pi-usereq 0.67.0 → 0.69.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 +1 -1
- package/package.json +1 -1
- package/src/core/context-file-size.ts +133 -12
- package/src/core/token-counter.ts +104 -15
- package/src/index.ts +17 -7
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
|
62
|
-
* @details
|
|
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
|
-
|
|
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 `
|
|
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
|
|
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 =
|
|
127
|
+
const metrics = countTextTokensAndChars(fs.readFileSync(filePath, "utf8"));
|
|
108
128
|
const facts: ContextFileSizeFacts = { exists: true, chars: metrics.chars, tokens: metrics.tokens };
|
|
109
|
-
if (
|
|
110
|
-
|
|
129
|
+
if (cache.size >= CONTEXT_FILE_SIZE_CACHE_LIMIT) {
|
|
130
|
+
cache.clear();
|
|
111
131
|
}
|
|
112
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
450
|
-
* @details
|
|
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
|
|
489
|
+
* @return {{ tokens: number; chars: number }} Token estimate and character count for the supplied content.
|
|
454
490
|
*/
|
|
455
|
-
export function
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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,
|
|
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);
|