@firstpick/pi-utils 0.1.5 → 0.1.7
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 +5 -0
- package/index.ts +1 -0
- package/package.json +2 -1
- package/src/local-wiki.ts +78 -24
- package/src/prompt-calibration.ts +125 -0
- package/src/tokens.ts +222 -1
package/README.md
CHANGED
|
@@ -19,7 +19,12 @@ Shared helper utilities used by `@firstpick/pi-extension-*` packages.
|
|
|
19
19
|
- `slugify(input, options?)`
|
|
20
20
|
- `formatTokens(count)`
|
|
21
21
|
- `estimateTokensFromCharCount(charCount)`
|
|
22
|
+
- `estimateTokensFromText(text)`
|
|
22
23
|
- `estimatePromptInjectionTokens(systemPrompt)`
|
|
24
|
+
- `estimateInitialPromptInput(options)`
|
|
25
|
+
- `collectInitialPromptCalibration(sessionDir, maxSamples?)`
|
|
26
|
+
- `buildInitialPromptCalibrationRecord(args)`
|
|
27
|
+
- `appendInitialPromptCalibrationRecord(appendEntry, record)`
|
|
23
28
|
- `delay(ms)`
|
|
24
29
|
- `createExtensionWorkingIndicator(ctx, initialMessage, options?)`
|
|
25
30
|
- `withExtensionWorkingIndicator(ctx, initialMessage, run, options?)`
|
package/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ export * from "./src/paths";
|
|
|
2
2
|
export * from "./src/env";
|
|
3
3
|
export * from "./src/text";
|
|
4
4
|
export * from "./src/tokens";
|
|
5
|
+
export * from "./src/prompt-calibration";
|
|
5
6
|
export * from "./src/async";
|
|
6
7
|
export * from "./src/ui/working-indicator";
|
|
7
8
|
export * from "./src/local-wiki";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@firstpick/pi-utils",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "Shared utilities for Firstpick Pi extension packages.",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"exports": {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"./env": "./src/env.ts",
|
|
10
10
|
"./text": "./src/text.ts",
|
|
11
11
|
"./tokens": "./src/tokens.ts",
|
|
12
|
+
"./prompt-calibration": "./src/prompt-calibration.ts",
|
|
12
13
|
"./async": "./src/async.ts",
|
|
13
14
|
"./ui": "./src/ui/working-indicator.ts",
|
|
14
15
|
"./local-wiki": "./src/local-wiki.ts"
|
package/src/local-wiki.ts
CHANGED
|
@@ -59,6 +59,8 @@ export interface LocalWikiEngineConfig {
|
|
|
59
59
|
fileExtensions: RegExp;
|
|
60
60
|
format: LocalWikiFormat;
|
|
61
61
|
queryExpansions?: Record<string, string[]>;
|
|
62
|
+
searchStopwords?: Iterable<string>;
|
|
63
|
+
termWeights?: Record<string, number>;
|
|
62
64
|
missingDocsMessage?: string;
|
|
63
65
|
ignoredDirs?: string[];
|
|
64
66
|
sourceName?: (filePath: string, docsPath: string) => string | undefined;
|
|
@@ -74,6 +76,7 @@ export function createLocalWikiEngine(config: LocalWikiEngineConfig) {
|
|
|
74
76
|
const metadataCache = path.join(config.cacheDir, "metadata.json");
|
|
75
77
|
const ignoredDirs = new Set([".git", "node_modules", "result", ...(config.ignoredDirs ?? [])]);
|
|
76
78
|
const missingDocsMessage = config.missingDocsMessage ?? `Local ${config.displayName} docs are not available at ${config.docsPath}.`;
|
|
79
|
+
const searchStopwords = new Set([...(config.searchStopwords ?? [])].map((word) => normalizeQuery(word)).filter(Boolean));
|
|
77
80
|
|
|
78
81
|
async function localExists(filePath: string): Promise<boolean> {
|
|
79
82
|
try { await fsp.access(filePath); return true; } catch { return false; }
|
|
@@ -119,7 +122,36 @@ export function createLocalWikiEngine(config: LocalWikiEngineConfig) {
|
|
|
119
122
|
}
|
|
120
123
|
|
|
121
124
|
function stripMarkdownDecorators(input: string): string {
|
|
122
|
-
return input
|
|
125
|
+
return input
|
|
126
|
+
.replace(/^#+\s*/, "")
|
|
127
|
+
.replace(/\s*\{#[^}]+\}\s*$/g, "")
|
|
128
|
+
.replace(/[*_`~]/g, "")
|
|
129
|
+
.replace(/\[([^\]]+)\]\([^\)]+\)/g, "$1")
|
|
130
|
+
.trim();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function stripYamlFrontmatter(markdown: string): string {
|
|
134
|
+
return markdown.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, "");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function yamlFrontmatterTitle(markdown: string): string | undefined {
|
|
138
|
+
const frontmatter = markdown.match(/^---\s*\n([\s\S]*?)\n---\s*\n?/);
|
|
139
|
+
const raw = frontmatter?.[1]?.match(/^title:\s*["']?(.+?)["']?\s*$/m)?.[1];
|
|
140
|
+
return raw ? stripMarkdownDecorators(raw) : undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function firstMarkdownHeading(markdown: string): string | undefined {
|
|
144
|
+
let inFence = false;
|
|
145
|
+
for (const line of stripYamlFrontmatter(markdown).split(/\n/)) {
|
|
146
|
+
if (/^\s*(```|~~~)/.test(line)) {
|
|
147
|
+
inFence = !inFence;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (inFence) continue;
|
|
151
|
+
const match = line.match(/^#\s+(.+)$/);
|
|
152
|
+
if (match) return match[1].trim();
|
|
153
|
+
}
|
|
154
|
+
return undefined;
|
|
123
155
|
}
|
|
124
156
|
|
|
125
157
|
function decodeEntities(input: string): string {
|
|
@@ -137,10 +169,17 @@ export function createLocalWikiEngine(config: LocalWikiEngineConfig) {
|
|
|
137
169
|
}
|
|
138
170
|
|
|
139
171
|
function markdownSections(markdown: string, fallbackTitle: string): LocalWikiSection[] {
|
|
172
|
+
const body = stripYamlFrontmatter(markdown);
|
|
140
173
|
const sections: LocalWikiSection[] = [];
|
|
141
174
|
let current: LocalWikiSection | undefined;
|
|
142
|
-
|
|
143
|
-
|
|
175
|
+
let inFence = false;
|
|
176
|
+
for (const line of body.split(/\n/)) {
|
|
177
|
+
if (/^\s*(```|~~~)/.test(line)) {
|
|
178
|
+
inFence = !inFence;
|
|
179
|
+
if (current) current.text += `${line}\n`;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const match = !inFence ? line.match(/^(#{1,6})\s+(.+)$/) : undefined;
|
|
144
183
|
if (match) {
|
|
145
184
|
const title = stripMarkdownDecorators(match[2]);
|
|
146
185
|
if (title.toLowerCase() === "contents") continue;
|
|
@@ -151,7 +190,7 @@ export function createLocalWikiEngine(config: LocalWikiEngineConfig) {
|
|
|
151
190
|
}
|
|
152
191
|
if (current) current.text += `${line}\n`;
|
|
153
192
|
}
|
|
154
|
-
if (!current) sections.push({ title: fallbackTitle, level: 1, anchor: anchorFromHeading(fallbackTitle), text:
|
|
193
|
+
if (!current) sections.push({ title: fallbackTitle, level: 1, anchor: anchorFromHeading(fallbackTitle), text: body.trim() });
|
|
155
194
|
else current.text = current.text.trim();
|
|
156
195
|
return sections;
|
|
157
196
|
}
|
|
@@ -168,7 +207,7 @@ export function createLocalWikiEngine(config: LocalWikiEngineConfig) {
|
|
|
168
207
|
}
|
|
169
208
|
|
|
170
209
|
function markdownTitle(markdown: string, filePath: string): string {
|
|
171
|
-
return stripMarkdownDecorators(markdown
|
|
210
|
+
return stripMarkdownDecorators(yamlFrontmatterTitle(markdown) || firstMarkdownHeading(markdown) || titleFromPath(filePath));
|
|
172
211
|
}
|
|
173
212
|
|
|
174
213
|
function htmlTitle(html: string, filePath: string): string {
|
|
@@ -211,10 +250,11 @@ export function createLocalWikiEngine(config: LocalWikiEngineConfig) {
|
|
|
211
250
|
|
|
212
251
|
function parsePage(raw: string, filePath: string, mtimeMs: number): LocalWikiPage {
|
|
213
252
|
const title = config.format === "html" ? htmlTitle(raw, filePath) : markdownTitle(raw, filePath);
|
|
214
|
-
const
|
|
253
|
+
const markdownBody = config.format === "html" ? raw : stripYamlFrontmatter(raw);
|
|
254
|
+
const baseText = config.format === "html" ? htmlToText(raw) : normalizeWhitespace(markdownBody);
|
|
215
255
|
const text = config.transformText?.(baseText, title, filePath) ?? baseText;
|
|
216
256
|
const sections = markdownSections(text, title);
|
|
217
|
-
return { title, slug: path.relative(config.docsPath, filePath).replace(config.fileExtensions, ""), path: filePath, source: config.sourceName?.(filePath, config.docsPath), headings: sections.map((s) => s.title), sections, links: config.format === "html" ? htmlLinks(raw, filePath) : markdownLinks(
|
|
257
|
+
return { title, slug: path.relative(config.docsPath, filePath).replace(config.fileExtensions, ""), path: filePath, source: config.sourceName?.(filePath, config.docsPath), headings: sections.map((s) => s.title), sections, links: config.format === "html" ? htmlLinks(raw, filePath) : markdownLinks(markdownBody, filePath), text, mtimeMs };
|
|
218
258
|
}
|
|
219
259
|
|
|
220
260
|
function limitText(text: string, maxChars = 12000): { text: string; truncated: boolean } {
|
|
@@ -257,12 +297,21 @@ export function createLocalWikiEngine(config: LocalWikiEngineConfig) {
|
|
|
257
297
|
}
|
|
258
298
|
|
|
259
299
|
function expandQuery(query: string): string[] {
|
|
260
|
-
const tokens = normalizeQuery(query).split(/\s+/).filter(
|
|
300
|
+
const tokens = normalizeQuery(query).split(/\s+/).filter((token) => token && !searchStopwords.has(token));
|
|
261
301
|
const expanded = new Set(tokens);
|
|
262
|
-
for (const token of tokens)
|
|
302
|
+
for (const token of tokens) {
|
|
303
|
+
for (const extra of config.queryExpansions?.[token] ?? []) {
|
|
304
|
+
const normalized = normalizeQuery(extra);
|
|
305
|
+
if (normalized && !searchStopwords.has(normalized)) expanded.add(normalized);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
263
308
|
return [...expanded].filter(Boolean);
|
|
264
309
|
}
|
|
265
310
|
|
|
311
|
+
function tokenWeight(token: string): number {
|
|
312
|
+
return config.termWeights?.[token] ?? 1;
|
|
313
|
+
}
|
|
314
|
+
|
|
266
315
|
function makeSnippet(text: string, tokens: string[], max = 280): string | undefined {
|
|
267
316
|
const lower = text.toLowerCase();
|
|
268
317
|
const index = tokens.map((t) => lower.indexOf(t.toLowerCase())).filter((i) => i >= 0).sort((a, b) => a - b)[0];
|
|
@@ -282,14 +331,15 @@ export function createLocalWikiEngine(config: LocalWikiEngineConfig) {
|
|
|
282
331
|
const matchedFields = new Set<string>();
|
|
283
332
|
const scoreExplanation: string[] = [];
|
|
284
333
|
for (const token of tokens) {
|
|
285
|
-
|
|
286
|
-
if (
|
|
287
|
-
if (
|
|
288
|
-
if (
|
|
334
|
+
const weight = tokenWeight(token);
|
|
335
|
+
if (title.includes(token)) { score += 25 * weight; matchedFields.add("title"); scoreExplanation.push(`title matched '${token}'`); }
|
|
336
|
+
if (slug.includes(token)) { score += 12 * weight; matchedFields.add("slug"); }
|
|
337
|
+
if (source.includes(token)) { score += 8 * weight; matchedFields.add("source"); }
|
|
338
|
+
if (headings.includes(token)) { score += 10 * weight; matchedFields.add("headings"); }
|
|
289
339
|
const textMatches = text.split(token).length - 1;
|
|
290
|
-
if (textMatches > 0) { score += Math.min(15, textMatches); matchedFields.add("text"); }
|
|
340
|
+
if (textMatches > 0) { score += Math.min(15, textMatches) * weight; matchedFields.add("text"); }
|
|
291
341
|
}
|
|
292
|
-
return score > 0 ? { title: page.title, path: page.path, source: page.source, score, matchedFields: [...matchedFields], scoreExplanation, snippet: makeSnippet(page.text, tokens) } : undefined;
|
|
342
|
+
return score > 0 ? { title: page.title, path: page.path, source: page.source, score: Number(score.toFixed(2)), matchedFields: [...matchedFields], scoreExplanation, snippet: makeSnippet(page.text, tokens) } : undefined;
|
|
293
343
|
}
|
|
294
344
|
|
|
295
345
|
function findPage(pages: LocalWikiPage[], pageRef: string): LocalWikiPage | undefined {
|
|
@@ -308,9 +358,9 @@ export function createLocalWikiEngine(config: LocalWikiEngineConfig) {
|
|
|
308
358
|
async function search(params: { query: string; limit?: number; includeSnippets?: boolean }) {
|
|
309
359
|
const { pages } = await loadCache();
|
|
310
360
|
const tokens = expandQuery(params.query);
|
|
311
|
-
const limit = Math.max(1, Math.min(params.limit ??
|
|
361
|
+
const limit = Math.max(1, Math.min(params.limit ?? 8, 50));
|
|
312
362
|
const results = pages.map((p) => scorePage(p, tokens)).filter((x): x is LocalWikiSearchResult => Boolean(x)).sort((a, b) => b.score - a.score).slice(0, limit);
|
|
313
|
-
return { query: params.query, expandedTokens: tokens, results: params.includeSnippets ===
|
|
363
|
+
return { query: params.query, expandedTokens: tokens, results: params.includeSnippets === true ? results : results.map(({ snippet, ...rest }) => rest) };
|
|
314
364
|
}
|
|
315
365
|
|
|
316
366
|
async function loadPage(pageRef: string): Promise<LocalWikiPage> {
|
|
@@ -326,23 +376,27 @@ export function createLocalWikiEngine(config: LocalWikiEngineConfig) {
|
|
|
326
376
|
return { title: page.title, source: page.source, path: page.path, citation: `${page.path} — ${page.title}`, truncated: limited.truncated, text: limited.text };
|
|
327
377
|
}
|
|
328
378
|
|
|
329
|
-
async function sections(params: { page: string }) {
|
|
379
|
+
async function sections(params: { page: string; maxSections?: number }) {
|
|
330
380
|
const page = await loadPage(params.page);
|
|
331
|
-
|
|
381
|
+
const maxSections = Math.max(1, Math.min(params.maxSections ?? 80, 300));
|
|
382
|
+
const selected = page.sections.slice(0, maxSections);
|
|
383
|
+
return { title: page.title, source: page.source, path: page.path, sectionCount: page.sections.length, omittedSectionCount: Math.max(0, page.sections.length - selected.length), sections: selected.map((s) => ({ title: s.title, level: s.level, anchor: s.anchor })) };
|
|
332
384
|
}
|
|
333
385
|
|
|
334
|
-
async function extract(params: { page: string; section?: string; query?: string; maxChars?: number }) {
|
|
386
|
+
async function extract(params: { page: string; section?: string; query?: string; maxChars?: number; maxSections?: number }) {
|
|
335
387
|
const page = await loadPage(params.page);
|
|
336
388
|
let matchedSections = page.sections;
|
|
337
389
|
if (params.section) { const needle = normalizeQuery(params.section); matchedSections = matchedSections.filter((s) => normalizeQuery(s.title).includes(needle)); }
|
|
338
390
|
if (params.query) {
|
|
339
391
|
const tokens = expandQuery(params.query);
|
|
340
|
-
matchedSections = matchedSections.map((section) => ({ section, score: tokens.reduce((sum, token) => sum + (normalizeQuery(`${section.title} ${section.text}`).includes(token) ?
|
|
392
|
+
matchedSections = matchedSections.map((section) => ({ section, score: tokens.reduce((sum, token) => sum + (normalizeQuery(`${section.title} ${section.text}`).includes(token) ? tokenWeight(token) : 0), 0) })).filter((i) => i.score > 0).sort((a, b) => b.score - a.score).map((i) => i.section);
|
|
341
393
|
}
|
|
342
|
-
|
|
394
|
+
const maxSections = Math.max(1, Math.min(params.maxSections ?? (params.section || params.query ? 6 : 5), 50));
|
|
395
|
+
const totalMatchedSections = matchedSections.length;
|
|
396
|
+
matchedSections = matchedSections.slice(0, maxSections);
|
|
343
397
|
const joined = matchedSections.map((s) => `${"#".repeat(Math.min(s.level, 6))} ${s.title}\n\n${s.text}`).join("\n\n");
|
|
344
|
-
const limited = limitText(joined || page.text, params.maxChars ??
|
|
345
|
-
return { title: page.title, source: page.source, path: page.path, citation: `${page.path} — ${matchedSections.map((s) => s.title).join(", ") || page.title}`, matchedSections: matchedSections.map((s) => ({ title: s.title, level: s.level, anchor: s.anchor })), truncated: limited.truncated, text: limited.text };
|
|
398
|
+
const limited = limitText(joined || page.text, params.maxChars ?? 10000);
|
|
399
|
+
return { title: page.title, source: page.source, path: page.path, citation: `${page.path} — ${matchedSections.map((s) => s.title).join(", ") || page.title}`, matchedSections: matchedSections.map((s) => ({ title: s.title, level: s.level, anchor: s.anchor })), totalMatchedSections, omittedSectionCount: Math.max(0, totalMatchedSections - matchedSections.length), truncated: limited.truncated, text: limited.text };
|
|
346
400
|
}
|
|
347
401
|
|
|
348
402
|
async function related(params: { page: string; limit?: number }) {
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import type { InitialPromptCalibration, InitialPromptInputEstimate } from "./tokens";
|
|
4
|
+
|
|
5
|
+
export const INITIAL_PROMPT_CALIBRATION_CUSTOM_TYPE = "stats_initial_prompt_estimate";
|
|
6
|
+
|
|
7
|
+
export type InitialPromptCalibrationSample = {
|
|
8
|
+
ratio: number;
|
|
9
|
+
timestamp: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type InitialPromptCalibrationRecord = {
|
|
13
|
+
version: 1;
|
|
14
|
+
ratio: number;
|
|
15
|
+
estimatedUncalibratedTokens: number;
|
|
16
|
+
estimatedFinalTokens: number;
|
|
17
|
+
actualInitialInputTokens: number;
|
|
18
|
+
actualInjectedTokens: number;
|
|
19
|
+
firstUserTokens: number;
|
|
20
|
+
provider: string;
|
|
21
|
+
model: string;
|
|
22
|
+
createdAt: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type AppendEntryLike = <T = unknown>(customType: string, data?: T) => void;
|
|
26
|
+
|
|
27
|
+
function listSessionFiles(sessionDir: string): string[] {
|
|
28
|
+
try {
|
|
29
|
+
return fs
|
|
30
|
+
.readdirSync(sessionDir, { withFileTypes: true })
|
|
31
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
|
|
32
|
+
.map((entry) => resolve(sessionDir, entry.name));
|
|
33
|
+
} catch {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function quantile(values: number[], q: number): number {
|
|
39
|
+
if (values.length === 0) return 1;
|
|
40
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
41
|
+
const pos = (sorted.length - 1) * q;
|
|
42
|
+
const lower = Math.floor(pos);
|
|
43
|
+
const upper = Math.ceil(pos);
|
|
44
|
+
if (lower === upper) return sorted[lower] ?? 1;
|
|
45
|
+
const weight = pos - lower;
|
|
46
|
+
return (sorted[lower] ?? 1) * (1 - weight) + (sorted[upper] ?? 1) * weight;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function collectInitialPromptCalibrationSamples(sessionDir: string, maxSamples = 100): InitialPromptCalibrationSample[] {
|
|
50
|
+
const samples: InitialPromptCalibrationSample[] = [];
|
|
51
|
+
|
|
52
|
+
for (const file of listSessionFiles(sessionDir)) {
|
|
53
|
+
let content: string;
|
|
54
|
+
try {
|
|
55
|
+
content = fs.readFileSync(file, "utf8");
|
|
56
|
+
} catch {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (const line of content.split(/\r?\n/)) {
|
|
61
|
+
if (!line.trim()) continue;
|
|
62
|
+
try {
|
|
63
|
+
const entry = JSON.parse(line);
|
|
64
|
+
if (entry?.type !== "custom" || entry?.customType !== INITIAL_PROMPT_CALIBRATION_CUSTOM_TYPE) continue;
|
|
65
|
+
const ratio = Number(entry?.data?.ratio);
|
|
66
|
+
if (!Number.isFinite(ratio) || ratio <= 0.25 || ratio >= 4) continue;
|
|
67
|
+
samples.push({ ratio, timestamp: String(entry?.timestamp ?? entry?.data?.createdAt ?? "") });
|
|
68
|
+
} catch {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return samples.sort((a, b) => a.timestamp.localeCompare(b.timestamp)).slice(-maxSamples);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function collectInitialPromptCalibration(sessionDir: string, maxSamples = 100): InitialPromptCalibration | null {
|
|
78
|
+
const ratios = collectInitialPromptCalibrationSamples(sessionDir, maxSamples).map((sample) => sample.ratio);
|
|
79
|
+
if (ratios.length === 0) return null;
|
|
80
|
+
|
|
81
|
+
const median = quantile(ratios, 0.5);
|
|
82
|
+
const q25 = quantile(ratios, 0.25);
|
|
83
|
+
const q75 = quantile(ratios, 0.75);
|
|
84
|
+
return {
|
|
85
|
+
multiplier: median,
|
|
86
|
+
lowMultiplier: Math.min(q25, median * 0.95),
|
|
87
|
+
highMultiplier: Math.max(q75, median * 1.05),
|
|
88
|
+
samples: ratios.length,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function buildInitialPromptCalibrationRecord(args: {
|
|
93
|
+
estimate: InitialPromptInputEstimate;
|
|
94
|
+
actualInitialInputTokens: number;
|
|
95
|
+
firstUserTokens: number;
|
|
96
|
+
provider: string;
|
|
97
|
+
model: string;
|
|
98
|
+
createdAt?: string;
|
|
99
|
+
}): InitialPromptCalibrationRecord | null {
|
|
100
|
+
const actualInjectedTokens = Math.max(0, args.actualInitialInputTokens - args.firstUserTokens);
|
|
101
|
+
const ratio = args.estimate.uncalibratedTotal > 0 ? actualInjectedTokens / args.estimate.uncalibratedTotal : 0;
|
|
102
|
+
if (!Number.isFinite(ratio) || ratio <= 0.25 || ratio >= 4) return null;
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
version: 1,
|
|
106
|
+
ratio,
|
|
107
|
+
estimatedUncalibratedTokens: args.estimate.uncalibratedTotal,
|
|
108
|
+
estimatedFinalTokens: args.estimate.total,
|
|
109
|
+
actualInitialInputTokens: args.actualInitialInputTokens,
|
|
110
|
+
actualInjectedTokens,
|
|
111
|
+
firstUserTokens: args.firstUserTokens,
|
|
112
|
+
provider: args.provider,
|
|
113
|
+
model: args.model,
|
|
114
|
+
createdAt: args.createdAt ?? new Date().toISOString(),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function appendInitialPromptCalibrationRecord(appendEntry: AppendEntryLike, record: InitialPromptCalibrationRecord): boolean {
|
|
119
|
+
try {
|
|
120
|
+
appendEntry(INITIAL_PROMPT_CALIBRATION_CUSTOM_TYPE, record);
|
|
121
|
+
return true;
|
|
122
|
+
} catch {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
package/src/tokens.ts
CHANGED
|
@@ -1,3 +1,61 @@
|
|
|
1
|
+
export type TokenEstimateConfidence = "estimated" | "calibrated" | "measured-after-call";
|
|
2
|
+
|
|
3
|
+
export type InitialPromptToolInfo = {
|
|
4
|
+
name: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
parameters?: unknown;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type InitialPromptCalibration = {
|
|
10
|
+
multiplier?: number;
|
|
11
|
+
lowMultiplier?: number;
|
|
12
|
+
highMultiplier?: number;
|
|
13
|
+
samples?: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type InitialPromptInputEstimate = {
|
|
17
|
+
/** Final best estimate after optional calibration. */
|
|
18
|
+
total: number;
|
|
19
|
+
/** Lower bound for dashboard/budget display. */
|
|
20
|
+
low: number;
|
|
21
|
+
/** Upper bound for dashboard/budget display. */
|
|
22
|
+
high: number;
|
|
23
|
+
/** Uncalibrated total: prompt text + tool schemas + framing. */
|
|
24
|
+
uncalibratedTotal: number;
|
|
25
|
+
/** Estimated tokens in Pi's assembled system prompt text. */
|
|
26
|
+
promptText: number;
|
|
27
|
+
/** Estimated tokens in provider-level active tool schemas. */
|
|
28
|
+
toolSchemas: number;
|
|
29
|
+
/** Provider/message/request framing allowance. */
|
|
30
|
+
framing: number;
|
|
31
|
+
/** Number of active tool schemas included in the estimate. */
|
|
32
|
+
toolCount: number;
|
|
33
|
+
/** Multiplier applied to uncalibratedTotal. */
|
|
34
|
+
calibrationMultiplier: number;
|
|
35
|
+
/** Calibration samples used for multiplier/range. */
|
|
36
|
+
calibrationSamples: number;
|
|
37
|
+
confidence: TokenEstimateConfidence;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type EstimateInitialPromptInputOptions = {
|
|
41
|
+
systemPrompt: string;
|
|
42
|
+
activeTools?: string[];
|
|
43
|
+
allTools?: InitialPromptToolInfo[];
|
|
44
|
+
calibration?: InitialPromptCalibration | number | null;
|
|
45
|
+
/** Override request framing tokens. Defaults to a conservative provider-agnostic allowance. */
|
|
46
|
+
framingTokens?: number;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const ASCII_TOKENS_PER_CHAR = 0.25;
|
|
50
|
+
const LATIN_EXTENDED_TOKENS_PER_CHAR = 0.5;
|
|
51
|
+
const CJK_TOKENS_PER_CHAR = 1.2;
|
|
52
|
+
const OTHER_UNICODE_TOKENS_PER_CHAR = 0.75;
|
|
53
|
+
const EMOJI_TOKENS_PER_CODE_POINT = 2;
|
|
54
|
+
|
|
55
|
+
const DEFAULT_REQUEST_FRAMING_TOKENS = 64;
|
|
56
|
+
const SYSTEM_MESSAGE_FRAMING_TOKENS = 12;
|
|
57
|
+
const TOOL_SCHEMA_FRAMING_TOKENS = 8;
|
|
58
|
+
|
|
1
59
|
export function formatTokens(count: number): string {
|
|
2
60
|
if (count < 1000) return count.toString();
|
|
3
61
|
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
|
@@ -6,10 +64,173 @@ export function formatTokens(count: number): string {
|
|
|
6
64
|
return `${Math.round(count / 1000000)}M`;
|
|
7
65
|
}
|
|
8
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Legacy fast heuristic for callers that only have a character count.
|
|
69
|
+
*/
|
|
9
70
|
export function estimateTokensFromCharCount(charCount: number): number {
|
|
10
71
|
return Math.max(0, Math.round(charCount / 4));
|
|
11
72
|
}
|
|
12
73
|
|
|
74
|
+
function isCjkLike(codePoint: number): boolean {
|
|
75
|
+
return (
|
|
76
|
+
(codePoint >= 0x3040 && codePoint <= 0x30ff) || // Hiragana/Katakana
|
|
77
|
+
(codePoint >= 0x3400 && codePoint <= 0x4dbf) || // CJK Extension A
|
|
78
|
+
(codePoint >= 0x4e00 && codePoint <= 0x9fff) || // CJK Unified Ideographs
|
|
79
|
+
(codePoint >= 0xac00 && codePoint <= 0xd7af) || // Hangul syllables
|
|
80
|
+
(codePoint >= 0xf900 && codePoint <= 0xfaff) || // CJK compatibility
|
|
81
|
+
(codePoint >= 0x20000 && codePoint <= 0x2fa1f)
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isEmojiLike(codePoint: number): boolean {
|
|
86
|
+
return (
|
|
87
|
+
(codePoint >= 0x1f000 && codePoint <= 0x1faff) ||
|
|
88
|
+
(codePoint >= 0x2600 && codePoint <= 0x27bf)
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isCombiningMark(codePoint: number): boolean {
|
|
93
|
+
return (
|
|
94
|
+
(codePoint >= 0x0300 && codePoint <= 0x036f) ||
|
|
95
|
+
(codePoint >= 0x1ab0 && codePoint <= 0x1aff) ||
|
|
96
|
+
(codePoint >= 0x1dc0 && codePoint <= 0x1dff) ||
|
|
97
|
+
(codePoint >= 0x20d0 && codePoint <= 0x20ff) ||
|
|
98
|
+
(codePoint >= 0xfe20 && codePoint <= 0xfe2f)
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Provider-agnostic text token estimate.
|
|
104
|
+
*
|
|
105
|
+
* English/code remains close to the common chars/4 rule, while non-ASCII text is
|
|
106
|
+
* weighted higher to avoid underestimating CJK or emoji-heavy prompts.
|
|
107
|
+
*/
|
|
108
|
+
export function estimateTokensFromText(text: string): number {
|
|
109
|
+
if (!text) return 0;
|
|
110
|
+
|
|
111
|
+
let tokens = 0;
|
|
112
|
+
for (let i = 0; i < text.length; i++) {
|
|
113
|
+
const codePoint = text.codePointAt(i) ?? 0;
|
|
114
|
+
if (codePoint > 0xffff) i++;
|
|
115
|
+
|
|
116
|
+
if (codePoint <= 0x7f) {
|
|
117
|
+
tokens += ASCII_TOKENS_PER_CHAR;
|
|
118
|
+
} else if (isCombiningMark(codePoint)) {
|
|
119
|
+
// Combining marks usually merge into the previous token/grapheme.
|
|
120
|
+
continue;
|
|
121
|
+
} else if (isEmojiLike(codePoint)) {
|
|
122
|
+
tokens += EMOJI_TOKENS_PER_CODE_POINT;
|
|
123
|
+
} else if (isCjkLike(codePoint)) {
|
|
124
|
+
tokens += CJK_TOKENS_PER_CHAR;
|
|
125
|
+
} else if (codePoint <= 0x024f) {
|
|
126
|
+
tokens += LATIN_EXTENDED_TOKENS_PER_CHAR;
|
|
127
|
+
} else {
|
|
128
|
+
tokens += OTHER_UNICODE_TOKENS_PER_CHAR;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return Math.max(0, Math.ceil(tokens));
|
|
133
|
+
}
|
|
134
|
+
|
|
13
135
|
export function estimatePromptInjectionTokens(systemPrompt: string): number {
|
|
14
|
-
return
|
|
136
|
+
return estimateTokensFromText(systemPrompt);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function replacer(_key: string, value: unknown): unknown {
|
|
140
|
+
if (typeof value === "bigint") return value.toString();
|
|
141
|
+
if (typeof value === "function") return "[Function]";
|
|
142
|
+
if (typeof value === "symbol") return value.toString();
|
|
143
|
+
return value;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function stringifyForTokenEstimate(value: unknown): string {
|
|
147
|
+
try {
|
|
148
|
+
return JSON.stringify(value, replacer) ?? "";
|
|
149
|
+
} catch {
|
|
150
|
+
return String(value ?? "");
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function buildActiveToolSchemaPayload(activeTools: string[] | undefined, allTools: InitialPromptToolInfo[] | undefined) {
|
|
155
|
+
if (!allTools || allTools.length === 0) return [];
|
|
156
|
+
|
|
157
|
+
const toolsByName = new Map<string, InitialPromptToolInfo>();
|
|
158
|
+
for (const tool of allTools) {
|
|
159
|
+
if (tool?.name && !toolsByName.has(tool.name)) {
|
|
160
|
+
toolsByName.set(tool.name, tool);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const orderedNames = activeTools && activeTools.length > 0 ? activeTools : Array.from(toolsByName.keys()).sort();
|
|
165
|
+
return orderedNames
|
|
166
|
+
.map((name) => toolsByName.get(name))
|
|
167
|
+
.filter((tool): tool is InitialPromptToolInfo => !!tool)
|
|
168
|
+
.map((tool) => ({
|
|
169
|
+
name: tool.name,
|
|
170
|
+
description: tool.description ?? "",
|
|
171
|
+
parameters: tool.parameters ?? {},
|
|
172
|
+
}));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function normalizeMultiplier(value: unknown, fallback: number): number {
|
|
176
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
177
|
+
if (!Number.isFinite(n) || n <= 0) return fallback;
|
|
178
|
+
return Math.min(4, Math.max(0.25, n));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function resolveCalibration(calibration: InitialPromptCalibration | number | null | undefined): Required<InitialPromptCalibration> {
|
|
182
|
+
if (typeof calibration === "number") {
|
|
183
|
+
const multiplier = normalizeMultiplier(calibration, 1);
|
|
184
|
+
return {
|
|
185
|
+
multiplier,
|
|
186
|
+
lowMultiplier: multiplier * 0.95,
|
|
187
|
+
highMultiplier: multiplier * 1.05,
|
|
188
|
+
samples: 1,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const samples = Math.max(0, Math.floor(Number(calibration?.samples ?? 0) || 0));
|
|
193
|
+
const multiplier = normalizeMultiplier(calibration?.multiplier, 1);
|
|
194
|
+
const lowMultiplier = normalizeMultiplier(calibration?.lowMultiplier, samples > 0 ? multiplier * 0.95 : 0.85);
|
|
195
|
+
const highMultiplier = normalizeMultiplier(calibration?.highMultiplier, samples > 0 ? multiplier * 1.05 : 1.25);
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
multiplier: samples > 0 ? multiplier : 1,
|
|
199
|
+
lowMultiplier: Math.min(lowMultiplier, highMultiplier),
|
|
200
|
+
highMultiplier: Math.max(lowMultiplier, highMultiplier),
|
|
201
|
+
samples,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function estimateInitialPromptInput(options: EstimateInitialPromptInputOptions): InitialPromptInputEstimate {
|
|
206
|
+
const systemPrompt = options.systemPrompt ?? "";
|
|
207
|
+
const promptText = estimatePromptInjectionTokens(systemPrompt);
|
|
208
|
+
const toolPayload = buildActiveToolSchemaPayload(options.activeTools, options.allTools);
|
|
209
|
+
const toolSchemas = toolPayload.length > 0 ? estimateTokensFromText(stringifyForTokenEstimate(toolPayload)) : 0;
|
|
210
|
+
const framing = Math.max(
|
|
211
|
+
0,
|
|
212
|
+
Math.round(
|
|
213
|
+
options.framingTokens ??
|
|
214
|
+
DEFAULT_REQUEST_FRAMING_TOKENS + SYSTEM_MESSAGE_FRAMING_TOKENS + toolPayload.length * TOOL_SCHEMA_FRAMING_TOKENS,
|
|
215
|
+
),
|
|
216
|
+
);
|
|
217
|
+
const uncalibratedTotal = Math.max(0, promptText + toolSchemas + framing);
|
|
218
|
+
const calibration = resolveCalibration(options.calibration);
|
|
219
|
+
const total = Math.max(0, Math.round(uncalibratedTotal * calibration.multiplier));
|
|
220
|
+
const low = Math.max(0, Math.round(uncalibratedTotal * calibration.lowMultiplier));
|
|
221
|
+
const high = Math.max(low, Math.round(uncalibratedTotal * calibration.highMultiplier));
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
total,
|
|
225
|
+
low,
|
|
226
|
+
high,
|
|
227
|
+
uncalibratedTotal,
|
|
228
|
+
promptText,
|
|
229
|
+
toolSchemas,
|
|
230
|
+
framing,
|
|
231
|
+
toolCount: toolPayload.length,
|
|
232
|
+
calibrationMultiplier: calibration.multiplier,
|
|
233
|
+
calibrationSamples: calibration.samples,
|
|
234
|
+
confidence: calibration.samples > 0 ? "calibrated" : "estimated",
|
|
235
|
+
};
|
|
15
236
|
}
|