@oh-my-pi/pi-utils 15.10.10 → 15.10.11

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.
@@ -117,6 +117,8 @@ export declare function getAutoresearchDbPath(encodedProject: string): string;
117
117
  export declare function getAutoresearchRunDir(encodedProject: string, runId: number): string;
118
118
  /** Get the path to agent.db (SQLite database for settings and auth storage). */
119
119
  export declare function getAgentDbPath(agentDir?: string): string;
120
+ /** Get the last-seen-changelog-version marker file (~/.omp/agent/last-changelog-version). */
121
+ export declare function getLastChangelogVersionPath(agentDir?: string): string;
120
122
  /** Get the path to history.db (SQLite database for session history). */
121
123
  export declare function getHistoryDbPath(agentDir?: string): string;
122
124
  /** Get the path to models.db (model cache database). */
@@ -52,4 +52,8 @@ export declare function isBunTestRuntime(): boolean;
52
52
  * first for cheap fast-path detection.
53
53
  */
54
54
  export declare function isCompiledBinary(): boolean;
55
+ /** Called by CLI entrypoints whose main module dispatches worker argv selectors. */
56
+ export declare function declareWorkerHostEntry(): void;
57
+ /** Main-module path of the self-dispatching CLI host, or null outside it. */
58
+ export declare function workerHostEntry(): string | null;
55
59
  export declare function $flag(name: string, def?: boolean): boolean;
@@ -31,6 +31,15 @@ export declare function info(message: string, context?: Record<string, unknown>)
31
31
  * @param context - The context to log.
32
32
  */
33
33
  export declare function debug(message: string, context?: Record<string, unknown>): void;
34
+ /**
35
+ * Streaming startup markers, enabled by `PI_DEBUG_STARTUP`. Unlike the
36
+ * PI_TIMING tree (printed only after startup completes), these write one
37
+ * synchronous stderr line as each phase begins/ends, so a hard hang still
38
+ * shows the last phase that started. `fs.writeSync(2)` is used deliberately:
39
+ * it cannot be reordered or buffered past a synchronous block of the event
40
+ * loop (dlopen, sync fs on a dead mount, spawnSync).
41
+ */
42
+ export declare function startupMarker(text: string): void;
34
43
  export declare function timingModeIncludes(option: "full" | "x"): boolean;
35
44
  export declare function shouldExitAfterTimings(): boolean;
36
45
  /**
@@ -55,6 +64,12 @@ export declare function recordModuleLoadSpan(path: string, start: number, durati
55
64
  * End timing window and clear buffers.
56
65
  */
57
66
  export declare function endTiming(): void;
67
+ /**
68
+ * Ops of the currently-open span chain (root → deepest), following the most
69
+ * recently started unfinished child at each level. Lets a startup watchdog
70
+ * name the phase a stalled startup is stuck in.
71
+ */
72
+ export declare function openSpanPath(): string[];
58
73
  /**
59
74
  * Time a span. Three forms:
60
75
  * time(op) — point event (zero-duration breadcrumb)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-utils",
4
- "version": "15.10.10",
4
+ "version": "15.10.11",
5
5
  "description": "Shared utilities for pi packages",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -31,7 +31,7 @@
31
31
  "fmt": "biome format --write ."
32
32
  },
33
33
  "dependencies": {
34
- "@oh-my-pi/pi-natives": "15.10.10",
34
+ "@oh-my-pi/pi-natives": "15.10.11",
35
35
  "beautiful-mermaid": "^1.1.3",
36
36
  "handlebars": "^4.7.9",
37
37
  "winston": "^3.19.0",
package/src/cli.ts CHANGED
@@ -9,8 +9,25 @@
9
9
  * - Lazy command imports (only the invoked command is loaded)
10
10
  * - Typed `this.parse()` output matching oclif's API shape
11
11
  */
12
+ import * as fs from "node:fs";
12
13
  import { parseArgs as nodeParseArgs } from "node:util";
13
14
 
15
+ /**
16
+ * Streaming startup marker, enabled by `PI_DEBUG_STARTUP`. Local copy of
17
+ * `logger.startupMarker` so the minimal `--version`/bootstrap import graph
18
+ * stays free of the winston-backed logger module. Synchronous on purpose:
19
+ * a command module whose import hangs (dlopen, fs on a dead mount) must
20
+ * still leave its `:start` marker behind.
21
+ */
22
+ function startupMarker(text: string): void {
23
+ if (!process.env.PI_DEBUG_STARTUP) return;
24
+ try {
25
+ fs.writeSync(2, `[startup] ${text}\n`);
26
+ } catch {
27
+ // stderr unavailable; markers are best-effort
28
+ }
29
+ }
30
+
14
31
  // ---------------------------------------------------------------------------
15
32
  // Flag & Arg descriptors
16
33
  // ---------------------------------------------------------------------------
@@ -392,14 +409,14 @@ export async function run(opts: RunOptions): Promise<void> {
392
409
  return;
393
410
  }
394
411
 
395
- // Per-command help
412
+ // Per-command help: load only the requested command. Loading the full
413
+ // command table here would make `omp <cmd> --help` hang or crash whenever
414
+ // any *unrelated* command module misbehaves at import time.
396
415
  if (commandArgv.includes("--help") || commandArgv.includes("-h")) {
397
- const config = await loadAllCommands(opts);
398
- // Resolve aliases for help too
399
416
  const entry = findEntry(opts.commands, commandId);
400
- const Cmd = entry ? config.commands.get(entry.name) : undefined;
401
- if (Cmd) {
402
- renderCommandHelp(bin, entry!.name, Cmd);
417
+ if (entry) {
418
+ const Cmd = await loadEntry(entry);
419
+ renderCommandHelp(bin, entry.name, Cmd);
403
420
  } else {
404
421
  process.stderr.write(`Unknown command: ${commandId}\n`);
405
422
  }
@@ -415,16 +432,24 @@ export async function run(opts: RunOptions): Promise<void> {
415
432
  return;
416
433
  }
417
434
 
418
- const Cmd = await entry.load();
435
+ const Cmd = await loadEntry(entry);
419
436
  const config: CliConfig = { bin, version, commands: new Map([[entry.name, Cmd]]) };
420
437
  const instance = new Cmd(commandArgv, config);
421
438
  await instance.run();
422
439
  }
423
440
 
441
+ /** Load one command module, leaving streaming markers around the import. */
442
+ async function loadEntry(entry: CommandEntry): Promise<CommandCtor> {
443
+ startupMarker(`cli:load:${entry.name}:start`);
444
+ const Cmd = await entry.load();
445
+ startupMarker(`cli:load:${entry.name}:done`);
446
+ return Cmd;
447
+ }
448
+
424
449
  /** Resolve all command loaders for help/alias display. */
425
450
  async function loadAllCommands(opts: RunOptions): Promise<CliConfig> {
426
451
  const commands = new Map<string, CommandCtor>();
427
- const loaded = await Promise.all(opts.commands.map(async e => [e.name, await e.load()] as const));
452
+ const loaded = await Promise.all(opts.commands.map(async e => [e.name, await loadEntry(e)] as const));
428
453
  for (const [name, Cmd] of loaded) {
429
454
  commands.set(name, Cmd);
430
455
  }
package/src/dirs.ts CHANGED
@@ -398,6 +398,11 @@ export function getAgentDbPath(agentDir?: string): string {
398
398
  return dirs.agentSubdir(agentDir, "agent.db", "data");
399
399
  }
400
400
 
401
+ /** Get the last-seen-changelog-version marker file (~/.omp/agent/last-changelog-version). */
402
+ export function getLastChangelogVersionPath(agentDir?: string): string {
403
+ return dirs.agentSubdir(agentDir, "last-changelog-version", "state");
404
+ }
405
+
401
406
  /** Get the path to history.db (SQLite database for session history). */
402
407
  export function getHistoryDbPath(agentDir?: string): string {
403
408
  return dirs.agentSubdir(agentDir, "history.db", "data");
package/src/env.ts CHANGED
@@ -156,11 +156,31 @@ export function isBunTestRuntime(): boolean {
156
156
  * first for cheap fast-path detection.
157
157
  */
158
158
  export function isCompiledBinary(): boolean {
159
- if (Bun.env.PI_COMPILED) return true;
159
+ if (process.env.PI_COMPILED || Bun.env.PI_COMPILED) return true;
160
160
  const url = import.meta.url;
161
161
  return url.includes("$bunfs") || url.includes("~BUN") || url.includes("%7EBUN");
162
162
  }
163
163
 
164
+ /**
165
+ * Main-module path declared by self-dispatching CLI entrypoints — entries
166
+ * whose top-level argv handling routes hidden `__omp_*` worker selectors.
167
+ * Worker spawn sites re-enter this module via `new Worker(entry, { argv })`,
168
+ * so every distribution (source, npm bundle, compiled binary) needs exactly
169
+ * one JavaScript entrypoint. Never set under `bun test`, SDK embedding, or
170
+ * standalone package bins — those hosts load worker modules directly.
171
+ */
172
+ let workerHostMain: string | null = null;
173
+
174
+ /** Called by CLI entrypoints whose main module dispatches worker argv selectors. */
175
+ export function declareWorkerHostEntry(): void {
176
+ workerHostMain = Bun.main;
177
+ }
178
+
179
+ /** Main-module path of the self-dispatching CLI host, or null outside it. */
180
+ export function workerHostEntry(): string | null {
181
+ return workerHostMain;
182
+ }
183
+
164
184
  const TRUTHY: Dict<boolean> = {
165
185
  "1": true,
166
186
  Y: true,
package/src/logger.ts CHANGED
@@ -47,25 +47,30 @@ function jsonReplacer(_key: string, value: unknown): unknown {
47
47
  return value;
48
48
  }
49
49
 
50
- /** Custom format that includes pid and flattens metadata */
51
- const logFormat = winston.format.combine(
52
- winston.format.timestamp({ format: "YYYY-MM-DDTHH:mm:ss.SSSZ" }),
53
- winston.format.printf(({ timestamp, level, message, ...meta }) => {
54
- const entry: Record<string, unknown> = {
55
- timestamp,
56
- level,
57
- pid: process.pid,
58
- message,
59
- };
60
- // Flatten metadata into entry
61
- for (const [key, value] of Object.entries(meta)) {
62
- if (key !== "level" && key !== "timestamp" && key !== "message") {
63
- entry[key] = value;
50
+ /** Custom format that includes pid and flattens metadata; built on first use. */
51
+ let logFormat: winston.Logform.Format | undefined;
52
+
53
+ function getLogFormat(): winston.Logform.Format {
54
+ logFormat ??= winston.format.combine(
55
+ winston.format.timestamp({ format: "YYYY-MM-DDTHH:mm:ss.SSSZ" }),
56
+ winston.format.printf(({ timestamp, level, message, ...meta }) => {
57
+ const entry: Record<string, unknown> = {
58
+ timestamp,
59
+ level,
60
+ pid: process.pid,
61
+ message,
62
+ };
63
+ // Flatten metadata into entry
64
+ for (const [key, value] of Object.entries(meta)) {
65
+ if (key !== "level" && key !== "timestamp" && key !== "message") {
66
+ entry[key] = value;
67
+ }
64
68
  }
65
- }
66
- return JSON.stringify(entry, jsonReplacer);
67
- }),
68
- );
69
+ return JSON.stringify(entry, jsonReplacer);
70
+ }),
71
+ );
72
+ return logFormat;
73
+ }
69
74
 
70
75
  /** Build a rotating file transport, materializing the target directory lazily. */
71
76
  function makeFileTransport(dir?: string): winston.transport {
@@ -80,17 +85,35 @@ function makeFileTransport(dir?: string): winston.transport {
80
85
  }
81
86
 
82
87
  function makeConsoleTransport(): winston.transport {
83
- return new winston.transports.Console({ format: logFormat });
88
+ return new winston.transports.Console({ format: getLogFormat() });
84
89
  }
85
90
 
86
- /** The winston logger instance. Default: file ON (TUI-safe), console OFF. */
87
- const winstonLogger = winston.createLogger({
88
- level: "debug",
89
- format: logFormat,
90
- transports: [makeFileTransport()],
91
- // Don't exit on error - logging failures shouldn't crash the app
92
- exitOnError: false,
93
- });
91
+ /**
92
+ * Desired transport configuration, applied when the winston logger is built.
93
+ * Default: file ON (TUI-safe), console OFF.
94
+ */
95
+ let transportOpts: { console?: boolean; file?: boolean | string } = { file: true };
96
+
97
+ /** The winston logger instance, created lazily on first log emission. */
98
+ let winstonLogger: winston.Logger | undefined;
99
+
100
+ function buildTransports(opts: { console?: boolean; file?: boolean | string }): winston.transport[] {
101
+ const transports: winston.transport[] = [];
102
+ if (opts.file) transports.push(makeFileTransport(typeof opts.file === "string" ? opts.file : undefined));
103
+ if (opts.console) transports.push(makeConsoleTransport());
104
+ return transports;
105
+ }
106
+
107
+ function getWinstonLogger(): winston.Logger {
108
+ winstonLogger ??= winston.createLogger({
109
+ level: "debug",
110
+ format: getLogFormat(),
111
+ transports: buildTransports(transportOpts),
112
+ // Don't exit on error - logging failures shouldn't crash the app
113
+ exitOnError: false,
114
+ });
115
+ return winstonLogger;
116
+ }
94
117
 
95
118
  /**
96
119
  * Replace the active log transports. Pass `console: true, file: false` for
@@ -98,11 +121,10 @@ const winstonLogger = winston.createLogger({
98
121
  * logs piped into a process supervisor instead of the rotating file.
99
122
  */
100
123
  export function setTransports(opts: { console?: boolean; file?: boolean | string }): void {
124
+ transportOpts = opts;
125
+ if (!winstonLogger) return; // applied lazily when the logger is first built
101
126
  winstonLogger.clear();
102
- if (opts.file) {
103
- winstonLogger.add(makeFileTransport(typeof opts.file === "string" ? opts.file : undefined));
104
- }
105
- if (opts.console) winstonLogger.add(makeConsoleTransport());
127
+ for (const transport of buildTransports(opts)) winstonLogger.add(transport);
106
128
  }
107
129
 
108
130
  /**
@@ -112,7 +134,7 @@ export function setTransports(opts: { console?: boolean; file?: boolean | string
112
134
  */
113
135
  export function error(message: string, context?: Record<string, unknown>): void {
114
136
  try {
115
- winstonLogger.error(message, context);
137
+ getWinstonLogger().error(message, context);
116
138
  } catch {
117
139
  // Silently ignore logging failures
118
140
  }
@@ -125,7 +147,7 @@ export function error(message: string, context?: Record<string, unknown>): void
125
147
  */
126
148
  export function warn(message: string, context?: Record<string, unknown>): void {
127
149
  try {
128
- winstonLogger.warn(message, context);
150
+ getWinstonLogger().warn(message, context);
129
151
  } catch {
130
152
  // Silently ignore logging failures
131
153
  }
@@ -138,7 +160,7 @@ export function warn(message: string, context?: Record<string, unknown>): void {
138
160
  */
139
161
  export function info(message: string, context?: Record<string, unknown>): void {
140
162
  try {
141
- winstonLogger.info(message, context);
163
+ getWinstonLogger().info(message, context);
142
164
  } catch {
143
165
  // Silently ignore logging failures
144
166
  }
@@ -151,12 +173,29 @@ export function info(message: string, context?: Record<string, unknown>): void {
151
173
  */
152
174
  export function debug(message: string, context?: Record<string, unknown>): void {
153
175
  try {
154
- winstonLogger.debug(message, context);
176
+ getWinstonLogger().debug(message, context);
155
177
  } catch {
156
178
  // Silently ignore logging failures
157
179
  }
158
180
  }
159
181
 
182
+ /**
183
+ * Streaming startup markers, enabled by `PI_DEBUG_STARTUP`. Unlike the
184
+ * PI_TIMING tree (printed only after startup completes), these write one
185
+ * synchronous stderr line as each phase begins/ends, so a hard hang still
186
+ * shows the last phase that started. `fs.writeSync(2)` is used deliberately:
187
+ * it cannot be reordered or buffered past a synchronous block of the event
188
+ * loop (dlopen, sync fs on a dead mount, spawnSync).
189
+ */
190
+ export function startupMarker(text: string): void {
191
+ if (!process.env.PI_DEBUG_STARTUP) return;
192
+ try {
193
+ fs.writeSync(2, `[startup] ${text}\n`);
194
+ } catch {
195
+ // stderr unavailable; markers are best-effort
196
+ }
197
+ }
198
+
160
199
  const LOGGED_TIMING_THRESHOLD_MS = 0.5;
161
200
 
162
201
  interface Span {
@@ -329,6 +368,29 @@ export function endTiming(): void {
329
368
  gRecordTimings = false;
330
369
  }
331
370
 
371
+ /**
372
+ * Ops of the currently-open span chain (root → deepest), following the most
373
+ * recently started unfinished child at each level. Lets a startup watchdog
374
+ * name the phase a stalled startup is stuck in.
375
+ */
376
+ export function openSpanPath(): string[] {
377
+ const ops: string[] = [];
378
+ let node = gRootSpan;
379
+ while (node) {
380
+ let next: Span | undefined;
381
+ for (let i = node.children.length - 1; i >= 0; i--) {
382
+ if (node.children[i].end === undefined) {
383
+ next = node.children[i];
384
+ break;
385
+ }
386
+ }
387
+ if (!next) break;
388
+ ops.push(next.op);
389
+ node = next;
390
+ }
391
+ return ops;
392
+ }
393
+
332
394
  function durationOf(span: Span): number {
333
395
  if (span.point || span.end === undefined) return 0;
334
396
  return span.end - span.start;
@@ -550,33 +612,51 @@ function isParallel(span: Span): boolean {
550
612
  export function time(op: string): void;
551
613
  export function time<T, A extends unknown[]>(op: string, fn: (...args: A) => T, ...args: A): T;
552
614
  export function time<T, A extends unknown[]>(op: string, fn?: (...args: A) => T, ...args: A): T | undefined {
553
- if (!gRecordTimings || !gRootSpan) {
554
- if (fn === undefined) return undefined as T;
555
- return fn(...args);
556
- }
557
-
558
- const parent = spanStorage.getStore() ?? gRootSpan;
559
- const span: Span = { op, start: performance.now(), parent, children: [] };
560
- parent.children.push(span);
615
+ const recording = gRecordTimings && gRootSpan !== undefined;
561
616
 
562
617
  if (fn === undefined) {
563
- span.end = span.start;
564
- span.point = true;
618
+ startupMarker(op);
619
+ if (!recording) return undefined as T;
620
+ const parent = spanStorage.getStore() ?? gRootSpan!;
621
+ const now = performance.now();
622
+ parent.children.push({ op, start: now, end: now, parent, children: [], point: true });
565
623
  return undefined as T;
566
624
  }
567
625
 
568
- const finish = (): void => {
569
- span.end = performance.now();
626
+ if (!recording && !process.env.PI_DEBUG_STARTUP) {
627
+ return fn(...args);
628
+ }
629
+
630
+ startupMarker(`${op}:start`);
631
+ let span: Span | undefined;
632
+ if (recording) {
633
+ const parent = spanStorage.getStore() ?? gRootSpan!;
634
+ span = { op, start: performance.now(), parent, children: [] };
635
+ parent.children.push(span);
636
+ }
637
+
638
+ const finish = (ok: boolean): void => {
639
+ if (span) span.end = performance.now();
640
+ startupMarker(ok ? `${op}:done` : `${op}:fail`);
570
641
  };
571
642
  try {
572
- const result = spanStorage.run(span, () => fn(...args));
643
+ const result = span ? spanStorage.run(span, () => fn(...args)) : fn(...args);
573
644
  if (isPromise(result)) {
574
- return result.finally(finish) as T;
645
+ return result.then(
646
+ value => {
647
+ finish(true);
648
+ return value;
649
+ },
650
+ error => {
651
+ finish(false);
652
+ throw error;
653
+ },
654
+ ) as T;
575
655
  }
576
- finish();
656
+ finish(true);
577
657
  return result;
578
658
  } catch (error) {
579
- finish();
659
+ finish(false);
580
660
  throw error;
581
661
  }
582
662
  }
package/src/prompt.ts CHANGED
@@ -13,14 +13,53 @@ export interface PromptFormatOptions {
13
13
 
14
14
  // Opening XML tag (not self-closing, not closing)
15
15
  const OPENING_XML = /^<([a-z_-]+)(?:\s+[^>]*)?>$/;
16
- // Closing XML tag
17
- const CLOSING_XML = /^<\/([a-z_-]+)>$/;
18
- // Handlebars block end: {{/if}}, {{/has}}, {{/list}}, etc.
19
- const CLOSING_HBS = /^\{\{\//;
16
+
17
+ /**
18
+ * Closing XML tag matcher, manual equivalent of `/^<\/([a-z_-]+)>$/` — avoids a
19
+ * RegExp exec (and match array allocation) per `<`-prefixed line. Caller
20
+ * guarantees `s` starts `</`.
21
+ */
22
+ function closingTagName(s: string): string | null {
23
+ const n = s.length;
24
+ if (n < 4 || s.charCodeAt(n - 1) !== 62 /* > */) return null;
25
+ for (let j = 2; j < n - 1; j++) {
26
+ const c = s.charCodeAt(j);
27
+ if (!((c >= 97 /* a */ && c <= 122) /* z */ || c === 45 /* - */ || c === 95) /* _ */) return null;
28
+ }
29
+ return s.slice(2, n - 1);
30
+ }
31
+
32
+ /**
33
+ * Manual equivalent of {@link OPENING_XML}. Caller guarantees `s` starts with
34
+ * `<` but not `</`. Falls back to the regex when the char after the tag name
35
+ * is non-ASCII (possible unicode whitespace).
36
+ */
37
+ function openingTagName(s: string): string | null {
38
+ const n = s.length;
39
+ if (n < 3 || s.charCodeAt(n - 1) !== 62 /* > */) return null;
40
+ let j = 1;
41
+ while (j < n - 1) {
42
+ const c = s.charCodeAt(j);
43
+ if ((c >= 97 /* a */ && c <= 122) /* z */ || c === 45 /* - */ || c === 95 /* _ */) j++;
44
+ else break;
45
+ }
46
+ if (j === 1) return null;
47
+ if (j === n - 1) return s.slice(1, j); // `<tag>`
48
+ const c = s.charCodeAt(j);
49
+ if (c !== 32 /* space */ && c !== 9 /* tab */) {
50
+ if (c < 128) return null;
51
+ const match = OPENING_XML.exec(s);
52
+ return match ? match[1] : null;
53
+ }
54
+ // `\s+[^>]*>$` ⇔ no further `>` before the final char.
55
+ return s.indexOf(">", j + 1) === n - 1 ? s.slice(1, j) : null;
56
+ }
20
57
  // Table row
21
58
  const TABLE_ROW = /^\|.*\|$/;
22
59
  // Table separator (|---|---|)
23
60
  const TABLE_SEP = /^\|[-:\s|]+\|$/;
61
+ // Any non-whitespace char — blank-line check without allocating a trimmed copy
62
+ const NON_BLANK = /\S/;
24
63
 
25
64
  /**
26
65
  * RFC 2119 keywords (plus project aliases NEVER/AVOID) wrapped in markdown bold
@@ -28,6 +67,19 @@ const TABLE_SEP = /^\|[-:\s|]+\|$/;
28
67
  */
29
68
  const RFC2119_BOLD = /\*\*(MUST NOT|SHOULD NOT|RECOMMENDED|REQUIRED|OPTIONAL|SHOULD|MUST|MAY|NEVER|AVOID)\*\*/g;
30
69
 
70
+ /**
71
+ * Fast pre-check for {@link normalizeRfc2119}: a line that lacks every one of
72
+ * these substrings is untouched by all three replacements, so the
73
+ * split/replace/join machinery can be skipped entirely.
74
+ */
75
+ const RFC2119_GUARD = /\*\*(?:MUST|SHOULD|RECOMMENDED|REQUIRED|OPTIONAL|MAY|NEVER|AVOID)|MUST NOT|SHOULD NOT/;
76
+ const MUST_NOT = /\bMUST NOT\b/g;
77
+ const SHOULD_NOT = /\bSHOULD NOT\b/g;
78
+
79
+ function applyRfc2119(text: string): string {
80
+ return text.replace(RFC2119_BOLD, "$1").replace(MUST_NOT, "NEVER").replace(SHOULD_NOT, "AVOID");
81
+ }
82
+
31
83
  /**
32
84
  * Normalize RFC 2119 markers per project convention:
33
85
  * - Strip `**KEYWORD**` bold (visual noise, no semantics).
@@ -35,12 +87,11 @@ const RFC2119_BOLD = /\*\*(MUST NOT|SHOULD NOT|RECOMMENDED|REQUIRED|OPTIONAL|SHO
35
87
  * Skips spans inside inline code (`` `…` ``) so alias definitions can be quoted literally.
36
88
  */
37
89
  function normalizeRfc2119(line: string): string {
90
+ if (!RFC2119_GUARD.test(line)) return line;
91
+ if (!line.includes("`")) return applyRfc2119(line);
38
92
  const segments = line.split("`");
39
93
  for (let i = 0; i < segments.length; i += 2) {
40
- segments[i] = segments[i]
41
- .replace(RFC2119_BOLD, "$1")
42
- .replace(/\bMUST NOT\b/g, "NEVER")
43
- .replace(/\bSHOULD NOT\b/g, "AVOID");
94
+ segments[i] = applyRfc2119(segments[i]);
44
95
  }
45
96
  return segments.join("`");
46
97
  }
@@ -73,19 +124,31 @@ type HtmlCommentState = {
73
124
  inHtmlComment: boolean;
74
125
  };
75
126
 
127
+ // Single-pass alternation equivalent to the former chain of seven .replace()
128
+ // calls. Alternative order mirrors the old sequential order (`<->` before
129
+ // `->`/`<-`), and every replacement emits a non-ASCII char, so one pass
130
+ // produces byte-identical output to the sequential passes.
131
+ const ASCII_SYMBOLS = /\.{3}|<->|->|<-|!=|<=|>=/g;
132
+ const ASCII_SYMBOL_REPLACEMENTS: Record<string, string> = {
133
+ "...": "…",
134
+ "<->": "↔",
135
+ "->": "→",
136
+ "<-": "←",
137
+ "!=": "≠",
138
+ "<=": "≤",
139
+ ">=": "≥",
140
+ };
141
+ const replaceAsciiSymbol = (match: string): string => ASCII_SYMBOL_REPLACEMENTS[match];
142
+
76
143
  function replaceCommonAsciiSymbols(line: string): string {
77
- return line
78
- .replace(/\.{3}/g, "…")
79
- .replace(/<->/g, "↔")
80
- .replace(/->/g, "→")
81
- .replace(/<-/g, "←")
82
- .replace(/!=/g, "≠")
83
- .replace(/<=/g, "≤")
84
- .replace(/>=/g, "≥");
144
+ return line.replace(ASCII_SYMBOLS, replaceAsciiSymbol);
85
145
  }
86
146
 
87
147
  function replaceCommonAsciiSymbolsOutsideHtmlComments(line: string, state: HtmlCommentState): string {
88
- if (!state.inHtmlComment && !line.includes(HTML_COMMENT_OPEN) && !line.includes(HTML_COMMENT_CLOSE)) {
148
+ // When not inside a comment, a line without `<!--` takes the fast path even
149
+ // if it contains `-->`: the slow path would hit openIndex === -1 and replace
150
+ // the whole line identically.
151
+ if (!state.inHtmlComment && !line.includes(HTML_COMMENT_OPEN)) {
89
152
  return replaceCommonAsciiSymbols(line);
90
153
  }
91
154
 
@@ -133,86 +196,111 @@ export function format(content: string, options: PromptFormatOptions = {}): stri
133
196
  } = options;
134
197
  const isPreRender = renderPhase === "pre-render";
135
198
  const lines = content.split("\n");
136
- const result: string[] = [];
199
+ const result: string[] = new Array(lines.length);
200
+ let n = 0; // logical length of `result` (pops are n--)
137
201
  let inCodeBlock = false;
138
202
 
139
203
  const htmlCommentState: HtmlCommentState = { inHtmlComment: false };
140
204
  const topLevelTags: string[] = [];
141
205
 
142
206
  for (let i = 0; i < lines.length; i++) {
143
- let line = lines[i].trimEnd();
144
- let trimmedStart = line.trimStart();
145
- if (trimmedStart.startsWith("```") || trimmedStart.startsWith("~~~")) {
207
+ const raw = lines[i];
208
+ // charCode fast paths: only pay for trimEnd when the last char might be
209
+ // whitespace (<= 0x20 ASCII ws/controls, >= 0x80 unicode ws). Untouched
210
+ // lines are pushed as the original string — no allocation.
211
+ const last = raw.charCodeAt(raw.length - 1);
212
+ let line = last <= 32 || last >= 128 ? raw.trimEnd() : raw;
213
+ // Locate the first non-whitespace char without allocating a trimStart
214
+ // copy; `s` is the indent width, `first` the char code there (NaN when
215
+ // the line is blank).
216
+ let s = 0;
217
+ let first = line.charCodeAt(0);
218
+ while (first === 32 /* space */ || first === 9 /* tab */) first = line.charCodeAt(++s);
219
+ if (first >= 128) {
220
+ // Possible unicode leading whitespace — defer to trimStart for exactness.
221
+ s = line.length - line.trimStart().length;
222
+ first = line.charCodeAt(s);
223
+ }
224
+
225
+ if ((first === 96 /* ` */ || first === 126) /* ~ */ && (line.startsWith("```", s) || line.startsWith("~~~", s))) {
146
226
  inCodeBlock = !inCodeBlock;
147
- result.push(line);
227
+ result[n++] = line;
148
228
  continue;
149
229
  }
150
230
 
151
231
  if (inCodeBlock) {
152
- result.push(line);
232
+ result[n++] = line;
153
233
  continue;
154
234
  }
155
235
 
156
236
  if (replaceAsciiSymbols) {
157
- line = replaceCommonAsciiSymbolsOutsideHtmlComments(line, htmlCommentState);
158
- }
159
- trimmedStart = line.trimStart();
160
- const trimmed = line.trim();
161
-
162
- const isOpeningXml = OPENING_XML.test(trimmedStart) && !trimmedStart.endsWith("/>");
163
- if (isOpeningXml && line.length === trimmedStart.length) {
164
- const match = OPENING_XML.exec(trimmedStart);
165
- if (match) topLevelTags.push(match[1]);
237
+ const replaced = replaceCommonAsciiSymbolsOutsideHtmlComments(line, htmlCommentState);
238
+ if (replaced !== line) {
239
+ line = replaced;
240
+ s = 0;
241
+ first = line.charCodeAt(0);
242
+ while (first === 32 || first === 9) first = line.charCodeAt(++s);
243
+ if (first >= 128) {
244
+ s = line.length - line.trimStart().length;
245
+ first = line.charCodeAt(s);
246
+ }
247
+ }
166
248
  }
167
249
 
168
- const closingMatch = CLOSING_XML.exec(trimmedStart);
169
- if (closingMatch) {
170
- const tagName = closingMatch[1];
171
- if (topLevelTags.length > 0 && topLevelTags[topLevelTags.length - 1] === tagName) {
172
- topLevelTags.pop();
250
+ let isClosingLine = false;
251
+ if (first === 60 /* < */) {
252
+ const trimmedStart = s === 0 ? line : line.slice(s);
253
+ if (trimmedStart.charCodeAt(1) === 47 /* / */) {
254
+ const tagName = closingTagName(trimmedStart);
255
+ if (tagName !== null) {
256
+ isClosingLine = true;
257
+ if (topLevelTags.length > 0 && topLevelTags[topLevelTags.length - 1] === tagName) {
258
+ topLevelTags.pop();
259
+ }
260
+ }
261
+ } else if (s === 0 && !trimmedStart.endsWith("/>")) {
262
+ const tagName = openingTagName(trimmedStart);
263
+ if (tagName !== null) topLevelTags.push(tagName);
264
+ }
265
+ } else if (first === 124 /* | */) {
266
+ const trimmedStart = s === 0 ? line : line.slice(s);
267
+ if (TABLE_SEP.test(trimmedStart)) {
268
+ line = `${line.slice(0, s)}${compactTableSep(trimmedStart)}`;
269
+ } else if (TABLE_ROW.test(trimmedStart)) {
270
+ line = `${line.slice(0, s)}${compactTableRow(trimmedStart)}`;
173
271
  }
174
- } else if (isPreRender && trimmedStart.startsWith("{{")) {
175
- /* keep indentation as-is in pre-render for Handlebars markers */
176
- } else if (TABLE_SEP.test(trimmedStart)) {
177
- const leadingWhitespace = line.slice(0, line.length - trimmedStart.length);
178
- line = `${leadingWhitespace}${compactTableSep(trimmedStart)}`;
179
- } else if (TABLE_ROW.test(trimmedStart)) {
180
- const leadingWhitespace = line.slice(0, line.length - trimmedStart.length);
181
- line = `${leadingWhitespace}${compactTableRow(trimmedStart)}`;
182
272
  }
183
273
 
184
274
  if (shouldNormalizeRfc2119) {
185
275
  line = normalizeRfc2119(line);
186
276
  }
187
277
 
188
- if (trimmed === "") {
189
- const nextLine = lines[i + 1]?.trim() ?? "";
278
+ if (s >= line.length) {
279
+ // Blank line (`line` carries no trailing whitespace, so it is "").
280
+ const next = lines[i + 1];
190
281
  // Strip any run of 2+ consecutive blank lines entirely; preserve a single blank.
191
- if (nextLine === "") {
192
- while (result.length > 0 && result[result.length - 1].trim() === "") {
193
- result.pop();
194
- }
195
- while (i + 1 < lines.length && lines[i + 1].trim() === "") i++;
282
+ if (next === undefined || next.length === 0 || !NON_BLANK.test(next)) {
283
+ while (n > 0 && result[n - 1].length === 0) n--;
284
+ let j = i + 1;
285
+ while (j < lines.length && (lines[j].length === 0 || !NON_BLANK.test(lines[j]))) j++;
286
+ i = j - 1;
196
287
  continue;
197
288
  }
198
- const prevLine = result[result.length - 1]?.trim() ?? "";
199
- if (prevLine === "") {
289
+ if (n === 0 || result[n - 1].length === 0) {
200
290
  continue;
201
291
  }
202
292
  }
203
293
 
204
- if (CLOSING_XML.test(trimmed) || (isPreRender && CLOSING_HBS.test(trimmed))) {
205
- while (result.length > 0 && result[result.length - 1].trim() === "") {
206
- result.pop();
207
- }
294
+ // CLOSING_HBS (`/^\{\{\//`) startsWith("{{/") at the indent offset.
295
+ if (isClosingLine || (isPreRender && first === 123 /* { */ && line.startsWith("{{/", s))) {
296
+ while (n > 0 && result[n - 1].length === 0) n--;
208
297
  }
209
298
 
210
- result.push(line);
299
+ result[n++] = line;
211
300
  }
212
301
 
213
- while (result.length > 0 && result[result.length - 1].trim() === "") {
214
- result.pop();
215
- }
302
+ while (n > 0 && result[n - 1].length === 0) n--;
303
+ result.length = n;
216
304
 
217
305
  return result.join("\n");
218
306
  }
@@ -454,13 +542,14 @@ function disambiguateClosingBraces(template: string): string {
454
542
  const compiledTemplateCache = new Map<string, (context: TemplateContext) => string>();
455
543
 
456
544
  export function compile(template: string): (context: TemplateContext) => string {
457
- const disambiguated = disambiguateClosingBraces(template);
458
- const cached = compiledTemplateCache.get(disambiguated);
545
+ // Keyed on the raw template so repeat renders skip disambiguateClosingBraces
546
+ // (a full-template regex pass) as well as the Handlebars compile.
547
+ const cached = compiledTemplateCache.get(template);
459
548
  if (cached) return cached;
460
- const compiled = handlebars.compile(disambiguated, { noEscape: true, strict: false }) as (
549
+ const compiled = handlebars.compile(disambiguateClosingBraces(template), { noEscape: true, strict: false }) as (
461
550
  context: TemplateContext,
462
551
  ) => string;
463
- compiledTemplateCache.set(disambiguated, compiled);
552
+ compiledTemplateCache.set(template, compiled);
464
553
  return compiled;
465
554
  }
466
555
 
package/src/snowflake.ts CHANGED
@@ -1,6 +1,3 @@
1
- // 16-bit hex lookup table (65536 entries) for fast conversion
2
- const HEX4 = Array.from({ length: 65536 }, (_, i) => i.toString(16).padStart(4, "0"));
3
-
4
1
  function randu32() {
5
2
  return crypto.getRandomValues(new Uint32Array(1))[0];
6
3
  }
@@ -28,29 +25,14 @@ namespace Snowflake {
28
25
  //
29
26
  export const MAX_SEQUENCE = MAX_SEQ;
30
27
 
31
- // Parses a hex string or bigint to bigint.
32
- //
33
- function toBigInt(value: Snowflake): bigint {
34
- const hi = Number.parseInt(value.substring(0, 8), 16);
35
- const lo = Number.parseInt(value.substring(8, 16), 16);
36
- return (BigInt(hi) << 32n) | BigInt(lo);
37
- }
38
-
39
28
  // Formats a sequence and timestamp into a snowflake hex string.
40
29
  //
30
+ // dt fits well within BigInt range: (dt << 22) | seq stays under 2^64 for
31
+ // any dt < 2^42 (~year 2154), so a single 64-bit format is exact — and
32
+ // measures ~1.7x faster than stitching four 16-bit hex segments.
33
+ //
41
34
  export function formatParts(dt: number, seq: number): Snowflake {
42
- // Split dt into hi/lo to avoid exceeding Number.MAX_SAFE_INTEGER.
43
- // dt is ~39 bits; dt<<22 would be ~61 bits, so we split at bit 10:
44
- // lo32 = (dtLo << 22) | seq (10+22 = 32 bits, no overlap)
45
- // hi32 = dtHi (~29 bits)
46
- const dtLo = dt % 1024;
47
- const hi = (dt - dtLo) / 1024; // dt >>> 10
48
- const lo = ((dtLo << 22) | seq) >>> 0;
49
- const hi1 = (hi >>> 16) & 0xffff;
50
- const hi2 = hi & 0xffff;
51
- const lo1 = (lo >>> 16) & 0xffff;
52
- const lo2 = lo & 0xffff;
53
- return `${HEX4[hi1]}${HEX4[hi2]}${HEX4[lo1]}${HEX4[lo2]}` as Snowflake;
35
+ return ((BigInt(dt) << 22n) | BigInt(seq)).toString(16).padStart(16, "0") as Snowflake;
54
36
  }
55
37
 
56
38
  // Snowflake generator type.
@@ -85,8 +67,9 @@ namespace Snowflake {
85
67
 
86
68
  // Gets the next snowflake given the timestamp.
87
69
  //
88
- const defaultSource = new Source();
70
+ let defaultSource: Source | undefined;
89
71
  export function next(timestamp = Date.now()): Snowflake {
72
+ defaultSource ??= new Source();
90
73
  return defaultSource.generate(timestamp);
91
74
  }
92
75
 
@@ -125,8 +108,10 @@ namespace Snowflake {
125
108
  return Number.parseInt(value.substring(8, 16), 16) & MAX_SEQ;
126
109
  }
127
110
  export function getTimestamp(value: Snowflake) {
128
- const n = toBigInt(value) >> 22n;
129
- return Number(n + BigInt(EPOCH));
111
+ const hi = Number.parseInt(value.substring(0, 8), 16);
112
+ const lo = Number.parseInt(value.substring(8, 16), 16);
113
+ // (hi:lo) >> 22 == hi * 2^10 + (lo >>> 22); at most ~2^42, exact in a double.
114
+ return hi * 1024 + (lo >>> 22) + EPOCH;
130
115
  }
131
116
  export function getDate(value: Snowflake) {
132
117
  return new Date(getTimestamp(value));