pi-usereq 0.67.0 → 0.68.0

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/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # PI-useReq/pi-usereq (0.67.0)
1
+ # PI-useReq/pi-usereq (0.68.0)
2
2
 
3
3
  <p align="center">
4
4
  <img src="https://img.shields.io/badge/python-3.11%2B-3776AB?style=flat-square&logo=python&logoColor=white" alt="Python 3.11+">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-usereq",
3
- "version": "0.67.0",
3
+ "version": "0.68.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/Ogekuri/PI-useReq.git"
@@ -1,14 +1,14 @@
1
1
  /**
2
2
  * @file
3
3
  * @brief Measures runtime character and token sizes of the canonical context files.
4
- * @details Provides reusable helpers that compute character and `cl100k_base` token estimates for `REQUIREMENTS.md`, `REFERENCES.md`, and `WORKFLOW.md` under `<base-path>/<docs-dir>` so configuration menus and command summaries share one measurement contract. Measurements are memoized per resolved path against the file `mtimeMs` plus `size` signature and reuses the process-cached shared tokenizer, so repeated menu renders resolve unchanged files through filesystem stats only. Runtime is O(n) on first measurement per content revision and O(1) per unchanged remeasure. Side effects are limited to filesystem reads and module-local cache mutation.
4
+ * @details Provides reusable helpers that compute character and `cl100k_base` token estimates for `REQUIREMENTS.md`, `REFERENCES.md`, and `WORKFLOW.md` under `<base-path>/<docs-dir>` so configuration menus and command summaries share one measurement contract. Measurements are memoized per resolved path against the file `mtimeMs` plus `size` signature inside a process-scoped cache that survives extension rebinds, reuse the lean process-cached shared tokenizer counter, and expose idle-time pre-warm scheduling plus a yielding async measurement variant so the one-time tokenizer module load and per-revision content encodes never execute inside a synchronous menu critical path. Runtime is O(n) on first measurement per content revision and O(1) per unchanged remeasure. Side effects are limited to filesystem reads and process-scoped cache mutation.
5
5
  */
6
6
 
7
7
  import fs from "node:fs";
8
8
  import path from "node:path";
9
9
  import { DEFAULT_DOCS_DIR, type UseReqConfig } from "./config.js";
10
10
  import { normalizeRelativeDirContract } from "./path-context.js";
11
- import { countFileMetrics } from "./token-counter.js";
11
+ import { countTextTokensAndChars, prewarmTokenCounterEncoder } from "./token-counter.js";
12
12
  import { ReqError } from "./errors.js";
13
13
 
14
14
  /**
@@ -58,10 +58,29 @@ interface CachedContextFileSizeEntry {
58
58
  const CONTEXT_FILE_SIZE_CACHE_LIMIT = 64;
59
59
 
60
60
  /**
61
- * @brief Stores the module-local context-file measurement cache keyed by resolved path.
62
- * @details Maps each measured absolute context-file path to its stat-signed facts so repeated configuration-menu renders and command summaries reuse identical measurements while any content revision invalidates only the affected entry. Cache state is module-local and never serialized into configuration.
61
+ * @brief Describes the process-scoped context-file measurement state persisted across extension rebinds.
62
+ * @details Stores the stat-signed measurement cache plus the pending pre-warm schedule guards on `globalThis` because pi rebinds extension modules for `/new`, `/resume`, `/fork`, and `/reload`, while the hosting process persists across those operations, so session rebinds never re-trigger the heavy first-measurement path for unchanged context files. The interface is compile-time only and introduces no runtime cost.
63
63
  */
64
- const contextFileSizeCache = new Map<string, CachedContextFileSizeEntry>();
64
+ interface ProcessScopedContextFileSizeStore {
65
+ readonly contextFileSizeCache: Map<string, CachedContextFileSizeEntry>;
66
+ readonly scheduledPrewarmBases: Set<string>;
67
+ }
68
+
69
+ /**
70
+ * @brief Returns the process-scoped context-file measurement store.
71
+ * @details Lazily initializes one `globalThis` record so session rebinds reuse already measured facts and never double-schedule the idle-time pre-warm for one project base. Runtime is O(1). Side effect: initializes process-scoped state on first access.
72
+ * @return {ProcessScopedContextFileSizeStore} Mutable process-scoped measurement store.
73
+ */
74
+ function getProcessScopedContextFileSizeStore(): ProcessScopedContextFileSizeStore {
75
+ const globalScope = globalThis as typeof globalThis & { __piUsereqContextFileSizeStore?: ProcessScopedContextFileSizeStore };
76
+ if (!globalScope.__piUsereqContextFileSizeStore) {
77
+ globalScope.__piUsereqContextFileSizeStore = {
78
+ contextFileSizeCache: new Map<string, CachedContextFileSizeEntry>(),
79
+ scheduledPrewarmBases: new Set<string>(),
80
+ };
81
+ }
82
+ return globalScope.__piUsereqContextFileSizeStore;
83
+ }
65
84
 
66
85
  /**
67
86
  * @brief Resolves one canonical context-file absolute path for one project base.
@@ -83,7 +102,7 @@ export function resolveContextFilePath(
83
102
 
84
103
  /**
85
104
  * @brief Measures one context file into deterministic size facts.
86
- * @details Probes the target with one `stat` call, returns the memoized facts when the observed `mtimeMs` plus `size` signature matches the cached entry, and otherwise reads UTF-8 content and reuses the process-cached shared tokenizer behind `countFileMetrics` for the `cl100k_base` token estimate before storing the fresh facts in the bounded cache. Missing, non-file, and unreadable targets return `MISSING_CONTEXT_FILE_SIZE` facts without throwing and without caching. Runtime is O(1) for unchanged remeasures and O(n) in file size on first measurement per content revision. Side effects are limited to filesystem reads and module-local cache mutation.
105
+ * @details Probes the target with one `stat` call, returns the memoized facts when the observed `mtimeMs` plus `size` signature matches the cached entry, and otherwise reads UTF-8 content and reuses the lean process-cached shared tokenizer counter behind `countTextTokensAndChars` for the `cl100k_base` token estimate before storing the fresh facts in the bounded process-scoped cache. Missing, non-file, and unreadable targets return `MISSING_CONTEXT_FILE_SIZE` facts without throwing and without caching. Runtime is O(1) for unchanged remeasures and O(n) in file size on first measurement per content revision. Side effects are limited to filesystem reads and process-scoped cache mutation.
87
106
  * @param[in] filePath {string} Absolute context-file path to measure.
88
107
  * @return {ContextFileSizeFacts} Measured size facts for the target.
89
108
  * @satisfies REQ-373, REQ-374
@@ -99,17 +118,18 @@ export function measureContextFileSize(filePath: string): ContextFileSizeFacts {
99
118
  return MISSING_CONTEXT_FILE_SIZE;
100
119
  }
101
120
  const statSignature = `${stat.mtimeMs}:${stat.size}`;
102
- const cachedEntry = contextFileSizeCache.get(filePath);
121
+ const cache = getProcessScopedContextFileSizeStore().contextFileSizeCache;
122
+ const cachedEntry = cache.get(filePath);
103
123
  if (cachedEntry && cachedEntry.statSignature === statSignature) {
104
124
  return cachedEntry.facts;
105
125
  }
106
126
  try {
107
- const metrics = countFileMetrics(fs.readFileSync(filePath, "utf8"));
127
+ const metrics = countTextTokensAndChars(fs.readFileSync(filePath, "utf8"));
108
128
  const facts: ContextFileSizeFacts = { exists: true, chars: metrics.chars, tokens: metrics.tokens };
109
- if (contextFileSizeCache.size >= CONTEXT_FILE_SIZE_CACHE_LIMIT) {
110
- contextFileSizeCache.clear();
129
+ if (cache.size >= CONTEXT_FILE_SIZE_CACHE_LIMIT) {
130
+ cache.clear();
111
131
  }
112
- contextFileSizeCache.set(filePath, { statSignature, facts });
132
+ cache.set(filePath, { statSignature, facts });
113
133
  return facts;
114
134
  } catch {
115
135
  return MISSING_CONTEXT_FILE_SIZE;
@@ -118,7 +138,7 @@ export function measureContextFileSize(filePath: string): ContextFileSizeFacts {
118
138
 
119
139
  /**
120
140
  * @brief Measures every canonical context file for one project base.
121
- * @details Iterates `CONTEXT_FILE_NAMES` in documented order, resolves each configured `<base-path>/<docs-dir>` target through `resolveContextFilePath`, and returns the keyed facts record consumed by configuration menus and command summaries. Repeated invocations with unchanged files resolve through the stat-signed measurement cache. Runtime is O(n) in aggregate context-file size on first measurement per content revision and O(1) per unchanged remeasure. Side effects are limited to filesystem reads and module-local cache mutation.
141
+ * @details Iterates `CONTEXT_FILE_NAMES` in documented order, resolves each configured `<base-path>/<docs-dir>` target through `resolveContextFilePath`, and returns the keyed facts record consumed by configuration menus and command summaries. Repeated invocations with unchanged files resolve through the process-scoped stat-signed measurement cache. Runtime is O(n) in aggregate context-file size on first measurement per content revision and O(1) per unchanged remeasure. Side effects are limited to filesystem reads and process-scoped cache mutation.
122
142
  * @param[in] projectBase {string} Absolute project root path.
123
143
  * @param[in] config {UseReqConfig} Effective project configuration supplying the docs directory.
124
144
  * @return {Record<ContextFileName, ContextFileSizeFacts>} Measured size facts keyed by canonical file name.
@@ -135,6 +155,107 @@ export function measureContextFileSizes(
135
155
  return facts;
136
156
  }
137
157
 
158
+ /**
159
+ * @brief Yields one macrotask turn to the host event loop.
160
+ * @details Resolves after `setImmediate`, letting pending UI renders, lifecycle callbacks, and input events process between CPU-bound measurement slices. Runtime is O(1). Side effect: schedules one process task.
161
+ * @return {Promise<void>} Promise resolved on the next macrotask turn.
162
+ */
163
+ function yieldToEventLoop(): Promise<void> {
164
+ return new Promise((resolve) => setImmediate(resolve));
165
+ }
166
+
167
+ /**
168
+ * @brief Measures one context file into deterministic size facts with an event-loop yield before re-encoding.
169
+ * @details Applies the identical stat-signature freshness contract as `measureContextFileSize`, but awaits one macrotask yield between the stat probe and the read-plus-encode slice when the signature misses, so the TUI can process pending events instead of blocking for the full CPU-bound encode. Cache hits resolve synchronously without yielding. Missing, non-file, and unreadable targets return `MISSING_CONTEXT_FILE_SIZE` facts without throwing and without caching. Runtime is O(1) for unchanged remeasures and O(n) in file size on first measurement per content revision, plus one deferred macrotask per re-encode. Side effects are limited to filesystem reads and process-scoped cache mutation.
170
+ * @param[in] filePath {string} Absolute context-file path to measure.
171
+ * @return {Promise<ContextFileSizeFacts>} Measured size facts for the target.
172
+ * @satisfies REQ-373, REQ-374
173
+ */
174
+ export async function measureContextFileSizeAsync(filePath: string): Promise<ContextFileSizeFacts> {
175
+ let stat: fs.Stats | undefined;
176
+ try {
177
+ stat = fs.statSync(filePath);
178
+ } catch {
179
+ stat = undefined;
180
+ }
181
+ if (!stat || !stat.isFile()) {
182
+ return MISSING_CONTEXT_FILE_SIZE;
183
+ }
184
+ const statSignature = `${stat.mtimeMs}:${stat.size}`;
185
+ const cache = getProcessScopedContextFileSizeStore().contextFileSizeCache;
186
+ const cachedEntry = cache.get(filePath);
187
+ if (cachedEntry && cachedEntry.statSignature === statSignature) {
188
+ return cachedEntry.facts;
189
+ }
190
+ await yieldToEventLoop();
191
+ try {
192
+ const metrics = countTextTokensAndChars(fs.readFileSync(filePath, "utf8"));
193
+ const facts: ContextFileSizeFacts = { exists: true, chars: metrics.chars, tokens: metrics.tokens };
194
+ if (cache.size >= CONTEXT_FILE_SIZE_CACHE_LIMIT) {
195
+ cache.clear();
196
+ }
197
+ cache.set(filePath, { statSignature, facts });
198
+ return facts;
199
+ } catch {
200
+ return MISSING_CONTEXT_FILE_SIZE;
201
+ }
202
+ }
203
+
204
+ /**
205
+ * @brief Measures every canonical context file for one project base with event-loop yields between files.
206
+ * @details Iterates `CONTEXT_FILE_NAMES` in documented order, resolves each configured `<base-path>/<docs-dir>` target through `resolveContextFilePath`, and awaits `measureContextFileSizeAsync` per file so CPU-bound re-encode slices are separated by macrotask turns consumed by configuration-menu and command-summary rendering. Returned facts are bit-identical to `measureContextFileSizes`. Runtime is O(n) in aggregate context-file size on first measurement per content revision and O(1) per unchanged remeasure. Side effects are limited to filesystem reads and process-scoped cache mutation.
207
+ * @param[in] projectBase {string} Absolute project root path.
208
+ * @param[in] config {UseReqConfig} Effective project configuration supplying the docs directory.
209
+ * @return {Promise<Record<ContextFileName, ContextFileSizeFacts>>} Measured size facts keyed by canonical file name.
210
+ * @satisfies REQ-373, REQ-374
211
+ */
212
+ export async function measureContextFileSizesAsync(
213
+ projectBase: string,
214
+ config: UseReqConfig,
215
+ ): Promise<Record<ContextFileName, ContextFileSizeFacts>> {
216
+ const facts = {} as Record<ContextFileName, ContextFileSizeFacts>;
217
+ for (const fileName of CONTEXT_FILE_NAMES) {
218
+ facts[fileName] = await measureContextFileSizeAsync(resolveContextFilePath(projectBase, config, fileName));
219
+ }
220
+ return facts;
221
+ }
222
+
223
+ /**
224
+ * @brief Schedules idle-time pre-warm measurement of the canonical context files for one project base.
225
+ * @details Chains one `setImmediate` task per canonical context file plus one encoder pre-warm task, so the one-time `js-tiktoken` module load, bundled BPE-rank parse, and first per-file content encodes execute outside any synchronous menu or preflight critical path and later menu renders resolve through stat-signed cache hits. Duplicate scheduling for one resolved project base is suppressed through a process-scoped pending set until the chain completes. The operation is best-effort: every measurement failure is swallowed because measurement already degrades to `MISSING_CONTEXT_FILE_SIZE` facts and later renders re-measure through the identical stat-signature contract. Runtime is O(1) for scheduling; deferred cost is the standard first-measurement cost per file. Side effects include scheduled process tasks, filesystem reads, encoder construction, and process-scoped cache mutation.
226
+ * @param[in] projectBase {string} Absolute project root path whose canonical context files should pre-warm.
227
+ * @param[in] config {UseReqConfig} Effective project configuration supplying the docs directory.
228
+ * @return {void} No return value.
229
+ */
230
+ export function prewarmContextFileMeasurements(projectBase: string, config: UseReqConfig): void {
231
+ const store = getProcessScopedContextFileSizeStore();
232
+ const normalizedBase = path.resolve(projectBase);
233
+ if (store.scheduledPrewarmBases.has(normalizedBase)) {
234
+ return;
235
+ }
236
+ store.scheduledPrewarmBases.add(normalizedBase);
237
+ const releaseSchedule = (): void => {
238
+ store.scheduledPrewarmBases.delete(normalizedBase);
239
+ };
240
+ prewarmTokenCounterEncoder();
241
+ const filePaths = CONTEXT_FILE_NAMES.map((fileName) => resolveContextFilePath(normalizedBase, config, fileName));
242
+ const measureRemaining = (index: number): void => {
243
+ if (index >= filePaths.length) {
244
+ releaseSchedule();
245
+ return;
246
+ }
247
+ setImmediate(() => {
248
+ try {
249
+ measureContextFileSize(filePaths[index]);
250
+ } catch {
251
+ // Best-effort pre-warm: measurement failures surface unchanged at render time.
252
+ }
253
+ measureRemaining(index + 1);
254
+ });
255
+ };
256
+ measureRemaining(0);
257
+ }
258
+
138
259
  /**
139
260
  * @brief Formats one measured context-file size as a compact character and token estimate.
140
261
  * @details Emits the deterministic `<chars>c/<tokens>t` shape reused by menu rows, the top-level summary, and the command invocation summary so every surface exposes identical size facts. Runtime is O(n) in rendered length. No external state is mutated.
@@ -196,22 +196,42 @@ function defaultJsTiktokenModuleLoader(): JsTiktokenModule {
196
196
 
197
197
  let jsTiktokenModuleLoader: () => JsTiktokenModule = defaultJsTiktokenModuleLoader;
198
198
 
199
- const cachedTokenCounterEncodings = new Map<string, TokenCounterEncoding>();
199
+ /**
200
+ * @brief Describes the process-scoped tokenizer state persisted across extension rebinds.
201
+ * @details Stores the per-encoding shared encoders on `globalThis` because pi rebinds extension modules for `/new`, `/resume`, `/fork`, and `/reload`, while the hosting process persists across those operations, so session rebinds reuse already parsed encoders instead of re-parsing the bundled BPE ranks. The interface is compile-time only and introduces no runtime cost.
202
+ */
203
+ interface ProcessScopedTokenCounterStore {
204
+ readonly cachedTokenCounterEncodings: Map<string, TokenCounterEncoding>;
205
+ }
206
+
207
+ /**
208
+ * @brief Returns the process-scoped tokenizer encoding cache.
209
+ * @details Lazily initializes one `globalThis` record so session rebinds never re-trigger the heavy encoder construction. Runtime is O(1). Side effect: initializes process-scoped state on first access.
210
+ * @return {ProcessScopedTokenCounterStore} Mutable process-scoped tokenizer store.
211
+ */
212
+ function getProcessScopedTokenCounterStore(): ProcessScopedTokenCounterStore {
213
+ const globalScope = globalThis as typeof globalThis & { __piUsereqTokenCounterStore?: ProcessScopedTokenCounterStore };
214
+ if (!globalScope.__piUsereqTokenCounterStore) {
215
+ globalScope.__piUsereqTokenCounterStore = { cachedTokenCounterEncodings: new Map<string, TokenCounterEncoding>() };
216
+ }
217
+ return globalScope.__piUsereqTokenCounterStore;
218
+ }
200
219
 
201
220
  /**
202
221
  * @brief Resolves one shared tokenizer encoding for a `TokenCounter` construction.
203
- * @details Checks the process-scoped per-encoding cache first and constructs the `js-tiktoken` encoding only on the first request per encoding name, so repeated `TokenCounter` construction across the `files-tokens` runner, canonical-doc counting, and context-file measurement reuses one parsed encoder instead of re-parsing the bundled BPE ranks each time. Runtime is O(1) after the first construction for the encoding and dominated by the single deferred `getEncoding` construction otherwise. Side effect: mutates the module-local encoding cache on first resolution per encoding name.
222
+ * @details Checks the process-scoped per-encoding cache first and constructs the `js-tiktoken` encoding only on the first request per encoding name, so repeated `TokenCounter` construction across the `files-tokens` runner, canonical-doc counting, and context-file measurement reuses one parsed encoder instead of re-parsing the bundled BPE ranks each time. Runtime is O(1) after the first construction for the encoding and dominated by the single deferred `getEncoding` construction otherwise. Side effect: mutates the process-scoped encoding cache on first resolution per encoding name.
204
223
  * @param[in] encodingName {string} `js-tiktoken` encoding identifier.
205
224
  * @return {TokenCounterEncoding} Cached or newly constructed tokenizer encoding.
206
225
  * @throws {ReqError} Propagates the deterministic unavailable-dependency failure when `js-tiktoken` cannot be loaded.
207
226
  */
208
227
  function resolveTokenCounterEncoding(encodingName: string): TokenCounterEncoding {
209
- const cachedEncoding = cachedTokenCounterEncodings.get(encodingName);
228
+ const encodings = getProcessScopedTokenCounterStore().cachedTokenCounterEncodings;
229
+ const cachedEncoding = encodings.get(encodingName);
210
230
  if (cachedEncoding) {
211
231
  return cachedEncoding;
212
232
  }
213
233
  const encoding = loadJsTiktokenModule().getEncoding(encodingName);
214
- cachedTokenCounterEncodings.set(encodingName, encoding);
234
+ encodings.set(encodingName, encoding);
215
235
  return encoding;
216
236
  }
217
237
 
@@ -223,7 +243,23 @@ function resolveTokenCounterEncoding(encodingName: string): TokenCounterEncoding
223
243
  */
224
244
  export function setJsTiktokenModuleLoaderForTests(loader?: () => JsTiktokenModule): void {
225
245
  jsTiktokenModuleLoader = loader ?? defaultJsTiktokenModuleLoader;
226
- cachedTokenCounterEncodings.clear();
246
+ getProcessScopedTokenCounterStore().cachedTokenCounterEncodings.clear();
247
+ }
248
+
249
+ /**
250
+ * @brief Pre-warms the shared tokenizer encoder during idle time.
251
+ * @details Schedules one `setImmediate` task that resolves the shared encoding through the process-scoped cache, so the one-time `require("js-tiktoken")` plus bundled BPE-rank parse never executes inside a synchronous menu or preflight critical path. The task is best-effort: loader and encoding failures are swallowed because real failures still surface deterministically at measurement time. Runtime is O(1) for scheduling plus the deferred one-time construction cost. Side effect: schedules one process task and mutates the process-scoped encoding cache when the construction succeeds.
252
+ * @param[in] encodingName {string} `js-tiktoken` encoding identifier. Defaults to `cl100k_base`.
253
+ * @return {void} No return value.
254
+ */
255
+ export function prewarmTokenCounterEncoder(encodingName = TOKEN_COUNTER_ENCODING): void {
256
+ setImmediate(() => {
257
+ try {
258
+ resolveTokenCounterEncoding(encodingName);
259
+ } catch {
260
+ // Best-effort pre-warm: dependency failures surface unchanged at measurement time.
261
+ }
262
+ });
227
263
  }
228
264
 
229
265
  /**
@@ -446,30 +482,62 @@ function buildCountFileMetricsResult(filePath: string, content: string, counter:
446
482
  }
447
483
 
448
484
  /**
449
- * @brief Counts tokens, characters, bytes, and lines for one in-memory content string.
450
- * @details Resolves the process-cached shared `TokenCounter` encoder for the encoding, tokenizes the supplied text once, and pairs the result with raw character length, UTF-8 byte size, and logical line count. Runtime is O(n) after the one-time per-encoding encoder construction. No filesystem I/O occurs.
485
+ * @brief Counts only tokenizer tokens and characters for one in-memory content string.
486
+ * @details Lean variant of the former full-metrics counter used by context-file sizing: resolves the process-cached shared `TokenCounter` encoder, tokenizes the supplied text once, and pairs the result with the raw character length while skipping UTF-8 byte sizing and the logical-line regex allocation. Token and character semantics are bit-identical to the full metrics contract. Runtime is O(n) after the one-time per-encoding encoder construction. No filesystem I/O occurs.
451
487
  * @param[in] content {string} Text payload to measure.
452
488
  * @param[in] encodingName {string} Tokenizer identifier. Defaults to `cl100k_base`.
453
- * @return {{ tokens: number; chars: number; bytes: number; lines: number }} Aggregate metrics for the supplied content.
489
+ * @return {{ tokens: number; chars: number }} Token estimate and character count for the supplied content.
454
490
  */
455
- export function countFileMetrics(content: string, encodingName = TOKEN_COUNTER_ENCODING): {
491
+ export function countTextTokensAndChars(content: string, encodingName = TOKEN_COUNTER_ENCODING): {
456
492
  tokens: number;
457
493
  chars: number;
458
- bytes: number;
459
- lines: number;
460
494
  } {
461
495
  const counter = new TokenCounter(encodingName);
462
496
  return {
463
497
  tokens: counter.countTokens(content),
464
498
  chars: TokenCounter.countChars(content),
465
- bytes: Buffer.byteLength(content, "utf8"),
466
- lines: countLines(content),
467
499
  };
468
500
  }
469
501
 
502
+ /**
503
+ * @brief Describes one stat-signed full-metrics cache entry.
504
+ * @details Pairs one computed `CountFileMetricsResult` with the `mtimeMs` plus `size` filesystem signature observed at computation time so later invocations can reuse the record through one stat call. The interface is compile-time only and introduces no runtime cost.
505
+ */
506
+ interface FileMetricsCacheEntry {
507
+ readonly statSignature: string;
508
+ readonly result: CountFileMetricsResult;
509
+ }
510
+
511
+ /**
512
+ * @brief Bounds the process-scoped per-file metrics cache.
513
+ * @details Explicit-file and canonical-doc token requests measure bounded path sets per invocation, so unbounded caching would accumulate stale entries in long-lived pi hosts. Exceeding the bound clears the cache completely, which only costs one re-measurement per live path. Lookup complexity is O(1).
514
+ */
515
+ const FILE_METRICS_CACHE_LIMIT = 128;
516
+
517
+ /**
518
+ * @brief Describes the process-scoped per-file metrics state persisted across extension rebinds.
519
+ * @details Stores the stat-signed full-metric records on `globalThis` because pi rebinds extension modules for `/new`, `/resume`, `/fork`, and `/reload`, while the hosting process persists across those operations, so repeated `files-tokens` and canonical-doc `tokens` invocations reuse already encoded documents instead of re-encoding unchanged content. The interface is compile-time only and introduces no runtime cost.
520
+ */
521
+ interface ProcessScopedFileMetricsStore {
522
+ readonly statSignedMetrics: Map<string, FileMetricsCacheEntry>;
523
+ }
524
+
525
+ /**
526
+ * @brief Returns the process-scoped per-file metrics cache.
527
+ * @details Lazily initializes one `globalThis` record so session rebinds never re-trigger the heavy per-file encode path. Runtime is O(1). Side effect: initializes process-scoped state on first access.
528
+ * @return {ProcessScopedFileMetricsStore} Mutable process-scoped metrics store.
529
+ */
530
+ function getProcessScopedFileMetricsStore(): ProcessScopedFileMetricsStore {
531
+ const globalScope = globalThis as typeof globalThis & { __piUsereqFileMetricsStore?: ProcessScopedFileMetricsStore };
532
+ if (!globalScope.__piUsereqFileMetricsStore) {
533
+ globalScope.__piUsereqFileMetricsStore = { statSignedMetrics: new Map<string, FileMetricsCacheEntry>() };
534
+ }
535
+ return globalScope.__piUsereqFileMetricsStore;
536
+ }
537
+
470
538
  /**
471
539
  * @brief Counts tokens, characters, bytes, and lines for multiple files.
472
- * @details Reuses a single `TokenCounter`, reads each file as UTF-8, and returns per-file metrics plus direct-access metadata such as heading and Doxygen file fields. Read failures are captured as error strings instead of aborting the entire batch. Runtime is O(F + S). Side effects are limited to filesystem reads.
540
+ * @details Reuses a single `TokenCounter`, reuses stat-signed cached metric records for unchanged files through one stat call per path, reads changed files as UTF-8, and returns per-file metrics plus direct-access metadata such as heading and Doxygen file fields. Read failures are captured as error strings instead of aborting the entire batch, and failed targets are never cached. Runtime is O(F + S) on first measurement per content revision and O(F) in stat calls per unchanged remeasure. Side effects are limited to filesystem reads and process-scoped cache mutation.
473
541
  * @param[in] filePaths {string[]} File paths to measure.
474
542
  * @param[in] encodingName {string} Tokenizer identifier. Defaults to `cl100k_base`.
475
543
  * @return {CountFileMetricsResult[]} Per-file metrics and optional read errors.
@@ -477,10 +545,31 @@ export function countFileMetrics(content: string, encodingName = TOKEN_COUNTER_E
477
545
  */
478
546
  export function countFilesMetrics(filePaths: string[], encodingName = TOKEN_COUNTER_ENCODING): CountFileMetricsResult[] {
479
547
  const counter = new TokenCounter(encodingName);
548
+ const metricsCache = getProcessScopedFileMetricsStore().statSignedMetrics;
480
549
  return filePaths.map((filePath) => {
550
+ let statSignature: string | undefined;
551
+ try {
552
+ const stat = fs.statSync(filePath);
553
+ if (stat.isFile()) {
554
+ statSignature = `${stat.mtimeMs}:${stat.size}`;
555
+ const cachedEntry = metricsCache.get(filePath);
556
+ if (cachedEntry && cachedEntry.statSignature === statSignature) {
557
+ return { ...cachedEntry.result };
558
+ }
559
+ }
560
+ } catch {
561
+ statSignature = undefined;
562
+ }
481
563
  try {
482
564
  const content = fs.readFileSync(filePath, "utf8");
483
- return buildCountFileMetricsResult(filePath, content, counter);
565
+ const result = buildCountFileMetricsResult(filePath, content, counter);
566
+ if (statSignature !== undefined) {
567
+ if (metricsCache.size >= FILE_METRICS_CACHE_LIMIT) {
568
+ metricsCache.clear();
569
+ }
570
+ metricsCache.set(filePath, { statSignature, result });
571
+ }
572
+ return result;
484
573
  } catch (error) {
485
574
  return {
486
575
  file: filePath,
package/src/index.ts CHANGED
@@ -87,6 +87,8 @@ import {
87
87
  computeContextOccupancyFacts,
88
88
  formatContextFileSize,
89
89
  measureContextFileSizes,
90
+ measureContextFileSizesAsync,
91
+ prewarmContextFileMeasurements,
90
92
  sumEnabledContextFileTokens,
91
93
  type ContextFileName,
92
94
  type ContextFileSizeFacts,
@@ -1436,7 +1438,7 @@ async function finalizeMatchedPromptSuccess(
1436
1438
 
1437
1439
  /**
1438
1440
  * @brief Handles one intercepted pi lifecycle hook for pi-usereq status updates.
1439
- * @details Applies session-start-specific resource validation, project-config refresh, startup-tool enablement, and selected debug-tool logging before forwarding the originating hook name and payload into the shared `updateExtensionStatus(...)` pipeline. Before `agent_start`, re-verifies any prepared prompt execution session switch. On `agent_end`, dispatches configured command-notify, sound, and prompt-specific Pushover effects, logs dedicated workflow-closure diagnostics, classifies the prompt outcome, and for every matched successful worktree-backed completion defers the restore switch, stash-assisted merge, and worktree deletion to `agent_settled` when the running pi host supports that 0.80.4+ event (because its `switchSession` awaits the active agent run to become idle and would deadlock inside `agent_end`), or executes the finalization directly at `agent_end` when the host does not emit `agent_settled`. On `agent_settled`, reuses persisted replacement-session command contexts when event contexts omit `switchSession()`, executes the deferred stash-assisted merge-and-delete finalization path, emits a warning-only notification when restored `base-path` changes are reapplied after merge, tolerates stale replacement-session notification contexts after session replacement, retains the worktree plus notifies closure failure for interrupted or failed outcomes, logs selected prompt workflow transitions, and transitions workflow state through `merging`, `error`, and `idle` as required. On `session_shutdown`, captures pre-update prompt snapshots so workflow-shutdown diagnostics and same-runtime command continuation preserve the active prompt workflow state across switch-triggered rebinding, then disposes the shared controller. Runtime is dominated by configuration loading during `session_start` and git finalization during matched successful closure handling; all other hooks are O(1). Side effects include resource checks, active-tool mutation, active-session replacement, status updates, live-ticker disposal on shutdown, optional child-process spawning, outbound HTTPS requests, branch merges, worktree deletion, and optional debug-log writes.
1441
+ * @details Applies session-start-specific resource validation, project-config refresh, idle-time shared-tokenizer and context-file measurement pre-warm, startup-tool enablement, and selected debug-tool logging before forwarding the originating hook name and payload into the shared `updateExtensionStatus(...)` pipeline. Before `agent_start`, re-verifies any prepared prompt execution session switch. On `agent_end`, dispatches configured command-notify, sound, and prompt-specific Pushover effects, logs dedicated workflow-closure diagnostics, classifies the prompt outcome, and for every matched successful worktree-backed completion defers the restore switch, stash-assisted merge, and worktree deletion to `agent_settled` when the running pi host supports that 0.80.4+ event (because its `switchSession` awaits the active agent run to become idle and would deadlock inside `agent_end`), or executes the finalization directly at `agent_end` when the host does not emit `agent_settled`. On `agent_settled`, reuses persisted replacement-session command contexts when event contexts omit `switchSession()`, executes the deferred stash-assisted merge-and-delete finalization path, emits a warning-only notification when restored `base-path` changes are reapplied after merge, tolerates stale replacement-session notification contexts after session replacement, retains the worktree plus notifies closure failure for interrupted or failed outcomes, logs selected prompt workflow transitions, and transitions workflow state through `merging`, `error`, and `idle` as required. On `session_shutdown`, captures pre-update prompt snapshots so workflow-shutdown diagnostics and same-runtime command continuation preserve the active prompt workflow state across switch-triggered rebinding, then disposes the shared controller. Runtime is dominated by configuration loading during `session_start` and git finalization during matched successful closure handling; all other hooks are O(1). Side effects include resource checks, active-tool mutation, active-session replacement, status updates, live-ticker disposal on shutdown, optional child-process spawning, outbound HTTPS requests, branch merges, worktree deletion, and optional debug-log writes.
1440
1442
  * @param[in] pi {ExtensionAPI} Active extension API instance.
1441
1443
  * @param[in,out] statusController {PiUsereqStatusController} Mutable status controller.
1442
1444
  * @param[in] hookName {PiUsereqStatusHookName} Intercepted hook name.
@@ -1468,6 +1470,7 @@ async function handleExtensionStatusEvent(
1468
1470
  gitPath: resolveRuntimeGitPath(startupCwd),
1469
1471
  });
1470
1472
  const config = loadProjectConfig(startupCwd);
1473
+ prewarmContextFileMeasurements(startupCwd, config);
1471
1474
  applyConfiguredPiUsereqTools(pi, config);
1472
1475
  setPiUsereqStatusConfig(statusController, config);
1473
1476
  const missingCheckers = checkDefaultCheckersAvailability(config);
@@ -3474,7 +3477,7 @@ function registerPromptCommands(
3474
3477
  promptName,
3475
3478
  args,
3476
3479
  config,
3477
- measureContextFileSizes(projectBase, config),
3480
+ await measureContextFileSizesAsync(projectBase, config),
3478
3481
  maxContextTokens,
3479
3482
  );
3480
3483
  const promptDelivery = deliverPromptCommand(pi, content, commandSummary, promptContext);
@@ -4632,7 +4635,7 @@ function buildContextFilesMenuChoices(
4632
4635
 
4633
4636
  /**
4634
4637
  * @brief Runs the `Context Files` configuration submenu.
4635
- * @details Loads the shared settings menu with the three context-file toggle rows, persists each inline toggle immediately through the shared change callback, restores all three flags to enabled on approved subtree reset, preserves focus on the toggled row, and returns to the top-level menu on cancel. Runtime depends on user interaction count. Side effects include config writes and UI notifications.
4638
+ * @details Measures the context files through the yielding async measurement helper, then loads the shared settings menu with the three context-file toggle rows, persists each inline toggle immediately through the shared change callback, restores all three flags to enabled on approved subtree reset, preserves focus on the toggled row, and returns to the top-level menu on cancel. Runtime depends on user interaction count. Side effects include config writes and UI notifications.
4636
4639
  * @param[in] ctx {ExtensionCommandContext} Active command context.
4637
4640
  * @param[in,out] config {UseReqConfig} Mutable effective project configuration.
4638
4641
  * @param[in] onConfigChange {() => void} Shared persistence-plus-status callback.
@@ -4646,7 +4649,7 @@ async function configureContextFilesMenu(
4646
4649
  onConfigChange: () => void,
4647
4650
  maxContextTokens?: number | undefined,
4648
4651
  ): Promise<void> {
4649
- const contextFileSizes = measureContextFileSizes(getProjectBase(ctx.cwd), config);
4652
+ const contextFileSizes = await measureContextFileSizesAsync(getProjectBase(ctx.cwd), config);
4650
4653
  const setFlag = (flagKey: "context-files-requirements" | "context-files-references" | "context-files-workflow", enabled: boolean): void => {
4651
4654
  config[flagKey] = enabled;
4652
4655
  onConfigChange();
@@ -4869,7 +4872,7 @@ function buildSrcDirRemovalChoices(config: UseReqConfig): PiUsereqSettingsMenuCh
4869
4872
 
4870
4873
  /**
4871
4874
  * @brief Runs the top-level pi-usereq configuration menu.
4872
- * @details Loads the effective merged config, exposes docs/test/source/automatic-commit/worktree/static-check/startup-tool/notification/debug actions through the shared settings-menu renderer, forces worktree disablement when automatic git commit is disabled, prevents locked row edits, persists changes on exit, closes immediately after `Show local configuration` or `Show global configuration`, and refreshes the single-line status bar. Runtime depends on user interaction count. Side effects include UI updates, config writes, active-tool changes, and editor text updates.
4875
+ * @details Loads the effective merged config, performs one yielding async context-file measurement warm pass so menu renders resolve through stat-signed cache hits instead of first-measurement encodes, exposes docs/test/source/automatic-commit/worktree/static-check/startup-tool/notification/debug actions through the shared settings-menu renderer, forces worktree disablement when automatic git commit is disabled, prevents locked row edits, persists changes on exit, closes immediately after `Show local configuration` or `Show global configuration`, and refreshes the single-line status bar. Runtime depends on user interaction count. Side effects include UI updates, config writes, active-tool changes, and editor text updates.
4873
4876
  * @param[in] pi {ExtensionAPI} Active extension API instance.
4874
4877
  * @param[in] ctx {ExtensionCommandContext} Active command context.
4875
4878
  * @param[in,out] statusController {PiUsereqStatusController} Mutable status controller.
@@ -4887,6 +4890,7 @@ async function configurePiUsereq(
4887
4890
  let config = loadProjectConfig(ctx.cwd);
4888
4891
  const projectBase = getProjectBase(ctx.cwd);
4889
4892
  const maxContextTokens = resolveModelContextWindowTokens(statusController.state.contextUsage);
4893
+ await measureContextFileSizesAsync(projectBase, config);
4890
4894
  const initialShortcut = config["notify-sound-toggle-shortcut"];
4891
4895
  const persistConfigChange = () => {
4892
4896
  Object.assign(config, normalizeConfigPaths(projectBase, config));
@@ -5094,6 +5098,7 @@ async function configurePiUsereq(
5094
5098
  }
5095
5099
  if (choice === "reset-defaults") {
5096
5100
  const defaultConfig = getDefaultConfig(projectBase);
5101
+ const resetContextFileSizes = measureContextFileSizes(ctx.cwd, config);
5097
5102
  const approved = await confirmResetChanges(
5098
5103
  ctx,
5099
5104
  "Confirm pi-usereq reset",
@@ -5101,7 +5106,7 @@ async function configurePiUsereq(
5101
5106
  { label: "Document directory", previousValue: config["docs-dir"], nextValue: defaultConfig["docs-dir"] },
5102
5107
  { label: "Source-code directories", previousValue: config["src-dir"].join(", "), nextValue: defaultConfig["src-dir"].join(", ") },
5103
5108
  { label: "Unit tests directory", previousValue: config["tests-dir"], nextValue: defaultConfig["tests-dir"] },
5104
- { label: "Context Files", previousValue: formatContextFilesSummary(config, measureContextFileSizes(ctx.cwd, config), maxContextTokens), nextValue: formatContextFilesSummary(defaultConfig, measureContextFileSizes(ctx.cwd, defaultConfig), maxContextTokens) },
5109
+ { label: "Context Files", previousValue: formatContextFilesSummary(config, resetContextFileSizes, maxContextTokens), nextValue: formatContextFilesSummary(defaultConfig, resetContextFileSizes, maxContextTokens) },
5105
5110
  { label: "Auto git commit", previousValue: config.AUTO_GIT_COMMIT, nextValue: defaultConfig.AUTO_GIT_COMMIT },
5106
5111
  { label: "Git worktree", previousValue: config.GIT_WORKTREE_ENABLED, nextValue: defaultConfig.GIT_WORKTREE_ENABLED },
5107
5112
  { label: "Worktree prefix", previousValue: config.GIT_WORKTREE_PREFIX, nextValue: defaultConfig.GIT_WORKTREE_PREFIX },
@@ -5159,7 +5164,7 @@ function registerConfigCommands(
5159
5164
 
5160
5165
  /**
5161
5166
  * @brief Registers the complete pi-usereq extension.
5162
- * @details Persists the live extension api into process-scoped storage on every bind so model re-application after session replacement binds to the api of the currently active session runner instead of an invalidated captured api, then validates installation-owned bundled resources, registers the specialized `req-reset` and `req-references` commands plus bundled prompt-backed commands and agent tools, conditionally registers config-gated debug tool wrapper commands when the current project enables them, registers configuration commands, registers the configurable notification-sound shortcut when the runtime supports shortcuts, and installs shared wrappers for all supported pi lifecycle hooks so status telemetry, context usage, prompt timing, cumulative runtime, prompt-specific Pushover metadata, tool-result debug logging, and prompt-orchestration effects remain synchronized with runtime events. Runtime is O(h) in hook count during registration. Side effects include filesystem reads, command/tool/shortcut registration, UI updates, active-tool changes, process-scoped extension-api persistence, optional debug-log writes, and timer scheduling.
5167
+ * @details Persists the live extension api into process-scoped storage on every bind so model re-application after session replacement binds to the api of the currently active session runner instead of an invalidated captured api, then validates installation-owned bundled resources, schedules a best-effort idle-time shared-tokenizer plus canonical-context-file measurement pre-warm so the first configuration-menu render never pays the one-time `js-tiktoken` module load or first content encodes synchronously, registers the specialized `req-reset` and `req-references` commands plus bundled prompt-backed commands and agent tools, conditionally registers config-gated debug tool wrapper commands when the current project enables them, registers configuration commands, registers the configurable notification-sound shortcut when the runtime supports shortcuts, and installs shared wrappers for all supported pi lifecycle hooks so status telemetry, context usage, prompt timing, cumulative runtime, prompt-specific Pushover metadata, tool-result debug logging, and prompt-orchestration effects remain synchronized with runtime events. Runtime is O(h) in hook count during registration. Side effects include filesystem reads, command/tool/shortcut registration, UI updates, active-tool changes, process-scoped extension-api persistence, optional debug-log writes, and timer scheduling.
5163
5168
  * @param[in] pi {ExtensionAPI} Active extension API instance.
5164
5169
  * @return {void} No return value.
5165
5170
  * @satisfies DES-002, DES-015, REQ-004, REQ-005, REQ-009, REQ-044, REQ-067, REQ-068, REQ-109, REQ-111, REQ-112, REQ-113, REQ-114, REQ-115, REQ-116, REQ-117, REQ-118, REQ-119, REQ-120, REQ-121, REQ-122, REQ-123, REQ-124, REQ-125, REQ-126, REQ-127, REQ-128, REQ-131, REQ-132, REQ-133, REQ-134, REQ-137, REQ-159, REQ-163, REQ-164, REQ-165, REQ-166, REQ-167, REQ-168, REQ-169, REQ-172, REQ-174, REQ-179, REQ-180, REQ-184, REQ-188, REQ-190, REQ-191, REQ-192, REQ-193, REQ-194, REQ-195, REQ-196, REQ-197, REQ-236, REQ-237, REQ-238, REQ-239, REQ-240, REQ-241, REQ-242, REQ-243, REQ-244, REQ-245, REQ-246, REQ-247, REQ-298, REQ-299, REQ-300, REQ-301, REQ-302, REQ-303, REQ-304, REQ-305, REQ-306, REQ-312, REQ-313, REQ-323, REQ-324, REQ-325, REQ-326, REQ-327
@@ -5168,6 +5173,11 @@ export default function piUsereqExtension(pi: ExtensionAPI): void {
5168
5173
  writePersistedPromptCommandRuntimeApi(pi as unknown as PersistedPromptCommandRuntimeApi);
5169
5174
  const statusController = createPiUsereqStatusController();
5170
5175
  ensureBundledResourcesAccessible();
5176
+ try {
5177
+ prewarmContextFileMeasurements(getProcessCwdSafe(), loadProjectConfig(getProcessCwdSafe()));
5178
+ } catch {
5179
+ // Best-effort idle-time pre-warm: measurement failures surface unchanged at render time.
5180
+ }
5171
5181
  registerReqResetCommand(pi, statusController);
5172
5182
  registerReqReferencesCommand(pi, statusController);
5173
5183
  registerPromptCommands(pi, statusController);