@aeondave/pi-skill-optimizer 3.0.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/src/skills.ts ADDED
@@ -0,0 +1,1230 @@
1
+ import { createHash } from "node:crypto";
2
+ import { contextualSkillNames } from "./activation.ts";
3
+ import { buildCatalogAliases, expandQueryTokens, type QueryAliasMap } from "./aliases.ts";
4
+ import { EMPTY_PROFILE, type SkillOptimizerProfile } from "./profile.ts";
5
+
6
+ export interface Skill {
7
+ name: string;
8
+ description: string;
9
+ location: string;
10
+ }
11
+
12
+ export interface StableCatalogOptions {
13
+ never?: readonly string[];
14
+ intentMaxChars?: number;
15
+ budgetChars?: number;
16
+ }
17
+
18
+ export interface CatalogBudgetOutcome {
19
+ requestedChars: number;
20
+ usedChars: number;
21
+ floorChars: number;
22
+ intentCount: number;
23
+ nameOnlyCount: number;
24
+ overBudgetChars: number;
25
+ }
26
+
27
+ export interface StableCatalogResult {
28
+ text: string;
29
+ removedChars: number;
30
+ skills: Skill[];
31
+ fingerprint: string;
32
+ budget: CatalogBudgetOutcome;
33
+ }
34
+
35
+ export interface StableCatalogInspection {
36
+ skills: Skill[];
37
+ fingerprint: string;
38
+ floorChars: number;
39
+ }
40
+
41
+ export interface SkillPrefetchOptions {
42
+ profile?: SkillOptimizerProfile;
43
+ usagePrior?: Readonly<Record<string, number>> | ReadonlyMap<string, number>;
44
+ always?: readonly string[];
45
+ never?: readonly string[];
46
+ targetTopK?: number;
47
+ minTopK?: number;
48
+ maxTopK?: number;
49
+ fullRenderBudgetChars?: number;
50
+ fuzzyCandidateLimit?: number;
51
+ }
52
+
53
+ export type RankReason = "exact" | "lexical" | "fuzzy" | "profile" | "usage" | "contextual";
54
+
55
+ export interface RankedSkill {
56
+ skill: Skill;
57
+ score: number;
58
+ lexicalScore: number;
59
+ exactScore: number;
60
+ fuzzyScore: number;
61
+ usageScore: number;
62
+ reasons: RankReason[];
63
+ marginalChars: number;
64
+ }
65
+
66
+ export interface SkillPrefetchPlan {
67
+ query: string;
68
+ fingerprint: string;
69
+ hasSignal: boolean;
70
+ confidence: number;
71
+ selected: Skill[];
72
+ selectedNames: string[];
73
+ ranked: RankedSkill[];
74
+ marginalChars: number;
75
+ skippedForBudget: string[];
76
+ }
77
+
78
+ export interface SkillSearchOptions extends SkillPrefetchOptions {
79
+ cursor?: string;
80
+ pageSize?: number;
81
+ intentMaxChars?: number;
82
+ }
83
+
84
+ export interface SkillSearchItem {
85
+ name: string;
86
+ intent: string;
87
+ score: number;
88
+ confidence: number;
89
+ reasons: RankReason[];
90
+ }
91
+
92
+ export interface SkillSearchResult {
93
+ query: string;
94
+ fingerprint: string;
95
+ confidence: number;
96
+ total: number;
97
+ items: SkillSearchItem[];
98
+ nextCursor?: string;
99
+ }
100
+
101
+ export type SkillDescriptionIssueCode =
102
+ | "too_long"
103
+ | "near_empty"
104
+ | "missing_routing"
105
+ | "duplicate_description";
106
+
107
+ export interface SkillDescriptionIssue {
108
+ name: string;
109
+ code: SkillDescriptionIssueCode;
110
+ descriptionChars: number;
111
+ relatedSkill?: string;
112
+ }
113
+
114
+ export interface SkillDescriptionAuditOptions {
115
+ maxChars?: number;
116
+ minChars?: number;
117
+ duplicateThreshold?: number;
118
+ }
119
+
120
+ export interface SkillDescriptionAudit {
121
+ skillCount: number;
122
+ issueCount: number;
123
+ counts: Record<SkillDescriptionIssueCode, number>;
124
+ estimatedReducibleChars: number;
125
+ issues: SkillDescriptionIssue[];
126
+ }
127
+
128
+ export interface SkillCatalogOptimizationResult extends StableCatalogResult {
129
+ plan: SkillPrefetchPlan;
130
+ }
131
+
132
+ export const DEFAULT_FULL_RENDER_BUDGET_CHARS = 12_000;
133
+ export const DEFAULT_CATALOG_BUDGET_CHARS = 12_000;
134
+ export const DEFAULT_INTENT_MAX_CHARS = 96;
135
+ const AUTO_MARKER = "<!--skill-optimizer:auto:v2-->";
136
+ const CATALOG_RE = /<available_skills\b[^>]*>([\s\S]*?)<\/available_skills>/gi;
137
+ const XML_SKILL_RE = /<skill\b[^>]*>([\s\S]*?)<\/skill>/gi;
138
+ const INDEX_RE = /<skill_index\b[^>]*>([\s\S]*?)<\/skill_index>/gi;
139
+ const STABLE_CATALOG_CACHE_MAX = 8;
140
+ const FIELD_NAMES = ["name", "intent", "description", "queries"] as const;
141
+ type FieldName = typeof FIELD_NAMES[number];
142
+
143
+ const FIELD_WEIGHTS: Readonly<Record<FieldName, number>> = {
144
+ name: 5,
145
+ intent: 2.4,
146
+ description: 1,
147
+ queries: 2,
148
+ };
149
+ const FIELD_B: Readonly<Record<FieldName, number>> = {
150
+ name: 0.2,
151
+ intent: 0.5,
152
+ description: 0.72,
153
+ queries: 0.45,
154
+ };
155
+
156
+ const STOPWORDS = new Set([
157
+ "about", "after", "again", "also", "and", "are", "con", "come", "che", "da", "del", "della",
158
+ "delle", "dei", "degli", "di", "do", "does", "for", "from", "gli", "how", "il", "in", "into",
159
+ "is", "it", "la", "le", "lo", "nel", "nella", "of", "on", "or", "per", "please", "questo", "that",
160
+ "the", "this", "to", "un", "una", "use", "using", "with",
161
+ ]);
162
+
163
+ function decodeXml(text: string): string {
164
+ return text
165
+ .replace(/&lt;/g, "<")
166
+ .replace(/&gt;/g, ">")
167
+ .replace(/&quot;/g, '"')
168
+ .replace(/&#39;|&apos;/g, "'")
169
+ .replace(/&amp;/g, "&");
170
+ }
171
+
172
+ function encodeXml(text: string): string {
173
+ return text
174
+ .replace(/&/g, "&amp;")
175
+ .replace(/</g, "&lt;")
176
+ .replace(/>/g, "&gt;")
177
+ .replace(/"/g, "&quot;");
178
+ }
179
+
180
+ function readTag(body: string, tag: string): string {
181
+ const match = body.match(new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)<\\/${tag}>`, "i"));
182
+ return match ? decodeXml(match[1].trim()) : "";
183
+ }
184
+
185
+ function escapeTsv(text: string): string {
186
+ return text.replace(/\\/g, "\\\\").replace(/\t/g, "\\t").replace(/\r/g, "\\r").replace(/\n/g, "\\n");
187
+ }
188
+
189
+ function unescapeTsv(text: string): string {
190
+ let out = "";
191
+ for (let index = 0; index < text.length; index += 1) {
192
+ if (text[index] !== "\\" || index + 1 >= text.length) {
193
+ out += text[index];
194
+ continue;
195
+ }
196
+ const next = text[index + 1];
197
+ index += 1;
198
+ out += next === "t" ? "\t" : next === "r" ? "\r" : next === "n" ? "\n" : next;
199
+ }
200
+ return out;
201
+ }
202
+
203
+ function clamp(value: number, min: number, max: number): number {
204
+ return Math.min(max, Math.max(min, value));
205
+ }
206
+
207
+ function unique<T>(values: Iterable<T>): T[] {
208
+ return [...new Set(values)];
209
+ }
210
+
211
+ export function tokenize(text: string): string[] {
212
+ const normalized = decodeXml(text)
213
+ .replace(/github/gi, " github ")
214
+ .replace(/([\p{Ll}\d])([\p{Lu}])/gu, "$1 $2")
215
+ .normalize("NFKD")
216
+ .replace(/[\u0300-\u036f]/g, "")
217
+ .toLocaleLowerCase("en-US")
218
+ .replace(/[_/\\.-]+/g, " ");
219
+ const matches = normalized.match(/[\p{L}\p{N}][\p{L}\p{N}+#]*/gu) ?? [];
220
+ return matches.filter((token) =>
221
+ token.length >= 2
222
+ && !/^\d+$/.test(token)
223
+ && !STOPWORDS.has(token)
224
+ );
225
+ }
226
+
227
+ export function parseSkills(text: string): Skill[] {
228
+ const xmlSkills: Skill[] = [];
229
+ for (const match of text.matchAll(XML_SKILL_RE)) {
230
+ const body = match[1];
231
+ const name = readTag(body, "name");
232
+ if (!name) continue;
233
+ xmlSkills.push({
234
+ name,
235
+ description: readTag(body, "description"),
236
+ location: readTag(body, "location"),
237
+ });
238
+ }
239
+ if (xmlSkills.length > 0) return xmlSkills;
240
+
241
+ const indexed: Skill[] = [];
242
+ for (const match of text.matchAll(INDEX_RE)) {
243
+ const decoded = decodeXml(match[1]);
244
+ for (const rawLine of decoded.split(/\r?\n/)) {
245
+ const line = rawLine.trim();
246
+ if (!line) continue;
247
+ const columns = line.split("\t").map(unescapeTsv);
248
+ const name = columns[0]?.trim() ?? "";
249
+ if (!name) continue;
250
+ indexed.push({
251
+ name,
252
+ description: columns[1]?.trim() ?? "",
253
+ location: columns[2]?.trim() ?? "",
254
+ });
255
+ }
256
+ }
257
+ return indexed;
258
+ }
259
+
260
+ function truncateAtWord(text: string, maxChars: number): string {
261
+ if (maxChars <= 0) return "";
262
+ if (text.length <= maxChars) return text;
263
+ if (maxChars <= 3) return text.slice(0, maxChars);
264
+ const boundary = text.lastIndexOf(" ", maxChars - 3);
265
+ return `${text.slice(0, boundary >= Math.floor(maxChars / 2) ? boundary : maxChars - 3).trimEnd()}...`;
266
+ }
267
+
268
+ export function compactDescription(description: string, maxChars: number): string {
269
+ const clean = decodeXml(description).replace(/\s+/g, " ").trim();
270
+ if (!clean || maxChars <= 0) return "";
271
+ const sentences = clean.match(/[^.!?]+[.!?]+|[^.!?]+$/g)?.map((part) => part.trim()) ?? [clean];
272
+ const lead = sentences[0] ?? clean;
273
+ const routing = sentences.slice(1).find((sentence) => /\b(?:apply|load|trigger|use)\s+when\b/i.test(sentence));
274
+ if (!routing) return truncateAtWord(lead, maxChars);
275
+ if (lead === routing) return truncateAtWord(lead, maxChars);
276
+ const combined = `${lead} ${routing}`;
277
+ if (combined.length <= maxChars) return combined;
278
+ const separator = " ";
279
+ const minimumLead = Math.min(32, Math.max(12, Math.floor(maxChars * 0.4)));
280
+ const routeBudget = Math.max(0, maxChars - minimumLead - separator.length);
281
+ const shortRoute = truncateAtWord(routing, routeBudget);
282
+ const leadBudget = Math.max(0, maxChars - shortRoute.length - separator.length);
283
+ return truncateAtWord(`${truncateAtWord(lead, leadBudget)}${separator}${shortRoute}`.trim(), maxChars);
284
+ }
285
+
286
+ function matchesPattern(name: string, patterns: readonly string[]): boolean {
287
+ const lower = name.toLocaleLowerCase("en-US");
288
+ return patterns.some((rawPattern) => {
289
+ const pattern = rawPattern.trim().toLocaleLowerCase("en-US");
290
+ if (!pattern) return false;
291
+ return pattern.endsWith("*") ? lower.startsWith(pattern.slice(0, -1)) : lower === pattern;
292
+ });
293
+ }
294
+
295
+ function denseRow(skill: Skill, intentMaxChars = DEFAULT_INTENT_MAX_CHARS): string {
296
+ const fields = [
297
+ escapeTsv(skill.name),
298
+ escapeTsv(compactDescription(skill.description, intentMaxChars)),
299
+ ];
300
+ while (fields.length > 1 && fields.at(-1) === "") fields.pop();
301
+ return fields.map(encodeXml).join("\t");
302
+ }
303
+
304
+ function renderStableBlock(skills: readonly Skill[], intentLimits: readonly number[]): string {
305
+ const rows = skills.map((skill, index) => denseRow(skill, intentLimits[index] ?? 0)).join("\n");
306
+ return [
307
+ "<available_skills>",
308
+ AUTO_MARKER,
309
+ "<skill_resolver>Load full instructions for any listed name with skill_search.</skill_resolver>",
310
+ '<skill_index format="tsv" columns="name,intent">',
311
+ rows,
312
+ "</skill_index>",
313
+ "</available_skills>",
314
+ ].join("\n");
315
+ }
316
+
317
+ function canonicalSkillData(skills: readonly Skill[]): string {
318
+ return skills.map((skill) =>
319
+ `${skill.name.length}:${skill.name}${skill.description.length}:${skill.description}`
320
+ ).join("");
321
+ }
322
+
323
+ export function catalogFingerprint(skills: readonly Skill[]): string {
324
+ return createHash("sha256").update(canonicalSkillData(skills), "utf8").digest("hex");
325
+ }
326
+
327
+ interface PreparedStableCatalogBlock {
328
+ blockIndex: number;
329
+ marked: boolean;
330
+ parsed: readonly Skill[];
331
+ retained: readonly Skill[];
332
+ }
333
+
334
+ interface PreparedStableCatalog {
335
+ cacheKey: string;
336
+ source: string;
337
+ exclusions: string;
338
+ blocks: readonly PreparedStableCatalogBlock[];
339
+ skills: readonly Skill[];
340
+ fingerprint: string;
341
+ floorChars: number;
342
+ }
343
+
344
+ const stableCatalogCache = new Map<string, PreparedStableCatalog>();
345
+ const stableCatalogRenderCache = new Map<string, StableCatalogResult>();
346
+
347
+ function prepareStableCatalog(text: string, never: readonly string[]): PreparedStableCatalog {
348
+ const exclusions = JSON.stringify(never);
349
+ const cacheKey = createHash("sha256")
350
+ .update("pi-skill-optimizer:stable-catalog:v1\0", "utf8")
351
+ .update(exclusions, "utf8")
352
+ .update("\0", "utf8")
353
+ .update(text, "utf8")
354
+ .digest("hex");
355
+ const cached = stableCatalogCache.get(cacheKey);
356
+ if (cached?.source === text && cached.exclusions === exclusions) {
357
+ stableCatalogCache.delete(cacheKey);
358
+ stableCatalogCache.set(cacheKey, cached);
359
+ return cached;
360
+ }
361
+
362
+ const blocks = [...text.matchAll(CATALOG_RE)].map((match, blockIndex): PreparedStableCatalogBlock => {
363
+ const inner = match[1];
364
+ const parsed = parseSkills(inner);
365
+ return {
366
+ blockIndex,
367
+ marked: inner.includes(AUTO_MARKER),
368
+ parsed,
369
+ retained: parsed.filter((skill) => !matchesPattern(skill.name, never)),
370
+ };
371
+ });
372
+ const skills = [...new Map(blocks
373
+ .flatMap((block) => block.retained)
374
+ .map((skill) => [skill.name, skill])).values()];
375
+ const floorChars = blocks
376
+ .filter((block) => !block.marked && block.parsed.length > 0)
377
+ .reduce((sum, block) => sum + renderStableBlock(block.retained, block.retained.map(() => 0)).length, 0);
378
+ const prepared: PreparedStableCatalog = {
379
+ cacheKey,
380
+ source: text,
381
+ exclusions,
382
+ blocks,
383
+ skills,
384
+ fingerprint: catalogFingerprint(skills),
385
+ floorChars,
386
+ };
387
+ stableCatalogCache.set(cacheKey, prepared);
388
+ while (stableCatalogCache.size > STABLE_CATALOG_CACHE_MAX) {
389
+ stableCatalogCache.delete(stableCatalogCache.keys().next().value as string);
390
+ }
391
+ return prepared;
392
+ }
393
+
394
+ function cloneStableCatalogResult(result: StableCatalogResult): StableCatalogResult {
395
+ return {
396
+ ...result,
397
+ skills: result.skills.map((skill) => ({ ...skill })),
398
+ budget: { ...result.budget },
399
+ };
400
+ }
401
+
402
+ /** Inspect the immutable all-name floor without rendering the catalog a first time. */
403
+ export function inspectStableSkillCatalog(
404
+ text: string,
405
+ options: Omit<StableCatalogOptions, "budgetChars"> = {},
406
+ ): StableCatalogInspection {
407
+ const prepared = prepareStableCatalog(text, options.never ?? []);
408
+ return {
409
+ skills: prepared.skills.map((skill) => ({ ...skill })),
410
+ fingerprint: prepared.fingerprint,
411
+ floorChars: prepared.floorChars,
412
+ };
413
+ }
414
+
415
+ export function renderStableSkillCatalog(text: string, options: StableCatalogOptions = {}): StableCatalogResult {
416
+ const never = options.never ?? [];
417
+ const intentMaxChars = clamp(Math.trunc(options.intentMaxChars ?? DEFAULT_INTENT_MAX_CHARS), 24, 240);
418
+ const requestedChars = Math.max(0, Math.trunc(options.budgetChars ?? DEFAULT_CATALOG_BUDGET_CHARS));
419
+ const prepared = prepareStableCatalog(text, never);
420
+ const renderCacheKey = `${prepared.cacheKey}:${intentMaxChars}:${requestedChars}`;
421
+ const cached = stableCatalogRenderCache.get(renderCacheKey);
422
+ if (cached) {
423
+ stableCatalogRenderCache.delete(renderCacheKey);
424
+ stableCatalogRenderCache.set(renderCacheKey, cached);
425
+ return cloneStableCatalogResult(cached);
426
+ }
427
+ const blocks = prepared.blocks.map((block) => ({
428
+ ...block,
429
+ intentLimits: block.retained.map(() => 0),
430
+ }));
431
+ const rawBlocks = blocks.filter((block) => !block.marked && block.parsed.length > 0);
432
+ const floorChars = prepared.floorChars;
433
+ let remaining = Math.max(0, requestedChars - floorChars);
434
+ const candidates = rawBlocks.flatMap((block) => block.retained
435
+ .map((skill, skillIndex) => {
436
+ if (!skill.description) return undefined;
437
+ const floor = denseRow(skill, 0).length;
438
+ const full = denseRow(skill, intentMaxChars).length;
439
+ return {
440
+ block,
441
+ skill,
442
+ skillIndex,
443
+ cost: full - floor,
444
+ };
445
+ })
446
+ .filter((candidate): candidate is NonNullable<typeof candidate> => candidate !== undefined))
447
+ .sort((left, right) =>
448
+ left.cost - right.cost
449
+ || left.skill.name.localeCompare(right.skill.name)
450
+ || left.block.blockIndex - right.block.blockIndex
451
+ || left.skillIndex - right.skillIndex
452
+ );
453
+ const deferred: typeof candidates = [];
454
+ for (const candidate of candidates) {
455
+ if (candidate.cost <= remaining) {
456
+ candidate.block.intentLimits[candidate.skillIndex] = intentMaxChars;
457
+ remaining -= candidate.cost;
458
+ } else {
459
+ deferred.push(candidate);
460
+ }
461
+ }
462
+ // Spend a useful remainder on one partial intent rather than padding or
463
+ // exceeding the cap. The binary search accounts for XML/TSV escaping.
464
+ if (remaining > 4) {
465
+ for (const candidate of deferred) {
466
+ const floor = denseRow(candidate.skill, 0).length;
467
+ let low = 24;
468
+ let high = intentMaxChars;
469
+ let best = 0;
470
+ while (low <= high) {
471
+ const middle = Math.floor((low + high) / 2);
472
+ const cost = denseRow(candidate.skill, middle).length - floor;
473
+ if (cost <= remaining) {
474
+ best = middle;
475
+ low = middle + 1;
476
+ } else {
477
+ high = middle - 1;
478
+ }
479
+ }
480
+ if (best > 0 && denseRow(candidate.skill, best).length > floor) {
481
+ candidate.block.intentLimits[candidate.skillIndex] = best;
482
+ remaining -= denseRow(candidate.skill, best).length - floor;
483
+ break;
484
+ }
485
+ }
486
+ }
487
+ let changed = false;
488
+ let blockCursor = 0;
489
+ let usedChars = 0;
490
+ let intentCount = 0;
491
+ let nameOnlyCount = 0;
492
+ const next = text.replace(CATALOG_RE, (block, inner: string) => {
493
+ const state = blocks[blockCursor++];
494
+ if (!state || state.parsed.length === 0) return block;
495
+ if (state.marked) {
496
+ usedChars += block.length;
497
+ for (const skill of state.retained) {
498
+ if (skill.description) intentCount += 1;
499
+ else nameOnlyCount += 1;
500
+ }
501
+ return block;
502
+ }
503
+ const rebuilt = renderStableBlock(state.retained, state.intentLimits);
504
+ usedChars += rebuilt.length;
505
+ for (const limit of state.intentLimits) {
506
+ if (limit > 0) intentCount += 1;
507
+ else nameOnlyCount += 1;
508
+ }
509
+ changed = true;
510
+ return rebuilt;
511
+ });
512
+ const result: StableCatalogResult = {
513
+ text: changed ? next : text,
514
+ removedChars: changed ? Math.max(0, text.length - next.length) : 0,
515
+ skills: prepared.skills.map((skill) => ({ ...skill })),
516
+ fingerprint: prepared.fingerprint,
517
+ budget: {
518
+ requestedChars,
519
+ usedChars,
520
+ floorChars,
521
+ intentCount,
522
+ nameOnlyCount,
523
+ overBudgetChars: Math.max(0, usedChars - requestedChars),
524
+ },
525
+ };
526
+ stableCatalogRenderCache.set(renderCacheKey, result);
527
+ while (stableCatalogRenderCache.size > STABLE_CATALOG_CACHE_MAX) {
528
+ stableCatalogRenderCache.delete(stableCatalogRenderCache.keys().next().value as string);
529
+ }
530
+ return cloneStableCatalogResult(result);
531
+ }
532
+
533
+ interface TermDocument {
534
+ fields: Record<FieldName, Map<string, number>>;
535
+ fieldLengths: Record<FieldName, number>;
536
+ allTerms: Set<string>;
537
+ nameTerms: Set<string>;
538
+ intentTerms: Set<string>;
539
+ namePhraseTokens: string[];
540
+ namePhrase: string;
541
+ profileQueryPhrases: string[];
542
+ name: string;
543
+ nameSuffix: string | undefined;
544
+ aliasLabels: string[];
545
+ aliasLabelSet: Set<string>;
546
+ fuzzyLabels: string[] | undefined;
547
+ fuzzyLabelTrigrams: Set<string>[] | undefined;
548
+ }
549
+
550
+ interface CatalogAnalysis {
551
+ documents: TermDocument[];
552
+ df: Map<string, number>;
553
+ averageLengths: Record<FieldName, number>;
554
+ aliases: QueryAliasMap;
555
+ fingerprint: string;
556
+ }
557
+
558
+ const ANALYSIS_CACHE_MAX = 8;
559
+ const analysisCache = new Map<string, CatalogAnalysis>();
560
+
561
+ function termFrequency(tokens: readonly string[]): Map<string, number> {
562
+ const out = new Map<string, number>();
563
+ for (const token of tokens) out.set(token, (out.get(token) ?? 0) + 1);
564
+ return out;
565
+ }
566
+
567
+ function stableObject(value: unknown): unknown {
568
+ if (Array.isArray(value)) return value.map(stableObject);
569
+ if (!value || typeof value !== "object") return value;
570
+ return Object.fromEntries(Object.entries(value as Record<string, unknown>)
571
+ .sort(([a], [b]) => a.localeCompare(b))
572
+ .map(([key, item]) => [key, stableObject(item)]));
573
+ }
574
+
575
+ function profileFingerprint(profile: SkillOptimizerProfile): string {
576
+ return createHash("sha256").update(JSON.stringify(stableObject(profile)), "utf8").digest("hex");
577
+ }
578
+
579
+ function routingIntent(description: string): string {
580
+ return compactDescription(description, DEFAULT_INTENT_MAX_CHARS);
581
+ }
582
+
583
+ function analyzeCatalog(skills: readonly Skill[], profile: SkillOptimizerProfile): CatalogAnalysis {
584
+ const fingerprint = catalogFingerprint(skills);
585
+ const cacheKey = `${fingerprint}:${profileFingerprint(profile)}`;
586
+ const cached = analysisCache.get(cacheKey);
587
+ if (cached) {
588
+ analysisCache.delete(cacheKey);
589
+ analysisCache.set(cacheKey, cached);
590
+ return cached;
591
+ }
592
+
593
+ const documents = skills.map((skill): TermDocument => {
594
+ const namePhraseTokens = tokenize(skill.name);
595
+ const fields: Record<FieldName, Map<string, number>> = {
596
+ name: termFrequency(namePhraseTokens),
597
+ intent: termFrequency(tokenize(routingIntent(skill.description))),
598
+ description: termFrequency(tokenize(skill.description)),
599
+ queries: termFrequency((profile.queries[skill.name] ?? []).flatMap(tokenize)),
600
+ };
601
+ const fieldLengths = Object.fromEntries(FIELD_NAMES.map((field) => [
602
+ field,
603
+ [...fields[field].values()].reduce((sum, frequency) => sum + frequency, 0),
604
+ ])) as Record<FieldName, number>;
605
+ const allTerms = new Set(FIELD_NAMES.flatMap((field) => [...fields[field].keys()]));
606
+ return {
607
+ fields,
608
+ fieldLengths,
609
+ allTerms,
610
+ nameTerms: new Set(fields.name.keys()),
611
+ intentTerms: new Set(fields.intent.keys()),
612
+ namePhraseTokens,
613
+ namePhrase: namePhraseTokens.join(" "),
614
+ profileQueryPhrases: (profile.queries[skill.name] ?? []).map((phrase) => tokenize(phrase).join(" ")),
615
+ name: skill.name,
616
+ nameSuffix: undefined,
617
+ aliasLabels: [],
618
+ aliasLabelSet: new Set(),
619
+ fuzzyLabels: undefined,
620
+ fuzzyLabelTrigrams: undefined,
621
+ };
622
+ });
623
+ const df = new Map<string, number>();
624
+ for (const document of documents) {
625
+ for (const term of document.allTerms) df.set(term, (df.get(term) ?? 0) + 1);
626
+ }
627
+ const aliases = buildCatalogAliases((term) => df.has(term));
628
+ for (const document of documents) {
629
+ for (const [source, targets] of aliases) {
630
+ if (targets.some((target) => document.allTerms.has(target))) document.aliasLabels.push(source);
631
+ }
632
+ document.aliasLabelSet = new Set(document.aliasLabels);
633
+ }
634
+ const averageLengths = Object.fromEntries(FIELD_NAMES.map((field) => {
635
+ const total = documents.reduce((sum, document) =>
636
+ sum + document.fieldLengths[field], 0);
637
+ return [field, Math.max(1, total / Math.max(1, documents.length))];
638
+ })) as Record<FieldName, number>;
639
+ const analysis: CatalogAnalysis = { documents, df, averageLengths, aliases, fingerprint };
640
+ analysisCache.set(cacheKey, analysis);
641
+ while (analysisCache.size > ANALYSIS_CACHE_MAX) analysisCache.delete(analysisCache.keys().next().value as string);
642
+ return analysis;
643
+ }
644
+
645
+ function bm25fScore(
646
+ analysis: CatalogAnalysis,
647
+ index: number,
648
+ terms: readonly string[],
649
+ idfByTerm: ReadonlyMap<string, number>,
650
+ ): number {
651
+ const document = analysis.documents[index];
652
+ let score = 0;
653
+ for (const term of terms) {
654
+ const idf = idfByTerm.get(term) ?? 0;
655
+ if (idf === 0) continue;
656
+ let weightedFrequency = 0;
657
+ for (const field of FIELD_NAMES) {
658
+ const values = document.fields[field];
659
+ const frequency = values.get(term) ?? 0;
660
+ if (frequency === 0) continue;
661
+ const normalization = 1 - FIELD_B[field] + FIELD_B[field] * (document.fieldLengths[field] / analysis.averageLengths[field]);
662
+ weightedFrequency += FIELD_WEIGHTS[field] * frequency / Math.max(0.2, normalization);
663
+ }
664
+ score += idf * (weightedFrequency * 2.2) / (weightedFrequency + 1.2);
665
+ }
666
+ return score;
667
+ }
668
+
669
+ function containsTokenPhrase(text: string, phrase: string): boolean {
670
+ return phrase.length > 0 && (` ${text} `).includes(` ${phrase} `);
671
+ }
672
+
673
+ function exactScore(
674
+ document: TermDocument,
675
+ queryPhrase: string,
676
+ rawTerms: readonly string[],
677
+ rawTermSet: ReadonlySet<string>,
678
+ ): number {
679
+ let score = 0;
680
+ if (containsTokenPhrase(queryPhrase, document.namePhrase)) score = 12;
681
+ else if (document.nameTerms.size > 0 && [...document.nameTerms].every((term) => rawTermSet.has(term))) score = 8;
682
+ else if (rawTerms.some((term) => document.nameTerms.has(term))) score = 3;
683
+ for (const examplePhrase of document.profileQueryPhrases) {
684
+ if (containsTokenPhrase(queryPhrase, examplePhrase)) score = Math.max(score, 7);
685
+ }
686
+ if (rawTerms.some((term) => document.aliasLabelSet.has(term))) score = Math.max(score, 4);
687
+ return score;
688
+ }
689
+
690
+ function trigrams(text: string): Set<string> {
691
+ const normalized = text.replace(/\s+/g, "");
692
+ const out = new Set<string>();
693
+ if (normalized.length < 3) return out;
694
+ for (let index = 0; index <= normalized.length - 3; index += 1) out.add(normalized.slice(index, index + 3));
695
+ return out;
696
+ }
697
+
698
+ function dice(left: Set<string>, right: Set<string>): number {
699
+ if (left.size === 0 || right.size === 0) return 0;
700
+ let intersection = 0;
701
+ for (const value of left) if (right.has(value)) intersection += 1;
702
+ return 2 * intersection / (left.size + right.size);
703
+ }
704
+
705
+ function terminalLabelSegment(value: string): string {
706
+ return value.normalize("NFKC").toLowerCase().match(/[\p{L}\p{N}]+/gu)?.at(-1) ?? "";
707
+ }
708
+
709
+ function fuzzyScores(
710
+ analysis: CatalogAnalysis,
711
+ query: string,
712
+ rawTerms: readonly string[],
713
+ coverage: number,
714
+ limit: number,
715
+ ): number[] {
716
+ const scores = analysis.documents.map(() => 0);
717
+ if (rawTerms.length === 0 || coverage >= 0.6 || limit <= 0) return scores;
718
+ const queryLabels = rawTerms.filter((term) => term.length >= 5);
719
+ if (queryLabels.length === 0) return scores;
720
+ const queryTrigrams = queryLabels.map(trigrams);
721
+ const querySuffix = terminalLabelSegment(query);
722
+ for (let index = 0; index < analysis.documents.length; index += 1) {
723
+ const document = analysis.documents[index];
724
+ const labels = document.fuzzyLabels ??= unique([
725
+ document.namePhraseTokens.join(""),
726
+ ...document.namePhraseTokens,
727
+ ...document.aliasLabels,
728
+ ]).filter((label) => label.length >= 5);
729
+ const labelTrigrams = document.fuzzyLabelTrigrams ??= labels.map(trigrams);
730
+ let best = 0;
731
+ for (const queryLabelTrigrams of queryTrigrams) {
732
+ for (const labelTri of labelTrigrams) best = Math.max(best, dice(queryLabelTrigrams, labelTri));
733
+ }
734
+ if (best >= 0.56) {
735
+ const nameSuffix = document.nameSuffix ??= terminalLabelSegment(document.name);
736
+ scores[index] = querySuffix && nameSuffix === querySuffix ? Math.min(1, best + 0.08) : best;
737
+ }
738
+ }
739
+ const candidates = scores
740
+ .map((score, index) => ({ score, index }))
741
+ .filter(({ score }) => score > 0)
742
+ .sort((a, b) => b.score - a.score || a.index - b.index);
743
+ for (const candidate of candidates.slice(limit)) scores[candidate.index] = 0;
744
+ return scores;
745
+ }
746
+
747
+ function isUsageMap(
748
+ prior: NonNullable<SkillPrefetchOptions["usagePrior"]>,
749
+ ): prior is ReadonlyMap<string, number> {
750
+ return typeof (prior as { get?: unknown }).get === "function";
751
+ }
752
+
753
+ function usageValue(prior: SkillPrefetchOptions["usagePrior"], name: string): number {
754
+ if (!prior) return 0;
755
+ const value = isUsageMap(prior) ? prior.get(name) : prior[name];
756
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
757
+ }
758
+
759
+ function addRrfSignal(
760
+ out: number[],
761
+ values: readonly number[],
762
+ weight: number,
763
+ eligible?: ReadonlySet<number>,
764
+ ): void {
765
+ const ranked = values
766
+ .map((value, index) => ({ value, index }))
767
+ .filter(({ value, index }) => value > 0 && (!eligible || eligible.has(index)))
768
+ .sort((a, b) => b.value - a.value || a.index - b.index);
769
+ for (let rank = 0; rank < ranked.length; rank += 1) {
770
+ out[ranked[rank].index] += weight / (60 + rank + 1);
771
+ }
772
+ }
773
+
774
+ function skillClusters(profile: SkillOptimizerProfile): Map<string, string[]> {
775
+ const out = new Map<string, string[]>();
776
+ for (const [cluster, names] of Object.entries(profile.clusters)) {
777
+ for (const name of names) out.set(name, [...(out.get(name) ?? []), cluster]);
778
+ }
779
+ return out;
780
+ }
781
+
782
+ function diversityOrder(
783
+ ranked: readonly RankedSkill[],
784
+ profile: SkillOptimizerProfile,
785
+ prefixLimit: number,
786
+ ): RankedSkill[] {
787
+ const remaining = [...ranked];
788
+ const ordered: RankedSkill[] = [];
789
+ const clusterCounts = new Map<string, number>();
790
+ const clusters = skillClusters(profile);
791
+ const limit = clamp(Math.trunc(prefixLimit), 0, ranked.length);
792
+ while (remaining.length > 0 && ordered.length < limit) {
793
+ let bestIndex = 0;
794
+ let bestAdjusted = -Infinity;
795
+ for (let index = 0; index < remaining.length; index += 1) {
796
+ const repeated = (clusters.get(remaining[index].skill.name) ?? [])
797
+ .reduce((sum, cluster) => sum + (clusterCounts.get(cluster) ?? 0), 0);
798
+ const adjusted = remaining[index].score / (1 + repeated * 0.18);
799
+ if (adjusted > bestAdjusted) {
800
+ bestAdjusted = adjusted;
801
+ bestIndex = index;
802
+ }
803
+ }
804
+ const [picked] = remaining.splice(bestIndex, 1);
805
+ ordered.push(picked);
806
+ for (const cluster of clusters.get(picked.skill.name) ?? []) {
807
+ clusterCounts.set(cluster, (clusterCounts.get(cluster) ?? 0) + 1);
808
+ }
809
+ }
810
+ return ordered.length === ranked.length ? ordered : [...ordered, ...remaining];
811
+ }
812
+
813
+ function renderFullSkill(skill: Skill): string {
814
+ const parts = [
815
+ " <skill>",
816
+ ` <name>${encodeXml(skill.name)}</name>`,
817
+ ];
818
+ if (skill.description) parts.push(` <description>${encodeXml(skill.description)}</description>`);
819
+ parts.push(" </skill>");
820
+ return parts.join("\n");
821
+ }
822
+
823
+ function marginalRenderChars(skill: Skill): number {
824
+ return Math.max(0, renderFullSkill(skill).length - denseRow(skill).length);
825
+ }
826
+
827
+ function emptyPlan(query: string, skills: readonly Skill[]): SkillPrefetchPlan {
828
+ return {
829
+ query,
830
+ fingerprint: catalogFingerprint(skills),
831
+ hasSignal: false,
832
+ confidence: 0,
833
+ selected: [],
834
+ selectedNames: [],
835
+ ranked: [],
836
+ marginalChars: 0,
837
+ skippedForBudget: [],
838
+ };
839
+ }
840
+
841
+ export function planSkillPrefetch(
842
+ skills: readonly Skill[],
843
+ query: string,
844
+ options: SkillPrefetchOptions = {},
845
+ ): SkillPrefetchPlan {
846
+ const profile = options.profile ?? EMPTY_PROFILE;
847
+ const never = options.never ?? [];
848
+ const available = skills.filter((skill) => !matchesPattern(skill.name, never));
849
+ if (available.length === 0) return emptyPlan(query, available);
850
+ const analysis = analyzeCatalog(available, profile);
851
+ const queryTokens = tokenize(query);
852
+ const rawTerms = unique(queryTokens);
853
+ const rawTermSet = new Set(rawTerms);
854
+ const queryPhrase = queryTokens.join(" ");
855
+ const expandedTerms = expandQueryTokens(rawTerms, analysis.aliases);
856
+ const termMatches = rawTerms.filter((term) =>
857
+ analysis.df.has(term) || (analysis.aliases.get(term)?.length ?? 0) > 0
858
+ ).length;
859
+ const coverage = rawTerms.length > 0 ? termMatches / rawTerms.length : 0;
860
+ const count = Math.max(1, available.length);
861
+ const idfByTerm = new Map<string, number>();
862
+ for (const term of expandedTerms) {
863
+ const documentFrequency = analysis.df.get(term) ?? 0;
864
+ if (documentFrequency > 0) {
865
+ idfByTerm.set(term, Math.log(1 + (count - documentFrequency + 0.5) / (documentFrequency + 0.5)));
866
+ }
867
+ }
868
+ const lexical = available.map((_, index) => bm25fScore(analysis, index, expandedTerms, idfByTerm));
869
+ const exact = available.map((_, index) => exactScore(analysis.documents[index], queryPhrase, rawTerms, rawTermSet));
870
+ const fuzzy = fuzzyScores(
871
+ analysis,
872
+ query,
873
+ rawTerms,
874
+ coverage,
875
+ clamp(Math.trunc(options.fuzzyCandidateLimit ?? 12), 0, 32),
876
+ );
877
+ const eligible = new Set<number>();
878
+ for (let index = 0; index < available.length; index += 1) {
879
+ if (lexical[index] > 0 || exact[index] > 0 || fuzzy[index] > 0) eligible.add(index);
880
+ }
881
+ const hasSignal = rawTerms.length > 0 && eligible.size > 0;
882
+ const usage = available.map((skill) => usageValue(options.usagePrior, skill.name));
883
+ const maximumUsage = Math.max(0, ...usage);
884
+ const normalizedUsage = usage.map((value) => maximumUsage > 0 ? value / maximumUsage : 0);
885
+ const fused = available.map(() => 0);
886
+ addRrfSignal(fused, lexical, 1.5);
887
+ addRrfSignal(fused, exact, 2.2);
888
+ addRrfSignal(fused, fuzzy, 0.8);
889
+ if (hasSignal) addRrfSignal(fused, normalizedUsage, 0.35, eligible);
890
+
891
+ for (let index = 0; index < available.length; index += 1) {
892
+ const negative = new Set((profile.negativeHints[available[index].name] ?? []).flatMap(tokenize));
893
+ if (rawTerms.some((term) => negative.has(term))) fused[index] *= 0.55;
894
+ }
895
+
896
+ let ranked = available
897
+ .map((skill, index): RankedSkill => {
898
+ const reasons: RankReason[] = [];
899
+ if (exact[index] > 0) reasons.push("exact");
900
+ if (lexical[index] > 0) reasons.push("lexical");
901
+ if (fuzzy[index] > 0) reasons.push("fuzzy");
902
+ if ((profile.queries[skill.name]?.length ?? 0) > 0 && lexical[index] > 0) reasons.push("profile");
903
+ if (normalizedUsage[index] > 0 && eligible.has(index)) reasons.push("usage");
904
+ return {
905
+ skill,
906
+ score: fused[index] * 100,
907
+ lexicalScore: lexical[index],
908
+ exactScore: exact[index],
909
+ fuzzyScore: fuzzy[index],
910
+ usageScore: normalizedUsage[index],
911
+ reasons,
912
+ marginalChars: marginalRenderChars(skill),
913
+ };
914
+ })
915
+ .filter((entry, index) => hasSignal && eligible.has(index) && entry.score > 0)
916
+ .sort((left, right) => right.score - left.score || right.exactScore - left.exactScore || left.skill.name.localeCompare(right.skill.name));
917
+ const minTopK = clamp(Math.trunc(options.minTopK ?? 3), 0, available.length);
918
+ const maxTopK = clamp(Math.trunc(options.maxTopK ?? 16), minTopK, available.length);
919
+ const targetTopK = clamp(Math.trunc(options.targetTopK ?? 8), minTopK, maxTopK);
920
+ ranked = diversityOrder(ranked, profile, maxTopK);
921
+
922
+ const top = ranked[0]?.score ?? 0;
923
+ const second = ranked[1]?.score ?? 0;
924
+ const gap = top > 0 ? clamp((top - second) / top, 0, 1) : 0;
925
+ const exactConfidence = (ranked[0]?.exactScore ?? 0) >= 7 ? 1 : (ranked[0]?.exactScore ?? 0) > 0 ? 0.5 : 0;
926
+ const confidence = hasSignal ? clamp(0.15 + 0.35 * coverage + 0.35 * exactConfidence + 0.15 * gap, 0, 1) : 0;
927
+ let desired = confidence >= 0.8
928
+ ? minTopK
929
+ : confidence <= 0.35
930
+ ? maxTopK
931
+ : targetTopK;
932
+ if (confidence < 0.8 && desired > 0 && ranked.length > desired) {
933
+ const boundary = ranked[desired - 1]?.score ?? 0;
934
+ while (desired < maxTopK && (ranked[desired]?.score ?? 0) >= boundary * 0.9) desired += 1;
935
+ }
936
+
937
+ const protectedNames = new Set([
938
+ ...profile.critical,
939
+ ...(options.always ?? []),
940
+ ].filter((name) => available.some((skill) => skill.name === name)));
941
+ const selected: Skill[] = [];
942
+ const selectedNames = new Set<string>();
943
+ for (const skill of available) {
944
+ if (!protectedNames.has(skill.name)) continue;
945
+ selected.push(skill);
946
+ selectedNames.add(skill.name);
947
+ }
948
+ const budget = Math.max(0, Math.trunc(options.fullRenderBudgetChars ?? DEFAULT_FULL_RENDER_BUDGET_CHARS));
949
+ const marginalChars = selected.reduce((sum, skill) => sum + marginalRenderChars(skill), 0);
950
+ let ordinaryChars = 0;
951
+ const skippedForBudget: string[] = [];
952
+ const directSelectionOrder = new Map(ranked.map((entry, index) => [entry.skill.name, index]));
953
+ const availableByName = new Map(available.map((skill) => [skill.name, skill]));
954
+ const contextualNamesFor = (primaryName: string): string[] => {
955
+ if (profile.activation?.[primaryName]?.kind !== "specialist") return [];
956
+ return contextualSkillNames(profile.activation, primaryName).filter((name) => {
957
+ if (!availableByName.has(name) || selectedNames.has(name)) return false;
958
+ const negative = new Set((profile.negativeHints[name] ?? []).flatMap(tokenize));
959
+ if (rawTerms.some((term) => negative.has(term))) return false;
960
+ const directIndex = directSelectionOrder.get(name);
961
+ return directIndex === undefined || directIndex >= desired;
962
+ });
963
+ };
964
+ const exactDirectNames = new Set(ranked
965
+ .slice(0, desired)
966
+ .filter((entry) => entry.exactScore >= 8 && !selectedNames.has(entry.skill.name))
967
+ .map((entry) => entry.skill.name));
968
+ const reservedDirectLimit = Math.max(desired - 1, exactDirectNames.size);
969
+ const hasReachableContextualCandidate = desired >= 2
970
+ && exactDirectNames.size < desired
971
+ && ranked.slice(0, desired).some((entry) => contextualNamesFor(entry.skill.name).length > 0);
972
+ const directOrder = hasReachableContextualCandidate
973
+ ? [
974
+ ...ranked.filter((entry) => exactDirectNames.has(entry.skill.name)),
975
+ ...ranked.filter((entry) => !exactDirectNames.has(entry.skill.name)),
976
+ ]
977
+ : ranked;
978
+ const canReserveContextualSlot = hasReachableContextualCandidate
979
+ && directOrder.slice(0, reservedDirectLimit).some((entry) => contextualNamesFor(entry.skill.name).length > 0);
980
+ const directLimit = canReserveContextualSlot ? reservedDirectLimit : desired;
981
+ const directSelected: RankedSkill[] = [];
982
+ const attemptedDirect = new Set<string>();
983
+ const selectDirectUntil = (limit: number, entries: readonly RankedSkill[]): void => {
984
+ for (const entry of entries) {
985
+ if (directSelected.length >= limit) break;
986
+ if (selectedNames.has(entry.skill.name) || attemptedDirect.has(entry.skill.name)) continue;
987
+ attemptedDirect.add(entry.skill.name);
988
+ if (ordinaryChars + entry.marginalChars > budget) {
989
+ skippedForBudget.push(entry.skill.name);
990
+ continue;
991
+ }
992
+ directSelected.push(entry);
993
+ selectedNames.add(entry.skill.name);
994
+ ordinaryChars += entry.marginalChars;
995
+ }
996
+ };
997
+ selectDirectUntil(directLimit, directOrder);
998
+
999
+ let contextualEntry: RankedSkill | undefined;
1000
+ if (canReserveContextualSlot) {
1001
+ for (const primary of [...directSelected].sort((left, right) =>
1002
+ (directSelectionOrder.get(left.skill.name) ?? 0) - (directSelectionOrder.get(right.skill.name) ?? 0)
1003
+ )) {
1004
+ for (const contextualName of contextualNamesFor(primary.skill.name)) {
1005
+ const contextualSkill = availableByName.get(contextualName);
1006
+ if (!contextualSkill || selectedNames.has(contextualName)) continue;
1007
+ const contextualChars = marginalRenderChars(contextualSkill);
1008
+ if (ordinaryChars + contextualChars > budget) {
1009
+ skippedForBudget.push(contextualName);
1010
+ continue;
1011
+ }
1012
+ const directEntry = ranked.find((entry) => entry.skill.name === contextualName);
1013
+ contextualEntry = directEntry
1014
+ ? { ...directEntry, reasons: [...directEntry.reasons, "contextual"] }
1015
+ : {
1016
+ skill: contextualSkill,
1017
+ score: 0,
1018
+ lexicalScore: 0,
1019
+ exactScore: 0,
1020
+ fuzzyScore: 0,
1021
+ usageScore: 0,
1022
+ reasons: ["contextual"],
1023
+ marginalChars: contextualChars,
1024
+ };
1025
+ selectedNames.add(contextualName);
1026
+ ordinaryChars += contextualChars;
1027
+ break;
1028
+ }
1029
+ if (contextualEntry) break;
1030
+ }
1031
+ }
1032
+ if (!contextualEntry) selectDirectUntil(desired, ranked);
1033
+ directSelected.sort((left, right) =>
1034
+ (directSelectionOrder.get(left.skill.name) ?? 0) - (directSelectionOrder.get(right.skill.name) ?? 0)
1035
+ );
1036
+ selected.push(...directSelected.map((entry) => entry.skill));
1037
+ if (contextualEntry) selected.push(contextualEntry.skill);
1038
+ const contextualIsDirect = contextualEntry !== undefined && directSelectionOrder.has(contextualEntry.skill.name);
1039
+ const rankedWithContext = contextualEntry
1040
+ ? contextualIsDirect
1041
+ ? ranked.map((entry) => entry.skill.name === contextualEntry?.skill.name ? contextualEntry : entry)
1042
+ : [...ranked, contextualEntry]
1043
+ : ranked;
1044
+ return {
1045
+ query,
1046
+ fingerprint: analysis.fingerprint,
1047
+ hasSignal,
1048
+ confidence,
1049
+ selected,
1050
+ selectedNames: selected.map((skill) => skill.name),
1051
+ ranked: rankedWithContext,
1052
+ marginalChars: marginalChars + ordinaryChars,
1053
+ skippedForBudget: unique(skippedForBudget),
1054
+ };
1055
+ }
1056
+
1057
+ export function renderSkillPrefetch(plan: SkillPrefetchPlan): string {
1058
+ if (plan.selected.length === 0) return "";
1059
+ return [
1060
+ `<skill_prefetch catalog_sha256="${plan.fingerprint}">`,
1061
+ " <skill_resolver>Use skill_search(name) to load the full skill resource.</skill_resolver>",
1062
+ ...plan.selected.map(renderFullSkill),
1063
+ "</skill_prefetch>",
1064
+ ].join("\n");
1065
+ }
1066
+
1067
+ interface SearchCursorPayload {
1068
+ v: 2;
1069
+ f: string;
1070
+ q: string;
1071
+ r: string;
1072
+ o: number;
1073
+ s: string;
1074
+ }
1075
+
1076
+ function searchQueryFingerprint(query: string): string {
1077
+ return createHash("sha256").update(query, "utf8").digest("hex");
1078
+ }
1079
+
1080
+ function rankedNamesFingerprint(ranked: readonly RankedSkill[]): string {
1081
+ const hash = createHash("sha256").update("pi-skill-optimizer:rank-order:v1\0", "utf8");
1082
+ for (const { skill } of ranked) hash.update(`${skill.name.length}:${skill.name}\0`, "utf8");
1083
+ return hash.digest("hex");
1084
+ }
1085
+
1086
+ function cursorSignature(fingerprint: string, queryHash: string, rankHash: string, offset: number): string {
1087
+ return createHash("sha256")
1088
+ .update(`pi-skill-optimizer:cursor:v2\0${fingerprint}\0${queryHash}\0${rankHash}\0${offset}`, "utf8")
1089
+ .digest("hex")
1090
+ .slice(0, 24);
1091
+ }
1092
+
1093
+ function encodeSearchCursor(fingerprint: string, queryHash: string, rankHash: string, offset: number): string {
1094
+ const payload: SearchCursorPayload = {
1095
+ v: 2,
1096
+ f: fingerprint,
1097
+ q: queryHash,
1098
+ r: rankHash,
1099
+ o: offset,
1100
+ s: cursorSignature(fingerprint, queryHash, rankHash, offset),
1101
+ };
1102
+ return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
1103
+ }
1104
+
1105
+ function decodeSearchCursor(cursor: string, fingerprint: string, queryHash: string, rankHash: string): number {
1106
+ try {
1107
+ const payload = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as Partial<SearchCursorPayload>;
1108
+ if (
1109
+ payload.v !== 2
1110
+ || payload.f !== fingerprint
1111
+ || payload.q !== queryHash
1112
+ || payload.r !== rankHash
1113
+ || !Number.isSafeInteger(payload.o)
1114
+ || (payload.o ?? -1) < 0
1115
+ || payload.s !== cursorSignature(fingerprint, queryHash, rankHash, payload.o as number)
1116
+ ) throw new Error("mismatch");
1117
+ return payload.o as number;
1118
+ } catch {
1119
+ throw new RangeError("Invalid or stale skill search cursor");
1120
+ }
1121
+ }
1122
+
1123
+ export function searchSkillCatalog(
1124
+ skills: readonly Skill[],
1125
+ query: string,
1126
+ options: SkillSearchOptions = {},
1127
+ ): SkillSearchResult {
1128
+ const plan = planSkillPrefetch(skills, query, options);
1129
+ const pageSize = clamp(Math.trunc(options.pageSize ?? 10), 1, 50);
1130
+ const queryHash = searchQueryFingerprint(query);
1131
+ const rankHash = rankedNamesFingerprint(plan.ranked);
1132
+ const offset = options.cursor
1133
+ ? decodeSearchCursor(options.cursor, plan.fingerprint, queryHash, rankHash)
1134
+ : 0;
1135
+ const intentMaxChars = clamp(Math.trunc(options.intentMaxChars ?? DEFAULT_INTENT_MAX_CHARS), 24, 240);
1136
+ const page = plan.ranked.slice(offset, offset + pageSize);
1137
+ const topScore = plan.ranked[0]?.score ?? 0;
1138
+ const items = page.map((entry): SkillSearchItem => ({
1139
+ name: entry.skill.name,
1140
+ intent: compactDescription(entry.skill.description, intentMaxChars),
1141
+ score: entry.score,
1142
+ confidence: entry.exactScore >= 12
1143
+ ? 1
1144
+ : entry.exactScore >= 8
1145
+ ? Math.max(0.95, plan.confidence)
1146
+ : clamp(plan.confidence * (topScore > 0 ? entry.score / topScore : 0), 0, 0.94),
1147
+ reasons: [...entry.reasons],
1148
+ }));
1149
+ const nextOffset = offset + page.length;
1150
+ return {
1151
+ query,
1152
+ fingerprint: plan.fingerprint,
1153
+ confidence: plan.confidence,
1154
+ total: plan.ranked.length,
1155
+ items,
1156
+ ...(nextOffset < plan.ranked.length
1157
+ ? { nextCursor: encodeSearchCursor(plan.fingerprint, queryHash, rankHash, nextOffset) }
1158
+ : {}),
1159
+ };
1160
+ }
1161
+
1162
+ function jaccard(left: ReadonlySet<string>, right: ReadonlySet<string>): number {
1163
+ if (left.size === 0 || right.size === 0) return 0;
1164
+ let intersection = 0;
1165
+ for (const value of left) if (right.has(value)) intersection += 1;
1166
+ return intersection / (left.size + right.size - intersection);
1167
+ }
1168
+
1169
+ export function auditSkillDescriptions(
1170
+ skills: readonly Skill[],
1171
+ options: SkillDescriptionAuditOptions = {},
1172
+ ): SkillDescriptionAudit {
1173
+ const maxChars = clamp(Math.trunc(options.maxChars ?? 600), 80, 10_000);
1174
+ const minChars = clamp(Math.trunc(options.minChars ?? 24), 1, maxChars);
1175
+ const duplicateThreshold = clamp(options.duplicateThreshold ?? 0.9, 0.7, 1);
1176
+ const issues: SkillDescriptionIssue[] = [];
1177
+ const tokenSets = skills.map((skill) => new Set(tokenize(skill.description)));
1178
+ const routingPattern = /\b(?:apply|load|trigger|use)\s+when\b|\bwhen\s+(?:handling|reviewing|working|you|your)\b|\bfor\s+(?:requests?|tasks?|workflows?|cases?|projects?)\b/i;
1179
+ let estimatedReducibleChars = 0;
1180
+ for (let index = 0; index < skills.length; index += 1) {
1181
+ const skill = skills[index];
1182
+ const clean = skill.description.replace(/\s+/g, " ").trim();
1183
+ if (clean.length > maxChars) {
1184
+ issues.push({ name: skill.name, code: "too_long", descriptionChars: clean.length });
1185
+ estimatedReducibleChars += clean.length - maxChars;
1186
+ }
1187
+ if (clean.length < minChars || tokenSets[index].size < 3) {
1188
+ issues.push({ name: skill.name, code: "near_empty", descriptionChars: clean.length });
1189
+ } else if (!routingPattern.test(clean)) {
1190
+ issues.push({ name: skill.name, code: "missing_routing", descriptionChars: clean.length });
1191
+ }
1192
+ for (let previous = 0; previous < index; previous += 1) {
1193
+ if (tokenSets[index].size < 5 || tokenSets[previous].size < 5) continue;
1194
+ if (jaccard(tokenSets[index], tokenSets[previous]) < duplicateThreshold) continue;
1195
+ issues.push({
1196
+ name: skill.name,
1197
+ code: "duplicate_description",
1198
+ descriptionChars: clean.length,
1199
+ relatedSkill: skills[previous].name,
1200
+ });
1201
+ break;
1202
+ }
1203
+ }
1204
+ const counts: Record<SkillDescriptionIssueCode, number> = {
1205
+ too_long: 0,
1206
+ near_empty: 0,
1207
+ missing_routing: 0,
1208
+ duplicate_description: 0,
1209
+ };
1210
+ for (const issue of issues) counts[issue.code] += 1;
1211
+ return {
1212
+ skillCount: skills.length,
1213
+ issueCount: issues.length,
1214
+ counts,
1215
+ estimatedReducibleChars,
1216
+ issues,
1217
+ };
1218
+ }
1219
+
1220
+ export function optimizeSkillCatalog(
1221
+ text: string,
1222
+ query: string,
1223
+ options: SkillPrefetchOptions & StableCatalogOptions = {},
1224
+ ): SkillCatalogOptimizationResult {
1225
+ const stable = renderStableSkillCatalog(text, options);
1226
+ return {
1227
+ ...stable,
1228
+ plan: planSkillPrefetch(stable.skills, query, options),
1229
+ };
1230
+ }