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.
Files changed (197) hide show
  1. package/bin/workspace-dynamic-hook.sh +12 -13
  2. package/dist/agent-scheduler/index.js +65 -5
  3. package/dist/auth-broker/index.js +6623 -514
  4. package/dist/cli/notion-write-pretool.mjs +64 -4
  5. package/dist/cli/switchroom.js +1888 -1162
  6. package/dist/host-control/main.js +6306 -162
  7. package/dist/vault/approvals/kernel-server.js +6014 -202
  8. package/dist/vault/broker/server.js +6741 -940
  9. package/package.json +1 -1
  10. package/profiles/_base/settings.json.hbs +2 -2
  11. package/profiles/_base/start.sh.hbs +218 -25
  12. package/profiles/coding/CLAUDE.md.hbs +1 -1
  13. package/profiles/default/CLAUDE.md +116 -0
  14. package/profiles/default/CLAUDE.md.hbs +2 -2
  15. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  16. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  17. package/skills/mental-model-curator/SKILL.md +162 -0
  18. package/telegram-plugin/auth-snapshot-format.ts +22 -24
  19. package/telegram-plugin/bridge/bridge.ts +80 -1
  20. package/telegram-plugin/bridge/ipc-client.ts +19 -0
  21. package/telegram-plugin/bridge/permission-ledger.ts +61 -0
  22. package/telegram-plugin/consolidation-legibility.ts +279 -0
  23. package/telegram-plugin/context-exhaustion.ts +124 -0
  24. package/telegram-plugin/dist/bridge/bridge.js +85 -1
  25. package/telegram-plugin/dist/gateway/gateway.js +25802 -8488
  26. package/telegram-plugin/dist/server.js +86 -2
  27. package/telegram-plugin/feed-heartbeat-climb.ts +206 -0
  28. package/telegram-plugin/gateway/activity-card-store.ts +369 -0
  29. package/telegram-plugin/gateway/gateway.ts +1861 -172
  30. package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
  31. package/telegram-plugin/gateway/inbound-spool.ts +22 -0
  32. package/telegram-plugin/gateway/mental-model-propose-card.ts +69 -0
  33. package/telegram-plugin/gateway/mental-model-propose-diff.ts +171 -0
  34. package/telegram-plugin/gateway/mental-model-propose-inbound-builders.ts +147 -0
  35. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +201 -0
  36. package/telegram-plugin/gateway/missed-approvals-card.ts +161 -0
  37. package/telegram-plugin/gateway/missed-approvals-store.ts +167 -0
  38. package/telegram-plugin/gateway/model-command.ts +70 -10
  39. package/telegram-plugin/gateway/permission-rearm.ts +115 -0
  40. package/telegram-plugin/gateway/scoped-grant-store.ts +89 -0
  41. package/telegram-plugin/memory-legibility.ts +217 -0
  42. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  43. package/telegram-plugin/package.json +6 -0
  44. package/telegram-plugin/quota-watch.ts +4 -6
  45. package/telegram-plugin/registry/turns-schema.test.ts +97 -0
  46. package/telegram-plugin/registry/turns-schema.ts +78 -0
  47. package/telegram-plugin/render/ir.ts +209 -0
  48. package/telegram-plugin/render/parse.ts +363 -0
  49. package/telegram-plugin/render/render.ts +440 -0
  50. package/telegram-plugin/render/rich-render.ts +72 -0
  51. package/telegram-plugin/scoped-approval.ts +59 -0
  52. package/telegram-plugin/silent-end.ts +78 -0
  53. package/telegram-plugin/stream-controller.ts +14 -3
  54. package/telegram-plugin/subagent-watcher.ts +60 -6
  55. package/telegram-plugin/tests/activity-card-store.test.ts +530 -0
  56. package/telegram-plugin/tests/activity-card-wiring.test.ts +88 -0
  57. package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
  58. package/telegram-plugin/tests/auth-snapshot-format.test.ts +30 -16
  59. package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -0
  60. package/telegram-plugin/tests/consolidation-legibility.test.ts +224 -0
  61. package/telegram-plugin/tests/emission-authority-facade.test.ts +25 -10
  62. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +44 -9
  63. package/telegram-plugin/tests/feed-survival.test.ts +39 -0
  64. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  65. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +81 -0
  66. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +82 -0
  67. package/telegram-plugin/tests/inbound-spool.test.ts +105 -0
  68. package/telegram-plugin/tests/liveness-tracker.test.ts +228 -0
  69. package/telegram-plugin/tests/memory-legibility.test.ts +216 -0
  70. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +67 -0
  71. package/telegram-plugin/tests/mental-model-propose-card.test.ts +56 -0
  72. package/telegram-plugin/tests/mental-model-propose-diff.test.ts +201 -0
  73. package/telegram-plugin/tests/mental-model-propose-inbound-builders.test.ts +68 -0
  74. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +157 -0
  75. package/telegram-plugin/tests/missed-approvals-card.test.ts +145 -0
  76. package/telegram-plugin/tests/missed-approvals-store.test.ts +147 -0
  77. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +89 -0
  78. package/telegram-plugin/tests/model-command.test.ts +193 -16
  79. package/telegram-plugin/tests/narrative-render.test.ts +125 -0
  80. package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +123 -163
  81. package/telegram-plugin/tests/permission-ledger.test.ts +166 -0
  82. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +1 -1
  83. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +175 -0
  84. package/telegram-plugin/tests/permission-rearm.test.ts +126 -0
  85. package/telegram-plugin/tests/quota-watch.test.ts +1 -4
  86. package/telegram-plugin/tests/rapid-fire-delivery-ordering.test.ts +149 -0
  87. package/telegram-plugin/tests/render/parse-torture.test.ts +136 -0
  88. package/telegram-plugin/tests/render/parse.test.ts +393 -0
  89. package/telegram-plugin/tests/render/render.test.ts +436 -0
  90. package/telegram-plugin/tests/render/rich-render.test.ts +85 -0
  91. package/telegram-plugin/tests/scoped-grant-persist.test.ts +223 -0
  92. package/telegram-plugin/tests/silent-end-transport.test.ts +290 -0
  93. package/telegram-plugin/tests/silent-turn-climb-transport.test.ts +337 -0
  94. package/telegram-plugin/tests/subagent-watcher.test.ts +139 -0
  95. package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +155 -1
  96. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +198 -0
  97. package/telegram-plugin/turn-liveness-floor.ts +35 -1
  98. package/telegram-plugin/uat/assertions.ts +88 -4
  99. package/telegram-plugin/uat/feed-matcher.test.ts +69 -0
  100. package/telegram-plugin/uat/scenarios/fuzz-liveness-climb-dm.test.ts +155 -0
  101. package/telegram-plugin/uat/scenarios/jtbd-directive-capture-nudge-dm.test.ts +185 -0
  102. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-channel.test.ts +192 -0
  103. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-dm.test.ts +220 -0
  104. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +137 -0
  105. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +148 -0
  106. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-channel.test.ts +66 -0
  107. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-dm.test.ts +61 -0
  108. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
  109. package/telegram-plugin/uat/scenarios/silent-end-recovery-channel.test.ts +136 -0
  110. package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +24 -2
  111. package/telegram-plugin/worktree-watch-cwds.ts +135 -0
  112. package/vendor/hindsight-memory/hooks/hooks.json +9 -0
  113. package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
  114. package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
  115. package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
  116. package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
  117. package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
  118. package/vendor/hindsight-memory/scripts/directive_verify.py +445 -0
  119. package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  120. package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
  121. package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
  122. package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
  123. package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
  124. package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
  125. package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
  126. package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
  127. package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
  128. package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
  129. package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
  130. package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
  131. package/vendor/hindsight-memory/scripts/lib/client.py +11 -1
  132. package/vendor/hindsight-memory/scripts/lib/config.py +46 -2
  133. package/vendor/hindsight-memory/scripts/lib/directives.py +88 -0
  134. package/vendor/hindsight-memory/scripts/lib/switchroom_envelope.py +77 -0
  135. package/vendor/hindsight-memory/scripts/recall.py +217 -10
  136. package/vendor/hindsight-memory/scripts/retain.py +17 -0
  137. package/vendor/hindsight-memory/scripts/setup_hooks.py +9 -0
  138. package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  139. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
  140. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
  141. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
  142. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
  143. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
  144. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
  145. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
  146. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
  147. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
  148. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
  149. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
  150. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
  151. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
  152. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
  153. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
  154. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
  155. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
  156. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
  157. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
  158. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
  159. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
  160. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
  161. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
  162. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
  163. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
  164. package/vendor/hindsight-memory/scripts/tests/test_directive_capture_nudge.py +185 -0
  165. package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +516 -0
  166. package/vendor/hindsight-memory/scripts/tests/test_directives.py +49 -0
  167. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
  168. package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +66 -1
  169. package/vendor/hindsight-memory/scripts/tests/test_switchroom_envelope.py +69 -0
  170. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  171. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  172. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
  173. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
  174. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
  175. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
  176. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
  177. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
  178. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
  179. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
  180. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
  181. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  182. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
  183. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
  184. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
  185. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
  186. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
  187. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  188. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
  189. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
  190. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
  191. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  192. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
  193. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
  194. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
  195. package/vendor/hindsight-memory/tests/test_client.py +43 -0
  196. package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +49 -2
  197. package/vendor/hindsight-memory/tests/test_recall_precision.py +114 -0
@@ -0,0 +1,393 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { parse } from "../../render/parse.js";
3
+ import type { Block, Inline } from "../../render/ir.js";
4
+
5
+ // Assert every node in a subtree round-trips: source.slice(start, end) is the
6
+ // exact source text the node spans. Walks both inline and block children.
7
+ function assertOffsetsRoundTrip(node: any, source: string) {
8
+ expect(typeof node.start).toBe("number");
9
+ expect(typeof node.end).toBe("number");
10
+ // The slice must be non-empty for real nodes and lie within bounds.
11
+ expect(node.start).toBeGreaterThanOrEqual(0);
12
+ expect(node.end).toBeLessThanOrEqual(source.length);
13
+ expect(node.end).toBeGreaterThanOrEqual(node.start);
14
+ if (Array.isArray(node.children)) {
15
+ for (const c of node.children) assertOffsetsRoundTrip(c, source);
16
+ }
17
+ if (Array.isArray(node.items)) {
18
+ for (const it of node.items) assertOffsetsRoundTrip(it, source);
19
+ }
20
+ if (Array.isArray(node.cells)) {
21
+ for (const c of node.cells) assertOffsetsRoundTrip(c, source);
22
+ }
23
+ }
24
+
25
+ describe("parse: inline palette", () => {
26
+ const cases: Array<{
27
+ name: string;
28
+ md: string;
29
+ // The inline construct's source text (what slice(start,end) must equal).
30
+ fragment: string;
31
+ check: (inline: Inline) => void;
32
+ }> = [
33
+ {
34
+ name: "bold",
35
+ md: "**hi**",
36
+ fragment: "**hi**",
37
+ check: (n) => {
38
+ expect(n.type).toBe("bold");
39
+ expect((n as any).children[0]).toMatchObject({ type: "plain", text: "hi" });
40
+ },
41
+ },
42
+ {
43
+ name: "italic",
44
+ md: "*hi*",
45
+ fragment: "*hi*",
46
+ check: (n) => {
47
+ expect(n.type).toBe("italic");
48
+ expect((n as any).children[0]).toMatchObject({ type: "plain", text: "hi" });
49
+ },
50
+ },
51
+ {
52
+ name: "strike",
53
+ md: "~~hi~~",
54
+ fragment: "~~hi~~",
55
+ check: (n) => {
56
+ expect(n.type).toBe("strike");
57
+ expect((n as any).children[0]).toMatchObject({ type: "plain", text: "hi" });
58
+ },
59
+ },
60
+ {
61
+ name: "inline code",
62
+ md: "`x = 1`",
63
+ fragment: "`x = 1`",
64
+ check: (n) => {
65
+ expect(n).toMatchObject({ type: "code", text: "x = 1" });
66
+ },
67
+ },
68
+ {
69
+ name: "link",
70
+ md: "[label](https://example.com)",
71
+ fragment: "[label](https://example.com)",
72
+ check: (n) => {
73
+ expect(n.type).toBe("link");
74
+ expect((n as any).href).toBe("https://example.com");
75
+ expect((n as any).children[0]).toMatchObject({ type: "plain", text: "label" });
76
+ },
77
+ },
78
+ ];
79
+
80
+ for (const c of cases) {
81
+ it(`parses ${c.name} and offsets round-trip`, () => {
82
+ const doc = parse(c.md);
83
+ expect(doc.blocks).toHaveLength(1);
84
+ const para = doc.blocks[0];
85
+ expect(para.type).toBe("paragraph");
86
+ const inline = (para as any).children[0] as Inline;
87
+ c.check(inline);
88
+ expect(c.md.slice(inline.start, inline.end)).toBe(c.fragment);
89
+ assertOffsetsRoundTrip(para, c.md);
90
+ });
91
+ }
92
+ });
93
+
94
+ describe("parse: heading levels", () => {
95
+ for (let level = 1; level <= 6; level++) {
96
+ it(`parses an h${level} heading`, () => {
97
+ const md = `${"#".repeat(level)} Title`;
98
+ const doc = parse(md);
99
+ expect(doc.blocks).toHaveLength(1);
100
+ const h = doc.blocks[0] as any;
101
+ expect(h.type).toBe("heading");
102
+ expect(h.level).toBe(level);
103
+ expect(h.children[0]).toMatchObject({ type: "plain", text: "Title" });
104
+ expect(md.slice(h.start, h.end)).toBe(md);
105
+ });
106
+ }
107
+ });
108
+
109
+ describe("parse: fenced code with language", () => {
110
+ it("captures text and language", () => {
111
+ const md = "```ts\nconst x = 1;\n```";
112
+ const doc = parse(md);
113
+ const cb = doc.blocks[0] as any;
114
+ expect(cb.type).toBe("code-block");
115
+ expect(cb.language).toBe("ts");
116
+ expect(cb.text).toBe("const x = 1;");
117
+ expect(md.slice(cb.start, cb.end)).toBe(md);
118
+ });
119
+
120
+ it("null language for a bare fence", () => {
121
+ const md = "```\nplain\n```";
122
+ const doc = parse(md);
123
+ const cb = doc.blocks[0] as any;
124
+ expect(cb.type).toBe("code-block");
125
+ expect(cb.language).toBeNull();
126
+ });
127
+ });
128
+
129
+ describe("parse: blockquote", () => {
130
+ it("parses a blockquote with expandable=false", () => {
131
+ const md = "> quoted text";
132
+ const doc = parse(md);
133
+ const bq = doc.blocks[0] as any;
134
+ expect(bq.type).toBe("blockquote");
135
+ expect(bq.expandable).toBe(false);
136
+ expect(bq.children[0].type).toBe("paragraph");
137
+ expect(md.slice(bq.start, bq.end)).toBe(md);
138
+ });
139
+ });
140
+
141
+ describe("parse: lists", () => {
142
+ it("parses an unordered list (start=null)", () => {
143
+ const md = "- one\n- two";
144
+ const doc = parse(md);
145
+ const list = doc.blocks[0] as any;
146
+ expect(list.type).toBe("list");
147
+ expect(list.ordered).toBe(false);
148
+ expect(list.startNumber).toBeNull();
149
+ expect(list.items).toHaveLength(2);
150
+ expect(md.slice(list.start, list.end)).toBe(md);
151
+ assertOffsetsRoundTrip(list, md);
152
+ });
153
+
154
+ it("parses an ordered list with a start number", () => {
155
+ const md = "3. three\n4. four";
156
+ const doc = parse(md);
157
+ const list = doc.blocks[0] as any;
158
+ expect(list.type).toBe("list");
159
+ expect(list.ordered).toBe(true);
160
+ expect(list.startNumber).toBe(3);
161
+ expect(list.items).toHaveLength(2);
162
+ });
163
+
164
+ it("parses task-list checked/unchecked state", () => {
165
+ const md = "- [x] done\n- [ ] todo";
166
+ const doc = parse(md);
167
+ const list = doc.blocks[0] as any;
168
+ expect(list.items[0].checked).toBe(true);
169
+ expect(list.items[1].checked).toBe(false);
170
+ });
171
+
172
+ it("plain (non-task) list items have checked=null", () => {
173
+ const md = "- a\n- b";
174
+ const doc = parse(md);
175
+ const list = doc.blocks[0] as any;
176
+ expect(list.items[0].checked).toBeNull();
177
+ expect(list.items[1].checked).toBeNull();
178
+ });
179
+ });
180
+
181
+ describe("parse: GFM table with mixed alignment", () => {
182
+ it("captures header, rows, and per-column alignment", () => {
183
+ const md = ["| L | C | R |", "|:--|:-:|--:|", "| 1 | 2 | 3 |"].join("\n");
184
+ const doc = parse(md);
185
+ const table = doc.blocks[0] as any;
186
+ expect(table.type).toBe("table");
187
+ expect(table.align).toEqual(["left", "center", "right"]);
188
+ expect(table.header.cells).toHaveLength(3);
189
+ expect(table.rows).toHaveLength(1);
190
+ expect(table.rows[0].cells[0].children[0]).toMatchObject({ type: "plain", text: "1" });
191
+ expect(md.slice(table.start, table.end)).toBe(md);
192
+ assertOffsetsRoundTrip(table, md);
193
+ });
194
+ });
195
+
196
+ describe("parse: thematic break", () => {
197
+ it("parses a horizontal rule", () => {
198
+ const md = "---";
199
+ const doc = parse(md);
200
+ const tb = doc.blocks[0] as any;
201
+ expect(tb.type).toBe("thematic-break");
202
+ expect(md.slice(tb.start, tb.end)).toBe(md);
203
+ });
204
+ });
205
+
206
+ describe("parse: nested inline", () => {
207
+ it("bold containing italic, offsets round-trip at both levels", () => {
208
+ const md = "**bold *and italic* here**";
209
+ const doc = parse(md);
210
+ const bold = (doc.blocks[0] as any).children[0];
211
+ expect(bold.type).toBe("bold");
212
+ expect(md.slice(bold.start, bold.end)).toBe("**bold *and italic* here**");
213
+ const italic = bold.children.find((c: any) => c.type === "italic");
214
+ expect(italic).toBeDefined();
215
+ expect(md.slice(italic.start, italic.end)).toBe("*and italic*");
216
+ assertOffsetsRoundTrip(doc.blocks[0] as Block, md);
217
+ });
218
+ });
219
+
220
+ describe("parse: unsupported construct fallback", () => {
221
+ it("degrades a raw HTML block to plain text without dropping content", () => {
222
+ // Raw HTML blocks (mdast `html`) are outside the palette; they must
223
+ // survive as plain source text.
224
+ const md = "<div class=\"x\">raw</div>";
225
+ const doc = parse(md);
226
+ const block = doc.blocks[0] as any;
227
+ expect(block.type).toBe("paragraph");
228
+ const plain = block.children[0];
229
+ expect(plain.type).toBe("plain");
230
+ expect(plain.text).toBe(md);
231
+ expect(md.slice(plain.start, plain.end)).toBe(md);
232
+ });
233
+ });
234
+
235
+ describe("parse: astral-plane emoji offsets are UTF-16 units", () => {
236
+ it("uses UTF-16 code-unit offsets, not codepoints or bytes", () => {
237
+ // U+1F680 ROCKET is one codepoint but TWO UTF-16 code units (a surrogate
238
+ // pair) and FOUR UTF-8 bytes. The bold node after it must start at the
239
+ // UTF-16 offset.
240
+ const emoji = "\u{1F680}"; // 🚀, length 2 in UTF-16
241
+ expect(emoji.length).toBe(2);
242
+ const md = `${emoji} **go**`;
243
+ const doc = parse(md);
244
+ const para = doc.blocks[0] as any;
245
+ const bold = para.children.find((c: any) => c.type === "bold");
246
+ expect(bold).toBeDefined();
247
+ // "🚀 " is 3 UTF-16 code units (2 + space), so bold starts at offset 3.
248
+ expect(bold.start).toBe(3);
249
+ expect(md.slice(bold.start, bold.end)).toBe("**go**");
250
+ // Sanity: byte-based or codepoint-based offsets would give 2 or a
251
+ // different index, not 3.
252
+ expect(md.slice(bold.start, bold.end)).not.toBe("*go**");
253
+ });
254
+ });
255
+
256
+ describe("parse: underline vs bold (`__` vs `**`)", () => {
257
+ it("folds a `__…__` run into an underline node (not bold)", () => {
258
+ const md = "__underlined__";
259
+ const doc = parse(md);
260
+ const u = (doc.blocks[0] as any).children[0];
261
+ expect(u.type).toBe("underline");
262
+ expect(u.children[0]).toMatchObject({ type: "plain", text: "underlined" });
263
+ expect(md.slice(u.start, u.end)).toBe(md);
264
+ });
265
+
266
+ it("keeps a `**…**` run as bold", () => {
267
+ const b = (parse("**strong**").blocks[0] as any).children[0];
268
+ expect(b.type).toBe("bold");
269
+ });
270
+
271
+ it("keeps single-delimiter `_…_` as italic", () => {
272
+ const i = (parse("_em_").blocks[0] as any).children[0];
273
+ expect(i.type).toBe("italic");
274
+ });
275
+
276
+ it("recovers nested bold inside underline with offsets intact", () => {
277
+ const md = "__a **b** c__";
278
+ const doc = parse(md);
279
+ const u = (doc.blocks[0] as any).children[0];
280
+ expect(u.type).toBe("underline");
281
+ expect(md.slice(u.start, u.end)).toBe(md);
282
+ const bold = u.children.find((c: any) => c.type === "bold");
283
+ expect(bold).toBeDefined();
284
+ expect(md.slice(bold.start, bold.end)).toBe("**b**");
285
+ assertOffsetsRoundTrip(doc.blocks[0], md);
286
+ });
287
+ });
288
+
289
+ describe("parse: spoiler (`||…||`) + highlight (`==…==`)", () => {
290
+ it("splits `||secret||` into a spoiler node", () => {
291
+ const md = "before ||secret|| after";
292
+ const doc = parse(md);
293
+ const kids = (doc.blocks[0] as any).children;
294
+ expect(kids.map((k: any) => k.type)).toEqual(["plain", "spoiler", "plain"]);
295
+ const sp = kids[1];
296
+ expect(sp.children[0]).toMatchObject({ type: "plain", text: "secret" });
297
+ expect(md.slice(sp.start, sp.end)).toBe("||secret||");
298
+ assertOffsetsRoundTrip(doc.blocks[0], md);
299
+ });
300
+
301
+ it("splits `==marked==` into a highlight node", () => {
302
+ const md = "a ==marked== b";
303
+ const doc = parse(md);
304
+ const kids = (doc.blocks[0] as any).children;
305
+ const hi = kids.find((k: any) => k.type === "highlight");
306
+ expect(hi).toBeDefined();
307
+ expect(hi.children[0]).toMatchObject({ type: "plain", text: "marked" });
308
+ expect(md.slice(hi.start, hi.end)).toBe("==marked==");
309
+ });
310
+
311
+ it("recognises both delimiters in one run", () => {
312
+ const md = "||s|| and ==m==";
313
+ const types = (parse(md).blocks[0] as any).children.map((c: any) => c.type);
314
+ expect(types).toContain("spoiler");
315
+ expect(types).toContain("highlight");
316
+ });
317
+
318
+ it("leaves an unclosed / single delimiter as literal plain text", () => {
319
+ // `a || b` has no closing `||` — must NOT become a spoiler.
320
+ const kids = (parse("a || b").blocks[0] as any).children;
321
+ expect(kids.every((k: any) => k.type === "plain")).toBe(true);
322
+ });
323
+ });
324
+
325
+ describe("parse: nested lists", () => {
326
+ it("folds a nested sub-list under a list item", () => {
327
+ const md = "- a\n - nested1\n - nested2\n- b";
328
+ const doc = parse(md);
329
+ const list = doc.blocks[0] as any;
330
+ expect(list.type).toBe("list");
331
+ const item0Kinds = list.items[0].children.map((c: any) => c.type);
332
+ expect(item0Kinds).toContain("list");
333
+ const nested = list.items[0].children.find((c: any) => c.type === "list");
334
+ expect(nested.items).toHaveLength(2);
335
+ assertOffsetsRoundTrip(list, md);
336
+ });
337
+ });
338
+
339
+ describe("expandable blockquote (Bot API 10.1 `**>` marker)", () => {
340
+ it("parses a single-line `**>` quote into an expandable blockquote", () => {
341
+ const md = "**> a collapsible line";
342
+ const doc = parse(md);
343
+ const bq = doc.blocks[0] as any;
344
+ expect(bq.type).toBe("blockquote");
345
+ expect(bq.expandable).toBe(true);
346
+ });
347
+
348
+ it("marks a multi-line quote expandable when the FIRST line carries `**>`", () => {
349
+ const md = "**> line one\n> line two\n> line three";
350
+ const doc = parse(md);
351
+ const bq = doc.blocks[0] as any;
352
+ expect(bq.type).toBe("blockquote");
353
+ expect(bq.expandable).toBe(true);
354
+ // All three quoted lines fold into ONE blockquote.
355
+ expect(bq.children.length).toBeGreaterThanOrEqual(1);
356
+ });
357
+
358
+ it("leaves a plain `>` quote non-expandable", () => {
359
+ const doc = parse("> just an ordinary quote");
360
+ const bq = doc.blocks[0] as any;
361
+ expect(bq.type).toBe("blockquote");
362
+ expect(bq.expandable).toBe(false);
363
+ });
364
+
365
+ it("keeps content offsets byte-identical to the ORIGINAL source", () => {
366
+ const md = "**> quoted body here";
367
+ const doc = parse(md);
368
+ const bq = doc.blocks[0] as any;
369
+ // The marker rewrite (`**>` -> ` >`) is length-preserving, so the inner
370
+ // text still slices out of the ORIGINAL markdown exactly.
371
+ const plain = bq.children[0].children[0];
372
+ expect(md.slice(plain.start, plain.end)).toBe("quoted body here");
373
+ assertOffsetsRoundTrip(bq, md);
374
+ });
375
+
376
+ it("only recognises the marker at column 0 (leading indent is not expandable)", () => {
377
+ // The renderer only ever emits `**>` at column 0; a leading-indented
378
+ // variant is deliberately NOT treated as an expandable quote (matching it
379
+ // would push the length-preserving rewrite past the 3-space blockquote
380
+ // budget into indented-code-block territory).
381
+ const md = " **> indented";
382
+ const doc = parse(md);
383
+ const bq = doc.blocks[0] as any;
384
+ expect(bq.expandable).not.toBe(true);
385
+ });
386
+
387
+ it("does not treat inline `**` bold followed by `>` mid-line as expandable", () => {
388
+ // `**bold** > text` is a paragraph, not a quote — the marker must be at
389
+ // line start.
390
+ const doc = parse("**bold** > not a quote");
391
+ expect(doc.blocks[0].type).toBe("paragraph");
392
+ });
393
+ });