@toddzheng024/dscode-bundle 0.7.21 → 0.7.23

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.
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Live cross-session communication feed: a bounded, display-only projection of
3
+ * the root session's session-bridge traffic. The transcript store folds tool
4
+ * cards and hides their arguments, so a `send_session` that is waiting on
5
+ * another session looks exactly like any other tool call; this module keeps the
6
+ * direction, the peer, the delivery mode and the outstanding request visible.
7
+ *
8
+ * Two event families feed it, both already durable:
9
+ * - `tool/call` / `tool/result` for `send_session` and `reply_session`, with
10
+ * the call arguments carrying target, kind and mode (a result carries no such
11
+ * fields, so a failing call is reported only by its paired error).
12
+ * - `user/message` relayed by the session bridge, which is an inbound message
13
+ * from another session or an external source.
14
+ *
15
+ * Rows are advisory display state rebuilt from events; nothing here persists or
16
+ * replays, and a resume re-derives the history from the log.
17
+ *
18
+ * @module @deepseek-ai/dsh-code/communication
19
+ */
20
+ /** Hard row cap for `/tasks`; the newest rows survive. */
21
+ export const MAX_COMMUNICATION_ROWS = 32;
22
+ /** Bound on outstanding requests tracked at once (display state, not a budget). */
23
+ const MAX_WAITING_REQUESTS = 16;
24
+ /** Bounded body preview, matching the activity line's display budget. */
25
+ const MAX_PREVIEW_CHARS = 72;
26
+ /** The plugin name the session bridge stamps on its relayed messages. */
27
+ const BRIDGE_PLUGIN = 'dscode-session-bridge';
28
+ /** Tool names that address another session. */
29
+ const SEND_TOOLS = new Set(['send_session', 'reply_session']);
30
+ /**
31
+ * One peer identity across both directions. Outbound rows carry the bare
32
+ * session id from the tool arguments; an inbound relay's bridge label is
33
+ * `session:<id>`, so the prefix is stripped before they are compared.
34
+ */
35
+ function peerKey(peer) {
36
+ return peer.startsWith('session:') ? peer.slice('session:'.length) : peer;
37
+ }
38
+ /** Fold one tool-call argument JSON string into the fields a row needs. */
39
+ function callFields(raw) {
40
+ if (typeof raw !== 'string' || raw.length > 8192)
41
+ return { preview: '' };
42
+ let parsed;
43
+ try {
44
+ parsed = JSON.parse(raw);
45
+ }
46
+ catch {
47
+ return { preview: '' };
48
+ }
49
+ if (parsed === null || typeof parsed !== 'object')
50
+ return { preview: '' };
51
+ const fields = parsed;
52
+ const text = (value) => (typeof value === 'string' ? value.replace(/\s+/gu, ' ').trim() : '');
53
+ const peer = text(fields.session_id) || text(fields.request_message_id);
54
+ const preview = text(fields.text);
55
+ return {
56
+ ...(peer === '' ? {} : { peer }),
57
+ ...(text(fields.kind) === '' ? {} : { kind: text(fields.kind) }),
58
+ ...(text(fields.mode) === '' ? {} : { mode: text(fields.mode) }),
59
+ preview: preview.length > MAX_PREVIEW_CHARS ? `${preview.slice(0, MAX_PREVIEW_CHARS - 1)}…` : preview,
60
+ };
61
+ }
62
+ /**
63
+ * One relayed message's peer, message kind and bounded text. The bridge stamps
64
+ * the kind only inside the body header (`[External source: …] [kind/mode]`),
65
+ * which is its documented presentation contract, so an unrecognised header
66
+ * leaves the kind undefined and the caller stays conservative: an unknown
67
+ * inbound message never counts as an answer.
68
+ */
69
+ function relayFields(data) {
70
+ if (data === null || typeof data !== 'object')
71
+ return undefined;
72
+ const message = data;
73
+ const source = message.source;
74
+ if (source === null || typeof source !== 'object')
75
+ return undefined;
76
+ const stamp = source;
77
+ if (stamp.kind !== 'plugin' || stamp.plugin !== BRIDGE_PLUGIN || stamp.form !== 'relay')
78
+ return undefined;
79
+ const label = typeof stamp.label === 'string' && stamp.label !== '' ? stamp.label : undefined;
80
+ const id = typeof stamp.communicationId === 'string' && stamp.communicationId !== '' ? stamp.communicationId : undefined;
81
+ if (label === undefined && id === undefined)
82
+ return undefined;
83
+ const texts = [];
84
+ if (Array.isArray(message.content)) {
85
+ for (const block of message.content) {
86
+ if (typeof block === 'object' && block !== null) {
87
+ const { type, text } = block;
88
+ if (type === 'text' && typeof text === 'string')
89
+ texts.push(text);
90
+ }
91
+ }
92
+ }
93
+ const body = texts.join(' ').replace(/\s+/gu, ' ').trim();
94
+ // The body opens with the bridge's own header line (`[External source: …]
95
+ // [kind/mode]` plus the message id); the preview is the message under it.
96
+ const kind = body.match(/\[([a-z]+)\/[a-z]+\]/u)?.[1];
97
+ const joinLines = texts.join('\n');
98
+ const joined = joinLines
99
+ .replace(/^\[External source:[^\]]*\](\s*\[[a-z]+\/[a-z]+\])?\s*/u, '')
100
+ .replace(/^Message ID:[^\n]*\n?/u, '')
101
+ .replace(/\s+/gu, ' ')
102
+ .trim();
103
+ return {
104
+ peer: label ?? id ?? 'unknown',
105
+ ...(kind === undefined ? {} : { kind }),
106
+ preview: joined.length > MAX_PREVIEW_CHARS ? `${joined.slice(0, MAX_PREVIEW_CHARS - 1)}…` : joined,
107
+ };
108
+ }
109
+ /**
110
+ * Fold one root-session event into the feed, returning the same view identity
111
+ * when the event is not communication traffic.
112
+ * @param view - the current view.
113
+ * @param event - one session event from the root session.
114
+ * @returns the next view, or the same object when nothing changed.
115
+ */
116
+ export function foldCommunication(view, event) {
117
+ if (event.type === 'tool/call') {
118
+ const data = event.data;
119
+ if (!SEND_TOOLS.has(data.name))
120
+ return view;
121
+ const fields = callFields(data.arguments);
122
+ const row = {
123
+ id: outboundId(data.callId),
124
+ direction: 'sent',
125
+ peer: fields.peer ?? 'unknown',
126
+ ...(fields.kind === undefined ? {} : { kind: fields.kind }),
127
+ ...(fields.mode === undefined ? {} : { mode: fields.mode }),
128
+ preview: fields.preview,
129
+ at: event.time,
130
+ pending: true,
131
+ failed: false,
132
+ };
133
+ const rows = bound([...view.rows, row]);
134
+ // A send in flight is not an outstanding request yet: it becomes one when
135
+ // the call settles and the message was a request.
136
+ return { rows, waiting: view.waiting };
137
+ }
138
+ if (event.type === 'tool/result') {
139
+ const data = event.data;
140
+ const callId = resultCallId(data);
141
+ if (callId === undefined)
142
+ return view;
143
+ const id = outboundId(callId);
144
+ const index = view.rows.findIndex(row => row.id === id);
145
+ if (index === -1)
146
+ return view;
147
+ const settled = { ...view.rows[index], pending: false, failed: resultFailed(data) };
148
+ const rows = view.rows.map((row, at) => (at === index ? settled : row));
149
+ const answers = settled.kind === 'request' && !settled.failed;
150
+ return {
151
+ rows,
152
+ waiting: answers ? markWaiting(view.waiting, settled) : view.waiting,
153
+ };
154
+ }
155
+ if (event.type === 'user/message') {
156
+ const relay = relayFields(event.data);
157
+ if (relay === undefined)
158
+ return view;
159
+ const row = { id: inboundId(event.data, event.seq), direction: 'received', ...(relay.kind === undefined ? {} : { kind: relay.kind }), peer: relay.peer, preview: relay.preview, at: event.time, pending: false, failed: false };
160
+ return {
161
+ rows: bound([...view.rows, row]),
162
+ // Only the single final reply answers a request; a bare notify from the
163
+ // same peer must not clear a turn that is still blocked on an answer.
164
+ waiting: relay.kind === 'reply' ? clearWaiting(view.waiting, row.peer) : view.waiting,
165
+ };
166
+ }
167
+ return view;
168
+ }
169
+ /** Outbound row identity, namespaced so an inbound id can never collide with it. */
170
+ function outboundId(callId) {
171
+ return `sent:${callId}`;
172
+ }
173
+ /** Inbound row identity, namespaced and falling back to the event sequence. */
174
+ function inboundId(data, seq) {
175
+ return `recv:${relayId(data) ?? seq}`;
176
+ }
177
+ /**
178
+ * Record one settled request as awaiting its answer. Keyed by the outbound row,
179
+ * not by peer: a session may have two requests open to one session, and a
180
+ * peer-keyed entry would let the second send overwrite the first.
181
+ */
182
+ function markWaiting(waiting, request) {
183
+ if (waiting.some(entry => entry.id === request.id))
184
+ return waiting;
185
+ const next = [...waiting, { id: request.id, peer: request.peer, since: request.at }];
186
+ return next.length <= MAX_WAITING_REQUESTS ? next : next.slice(next.length - MAX_WAITING_REQUESTS);
187
+ }
188
+ /**
189
+ * Clear the outstanding requests a reply answers. One reply answers one of the
190
+ * requests sent to that peer; without a request-id correlation in the relay,
191
+ * every open request to that peer retires together, which is the conservative
192
+ * direction — it never leaves the terminal claiming to await an answered peer.
193
+ */
194
+ function clearWaiting(waiting, peer) {
195
+ const next = waiting.filter(entry => peerKey(entry.peer) !== peerKey(peer));
196
+ return next.length === waiting.length ? waiting : next;
197
+ }
198
+ /** Keep only the newest rows. */
199
+ function bound(rows) {
200
+ return rows.length <= MAX_COMMUNICATION_ROWS ? rows : rows.slice(rows.length - MAX_COMMUNICATION_ROWS);
201
+ }
202
+ /** The peer of a reply, which the tool result does not name: the call it answers. */
203
+ function resultCallId(data) {
204
+ if (!Array.isArray(data.message?.content))
205
+ return undefined;
206
+ for (const block of data.message.content) {
207
+ if (typeof block === 'object' && block !== null) {
208
+ const { toolCallId } = block;
209
+ if (typeof toolCallId === 'string')
210
+ return toolCallId;
211
+ }
212
+ }
213
+ return undefined;
214
+ }
215
+ /** True when the tool result's block carries `isError`. */
216
+ function resultFailed(data) {
217
+ if (!Array.isArray(data.message?.content))
218
+ return false;
219
+ return data.message.content.some(block => typeof block === 'object' && block !== null && block.isError === true);
220
+ }
221
+ /** The relay's stable message identity, when the bridge stamped one. */
222
+ function relayId(data) {
223
+ const source = data?.source;
224
+ return typeof source?.communicationId === 'string' && source.communicationId !== '' ? source.communicationId : undefined;
225
+ }
226
+ /**
227
+ * Render the `/tasks` listing: one line per communication, newest last, with
228
+ * the same arrows the activity line and the notices use. A request that is
229
+ * still open is marked so a blocked turn is readable without opening anything.
230
+ * @param view - the folded view.
231
+ * @param limit - the newest rows to show.
232
+ * @returns the panel text, or a short explanation when there is no traffic.
233
+ */
234
+ export function communicationPanel(view, limit = 20) {
235
+ if (view.rows.length === 0)
236
+ return 'No cross-session messages in this session.';
237
+ const waiting = new Set(view.waiting.map(entry => peerKey(entry.peer)));
238
+ const shown = view.rows.slice(-limit);
239
+ const lines = shown.map(row => {
240
+ const arrow = row.direction === 'received' ? '←' : '→';
241
+ const state = row.direction === 'received' ? 'received' : row.failed ? 'failed' : row.pending ? 'sending' : waiting.has(peerKey(row.peer)) ? 'awaiting reply' : 'sent';
242
+ const kind = row.kind === undefined ? '' : ` ${row.kind}`;
243
+ const mode = row.mode === undefined ? '' : `/${row.mode}`;
244
+ const text = row.preview === '' ? '' : ` — ${row.preview}`;
245
+ return `${arrow} ${row.peer}${kind}${mode} · ${state}${text}`;
246
+ });
247
+ const hidden = view.rows.length - shown.length;
248
+ return [
249
+ ...(hidden > 0 ? [`(${hidden} older message${hidden === 1 ? '' : 's'} hidden)`] : []),
250
+ ...lines,
251
+ ].join('\n');
252
+ }
253
+ /**
254
+ * Build the empty feed and its folder.
255
+ * @returns a feed that folds one event at a time through {@link foldCommunication}.
256
+ * @example
257
+ * let view = createCommunicationFeed()
258
+ * view = foldCommunication(view, event)
259
+ */
260
+ export function createCommunicationFeed() {
261
+ return { rows: [], waiting: [] };
262
+ }
@@ -41,6 +41,40 @@ function thinkingLines(reasoning, width) {
41
41
  ? lines
42
42
  : [...lines.slice(0, cap), ...textLines(' … ' + (lines.length - cap) + ' more lines · Ctrl+O opens the full history', width, 'dim')];
43
43
  }
44
+ /**
45
+ * Memoize the live-region render by ENTRY IDENTITY. Streaming carried the whole
46
+ * live region into a re-render per frame (`view.entries` changes identity on
47
+ * every event), so the same settled-but-unflushed entries were re-wrapped from
48
+ * scratch ~60 times a second while only the streaming text — which lives in
49
+ * `view.streaming`, not in an entry — actually changed. The projection replaces
50
+ * an entry with a new object whenever its content changes (`{...entry}` at every
51
+ * mutation site), so identity is a sound cache key, and the wrap depends only on
52
+ * the entry, the column budget and `verbose`.
53
+ * @returns a `(entry, columns, verbose) => lines` function with a WeakMap cache.
54
+ */
55
+ export function createChatLinesCache() {
56
+ // Per wrap context: a width or `verbose` change re-wraps every row. Keying
57
+ // the maps instead of resetting one map keeps interleaved contexts (two
58
+ // regions rendering at different widths in one frame) from thrashing.
59
+ const contexts = new Map();
60
+ return (entry, columns, verbose = false) => {
61
+ const width = Math.max(1, Math.floor(columns));
62
+ const key = `${width}:${verbose ? 1 : 0}`;
63
+ let cache = contexts.get(key);
64
+ if (cache === undefined) {
65
+ cache = new WeakMap();
66
+ contexts.set(key, cache);
67
+ }
68
+ const hit = cache.get(entry);
69
+ if (hit !== undefined)
70
+ return hit;
71
+ // The wrapped width is the same normalized value the key names, so a
72
+ // fractional column count cannot key one width and wrap at another.
73
+ const lines = dscodeChatLines(entry, width, verbose);
74
+ cache.set(entry, lines);
75
+ return lines;
76
+ };
77
+ }
44
78
  /**
45
79
  * Render one transcript entry the DSCODE way: tool rows are quiet unless verbose
46
80
  * is on, assistant rows fold their thinking above the answer, and user prompts
@@ -12,14 +12,14 @@
12
12
  export function dscodeFilterModels(rows, query, provider) {
13
13
  // One provider at a time: the picker lists the routes the session can actually select.
14
14
  const directory = provider === void 0 ? rows : rows.filter(row => row.provider === provider);
15
- const label = row => String(row.modelName ?? row.model ?? "");
15
+ const label = (row) => String(row.modelName ?? row.model ?? "");
16
16
  // Display order is the label itself, so the list reads alphabetically and digits inside a
17
17
  // name compare naturally ("GLM 5.2" before "GLM 5.3"); searching narrows rows, never re-ranks them.
18
18
  const byLabel = (left, right) => label(left).localeCompare(label(right), void 0, {
19
19
  numeric: true,
20
20
  sensitivity: "base"
21
21
  }) || String(left.model ?? "").localeCompare(String(right.model ?? "")) || String(left.provider ?? "").localeCompare(String(right.provider ?? ""));
22
- const tokenize = text => String(text ?? '').normalize('NFKC').toLowerCase().match(/[a-z]+|[0-9]+|[^\s\x00-\x7f]+/g) ?? [];
22
+ const tokenize = (text) => String(text ?? '').normalize('NFKC').toLowerCase().match(/[a-z]+|[0-9]+|[^\s\x00-\x7f]+/g) ?? [];
23
23
  const raw = String(query ?? '');
24
24
  const words = tokenize(raw);
25
25
  if (words.length === 0)
@@ -0,0 +1,100 @@
1
+ /**
2
+ * dscode: the command palette's memory. The completion menu ranks by where a
3
+ * name matches; the palette additionally remembers what this user actually
4
+ * runs, so the third time `/review` is opened it is already the first row.
5
+ *
6
+ * Usage lives in one user-level JSON file (`~/.dsh/dsh-code/palette.json`),
7
+ * written through the same crash-atomic chain as the statusline file. A missing
8
+ * file is an empty history and a corrupt one is treated the same way instead of
9
+ * failing the TUI: losing the sort order must never cost a session.
10
+ *
11
+ * @module @deepseek-ai/dsh-code/dscode/palette
12
+ */
13
+ import { rankByName } from '../render/fuzzy.mjs';
14
+ /** A description longer than this is clipped in the row, never dropped. */
15
+ const MAX_DESCRIPTION = 160;
16
+ /** Parse the persisted usage object; anything unrecognized is dropped. */
17
+ export function parsePaletteUsage(raw) {
18
+ if (raw === null || typeof raw !== 'object')
19
+ return {};
20
+ const source = raw.usage;
21
+ if (source === null || typeof source !== 'object' || Array.isArray(source))
22
+ return {};
23
+ const usage = {};
24
+ for (const [name, value] of Object.entries(source)) {
25
+ if (name === '' || value === null || typeof value !== 'object')
26
+ continue;
27
+ const entry = value;
28
+ const lastUsed = typeof entry.lastUsed === 'number' && Number.isFinite(entry.lastUsed) ? entry.lastUsed : 0;
29
+ const uses = typeof entry.uses === 'number' && Number.isFinite(entry.uses) && entry.uses > 0 ? Math.floor(entry.uses) : 0;
30
+ if (uses === 0)
31
+ continue;
32
+ usage[name] = { lastUsed, uses };
33
+ }
34
+ return usage;
35
+ }
36
+ /** Serialize the usage map back to the file's shape. */
37
+ export function serializePaletteUsage(usage) {
38
+ const sorted = {};
39
+ // Stable key order keeps the file readable and diffable across sessions.
40
+ for (const name of Object.keys(usage).sort())
41
+ sorted[name] = usage[name];
42
+ return JSON.stringify({ usage: sorted }, null, 2) + '\n';
43
+ }
44
+ /** Record one use: the newest timestamp wins, the count accumulates. */
45
+ export function recordPaletteUse(usage, name, now = Date.now()) {
46
+ if (name === '')
47
+ return usage;
48
+ const previous = usage[name];
49
+ return { ...usage, [name]: { lastUsed: now, uses: (previous?.uses ?? 0) + 1 } };
50
+ }
51
+ /**
52
+ * Usage weight for sorting. Recency and frequency trade off on a square root,
53
+ * so a single fresh run overtakes an old favorite but a well-worn command does
54
+ * not sink the moment another one is used once.
55
+ */
56
+ function weight(entry, now) {
57
+ if (entry === undefined || entry.uses <= 0)
58
+ return 0;
59
+ const ageDays = Math.max(0, now - entry.lastUsed) / 86400000;
60
+ return Math.sqrt(entry.uses) / (1 + ageDays);
61
+ }
62
+ /**
63
+ * Every runnable row for the palette, before a query.
64
+ *
65
+ * User-invocable skills are included: the palette is the one place a skill that
66
+ * the model cannot invoke is still reachable by hand, so hiding it here would
67
+ * make those skills undiscoverable. Model-only skills keep their full
68
+ * description so the row states which is which.
69
+ */
70
+ export function paletteEntries(local, descriptors, skills) {
71
+ const rows = [];
72
+ const seen = new Set();
73
+ const push = (name, description, origin) => {
74
+ if (name === '' || seen.has(name))
75
+ return;
76
+ seen.add(name);
77
+ rows.push({ name, label: `/${name}`, description: description.slice(0, MAX_DESCRIPTION), origin });
78
+ };
79
+ // Local commands shadow the registry (the same precedence dispatch uses).
80
+ for (const command of local)
81
+ push(command.name, command.description, 'command');
82
+ for (const descriptor of descriptors)
83
+ push(descriptor.name, descriptor.description, 'command');
84
+ for (const skill of skills)
85
+ push(skill.name, `skill · ${skill.description}`, 'skill');
86
+ return rows;
87
+ }
88
+ /**
89
+ * Rank rows for one query: fuzzy match first, then the usage weight, then the
90
+ * source order. An empty query is the personal order — most-used, most-recent
91
+ * commands first — with the untouched catalog keeping its declared order.
92
+ */
93
+ export function rankPalette(entries, query, usage, now = Date.now()) {
94
+ // rankByName keeps the source order for equally good matches; stamping each
95
+ // row once lets the usage sort stay stable without an indexOf per comparison.
96
+ const matched = rankByName(entries, query).map((entry, order) => ({ entry, order }));
97
+ return matched
98
+ .sort((left, right) => weight(usage[right.entry.name], now) - weight(usage[left.entry.name], now) || left.order - right.order)
99
+ .map(item => item.entry);
100
+ }
@@ -32,7 +32,7 @@ export function dscodeCacheTone(rate) {
32
32
  return 'blue';
33
33
  }
34
34
  /** Every label the cache figure can carry, across the shipped interface languages. */
35
- const DSCode_CACHE_LABELS = ['cache hit', '缓存命中', '快取命中', 'キャッシュヒット', '캐시 적중', 'éxitos de caché'];
35
+ const DSCode_CACHE_LABELS = ['cache', '缓存', '快取', 'キャッシュ', '캐시', 'caché'];
36
36
  /** The ink colour for a tier, theme-aware for the two ends of the scale. */
37
37
  export function dscodeTpsInkColor(tone) {
38
38
  const palette = getPalette();
@@ -54,22 +54,29 @@ export function dscodeTpsInkColor(tone) {
54
54
  */
55
55
  export function dscodeTelemetryParts(value) {
56
56
  const result = [];
57
- const parts = String(value).split(' | ');
57
+ const parts = String(value).split(' · ');
58
58
  for (const [index, part] of parts.entries()) {
59
59
  if (index > 0)
60
- result.push({ text: ' | ', tone: null });
61
- const match = /^(.+?: )(~?(?:\d+(?:\.\d+)?|--)) tps$/.exec(part);
62
- if (match) {
63
- const display = match[2];
64
- const rate = Number(display.startsWith('~') ? display.slice(1) : display);
65
- result.push({ text: match[1], tone: null }, { text: display + ' tps', tone: display ? dscodeTpsTone(rate) : null });
60
+ result.push({ text: ' · ', tone: null });
61
+ // A rate figure reserves its columns, so the padding stays outside the tinted
62
+ // run and only the reading carries the tier.
63
+ const rate = /^(\s*)(~?(?:\d+(?:\.\d+)?|--)) tps( \S+)?$/.exec(part);
64
+ if (rate) {
65
+ const display = rate[2];
66
+ result.push({ text: rate[1], tone: null }, { text: display + ' tps', tone: dscodeTpsTone(Number(display.startsWith('~') ? display.slice(1) : display)) });
67
+ // The average's trailing qualifier rides along untinted.
68
+ if (rate[3] !== undefined)
69
+ result.push({ text: rate[3], tone: null });
66
70
  continue;
67
71
  }
68
- // Only a recognised cache label at the very end of the line is tinted.
69
- const labelled = index === parts.length - 1 ? /^(.*?)(\d+(?:\.\d+)?)%$/.exec(part) : null;
70
- const cache = labelled && DSCode_CACHE_LABELS.includes(labelled[1].replace(/:\s*$/, '').trim().toLowerCase()) ? labelled : null;
72
+ // Only a recognised cache label on the very last figure is tinted; the label
73
+ // itself and every separator keep the surrounding colour.
74
+ const labelled = index === parts.length - 1 ? /^(\s*)(\d+(?:\.\d+)?|--)%( \S+)$/.exec(part) : null;
75
+ const cache = labelled && DSCode_CACHE_LABELS.includes(labelled[3].trim().toLowerCase()) ? labelled : null;
71
76
  if (cache) {
72
77
  result.push({ text: cache[1], tone: null }, { text: cache[2] + '%', tone: dscodeCacheTone(Number(cache[2])) });
78
+ if (cache[3] !== undefined)
79
+ result.push({ text: cache[3], tone: null });
73
80
  continue;
74
81
  }
75
82
  result.push({ text: part, tone: null });