@vzn/vx 0.0.623 → 0.0.624

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.623",
3
+ "version": "0.0.624",
4
4
  "description": "The fastest task runner and build cache for JavaScript monorepos.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,10 +21,10 @@
21
21
  "node": ">=18"
22
22
  },
23
23
  "optionalDependencies": {
24
- "@vzn/vx-linux-x64": "0.0.623",
25
- "@vzn/vx-linux-arm64": "0.0.623",
26
- "@vzn/vx-darwin-x64": "0.0.623",
27
- "@vzn/vx-darwin-arm64": "0.0.623"
24
+ "@vzn/vx-linux-x64": "0.0.624",
25
+ "@vzn/vx-linux-arm64": "0.0.624",
26
+ "@vzn/vx-darwin-x64": "0.0.624",
27
+ "@vzn/vx-darwin-arm64": "0.0.624"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -0,0 +1,139 @@
1
+ // How long the rest of a live run should take, from each task's executed
2
+ // p50 in local history. A display aid for the live region only: it never
3
+ // steers the scheduler and never reaches the final summary.
4
+
5
+ import { isGroupTask, type TaskNode } from '../graph/index.js'
6
+
7
+ /** What the live region says about the rest of a run. */
8
+ export interface LiveForecast {
9
+ /** Remaining wall ms, absent when history knows none of what is left. */
10
+ etaMs?: number
11
+ /** The running task at the head of the longest unfinished chain. */
12
+ critical?: string
13
+ /** Each running task's unfinished dependents, direct and transitive. */
14
+ blocks: ReadonlyMap<string, number>
15
+ }
16
+
17
+ export type Forecast = (
18
+ finished: ReadonlySet<string>,
19
+ running: ReadonlyMap<string, number>,
20
+ nowMs: number,
21
+ ) => LiveForecast
22
+
23
+ /** How far past its p50 a running task may go before the forecast gives up. */
24
+ const OVERRUN = 1.25
25
+
26
+ /**
27
+ * The later of the longest unfinished chain and the unfinished work spread
28
+ * over the workers. A task still to run costs its p50 (a pending task may
29
+ * yet hit, so this leans long, and the hits finish first); a running one
30
+ * costs what its p50 has left, never below zero; a group or a task with no
31
+ * history costs nothing. A running task well past its p50 means history
32
+ * no longer describes this run, and the forecast says nothing of time.
33
+ */
34
+ export function createForecast(
35
+ nodes: ReadonlyMap<string, TaskNode>,
36
+ p50s: ReadonlyMap<string, number>,
37
+ concurrency: number,
38
+ ): Forecast {
39
+ // Dependencies before dependents, once: each call is one pass over it.
40
+ const order: TaskNode[] = []
41
+ const placed = new Set<string>()
42
+ const place = (n: TaskNode): void => {
43
+ const stack: Array<[TaskNode, number]> = [[n, 0]]
44
+ while (stack.length > 0) {
45
+ const top = stack[stack.length - 1]!
46
+ const [node, i] = top
47
+ if (placed.has(node.id)) {
48
+ stack.pop()
49
+ continue
50
+ }
51
+ if (i < node.deps.length) {
52
+ top[1] = i + 1
53
+ const dep = nodes.get(node.deps[i]!)
54
+ if (dep !== undefined && !placed.has(dep.id)) stack.push([dep, 0])
55
+ continue
56
+ }
57
+ placed.add(node.id)
58
+ order.push(node)
59
+ stack.pop()
60
+ }
61
+ }
62
+ for (const n of nodes.values()) place(n)
63
+ const cost = new Map<string, number>()
64
+ for (const n of order) {
65
+ const p = isGroupTask(n) ? undefined : p50s.get(n.id)
66
+ if (p !== undefined) cost.set(n.id, p)
67
+ }
68
+ const workers = Math.max(1, concurrency)
69
+
70
+ const dependents = new Map<string, string[]>()
71
+ for (const n of order)
72
+ for (const d of n.deps) {
73
+ const list = dependents.get(d)
74
+ if (list === undefined) dependents.set(d, [n.id])
75
+ else list.push(n.id)
76
+ }
77
+
78
+ const blocked = (id: string, finished: ReadonlySet<string>): number => {
79
+ const seen = new Set<string>()
80
+ const stack = [id]
81
+ while (stack.length > 0) {
82
+ for (const next of dependents.get(stack.pop()!) ?? []) {
83
+ if (seen.has(next) || finished.has(next)) continue
84
+ seen.add(next)
85
+ stack.push(next)
86
+ }
87
+ }
88
+ return seen.size
89
+ }
90
+
91
+ return (finished, running, nowMs) => {
92
+ const blocks = new Map<string, number>()
93
+ for (const id of running.keys()) blocks.set(id, blocked(id, finished))
94
+ const end = new Map<string, number>()
95
+ // The running task each node's longest chain waits on.
96
+ const head = new Map<string, string>()
97
+ let chain = 0
98
+ let chainEnd: string | undefined
99
+ let work = 0
100
+ let known = false
101
+ let overrun = false
102
+ for (const n of order) {
103
+ if (finished.has(n.id)) continue
104
+ let start = 0
105
+ let via: string | undefined
106
+ for (const d of n.deps) {
107
+ const e = end.get(d)
108
+ if (e !== undefined && (via === undefined || e > start)) {
109
+ start = e
110
+ via = d
111
+ }
112
+ }
113
+ const since = running.get(n.id)
114
+ if (since !== undefined) head.set(n.id, n.id)
115
+ else if (via !== undefined && head.has(via)) head.set(n.id, head.get(via)!)
116
+ const p = cost.get(n.id)
117
+ let own = 0
118
+ if (p !== undefined) {
119
+ known = true
120
+ if (since !== undefined && nowMs - since > p * OVERRUN) overrun = true
121
+ own = since === undefined ? p : Math.max(0, p - (nowMs - since))
122
+ }
123
+ const e = start + own
124
+ end.set(n.id, e)
125
+ if (e > chain || chainEnd === undefined) {
126
+ chain = e
127
+ chainEnd = n.id
128
+ }
129
+ work += own
130
+ }
131
+ if (!known || overrun) return { blocks }
132
+ const critical = chainEnd === undefined ? undefined : head.get(chainEnd)
133
+ return {
134
+ etaMs: Math.max(chain, work / workers),
135
+ ...(critical !== undefined ? { critical } : {}),
136
+ blocks,
137
+ }
138
+ }
139
+ }
@@ -31,6 +31,13 @@ import {
31
31
  } from '../util/index.js'
32
32
  import { isCacheHit } from './telemetry.js'
33
33
  import { failedLabel, outcomeWord, ranNoCache } from './events.js'
34
+ import type { Forecast } from './forecast.js'
35
+ import {
36
+ notifyEscape,
37
+ PROGRESS_CLEAR,
38
+ progressEscape,
39
+ terminalSignals,
40
+ } from './terminal-signals.js'
34
41
 
35
42
  export interface Logger {
36
43
  /** Header / footer / status text. Written verbatim, one trailing \n added. */
@@ -71,6 +78,12 @@ export interface DefaultLogger extends Logger {
71
78
  * throw that never reached `runEnd`.
72
79
  */
73
80
  settle(): void
81
+ /**
82
+ * Hand the live region a way to predict the run's end. Called once at
83
+ * run start; `load` runs only if the region is still live a second in,
84
+ * so a run that ends sooner never reads history for it.
85
+ */
86
+ forecast(load: () => Promise<Forecast>): void
74
87
  }
75
88
 
76
89
  /**
@@ -168,6 +181,11 @@ export function resolveOutputView(
168
181
  return mk('full')
169
182
  }
170
183
 
184
+ /** A run shorter than this never reads history for a forecast. */
185
+ const FORECAST_AFTER_MS = 1000
186
+ /** A run shorter than this ends without a desktop notification. */
187
+ const NOTIFY_AFTER_MS = 10_000
188
+
171
189
  export function defaultLogger(
172
190
  colors: ColorSupport = detectColors(),
173
191
  view: OutputView = { mode: 'full' },
@@ -178,6 +196,8 @@ export function defaultLogger(
178
196
  tty?: boolean
179
197
  /** The args after `--`: a requested task's `$ <command>` line shows them, as it ran. */
180
198
  forwardArgs?: readonly string[]
199
+ /** The environment that names the terminal (`TERM_PROGRAM`, …); `process.env` by default. */
200
+ env?: Readonly<Record<string, string | undefined>>
181
201
  } = {},
182
202
  ): DefaultLogger {
183
203
  const forwardArgs = opts.forwardArgs ?? []
@@ -266,6 +286,20 @@ export function defaultLogger(
266
286
  let startedAtMs = Date.now()
267
287
  let ticker: ReturnType<typeof setInterval> | null = null
268
288
  let statusDead = !writer.enabled
289
+ // The live run's forecast (live region only). Finished ids feed it; the
290
+ // set is kept only where a region can show the answer.
291
+ const finished = new Set<string>()
292
+ let forecastFn: Forecast | undefined
293
+ let forecastTimer: ReturnType<typeof setTimeout> | null = null
294
+ let etaMs: number | undefined
295
+ let critical: string | undefined
296
+ let blocks: ReadonlyMap<string, number> | undefined
297
+ let etaAtMs = 0
298
+ const signals = writer.enabled
299
+ ? terminalSignals(opts.env ?? process.env)
300
+ : { progress: false, notify: false }
301
+ let progressShown = -1
302
+ let progressRed = false
269
303
 
270
304
  // Every interactive view renders the fixed-height worker region:
271
305
  // one row per worker slot so a task's name never moves while it
@@ -357,6 +391,32 @@ export function defaultLogger(
357
391
 
358
392
  const refresh = (force: boolean): void => {
359
393
  if (statusDead) return
394
+ const now = Date.now()
395
+ // Twice a second at most: one pass over the unfinished graph.
396
+ if (forecastFn !== undefined && (force || now - etaAtMs >= 500)) {
397
+ etaAtMs = now
398
+ const running = new Map<string, number>()
399
+ for (const s of slots) if (s !== null) running.set(s.id, s.startedMs)
400
+ for (const s of slotQueue) running.set(s.id, s.startedMs)
401
+ const f = forecastFn(finished, running, now)
402
+ etaMs = f.etaMs
403
+ critical = f.critical
404
+ blocks = f.blocks
405
+ }
406
+ if (signals.progress && total > 0) {
407
+ const elapsed = now - startedAtMs
408
+ const pct = Math.floor(
409
+ etaMs !== undefined && elapsed + etaMs > 0
410
+ ? (elapsed / (elapsed + etaMs)) * 100
411
+ : (done / total) * 100,
412
+ )
413
+ // Never backwards: a forecast that gives up falls back to the count.
414
+ if (pct > progressShown || failed > 0 !== progressRed) {
415
+ progressShown = Math.max(pct, progressShown)
416
+ progressRed = failed > 0
417
+ out.write(progressEscape(progressShown, progressRed))
418
+ }
419
+ }
360
420
  // The live summary IS the final summary's section, built from the
361
421
  // same formatter so the region visually becomes the printout.
362
422
  const summaryLines = formatSummarySection(
@@ -372,6 +432,7 @@ export function defaultLogger(
372
432
  noCache,
373
433
  savedMs,
374
434
  left: total - done,
435
+ ...(etaMs !== undefined ? { etaMs } : {}),
375
436
  spread:
376
437
  spreadCount > 0
377
438
  ? { maxMs: spreadMax, minMs: spreadMin, sumMs: spreadSum, count: spreadCount }
@@ -389,6 +450,8 @@ export function defaultLogger(
389
450
  overflow: slotQueue.length,
390
451
  nowMs: Date.now(),
391
452
  summaryLines,
453
+ ...(blocks !== undefined ? { blocks } : {}),
454
+ ...(critical !== undefined ? { critical } : {}),
392
455
  },
393
456
  colors,
394
457
  ),
@@ -400,6 +463,14 @@ export function defaultLogger(
400
463
  clearInterval(ticker)
401
464
  ticker = null
402
465
  }
466
+ if (forecastTimer !== null) {
467
+ clearTimeout(forecastTimer)
468
+ forecastTimer = null
469
+ }
470
+ if (progressShown >= 0) {
471
+ progressShown = -1
472
+ out.write(PROGRESS_CLEAR)
473
+ }
403
474
  if (statusDead) return
404
475
  statusDead = true
405
476
  writer.clearStatus()
@@ -516,7 +587,34 @@ export function defaultLogger(
516
587
  else slotQueue.push(slot)
517
588
  refresh(true)
518
589
  },
590
+ forecast(load) {
591
+ if (statusDead || forecastTimer !== null) return
592
+ forecastTimer = setTimeout(() => {
593
+ forecastTimer = null
594
+ if (statusDead) return
595
+ load().then(
596
+ (f) => {
597
+ forecastFn = f
598
+ if (!statusDead) refresh(true)
599
+ },
600
+ // History is observability: an unreadable one forecasts nothing.
601
+ () => {},
602
+ )
603
+ }, FORECAST_AFTER_MS)
604
+ forecastTimer.unref?.()
605
+ },
519
606
  runEnd() {
607
+ // A long run says it ended where the user is not looking. Once:
608
+ // the status is dead after the first runEnd.
609
+ if (signals.notify && !statusDead && Date.now() - startedAtMs >= NOTIFY_AFTER_MS) {
610
+ out.write(
611
+ notifyEscape(
612
+ failed > 0
613
+ ? `vx: ${failed} of ${total} task${total === 1 ? '' : 's'} failed`
614
+ : `vx: ${total} task${total === 1 ? '' : 's'} done`,
615
+ ),
616
+ )
617
+ }
520
618
  killStatus()
521
619
  // Persistent tails first: they are context for whatever ran against
522
620
  // the server, so they read above the failures they explain. `none`
@@ -603,6 +701,7 @@ export function defaultLogger(
603
701
  pushChunk(stderrBuffers, node.id, chunk)
604
702
  },
605
703
  taskComplete(node, outcome) {
704
+ if (!statusDead) finished.add(node.id)
606
705
  // A persistent task buffered into its bounded tail rather than the
607
706
  // per-task buffers (see taskStart), so its frame is drained from
608
707
  // there — carrying how much the cap dropped, since a truncated log
@@ -63,10 +63,11 @@ import { buildAdmission, executorLabel, resolveExecutors, teardownPlugins } from
63
63
  import { subscribeTelemetry, type TelemetryHandle } from './telemetry-host.js'
64
64
  import { assembleRunSummary, isPassStatus } from './telemetry.js'
65
65
  import type { RunContextRecord } from './telemetry.js'
66
- import { defaultLogger, resolveOutputView, type Logger } from './logger.js'
66
+ import { defaultLogger, type DefaultLogger, resolveOutputView, type Logger } from './logger.js'
67
67
  import { detectColors, type ColorSupport } from './colors.js'
68
68
  import { plainOutput } from './plain-output.js'
69
69
  import { formatPersistentList } from './framed-output.js'
70
+ import { createForecast } from './forecast.js'
70
71
  import { LocalHistoryProvider } from './history.js'
71
72
  import { plan, type RunPlan } from './plan.js'
72
73
  import { prepareRun, type PreparedRun } from './prepare.js'
@@ -339,7 +340,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
339
340
  }
340
341
  let held = false
341
342
  try {
342
- const summary = await runOnBus(options, bus, colors)
343
+ const summary = await runOnBus(options, bus, colors, terminal)
343
344
  if (summary.persistent === undefined) return summary
344
345
  // Servers handed back still running still write: `vx watch dev` printed
345
346
  // none of its server's log while it idled, the renderer gone with this
@@ -362,6 +363,7 @@ async function runOnBus(
362
363
  options: RunOptions,
363
364
  bus: EventBus,
364
365
  colors: ColorSupport,
366
+ terminal: DefaultLogger | null,
365
367
  ): Promise<RunSummary> {
366
368
  const log = busLogger(bus)
367
369
  // A server's output goes through a cell of its own: a later watch cycle
@@ -889,6 +891,14 @@ async function runOnBus(
889
891
  context: runContext,
890
892
  startedAtMs: endedAtMsAtStart,
891
893
  })
894
+ // The live region's end-of-run forecast. The logger asks only if its
895
+ // region is still up a second in, so a warm run never reads history.
896
+ terminal?.forecast(async () => {
897
+ const ids: string[] = []
898
+ for (const n of nodes.values()) if (!isGroupTask(n)) ids.push(n.id)
899
+ const p50s = await new LocalHistoryProvider(prepared.localCache.dbHandle()).p50sFor(ids)
900
+ return createForecast(nodes, p50s, concurrency)
901
+ })
892
902
 
893
903
  // Remote-only: kick off background prefetches so remote-GET latency
894
904
  // overlaps execution. Fire-and-forget — execution starts on the next
@@ -12,6 +12,7 @@ const NO_COLOR: ColorSupport = { enabled: false }
12
12
  // Same palette as framed-output.ts so glyphs and stats agree.
13
13
  const ACCENT = '#06b6d4'
14
14
  const ERROR = '#ef4444'
15
+ const WARN = '#eab308' // yellow-500, as the summary's
15
16
 
16
17
  /** Erase the current line and return the cursor to column 0. */
17
18
  const CLEAR = '\x1b[2K\r'
@@ -241,6 +242,10 @@ export interface WorkerSlot {
241
242
  }
242
243
 
243
244
  export interface StatusRegionState {
245
+ /** A running task's unfinished dependents (`blocks 14` on its row); 0 says nothing. */
246
+ blocks?: ReadonlyMap<string, number>
247
+ /** The running task at the head of the longest unfinished chain. */
248
+ critical?: string
244
249
  /**
245
250
  * Ready persistent tasks (dev servers …) whose children keep
246
251
  * running after their outcome lands. Pinned until runEnd — the
@@ -331,11 +336,19 @@ export function formatStatusRegion(
331
336
  // the ticking time IS the motion), then `running`, full id (never
332
337
  // truncated — name is the last column). Idle rows hold their slot's
333
338
  // place, dim, aligned under the status column.
339
+ // One task running has no path to compare against.
340
+ const busy = s.slots.filter((x) => x !== null).length
334
341
  for (const slot of s.slots) {
335
342
  if (slot === null) {
336
343
  lines.push(`${' '.repeat(3 + TIME_COL + 1)}${paint(IDLE, 'idle', colors, { dim: true })}`)
337
344
  continue
338
345
  }
346
+ // What waits on it, after the id: the row to watch is the one the
347
+ // longest chain waits on, and the one the most tasks wait on.
348
+ const waiting = s.blocks?.get(slot.id) ?? 0
349
+ const notes: string[] = []
350
+ if (s.critical === slot.id && busy > 1) notes.push(paint(WARN, 'critical path', colors))
351
+ if (waiting > 0) notes.push(dim(`blocks ${waiting}`))
339
352
  lines.push(
340
353
  formatTaskRow(
341
354
  ' ',
@@ -346,7 +359,7 @@ export function formatStatusRegion(
346
359
  '',
347
360
  paintPinnedId(slot.id, colors),
348
361
  colors,
349
- ),
362
+ ) + (notes.length > 0 ? ` ${notes.join(dim(' \u00b7 '))}` : ''),
350
363
  )
351
364
  }
352
365
  if (s.overflow > 0) {
@@ -21,6 +21,7 @@ const LOCAL = '#38bdf8' // sky-400
21
21
  const REMOTE = '#2563eb' // blue-600
22
22
  const ERROR = '#ef4444'
23
23
  const ABORTED = '#f97316' // orange-500: red's neighbour, not a failure
24
+ const ACCENT = '#06b6d4' // cyan-500: the live region's running word
24
25
 
25
26
  const BAR_WIDTH = 50
26
27
  // Brand gradient for the rule: identity violet → pink (the project /
@@ -120,6 +121,8 @@ export interface SummaryStats {
120
121
  spread: { maxMs: number; minMs: number; sumMs: number; count: number } | null
121
122
  /** Tasks an `admit` policy held with a worker free, and the waits summed. Absent with no hold. */
122
123
  held?: { count: number; sumMs: number }
124
+ /** Live section only: the forecast of what is left, from history. */
125
+ etaMs?: number
123
126
  /** The hits' stored exec times summed: the work the cache skipped. */
124
127
  savedMs?: number
125
128
  /** A run of groups only: their names, so `0 tasks` says why. */
@@ -269,6 +272,11 @@ export function formatSummarySection(
269
272
  const { maxMs, minMs, sumMs, count } = stats.spread
270
273
  spread = ` ${dim(`\u00b7 max ${formatElapsed(maxMs)} \u00b7 avg ${formatElapsed(sumMs / count)} \u00b7 min ${formatElapsed(minMs)}`)}`
271
274
  }
275
+ // The live run's forecast leads the spread: the one number on the row
276
+ // that is about the future.
277
+ if (stats.etaMs !== undefined) {
278
+ spread = ` ${dim('\u00b7')} ${paint(ACCENT, `${formatEta(stats.etaMs)} left`, colors)}${spread}`
279
+ }
272
280
  // Run-shape footer (final summary only): worker pool + cache mode,
273
281
  // grouped with time under a blank line below the meters.
274
282
  if (context !== undefined) {
@@ -296,6 +304,17 @@ export function formatSummarySection(
296
304
  return lines
297
305
  }
298
306
 
307
+ /**
308
+ * A forecast, said as coarsely as it is known: `~4s`, `~2m 05s`, `<1s`
309
+ * once the history says the end is due.
310
+ */
311
+ function formatEta(ms: number): string {
312
+ if (ms < 1000) return '<1s'
313
+ const s = Math.ceil(ms / 1000)
314
+ if (s < 60) return `~${s}s`
315
+ return `~${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`
316
+ }
317
+
299
318
  /**
300
319
  * `42 tasks · 38 cached (90%) · 3.2s`, `2 tasks · all cached · 23ms`,
301
320
  * with the failures first when there are any. Cached is every hit (up to
@@ -0,0 +1,45 @@
1
+ // Zero-width escapes some terminals act on: a progress bar on the tab or
2
+ // taskbar (OSC 9;4) and a desktop notification (OSC 9). Sent only to a
3
+ // terminal known to read them: elsewhere OSC 9 IS the notification, so a
4
+ // progress update would post "4;1;40" as one.
5
+
6
+ export interface TerminalSignals {
7
+ progress: boolean
8
+ notify: boolean
9
+ }
10
+
11
+ /** What the terminal `env` names can show; tmux and screen hide it, so they get neither. */
12
+ export function terminalSignals(
13
+ env: Readonly<Record<string, string | undefined>>,
14
+ ): TerminalSignals {
15
+ const program = env['TERM_PROGRAM']
16
+ if (program === 'ghostty') return { progress: true, notify: true }
17
+ // iTerm2 posts OSC 9 since 3.0 and draws OSC 9;4 since 3.6.
18
+ if (program === 'iTerm.app')
19
+ return { progress: atLeast(env['TERM_PROGRAM_VERSION'], 3, 6), notify: true }
20
+ if (program === 'WezTerm') return { progress: false, notify: true }
21
+ // Windows Terminal and ConEmu draw the progress; neither posts OSC 9.
22
+ if (env['WT_SESSION'] !== undefined || env['ConEmuPID'] !== undefined)
23
+ return { progress: true, notify: false }
24
+ return { progress: false, notify: false }
25
+ }
26
+
27
+ function atLeast(version: string | undefined, major: number, minor: number): boolean {
28
+ const m = /^(\d+)\.(\d+)/.exec(version ?? '')
29
+ if (m === null) return false
30
+ const [a, b] = [Number(m[1]), Number(m[2])]
31
+ return a > major || (a === major && b >= minor)
32
+ }
33
+
34
+ /** Tab / taskbar progress at `pct` (0–100), or `error` to draw it red. */
35
+ export function progressEscape(pct: number, error = false): string {
36
+ return `\x1b]9;4;${error ? 2 : 1};${Math.max(0, Math.min(100, Math.round(pct)))}\x07`
37
+ }
38
+
39
+ export const PROGRESS_CLEAR = '\x1b]9;4;0;0\x07'
40
+
41
+ /** A desktop notification. BEL and ESC end the sequence, so neither may ride in `text`. */
42
+ export function notifyEscape(text: string): string {
43
+ // eslint-disable-next-line no-control-regex -- the control range is the point
44
+ return `\x1b]9;${text.replace(/[\x00-\x1f\x7f]/g, ' ')}\x07`
45
+ }