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
|
@@ -36,126 +36,38 @@
|
|
|
36
36
|
// plain-English description or a domain label. This is the signal the
|
|
37
37
|
// draft-mirror renders (option A: uniform across code + non-code agents).
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const parts = p.split("/").filter(Boolean);
|
|
43
|
-
return parts.length > 0 ? parts[parts.length - 1] : p;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Extract a bare hostname from a URL for display (no scheme/path). */
|
|
47
|
-
function hostName(u: unknown): string | null {
|
|
48
|
-
if (typeof u !== "string" || u.length === 0) return null;
|
|
49
|
-
try {
|
|
50
|
-
return new URL(u).hostname.replace(/^www\./, "");
|
|
51
|
-
} catch {
|
|
52
|
-
return u.replace(/^https?:\/\//, "").split("/")[0] || null;
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function clip(s: unknown, n: number): string | null {
|
|
57
|
-
if (typeof s !== "string") return null;
|
|
58
|
-
const t = s.trim();
|
|
59
|
-
if (t.length === 0) return null;
|
|
60
|
-
return t.length > n ? t.slice(0, n - 1) + "…" : t;
|
|
61
|
-
}
|
|
39
|
+
// (The per-tool wording helpers — basename/hostname/clip — live with the one
|
|
40
|
+
// composer in hooks/tool-label-pretool.mjs. See describeToolUse below.)
|
|
41
|
+
import { computeLabel } from './hooks/tool-label-pretool.mjs'
|
|
62
42
|
|
|
63
43
|
/**
|
|
64
44
|
* Render a single tool_use into a human-friendly, present-tense activity
|
|
65
45
|
* line for the live draft preview — or null when the tool should NOT be
|
|
66
46
|
* surfaced (the Telegram-plugin surface tools, which ARE the conversation).
|
|
67
47
|
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
48
|
+
* SINGLE-COMPOSER RULE: the wording itself lives in ONE place —
|
|
49
|
+
* `computeLabel` in hooks/tool-label-pretool.mjs, the same function the
|
|
50
|
+
* real-time PreToolUse sidecar runs at tool-call time. This function is a
|
|
51
|
+
* thin delegating wrapper, NOT a second vocabulary: before the delegation
|
|
52
|
+
* the two tables had drifted (Grep "Searching . for X" vs "Searching for
|
|
53
|
+
* X", WebFetch "Fetching host/path" vs "Reading host", retain "Saving
|
|
54
|
+
* memory" vs "Saving to memory"), so the live feed and the flush-time /
|
|
55
|
+
* nested-sub-agent / worker-card lines rendered the SAME action with
|
|
56
|
+
* different copy — the wording drift the know-what-my-agent-is-doing spec
|
|
57
|
+
* bars. `status-vocabulary-unification.test.ts` pins the delegation; do not
|
|
58
|
+
* re-fork the table here.
|
|
71
59
|
*/
|
|
72
60
|
export function describeToolUse(
|
|
73
61
|
toolName: string,
|
|
74
62
|
input: Record<string, unknown> | undefined,
|
|
75
63
|
): string | null {
|
|
76
64
|
if (!toolName) return null;
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
// Surface tools ARE the conversation — never mirror them.
|
|
84
|
-
// Use isTelegramSurfaceTool (regex-based, key-agnostic) so forks/renames work.
|
|
85
|
-
if (isTelegramSurfaceTool(toolName)) return null;
|
|
86
|
-
if (server === "hindsight") {
|
|
87
|
-
if (tool === "recall" || tool === "reflect") return "Searching memory";
|
|
88
|
-
if (tool === "retain" || tool === "update_memory" || tool === "sync_retain")
|
|
89
|
-
return "Saving to memory";
|
|
90
|
-
return "Working with memory";
|
|
91
|
-
}
|
|
92
|
-
if (
|
|
93
|
-
server === "google-workspace" ||
|
|
94
|
-
server === "claude_ai_google_calendar"
|
|
95
|
-
) {
|
|
96
|
-
return "Checking your calendar";
|
|
97
|
-
}
|
|
98
|
-
if (server === "claude_ai_gmail") return "Checking your email";
|
|
99
|
-
if (server === "claude_ai_google_drive") return "Looking through your files";
|
|
100
|
-
if (server === "notion" || server === "claude_ai_notion") {
|
|
101
|
-
return "Checking your notes";
|
|
102
|
-
}
|
|
103
|
-
// Unknown MCP tool: prefer a model-authored field, else a humanized name.
|
|
104
|
-
const desc = clip(inp.description, 60) ?? clip(inp.query, 50) ?? clip(inp.title, 50);
|
|
105
|
-
if (desc) return desc;
|
|
106
|
-
return "Using " + tool.replace(/[-_]+/g, " ");
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
switch (toolName) {
|
|
110
|
-
case "Bash": {
|
|
111
|
-
// The model writes a plain-English description for every command.
|
|
112
|
-
return clip(inp.description, 70) ?? "Running a command";
|
|
113
|
-
}
|
|
114
|
-
case "BashOutput":
|
|
115
|
-
case "KillShell":
|
|
116
|
-
return "Managing a background command";
|
|
117
|
-
case "Read": {
|
|
118
|
-
const f = baseName(inp.file_path);
|
|
119
|
-
return f ? `Reading ${f}` : "Reading a file";
|
|
120
|
-
}
|
|
121
|
-
case "Edit":
|
|
122
|
-
case "MultiEdit":
|
|
123
|
-
case "NotebookEdit": {
|
|
124
|
-
const f = baseName(inp.file_path) ?? baseName(inp.notebook_path);
|
|
125
|
-
return f ? `Editing ${f}` : "Editing a file";
|
|
126
|
-
}
|
|
127
|
-
case "Write": {
|
|
128
|
-
const f = baseName(inp.file_path);
|
|
129
|
-
return f ? `Writing ${f}` : "Writing a file";
|
|
130
|
-
}
|
|
131
|
-
case "Grep":
|
|
132
|
-
case "Glob": {
|
|
133
|
-
const p = clip(inp.pattern, 40);
|
|
134
|
-
return p ? `Searching for ${p}` : "Searching files";
|
|
135
|
-
}
|
|
136
|
-
case "WebFetch": {
|
|
137
|
-
const h = hostName(inp.url);
|
|
138
|
-
return h ? `Reading ${h}` : "Reading a web page";
|
|
139
|
-
}
|
|
140
|
-
case "WebSearch": {
|
|
141
|
-
const q = clip(inp.query, 50);
|
|
142
|
-
return q ? `Searching the web for ${q}` : "Searching the web";
|
|
143
|
-
}
|
|
144
|
-
case "Task":
|
|
145
|
-
case "Agent": {
|
|
146
|
-
const d = clip(inp.description, 60);
|
|
147
|
-
return d ? `Delegating: ${d}` : "Delegating to a sub-agent";
|
|
148
|
-
}
|
|
149
|
-
case "TodoWrite":
|
|
150
|
-
case "TaskCreate":
|
|
151
|
-
case "TaskUpdate":
|
|
152
|
-
case "TaskList":
|
|
153
|
-
return "Updating the plan";
|
|
154
|
-
case "ToolSearch":
|
|
155
|
-
return "Finding the right tool";
|
|
156
|
-
default:
|
|
157
|
-
return "Working…";
|
|
158
|
-
}
|
|
65
|
+
// Surface tools ARE the conversation — never mirror them. computeLabel
|
|
66
|
+
// suppresses these too (its own key-agnostic regex); this guard stays as
|
|
67
|
+
// the TS-side belt so a hook-side refactor can't leak a reply label into
|
|
68
|
+
// the feed.
|
|
69
|
+
if (isTelegramSurfaceTool(toolName)) return null;
|
|
70
|
+
return computeLabel(toolName, input ?? {});
|
|
159
71
|
}
|
|
160
72
|
|
|
161
73
|
// ─── Accumulating activity feed (draft-mirror Phase 2) ──────────────────────
|
|
@@ -177,7 +89,7 @@ import {
|
|
|
177
89
|
STATUS_LINE_MAX,
|
|
178
90
|
NESTED_PREFIX,
|
|
179
91
|
} from './status-no-truncate.js'
|
|
180
|
-
import { escapeMarkdown, stripMarkdown, truncate } from './card-format.js'
|
|
92
|
+
import { escapeMarkdown, stripMarkdown, truncate, stackCardLines } from './card-format.js'
|
|
181
93
|
import { isTelegramSurfaceTool } from './tool-names.js'
|
|
182
94
|
|
|
183
95
|
/**
|
|
@@ -304,7 +216,7 @@ function escapeStepLine(raw: string): string {
|
|
|
304
216
|
*
|
|
305
217
|
* `out` — accumulator mutated in place
|
|
306
218
|
* `steps` — pre-cleaned + pre-escaped HTML step strings
|
|
307
|
-
* `allDone` — when true ALL steps render done (✓ italic); when false the
|
|
219
|
+
* `allDone` — when true ALL steps render done (✓ struck italic); when false the
|
|
308
220
|
* newest renders in-progress (→ bold)
|
|
309
221
|
* `liveSuffix` — appended INSIDE the newest in-progress line (heartbeat tick)
|
|
310
222
|
*/
|
|
@@ -320,7 +232,7 @@ export function renderStepFeed(
|
|
|
320
232
|
if (hidden > 0) out.push(`_✓ +${hidden} earlier…_`)
|
|
321
233
|
const lastIdx = shown.length - 1
|
|
322
234
|
shown.forEach((s, i) => {
|
|
323
|
-
out.push(!allDone && i === lastIdx ? `**→ ${s}${liveSuffix}**` :
|
|
235
|
+
out.push(!allDone && i === lastIdx ? `**→ ${s}${liveSuffix}**` : `~~_✓ ${s}_~~`)
|
|
324
236
|
})
|
|
325
237
|
}
|
|
326
238
|
|
|
@@ -400,7 +312,7 @@ export function renderStatusCard(opts: StatusCardOpts): string | null {
|
|
|
400
312
|
const shownParent = steps.slice(-STATUS_ROLLING_LINES)
|
|
401
313
|
const hiddenParent = steps.length - shownParent.length
|
|
402
314
|
if (hiddenParent > 0) out.push(`_✓ +${hiddenParent} earlier…_`)
|
|
403
|
-
for (const s of shownParent) out.push(
|
|
315
|
+
for (const s of shownParent) out.push(`~~_✓ ${s}_~~`)
|
|
404
316
|
// Child block.
|
|
405
317
|
const shownChild = children.slice(-STATUS_ROLLING_LINES)
|
|
406
318
|
const hiddenChild = children.length - shownChild.length
|
|
@@ -410,7 +322,7 @@ export function renderStatusCard(opts: StatusCardOpts): string | null {
|
|
|
410
322
|
out.push(
|
|
411
323
|
i === lastChildIdx && !final
|
|
412
324
|
? `${NESTED_PREFIX}**→ ${s}${liveSuffix}**`
|
|
413
|
-
: `${NESTED_PREFIX}_${s}_
|
|
325
|
+
: `${NESTED_PREFIX}~~_${s}_~~`,
|
|
414
326
|
)
|
|
415
327
|
})
|
|
416
328
|
} else {
|
|
@@ -429,7 +341,10 @@ export function renderStatusCard(opts: StatusCardOpts): string | null {
|
|
|
429
341
|
// out always carries the two header lines, so it is never empty — but guard
|
|
430
342
|
// against a degenerate header-less future caller.
|
|
431
343
|
if (out.length === 0) return null
|
|
432
|
-
|
|
344
|
+
// Stack lines with GFM hard breaks (` \n`) so the card's styled prose lines
|
|
345
|
+
// don't collapse onto one visual line in the rich-message renderer — see
|
|
346
|
+
// stackCardLines. This is what makes a card render identically to a reply.
|
|
347
|
+
const joined = stackCardLines(out)
|
|
433
348
|
if (joined.length <= STATUS_CARD_CHAR_BUDGET) return joined
|
|
434
349
|
return fitCardToBudget(opts, headerLines)
|
|
435
350
|
}
|
|
@@ -482,7 +397,7 @@ function fitCardToBudget(opts: StatusCardOpts, headerLines: string[]): string {
|
|
|
482
397
|
const lastIdx = shown.length - 1
|
|
483
398
|
shown.forEach((esc, i) => lines.push(buildBullet(esc, i === lastIdx)))
|
|
484
399
|
lines.push(...footerLines)
|
|
485
|
-
const candidate = lines
|
|
400
|
+
const candidate = stackCardLines(lines)
|
|
486
401
|
if (candidate.length <= STATUS_CARD_CHAR_BUDGET) return candidate
|
|
487
402
|
}
|
|
488
403
|
|
|
@@ -508,7 +423,7 @@ function fitCardToBudget(opts: StatusCardOpts, headerLines: string[]): string {
|
|
|
508
423
|
if (parentMarker != null) lines.push(parentMarker)
|
|
509
424
|
lines.push(newestLine)
|
|
510
425
|
lines.push(...footerLines)
|
|
511
|
-
return lines
|
|
426
|
+
return stackCardLines(lines)
|
|
512
427
|
}
|
|
513
428
|
|
|
514
429
|
/**
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tts-normalize.ts — deterministic L1 text-normalization front-end for
|
|
3
|
+
* the voice sidecar (issue #2760, Phase 1).
|
|
4
|
+
*
|
|
5
|
+
* Kokoro ships almost no text normalization, so raw agent text (markdown,
|
|
6
|
+
* symbols, numbers, currency, emoji, URLs, code) gets read literally and
|
|
7
|
+
* sounds unnatural. This module is a PURE string→string pass applied in
|
|
8
|
+
* the gateway voice-out path immediately before the `POST /tts` body is
|
|
9
|
+
* built, mirroring the `text-voice-scrub.ts` precedent: deterministic
|
|
10
|
+
* transform, code-region park/restore, kill-switch env var, no network,
|
|
11
|
+
* no LLM (Phase 2 adds the optional local-LLM layer).
|
|
12
|
+
*
|
|
13
|
+
* It runs AFTER `normalizeForSpeech` (which already strips most markdown
|
|
14
|
+
* on the plan-building path) and is deliberately idempotent/overlapping
|
|
15
|
+
* with it: the Listen lazy path synthesizes from a persisted cache whose
|
|
16
|
+
* entries may predate normalizeForSpeech coverage, and this stage is the
|
|
17
|
+
* last line of defence at the actual /tts body build. Rules where both
|
|
18
|
+
* passes apply are no-ops the second time.
|
|
19
|
+
*
|
|
20
|
+
* Gating: ON BY DEFAULT (operator decision 2026-07-04 — Phase 1 ships
|
|
21
|
+
* default-on). The only gate is the kill switch:
|
|
22
|
+
* `SWITCHROOM_DISABLE_TTS_NORMALIZE=1` returns the input byte-identical —
|
|
23
|
+
* same shape as `SWITCHROOM_DISABLE_VOICE_SCRUB`; rollback is one env var.
|
|
24
|
+
*
|
|
25
|
+
* Rules (en locale, deterministic, conservative — when unsure, pass
|
|
26
|
+
* text through unchanged):
|
|
27
|
+
* - Markdown: bold/italic/strike markers removed; headings → plain;
|
|
28
|
+
* links → link text; bare URLs → spoken domain ("github dot com
|
|
29
|
+
* link"); list markers dropped; tables → "table omitted"; code
|
|
30
|
+
* fences → "code block omitted"; inline code read as-is without
|
|
31
|
+
* backticks (content is parked so number/symbol expansion can never
|
|
32
|
+
* mangle an identifier).
|
|
33
|
+
* - Emoji: stripped (a small map speaks the common ones).
|
|
34
|
+
* - Numbers: currency ($5.20 → "five dollars twenty"), percentages,
|
|
35
|
+
* ordinals (3rd → "third"), times (14:30), ISO dates, phone-like
|
|
36
|
+
* digit runs read digit-by-digit, units (km, GB, ms, …).
|
|
37
|
+
* - Symbols: & → and, @ → at, # → hash, word/word → "word slash word",
|
|
38
|
+
* ~5 → "about 5", > blockquote markers dropped.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
const NULL = '\x00'
|
|
42
|
+
const INLINE_PH = `${NULL}TN_INLINE`
|
|
43
|
+
|
|
44
|
+
/** On by default; the kill switch is the only gate. */
|
|
45
|
+
export function ttsNormalizeEnabled(): boolean {
|
|
46
|
+
const kill = process.env.SWITCHROOM_DISABLE_TTS_NORMALIZE
|
|
47
|
+
return !(kill === '1' || kill === 'true')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Number → words (English cardinal, 0..999,999,999; larger stays digits so we
|
|
52
|
+
// never emit a wrong reading).
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
const ONES = [
|
|
55
|
+
'zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight',
|
|
56
|
+
'nine', 'ten', 'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen',
|
|
57
|
+
'sixteen', 'seventeen', 'eighteen', 'nineteen',
|
|
58
|
+
]
|
|
59
|
+
const TENS = [
|
|
60
|
+
'', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy',
|
|
61
|
+
'eighty', 'ninety',
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
function belowThousand(n: number): string {
|
|
65
|
+
if (n < 20) return ONES[n]!
|
|
66
|
+
if (n < 100) {
|
|
67
|
+
const t = TENS[Math.floor(n / 10)]!
|
|
68
|
+
const o = n % 10
|
|
69
|
+
return o ? `${t}-${ONES[o]}` : t
|
|
70
|
+
}
|
|
71
|
+
const h = `${ONES[Math.floor(n / 100)]} hundred`
|
|
72
|
+
const rest = n % 100
|
|
73
|
+
return rest ? `${h} ${belowThousand(rest)}` : h
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function numberToWords(n: number): string | null {
|
|
77
|
+
if (!Number.isInteger(n) || n < 0 || n > 999_999_999) return null
|
|
78
|
+
if (n === 0) return 'zero'
|
|
79
|
+
const parts: string[] = []
|
|
80
|
+
const millions = Math.floor(n / 1_000_000)
|
|
81
|
+
const thousands = Math.floor((n % 1_000_000) / 1000)
|
|
82
|
+
const rest = n % 1000
|
|
83
|
+
if (millions) parts.push(`${belowThousand(millions)} million`)
|
|
84
|
+
if (thousands) parts.push(`${belowThousand(thousands)} thousand`)
|
|
85
|
+
if (rest) parts.push(belowThousand(rest))
|
|
86
|
+
return parts.join(' ')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Ordinal words for 0..99 ("third", "twenty-first"). null out of range. */
|
|
90
|
+
function ordinalToWords(n: number): string | null {
|
|
91
|
+
if (!Number.isInteger(n) || n < 0 || n > 99) return null
|
|
92
|
+
const IRREGULAR: Record<number, string> = {
|
|
93
|
+
0: 'zeroth', 1: 'first', 2: 'second', 3: 'third', 5: 'fifth',
|
|
94
|
+
8: 'eighth', 9: 'ninth', 12: 'twelfth',
|
|
95
|
+
}
|
|
96
|
+
if (IRREGULAR[n]) return IRREGULAR[n]
|
|
97
|
+
if (n < 20) return `${ONES[n]}th`
|
|
98
|
+
const tens = Math.floor(n / 10)
|
|
99
|
+
const ones = n % 10
|
|
100
|
+
if (ones === 0) return `${TENS[tens]!.replace(/y$/, 'ie')}th`
|
|
101
|
+
return `${TENS[tens]}-${ordinalToWords(ones)}`
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function yearToWords(y: number): string | null {
|
|
105
|
+
if (y < 1000 || y > 9999) return null
|
|
106
|
+
if (y >= 2000 && y < 2100) {
|
|
107
|
+
const lo = y % 100
|
|
108
|
+
return lo ? `two thousand ${belowThousand(lo)}` : 'two thousand'
|
|
109
|
+
}
|
|
110
|
+
const hi = Math.floor(y / 100)
|
|
111
|
+
const lo = y % 100
|
|
112
|
+
return lo === 0 ? `${belowThousand(hi)} hundred` : `${belowThousand(hi)} ${belowThousand(lo)}`
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const MONTHS = [
|
|
116
|
+
'', 'January', 'February', 'March', 'April', 'May', 'June', 'July',
|
|
117
|
+
'August', 'September', 'October', 'November', 'December',
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
/** Unit suffixes → spoken units (singular/plural). Lowercased keys. */
|
|
121
|
+
const UNIT_MAP: Record<string, { s: string; p: string }> = {
|
|
122
|
+
ms: { s: 'millisecond', p: 'milliseconds' },
|
|
123
|
+
s: { s: 'second', p: 'seconds' },
|
|
124
|
+
sec: { s: 'second', p: 'seconds' },
|
|
125
|
+
min: { s: 'minute', p: 'minutes' },
|
|
126
|
+
h: { s: 'hour', p: 'hours' },
|
|
127
|
+
hr: { s: 'hour', p: 'hours' },
|
|
128
|
+
d: { s: 'day', p: 'days' },
|
|
129
|
+
km: { s: 'kilometre', p: 'kilometres' },
|
|
130
|
+
m: { s: 'metre', p: 'metres' },
|
|
131
|
+
cm: { s: 'centimetre', p: 'centimetres' },
|
|
132
|
+
mm: { s: 'millimetre', p: 'millimetres' },
|
|
133
|
+
mi: { s: 'mile', p: 'miles' },
|
|
134
|
+
kg: { s: 'kilogram', p: 'kilograms' },
|
|
135
|
+
g: { s: 'gram', p: 'grams' },
|
|
136
|
+
kb: { s: 'kilobyte', p: 'kilobytes' },
|
|
137
|
+
mb: { s: 'megabyte', p: 'megabytes' },
|
|
138
|
+
gb: { s: 'gigabyte', p: 'gigabytes' },
|
|
139
|
+
tb: { s: 'terabyte', p: 'terabytes' },
|
|
140
|
+
ghz: { s: 'gigahertz', p: 'gigahertz' },
|
|
141
|
+
mhz: { s: 'megahertz', p: 'megahertz' },
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Small spoken map for the most common emoji; everything else is dropped. */
|
|
145
|
+
const EMOJI_SPOKEN: Record<string, string> = {
|
|
146
|
+
'👍': 'thumbs up',
|
|
147
|
+
'👎': 'thumbs down',
|
|
148
|
+
'✅': 'done',
|
|
149
|
+
'❤️': 'love',
|
|
150
|
+
'⚠️': 'warning',
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Speak a URL's domain: "https://github.com/x/y" → "github dot com link". */
|
|
154
|
+
function spokenUrl(url: string): string {
|
|
155
|
+
const m = /^https?:\/\/(?:www\.)?([^/\s:?#]+)/i.exec(url)
|
|
156
|
+
if (!m) return 'a link'
|
|
157
|
+
const host = m[1]!
|
|
158
|
+
// Only verbalize simple dotted hostnames; an IP or userinfo-laden host
|
|
159
|
+
// reads badly — fall back to "a link".
|
|
160
|
+
if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(host) || /^\d+\.\d+\.\d+\.\d+$/.test(host)) {
|
|
161
|
+
return 'a link'
|
|
162
|
+
}
|
|
163
|
+
return `${host.toLowerCase().split('.').join(' dot ')} link`
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function parkInline(text: string): {
|
|
167
|
+
parked: string
|
|
168
|
+
parts: Array<{ idx: number; raw: string }>
|
|
169
|
+
} {
|
|
170
|
+
const parts: Array<{ idx: number; raw: string }> = []
|
|
171
|
+
const parked = text.replace(/`([^`\n]+)`/g, (_m, content: string) => {
|
|
172
|
+
const idx = parts.length
|
|
173
|
+
// Park the CONTENT (backticks already dropped) so the spoken output
|
|
174
|
+
// reads the identifier as-is and no later pass can rewrite it.
|
|
175
|
+
parts.push({ idx, raw: content })
|
|
176
|
+
return `${INLINE_PH}${idx}${NULL}`
|
|
177
|
+
})
|
|
178
|
+
return { parked, parts }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function restoreInline(text: string, parts: Array<{ idx: number; raw: string }>): string {
|
|
182
|
+
let restored = text
|
|
183
|
+
for (let i = parts.length - 1; i >= 0; i--) {
|
|
184
|
+
const p = parts[i]!
|
|
185
|
+
restored = restored.replace(`${INLINE_PH}${p.idx}${NULL}`, () => p.raw)
|
|
186
|
+
}
|
|
187
|
+
return restored
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Normalize reply text for TTS. Pure + deterministic; returns the input
|
|
192
|
+
* unchanged when the feature flag is off, the kill switch is on, or the
|
|
193
|
+
* input is empty.
|
|
194
|
+
*/
|
|
195
|
+
export function normalizeForTts(text: string): string {
|
|
196
|
+
if (!ttsNormalizeEnabled() || text.length === 0) return text
|
|
197
|
+
|
|
198
|
+
let s = text.replace(/\r\n?/g, '\n')
|
|
199
|
+
|
|
200
|
+
// -- Code fences → spoken placeholder (before anything can see contents).
|
|
201
|
+
s = s.replace(/(^|\n)[ \t]*(`{3,}|~{3,})[^\n]*\n[\s\S]*?\n[ \t]*\2[ \t]*(?=\n|$)/g, '$1code block omitted.')
|
|
202
|
+
s = s.replace(/(^|\n)[ \t]*(`{3,}|~{3,})[^\n]*\n[\s\S]*$/g, '$1code block omitted.')
|
|
203
|
+
|
|
204
|
+
// -- Park inline code (content spoken as-is, backticks gone).
|
|
205
|
+
const { parked, parts } = parkInline(s)
|
|
206
|
+
s = parked
|
|
207
|
+
s = s.replace(/`/g, '') // residual unpaired backticks
|
|
208
|
+
|
|
209
|
+
// -- Markdown tables → "table omitted." A table block = consecutive lines
|
|
210
|
+
// with pipes that include a separator row (|---|---|).
|
|
211
|
+
s = s.replace(
|
|
212
|
+
/(?:^|\n)((?:[ \t]*\|[^\n]*\n)?[ \t]*\|?[ \t]*:?-{2,}:?[ \t]*(?:\|[ \t]*:?-{2,}:?[ \t]*)+\|?[ \t]*(?:\n[ \t]*\|[^\n]*)*)/g,
|
|
213
|
+
'\ntable omitted.',
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
// -- Images / links → text; autolinks + bare URLs → spoken domain.
|
|
217
|
+
s = s.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
218
|
+
s = s.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
219
|
+
s = s.replace(/<(https?:\/\/[^>\s]+)>/gi, (_m, url: string) => spokenUrl(url))
|
|
220
|
+
s = s.replace(/\bhttps?:\/\/[^\s)]+/gi, (m) => spokenUrl(m))
|
|
221
|
+
|
|
222
|
+
// -- Emoji: speak the small common map, drop everything else.
|
|
223
|
+
for (const [emoji, spoken] of Object.entries(EMOJI_SPOKEN)) {
|
|
224
|
+
s = s.split(emoji).join(` ${spoken} `)
|
|
225
|
+
}
|
|
226
|
+
s = s.replace(
|
|
227
|
+
/[\u{1F000}-\u{1FAFF}\u{1F1E6}-\u{1F1FF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}\u{FE00}-\u{FE0F}\u{200D}\u{2B50}]/gu,
|
|
228
|
+
'',
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
// -- Emphasis markers (paired forms, longest first), strikethrough.
|
|
232
|
+
s = s.replace(/\*\*\*(.+?)\*\*\*/g, '$1')
|
|
233
|
+
s = s.replace(/___(.+?)___/g, '$1')
|
|
234
|
+
s = s.replace(/\*\*(.+?)\*\*/g, '$1')
|
|
235
|
+
s = s.replace(/__(.+?)__/g, '$1')
|
|
236
|
+
s = s.replace(/\*(.+?)\*/g, '$1')
|
|
237
|
+
s = s.replace(/(?<![A-Za-z0-9])_(.+?)_(?![A-Za-z0-9])/g, '$1')
|
|
238
|
+
s = s.replace(/~~(.+?)~~/g, '$1')
|
|
239
|
+
|
|
240
|
+
// -- Block markup per line: headings, blockquote '>' markers, list
|
|
241
|
+
// markers, horizontal rules.
|
|
242
|
+
s = s.replace(/^[ \t]{0,3}#{1,6}[ \t]+/gm, '')
|
|
243
|
+
s = s.replace(/^[ \t]{0,3}>[ \t]?/gm, '')
|
|
244
|
+
s = s.replace(/^[ \t]{0,3}[-*+][ \t]+/gm, '')
|
|
245
|
+
s = s.replace(/^[ \t]{0,3}\d+[.)][ \t]+/gm, '')
|
|
246
|
+
s = s.replace(/^[ \t]{0,3}([-_*])\1{2,}[ \t]*$/gm, '')
|
|
247
|
+
|
|
248
|
+
// -- ISO dates YYYY-MM-DD → "July fourth two thousand twenty-six".
|
|
249
|
+
// MUST run before the phone-run pass (an ISO date is 8 digits with
|
|
250
|
+
// hyphens and would otherwise be read digit-by-digit).
|
|
251
|
+
s = s.replace(/\b(\d{4})-(\d{2})-(\d{2})\b/g, (m, y: string, mo: string, da: string) => {
|
|
252
|
+
const month = Number(mo)
|
|
253
|
+
const day = Number(da)
|
|
254
|
+
if (month < 1 || month > 12 || day < 1 || day > 31) return m
|
|
255
|
+
const yw = yearToWords(Number(y))
|
|
256
|
+
const dw = ordinalToWords(day)
|
|
257
|
+
if (!yw || !dw) return m
|
|
258
|
+
return `${MONTHS[month]} ${dw} ${yw}`
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
// -- Phone-like digit runs → digit-by-digit. Fires ONLY on clearly
|
|
262
|
+
// phone-shaped tokens: a leading + (international), or a leading 0
|
|
263
|
+
// (national) with separator-delimited 2-4-digit grouping; 7-15
|
|
264
|
+
// digits total. Ordinary space-separated numbers ("1024 2048 4096")
|
|
265
|
+
// and short groupings without a phone prefix ("12 34 56 78") stay
|
|
266
|
+
// untouched.
|
|
267
|
+
s = s.replace(/(?<![\w.])(\+?\d[\d\- ]{6,}\d)(?![\w.])/g, (m: string) => {
|
|
268
|
+
const digits = m.replace(/\D/g, '')
|
|
269
|
+
if (digits.length < 7 || digits.length > 15) return m
|
|
270
|
+
const groups = m.replace(/^\+/, '').split(/[- ]+/)
|
|
271
|
+
const phoneShaped =
|
|
272
|
+
m.startsWith('+') ||
|
|
273
|
+
(m.startsWith('0') &&
|
|
274
|
+
groups.length >= 2 &&
|
|
275
|
+
groups.every((g) => /^\d{2,4}$/.test(g)))
|
|
276
|
+
if (!phoneShaped) return m
|
|
277
|
+
const words: string[] = []
|
|
278
|
+
if (m.startsWith('+')) words.push('plus')
|
|
279
|
+
for (const ch of digits) words.push(ONES[Number(ch)]!)
|
|
280
|
+
return words.join(' ')
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
// -- Times HH:MM (24h) → spoken. The (?!:?\d) guard skips HH:MM:SS
|
|
284
|
+
// entirely — a half-spoken time with a dangling ":46" reads worse
|
|
285
|
+
// than leaving the digits as-is.
|
|
286
|
+
s = s.replace(/\b([01]?\d|2[0-3]):([0-5]\d)(?!:?\d)/g, (_m, hh: string, mm: string) => {
|
|
287
|
+
const h = Number(hh)
|
|
288
|
+
const min = Number(mm)
|
|
289
|
+
const hw = belowThousand(h)
|
|
290
|
+
if (min === 0) return `${hw} o'clock`
|
|
291
|
+
if (min < 10) return `${hw} oh ${ONES[min]}`
|
|
292
|
+
return `${hw} ${belowThousand(min)}`
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
// -- Currency: $5 → "five dollars"; $5.20 → "five dollars twenty";
|
|
296
|
+
// $1,000 → "one thousand dollars" (thousands separators consumed).
|
|
297
|
+
// The trailing lookahead bails on odd-cents ("$5.203") and partial
|
|
298
|
+
// thousands ("$1,00") — the whole token is left unchanged rather
|
|
299
|
+
// than half-read.
|
|
300
|
+
s = s.replace(
|
|
301
|
+
/\$(\d{1,3}(?:,\d{3})+|\d{1,9})(?:\.(\d{2}))?(?![\d,]|\.\d)/g,
|
|
302
|
+
(m, dollarsRaw: string, cents?: string) => {
|
|
303
|
+
const dollars = Number(dollarsRaw.replace(/,/g, ''))
|
|
304
|
+
const dw = numberToWords(dollars)
|
|
305
|
+
if (!dw) return m
|
|
306
|
+
const noun = dollars === 1 && !cents ? 'dollar' : 'dollars'
|
|
307
|
+
if (cents && cents !== '00') {
|
|
308
|
+
const cw = numberToWords(Number(cents))
|
|
309
|
+
if (!cw) return m
|
|
310
|
+
return `${dw} ${noun} ${cw}`
|
|
311
|
+
}
|
|
312
|
+
return `${dw} ${noun}`
|
|
313
|
+
},
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
// -- Percentages: 12% → "twelve percent"; 3.5% keeps digits + "percent".
|
|
317
|
+
s = s.replace(/\b(\d{1,9})%/g, (m, n: string) => {
|
|
318
|
+
const w = numberToWords(Number(n))
|
|
319
|
+
return w ? `${w} percent` : m
|
|
320
|
+
})
|
|
321
|
+
s = s.replace(/(\d)\s*%/g, '$1 percent')
|
|
322
|
+
|
|
323
|
+
// -- Ordinals: 3rd → "third" (guarded to matching suffix only).
|
|
324
|
+
s = s.replace(/\b(\d{1,2})(st|nd|rd|th)\b/gi, (m, n: string, suffix: string) => {
|
|
325
|
+
const num = Number(n)
|
|
326
|
+
const correct =
|
|
327
|
+
(num % 10 === 1 && num % 100 !== 11 && suffix.toLowerCase() === 'st') ||
|
|
328
|
+
(num % 10 === 2 && num % 100 !== 12 && suffix.toLowerCase() === 'nd') ||
|
|
329
|
+
(num % 10 === 3 && num % 100 !== 13 && suffix.toLowerCase() === 'rd') ||
|
|
330
|
+
(suffix.toLowerCase() === 'th' &&
|
|
331
|
+
!((num % 10 === 1 && num % 100 !== 11) || (num % 10 === 2 && num % 100 !== 12) || (num % 10 === 3 && num % 100 !== 13)))
|
|
332
|
+
if (!correct) return m
|
|
333
|
+
const w = ordinalToWords(num)
|
|
334
|
+
return w ?? m
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
// -- Number + unit suffix glued to the number (500ms, 5km, 10GB). Only a
|
|
338
|
+
// known unit bounded by a non-letter, so identifiers never match.
|
|
339
|
+
s = s.replace(
|
|
340
|
+
/\b(\d{1,9})\s?(ms|sec|min|km|cm|mm|kg|kb|mb|gb|tb|ghz|mhz|hr|mi|s|m|h|d|g)(?![a-z])/gi,
|
|
341
|
+
(m, num: string, unitRaw: string) => {
|
|
342
|
+
const unit = UNIT_MAP[unitRaw.toLowerCase()]
|
|
343
|
+
if (!unit) return m
|
|
344
|
+
const n = Number(num)
|
|
345
|
+
const w = numberToWords(n)
|
|
346
|
+
if (!w) return m
|
|
347
|
+
return `${w} ${n === 1 ? unit.s : unit.p}`
|
|
348
|
+
},
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
// -- Symbols in prose.
|
|
352
|
+
s = s.replace(/(\s)&(\s)/g, '$1and$2')
|
|
353
|
+
s = s.replace(/(\w)&(\w)/g, '$1 and $2')
|
|
354
|
+
s = s.replace(/(^|\s)@(\w)/g, '$1at $2')
|
|
355
|
+
s = s.replace(/(^|\s)#(\w)/g, '$1hash $2')
|
|
356
|
+
// word/word in prose → "word slash word" (letters only, so paths/dates
|
|
357
|
+
// with digits are untouched).
|
|
358
|
+
s = s.replace(/\b([a-zA-Z]{2,})\/([a-zA-Z]{2,})\b/g, '$1 slash $2')
|
|
359
|
+
// ~ before a number → "about"; other tildes dropped.
|
|
360
|
+
s = s.replace(/~\s*(\d)/g, 'about $1')
|
|
361
|
+
s = s.replace(/~/g, '')
|
|
362
|
+
// Arrows → "to".
|
|
363
|
+
s = s.replace(/[=-]>/g, ' to ')
|
|
364
|
+
s = s.replace(/[→⇒]/g, ' to ')
|
|
365
|
+
|
|
366
|
+
// -- Restore parked inline-code contents verbatim.
|
|
367
|
+
s = restoreInline(s, parts)
|
|
368
|
+
|
|
369
|
+
// -- Whitespace → sentence flow.
|
|
370
|
+
s = s.replace(/[ \t]*\n[ \t]*\n[ \t]*/g, '. ')
|
|
371
|
+
s = s.replace(/\s*\n\s*/g, ' ')
|
|
372
|
+
s = s.replace(/[ \t]{2,}/g, ' ')
|
|
373
|
+
s = s.replace(/\.\s*\.(\s|$)/g, '.$1')
|
|
374
|
+
s = s.replace(/\s+([,.!?;:])/g, '$1')
|
|
375
|
+
|
|
376
|
+
return s.trim()
|
|
377
|
+
}
|