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,217 @@
1
+ /**
2
+ * Sparse, chat-legible memory surface — hindsight Phase 4 (#2849).
3
+ *
4
+ * When the interactive `claude` session materially changes what it
5
+ * remembers during a turn — stores a new standing directive, or
6
+ * invalidates / demotes an existing memory — this module surfaces ONE
7
+ * terse line in the ORIGINATING Telegram chat/topic:
8
+ *
9
+ * 📌 <i>remembered:</i> "Always prefer TypeScript for this user"
10
+ * ✂️ <i>forgot:</i> superseded deploy runbook
11
+ *
12
+ * Why so sparse: the `remember-across-sessions` job spec lists
13
+ * "regurgitating old facts unprompted just to prove it remembered" as a
14
+ * top anti-pattern. A per-turn line would *become* that anti-pattern, so
15
+ * this surface fires ONLY on a genuine store/correct — never on ordinary
16
+ * recall, and never on routine consolidation. Detection is a
17
+ * deterministic tool-call observation (no model call, no polling): we
18
+ * watch the main-agent turn stream for the specific hindsight memory
19
+ * tools that represent a material change.
20
+ *
21
+ * Design notes / references:
22
+ * - RFC Phase 4: `reference/rfcs/hindsight-synthesis-layers.md`
23
+ * - Job spec: `reference/jobs/remember-across-sessions.md`
24
+ * - The `consolidation.completed` webhook the RFC names as the poll-free
25
+ * driver for the "updated what I know about Y" side does NOT exist in
26
+ * the pinned hindsight image (v0.8.4) — it is RFC-only. This v1 ships
27
+ * the tool-observation path; the webhook stays a follow-up.
28
+ *
29
+ * Reuse: `classifyMemoryToolCall` / `detectMemoryLegibilityEvent` are the
30
+ * single, reusable "did a directive-create / invalidate happen in this
31
+ * turn?" primitive on the TypeScript gateway side. Phase 3 Stage B (#2848)
32
+ * runs as a vendored Python hook (`recall.py`) and CANNOT import this TS
33
+ * module — it mirrors the same hindsight tool-name matching independently.
34
+ * Keep the two in sync by hand when the tool surface changes.
35
+ */
36
+
37
+ import { stripMarkdown, truncate } from './card-format.js'
38
+
39
+ /** ON by default; an operator opts out with SWITCHROOM_MEMORY_LEGIBILITY=0.
40
+ * Mirrors the SWITCHROOM_WORKER_ACTIVITY_FEED convention (only the literal
41
+ * string '0' disables; unset / anything else → enabled). */
42
+ export function isMemoryLegibilityEnabled(envVal: string | undefined): boolean {
43
+ return envVal !== '0'
44
+ }
45
+
46
+ /**
47
+ * The material memory operations we surface. Deliberately narrow — an
48
+ * ordinary `recall` / `reflect` / benign `update_memory` (a non-demote
49
+ * edit) is NOT material and returns null from the classifier.
50
+ */
51
+ export type MemoryToolClass = 'directive-create' | 'memory-invalidate'
52
+
53
+ /** The two hindsight tools that represent a material store / correct.
54
+ * Fully-qualified names as they appear in the turn stream (`ev.toolName`). */
55
+ export const CREATE_DIRECTIVE_TOOL = 'mcp__hindsight__create_directive'
56
+ export const INVALIDATE_MEMORY_TOOL = 'mcp__hindsight__invalidate_memory'
57
+ /** The demote path (`switchroom memory demote`) adds this client-side tag
58
+ * via the `update_memory` tool; that specific tagging is the "forgot"
59
+ * signal, whereas a plain `update_memory` edit is not material. */
60
+ export const UPDATE_MEMORY_TOOL = 'mcp__hindsight__update_memory'
61
+ export const DEMOTE_FROM_RECALL_MARKER = 'demote-from-recall'
62
+
63
+ export type MemoryLegibilityKind = 'remembered' | 'forgot'
64
+
65
+ export interface MemoryLegibilityEvent {
66
+ kind: MemoryLegibilityKind
67
+ /** Best-effort human detail (directive body / invalidate reason). May be
68
+ * empty when the tool carries no legible detail — the render then falls
69
+ * back to a bare "forgot a memory" line. Raw (unescaped, unstripped);
70
+ * `renderMemoryLegibilityLine` does the cleanup. */
71
+ detail: string
72
+ }
73
+
74
+ function asString(v: unknown): string {
75
+ return typeof v === 'string' ? v : ''
76
+ }
77
+
78
+ /** True when an `update_memory` call's tags carry the demote-from-recall
79
+ * marker — the only shape of `update_memory` that is a material correction.
80
+ * Bracket- and case-tolerant (`[demote-from-recall]` / `demote-from-recall`). */
81
+ function hasDemoteTag(input: Record<string, unknown> | undefined): boolean {
82
+ if (input == null) return false
83
+ const pools: unknown[] = []
84
+ for (const key of ['add_tags', 'tags']) {
85
+ const val = input[key]
86
+ if (Array.isArray(val)) pools.push(...val)
87
+ else if (typeof val === 'string') pools.push(val)
88
+ }
89
+ return pools.some(
90
+ (t) => typeof t === 'string' && t.toLowerCase().includes(DEMOTE_FROM_RECALL_MARKER),
91
+ )
92
+ }
93
+
94
+ /**
95
+ * Classify a single tool call as a material memory operation, or null if
96
+ * it isn't one. This is the reusable detection primitive: it answers "did a
97
+ * directive-create / invalidate happen in this turn?" with no rendering
98
+ * concern. #2848 Stage B (the vendored Python `recall.py` hook) mirrors this
99
+ * matching independently — it cannot import this TS module.
100
+ */
101
+ export function classifyMemoryToolCall(
102
+ toolName: string,
103
+ input: Record<string, unknown> | undefined,
104
+ ): MemoryToolClass | null {
105
+ if (toolName === CREATE_DIRECTIVE_TOOL) return 'directive-create'
106
+ if (toolName === INVALIDATE_MEMORY_TOOL) return 'memory-invalidate'
107
+ // `update_memory` is material ONLY when it applies the demote-from-recall
108
+ // tag; any other edit is routine and must not surface a line.
109
+ if (toolName === UPDATE_MEMORY_TOOL && hasDemoteTag(input)) return 'memory-invalidate'
110
+ return null
111
+ }
112
+
113
+ /**
114
+ * Detect a surfaceable memory-legibility event from one tool call, or null.
115
+ * Pure — no I/O. Extracts the best-available human detail from the tool
116
+ * input so the caller can render + route it.
117
+ */
118
+ export function detectMemoryLegibilityEvent(
119
+ toolName: string,
120
+ input: Record<string, unknown> | undefined,
121
+ ): MemoryLegibilityEvent | null {
122
+ const klass = classifyMemoryToolCall(toolName, input)
123
+ if (klass == null) return null
124
+ if (klass === 'directive-create') {
125
+ // The live server requires `content` (directive body); the profile
126
+ // guidance example writes `create_directive(text)`, so tolerate both,
127
+ // then fall back to the directive `name`.
128
+ const detail =
129
+ asString(input?.content).trim() ||
130
+ asString(input?.text).trim() ||
131
+ asString(input?.name).trim()
132
+ return { kind: 'remembered', detail }
133
+ }
134
+ // memory-invalidate: prefer a human `reason`; the opaque memory_id is not
135
+ // user-legible, so omit it and let the render use the bare fallback.
136
+ const detail = asString(input?.reason).trim()
137
+ return { kind: 'forgot', detail }
138
+ }
139
+
140
+ /**
141
+ * Confirm-before-legibility stager (Fix 1.3, #2903).
142
+ *
143
+ * A material memory op is detected on the `tool_use` event, but the write is
144
+ * not yet confirmed — the hindsight `tools/call` can still fail (engine down,
145
+ * `isError` envelope) and return an error `tool_result`. Sending the 📌/✂️ line
146
+ * on the tool_use would claim "remembered" for a write that then failed.
147
+ *
148
+ * This stager holds detected events keyed by `toolUseId`. `confirm` returns the
149
+ * event to send ONLY on a successful result; a failed result (`isError`) drops
150
+ * it and returns null. Pure and I/O-free so the send/no-send decision is unit-
151
+ * testable independent of the gateway's Telegram plumbing.
152
+ *
153
+ * `M` is caller-side routing metadata (chat/thread) carried opaquely.
154
+ */
155
+ export class MemoryLegibilityStager<M> {
156
+ private pending = new Map<string, { event: MemoryLegibilityEvent; meta: M }>()
157
+ /** Bound the map so a turn that never emits a matching tool_result (crash
158
+ * mid-tool) can't leak entries unboundedly. */
159
+ constructor(private readonly cap = 256) {}
160
+
161
+ /** Stage a detected event awaiting result confirmation. */
162
+ stage(toolUseId: string, event: MemoryLegibilityEvent, meta: M): void {
163
+ if (this.pending.size >= this.cap) {
164
+ const oldest = this.pending.keys().next().value
165
+ if (oldest != null) this.pending.delete(oldest)
166
+ }
167
+ this.pending.set(toolUseId, { event, meta })
168
+ }
169
+
170
+ /**
171
+ * Resolve a staged event on its matching tool_result. Returns the event +
172
+ * meta to send on CONFIRMED success; returns null when the write errored,
173
+ * when nothing was staged for this id, or when the id is empty. Always
174
+ * consumes the staged entry.
175
+ */
176
+ confirm(
177
+ toolUseId: string | null | undefined,
178
+ isError: boolean | undefined,
179
+ ): { event: MemoryLegibilityEvent; meta: M } | null {
180
+ if (toolUseId == null || toolUseId.length === 0) return null
181
+ const staged = this.pending.get(toolUseId)
182
+ if (staged == null) return null
183
+ this.pending.delete(toolUseId)
184
+ if (isError === true) return null
185
+ return staged
186
+ }
187
+
188
+ /** Test/introspection helper: number of currently-staged events. */
189
+ get size(): number {
190
+ return this.pending.size
191
+ }
192
+ }
193
+
194
+ /** HTML-escape for parse_mode:'HTML' (escape the 3 entity-significant chars). */
195
+ function escapeHtml(s: string): string {
196
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
197
+ }
198
+
199
+ /** Max chars of directive/reason detail shown on the one-liner. */
200
+ const DETAIL_MAX = 160
201
+
202
+ /**
203
+ * Render the terse one-line surface (Telegram HTML). Callers send this as a
204
+ * real `sendMessage` (not a draft/reaction) so it's observable + durable.
205
+ *
206
+ * 📌 <i>remembered:</i> "<directive, cleaned + truncated>"
207
+ * ✂️ <i>forgot:</i> <reason> (or "✂️ <i>forgot a memory.</i>")
208
+ */
209
+ export function renderMemoryLegibilityLine(ev: MemoryLegibilityEvent): string {
210
+ const clean = truncate(stripMarkdown(ev.detail).replace(/\s+/g, ' ').trim(), DETAIL_MAX)
211
+ if (ev.kind === 'remembered') {
212
+ if (clean.length === 0) return `📌 <i>remembered a new directive.</i>`
213
+ return `📌 <i>remembered:</i> "${escapeHtml(clean)}"`
214
+ }
215
+ if (clean.length === 0) return `✂️ <i>forgot a memory.</i>`
216
+ return `✂️ <i>forgot:</i> ${escapeHtml(clean)}`
217
+ }
@@ -0,0 +1 @@
1
+ {"version":"3.2.4","results":[[":tests/worker-activity-feed.test.ts",{"duration":604.6399409999995,"failed":true}]]}
@@ -33,8 +33,14 @@
33
33
  "@secretlint/types": "^12.2.0",
34
34
  "@xterm/headless": "^6.0.0",
35
35
  "grammy": "^1.44",
36
+ "mdast-util-from-markdown": "^2.0.2",
37
+ "mdast-util-gfm": "^3.0.0",
38
+ "micromark-extension-gfm": "^3.0.0",
36
39
  "posthog-node": "^5.29.2"
37
40
  },
41
+ "devDependencies": {
42
+ "@types/mdast": "^4.0.4"
43
+ },
38
44
  "engines": {
39
45
  "node": ">=20.11.0"
40
46
  },
@@ -97,9 +97,10 @@ export function emptyAccountState(): QuotaWatchAccountState {
97
97
  * SWITCHROOM_QUOTA_WATCH_FLEET_DEDUP "0" disables the broker claim
98
98
  * (every agent sends, pre-incident
99
99
  * behaviour)
100
- * SWITCHROOM_QUOTA_WATCH_SEND_ON_PROBE_FAIL "1" restores sending from
101
- * cached data when the pre-send
102
- * validation probe fails
100
+ *
101
+ * When the pre-send validation probe fails, the alert is unconditionally
102
+ * suppressed (a quota notification must never carry numbers we could not
103
+ * verify live); the transition re-evaluates on the next poll tick.
103
104
  */
104
105
  export interface QuotaWatchTuning {
105
106
  /** Cached snapshots older than this are treated as unknown (no opinion). 0 = off. */
@@ -108,8 +109,6 @@ export interface QuotaWatchTuning {
108
109
  lateRecoveryMs: number;
109
110
  /** Route sends through the broker's claim-notification dedup. */
110
111
  fleetDedup: boolean;
111
- /** Legacy: send from cached data when the validation probe fails. */
112
- sendOnProbeFail: boolean;
113
112
  }
114
113
 
115
114
  export const DEFAULT_QUOTA_WATCH_MAX_STALE_MS = 60 * 60_000;
@@ -133,7 +132,6 @@ export function resolveQuotaWatchTuning(
133
132
  maxStaleMs: num(env.SWITCHROOM_QUOTA_WATCH_MAX_STALE_MS, DEFAULT_QUOTA_WATCH_MAX_STALE_MS),
134
133
  lateRecoveryMs: num(env.SWITCHROOM_QUOTA_WATCH_LATE_RECOVERY_MS, DEFAULT_QUOTA_WATCH_LATE_RECOVERY_MS),
135
134
  fleetDedup: env.SWITCHROOM_QUOTA_WATCH_FLEET_DEDUP !== "0",
136
- sendOnProbeFail: env.SWITCHROOM_QUOTA_WATCH_SEND_ON_PROBE_FAIL === "1",
137
135
  };
138
136
  }
139
137
 
@@ -21,6 +21,7 @@ import {
21
21
  recordTurnEnd,
22
22
  findRecentTurnsForChat,
23
23
  getTurnByKey,
24
+ reapStaleOpenTurns,
24
25
  } from './turns-schema.js'
25
26
 
26
27
  // ---------------------------------------------------------------------------
@@ -157,3 +158,99 @@ describe('getTurnByKey', () => {
157
158
  db.close()
158
159
  })
159
160
  })
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // reapStaleOpenTurns — mid-session periodic orphan sweep (#2918)
164
+ // ---------------------------------------------------------------------------
165
+
166
+ describe('reapStaleOpenTurns (#2918 mid-session sweep)', () => {
167
+ // Helper: force a row's started_at into the past so the TTL gate is met.
168
+ function ageRow(db: ReturnType<typeof openTurnsDbInMemory>, turnKey: string, startedAt: number): void {
169
+ db.prepare('UPDATE turns SET started_at = ? WHERE turn_key = ?').run(startedAt, turnKey)
170
+ }
171
+
172
+ it('stamps an ownerless open row aged past the TTL as restart', () => {
173
+ const db = openTurnsDbInMemory()
174
+ const now = 1_000_000_000_000
175
+ recordTurnStart(db, { turnKey: 'dm:dead', chatId: '111' })
176
+ ageRow(db, 'dm:dead', now - 30 * 60_000) // 30 min old
177
+ const res = reapStaleOpenTurns(db, {
178
+ activeTurnKeys: new Set<string>(), // process gone → no live owner
179
+ ttlMs: 15 * 60_000,
180
+ now,
181
+ })
182
+ expect(res.reaped).toBe(1)
183
+ expect(res.reapedTurnKeys).toEqual(['dm:dead'])
184
+ const turn = getTurnByKey(db, 'dm:dead')
185
+ expect(turn?.ended_at).toBe(now)
186
+ expect(turn?.ended_via).toBe('restart')
187
+ db.close()
188
+ })
189
+
190
+ it('NEVER touches a healthy in-flight turn (turn_key in activeTurnKeys)', () => {
191
+ const db = openTurnsDbInMemory()
192
+ const now = 1_000_000_000_000
193
+ recordTurnStart(db, { turnKey: 'dm:live', chatId: '222' })
194
+ // Even though it is aged well past the TTL, a live owner protects it.
195
+ ageRow(db, 'dm:live', now - 6 * 60 * 60_000) // 6h "long-running" turn
196
+ const res = reapStaleOpenTurns(db, {
197
+ activeTurnKeys: new Set(['dm:live']),
198
+ ttlMs: 15 * 60_000,
199
+ now,
200
+ })
201
+ expect(res.reaped).toBe(0)
202
+ const turn = getTurnByKey(db, 'dm:live')
203
+ expect(turn?.ended_at).toBeNull()
204
+ expect(turn?.ended_via).toBeNull()
205
+ db.close()
206
+ })
207
+
208
+ it('does not reap an ownerless row younger than the TTL (race guard)', () => {
209
+ const db = openTurnsDbInMemory()
210
+ const now = 1_000_000_000_000
211
+ recordTurnStart(db, { turnKey: 'dm:fresh', chatId: '333' })
212
+ ageRow(db, 'dm:fresh', now - 60_000) // 1 min old, ownerless
213
+ const res = reapStaleOpenTurns(db, {
214
+ activeTurnKeys: new Set<string>(),
215
+ ttlMs: 15 * 60_000,
216
+ now,
217
+ })
218
+ expect(res.reaped).toBe(0)
219
+ expect(getTurnByKey(db, 'dm:fresh')?.ended_at).toBeNull()
220
+ db.close()
221
+ })
222
+
223
+ it('reaps the dead orphan while sparing a concurrently-live turn', () => {
224
+ const db = openTurnsDbInMemory()
225
+ const now = 1_000_000_000_000
226
+ recordTurnStart(db, { turnKey: 'dm:dead', chatId: '111' })
227
+ recordTurnStart(db, { turnKey: 'dm:live', chatId: '222' })
228
+ ageRow(db, 'dm:dead', now - 30 * 60_000)
229
+ ageRow(db, 'dm:live', now - 30 * 60_000)
230
+ const res = reapStaleOpenTurns(db, {
231
+ activeTurnKeys: new Set(['dm:live']),
232
+ ttlMs: 15 * 60_000,
233
+ now,
234
+ })
235
+ expect(res.reapedTurnKeys).toEqual(['dm:dead'])
236
+ expect(getTurnByKey(db, 'dm:dead')?.ended_via).toBe('restart')
237
+ expect(getTurnByKey(db, 'dm:live')?.ended_at).toBeNull()
238
+ db.close()
239
+ })
240
+
241
+ it('leaves already-ended rows alone (idempotent)', () => {
242
+ const db = openTurnsDbInMemory()
243
+ const now = 1_000_000_000_000
244
+ recordTurnStart(db, { turnKey: 'dm:done', chatId: '444' })
245
+ ageRow(db, 'dm:done', now - 30 * 60_000)
246
+ recordTurnEnd(db, { turnKey: 'dm:done', endedVia: 'stop' })
247
+ const res = reapStaleOpenTurns(db, {
248
+ activeTurnKeys: new Set<string>(),
249
+ ttlMs: 15 * 60_000,
250
+ now,
251
+ })
252
+ expect(res.reaped).toBe(0)
253
+ expect(getTurnByKey(db, 'dm:done')?.ended_via).toBe('stop')
254
+ db.close()
255
+ })
256
+ })
@@ -497,6 +497,84 @@ export function markOrphanedWithTimeoutClassification(
497
497
  return { reaped: (timeoutTurnKey ? 1 : 0) + rest.changes, timeoutTurnKey }
498
498
  }
499
499
 
500
+ export interface ReapStaleOpenTurnsOpts {
501
+ /**
502
+ * The set of turn_keys that belong to a turn still LIVE in this process's
503
+ * memory (the gateway's `currentTurnMap` registry keys plus the singleton
504
+ * `currentTurn` mirror). A row whose turn_key is in this set is NEVER
505
+ * reaped — it is a genuinely in-flight turn whose spinner must keep
506
+ * spinning, however long it runs. This is the load-bearing liveness
507
+ * predicate: age alone must never reap; only an open row with NO live
508
+ * owner qualifies.
509
+ */
510
+ activeTurnKeys: ReadonlySet<string>
511
+ /**
512
+ * Minimum age (ms, measured from `started_at`) before an ownerless open row
513
+ * is swept. A secondary guard against races — a turn that has JUST started
514
+ * but not yet populated the live set (or a row recorded microseconds ago) is
515
+ * protected until it ages past this. Liveness (activeTurnKeys) does the real
516
+ * work; the TTL only closes the "recorded-but-not-yet-tracked" window.
517
+ */
518
+ ttlMs: number
519
+ /** Injectable clock for tests. */
520
+ now?: number
521
+ }
522
+
523
+ export interface ReapStaleOpenTurnsResult {
524
+ /** Rows stamped `ended_via='restart'` by this sweep. */
525
+ reaped: number
526
+ /** The turn_keys that were stamped, for logging / card finalization. */
527
+ reapedTurnKeys: string[]
528
+ }
529
+
530
+ /**
531
+ * Mid-session periodic reaper (#2918). The boot-time
532
+ * `markOrphanedWithTimeoutClassification` only runs once, right after
533
+ * `openTurnsDb` — so a turn whose owning process dies MID-session (the SDK
534
+ * subprocess is SIGKILLed / OOMs / crashes) without a clean `recordTurnEnd`
535
+ * leaves its row `ended_at IS NULL`, and its activity card keeps spinning
536
+ * until the NEXT gateway boot (often many hours later). This sweep runs on a
537
+ * periodic timer inside the live gateway and stamps those ownerless open rows
538
+ * `ended_via='restart'` (the same clean-interrupt classification the boot
539
+ * reaper uses for a non-hung orphan) so the stale card can be finalized
540
+ * without waiting for a restart.
541
+ *
542
+ * CORRECTNESS: only rows that are BOTH (a) not owned by any live turn
543
+ * (`turn_key ∉ activeTurnKeys`) AND (b) older than `ttlMs` are swept. A
544
+ * healthy in-flight turn — however long it runs — is always in
545
+ * `activeTurnKeys` and is never touched. Never invents a new state; reuses
546
+ * `'restart'` so the existing resume/report policy applies unchanged.
547
+ */
548
+ export function reapStaleOpenTurns(
549
+ db: SqliteDatabase,
550
+ opts: ReapStaleOpenTurnsOpts,
551
+ ): ReapStaleOpenTurnsResult {
552
+ const now = opts.now ?? Date.now()
553
+ const cutoff = now - opts.ttlMs
554
+ // Candidate = open row aged past the TTL. Liveness is filtered in JS against
555
+ // the injected active set (avoids brittle SQL IN-list binding).
556
+ const candidates = db.prepare(`
557
+ SELECT turn_key FROM turns
558
+ WHERE ended_at IS NULL AND started_at <= ?
559
+ `).all(cutoff) as { turn_key: string }[]
560
+
561
+ const stamp = db.prepare(`
562
+ UPDATE turns
563
+ SET ended_at = ?,
564
+ ended_via = 'restart',
565
+ updated_at = ?
566
+ WHERE turn_key = ? AND ended_at IS NULL
567
+ `)
568
+
569
+ const reapedTurnKeys: string[] = []
570
+ for (const { turn_key } of candidates) {
571
+ if (opts.activeTurnKeys.has(turn_key)) continue // live — never reap
572
+ const r = stamp.run(now, now, turn_key) as { changes: number }
573
+ if (r.changes > 0) reapedTurnKeys.push(turn_key)
574
+ }
575
+ return { reaped: reapedTurnKeys.length, reapedTurnKeys }
576
+ }
577
+
500
578
  /**
501
579
  * Return the most recent N turns for `chatId` (any state — running or ended),
502
580
  * ordered by started_at DESC. Used by the idle-footer renderer to decide
@@ -0,0 +1,209 @@
1
+ // Typed intermediate representation (IR) for the Telegram HTML render engine.
2
+ //
3
+ // This is the parser <-> renderer contract. `parse()` (parse.ts) folds an
4
+ // mdast tree into this shape; a later increment's renderer walks it and emits
5
+ // Telegram Bot API HTML. Increment 1 lands ONLY the parser + this IR — there
6
+ // is no renderer yet.
7
+ //
8
+ // Every node carries `{ start, end }` UTF-16 source offsets copied verbatim
9
+ // from mdast `position.start.offset` / `position.end.offset`. They are UTF-16
10
+ // code-unit indices into the original markdown string, so
11
+ // `source.slice(node.start, node.end)` round-trips to the node's source text.
12
+ //
13
+ // Telegram HTML tag mapping (for the next increment — NOT implemented here):
14
+ //
15
+ // Inline
16
+ // plain -> (raw text, HTML-escaped)
17
+ // bold -> <b>…</b> (markdown `**…**`)
18
+ // italic -> <i>…</i> (markdown `*…*`)
19
+ // underline -> <u>…</u> (markdown `__…__`, Bot API 10.1)
20
+ // strike -> <s>…</s> (markdown `~~…~~`)
21
+ // spoiler -> <tg-spoiler>…</tg-spoiler> (markdown `||…||`)
22
+ // highlight -> <mark>…</mark> (markdown `==…==`, Bot API 10.1)
23
+ // code -> <code>…</code>
24
+ // link -> <a href="…">…</a>
25
+ //
26
+ // Block
27
+ // paragraph -> children joined; blocks separated by "\n\n"
28
+ // heading -> <b>…</b> (Telegram HTML has no <h1>…<h6>; bold + newlines)
29
+ // blockquote -> <blockquote>…</blockquote>
30
+ // (expandable === true -> <blockquote expandable>)
31
+ // code-block -> <pre><code class="language-…">…</code></pre>
32
+ // list -> rendered line-per-item with "•"/"1." bullets
33
+ // (Telegram HTML has no <ul>/<ol>)
34
+ // thematic-break -> a horizontal-rule text line (e.g. "───")
35
+ // table -> monospaced <pre> table (Telegram HTML has no <table>)
36
+
37
+ export interface Pos {
38
+ /** UTF-16 code-unit offset of the node's first char (mdast position.start.offset). */
39
+ start: number;
40
+ /** UTF-16 code-unit offset just past the node's last char (mdast position.end.offset). */
41
+ end: number;
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Inline nodes
46
+ // ---------------------------------------------------------------------------
47
+
48
+ export interface PlainNode extends Pos {
49
+ type: "plain";
50
+ text: string;
51
+ }
52
+
53
+ export interface BoldNode extends Pos {
54
+ type: "bold";
55
+ children: Inline[];
56
+ }
57
+
58
+ export interface ItalicNode extends Pos {
59
+ type: "italic";
60
+ children: Inline[];
61
+ }
62
+
63
+ /** Telegram underline (<u>…</u>). In Bot API 10.1 rich markdown the `__…__`
64
+ * double-underscore run is UNDERLINE — distinct from `**…**` bold, even though
65
+ * GFM/micromark folds both into a single `strong` mdast node. `parse.ts`
66
+ * disambiguates the two by looking at the run's source delimiter. */
67
+ export interface UnderlineNode extends Pos {
68
+ type: "underline";
69
+ children: Inline[];
70
+ }
71
+
72
+ export interface StrikeNode extends Pos {
73
+ type: "strike";
74
+ children: Inline[];
75
+ }
76
+
77
+ /** Telegram spoiler (<tg-spoiler>…</tg-spoiler>), markdown `||…||`. GFM has no
78
+ * spoiler syntax, so `parse.ts` recognises the `||…||` delimiter in a
79
+ * post-parse pass over `plain` text. */
80
+ export interface SpoilerNode extends Pos {
81
+ type: "spoiler";
82
+ children: Inline[];
83
+ }
84
+
85
+ /** Telegram highlight / marked text (<mark>…</mark>), markdown `==…==` (Bot API
86
+ * 10.1). Like spoiler, recognised by `parse.ts` in a post-parse pass over
87
+ * `plain` text (GFM has no highlight syntax). */
88
+ export interface HighlightNode extends Pos {
89
+ type: "highlight";
90
+ children: Inline[];
91
+ }
92
+
93
+ export interface CodeNode extends Pos {
94
+ type: "code";
95
+ text: string;
96
+ }
97
+
98
+ export interface LinkNode extends Pos {
99
+ type: "link";
100
+ href: string;
101
+ children: Inline[];
102
+ }
103
+
104
+ export type Inline =
105
+ | PlainNode
106
+ | BoldNode
107
+ | ItalicNode
108
+ | UnderlineNode
109
+ | StrikeNode
110
+ | SpoilerNode
111
+ | HighlightNode
112
+ | CodeNode
113
+ | LinkNode;
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Block nodes
117
+ // ---------------------------------------------------------------------------
118
+
119
+ export interface ParagraphNode extends Pos {
120
+ type: "paragraph";
121
+ children: Inline[];
122
+ }
123
+
124
+ export interface HeadingNode extends Pos {
125
+ type: "heading";
126
+ /** 1..6 */
127
+ level: number;
128
+ children: Inline[];
129
+ }
130
+
131
+ export interface BlockquoteNode extends Pos {
132
+ type: "blockquote";
133
+ children: Block[];
134
+ /** Telegram <blockquote expandable>. Always false in Increment 1 — see parse.ts. */
135
+ expandable: boolean;
136
+ }
137
+
138
+ export interface CodeBlockNode extends Pos {
139
+ type: "code-block";
140
+ text: string;
141
+ language: string | null;
142
+ }
143
+
144
+ export interface ListNode extends Pos {
145
+ type: "list";
146
+ ordered: boolean;
147
+ // NOTE: the spec names this ordinal `start`, but every node already carries
148
+ // `start`/`end` UTF-16 offsets (load-bearing for round-trip slicing). To
149
+ // avoid the collision the ordered-list ordinal is `startNumber` here; its
150
+ // semantics match the spec's `list.start` exactly (mdast `list.start`).
151
+ /** First number of an ordered list (mdast `start`); null for unordered. */
152
+ startNumber: number | null;
153
+ /** Loose vs tight (mdast `list.spread`). A LOOSE list separates its items
154
+ * with a blank line in the source; a TIGHT list keeps them on adjacent
155
+ * lines. The renderer preserves this: loose lists join items with a blank
156
+ * line, tight lists (including nested sub-lists) stay on single newlines so
157
+ * no spurious blank line is injected between a tight item and its sub-list. */
158
+ spread: boolean;
159
+ items: ListItem[];
160
+ }
161
+
162
+ export interface ThematicBreakNode extends Pos {
163
+ type: "thematic-break";
164
+ }
165
+
166
+ export interface TableNode extends Pos {
167
+ type: "table";
168
+ header: TableRow;
169
+ rows: TableRow[];
170
+ /** Per-column alignment, parallel to the cells. */
171
+ align: ("left" | "center" | "right" | null)[];
172
+ }
173
+
174
+ export type Block =
175
+ | ParagraphNode
176
+ | HeadingNode
177
+ | BlockquoteNode
178
+ | CodeBlockNode
179
+ | ListNode
180
+ | ThematicBreakNode
181
+ | TableNode;
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // Composite / container shapes
185
+ // ---------------------------------------------------------------------------
186
+
187
+ export interface ListItem extends Pos {
188
+ children: Block[];
189
+ /** GFM task-list state: true (checked), false (unchecked), null (not a task item). */
190
+ checked: boolean | null;
191
+ /** Loose vs tight at the ITEM level (mdast `listItem.spread`): whether this
192
+ * item's own block children are separated by a blank line in the source.
193
+ * A tight item (spread=false) — e.g. a paragraph followed by a nested
194
+ * sub-list — keeps its children on single newlines, so no blank line is
195
+ * injected before the sub-list. */
196
+ spread: boolean;
197
+ }
198
+
199
+ export interface TableRow extends Pos {
200
+ cells: TableCell[];
201
+ }
202
+
203
+ export interface TableCell extends Pos {
204
+ children: Inline[];
205
+ }
206
+
207
+ export interface Document {
208
+ blocks: Block[];
209
+ }