@linxiraos/pi-utils 1.1.27 → 1.2.1

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.
Files changed (63) hide show
  1. package/CHANGELOG.md +4 -6
  2. package/README.md +1 -1
  3. package/dist/types/ar/index.d.ts +1 -0
  4. package/dist/types/ar/zip-package.d.ts +21 -0
  5. package/dist/types/color.d.ts +2 -0
  6. package/dist/types/dirs.d.ts +31 -12
  7. package/dist/types/docx/converter.d.ts +2 -0
  8. package/dist/types/dom/core.d.ts +15 -4
  9. package/dist/types/dom/parser.d.ts +5 -3
  10. package/dist/types/dom/selector.d.ts +7 -1
  11. package/dist/types/index.d.ts +1 -0
  12. package/dist/types/logger/rotating-file.d.ts +4 -2
  13. package/dist/types/loop-phase.d.ts +12 -7
  14. package/dist/types/marked/core.d.ts +7 -6
  15. package/dist/types/marked-list.d.ts +38 -0
  16. package/dist/types/math-delimiters.d.ts +10 -0
  17. package/dist/types/readability/types.d.ts +2 -0
  18. package/dist/types/release-assets.d.ts +22 -0
  19. package/dist/types/temp.d.ts +7 -0
  20. package/dist/types/turndown/types.d.ts +7 -0
  21. package/dist/types/vterm/buffer.d.ts +16 -5
  22. package/package.json +3 -3
  23. package/src/ar/index.ts +1 -0
  24. package/src/ar/open.ts +29 -8
  25. package/src/ar/zip-package.ts +57 -0
  26. package/src/chalk.ts +66 -23
  27. package/src/color.ts +6 -0
  28. package/src/dirs.ts +84 -24
  29. package/src/docx/converter.ts +26 -12
  30. package/src/dom/core.ts +354 -111
  31. package/src/dom/parser.ts +42 -19
  32. package/src/dom/selector.ts +328 -95
  33. package/src/fetch-retry.ts +2 -0
  34. package/src/format.ts +5 -1
  35. package/src/index.ts +1 -0
  36. package/src/json-lexer.ts +54 -0
  37. package/src/json-parse.ts +21 -6
  38. package/src/logger/rotating-file.ts +5 -3
  39. package/src/logger.ts +39 -23
  40. package/src/loop-phase.ts +66 -21
  41. package/src/marked/core.ts +42 -18
  42. package/src/marked-list.ts +73 -0
  43. package/src/math-delimiters.ts +16 -0
  44. package/src/module-timer.ts +5 -5
  45. package/src/postmortem.ts +1 -1
  46. package/src/process-name.ts +2 -2
  47. package/src/prompt.ts +3 -1
  48. package/src/ptree.ts +28 -35
  49. package/src/readability/readability.ts +349 -39
  50. package/src/readability/types.ts +2 -0
  51. package/src/release-assets.ts +40 -0
  52. package/src/runtime-install.ts +2 -2
  53. package/src/stream.ts +34 -13
  54. package/src/temp.ts +7 -0
  55. package/src/template.ts +7 -4
  56. package/src/timing-buffer.ts +1 -1
  57. package/src/tls-fetch.ts +1 -1
  58. package/src/turndown/gfm.ts +16 -5
  59. package/src/turndown/html.ts +28 -8
  60. package/src/turndown/service.ts +43 -15
  61. package/src/turndown/types.ts +7 -0
  62. package/src/vterm/buffer.ts +31 -11
  63. package/src/vterm/terminal.ts +213 -100
package/src/json-lexer.ts CHANGED
@@ -32,6 +32,16 @@ export const LBRACKET = 0x5b;
32
32
  export const RBRACKET = 0x5d;
33
33
  const U = 0x75;
34
34
 
35
+ /**
36
+ * Minimum remaining string length for bulk-skipping ordinary characters in
37
+ * {@link JsonLexer.string} with two engine `indexOf` scans (nearest quote or
38
+ * backslash) instead of one `charCodeAt` per character. Below this the
39
+ * `indexOf` setup costs more than the per-char loop on quote-dense input;
40
+ * long string runs — the multi-KB tool-argument payloads that dominate
41
+ * streaming re-parses — scan several times faster above it.
42
+ */
43
+ const STRING_BULK_SCAN_MIN = 32;
44
+
35
45
  /** Valid chars after `\` in a strict JSON escape: `" \ / b f n r t u`. */
36
46
  export const VALID_ESCAPE_CHAR = new Uint8Array(128);
37
47
  for (const ch of '"\\/bfnrtu') VALID_ESCAPE_CHAR[ch.charCodeAt(0)] = 1;
@@ -176,6 +186,17 @@ const enum QuoteLook {
176
186
  */
177
187
  export class JsonLexer {
178
188
  pos: number;
189
+ /**
190
+ * Shared across {@link string} calls: `src` has no backslash in
191
+ * `[#backslashFrom, #backslashAt)` and `#backslashAt` is a backslash or
192
+ * `src.length`. An escape-free run can extend past its string's closing
193
+ * quote to the end of the document, so searching per string would rescan
194
+ * the same tail for every sibling string (quadratic on objects with many
195
+ * short values). `src` is immutable, so the range never goes stale; a
196
+ * cursor outside it (e.g. a rollback) re-searches.
197
+ */
198
+ #backslashFrom = 0;
199
+ #backslashAt = -1;
179
200
 
180
201
  constructor(
181
202
  readonly src: string,
@@ -218,7 +239,40 @@ export class JsonLexer {
218
239
  // malformed structure fails loudly instead of silently swallowing
219
240
  // commas/colons or sibling members.
220
241
  const lenient = quote === SQUOTE || this.mode === "streaming";
242
+ // Any delimiter code unit, not just `"`/`'`; a value above 0xFFFF matches
243
+ // no code unit, so such a string never closes.
244
+ const quoteChar = String.fromCharCode(quote);
245
+ // Next quote / backslash at or after the cursor, cached across iterations
246
+ // and re-searched only once the cursor passes them, so every character is
247
+ // scanned at most once per kind and the bulk path stays linear even on
248
+ // escape-dense input (re-searching the far closing quote after each
249
+ // escape would be quadratic). `n` means "none before end of input". The
250
+ // backslash search result is shared across calls via `#backslashAt`.
251
+ let nextQuote = quoteChar.charCodeAt(0) === quote ? -1 : n;
252
+ let nextBackslash = -1;
221
253
  while (i < n) {
254
+ // Bulk-skip a run of ordinary characters: jump to the nearest quote or
255
+ // backslash instead of one `charCodeAt` per character. Only entered on
256
+ // an ordinary character, so back-to-back escapes never pay an `indexOf`;
257
+ // short tails keep the per-char loop. Landing on a special char falls
258
+ // through to the unchanged handling below, so tokens are identical.
259
+ const c0 = s.charCodeAt(i);
260
+ if (c0 !== BACKSLASH && c0 !== quote && n - i > STRING_BULK_SCAN_MIN) {
261
+ if (nextQuote < i) {
262
+ const q = s.indexOf(quoteChar, i);
263
+ nextQuote = q === -1 ? n : q;
264
+ }
265
+ if (nextBackslash < i) {
266
+ if (i < this.#backslashFrom || i > this.#backslashAt) {
267
+ const b = s.indexOf("\\", i);
268
+ this.#backslashFrom = i;
269
+ this.#backslashAt = b === -1 ? n : b;
270
+ }
271
+ nextBackslash = this.#backslashAt;
272
+ }
273
+ i = nextQuote < nextBackslash ? nextQuote : nextBackslash;
274
+ if (i >= n) break;
275
+ }
222
276
  const cc = s.charCodeAt(i);
223
277
  if (cc !== BACKSLASH && cc !== quote) {
224
278
  i++;
package/src/json-parse.ts CHANGED
@@ -309,15 +309,30 @@ export function parseJsonWithRepair<T>(json: string): T {
309
309
  export function parseStreamingJson<T = Record<string, unknown>>(partialJson: string | undefined): T {
310
310
  const trimmed = partialJson?.trimStart();
311
311
  if (!trimmed) return {} as T;
312
+ // A container can only be strict JSON once its closer arrived; skip the
313
+ // doomed full-buffer JSON.parse (and its SyntaxError) while it streams.
314
+ const first = trimmed.charCodeAt(0);
315
+ if ((first !== 0x7b && first !== 0x5b) || canCloseContainer(trimmed)) {
316
+ try {
317
+ return JSON.parse(trimmed) as T;
318
+ } catch {}
319
+ }
312
320
  try {
313
- return JSON.parse(trimmed) as T;
321
+ return (new RelaxedJson(trimmed, true).parse() ?? {}) as T;
314
322
  } catch {
315
- try {
316
- return (new RelaxedJson(trimmed, true).parse() ?? {}) as T;
317
- } catch {
318
- return {} as T;
319
- }
323
+ return {} as T;
324
+ }
325
+ }
326
+
327
+ /** True when the last non-whitespace character is `}` or `]`. */
328
+ function canCloseContainer(text: string): boolean {
329
+ for (let i = text.length - 1; i >= 0; i--) {
330
+ const code = text.charCodeAt(i);
331
+ // JSON whitespace: space, \t, \n, \r
332
+ if (code === 0x20 || code === 0x09 || code === 0x0a || code === 0x0d) continue;
333
+ return code === 0x7d || code === 0x5d;
320
334
  }
335
+ return false;
321
336
  }
322
337
 
323
338
  /**
@@ -109,11 +109,13 @@ export class RotatingFileSink {
109
109
  /**
110
110
  * Append one already-formatted log record. A batching sink buffers it unless
111
111
  * `flushNow` is set or the buffer reached its size threshold; either way the
112
- * whole buffer, this record included, is then written in order.
112
+ * whole buffer, this record included, is then written in order. `day` is the
113
+ * record's `localDay` key, so callers that already took the record's `Date`
114
+ * reuse it.
113
115
  */
114
- write(line: string, flushNow = false): void {
116
+ write(line: string, flushNow = false, day = localDay(new Date())): void {
115
117
  if (this.#closed) return;
116
- this.#selectFile(localDay(new Date()));
118
+ this.#selectFile(day);
117
119
  const record = `${line}${os.EOL}`;
118
120
  const bytes = Buffer.byteLength(record, "utf8");
119
121
  this.#pending += record;
package/src/logger.ts CHANGED
@@ -209,9 +209,28 @@ function jsonReplacer(_key: string, value: unknown): unknown {
209
209
  return value;
210
210
  }
211
211
 
212
- interface NormalizedLogInfo extends Record<string, unknown> {
213
- level: LogLevel;
214
- message: unknown;
212
+ // Values visited by the Error pre-scan before falling back to the replacer.
213
+ const ERROR_SCAN_BUDGET = 128;
214
+
215
+ /**
216
+ * Whether `value` may contain an {@link Error} that {@link jsonReplacer} must
217
+ * unwrap. Walks what `JSON.stringify` serializes (arrays and own enumerable
218
+ * keys); objects with a `toJSON` other than `Date` may return an Error, and an
219
+ * exhausted budget answers true.
220
+ */
221
+ function mayContainError(root: unknown): boolean {
222
+ const stack: unknown[] = [root];
223
+ let budget = ERROR_SCAN_BUDGET;
224
+ while (stack.length > 0) {
225
+ const value = stack.pop();
226
+ if (value === null || typeof value !== "object") continue;
227
+ if (value instanceof Error) return true;
228
+ if (--budget < 0) return true;
229
+ if (value instanceof Date) continue;
230
+ if ("toJSON" in value && typeof value.toJSON === "function") return true;
231
+ for (const child of Object.values(value)) stack.push(child);
232
+ }
233
+ return false;
215
234
  }
216
235
 
217
236
  function padTimestampPart(value: number, width = 2): string {
@@ -232,33 +251,30 @@ function formatLocalTimestamp(date: Date): string {
232
251
 
233
252
  const FORMAT_TOKEN_PATTERN = /%[scdjifoO%]/;
234
253
 
235
- function normalizeLogInfo(
254
+ /** Serialize one log record: timestamp, level, pid, message, then context fields. */
255
+ function formatLogRecord(
236
256
  level: LogLevel,
237
257
  message: string,
238
258
  context: Record<string, unknown> | undefined,
239
- ): NormalizedLogInfo {
259
+ timestamp: string,
260
+ ): string {
240
261
  const metadata =
241
262
  !FORMAT_TOKEN_PATTERN.test(message) && context !== null && typeof context === "object" ? context : undefined;
242
- const info = Object.assign({}, metadata, { level, message }) as NormalizedLogInfo;
243
- if (metadata?.message) info.message = `${message} ${metadata.message}`;
244
- if (metadata?.stack) info.stack = metadata.stack;
245
- if (metadata?.cause) info.cause = metadata.cause;
246
- return info;
247
- }
248
-
249
- function formatLogInfo(info: NormalizedLogInfo): string {
250
- const timestamp = formatLocalTimestamp(new Date());
251
- info.timestamp = timestamp;
252
263
  const entry: Record<string, unknown> = {
253
264
  timestamp,
254
- level: info.level,
265
+ level,
255
266
  pid: process.pid,
256
- message: info.message,
267
+ message: metadata?.message ? `${message} ${metadata.message}` : message,
257
268
  };
258
- for (const [key, value] of Object.entries(info)) {
259
- if (key !== "level" && key !== "timestamp" && key !== "message") entry[key] = value;
269
+ if (metadata) {
270
+ for (const key of Object.keys(metadata)) {
271
+ if (key !== "level" && key !== "timestamp" && key !== "message") entry[key] = metadata[key];
272
+ }
273
+ // An Error passed as the context carries these as non-enumerable fields.
274
+ if (metadata.stack) entry.stack = metadata.stack;
275
+ if (metadata.cause) entry.cause = metadata.cause;
260
276
  }
261
- return JSON.stringify(entry, jsonReplacer) as string;
277
+ return mayContainError(entry) ? JSON.stringify(entry, jsonReplacer) : JSON.stringify(entry);
262
278
  }
263
279
 
264
280
  /** Longest a buffered file record waits before it is written. */
@@ -361,9 +377,9 @@ function emitLocally(level: LogLevel, message: string, context: Record<string, u
361
377
  const rank = LOG_LEVEL_RANK[level];
362
378
  const file = rank <= transports.fileLevelRank ? transports.file : undefined;
363
379
  if (!file && !transports.console) return;
364
- const info = normalizeLogInfo(level, message, context);
365
- const line = formatLogInfo(info);
366
- file?.write(line, rank <= IMMEDIATE_FLUSH_RANK || exiting);
380
+ const now = new Date();
381
+ const line = formatLogRecord(level, message, context, formatLocalTimestamp(now));
382
+ file?.write(line, rank <= IMMEDIATE_FLUSH_RANK || exiting, localDay(now));
367
383
  if (transports.console) fs.writeSync(1, `${line}${os.EOL}`);
368
384
  }
369
385
 
package/src/loop-phase.ts CHANGED
@@ -1,8 +1,16 @@
1
+ /** A label and its exclusive in-window time, as named by {@link takeLoopPhaseAttribution}. */
2
+ export interface LoopPhaseAttribution {
3
+ readonly label: string;
4
+ /** Total time this label was the innermost active phase inside the late window [start, end]. */
5
+ readonly ms: number;
6
+ }
7
+
1
8
  /**
2
- * Live event-loop phase breadcrumb. Hot synchronous paths push a short label
3
- * before running and pop it after (via `try`/`finally`); the loop watchdog
4
- * reads {@link takeRecentLoopPhase} when it detects a block, so a stall is
5
- * logged with the work that caused it instead of an opaque "unknown".
9
+ * Process-global synchronous loop phases. The watchdog arms a late window and
10
+ * reads attribution via {@link takeLoopPhaseAttribution}; attribution totals
11
+ * only the innermost label's time inside that window. A label is named only
12
+ * when its total outweighs all unlabeled time, otherwise the block remains
13
+ * "unknown". Disarmed push/pop do not read the clock.
6
14
  *
7
15
  * This is deliberately a process-global stack and not part of the logger span
8
16
  * machinery: `main.ts` ends timing spans before the interactive TUI starts, so
@@ -16,19 +24,31 @@
16
24
  * work, push/pop around each synchronous chunk, not across the await.
17
25
  */
18
26
  const stack: string[] = [];
19
- // The most recent label pushed, retained after it is popped. A hot path pushes
20
- // and pops a phase entirely within one synchronous macrotask, so by the time
21
- // the watchdog's delayed tick runs the stack is already empty; this slot keeps
22
- // the culprit available for that one tick. Consumed (cleared) on read so it
23
- // only attributes the just-elapsed interval.
24
- let recentPhase: string | undefined;
27
+ const defaultClock = () => performance.now();
28
+ let windowStart = Number.POSITIVE_INFINITY;
29
+ let segmentStart = windowStart;
30
+ let clock = defaultClock;
31
+ const totals = new Map<string, number>();
32
+ let labeledMs = 0;
33
+
34
+ function boundary(t: number): void {
35
+ const ms = Math.max(0, t - Math.max(segmentStart, windowStart));
36
+ const label = stack[stack.length - 1];
37
+ if (label !== undefined && ms > 0) {
38
+ totals.set(label, (totals.get(label) ?? 0) + ms);
39
+ labeledMs += ms;
40
+ }
41
+ segmentStart = t;
42
+ }
25
43
 
26
44
  export function pushLoopPhase(label: string): void {
45
+ if (windowStart !== Number.POSITIVE_INFINITY) boundary(clock());
27
46
  stack.push(label);
28
- recentPhase = label;
29
47
  }
30
48
 
31
49
  export function popLoopPhase(): void {
50
+ if (stack.length === 0) return;
51
+ if (windowStart !== Number.POSITIVE_INFINITY) boundary(clock());
32
52
  stack.pop();
33
53
  }
34
54
 
@@ -36,14 +56,39 @@ export function currentLoopPhase(): string | undefined {
36
56
  return stack[stack.length - 1];
37
57
  }
38
58
 
39
- /**
40
- * Phase to blame for a just-detected loop block: the live top phase if one is
41
- * still held, else the most recent phase pushed since the last call. Clears the
42
- * recent slot so a block in a later, phase-less interval is not misattributed
43
- * to a phase that already finished.
44
- */
45
- export function takeRecentLoopPhase(): string | undefined {
46
- const phase = stack[stack.length - 1] ?? recentPhase;
47
- recentPhase = undefined;
48
- return phase;
59
+ /** Run `fn` under `label`. For an async `fn`, only its synchronous prefix (up to its first await) is labeled; label each continuation separately. */
60
+ export function withLoopPhase<T>(label: string, fn: () => T): T {
61
+ pushLoopPhase(label);
62
+ try {
63
+ return fn();
64
+ } finally {
65
+ popLoopPhase();
66
+ }
67
+ }
68
+
69
+ /** Arm an attribution window starting at `start` (a deadline on `now`'s clock). Called with no arguments it disarms and clears. */
70
+ export function resetLoopPhaseWindow(start = Number.POSITIVE_INFINITY, now: () => number = defaultClock): void {
71
+ windowStart = segmentStart = start;
72
+ clock = now;
73
+ totals.clear();
74
+ labeledMs = 0;
75
+ }
76
+
77
+ /** Attribute the window [start, end] and disarm it. Returns undefined when no window is armed or no label outweighs unlabeled time. */
78
+ export function takeLoopPhaseAttribution(end?: number): LoopPhaseAttribution | undefined {
79
+ if (windowStart === Number.POSITIVE_INFINITY) return undefined;
80
+ const t = end ?? clock();
81
+ boundary(t);
82
+ const unknownMs = Math.max(0, t - windowStart - labeledMs);
83
+ let winner: string | undefined;
84
+ let winnerMs = 0;
85
+ for (const [label, ms] of totals) {
86
+ if (ms >= winnerMs) {
87
+ winner = label;
88
+ winnerMs = ms;
89
+ }
90
+ }
91
+ const result = winner !== undefined && winnerMs > unknownMs ? { label: winner, ms: winnerMs } : undefined;
92
+ resetLoopPhaseWindow();
93
+ return result;
49
94
  }
@@ -248,14 +248,15 @@ export type TokensList = Token[] & { links: Links };
248
248
  export interface TokenizerThis {
249
249
  lexer: Lexer;
250
250
  /**
251
- * For an inline tokenizer, the whole inline source, such as a paragraph's text. The `src` it receives is the part
252
- * of it before `end` that starts at `end - src.length`: inside a link label or the text of emphasis, `end` is where
253
- * that text ends, right before its closer, and a token there must not reach past it. Inline tokenizers get one
254
- * context object per inline source, the same for every call while the lexer works through it and the labels and
255
- * emphasis inside it, so a tokenizer can keep state for it in a `WeakMap`.
251
+ * The whole source the lexer works through. The `src` a tokenizer receives is the part of it before `end` that
252
+ * starts at `end - src.length`. For a block tokenizer, `source` is the text passed to one `blockTokens` call and
253
+ * `end` its length. For an inline tokenizer, it is the inline source, such as a paragraph's text: inside a link
254
+ * label or the text of emphasis, `end` is where that text ends, right before its closer, and a token there must
255
+ * not reach past it. Tokenizers get one context object per source, the same for every call while the lexer works
256
+ * through it (and, inline, the labels and emphasis inside it), so a tokenizer can keep state for it in a `WeakMap`.
256
257
  */
257
258
  source?: string;
258
- /** For an inline tokenizer, where the text the lexer works through ends in `source` (see `source`). */
259
+ /** Where the text the lexer works through ends in `source` (see `source`). */
259
260
  end?: number;
260
261
  }
261
262
  /** A tokenizer extension callback. */
@@ -971,13 +972,30 @@ function parseList(lines: string[], index: number, lexer: Lexer): { token: Token
971
972
 
972
973
  function blockTokens(src: string, lexer: Lexer, output: Token[]): Token[] {
973
974
  const lines = lineArray(src);
975
+ // `lines` tiles `src` exactly, so the unconsumed source at line `i` is
976
+ // `src.slice(lineStarts[i])`. Only block extensions and an lheading override
977
+ // read it; computing offsets once keeps the loop linear.
978
+ const blockExtensions = lexer.extensions.block;
979
+ const context: TokenizerThis = { lexer, source: src, end: src.length };
980
+ const lheadingOverride = lexer.tokenizerOverrides.lheading;
981
+ let lineStarts: number[] | undefined;
982
+ if (blockExtensions.length > 0 || lheadingOverride) {
983
+ lineStarts = [];
984
+ let offset = 0;
985
+ for (let index = 0; index < lines.length; index++) {
986
+ lineStarts.push(offset);
987
+ offset += lines[index]!.length;
988
+ }
989
+ }
974
990
  let i = 0;
975
991
  while (i < lines.length) {
976
- const remaining = lines.slice(i).join("");
977
992
  let custom: Tokens.Generic | undefined;
978
- for (const extension of lexer.extensions.block) {
979
- custom = extension.tokenizer.call({ lexer }, remaining, output);
980
- if (custom?.raw) break;
993
+ if (blockExtensions.length > 0) {
994
+ const remaining = src.slice(lineStarts![i]);
995
+ for (const extension of blockExtensions) {
996
+ custom = extension.tokenizer.call(context, remaining, output);
997
+ if (custom?.raw) break;
998
+ }
981
999
  }
982
1000
  if (custom?.raw) {
983
1001
  output.push(custom);
@@ -1006,9 +1024,10 @@ function blockTokens(src: string, lexer: Lexer, output: Token[]): Token[] {
1006
1024
  let raw = line;
1007
1025
  let text = "";
1008
1026
  i++;
1027
+ const closer = new RegExp(`^ {0,3}${marker[0]}{${marker.length},}[ \\t]*(?:\\n|$)$`);
1009
1028
  while (i < lines.length) {
1010
1029
  const next = lines[i++]!;
1011
- if (new RegExp(`^ {0,3}${marker[0]}{${marker.length},}[ \\t]*(?:\\n|$)$`).test(next)) {
1030
+ if (closer.test(next)) {
1012
1031
  raw += next;
1013
1032
  break;
1014
1033
  }
@@ -1090,10 +1109,16 @@ function blockTokens(src: string, lexer: Lexer, output: Token[]): Token[] {
1090
1109
  i++;
1091
1110
  if (line.trimStart().startsWith("<!--")) {
1092
1111
  while (!raw.includes("-->") && i < lines.length) raw += lines[i++]!;
1093
- } else if (tag && !new RegExp(`</${tag}>`, "i").test(raw)) {
1094
- while (i < lines.length) {
1095
- raw += lines[i++]!;
1096
- if (new RegExp(`</${tag}>`, "i").test(raw)) break;
1112
+ } else if (tag) {
1113
+ // A closing tag never spans lines, so testing each appended line is
1114
+ // equivalent to re-testing the accumulated block.
1115
+ const closeTag = new RegExp(`</${tag}>`, "i");
1116
+ if (!closeTag.test(raw)) {
1117
+ while (i < lines.length) {
1118
+ const next = lines[i++]!;
1119
+ raw += next;
1120
+ if (closeTag.test(next)) break;
1121
+ }
1097
1122
  }
1098
1123
  }
1099
1124
  const value = i < lines.length && /^\s*\n$/.test(lines[i]!) ? stripFinalNewline(raw) : raw;
@@ -1129,9 +1154,8 @@ function blockTokens(src: string, lexer: Lexer, output: Token[]): Token[] {
1129
1154
  }
1130
1155
  if (i + 1 < lines.length && /^ {0,3}(=+|-+)[ \t]*(?:\n|$)$/.test(lines[i + 1]!)) {
1131
1156
  let fallback: Tokens.Heading | undefined | false;
1132
- const override = lexer.tokenizerOverrides.lheading;
1133
- if (override) fallback = override.call(lexer.tokenizer, remaining);
1134
- if (!override || fallback === false) {
1157
+ if (lheadingOverride) fallback = lheadingOverride.call(lexer.tokenizer, src.slice(lineStarts![i]));
1158
+ if (!lheadingOverride || fallback === false) {
1135
1159
  const raw = line + lines[i + 1]!;
1136
1160
  const text = stripFinalNewline(line);
1137
1161
  const tokens: Token[] = [];
@@ -0,0 +1,73 @@
1
+ /**
2
+ * List-continuation grammar of `@linxiraos/pi-utils/marked`, for streaming
3
+ * renderers that freeze a lexed prefix at blank-line boundaries (TUI and
4
+ * collab web).
5
+ *
6
+ * marked's list tokenizer (Tokenizer.list, marked v18) continues a list across
7
+ * blank lines only when the remaining source matches
8
+ * `listItemRegex(marker)` = `^( {0,3}${marker})((?:[\t ][^\n]*)?(?:\n|$))`,
9
+ * where `marker` is the exact bullet char for unordered lists (`\${char}`) or
10
+ * 1-9 digits plus the exact delimiter for ordered lists (`\d{1,9}\${delim}`).
11
+ * The marker is derived from the list's FIRST item (`n = t[1].trim()`), which
12
+ * sits at the start of a top-level list token's raw.
13
+ */
14
+
15
+ /** A list's marker, read from the start of its raw: bullet in group 1, ordered delimiter in group 2. */
16
+ const LIST_MARKER_RE = /^ {0,3}(?:([*+-])|\d{1,9}([.)]))/;
17
+
18
+ /**
19
+ * Whether a same-marker item could still continue the list `listRaw` at
20
+ * `tailStart` (directly after the blank line ending the list), now or after
21
+ * any append to `text`.
22
+ *
23
+ * Streaming-freeze equivalence invariant: lex(prefix) ++ lex(tail) must equal
24
+ * lex(full text) for the current text and every append-only extension of it,
25
+ * because a frozen prefix is sticky. At a blank-line cut directly after a
26
+ * top-level `list` token, the only construct that can straddle the cut is a
27
+ * continuation item of that list: marked consumed the blank line into the last
28
+ * item's raw and re-ran `listItemRegex` at exactly `tailStart`, merging a
29
+ * same-marker item into one renumbered loose list. The cut is safe only when
30
+ * that regex can NEVER match at `tailStart`, no matter what is appended later.
31
+ *
32
+ * Append-only growth means "closed" may only be concluded from a present
33
+ * character that contradicts every possible continuation (tail "1x" can never
34
+ * grow into an ordered item, but tail "1" can become "1. c"). Running out of
35
+ * text mid-marker therefore answers "may continue".
36
+ *
37
+ * Returns true when the tail could still continue the list (or the list's
38
+ * marker is unrecognizable) — the conservative "don't freeze" answer. marked
39
+ * may break the list anyway when the matching line is also an hr (`- - -`);
40
+ * treating that as "may continue" merely skips a freeze, never corrupts one.
41
+ */
42
+ export function listMayContinueAt(text: string, tailStart: number, listRaw: string): boolean {
43
+ const marker = LIST_MARKER_RE.exec(listRaw);
44
+ if (marker === null) return true; // unrecognized list shape — stay conservative
45
+ const n = text.length;
46
+ let i = tailStart;
47
+ // `listItemRegex` allows up to 3 leading spaces.
48
+ while (i < n && i - tailStart < 3 && text.charCodeAt(i) === 0x20 /* space */) i++;
49
+ if (i >= n) return true;
50
+ const bullet = marker[1];
51
+ if (bullet !== undefined) {
52
+ if (text[i] !== bullet) return false; // wrong marker char — closed forever
53
+ i++;
54
+ } else {
55
+ // Ordered: 1-9 digits, then the same `.`/`)` delimiter.
56
+ let digits = 0;
57
+ while (i < n && digits < 10) {
58
+ const c = text.charCodeAt(i);
59
+ if (c < 0x30 /* 0 */ || c > 0x39 /* 9 */) break;
60
+ digits++;
61
+ i++;
62
+ }
63
+ if (digits === 0 || digits > 9) return false; // no digit run / too long — closed forever
64
+ if (i >= n) return true; // delimiter (or more digits) may still arrive
65
+ if (text[i] !== marker[2]) return false; // wrong delimiter — closed forever
66
+ i++;
67
+ }
68
+ // After the marker: `(?:[\t ][^\n]*)?(?:\n|$)` — tab/space + anything, a
69
+ // bare newline, or end-of-input (which appends can still extend).
70
+ if (i >= n) return true;
71
+ const after = text.charCodeAt(i);
72
+ return after === 0x20 /* space */ || after === 0x09 /* tab */ || after === 0x0a; /* \n */
73
+ }
@@ -235,6 +235,22 @@ export class MathBlockScan {
235
235
  }
236
236
  }
237
237
 
238
+ const blockScansByContext = new WeakMap<object, MathBlockScan>();
239
+
240
+ /**
241
+ * The own-line display block at the start of `src`, for a marked block tokenizer: `src` is the part of
242
+ * `context.source` that starts at `context.end - src.length`. One {@link MathBlockScan} is kept per context, which
243
+ * marked keeps for one `blockTokens` source, so asking at every block start takes linear time even when an opener
244
+ * never closes. Without `context.source` it is {@link mathBlockAt}.
245
+ */
246
+ export function mathBlockInContext(context: { source?: string; end?: number }, src: string): MathBlock | undefined {
247
+ const source = context.source;
248
+ if (source === undefined) return mathBlockAt(src);
249
+ let scan = blockScansByContext.get(context);
250
+ if (!scan) blockScansByContext.set(context, (scan = new MathBlockScan(source)));
251
+ return scan.at((context.end ?? source.length) - src.length);
252
+ }
253
+
238
254
  /**
239
255
  * The display opener alone on the line at `from` (up to 3 leading spaces,
240
256
  * trailing spaces or tabs; the source's last line may be unterminated), or
@@ -37,7 +37,7 @@
37
37
  * **Coverage limits:**
38
38
  * - TS/TSX only — intercepting `node_modules` CJS `.js`/`.cjs` and forcing ESM
39
39
  * breaks their default-export detection, so they are left to Bun's default path.
40
- * - **Dev runs only.** In the compiled `omp` binary every module is pre-bundled
40
+ * - **Dev runs only.** In the compiled `zetacode` binary every module is pre-bundled
41
41
  * into bunfs, so `onLoad` never fires; profile with a `bun --preload` dev run.
42
42
  */
43
43
  import { readFileSync } from "node:fs";
@@ -51,19 +51,19 @@ import { moduleLoadBuffer } from "./timing-buffer";
51
51
  // timing lives) is uniformly TypeScript, so a TS-only filter is both safe and
52
52
  // sufficient. Declaration files are imported as text assets, never evaluated.
53
53
  const MODULE_LOADER_FILTER = /^(?!.*\.d\.[mc]?ts$).*\.[mc]?tsx?$/;
54
- const MODULE_COMPLETE_KEY: symbol = Symbol.for("omp.moduleLoadComplete");
55
- const MODULE_BODY_START_KEY: symbol = Symbol.for("omp.moduleBodyStart");
54
+ const MODULE_COMPLETE_KEY: symbol = Symbol.for("zeta.moduleLoadComplete");
55
+ const MODULE_BODY_START_KEY: symbol = Symbol.for("zeta.moduleBodyStart");
56
56
  const STATIC_IMPORT_PATTERN =
57
57
  /\b(?:import|export)\s+(?:type\s+)?(?:[^"']*?\s+from\s+)?["']([^"']+)["']|\bimport\s*\(\s*["']([^"']+)["']\s*\)/g;
58
58
 
59
59
  type CompleteStore = Record<symbol, ((path: string) => void) | undefined>;
60
60
 
61
61
  function bodyStartMarker(path: string): string {
62
- return `;globalThis[Symbol.for("omp.moduleBodyStart")]?.(${JSON.stringify(path)});\n`;
62
+ return `;globalThis[Symbol.for("zeta.moduleBodyStart")]?.(${JSON.stringify(path)});\n`;
63
63
  }
64
64
 
65
65
  function completionMarker(path: string): string {
66
- return `\n;globalThis[Symbol.for("omp.moduleLoadComplete")]?.(${JSON.stringify(path)});\n`;
66
+ return `\n;globalThis[Symbol.for("zeta.moduleLoadComplete")]?.(${JSON.stringify(path)});\n`;
67
67
  }
68
68
 
69
69
  function instrumentContents(path: string, contents: string): string {
package/src/postmortem.ts CHANGED
@@ -332,7 +332,7 @@ function faultWorkerIpcChannels(err: Error): void {
332
332
  /**
333
333
  * Graceful shutdown driven by `process.stdout`'s own `error` event.
334
334
  *
335
- * A closed stdout consumer (`zeta-c --help | head`, an ACP client dropping the
335
+ * A closed stdout consumer (`zetacode --help | head`, an ACP client dropping the
336
336
  * pipe) delivers the broken-pipe write here — attributable to stdout by
337
337
  * construction, unlike a process-wide `syscall: "write"` match that a closed
338
338
  * subprocess stdin or socket would also satisfy — so it runs cleanup and exits
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Set the OS-visible process name (`/proc/self/comm`) so `omp` shows up as
3
- * `omp` — not `bun` — in `ps`, `pgrep`, `killall`, `top`, `htop`, and systemd.
2
+ * Set the OS-visible process name (`/proc/self/comm`) so `zetacode` shows up as
3
+ * `zetacode` — not `bun` — in `ps`, `pgrep`, `killall`, `top`, `htop`, and systemd.
4
4
  *
5
5
  * Bun's `process.title` setter only stores the value on the JS side; unlike
6
6
  * Node/libuv it never calls `prctl(PR_SET_NAME)`, so the kernel's `comm` stays
package/src/prompt.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { LRUCache } from "./lru";
1
2
  import type { HelperDelegate, Template } from "./template";
2
3
  import * as Handlebars from "./template";
3
4
 
@@ -526,7 +527,8 @@ export function registerPartial(name: string, fn: Template): void {
526
527
  handlebars.registerPartial(name, fn);
527
528
  }
528
529
 
529
- const compiledTemplateCache = new Map<string, (context: TemplateContext) => string>();
530
+ // Bounded: some callers compile template text that already embeds user arguments.
531
+ const compiledTemplateCache = new LRUCache<string, (context: TemplateContext) => string>({ max: 256 });
530
532
 
531
533
  export function compile(template: string): (context: TemplateContext) => string {
532
534
  // Keyed on the raw template so repeat renders skip parsing.