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,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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
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
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
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
|
+
}
|