switchroom 0.16.29 → 0.16.46
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/dist/agent-scheduler/index.js +101 -84
- package/dist/auth-broker/index.js +100 -83
- package/dist/cli/autoaccept-poll.js +8 -8
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/notion-write-pretool.mjs +102 -85
- package/dist/cli/skill-validate-pretool.mjs +91 -91
- package/dist/cli/switchroom.js +62124 -60406
- package/dist/cli/ui/index.html +877 -214
- package/dist/host-control/main.js +1000 -256
- package/dist/vault/approvals/kernel-server.js +141 -121
- package/dist/vault/broker/server.js +163 -143
- package/examples/minimal.yaml +1 -1
- package/examples/switchroom.yaml +1 -1
- package/package.json +3 -2
- package/profiles/_shared/agent-self-service.md.hbs +7 -2
- package/profiles/_shared/reply-discipline.md.hbs +9 -0
- package/skills/switchroom-status/SKILL.md +1 -1
- package/telegram-plugin/auth-snapshot-format.ts +173 -67
- package/telegram-plugin/auto-fallback-fleet.ts +3 -6
- package/telegram-plugin/bridge/bridge.ts +2 -1
- package/telegram-plugin/card-format.ts +59 -3
- package/telegram-plugin/credits-watch.ts +4 -7
- package/telegram-plugin/dist/bridge/bridge.js +132 -114
- package/telegram-plugin/dist/gateway/gateway.js +4456 -1591
- package/telegram-plugin/dist/server.js +180 -163
- package/telegram-plugin/format.ts +551 -20
- package/telegram-plugin/gateway/approval-card.ts +7 -14
- package/telegram-plugin/gateway/approvals-commands.ts +6 -9
- package/telegram-plugin/gateway/auth-command.ts +35 -38
- package/telegram-plugin/gateway/boot-card.ts +7 -2
- package/telegram-plugin/gateway/chat-id-fallback.ts +46 -0
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -9
- package/telegram-plugin/gateway/diff-preview-card.ts +3 -6
- package/telegram-plugin/gateway/gateway.ts +1524 -129
- package/telegram-plugin/gateway/ipc-protocol.ts +66 -2
- package/telegram-plugin/gateway/ipc-server.ts +91 -1
- package/telegram-plugin/gateway/linear-activity.ts +2 -5
- package/telegram-plugin/gateway/model-command.ts +173 -19
- package/telegram-plugin/gateway/obligation-turn-end.ts +27 -0
- package/telegram-plugin/gateway/permission-card-store.ts +104 -0
- package/telegram-plugin/gateway/permission-timeout.ts +25 -6
- package/telegram-plugin/gateway/status-pin-store.ts +302 -0
- package/telegram-plugin/gateway/turns-jsonl-rotate.ts +30 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +12 -1
- package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +35 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +61 -0
- package/telegram-plugin/history.ts +17 -7
- package/telegram-plugin/hooks/tool-label-pretool.d.mts +12 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
- package/telegram-plugin/idle-footer.ts +2 -2
- package/telegram-plugin/issues-card.ts +12 -7
- package/telegram-plugin/model-unavailable.ts +3 -6
- package/telegram-plugin/operator-events.ts +4 -6
- package/telegram-plugin/package.json +1 -1
- package/telegram-plugin/quota-check.ts +2 -2
- package/telegram-plugin/quota-watch.ts +7 -10
- package/telegram-plugin/server.ts +3 -1
- package/telegram-plugin/session-tail.ts +47 -1
- package/telegram-plugin/status-pin-driver.ts +102 -0
- package/telegram-plugin/status-pin.ts +76 -0
- package/telegram-plugin/stream-reply-handler.ts +33 -2
- package/telegram-plugin/subagent-watcher.ts +6 -3
- package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
- package/telegram-plugin/tests/auth-command-format2.test.ts +6 -2
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +18 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +204 -45
- package/telegram-plugin/tests/card-format.test.ts +79 -0
- package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
- package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -0
- package/telegram-plugin/tests/codespan-escaping-golden.test.ts +166 -0
- package/telegram-plugin/tests/credits-watch.test.ts +18 -0
- package/telegram-plugin/tests/format-consistency.test.ts +223 -0
- package/telegram-plugin/tests/formatting-parse-regression.test.ts +272 -0
- package/telegram-plugin/tests/formatting-torture-set.ts +218 -0
- package/telegram-plugin/tests/history.test.ts +38 -2
- package/telegram-plugin/tests/idle-footer.test.ts +53 -9
- package/telegram-plugin/tests/ipc-server-validate-rollout-status.test.ts +66 -0
- package/telegram-plugin/tests/issues-card.test.ts +24 -0
- package/telegram-plugin/tests/model-command.test.ts +213 -47
- package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +384 -3
- package/telegram-plugin/tests/permission-card-single-edit.test.ts +66 -0
- package/telegram-plugin/tests/permission-card-store.test.ts +83 -0
- package/telegram-plugin/tests/permission-timeout.test.ts +37 -6
- package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +23 -6
- package/telegram-plugin/tests/quota-check.test.ts +9 -2
- package/telegram-plugin/tests/quota-watch.test.ts +7 -7
- package/telegram-plugin/tests/rich-markdown-oracle.ts +469 -0
- package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
- package/telegram-plugin/tests/session-tail.test.ts +91 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +294 -0
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +210 -0
- package/telegram-plugin/tests/status-pin-store.test.ts +450 -0
- package/telegram-plugin/tests/status-pin.test.ts +202 -0
- package/telegram-plugin/tests/status-vocabulary-unification.test.ts +125 -0
- package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
- package/telegram-plugin/tests/telegram-format.test.ts +121 -8
- package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
- package/telegram-plugin/tests/tool-activity-summary.test.ts +88 -25
- package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
- package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +39 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +12 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +54 -0
- package/telegram-plugin/tests/vault-request-access-card.test.ts +94 -0
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +24 -0
- package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
- package/telegram-plugin/tests/voice-ondemand.test.ts +299 -0
- package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
- package/telegram-plugin/tests/voice-presynth.test.ts +437 -0
- package/telegram-plugin/tests/voice-synthesize-sidecar.test.ts +352 -0
- package/telegram-plugin/tests/voice-transcribe-sidecar.test.ts +332 -0
- package/telegram-plugin/tests/voice-transcribe.test.ts +188 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +77 -10
- package/telegram-plugin/text-voice-scrub.ts +68 -18
- package/telegram-plugin/tool-activity-summary.ts +31 -116
- package/telegram-plugin/tts-normalize.ts +377 -0
- package/telegram-plugin/uat/driver.ts +474 -17
- package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +34 -14
- package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +169 -0
- package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +134 -0
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +254 -0
- package/telegram-plugin/uat/scenarios/jtbd-status-phase-transitions-dm.test.ts +109 -0
- package/telegram-plugin/uat/uat-driver.test.ts +297 -0
- package/telegram-plugin/voice-normalize-text.ts +340 -0
- package/telegram-plugin/voice-ondemand.ts +289 -0
- package/telegram-plugin/voice-presynth.ts +242 -0
- package/telegram-plugin/voice-synthesize-sidecar.ts +259 -0
- package/telegram-plugin/voice-synthesize.ts +128 -0
- package/telegram-plugin/voice-transcribe-sidecar.ts +176 -0
- package/telegram-plugin/worker-activity-feed.ts +22 -5
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { decodeRichMessage } from "./driver.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Direct unit tests for `decodeRichMessage` — the Bot API 10.1 rich-message
|
|
6
|
+
* decoder in the MTProto UAT driver. These are HOSTED (no live Telegram, no
|
|
7
|
+
* driver session): they feed hand-built Instant-View page trees whose shapes
|
|
8
|
+
* are grounded 1:1 against `@mtcute/tl@223.0.0` (the TL layer mtcute 0.30
|
|
9
|
+
* ships) and assert the flat `{ text, entities }` the decoder produces.
|
|
10
|
+
*
|
|
11
|
+
* They are the CI-verifiable floor under the live render scenario
|
|
12
|
+
* (`scenarios/jtbd-rich-formatting-render-dm.test.ts`), which can only run on
|
|
13
|
+
* the gated uat-host. If Telegram's real GFM parser ever emits a shape these
|
|
14
|
+
* fixtures don't model, the live scenario catches it; these tests pin the
|
|
15
|
+
* decoder's behavior for the shapes we DO model so a decoder regression reds
|
|
16
|
+
* in ordinary CI without needing the runner.
|
|
17
|
+
*
|
|
18
|
+
* TL shape sources (all from `@mtcute/tl@223.0.0` index.d.ts):
|
|
19
|
+
* RichText: textPlain{text:string} · textConcat{texts:RichText[]} ·
|
|
20
|
+
* textBold/Italic/Underline/Strike/Fixed{text:RichText} ·
|
|
21
|
+
* textUrl{text:RichText,url:string} · textEmail{text,email} ·
|
|
22
|
+
* textMarked{text:RichText} (no textSpoiler exists → spoiler)
|
|
23
|
+
* PageBlock: pageBlockParagraph{text} · pageBlockPreformatted{text,language}
|
|
24
|
+
* · pageBlockHeader/Subheader{text} · pageBlockBlockquote/
|
|
25
|
+
* Pullquote{text,caption} · pageBlockList{items:PageListItem[]} ·
|
|
26
|
+
* pageBlockOrderedList{items:PageListOrderedItem[]} ·
|
|
27
|
+
* pageBlockTable{title,rows:PageTableRow[]} · pageBlockDivider
|
|
28
|
+
* Items: pageListItemText{text} · pageListItemBlocks{blocks} ·
|
|
29
|
+
* pageListOrderedItemText{num,text} ·
|
|
30
|
+
* pageListOrderedItemBlocks{num,blocks}
|
|
31
|
+
* Table: pageTableRow{cells:PageTableCell[]} ·
|
|
32
|
+
* pageTableCell{header?,text?:RichText}
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
// ── RichText builders (grounded shapes) ─────────────────────────────────────
|
|
36
|
+
const plain = (text: string) => ({ _: "textPlain", text });
|
|
37
|
+
const concat = (...texts: unknown[]) => ({ _: "textConcat", texts });
|
|
38
|
+
const bold = (text: unknown) => ({ _: "textBold", text });
|
|
39
|
+
const italic = (text: unknown) => ({ _: "textItalic", text });
|
|
40
|
+
const underline = (text: unknown) => ({ _: "textUnderline", text });
|
|
41
|
+
const strike = (text: unknown) => ({ _: "textStrike", text });
|
|
42
|
+
const fixed = (text: unknown) => ({ _: "textFixed", text });
|
|
43
|
+
const marked = (text: unknown) => ({ _: "textMarked", text }); // → spoiler
|
|
44
|
+
const url = (text: unknown, u: string) => ({ _: "textUrl", text, url: u });
|
|
45
|
+
const email = (text: unknown, e: string) => ({ _: "textEmail", text, email: e });
|
|
46
|
+
|
|
47
|
+
// ── PageBlock builders ──────────────────────────────────────────────────────
|
|
48
|
+
const para = (text: unknown) => ({ _: "pageBlockParagraph", text });
|
|
49
|
+
const pre = (text: unknown, language = "") => ({
|
|
50
|
+
_: "pageBlockPreformatted",
|
|
51
|
+
text,
|
|
52
|
+
language,
|
|
53
|
+
});
|
|
54
|
+
const header = (text: unknown) => ({ _: "pageBlockHeader", text });
|
|
55
|
+
const subheader = (text: unknown) => ({ _: "pageBlockSubheader", text });
|
|
56
|
+
const blockquote = (text: unknown, caption: unknown = { _: "textEmpty" }) => ({
|
|
57
|
+
_: "pageBlockBlockquote",
|
|
58
|
+
text,
|
|
59
|
+
caption,
|
|
60
|
+
});
|
|
61
|
+
const list = (...items: unknown[]) => ({ _: "pageBlockList", items });
|
|
62
|
+
const orderedList = (...items: unknown[]) => ({
|
|
63
|
+
_: "pageBlockOrderedList",
|
|
64
|
+
items,
|
|
65
|
+
});
|
|
66
|
+
const table = (title: unknown, rows: unknown[]) => ({
|
|
67
|
+
_: "pageBlockTable",
|
|
68
|
+
title,
|
|
69
|
+
rows,
|
|
70
|
+
});
|
|
71
|
+
const divider = () => ({ _: "pageBlockDivider" });
|
|
72
|
+
|
|
73
|
+
const itemText = (text: unknown) => ({ _: "pageListItemText", text });
|
|
74
|
+
const ordItemText = (num: string, text: unknown) => ({
|
|
75
|
+
_: "pageListOrderedItemText",
|
|
76
|
+
num,
|
|
77
|
+
text,
|
|
78
|
+
});
|
|
79
|
+
const row = (...cells: unknown[]) => ({ _: "pageTableRow", cells });
|
|
80
|
+
const cell = (text: unknown, header = false) => ({
|
|
81
|
+
_: "pageTableCell",
|
|
82
|
+
...(header ? { header: true } : {}),
|
|
83
|
+
text,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const rich = (...blocks: unknown[]) => ({ _: "richMessage", blocks });
|
|
87
|
+
|
|
88
|
+
// A convenience matcher — find the single entity of a kind.
|
|
89
|
+
const entOf = (
|
|
90
|
+
r: { entities: Array<{ kind: string }> },
|
|
91
|
+
kind: string,
|
|
92
|
+
) => r.entities.filter((e) => e.kind === kind);
|
|
93
|
+
|
|
94
|
+
describe("decodeRichMessage — non-rich guards", () => {
|
|
95
|
+
it("returns null for a non-richMessage value", () => {
|
|
96
|
+
expect(decodeRichMessage(undefined)).toBeNull();
|
|
97
|
+
expect(decodeRichMessage(null)).toBeNull();
|
|
98
|
+
expect(decodeRichMessage({ _: "message", blocks: [] })).toBeNull();
|
|
99
|
+
expect(decodeRichMessage({ _: "richMessage" })).toBeNull(); // no blocks[]
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("decodes an empty block list to empty output", () => {
|
|
103
|
+
expect(decodeRichMessage(rich())).toEqual({ text: "", entities: [] });
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("decodeRichMessage — inline RichText marks (existing coverage)", () => {
|
|
108
|
+
it("bold / italic / code with exact spans", () => {
|
|
109
|
+
const r = decodeRichMessage(
|
|
110
|
+
rich(
|
|
111
|
+
para(
|
|
112
|
+
concat(
|
|
113
|
+
plain("a "),
|
|
114
|
+
bold(plain("bold")),
|
|
115
|
+
plain(" "),
|
|
116
|
+
italic(plain("it")),
|
|
117
|
+
plain(" "),
|
|
118
|
+
fixed(plain("code")),
|
|
119
|
+
),
|
|
120
|
+
),
|
|
121
|
+
),
|
|
122
|
+
)!;
|
|
123
|
+
expect(r.text).toBe("a bold it code");
|
|
124
|
+
expect(entOf(r, "bold")[0]).toMatchObject({ offset: 2, length: 4, text: "bold" });
|
|
125
|
+
expect(entOf(r, "italic")[0]).toMatchObject({ text: "it" });
|
|
126
|
+
expect(entOf(r, "code")[0]).toMatchObject({ text: "code" });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("text_link carries the destination url; email classified", () => {
|
|
130
|
+
const r = decodeRichMessage(
|
|
131
|
+
rich(
|
|
132
|
+
para(
|
|
133
|
+
concat(
|
|
134
|
+
url(plain("repo"), "https://example.com/x"),
|
|
135
|
+
plain(" "),
|
|
136
|
+
email(plain("a@b.com"), "a@b.com"),
|
|
137
|
+
),
|
|
138
|
+
),
|
|
139
|
+
),
|
|
140
|
+
)!;
|
|
141
|
+
expect(entOf(r, "text_link")[0]).toMatchObject({
|
|
142
|
+
text: "repo",
|
|
143
|
+
url: "https://example.com/x",
|
|
144
|
+
});
|
|
145
|
+
expect(entOf(r, "email")[0]).toMatchObject({ text: "a@b.com" });
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("pageBlockPreformatted → a single pre entity with language", () => {
|
|
149
|
+
const r = decodeRichMessage(rich(pre(plain('echo "hi"'), "bash")))!;
|
|
150
|
+
expect(r.text).toBe('echo "hi"');
|
|
151
|
+
expect(entOf(r, "pre")[0]).toMatchObject({
|
|
152
|
+
offset: 0,
|
|
153
|
+
length: 9,
|
|
154
|
+
language: "bash",
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("decodeRichMessage — strikethrough / underline / spoiler marks", () => {
|
|
160
|
+
it("textStrike → strikethrough, textUnderline → underline", () => {
|
|
161
|
+
const r = decodeRichMessage(
|
|
162
|
+
rich(para(concat(strike(plain("gone")), plain(" "), underline(plain("keep"))))),
|
|
163
|
+
)!;
|
|
164
|
+
expect(r.text).toBe("gone keep");
|
|
165
|
+
expect(entOf(r, "strikethrough")[0]).toMatchObject({ text: "gone", offset: 0 });
|
|
166
|
+
expect(entOf(r, "underline")[0]).toMatchObject({ text: "keep", offset: 5 });
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("textMarked → spoiler (TL 223 has no textSpoiler)", () => {
|
|
170
|
+
const r = decodeRichMessage(rich(para(concat(plain("psst "), marked(plain("secret"))))))!;
|
|
171
|
+
expect(r.text).toBe("psst secret");
|
|
172
|
+
expect(entOf(r, "spoiler")[0]).toMatchObject({ text: "secret", offset: 5 });
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("decodeRichMessage — headings", () => {
|
|
177
|
+
it("pageBlockHeader / Subheader render as bold over the line", () => {
|
|
178
|
+
const r = decodeRichMessage(
|
|
179
|
+
rich(header(plain("Title")), subheader(plain("Sub")), para(plain("body"))),
|
|
180
|
+
)!;
|
|
181
|
+
expect(r.text).toBe("Title\nSub\nbody");
|
|
182
|
+
const bolds = entOf(r, "bold");
|
|
183
|
+
expect(bolds.map((e) => e.text).sort()).toEqual(["Sub", "Title"]);
|
|
184
|
+
expect(bolds.find((e) => e.text === "Title")).toMatchObject({ offset: 0, length: 5 });
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("decodeRichMessage — blockquotes", () => {
|
|
189
|
+
it("pageBlockBlockquote → blockquote entity over the body", () => {
|
|
190
|
+
const r = decodeRichMessage(rich(blockquote(plain("quoted line"))))!;
|
|
191
|
+
expect(r.text).toBe("quoted line");
|
|
192
|
+
expect(entOf(r, "blockquote")[0]).toMatchObject({
|
|
193
|
+
offset: 0,
|
|
194
|
+
length: 11,
|
|
195
|
+
text: "quoted line",
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("blockquote caption follows on its own line, unstyled", () => {
|
|
200
|
+
const r = decodeRichMessage(rich(blockquote(plain("body"), plain("— src"))))!;
|
|
201
|
+
expect(r.text).toBe("body\n— src");
|
|
202
|
+
expect(entOf(r, "blockquote")[0]).toMatchObject({ text: "body" });
|
|
203
|
+
// caption is not part of the blockquote span
|
|
204
|
+
expect(entOf(r, "blockquote")[0].length).toBe(4);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe("decodeRichMessage — lists", () => {
|
|
209
|
+
it("unordered list → bulleted lines, inline marks preserved", () => {
|
|
210
|
+
const r = decodeRichMessage(
|
|
211
|
+
rich(list(itemText(plain("first")), itemText(bold(plain("second"))))),
|
|
212
|
+
)!;
|
|
213
|
+
expect(r.text).toBe("• first\n• second");
|
|
214
|
+
expect(entOf(r, "bold")[0]).toMatchObject({ text: "second" });
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("ordered list → numbered lines using the wire .num label", () => {
|
|
218
|
+
const r = decodeRichMessage(
|
|
219
|
+
rich(
|
|
220
|
+
orderedList(
|
|
221
|
+
ordItemText("1", plain("alpha")),
|
|
222
|
+
ordItemText("2", plain("beta")),
|
|
223
|
+
),
|
|
224
|
+
),
|
|
225
|
+
)!;
|
|
226
|
+
expect(r.text).toBe("1. alpha\n2. beta");
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe("decodeRichMessage — tables", () => {
|
|
231
|
+
it("table rows join with ' | ', header cells emit bold", () => {
|
|
232
|
+
const r = decodeRichMessage(
|
|
233
|
+
rich(
|
|
234
|
+
table({ _: "textEmpty" }, [
|
|
235
|
+
row(cell(plain("H1"), true), cell(plain("H2"), true)),
|
|
236
|
+
row(cell(plain("a")), cell(plain("b"))),
|
|
237
|
+
]),
|
|
238
|
+
),
|
|
239
|
+
)!;
|
|
240
|
+
expect(r.text).toBe("H1 | H2\na | b");
|
|
241
|
+
const bolds = entOf(r, "bold").map((e) => e.text).sort();
|
|
242
|
+
expect(bolds).toEqual(["H1", "H2"]);
|
|
243
|
+
// body cells are not bold
|
|
244
|
+
expect(entOf(r, "bold").some((e) => e.text === "a")).toBe(false);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("table title renders on its own line above the rows", () => {
|
|
248
|
+
const r = decodeRichMessage(
|
|
249
|
+
rich(table(plain("Scores"), [row(cell(plain("x")), cell(plain("y")))])),
|
|
250
|
+
)!;
|
|
251
|
+
expect(r.text).toBe("Scores\nx | y");
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe("decodeRichMessage — divider", () => {
|
|
256
|
+
it("pageBlockDivider → a literal rule line, no entity", () => {
|
|
257
|
+
const r = decodeRichMessage(rich(para(plain("above")), divider(), para(plain("below"))))!;
|
|
258
|
+
expect(r.text).toBe("above\n———\nbelow");
|
|
259
|
+
expect(r.entities).toEqual([]);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
describe("decodeRichMessage — full torture tree (offsets stay UTF-16 correct)", () => {
|
|
264
|
+
it("mixed blocks decode with coherent offsets across the whole body", () => {
|
|
265
|
+
const r = decodeRichMessage(
|
|
266
|
+
rich(
|
|
267
|
+
header(plain("Report")),
|
|
268
|
+
para(
|
|
269
|
+
concat(
|
|
270
|
+
bold(plain("bold")),
|
|
271
|
+
plain(" "),
|
|
272
|
+
italic(plain("it")),
|
|
273
|
+
plain(" "),
|
|
274
|
+
strike(plain("old")),
|
|
275
|
+
plain(" "),
|
|
276
|
+
marked(plain("spoil")),
|
|
277
|
+
),
|
|
278
|
+
),
|
|
279
|
+
blockquote(plain("a wise quote")),
|
|
280
|
+
list(itemText(plain("one")), itemText(plain("two"))),
|
|
281
|
+
orderedList(ordItemText("1", plain("step"))),
|
|
282
|
+
divider(),
|
|
283
|
+
pre(plain("code()"), "js"),
|
|
284
|
+
),
|
|
285
|
+
)!;
|
|
286
|
+
// Every entity's recorded text must equal the slice at its offset —
|
|
287
|
+
// the invariant that proves offset bookkeeping is coherent.
|
|
288
|
+
for (const e of r.entities) {
|
|
289
|
+
expect(r.text.slice(e.offset, e.offset + e.length)).toBe(e.text);
|
|
290
|
+
}
|
|
291
|
+
// Sanity on the kinds present.
|
|
292
|
+
const kinds = new Set(r.entities.map((e) => e.kind));
|
|
293
|
+
for (const k of ["bold", "italic", "strikethrough", "spoiler", "blockquote", "pre"]) {
|
|
294
|
+
expect(kinds.has(k)).toBe(true);
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
});
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Speech normalization for OUTBOUND voice replies (voice-out).
|
|
3
|
+
*
|
|
4
|
+
* The problem: the agent's reply is Markdown-flavoured text. A TTS engine
|
|
5
|
+
* (Kokoro sidecar or OpenAI) reads it LITERALLY — so `~about 3`, `**bold**`,
|
|
6
|
+
* `` `code` ``, `# Heading`, `[label](https://…)`, and code fences all get
|
|
7
|
+
* spoken as "tilde", "asterisk asterisk", "backtick", "hash", or the raw URL
|
|
8
|
+
* read character-by-character. That is exactly the operator feedback: the
|
|
9
|
+
* voice is good but it pronounces the markup.
|
|
10
|
+
*
|
|
11
|
+
* `normalizeForSpeech` is a PURE string→string pass applied to the reply text
|
|
12
|
+
* BEFORE it is handed to any TTS engine. It replaces the older, partial
|
|
13
|
+
* `stripMarkdown` pass on the voice-out path (that one left `~`, code fences,
|
|
14
|
+
* tables, and arrows leaking through). It is deliberately conservative: the
|
|
15
|
+
* goal is natural prose, not aggressive rewriting — when in doubt it leaves
|
|
16
|
+
* real words alone.
|
|
17
|
+
*
|
|
18
|
+
* Documented behavioural choices (the "sensible defaults" the task allows):
|
|
19
|
+
* - Inline code (`` `x` ``): the backticks are dropped, the CONTENT is
|
|
20
|
+
* kept and spoken. Short inline code is usually a word/identifier the
|
|
21
|
+
* listener wants to hear.
|
|
22
|
+
* - Fenced code blocks (``` … ```): DROPPED entirely and replaced with a
|
|
23
|
+
* short spoken placeholder ("(code block omitted)"). Reading a block of
|
|
24
|
+
* code aloud is noise; a listener on a bike can't act on it anyway.
|
|
25
|
+
* - Links `[text](url)`: spoken as just `text`; the URL is dropped. A bare
|
|
26
|
+
* autolink `<https://…>` or a raw URL is replaced with "a link" so the
|
|
27
|
+
* engine never spells out a URL character-by-character.
|
|
28
|
+
* - `~` is DROPPED (not read as "tilde", not expanded to "about") — it is
|
|
29
|
+
* ambiguous (strikethrough marker vs. approx) and dropping is the safest
|
|
30
|
+
* choice that never mangles a real word.
|
|
31
|
+
* - `->` / `=>` / `→` become the spoken word "to".
|
|
32
|
+
* - A tiny, well-tested set of trivially-safe abbreviations is expanded
|
|
33
|
+
* ("e.g." → "for example", "i.e." → "that is", "etc." → "and so on",
|
|
34
|
+
* "vs" → "versus", "approx" → "approximately", "w/" → "with").
|
|
35
|
+
* Anything ambiguous is left alone.
|
|
36
|
+
*
|
|
37
|
+
* Phase 2 pre-TTS naturalization (all conservative, number/token-guarded):
|
|
38
|
+
* - Emoji & pictographs are dropped entirely (TTS would read their long
|
|
39
|
+
* CLDR names); `:shortcode:` forms are dropped too. Whitespace collapses.
|
|
40
|
+
* - Numbers, units & symbols are spoken: `%` → "percent", `$5.50` → "five
|
|
41
|
+
* dollars fifty", `12x`/`12×` → "twelve times", `°C` → "degrees",
|
|
42
|
+
* unit suffixes (`500ms`, `2h`, `10KB`, `100k`) expand to words, and the
|
|
43
|
+
* `& + =` glue symbols become "and / plus / equals".
|
|
44
|
+
* - A curated acronym set (CI, PR, API, URL, GPU, CPU, TTS, STT, HTTP,
|
|
45
|
+
* JSON, SQL, UI) is spelled letter-by-letter; word-style acronyms
|
|
46
|
+
* (NASA) are left alone.
|
|
47
|
+
* - Clear time / date patterns: `12:45` → "twelve forty-five", ISO
|
|
48
|
+
* `2026-07-01` → "July first two thousand twenty-six".
|
|
49
|
+
* Every phase-2 pass is guarded to fire only on a clear number+token so real
|
|
50
|
+
* identifiers (`my_var`, `class5`, `my5thing`) pass through untouched.
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/** Replace a fenced code block with a spoken placeholder. */
|
|
54
|
+
const CODE_BLOCK_PLACEHOLDER = 'code block omitted'
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Number → words helpers (small, deterministic, English cardinal only).
|
|
58
|
+
// Used by the numbers/units pass. Supports 0..999_999_999 which is far more
|
|
59
|
+
// than any realistic spoken quantity; larger inputs are left as digits so we
|
|
60
|
+
// never emit a wrong or truncated reading.
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
const ONES = [
|
|
63
|
+
'zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight',
|
|
64
|
+
'nine', 'ten', 'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen',
|
|
65
|
+
'sixteen', 'seventeen', 'eighteen', 'nineteen',
|
|
66
|
+
]
|
|
67
|
+
const TENS = [
|
|
68
|
+
'', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', 'eighty',
|
|
69
|
+
'ninety',
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
/** Cardinal words for 0..999. */
|
|
73
|
+
function belowThousand(n: number): string {
|
|
74
|
+
if (n < 20) return ONES[n]
|
|
75
|
+
if (n < 100) {
|
|
76
|
+
const t = TENS[Math.floor(n / 10)]
|
|
77
|
+
const o = n % 10
|
|
78
|
+
return o ? `${t}-${ONES[o]}` : t
|
|
79
|
+
}
|
|
80
|
+
const h = `${ONES[Math.floor(n / 100)]} hundred`
|
|
81
|
+
const rest = n % 100
|
|
82
|
+
return rest ? `${h} ${belowThousand(rest)}` : h
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Cardinal words for a non-negative integer, or null if out of range. */
|
|
86
|
+
function numberToWords(n: number): string | null {
|
|
87
|
+
if (!Number.isInteger(n) || n < 0 || n > 999_999_999) return null
|
|
88
|
+
if (n === 0) return 'zero'
|
|
89
|
+
const parts: string[] = []
|
|
90
|
+
const millions = Math.floor(n / 1_000_000)
|
|
91
|
+
const thousands = Math.floor((n % 1_000_000) / 1000)
|
|
92
|
+
const rest = n % 1000
|
|
93
|
+
if (millions) parts.push(`${belowThousand(millions)} million`)
|
|
94
|
+
if (thousands) parts.push(`${belowThousand(thousands)} thousand`)
|
|
95
|
+
if (rest) parts.push(belowThousand(rest))
|
|
96
|
+
return parts.join(' ')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Ordinal words for 1..31 (used for spoken dates). */
|
|
100
|
+
const ORDINALS: Record<number, string> = {
|
|
101
|
+
1: 'first', 2: 'second', 3: 'third', 4: 'fourth', 5: 'fifth', 6: 'sixth',
|
|
102
|
+
7: 'seventh', 8: 'eighth', 9: 'ninth', 10: 'tenth', 11: 'eleventh',
|
|
103
|
+
12: 'twelfth', 13: 'thirteenth', 14: 'fourteenth', 15: 'fifteenth',
|
|
104
|
+
16: 'sixteenth', 17: 'seventeenth', 18: 'eighteenth', 19: 'nineteenth',
|
|
105
|
+
20: 'twentieth', 21: 'twenty-first', 22: 'twenty-second',
|
|
106
|
+
23: 'twenty-third', 24: 'twenty-fourth', 25: 'twenty-fifth',
|
|
107
|
+
26: 'twenty-sixth', 27: 'twenty-seventh', 28: 'twenty-eighth',
|
|
108
|
+
29: 'twenty-ninth', 30: 'thirtieth', 31: 'thirty-first',
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const MONTHS = [
|
|
112
|
+
'', 'January', 'February', 'March', 'April', 'May', 'June', 'July',
|
|
113
|
+
'August', 'September', 'October', 'November', 'December',
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
/** Spoken form for a 4-digit year (e.g. 2026 → "two thousand twenty six"). */
|
|
117
|
+
function yearToWords(y: number): string | null {
|
|
118
|
+
if (y < 1000 || y > 9999) return null
|
|
119
|
+
// 2000..2099 read as "two thousand …" which is the realistic range for
|
|
120
|
+
// these timestamps and reads naturally for TTS.
|
|
121
|
+
if (y >= 2000 && y < 2100) {
|
|
122
|
+
const lo = y % 100
|
|
123
|
+
const base = 'two thousand'
|
|
124
|
+
return lo ? `${base} ${belowThousand(lo)}` : base
|
|
125
|
+
}
|
|
126
|
+
// Generic "nineteen eighty-four" style for other centuries.
|
|
127
|
+
const hi = Math.floor(y / 100)
|
|
128
|
+
const lo = y % 100
|
|
129
|
+
const hiW = belowThousand(hi)
|
|
130
|
+
if (lo === 0) return `${hiW} hundred`
|
|
131
|
+
return `${hiW} ${belowThousand(lo)}`
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Spoken minutes for a time-of-day (e.g. 45 → "forty-five", 5 → "oh five"). */
|
|
135
|
+
function minutesToWords(mm: number): string {
|
|
136
|
+
if (mm === 0) return "o'clock"
|
|
137
|
+
if (mm < 10) return `oh ${ONES[mm]}`
|
|
138
|
+
return belowThousand(mm)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Number-unit suffixes: token suffix → { singular, plural } spoken unit. */
|
|
142
|
+
const UNIT_MAP: Record<string, { s: string; p: string }> = {
|
|
143
|
+
ms: { s: 'millisecond', p: 'milliseconds' },
|
|
144
|
+
s: { s: 'second', p: 'seconds' },
|
|
145
|
+
sec: { s: 'second', p: 'seconds' },
|
|
146
|
+
min: { s: 'minute', p: 'minutes' },
|
|
147
|
+
m: { s: 'minute', p: 'minutes' },
|
|
148
|
+
h: { s: 'hour', p: 'hours' },
|
|
149
|
+
hr: { s: 'hour', p: 'hours' },
|
|
150
|
+
d: { s: 'day', p: 'days' },
|
|
151
|
+
kb: { s: 'kilobyte', p: 'kilobytes' },
|
|
152
|
+
mb: { s: 'megabyte', p: 'megabytes' },
|
|
153
|
+
gb: { s: 'gigabyte', p: 'gigabytes' },
|
|
154
|
+
tb: { s: 'terabyte', p: 'terabytes' },
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Curated initialisms spoken letter-by-letter. Uppercase keys only. */
|
|
158
|
+
const ACRONYMS = new Set([
|
|
159
|
+
'CI', 'PR', 'API', 'URL', 'GPU', 'CPU', 'TTS', 'STT', 'HTTP', 'JSON',
|
|
160
|
+
'SQL', 'UI',
|
|
161
|
+
])
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Convert a Markdown/plain reply into clean text for a TTS engine.
|
|
165
|
+
* Pure and deterministic — same input always yields the same output.
|
|
166
|
+
*/
|
|
167
|
+
export function normalizeForSpeech(input: string): string {
|
|
168
|
+
if (!input) return ''
|
|
169
|
+
let s = input.replace(/\r\n?/g, '\n')
|
|
170
|
+
|
|
171
|
+
// 0. Emoji & pictographs → dropped entirely, then whitespace collapsed.
|
|
172
|
+
// TTS reads an emoji as its long CLDR name ("grinning face"), which is
|
|
173
|
+
// noise. We also drop `:shortcode:` forms so nothing is read as
|
|
174
|
+
// "colon rocket colon". The shortcode form is matched narrowly
|
|
175
|
+
// (:word: with letters/digits/_/- ) so real colon usage survives.
|
|
176
|
+
s = s.replace(
|
|
177
|
+
/[\u{1F000}-\u{1FAFF}\u{1F1E6}-\u{1F1FF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}\u{FE00}-\u{FE0F}\u{200D}\u{2B50}\u{3030}\u{303D}\u{3297}\u{3299}\u{24C2}]/gu,
|
|
178
|
+
'',
|
|
179
|
+
)
|
|
180
|
+
s = s.replace(/:([a-z0-9][a-z0-9_+-]*):/gi, ' ')
|
|
181
|
+
|
|
182
|
+
// 1. Fenced code blocks first (```lang … ``` or ~~~ … ~~~) — drop the
|
|
183
|
+
// whole block before any inline processing can see its contents.
|
|
184
|
+
s = s.replace(/(^|\n)[ \t]*(`{3,}|~{3,})[^\n]*\n[\s\S]*?\n[ \t]*\2[ \t]*(?=\n|$)/g, `$1${CODE_BLOCK_PLACEHOLDER}.`)
|
|
185
|
+
// An unterminated fence (opening ``` with no close) — drop to end.
|
|
186
|
+
s = s.replace(/(^|\n)[ \t]*(`{3,}|~{3,})[^\n]*\n[\s\S]*$/g, `$1${CODE_BLOCK_PLACEHOLDER}.`)
|
|
187
|
+
|
|
188
|
+
// 2. Images  → alt text (or drop when alt is empty).
|
|
189
|
+
s = s.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
190
|
+
|
|
191
|
+
// 3. Links [text](url) → text ; drop the URL entirely.
|
|
192
|
+
s = s.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
193
|
+
|
|
194
|
+
// 4. Autolinks <https://…> and bare URLs → "a link" (never spell a URL).
|
|
195
|
+
s = s.replace(/<https?:\/\/[^>\s]+>/gi, 'a link')
|
|
196
|
+
s = s.replace(/\bhttps?:\/\/[^\s)]+/gi, 'a link')
|
|
197
|
+
|
|
198
|
+
// 5. Inline code `x` → x (keep content, drop backticks). Run before the
|
|
199
|
+
// generic backtick sweep so paired spans are handled cleanly.
|
|
200
|
+
s = s.replace(/`([^`\n]+)`/g, '$1')
|
|
201
|
+
// Any residual backticks → drop.
|
|
202
|
+
s = s.replace(/`/g, '')
|
|
203
|
+
|
|
204
|
+
// 6. Emphasis markers. Paired forms first (longest marker first), then
|
|
205
|
+
// strip residual markup-by-construction doubles. A LONE `*` or `_` in
|
|
206
|
+
// the middle of maths/words is left alone (see step 11).
|
|
207
|
+
s = s.replace(/\*\*\*(.+?)\*\*\*/g, '$1')
|
|
208
|
+
s = s.replace(/___(.+?)___/g, '$1')
|
|
209
|
+
s = s.replace(/\*\*(.+?)\*\*/g, '$1')
|
|
210
|
+
s = s.replace(/__(.+?)__/g, '$1')
|
|
211
|
+
s = s.replace(/\*(.+?)\*/g, '$1')
|
|
212
|
+
s = s.replace(/(?<![A-Za-z0-9])_(.+?)_(?![A-Za-z0-9])/g, '$1')
|
|
213
|
+
// Strikethrough ~~text~~ → text.
|
|
214
|
+
s = s.replace(/~~(.+?)~~/g, '$1')
|
|
215
|
+
|
|
216
|
+
// 7. Leading block markup, per line: headings, blockquotes, list markers.
|
|
217
|
+
// List bullets/numbers become a natural sentence pause rather than a
|
|
218
|
+
// spoken "dash" / "1 dot".
|
|
219
|
+
s = s.replace(/^[ \t]{0,3}#{1,6}[ \t]+/gm, '')
|
|
220
|
+
s = s.replace(/^[ \t]{0,3}>[ \t]?/gm, '')
|
|
221
|
+
s = s.replace(/^[ \t]{0,3}[-*+][ \t]+/gm, '')
|
|
222
|
+
s = s.replace(/^[ \t]{0,3}\d+[.)][ \t]+/gm, '')
|
|
223
|
+
|
|
224
|
+
// 8. Horizontal rules (---, ___, ***) on their own line → drop.
|
|
225
|
+
s = s.replace(/^[ \t]{0,3}([-_*])\1{2,}[ \t]*$/gm, '')
|
|
226
|
+
|
|
227
|
+
// 9. Table syntax: drop pipes and separator rows so tables read as prose.
|
|
228
|
+
s = s.replace(/^[ \t]*\|?[ \t]*:?-{2,}:?[ \t]*(\|[ \t]*:?-{2,}:?[ \t]*)+\|?[ \t]*$/gm, '')
|
|
229
|
+
s = s.replace(/\|/g, ' ')
|
|
230
|
+
|
|
231
|
+
// 10. Arrows → the spoken word "to".
|
|
232
|
+
s = s.replace(/[=-]>/g, ' to ')
|
|
233
|
+
s = s.replace(/[→⇒]/g, ' to ')
|
|
234
|
+
|
|
235
|
+
// 11. Stray tildes (approx / leftover markers) → drop. Ambiguous; dropping
|
|
236
|
+
// is the safe choice that never mangles a real word.
|
|
237
|
+
s = s.replace(/~/g, '')
|
|
238
|
+
|
|
239
|
+
// 12. Trivially-safe abbreviation expansions (case-insensitive, only at a
|
|
240
|
+
// word boundary followed by space/comma). Kept minimal on purpose.
|
|
241
|
+
s = s.replace(/\be\.g\.,?/gi, 'for example,')
|
|
242
|
+
s = s.replace(/\bi\.e\.,?/gi, 'that is,')
|
|
243
|
+
s = s.replace(/\betc\./gi, 'and so on')
|
|
244
|
+
s = s.replace(/\bapprox\.?(?=\s|$)/gi, 'approximately')
|
|
245
|
+
s = s.replace(/\bvs\.?(?=\s|$)/gi, 'versus')
|
|
246
|
+
s = s.replace(/\bw\/(?=\s)/gi, 'with ')
|
|
247
|
+
|
|
248
|
+
// 13. Dates & times (clear patterns only, run before the numbers pass so
|
|
249
|
+
// the colon in HH:MM and the hyphens in ISO dates are consumed here).
|
|
250
|
+
// ISO date YYYY-MM-DD → "Month Dayth Year".
|
|
251
|
+
s = s.replace(/\b(\d{4})-(\d{2})-(\d{2})\b/g, (m, y, mo, da) => {
|
|
252
|
+
const year = Number(y)
|
|
253
|
+
const month = Number(mo)
|
|
254
|
+
const day = Number(da)
|
|
255
|
+
if (month < 1 || month > 12 || day < 1 || day > 31) return m
|
|
256
|
+
const yw = yearToWords(year)
|
|
257
|
+
const ord = ORDINALS[day]
|
|
258
|
+
if (!yw || !ord) return m
|
|
259
|
+
return `${MONTHS[month]} ${ord} ${yw}`
|
|
260
|
+
})
|
|
261
|
+
// Clock time HH:MM (24h ok) → spoken. Guarded by word boundaries so a
|
|
262
|
+
// ratio like "3:2" or a bare number isn't caught (needs 2-digit MM).
|
|
263
|
+
s = s.replace(/\b([01]?\d|2[0-3]):([0-5]\d)\b/g, (m, hh, mm) => {
|
|
264
|
+
const h = Number(hh)
|
|
265
|
+
const min = Number(mm)
|
|
266
|
+
const hw = belowThousand(h)
|
|
267
|
+
if (min === 0) return `${hw} o'clock`
|
|
268
|
+
return `${hw} ${minutesToWords(min)}`
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
// 14. Numbers, units & symbols → spoken words. Each sub-pass is guarded so
|
|
272
|
+
// it only fires on a clear number+token, never mid-word.
|
|
273
|
+
// Currency: $5 / $5.50 → "five dollars" / "five dollars fifty".
|
|
274
|
+
s = s.replace(/\$(\d{1,9})(?:\.(\d{2}))?\b/g, (m, dollars, cents) => {
|
|
275
|
+
const dw = numberToWords(Number(dollars))
|
|
276
|
+
if (!dw) return m
|
|
277
|
+
const noun = Number(dollars) === 1 && !cents ? 'dollar' : 'dollars'
|
|
278
|
+
if (cents && cents !== '00') {
|
|
279
|
+
const cw = numberToWords(Number(cents))
|
|
280
|
+
return `${dw} ${noun} ${cw}`
|
|
281
|
+
}
|
|
282
|
+
return `${dw} ${noun}`
|
|
283
|
+
})
|
|
284
|
+
// Percent sign → " percent".
|
|
285
|
+
s = s.replace(/(\d)\s*%/g, '$1 percent')
|
|
286
|
+
// Temperature °C / °F → "degrees".
|
|
287
|
+
s = s.replace(/°\s*[CF]\b/g, ' degrees')
|
|
288
|
+
s = s.replace(/°/g, ' degrees')
|
|
289
|
+
// Multiplier NN× / NNx → "NN times" (x only when glued to a number).
|
|
290
|
+
s = s.replace(/\b(\d{1,9})\s*[×x](?![a-z0-9])/gi, (m, n) => {
|
|
291
|
+
const w = numberToWords(Number(n))
|
|
292
|
+
return w ? `${w} times` : m
|
|
293
|
+
})
|
|
294
|
+
// "100k" shorthand → "one hundred thousand" (k as thousands multiplier).
|
|
295
|
+
s = s.replace(/\b(\d{1,6})k\b/gi, (m, n) => {
|
|
296
|
+
const w = numberToWords(Number(n) * 1000)
|
|
297
|
+
return w ? w : m
|
|
298
|
+
})
|
|
299
|
+
// Number + unit suffix → "<number> <unit>" (e.g. 500ms, 2h, 10KB).
|
|
300
|
+
// Only when the suffix is a known unit glued directly to the number and
|
|
301
|
+
// bounded by a non-letter (so "my5thing" / "class5" are never touched).
|
|
302
|
+
s = s.replace(
|
|
303
|
+
/\b(\d{1,9})(ms|sec|min|kb|mb|gb|tb|hr|s|m|h|d)(?![a-z])/gi,
|
|
304
|
+
(m, num, unitRaw) => {
|
|
305
|
+
const unit = UNIT_MAP[unitRaw.toLowerCase()]
|
|
306
|
+
if (!unit) return m
|
|
307
|
+
const n = Number(num)
|
|
308
|
+
const w = numberToWords(n)
|
|
309
|
+
if (!w) return m
|
|
310
|
+
return `${w} ${n === 1 ? unit.s : unit.p}`
|
|
311
|
+
},
|
|
312
|
+
)
|
|
313
|
+
// Symbols between tokens: standalone & → and, + → plus, = → equals.
|
|
314
|
+
s = s.replace(/(\S)\s*\+\s*(\S)/g, '$1 plus $2')
|
|
315
|
+
s = s.replace(/\s=\s/g, ' equals ')
|
|
316
|
+
s = s.replace(/(\s)&(\s)/g, '$1and$2')
|
|
317
|
+
s = s.replace(/(\w)&(\w)/g, '$1 and $2')
|
|
318
|
+
|
|
319
|
+
// 15. Acronyms → letter-by-letter for a curated set of initialisms. Only a
|
|
320
|
+
// standalone all-caps token that exactly matches the map is expanded;
|
|
321
|
+
// word-style acronyms (NASA) and sub-tokens of larger words are left.
|
|
322
|
+
s = s.replace(/\b[A-Z]{2,5}\b/g, (tok) =>
|
|
323
|
+
ACRONYMS.has(tok) ? tok.split('').join(' ') : tok,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
// 16. Collapse excessive punctuation the engine would over-emphasise.
|
|
327
|
+
s = s.replace(/([!?.]){2,}/g, '$1')
|
|
328
|
+
|
|
329
|
+
// 17. Whitespace → sentence flow. Blank lines become a sentence break so
|
|
330
|
+
// paragraphs don't run together; everything else collapses to a single
|
|
331
|
+
// space.
|
|
332
|
+
s = s.replace(/[ \t]*\n[ \t]*\n[ \t]*/g, '. ')
|
|
333
|
+
s = s.replace(/\s*\n\s*/g, ' ')
|
|
334
|
+
s = s.replace(/[ \t]{2,}/g, ' ')
|
|
335
|
+
// Tidy artifacts from the paragraph→". " substitution (". ." → ". ").
|
|
336
|
+
s = s.replace(/\.\s*\.(\s|$)/g, '.$1')
|
|
337
|
+
s = s.replace(/\s+([,.!?;:])/g, '$1')
|
|
338
|
+
|
|
339
|
+
return s.trim()
|
|
340
|
+
}
|