@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/BENCHMARK.md +354 -0
- package/CHANGELOG.md +117 -0
- package/LICENSE +21 -0
- package/README.md +148 -0
- package/assets/banner.png +0 -0
- package/docs/ARCHITECTURE.md +66 -0
- package/docs/CONFIGURATION.md +138 -0
- package/docs/RANKING.md +155 -0
- package/package.json +85 -0
- package/src/activation.ts +88 -0
- package/src/aliases.ts +111 -0
- package/src/config.ts +446 -0
- package/src/corpus.ts +416 -0
- package/src/evaluation.ts +692 -0
- package/src/generate.ts +365 -0
- package/src/history.ts +147 -0
- package/src/index.ts +1362 -0
- package/src/init-profile.ts +96 -0
- package/src/init-prompt.ts +10 -0
- package/src/init-readiness.ts +123 -0
- package/src/optimize.ts +424 -0
- package/src/output-recovery.ts +19 -0
- package/src/output.ts +629 -0
- package/src/persistence.ts +535 -0
- package/src/profile.ts +253 -0
- package/src/prompts/init-profile.md +40 -0
- package/src/request.ts +304 -0
- package/src/skill-loader.ts +138 -0
- package/src/skills.ts +1230 -0
- package/src/stats.ts +178 -0
- package/src/tools.ts +548 -0
- package/src/usage.ts +256 -0
package/src/tools.ts
ADDED
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure local tool discovery.
|
|
3
|
+
*
|
|
4
|
+
* Tool definitions are indexed locally and remain untouched. Dynamic loading may
|
|
5
|
+
* activate a confident subset, but ambiguous or missing evidence always fails
|
|
6
|
+
* open to the complete named catalog.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { tokenize } from "./skills.ts";
|
|
10
|
+
|
|
11
|
+
export interface Tool {
|
|
12
|
+
name: string;
|
|
13
|
+
description?: string;
|
|
14
|
+
[key: string]: unknown;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Generous, case-insensitive set of core/agent tool names. These tools always
|
|
19
|
+
* remain active when a discovery catalog is used.
|
|
20
|
+
*/
|
|
21
|
+
export const CORE_TOOLS = new Set(
|
|
22
|
+
[
|
|
23
|
+
"read", "bash", "edit", "write", "multiedit", "str_replace_editor", "notebookedit",
|
|
24
|
+
"glob", "grep", "ls", "find", "todo", "todowrite", "todoread",
|
|
25
|
+
"subagent", "fork", "task", "mcp", "skill", "exitplanmode", "enterplanmode",
|
|
26
|
+
"webfetch", "websearch", "askuserquestion", "powershell",
|
|
27
|
+
].map((name) => name.toLowerCase()),
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
export type ToolSearchConfidence = "none" | "low" | "high";
|
|
31
|
+
export type ToolSearchField = "name" | "description" | "schemaName" | "schemaText";
|
|
32
|
+
|
|
33
|
+
export interface ToolDiscoveryBuildOptions {
|
|
34
|
+
/** Case-insensitive exact names or prefixes that must always remain active. */
|
|
35
|
+
protect?: readonly string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ToolTermIndex {
|
|
39
|
+
readonly frequencies: ReadonlyMap<string, number>;
|
|
40
|
+
readonly length: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ToolDiscoveryEntry {
|
|
44
|
+
readonly name: string;
|
|
45
|
+
readonly canonicalName: string;
|
|
46
|
+
readonly description: string;
|
|
47
|
+
readonly sourceIndex: number;
|
|
48
|
+
readonly alwaysActive: boolean;
|
|
49
|
+
readonly fields: Readonly<Record<ToolSearchField, ToolTermIndex>>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Immutable metadata plus a local BM25F index. No tool definition is rewritten. */
|
|
53
|
+
export interface ToolDiscoveryCatalog {
|
|
54
|
+
readonly tools: readonly unknown[];
|
|
55
|
+
readonly entries: readonly ToolDiscoveryEntry[];
|
|
56
|
+
readonly alwaysActiveNames: readonly string[];
|
|
57
|
+
readonly candidateNames: readonly string[];
|
|
58
|
+
/** Entries without a usable name; callers should leave them under Pi's native policy. */
|
|
59
|
+
readonly unmanagedToolIndexes: readonly number[];
|
|
60
|
+
readonly fingerprint: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ToolSearchOptions {
|
|
64
|
+
/** Opaque cursor returned by the preceding page. */
|
|
65
|
+
cursor?: string;
|
|
66
|
+
/** Results per page, clamped to 1..50. Default: 8. */
|
|
67
|
+
pageSize?: number;
|
|
68
|
+
/** Maximum compact description length, clamped to 0..500. Default: 240. */
|
|
69
|
+
descriptionChars?: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface ToolSearchMatch {
|
|
73
|
+
readonly name: string;
|
|
74
|
+
readonly description: string;
|
|
75
|
+
readonly score: number;
|
|
76
|
+
readonly matchedTerms: readonly string[];
|
|
77
|
+
readonly matchedFields: readonly ToolSearchField[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface ToolSearchPage {
|
|
81
|
+
readonly results: readonly ToolSearchMatch[];
|
|
82
|
+
readonly total: number;
|
|
83
|
+
readonly nextCursor?: string;
|
|
84
|
+
readonly confidence: ToolSearchConfidence;
|
|
85
|
+
/** True means results include the deterministic catalog fallback, not only matches. */
|
|
86
|
+
readonly failOpen: boolean;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface ToolActivationOptions {
|
|
90
|
+
/** Maximum ordinary candidates to activate after a confident search. */
|
|
91
|
+
topK: number;
|
|
92
|
+
/** Tools observed in conversation history. Matching is case-insensitive. */
|
|
93
|
+
usedNames?: ReadonlySet<string>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export type ToolActivationReason =
|
|
97
|
+
| "confident-match"
|
|
98
|
+
| "no-candidates"
|
|
99
|
+
| "no-signal"
|
|
100
|
+
| "no-match"
|
|
101
|
+
| "weak-signal"
|
|
102
|
+
| "ambiguous-cutoff"
|
|
103
|
+
| "invalid-top-k";
|
|
104
|
+
|
|
105
|
+
export interface ToolActivationPlan {
|
|
106
|
+
/** Stable registration order, suitable for Pi's setActiveTools(). */
|
|
107
|
+
readonly activeNames: readonly string[];
|
|
108
|
+
/** Core, configured-protected, and already-used names. */
|
|
109
|
+
readonly alwaysActiveNames: readonly string[];
|
|
110
|
+
/** Ranked ordinary matches; empty when the plan failed open. */
|
|
111
|
+
readonly selectedNames: readonly string[];
|
|
112
|
+
readonly confidence: ToolSearchConfidence;
|
|
113
|
+
readonly failOpen: boolean;
|
|
114
|
+
readonly reason: ToolActivationReason;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const FIELD_ORDER: readonly ToolSearchField[] = ["name", "description", "schemaName", "schemaText"];
|
|
118
|
+
const FIELD_WEIGHT: Readonly<Record<ToolSearchField, number>> = {
|
|
119
|
+
name: 7,
|
|
120
|
+
description: 2.2,
|
|
121
|
+
schemaName: 2.8,
|
|
122
|
+
schemaText: 0.9,
|
|
123
|
+
};
|
|
124
|
+
const FIELD_LENGTH_NORMALIZATION: Readonly<Record<ToolSearchField, number>> = {
|
|
125
|
+
name: 0.2,
|
|
126
|
+
description: 0.7,
|
|
127
|
+
schemaName: 0.45,
|
|
128
|
+
schemaText: 0.75,
|
|
129
|
+
};
|
|
130
|
+
const BM25_K1 = 1.2;
|
|
131
|
+
const MAX_NAME_SEARCH_CHARS = 512;
|
|
132
|
+
const MAX_DESCRIPTION_SEARCH_CHARS = 4_096;
|
|
133
|
+
const MAX_FIELD_TERMS = 512;
|
|
134
|
+
const MAX_SCHEMA_SEARCH_CHARS = 2_048;
|
|
135
|
+
const MAX_SCHEMA_SEARCH_NODES = 96;
|
|
136
|
+
const MAX_SCHEMA_SEARCH_DEPTH = 4;
|
|
137
|
+
const MAX_SCHEMA_SEARCH_TERMS = 256;
|
|
138
|
+
const DEFAULT_PAGE_SIZE = 8;
|
|
139
|
+
const DEFAULT_DESCRIPTION_CHARS = 240;
|
|
140
|
+
const CURSOR_VERSION = "td1";
|
|
141
|
+
|
|
142
|
+
interface SchemaFields {
|
|
143
|
+
names: string;
|
|
144
|
+
text: string;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
interface RankedEntry {
|
|
148
|
+
entry: ToolDiscoveryEntry;
|
|
149
|
+
score: number;
|
|
150
|
+
matchedTerms: string[];
|
|
151
|
+
matchedFields: ToolSearchField[];
|
|
152
|
+
exactName: boolean;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
interface Ranking {
|
|
156
|
+
ranked: RankedEntry[];
|
|
157
|
+
queryTerms: string[];
|
|
158
|
+
knownQueryTerms: number;
|
|
159
|
+
confidence: ToolSearchConfidence;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function canonicalName(name: string): string {
|
|
163
|
+
return name.trim().toLowerCase();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function matchesAny(name: string, patterns: readonly string[]): boolean {
|
|
167
|
+
const candidate = canonicalName(name);
|
|
168
|
+
return patterns.some((pattern) => {
|
|
169
|
+
const normalized = canonicalName(pattern);
|
|
170
|
+
return normalized.length > 0 && (candidate === normalized || candidate.startsWith(normalized));
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Read Anthropic/Responses or nested OpenAI Chat function definitions. */
|
|
175
|
+
export function getToolDefinition(value: unknown): Tool | undefined {
|
|
176
|
+
if (!value || typeof value !== "object") return undefined;
|
|
177
|
+
const record = value as Record<string, unknown>;
|
|
178
|
+
if (typeof record.name === "string") return record as Tool;
|
|
179
|
+
const fn = record.function;
|
|
180
|
+
if (fn && typeof fn === "object" && typeof (fn as { name?: unknown }).name === "string") return fn as Tool;
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Immutably replace a top-level or nested OpenAI Chat tool description. */
|
|
185
|
+
export function replaceToolDescription(value: unknown, description: string): unknown {
|
|
186
|
+
if (!value || typeof value !== "object") return value;
|
|
187
|
+
const record = value as Record<string, unknown>;
|
|
188
|
+
if (typeof record.name === "string") return { ...record, description };
|
|
189
|
+
const fn = record.function;
|
|
190
|
+
if (fn && typeof fn === "object" && typeof (fn as { name?: unknown }).name === "string") {
|
|
191
|
+
return { ...record, function: { ...(fn as Record<string, unknown>), description } };
|
|
192
|
+
}
|
|
193
|
+
return value;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function appendBounded(chunks: string[], value: unknown, state: { chars: number }): void {
|
|
197
|
+
if ((typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") || state.chars >= MAX_SCHEMA_SEARCH_CHARS) return;
|
|
198
|
+
const text = String(value)
|
|
199
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
200
|
+
.replace(/[_-]+/g, " ")
|
|
201
|
+
.trim();
|
|
202
|
+
if (!text) return;
|
|
203
|
+
const remaining = MAX_SCHEMA_SEARCH_CHARS - state.chars;
|
|
204
|
+
const bounded = text.slice(0, remaining);
|
|
205
|
+
chunks.push(bounded);
|
|
206
|
+
state.chars += bounded.length + 1;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Extract routing evidence only, never arbitrary/default schema values. */
|
|
210
|
+
function schemaFields(tool: Tool): SchemaFields {
|
|
211
|
+
const schema = tool.input_schema ?? tool.inputSchema ?? tool.parameters;
|
|
212
|
+
if (!schema || typeof schema !== "object") return { names: "", text: "" };
|
|
213
|
+
const names: string[] = [];
|
|
214
|
+
const text: string[] = [];
|
|
215
|
+
const state = { chars: 0, nodes: 0 };
|
|
216
|
+
const seen = new WeakSet<object>();
|
|
217
|
+
|
|
218
|
+
const visit = (value: unknown, depth: number): void => {
|
|
219
|
+
if (!value || typeof value !== "object" || depth > MAX_SCHEMA_SEARCH_DEPTH || state.nodes >= MAX_SCHEMA_SEARCH_NODES || state.chars >= MAX_SCHEMA_SEARCH_CHARS) return;
|
|
220
|
+
if (seen.has(value as object)) return;
|
|
221
|
+
seen.add(value as object);
|
|
222
|
+
state.nodes += 1;
|
|
223
|
+
const record = value as Record<string, unknown>;
|
|
224
|
+
if (Array.isArray(record.required)) for (const required of record.required) appendBounded(names, required, state);
|
|
225
|
+
if (Array.isArray(record.enum)) for (const option of record.enum) appendBounded(text, option, state);
|
|
226
|
+
if (typeof record.description === "string") appendBounded(text, record.description, state);
|
|
227
|
+
if (record.properties && typeof record.properties === "object" && !Array.isArray(record.properties)) {
|
|
228
|
+
const properties = record.properties as Record<string, unknown>;
|
|
229
|
+
for (const name of Object.keys(properties).sort()) {
|
|
230
|
+
appendBounded(names, name, state);
|
|
231
|
+
visit(properties[name], depth + 1);
|
|
232
|
+
if (state.nodes >= MAX_SCHEMA_SEARCH_NODES || state.chars >= MAX_SCHEMA_SEARCH_CHARS) break;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (record.items) visit(record.items, depth + 1);
|
|
236
|
+
for (const keyword of ["allOf", "anyOf", "oneOf"] as const) {
|
|
237
|
+
const branches = record[keyword];
|
|
238
|
+
if (!Array.isArray(branches)) continue;
|
|
239
|
+
for (const branch of branches) {
|
|
240
|
+
visit(branch, depth + 1);
|
|
241
|
+
if (state.nodes >= MAX_SCHEMA_SEARCH_NODES || state.chars >= MAX_SCHEMA_SEARCH_CHARS) break;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
visit(schema, 0);
|
|
247
|
+
return { names: names.join(" "), text: text.join(" ") };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function termIndex(text: string, maxChars: number, maxTerms: number): ToolTermIndex {
|
|
251
|
+
const terms = tokenize(text.slice(0, maxChars)).slice(0, maxTerms);
|
|
252
|
+
const frequencies = new Map<string, number>();
|
|
253
|
+
for (const term of terms) frequencies.set(term, (frequencies.get(term) ?? 0) + 1);
|
|
254
|
+
return { frequencies, length: terms.length };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function hashUpdate(hash: number, text: string): number {
|
|
258
|
+
let next = hash >>> 0;
|
|
259
|
+
for (let i = 0; i < text.length; i++) {
|
|
260
|
+
next ^= text.charCodeAt(i);
|
|
261
|
+
next = Math.imul(next, 0x01000193) >>> 0;
|
|
262
|
+
}
|
|
263
|
+
return next;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function catalogFingerprint(entries: readonly ToolDiscoveryEntry[]): string {
|
|
267
|
+
let hash = 0x811c9dc5;
|
|
268
|
+
for (const entry of entries) {
|
|
269
|
+
hash = hashUpdate(hash, `\0${entry.canonicalName}\0${entry.alwaysActive ? "1" : "0"}`);
|
|
270
|
+
for (const field of FIELD_ORDER) {
|
|
271
|
+
const terms = [...entry.fields[field].frequencies.entries()].sort(([a], [b]) => a.localeCompare(b));
|
|
272
|
+
for (const [term, count] of terms) hash = hashUpdate(hash, `\0${field}:${term}:${count}`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return hash.toString(36);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Build a deterministic, schema-bounded local index without changing tools. */
|
|
279
|
+
export function buildToolDiscoveryCatalog(
|
|
280
|
+
tools: readonly unknown[],
|
|
281
|
+
options: ToolDiscoveryBuildOptions = {},
|
|
282
|
+
): ToolDiscoveryCatalog {
|
|
283
|
+
const protect = options.protect ?? [];
|
|
284
|
+
const entries: ToolDiscoveryEntry[] = [];
|
|
285
|
+
const unmanagedToolIndexes: number[] = [];
|
|
286
|
+
const seenNames = new Set<string>();
|
|
287
|
+
|
|
288
|
+
tools.forEach((value, sourceIndex) => {
|
|
289
|
+
const definition = getToolDefinition(value);
|
|
290
|
+
if (!definition || canonicalName(definition.name).length === 0) {
|
|
291
|
+
unmanagedToolIndexes.push(sourceIndex);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const canonical = canonicalName(definition.name);
|
|
295
|
+
if (seenNames.has(canonical)) return;
|
|
296
|
+
seenNames.add(canonical);
|
|
297
|
+
const schema = schemaFields(definition);
|
|
298
|
+
const alwaysActive = CORE_TOOLS.has(canonical) || matchesAny(canonical, protect);
|
|
299
|
+
entries.push(Object.freeze({
|
|
300
|
+
name: definition.name,
|
|
301
|
+
canonicalName: canonical,
|
|
302
|
+
description: typeof definition.description === "string" ? definition.description : "",
|
|
303
|
+
sourceIndex,
|
|
304
|
+
alwaysActive,
|
|
305
|
+
fields: Object.freeze({
|
|
306
|
+
name: termIndex(definition.name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " "), MAX_NAME_SEARCH_CHARS, 64),
|
|
307
|
+
description: termIndex(typeof definition.description === "string" ? definition.description : "", MAX_DESCRIPTION_SEARCH_CHARS, MAX_FIELD_TERMS),
|
|
308
|
+
schemaName: termIndex(schema.names, MAX_SCHEMA_SEARCH_CHARS, MAX_SCHEMA_SEARCH_TERMS),
|
|
309
|
+
schemaText: termIndex(schema.text, MAX_SCHEMA_SEARCH_CHARS, MAX_SCHEMA_SEARCH_TERMS),
|
|
310
|
+
}),
|
|
311
|
+
}));
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const immutableEntries = Object.freeze(entries.slice());
|
|
315
|
+
return Object.freeze({
|
|
316
|
+
tools: Object.freeze([...tools]),
|
|
317
|
+
entries: immutableEntries,
|
|
318
|
+
alwaysActiveNames: Object.freeze(entries.filter((entry) => entry.alwaysActive).map((entry) => entry.name)),
|
|
319
|
+
candidateNames: Object.freeze(entries.filter((entry) => !entry.alwaysActive).map((entry) => entry.name)),
|
|
320
|
+
unmanagedToolIndexes: Object.freeze(unmanagedToolIndexes.slice()),
|
|
321
|
+
fingerprint: catalogFingerprint(immutableEntries),
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function uniqueTerms(query: string): string[] {
|
|
326
|
+
return [...new Set(tokenize(query))];
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function rankEntries(entries: readonly ToolDiscoveryEntry[], query: string): Ranking {
|
|
330
|
+
const queryTerms = uniqueTerms(query);
|
|
331
|
+
if (queryTerms.length === 0 || entries.length === 0) {
|
|
332
|
+
return { ranked: [], queryTerms, knownQueryTerms: 0, confidence: "none" };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const averageLength = Object.fromEntries(FIELD_ORDER.map((field) => [
|
|
336
|
+
field,
|
|
337
|
+
Math.max(1, entries.reduce((sum, entry) => sum + entry.fields[field].length, 0) / entries.length),
|
|
338
|
+
])) as Record<ToolSearchField, number>;
|
|
339
|
+
const documentFrequency = new Map<string, number>();
|
|
340
|
+
for (const term of queryTerms) {
|
|
341
|
+
let count = 0;
|
|
342
|
+
for (const entry of entries) {
|
|
343
|
+
if (FIELD_ORDER.some((field) => entry.fields[field].frequencies.has(term))) count += 1;
|
|
344
|
+
}
|
|
345
|
+
documentFrequency.set(term, count);
|
|
346
|
+
}
|
|
347
|
+
const knownQueryTerms = queryTerms.filter((term) => (documentFrequency.get(term) ?? 0) > 0).length;
|
|
348
|
+
const exactQuery = queryTerms.join(" ");
|
|
349
|
+
const ranked: RankedEntry[] = [];
|
|
350
|
+
|
|
351
|
+
for (const entry of entries) {
|
|
352
|
+
let score = 0;
|
|
353
|
+
const matchedTerms: string[] = [];
|
|
354
|
+
const matchedFieldSet = new Set<ToolSearchField>();
|
|
355
|
+
for (const term of queryTerms) {
|
|
356
|
+
const df = documentFrequency.get(term) ?? 0;
|
|
357
|
+
if (df === 0) continue;
|
|
358
|
+
let combinedFrequency = 0;
|
|
359
|
+
for (const field of FIELD_ORDER) {
|
|
360
|
+
const index = entry.fields[field];
|
|
361
|
+
const frequency = index.frequencies.get(term) ?? 0;
|
|
362
|
+
if (frequency === 0) continue;
|
|
363
|
+
matchedFieldSet.add(field);
|
|
364
|
+
const b = FIELD_LENGTH_NORMALIZATION[field];
|
|
365
|
+
const normalizedLength = 1 - b + b * (index.length / averageLength[field]);
|
|
366
|
+
combinedFrequency += FIELD_WEIGHT[field] * frequency / normalizedLength;
|
|
367
|
+
}
|
|
368
|
+
if (combinedFrequency === 0) continue;
|
|
369
|
+
matchedTerms.push(term);
|
|
370
|
+
const idf = Math.log(1 + (entries.length - df + 0.5) / (df + 0.5));
|
|
371
|
+
score += idf * (combinedFrequency * (BM25_K1 + 1)) / (combinedFrequency + BM25_K1);
|
|
372
|
+
}
|
|
373
|
+
const exactName = exactQuery.length > 0 && exactQuery === [...entry.fields.name.frequencies.keys()].join(" ");
|
|
374
|
+
if (exactName) score += 12;
|
|
375
|
+
if (score > 0) {
|
|
376
|
+
ranked.push({
|
|
377
|
+
entry,
|
|
378
|
+
score,
|
|
379
|
+
matchedTerms,
|
|
380
|
+
matchedFields: FIELD_ORDER.filter((field) => matchedFieldSet.has(field)),
|
|
381
|
+
exactName,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
ranked.sort((a, b) => b.score - a.score || a.entry.sourceIndex - b.entry.sourceIndex || a.entry.canonicalName.localeCompare(b.entry.canonicalName));
|
|
387
|
+
if (ranked.length === 0) return { ranked, queryTerms, knownQueryTerms, confidence: "none" };
|
|
388
|
+
const top = ranked[0];
|
|
389
|
+
const runner = ranked[1];
|
|
390
|
+
const queryCoverage = top.matchedTerms.length / queryTerms.length;
|
|
391
|
+
const knownCoverage = knownQueryTerms === 0 ? 0 : top.matchedTerms.length / knownQueryTerms;
|
|
392
|
+
const nameEvidence = top.matchedFields.includes("name");
|
|
393
|
+
const separated = !runner || top.score >= runner.score * 1.25;
|
|
394
|
+
const confidence: ToolSearchConfidence = top.exactName
|
|
395
|
+
|| (top.matchedTerms.length >= 2 && queryCoverage >= 0.25 && knownCoverage >= 0.6)
|
|
396
|
+
|| (nameEvidence && separated)
|
|
397
|
+
? "high"
|
|
398
|
+
: "low";
|
|
399
|
+
return { ranked, queryTerms, knownQueryTerms, confidence };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function boundedInteger(value: number | undefined, fallback: number, min: number, max: number): number {
|
|
403
|
+
if (value === undefined || !Number.isFinite(value)) return fallback;
|
|
404
|
+
return Math.min(max, Math.max(min, Math.floor(value)));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function compactDescription(description: string, maxChars: number): string {
|
|
408
|
+
if (maxChars === 0) return "";
|
|
409
|
+
const compact = description.replace(/\s+/g, " ").trim();
|
|
410
|
+
if (compact.length <= maxChars) return compact;
|
|
411
|
+
if (maxChars <= 3) return ".".repeat(maxChars);
|
|
412
|
+
return `${compact.slice(0, maxChars - 3).trimEnd()}...`;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function queryFingerprint(query: string): string {
|
|
416
|
+
return hashUpdate(0x811c9dc5, uniqueTerms(query).join("\0")).toString(36);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function encodeCursor(catalog: ToolDiscoveryCatalog, query: string, offset: number): string {
|
|
420
|
+
return `${CURSOR_VERSION}.${catalog.fingerprint}.${queryFingerprint(query)}.${offset.toString(36)}`;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function decodeCursor(catalog: ToolDiscoveryCatalog, query: string, cursor: string | undefined): number {
|
|
424
|
+
if (!cursor) return 0;
|
|
425
|
+
const parts = cursor.split(".");
|
|
426
|
+
if (parts.length !== 4 || parts[0] !== CURSOR_VERSION || parts[1] !== catalog.fingerprint || parts[2] !== queryFingerprint(query) || !/^[0-9a-z]+$/.test(parts[3])) {
|
|
427
|
+
throw new Error("Invalid tool discovery cursor for this catalog or query");
|
|
428
|
+
}
|
|
429
|
+
const offset = Number.parseInt(parts[3], 36);
|
|
430
|
+
if (!Number.isSafeInteger(offset) || offset < 0) throw new Error("Invalid tool discovery cursor offset");
|
|
431
|
+
return offset;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Search inactive candidates. Weak/no evidence appends every candidate in
|
|
436
|
+
* registration order so discovery cannot silently hide a tool.
|
|
437
|
+
*/
|
|
438
|
+
export function searchToolDiscoveryCatalog(
|
|
439
|
+
catalog: ToolDiscoveryCatalog,
|
|
440
|
+
query: string,
|
|
441
|
+
options: ToolSearchOptions = {},
|
|
442
|
+
): ToolSearchPage {
|
|
443
|
+
const candidates = catalog.entries.filter((entry) => !entry.alwaysActive);
|
|
444
|
+
const ranking = rankEntries(candidates, query);
|
|
445
|
+
const rankedNames = new Set(ranking.ranked.map((item) => item.entry.canonicalName));
|
|
446
|
+
const fallback: RankedEntry[] = ranking.confidence === "high" ? [] : candidates
|
|
447
|
+
.filter((entry) => !rankedNames.has(entry.canonicalName))
|
|
448
|
+
.map((entry) => ({ entry, score: 0, matchedTerms: [], matchedFields: [], exactName: false }));
|
|
449
|
+
const available = [...ranking.ranked, ...fallback];
|
|
450
|
+
const pageSize = boundedInteger(options.pageSize, DEFAULT_PAGE_SIZE, 1, 50);
|
|
451
|
+
const descriptionChars = boundedInteger(options.descriptionChars, DEFAULT_DESCRIPTION_CHARS, 0, 500);
|
|
452
|
+
const offset = decodeCursor(catalog, query, options.cursor);
|
|
453
|
+
const end = Math.min(available.length, offset + pageSize);
|
|
454
|
+
const results = available.slice(offset, end).map((item) => Object.freeze({
|
|
455
|
+
name: item.entry.name,
|
|
456
|
+
description: compactDescription(item.entry.description, descriptionChars),
|
|
457
|
+
score: Math.round(item.score * 1_000_000) / 1_000_000,
|
|
458
|
+
matchedTerms: Object.freeze(item.matchedTerms.slice()),
|
|
459
|
+
matchedFields: Object.freeze(item.matchedFields.slice()),
|
|
460
|
+
}));
|
|
461
|
+
return Object.freeze({
|
|
462
|
+
results: Object.freeze(results),
|
|
463
|
+
total: available.length,
|
|
464
|
+
...(end < available.length ? { nextCursor: encodeCursor(catalog, query, end) } : {}),
|
|
465
|
+
confidence: ranking.confidence,
|
|
466
|
+
failOpen: ranking.confidence !== "high",
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function canonicalSet(names: ReadonlySet<string> | undefined): Set<string> {
|
|
471
|
+
return new Set([...(names ?? [])].map(canonicalName).filter(Boolean));
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function allNamedTools(catalog: ToolDiscoveryCatalog): string[] {
|
|
475
|
+
return catalog.entries.map((entry) => entry.name);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function failedOpenPlan(
|
|
479
|
+
catalog: ToolDiscoveryCatalog,
|
|
480
|
+
alwaysActiveNames: readonly string[],
|
|
481
|
+
confidence: ToolSearchConfidence,
|
|
482
|
+
reason: ToolActivationReason,
|
|
483
|
+
): ToolActivationPlan {
|
|
484
|
+
return Object.freeze({
|
|
485
|
+
activeNames: Object.freeze(allNamedTools(catalog)),
|
|
486
|
+
alwaysActiveNames: Object.freeze([...alwaysActiveNames]),
|
|
487
|
+
selectedNames: Object.freeze([]),
|
|
488
|
+
confidence,
|
|
489
|
+
failOpen: true,
|
|
490
|
+
reason,
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Plan Pi active tool names. Registration order is preserved for cache
|
|
496
|
+
* stability. A non-positive topK, weak evidence, or ambiguous cutoff returns
|
|
497
|
+
* the complete named catalog rather than making tools unavailable.
|
|
498
|
+
*/
|
|
499
|
+
export function planToolActivation(
|
|
500
|
+
catalog: ToolDiscoveryCatalog,
|
|
501
|
+
query: string,
|
|
502
|
+
options: ToolActivationOptions,
|
|
503
|
+
): ToolActivationPlan {
|
|
504
|
+
const usedNames = canonicalSet(options.usedNames);
|
|
505
|
+
const alwaysActiveEntries = catalog.entries.filter((entry) => entry.alwaysActive || usedNames.has(entry.canonicalName));
|
|
506
|
+
const alwaysActiveNames = alwaysActiveEntries.map((entry) => entry.name);
|
|
507
|
+
const candidates = catalog.entries.filter((entry) => !entry.alwaysActive && !usedNames.has(entry.canonicalName));
|
|
508
|
+
if (candidates.length === 0) {
|
|
509
|
+
return Object.freeze({
|
|
510
|
+
activeNames: Object.freeze(alwaysActiveNames.slice()),
|
|
511
|
+
alwaysActiveNames: Object.freeze(alwaysActiveNames.slice()),
|
|
512
|
+
selectedNames: Object.freeze([]),
|
|
513
|
+
confidence: "none",
|
|
514
|
+
failOpen: false,
|
|
515
|
+
reason: "no-candidates",
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
if (!Number.isSafeInteger(options.topK) || options.topK <= 0) {
|
|
519
|
+
return failedOpenPlan(catalog, alwaysActiveNames, "none", "invalid-top-k");
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const ranking = rankEntries(candidates, query);
|
|
523
|
+
if (ranking.queryTerms.length === 0) return failedOpenPlan(catalog, alwaysActiveNames, "none", "no-signal");
|
|
524
|
+
if (ranking.ranked.length === 0) return failedOpenPlan(catalog, alwaysActiveNames, "none", "no-match");
|
|
525
|
+
if (ranking.confidence !== "high") return failedOpenPlan(catalog, alwaysActiveNames, ranking.confidence, "weak-signal");
|
|
526
|
+
|
|
527
|
+
const topK = Math.min(options.topK, ranking.ranked.length);
|
|
528
|
+
const selected = ranking.ranked.slice(0, topK);
|
|
529
|
+
const boundary = ranking.ranked[topK];
|
|
530
|
+
if (boundary) {
|
|
531
|
+
const last = selected[selected.length - 1];
|
|
532
|
+
const safelySeparated = last.score >= boundary.score * 1.15 || last.matchedTerms.length > boundary.matchedTerms.length;
|
|
533
|
+
if (!safelySeparated) return failedOpenPlan(catalog, alwaysActiveNames, ranking.confidence, "ambiguous-cutoff");
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const selectedCanonical = new Set(selected.map((item) => item.entry.canonicalName));
|
|
537
|
+
const activeNames = catalog.entries
|
|
538
|
+
.filter((entry) => entry.alwaysActive || usedNames.has(entry.canonicalName) || selectedCanonical.has(entry.canonicalName))
|
|
539
|
+
.map((entry) => entry.name);
|
|
540
|
+
return Object.freeze({
|
|
541
|
+
activeNames: Object.freeze(activeNames),
|
|
542
|
+
alwaysActiveNames: Object.freeze(alwaysActiveNames),
|
|
543
|
+
selectedNames: Object.freeze(selected.map((item) => item.entry.name)),
|
|
544
|
+
confidence: ranking.confidence,
|
|
545
|
+
failOpen: false,
|
|
546
|
+
reason: "confident-match",
|
|
547
|
+
});
|
|
548
|
+
}
|