feat: vendor grouped tool search

This commit is contained in:
云服务部-叶林立
2026-08-19 16:43:36 +08:00
parent 779a845e18
commit 3507f85363
22 changed files with 2236 additions and 6 deletions
+234
View File
@@ -0,0 +1,234 @@
import type { ToolInfo } from "@earendil-works/pi-coding-agent";
import {
CATALOG_VERSION,
applyCatalogOverrides,
createFallbackCatalog,
type CatalogConstraints,
type GroupCard,
type ToolCard,
type ToolCatalog,
} from "./catalog.ts";
interface BundleGroupDefinition {
id: string;
title: string;
summary: string;
useWhen: string[];
avoidWhen: string[];
keywords: string[];
tools: string[];
}
/** Curated groups for every non-core tool exposed by the my-pi bundle. */
export const BUNDLE_GROUP_DEFINITIONS: BundleGroupDefinition[] = [
{
id: "filesystem-navigation",
title: "Filesystem navigation",
summary: "Inspect directory layouts and run bounded multi-pattern literal searches.",
useWhen: ["You need directory entries or several literal searches after narrowing a path"],
avoidWhen: ["Code relationships or types are the real question"],
keywords: ["files", "directory", "list", "multi grep", "目录", "文件", "多模式搜索"],
tools: ["ls", "multi_grep"],
},
{
id: "code-intelligence",
title: "Code intelligence",
summary: "Explore code relationships and query language-server definitions, references, types, symbols, and diagnostics.",
useWhen: ["Understanding architecture, call paths, symbol identity, types, references, or compiler diagnostics"],
avoidWhen: ["Only an exact literal text match is required"],
keywords: ["codegraph", "lsp", "definition", "references", "diagnostics", "代码结构", "定义", "引用", "诊断"],
tools: ["codegraph_explore", "lsp_definition", "lsp_references", "lsp_hover", "lsp_symbols", "lsp_diagnostics"],
},
{
id: "web-tavily",
title: "Tavily web discovery",
summary: "Discover broad, current, or news-oriented web sources with Tavily and fetch selected pages.",
useWhen: ["Keywords are uncertain, many candidate sources help, or current/news coverage is needed"],
avoidWhen: ["Official technical sources or Chinese site/date constraints are more important"],
keywords: ["tavily", "web", "news", "discovery", "网页", "新闻", "广泛搜索"],
tools: ["tavily_web_search", "tavily_web_fetch"],
},
{
id: "web-exa",
title: "Exa precision research",
summary: "Find precise official, technical, academic, company, or API sources with Exa and read selected pages.",
useWhen: ["Source precision, official documentation, papers, releases, or advanced filters matter"],
avoidWhen: ["Broad news discovery or Chinese policy search is the primary need"],
keywords: ["exa", "official docs", "paper", "api", "research", "官方文档", "论文", "精确搜索"],
tools: ["exa_web_search", "exa_web_search_advanced", "exa_web_fetch"],
},
{
id: "web-keenable",
title: "Keenable focused search",
summary: "Search Chinese-language, site-restricted, policy, government, or date-filtered sources and fetch selected pages.",
useWhen: ["Chinese pages, policies, government notices, domain restrictions, or publication dates matter"],
avoidWhen: ["Broad international discovery or academic semantic search is a better fit"],
keywords: ["keenable", "Chinese", "policy", "site", "date", "中文", "政策", "站点", "日期"],
tools: ["keenable_search", "keenable_fetch"],
},
{
id: "context-execution",
title: "Context-isolated execution",
summary: "Run commands or analyze large files in a sandbox while returning only compact derived output.",
useWhen: ["Logs, tests, builds, generated data, commands, or workspace files may produce large output"],
avoidWhen: ["Exact source lines are needed for an anchored edit"],
keywords: ["execute", "large file", "logs", "tests", "build", "大文件", "日志", "测试", "构建"],
tools: ["ctx_execute", "ctx_execute_file", "ctx_batch_execute"],
},
{
id: "context-knowledge",
title: "Context knowledge base",
summary: "Index local or web documentation and retrieve focused passages from the persistent context-mode knowledge base.",
useWhen: ["Documentation or session knowledge should be stored and queried without rereading raw content"],
avoidWhen: ["A one-shot small source can be read directly"],
keywords: ["index", "search", "knowledge base", "fetch docs", "索引", "知识库", "文档检索"],
tools: ["ctx_index", "ctx_search", "ctx_fetch_and_index"],
},
{
id: "context-observability",
title: "Context observability",
summary: "Inspect context-mode health, savings statistics, and the hosted Insight dashboard.",
useWhen: ["Diagnosing context-mode or reviewing context savings and analytics"],
avoidWhen: ["The task is normal code or data processing"],
keywords: ["stats", "doctor", "insight", "health", "统计", "诊断", "上下文节省"],
tools: ["ctx_stats", "ctx_doctor", "ctx_insight"],
},
{
id: "context-administration",
title: "Context administration",
summary: "Upgrade context-mode or destructively purge indexed context data.",
useWhen: ["The user explicitly asks to upgrade context-mode or purge a named scope"],
avoidWhen: ["Routine searching, indexing, or performance inspection"],
keywords: ["upgrade", "purge", "delete", "升级", "清除", "删除知识库"],
tools: ["ctx_upgrade", "ctx_purge"],
},
{
id: "memory-recall",
title: "Memory and session recall",
summary: "Search durable memories, prior sessions, or recover pruned tool-call output.",
useWhen: ["Past decisions, preferences, failures, conversations, or condensed outputs may answer the question"],
avoidWhen: ["The required evidence is already in the current visible context"],
keywords: ["memory", "session", "history", "recover", "记忆", "历史会话", "恢复输出"],
tools: ["memory_search", "session_search", "context_tree_query"],
},
{
id: "memory-management",
title: "Memory management",
summary: "Add, replace, or remove durable user, project, global, or failure memories.",
useWhen: ["A stable preference, correction, environment fact, convention, or durable lesson should change"],
avoidWhen: ["The information is temporary task progress"],
keywords: ["remember", "add memory", "replace memory", "forget", "记住", "更新记忆", "删除记忆"],
tools: ["memory_add", "memory_replace", "memory_remove"],
},
{
id: "skill-management",
title: "Procedural skill management",
summary: "Create, inspect, patch, update, or remove reusable Pi-native procedural skills.",
useWhen: ["A reusable workflow or non-obvious procedure should persist across sessions"],
avoidWhen: ["Saving one-off task state or a generic summary"],
keywords: ["skill", "procedure", "workflow", "技能", "流程", "工作流"],
tools: ["skill_manage"],
},
{
id: "mcp-management",
title: "MCP management",
summary: "Inspect and manage MCP server connections exposed by Pi's shared adapter.",
useWhen: ["The user explicitly asks about MCP server status or operations"],
avoidWhen: ["A mapped first-class MCP tool already handles the task"],
keywords: ["mcp", "server", "connection", "MCP 服务", "连接"],
tools: ["mcp"],
},
];
export interface BundleCatalogResult {
catalog: ToolCatalog;
coveredNames: Set<string>;
unknownTools: ToolInfo[];
}
function compactDescription(tool: ToolInfo): string {
return tool.description.replace(/\s+/gu, " ").trim().slice(0, 240) || `Use the ${tool.name} tool.`;
}
export function createBundleCatalog(
tools: ToolInfo[],
manifestHash: string,
constraints: CatalogConstraints,
): BundleCatalogResult {
const available = new Map(tools.map((tool) => [tool.name, tool]));
const coveredNames = new Set<string>();
const groups: GroupCard[] = [];
const cards: ToolCard[] = [];
for (const definition of BUNDLE_GROUP_DEFINITIONS) {
const members = definition.tools.flatMap((name) => {
const tool = available.get(name);
return tool ? [tool] : [];
});
if (members.length === 0) continue;
for (const tool of members) {
coveredNames.add(tool.name);
cards.push({
name: tool.name,
summary: compactDescription(tool),
useWhen: [...definition.useWhen],
avoidWhen: [...definition.avoidWhen],
keywords: [...new Set([...definition.keywords, ...tool.name.split(/[_-]/u)])],
primaryGroup: definition.id,
});
}
groups.push({
id: definition.id,
title: definition.title,
summary: definition.summary,
useWhen: [...definition.useWhen],
avoidWhen: [...definition.avoidWhen],
tools: members.map((tool) => tool.name),
});
}
const unknownTools = tools.filter((tool) => !coveredNames.has(tool.name));
const fallback = createFallbackCatalog(unknownTools, manifestHash, {
maxToolsPerGroup: constraints.maxToolsPerGroup,
groupOverrides: {},
});
const reservedIds = new Set(groups.map((group) => group.id));
const renamedGroups = new Map<string, string>();
for (const group of fallback.groups) {
let id = group.id;
while (reservedIds.has(id)) id = `custom-${id}`;
reservedIds.add(id);
renamedGroups.set(group.id, id);
groups.push({ ...group, id, title: id.replace(/-/gu, " ") });
}
for (const card of fallback.tools) {
cards.push({ ...card, primaryGroup: renamedGroups.get(card.primaryGroup) ?? card.primaryGroup });
}
const overridden = applyCatalogOverrides(groups, cards, constraints.groupOverrides);
return {
catalog: {
version: CATALOG_VERSION,
manifestHash,
generatedAt: new Date().toISOString(),
generatedBy: "my-pi bundle seed",
groups: overridden.groups,
tools: overridden.tools,
},
coveredNames,
unknownTools,
};
}
export function catalogPreservesBundleAssignments(
candidate: ToolCatalog,
bundleCatalog: ToolCatalog,
coveredNames: Set<string>,
): boolean {
const expected = new Map(
bundleCatalog.tools.filter((tool) => coveredNames.has(tool.name)).map((tool) => [tool.name, tool.primaryGroup]),
);
const actual = new Map(candidate.tools.map((tool) => [tool.name, tool.primaryGroup]));
return [...expected].every(([name, group]) => actual.get(name) === group);
}
+366
View File
@@ -0,0 +1,366 @@
import { createHash } from "node:crypto";
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import type { ToolInfo } from "@earendil-works/pi-coding-agent";
export const CATALOG_VERSION = 1;
export interface ToolCard {
name: string;
summary: string;
useWhen: string[];
avoidWhen: string[];
keywords: string[];
primaryGroup: string;
}
export interface GroupCard {
id: string;
title: string;
summary: string;
useWhen: string[];
avoidWhen: string[];
tools: string[];
}
export interface ToolCatalog {
version: number;
manifestHash: string;
generatedAt: string;
generatedBy?: string;
groups: GroupCard[];
tools: ToolCard[];
}
export interface CatalogConstraints {
maxToolsPerGroup: number;
groupOverrides: Record<string, string[]>;
}
interface StoredCatalog {
version: number;
catalog: ToolCatalog;
}
function compactText(value: unknown, maxLength: number): string {
if (typeof value !== "string") return "";
return value.replace(/\s+/gu, " ").trim().slice(0, maxLength);
}
function compactStrings(value: unknown, maxItems: number, maxLength: number): string[] {
if (!Array.isArray(value)) return [];
return value
.filter((item): item is string => typeof item === "string")
.map((item) => compactText(item, maxLength))
.filter(Boolean)
.slice(0, maxItems);
}
function normalizeGroupId(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/gu, "-")
.replace(/^-+|-+$/gu, "")
.slice(0, 64);
}
function stableValue(value: unknown, seen = new WeakSet<object>()): unknown {
if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
return value;
}
if (Array.isArray(value)) return value.map((item) => stableValue(item, seen));
if (typeof value !== "object") return undefined;
if (seen.has(value)) return "[Circular]";
seen.add(value);
const output: Record<string, unknown> = {};
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
const child = stableValue((value as Record<string, unknown>)[key], seen);
if (child !== undefined) output[key] = child;
}
seen.delete(value);
return output;
}
export function buildManifestHash(tools: ToolInfo[], constraints: CatalogConstraints): string {
const manifest = tools
.map((tool) => ({
name: tool.name,
description: tool.description,
parameters: stableValue(tool.parameters),
promptGuidelines: tool.promptGuidelines,
sourceInfo: stableValue(tool.sourceInfo),
}))
.sort((left, right) => left.name.localeCompare(right.name));
const payload = stableValue({ manifest, constraints });
return createHash("sha256").update(JSON.stringify(payload)).digest("hex");
}
export function buildCatalogModelInput(tools: ToolInfo[]): string {
const input = tools
.map((tool) => ({
name: tool.name,
description: tool.description,
parameters: stableValue(tool.parameters),
promptGuidelines: tool.promptGuidelines,
sourceInfo: stableValue(tool.sourceInfo),
}))
.sort((left, right) => left.name.localeCompare(right.name));
return JSON.stringify(input);
}
function fallbackGroupKey(tool: ToolInfo): string {
const prefix = tool.name.includes("_") ? tool.name.split("_", 1)[0] : "";
if (prefix && prefix.length > 1) return normalizeGroupId(prefix);
const source = stableValue(tool.sourceInfo);
if (source && typeof source === "object") {
const record = source as Record<string, unknown>;
for (const key of ["package", "name", "path", "source"]) {
const candidate = record[key];
if (typeof candidate !== "string") continue;
const parts = candidate.split(/[\\/]/u);
const normalized = normalizeGroupId(parts.at(-1)?.replace(/\.[^.]+$/u, "") ?? candidate);
if (normalized) return normalized;
}
}
return "other-tools";
}
export function applyCatalogOverrides(
groups: GroupCard[],
toolCards: ToolCard[],
overrides: Record<string, string[]>,
): { groups: GroupCard[]; tools: ToolCard[] } {
const knownNames = new Set(toolCards.map((tool) => tool.name));
const overridden = new Map<string, string>();
for (const [rawId, names] of Object.entries(overrides)) {
const id = normalizeGroupId(rawId);
if (!id) continue;
for (const name of names) {
if (knownNames.has(name)) overridden.set(name, id);
}
}
if (overridden.size === 0) return { groups, tools: toolCards };
const byId = new Map<string, GroupCard>();
for (const group of groups) {
const remaining = group.tools.filter((name) => !overridden.has(name));
if (remaining.length > 0) byId.set(group.id, { ...group, tools: remaining });
}
for (const [name, id] of overridden) {
const existing = byId.get(id);
if (existing) existing.tools.push(name);
else {
byId.set(id, {
id,
title: id.replace(/-/gu, " "),
summary: `Configured tool group for ${id.replace(/-/gu, " ")}.`,
useWhen: [],
avoidWhen: [],
tools: [name],
});
}
}
const updatedTools = toolCards.map((tool) => ({
...tool,
primaryGroup: overridden.get(tool.name) ?? tool.primaryGroup,
}));
return { groups: [...byId.values()], tools: updatedTools };
}
export function createFallbackCatalog(
tools: ToolInfo[],
manifestHash: string,
constraints: CatalogConstraints,
): ToolCatalog {
const cards = tools.map<ToolCard>((tool) => ({
name: tool.name,
summary: compactText(tool.description, 240) || `Use the ${tool.name} tool.`,
useWhen: [],
avoidWhen: [],
keywords: tool.name.split(/[_-]/u).filter(Boolean),
primaryGroup: fallbackGroupKey(tool),
}));
const buckets = new Map<string, ToolCard[]>();
for (const card of cards) {
const bucket = buckets.get(card.primaryGroup) ?? [];
bucket.push(card);
buckets.set(card.primaryGroup, bucket);
}
const groups: GroupCard[] = [];
for (const [baseId, bucket] of [...buckets].sort(([left], [right]) => left.localeCompare(right))) {
for (let index = 0; index < bucket.length; index += constraints.maxToolsPerGroup) {
const chunk = bucket.slice(index, index + constraints.maxToolsPerGroup);
const suffix = index === 0 ? "" : `-${Math.floor(index / constraints.maxToolsPerGroup) + 1}`;
const id = `${baseId}${suffix}`;
for (const card of chunk) card.primaryGroup = id;
groups.push({
id,
title: id.replace(/-/gu, " "),
summary: compactText(chunk.map((tool) => tool.summary).join("; "), 280),
useWhen: [],
avoidWhen: [],
tools: chunk.map((tool) => tool.name),
});
}
}
const overridden = applyCatalogOverrides(groups, cards, constraints.groupOverrides);
return {
version: CATALOG_VERSION,
manifestHash,
generatedAt: new Date().toISOString(),
groups: overridden.groups,
tools: overridden.tools,
};
}
function parseJsonObject(text: string): unknown {
const unfenced = text.replace(/^\s*```(?:json)?\s*/iu, "").replace(/\s*```\s*$/u, "");
const start = unfenced.indexOf("{");
const end = unfenced.lastIndexOf("}");
if (start < 0 || end <= start) throw new Error("catalog response did not contain a JSON object");
return JSON.parse(unfenced.slice(start, end + 1));
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
export function parseGeneratedCatalog(
text: string,
tools: ToolInfo[],
manifestHash: string,
constraints: CatalogConstraints,
generatedBy: string,
): ToolCatalog {
const parsed = parseJsonObject(text);
if (!isRecord(parsed) || !Array.isArray(parsed.groups) || !Array.isArray(parsed.tools)) {
throw new Error("catalog response must contain groups and tools arrays");
}
const expectedNames = new Set(tools.map((tool) => tool.name));
const cards: ToolCard[] = [];
const cardNames = new Set<string>();
for (const value of parsed.tools) {
if (!isRecord(value) || typeof value.name !== "string" || typeof value.summary !== "string" || typeof value.primaryGroup !== "string") {
throw new Error("every generated tool card needs name, summary, and primaryGroup");
}
if (!expectedNames.has(value.name) || cardNames.has(value.name)) throw new Error(`invalid or duplicate tool card: ${value.name}`);
const primaryGroup = normalizeGroupId(value.primaryGroup);
if (!primaryGroup) throw new Error(`invalid primary group for ${value.name}`);
cardNames.add(value.name);
cards.push({
name: value.name,
summary: compactText(value.summary, 240),
useWhen: compactStrings(value.useWhen, 5, 160),
avoidWhen: compactStrings(value.avoidWhen, 5, 160),
keywords: compactStrings(value.keywords, 16, 60),
primaryGroup,
});
}
if (cardNames.size !== expectedNames.size) throw new Error("generated tool cards omitted one or more tools");
const groups: GroupCard[] = [];
const groupIds = new Set<string>();
const assignedNames = new Set<string>();
for (const value of parsed.groups) {
if (!isRecord(value) || typeof value.id !== "string" || typeof value.title !== "string" || typeof value.summary !== "string" || !Array.isArray(value.tools)) {
throw new Error("every generated group needs id, title, summary, and tools");
}
const id = normalizeGroupId(value.id);
if (!id || groupIds.has(id)) throw new Error(`invalid or duplicate group: ${value.id}`);
const names = value.tools.filter((name): name is string => typeof name === "string");
if (names.length === 0 || names.length > constraints.maxToolsPerGroup) throw new Error(`group ${id} has an invalid size`);
for (const name of names) {
if (!expectedNames.has(name) || assignedNames.has(name)) throw new Error(`invalid or duplicate group assignment: ${name}`);
assignedNames.add(name);
}
groupIds.add(id);
groups.push({
id,
title: compactText(value.title, 80),
summary: compactText(value.summary, 280),
useWhen: compactStrings(value.useWhen, 5, 160),
avoidWhen: compactStrings(value.avoidWhen, 5, 160),
tools: names,
});
}
if (assignedNames.size !== expectedNames.size) throw new Error("generated groups omitted one or more tools");
for (const card of cards) {
const group = groups.find((candidate) => candidate.id === card.primaryGroup);
if (!group?.tools.includes(card.name)) throw new Error(`tool card/group mismatch for ${card.name}`);
}
const overridden = applyCatalogOverrides(groups, cards, constraints.groupOverrides);
return {
version: CATALOG_VERSION,
manifestHash,
generatedAt: new Date().toISOString(),
generatedBy,
groups: overridden.groups,
tools: overridden.tools,
};
}
export function readCachedCatalog(
cachePath: string,
tools: ToolInfo[],
manifestHash: string,
constraints: CatalogConstraints,
): ToolCatalog | undefined {
try {
const stored: unknown = JSON.parse(readFileSync(cachePath, "utf8"));
if (!isRecord(stored) || stored.version !== CATALOG_VERSION || !isRecord(stored.catalog)) return undefined;
const catalog = stored.catalog as unknown as ToolCatalog;
if (catalog.manifestHash !== manifestHash) return undefined;
return parseGeneratedCatalog(JSON.stringify(catalog), tools, manifestHash, constraints, catalog.generatedBy ?? "cached model");
} catch {
return undefined;
}
}
export function writeCachedCatalog(cachePath: string, catalog: ToolCatalog): void {
mkdirSync(dirname(cachePath), { recursive: true, mode: 0o700 });
const temporaryPath = `${cachePath}.${process.pid}.tmp`;
const stored: StoredCatalog = { version: CATALOG_VERSION, catalog };
writeFileSync(temporaryPath, `${JSON.stringify(stored, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
chmodSync(temporaryPath, 0o600);
renameSync(temporaryPath, cachePath);
}
function queryTerms(value: string): Set<string> {
const normalized = value.toLowerCase();
const terms = new Set(normalized.match(/[\p{L}\p{N}]+/gu) ?? []);
const cjk = [...normalized].filter((character) => /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u.test(character));
for (const character of cjk) terms.add(character);
for (let index = 0; index + 1 < cjk.length; index += 1) terms.add(`${cjk[index]}${cjk[index + 1]}`);
return terms;
}
export function rankGroups(catalog: ToolCatalog, query: string): Array<{ group: GroupCard; score: number }> {
const querySet = queryTerms(query);
return catalog.groups
.map((group) => {
const cards = catalog.tools.filter((tool) => tool.primaryGroup === group.id);
const searchable = [
group.id,
group.title,
group.summary,
...group.useWhen,
...group.avoidWhen,
...cards.flatMap((tool) => [tool.name, tool.summary, ...tool.useWhen, ...tool.keywords]),
].join(" ");
const terms = queryTerms(searchable);
let score = 0;
for (const term of querySet) if (terms.has(term)) score += term.length > 1 ? 3 : 1;
if (group.id === query.trim().toLowerCase()) score += 100;
return { group, score };
})
.sort((left, right) => right.score - left.score || left.group.id.localeCompare(right.group.id));
}
+111
View File
@@ -0,0 +1,111 @@
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
export type ToolSearchDefaultResult = "updated" | "unchanged" | "skipped-invalid";
export const BUNDLE_TOOL_SEARCH_DEFAULTS = {
alwaysEnabled: ["codegraph_explore", "lsp_diagnostics"],
showToolSearchFooterStatus: false,
maxActiveGroups: 3,
maxToolsPerGroup: 8,
maxDynamicTools: 20,
groupOverrides: {},
} as const;
export interface ToolSearchConfig {
alwaysEnabled: string[];
showToolSearchFooterStatus: boolean;
maxActiveGroups: number;
maxToolsPerGroup: number;
maxDynamicTools: number;
groupOverrides: Record<string, string[]>;
}
function isObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function positiveInteger(value: unknown, fallback: number): number {
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : fallback;
}
function stringList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return [...new Set(value.filter((item): item is string => typeof item === "string" && item.length > 0))];
}
function readGroupOverrides(value: unknown): Record<string, string[]> {
if (!isObject(value)) return {};
const overrides: Record<string, string[]> = {};
for (const [groupId, names] of Object.entries(value)) {
const tools = stringList(names);
if (groupId.trim() && tools.length > 0) overrides[groupId] = tools;
}
return overrides;
}
export function bundleFallbackConfig(): ToolSearchConfig {
return {
alwaysEnabled: [...BUNDLE_TOOL_SEARCH_DEFAULTS.alwaysEnabled],
showToolSearchFooterStatus: BUNDLE_TOOL_SEARCH_DEFAULTS.showToolSearchFooterStatus,
maxActiveGroups: BUNDLE_TOOL_SEARCH_DEFAULTS.maxActiveGroups,
maxToolsPerGroup: BUNDLE_TOOL_SEARCH_DEFAULTS.maxToolsPerGroup,
maxDynamicTools: BUNDLE_TOOL_SEARCH_DEFAULTS.maxDynamicTools,
groupOverrides: {},
};
}
export function readToolSearchConfig(agentDir: string): ToolSearchConfig {
const fallback = bundleFallbackConfig();
try {
const parsed: unknown = JSON.parse(readFileSync(join(agentDir, "settings.json"), "utf8"));
if (!isObject(parsed) || !isObject(parsed.toolSearch)) return fallback;
const config = parsed.toolSearch;
return {
alwaysEnabled: Array.isArray(config.alwaysEnabled) ? stringList(config.alwaysEnabled) : fallback.alwaysEnabled,
showToolSearchFooterStatus:
typeof config.showToolSearchFooterStatus === "boolean"
? config.showToolSearchFooterStatus
: fallback.showToolSearchFooterStatus,
maxActiveGroups: positiveInteger(config.maxActiveGroups, fallback.maxActiveGroups),
maxToolsPerGroup: positiveInteger(config.maxToolsPerGroup, fallback.maxToolsPerGroup),
maxDynamicTools: positiveInteger(config.maxDynamicTools, fallback.maxDynamicTools),
groupOverrides: readGroupOverrides(config.groupOverrides),
};
} catch {
return fallback;
}
}
/** Add bundle defaults without replacing explicit user choices. */
export function ensureToolSearchDefaults(agentDir: string): ToolSearchDefaultResult {
const targetPath = join(agentDir, "settings.json");
let settings: Record<string, unknown> = {};
try {
const parsed: unknown = JSON.parse(readFileSync(targetPath, "utf8"));
if (!isObject(parsed)) return "skipped-invalid";
settings = parsed;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") return "skipped-invalid";
}
const existing = settings.toolSearch;
if (existing !== undefined && !isObject(existing)) return "skipped-invalid";
const toolSearch = { ...(existing ?? {}) };
let changed = false;
for (const [key, value] of Object.entries(BUNDLE_TOOL_SEARCH_DEFAULTS)) {
if (Object.hasOwn(toolSearch, key)) continue;
toolSearch[key] = Array.isArray(value) ? [...value] : isObject(value) ? { ...value } : value;
changed = true;
}
if (!changed) return "unchanged";
settings.toolSearch = toolSearch;
mkdirSync(dirname(targetPath), { recursive: true });
const temporaryPath = `${targetPath}.my-pi.tmp`;
writeFileSync(temporaryPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
renameSync(temporaryPath, targetPath);
return "updated";
}
+423
View File
@@ -0,0 +1,423 @@
/**
* pi-tool-search — model-built tool groups with bounded dynamic schema loading.
*
* Imported from https://github.com/tuansondinh/pi-tool-search and maintained
* locally from snapshot ddfb23646fd3957b791214de278e23aa393c9b13 (v0.3.6).
*/
import { randomUUID } from "node:crypto";
import { unlinkSync } from "node:fs";
import { join } from "node:path";
import { getAgentDir, type ExtensionAPI, type ExtensionContext, type ToolInfo } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import {
buildCatalogModelInput,
buildManifestHash,
createFallbackCatalog,
parseGeneratedCatalog,
rankGroups,
readCachedCatalog,
writeCachedCatalog,
type CatalogConstraints,
type GroupCard,
type ToolCatalog,
} from "./catalog.ts";
import {
catalogPreservesBundleAssignments,
createBundleCatalog,
type BundleCatalogResult,
} from "./bundle-groups.ts";
import { ensureToolSearchDefaults, readToolSearchConfig } from "./config.ts";
const TOOL_SEARCH_NAME = "tool_search";
const CORE_TOOLS = ["read", "write", "edit", "bash", "grep", "find"];
type CatalogSource = "bundle" | "cache" | "fallback" | "hybrid" | "model";
type ModelUsage = Awaited<ReturnType<ExtensionContext["modelRegistry"]["complete"]>>["usage"];
function unique(values: Iterable<string>): string[] {
return [...new Set(values)];
}
function catalogPrompt(toolsJson: string, maxToolsPerGroup: number, fixedGroupsJson: string): string {
return [
"Build a compact retrieval catalog for the provided Pi tools.",
"The catalog is metadata only: never rename tools or invent parameters.",
"Group tools that are commonly needed together in one workflow, but keep providers or security-sensitive administration separate when that improves routing.",
`Every tool must appear exactly once and every group must contain 1-${maxToolsPerGroup} tools.`,
"Tools listed in <fixed-groups> must keep exactly those primaryGroup ids; these are curated bundle assignments.",
"Summaries must preserve capability, important boundaries, and when not to use a tool.",
"Keywords should include common English terms and useful Chinese equivalents when applicable.",
"Return JSON only with this exact shape:",
'{"groups":[{"id":"kebab-case","title":"...","summary":"...","useWhen":["..."],"avoidWhen":["..."],"tools":["exact_tool_name"]}],"tools":[{"name":"exact_tool_name","summary":"...","useWhen":["..."],"avoidWhen":["..."],"keywords":["..."],"primaryGroup":"kebab-case"}]}',
"Do not include markdown fences or explanatory text.",
"",
"<fixed-groups>",
fixedGroupsJson,
"</fixed-groups>",
"",
"<tools>",
toolsJson,
"</tools>",
].join("\n");
}
function responseText(content: Array<{ type: string; text?: string }>): string {
return content
.filter((item): item is { type: string; text: string } => item.type === "text" && typeof item.text === "string")
.map((item) => item.text)
.join("\n");
}
function groupDescription(catalog: ToolCatalog, source: CatalogSource): string {
const sourceLabel =
source === "bundle"
? "precomputed my-pi bundle catalog; no model generation required"
: source === "hybrid"
? "my-pi bundle catalog plus deterministic groups for unrecognized tools; the first search may enrich the unknown tools"
: source === "fallback"
? "deterministic fallback"
: `${source} catalog`;
const groups = catalog.groups
.map((group) => {
const when = group.useWhen.slice(0, 2).join("; ");
const detail = [group.summary, when].filter(Boolean).join(" Use when: ").slice(0, 360);
return ` ${group.id}: ${detail}`;
})
.join("\n");
return [
"Activate a complete tool group for the current task. Prefer an exact group id from this catalog; use query only when no id clearly matches.",
`Catalog source: ${sourceLabel}.`,
"Available groups:",
groups || " (no hidden tool groups)",
].join("\n");
}
export default function toolSearchExtension(pi: ExtensionAPI): void {
const agentDir = getAgentDir();
const cachePath = join(agentDir, "tool-search", "catalog-v1.json");
const defaultResult = ensureToolSearchDefaults(agentDir);
if (defaultResult === "skipped-invalid") {
console.warn("my-pi: skipped pi-tool-search defaults because settings.json or toolSearch is invalid");
}
let config = readToolSearchConfig(agentDir);
let tools: ToolInfo[] = [];
let catalog: ToolCatalog = createFallbackCatalog([], "", {
maxToolsPerGroup: Math.min(config.maxToolsPerGroup, config.maxDynamicTools),
groupOverrides: {},
});
let catalogSource: CatalogSource = "fallback";
let manifestHash = "";
let policySignature = "";
let attemptedGenerationHash: string | undefined;
let clock = 0;
const activeGroups = new Map<string, number>();
const pinnedTools = new Set<string>();
let bundleState: BundleCatalogResult = { catalog, coveredNames: new Set(), unknownTools: [] };
function constraints(): CatalogConstraints {
return {
maxToolsPerGroup: Math.min(config.maxToolsPerGroup, config.maxDynamicTools),
groupOverrides: config.groupOverrides,
};
}
function installBaseCatalog(useCache: boolean): void {
bundleState = createBundleCatalog(tools, manifestHash, constraints());
if (bundleState.unknownTools.length === 0) {
catalog = bundleState.catalog;
catalogSource = "bundle";
return;
}
const cached = useCache ? readCachedCatalog(cachePath, tools, manifestHash, constraints()) : undefined;
if (cached && catalogPreservesBundleAssignments(cached, bundleState.catalog, bundleState.coveredNames)) {
catalog = cached;
catalogSource = "cache";
return;
}
catalog = bundleState.catalog;
catalogSource = "hybrid";
}
function fixedBundleGroupsJson(): string {
return JSON.stringify(
bundleState.catalog.groups
.map((group) => ({
id: group.id,
tools: group.tools.filter((name) => bundleState.coveredNames.has(name)),
}))
.filter((group) => group.tools.length > 0),
);
}
function groupById(id: string): GroupCard | undefined {
return catalog.groups.find((group) => group.id === id);
}
function dynamicToolCount(): number {
return unique([...activeGroups.keys()].flatMap((id) => groupById(id)?.tools ?? [])).length;
}
function leastRecentlyUsedGroup(): string | undefined {
return [...activeGroups].sort((left, right) => left[1] - right[1] || left[0].localeCompare(right[0]))[0]?.[0];
}
function applyActiveTools(): void {
const groupTools = [...activeGroups.keys()].flatMap((id) => groupById(id)?.tools ?? []);
pi.setActiveTools(unique([TOOL_SEARCH_NAME, ...pinnedTools, ...groupTools]));
}
function updateStatus(ctx: Pick<ExtensionContext, "ui">): void {
const activeToolCount = unique([TOOL_SEARCH_NAME, ...pinnedTools, ...[...activeGroups.keys()].flatMap((id) => groupById(id)?.tools ?? [])]).length;
ctx.ui.setStatus(
"tool-search",
config.showToolSearchFooterStatus
? `${activeToolCount} / ${tools.length + 1} tools · ${activeGroups.size} / ${config.maxActiveGroups} groups`
: undefined,
);
}
function registerToolSearch(): void {
pi.registerTool({
name: TOOL_SEARCH_NAME,
label: "Tool Search",
description: groupDescription(catalog, catalogSource),
promptSnippet: `Activate relevant tool groups on demand; at most ${config.maxActiveGroups} dynamic groups remain active`,
parameters: Type.Object({
group: Type.Optional(Type.String({ description: "Exact group id from the tool_search catalog" })),
query: Type.Optional(Type.String({ description: "Natural-language task used to rank groups when an exact id is unclear" })),
}),
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
const requestedId = params.group?.trim().toLowerCase();
const preGenerationGroup = requestedId ? groupById(requestedId) : undefined;
const generation = await ensureModelCatalog(ctx, signal);
const lines: string[] = [];
if (generation.notice) lines.push(generation.notice);
let selected = requestedId ? groupById(requestedId) : undefined;
if (!selected && preGenerationGroup) {
const previousNames = new Set(preGenerationGroup.tools);
selected = [...catalog.groups]
.map((group) => ({ group, overlap: group.tools.filter((name) => previousNames.has(name)).length }))
.sort((left, right) => right.overlap - left.overlap || left.group.id.localeCompare(right.group.id))
.find((candidate) => candidate.overlap > 0)?.group;
if (selected) lines.push(`Mapped initial fallback group ${preGenerationGroup.id} to generated group ${selected.id}.`);
}
const ranked = params.query ? rankGroups(catalog, params.query).slice(0, 3) : [];
if (!selected && params.query && (ranked[0]?.score ?? 0) > 0) selected = ranked[0]?.group;
if (!selected) {
const candidates = ranked.length > 0 ? ranked : catalog.groups.slice(0, 5).map((group) => ({ group, score: 0 }));
lines.push(
params.group ? `Unknown group: ${params.group}` : "No group was activated. Provide an exact group id from the catalog.",
`Candidates: ${candidates.map(({ group }) => group.id).join(", ") || "none"}`,
);
return {
content: [{ type: "text", text: lines.join("\n") }],
details: { loadedGroup: undefined, evictedGroups: [], activeGroups: [...activeGroups.keys()], candidates: candidates.map(({ group, score }) => ({ id: group.id, score })), catalogSource },
...(generation.usage ? { usage: generation.usage } : {}),
};
}
const groupTools = unique(selected.tools);
if (groupTools.length > config.maxDynamicTools) {
lines.push(`Group ${selected.id} has ${groupTools.length} tools, exceeding maxDynamicTools=${config.maxDynamicTools}.`);
return {
content: [{ type: "text", text: lines.join("\n") }],
details: { loadedGroup: undefined, evictedGroups: [], activeGroups: [...activeGroups.keys()], candidates: [], catalogSource },
...(generation.usage ? { usage: generation.usage } : {}),
};
}
const evictedGroups: string[] = [];
if (!activeGroups.has(selected.id)) {
while (
activeGroups.size >= config.maxActiveGroups ||
(activeGroups.size > 0 && dynamicToolCount() + groupTools.length > config.maxDynamicTools)
) {
const evicted = leastRecentlyUsedGroup();
if (!evicted) break;
activeGroups.delete(evicted);
evictedGroups.push(evicted);
}
}
activeGroups.set(selected.id, ++clock);
applyActiveTools();
registerToolSearch();
updateStatus(ctx);
lines.push(`Loaded group: ${selected.id} (${groupTools.join(", ")})`);
if (evictedGroups.length > 0) lines.push(`Evicted least-recently-used: ${evictedGroups.join(", ")}`);
lines.push(`Active groups: ${[...activeGroups.keys()].join(", ")}`);
return {
content: [{ type: "text", text: lines.join("\n") }],
details: {
loadedGroup: selected.id,
evictedGroups,
activeGroups: [...activeGroups.keys()],
candidates: ranked.map(({ group, score }) => ({ id: group.id, score })),
catalogSource,
},
...(generation.usage ? { usage: generation.usage } : {}),
};
},
});
}
async function ensureModelCatalog(
ctx: ExtensionContext,
signal: AbortSignal | undefined,
): Promise<{ notice?: string; usage?: ModelUsage }> {
if (catalogSource !== "hybrid" || attemptedGenerationHash === manifestHash || tools.length === 0) return {};
attemptedGenerationHash = manifestHash;
if (!ctx.model || !ctx.modelRegistry.hasConfiguredAuth(ctx.model)) {
return { notice: "Using the precomputed bundle catalog plus deterministic groups for unrecognized tools because the current model is unavailable or unauthenticated." };
}
let generationUsage: ModelUsage | undefined;
try {
const response = await ctx.modelRegistry.complete(
ctx.model,
{
messages: [
{
role: "user",
content: [
{
type: "text",
text: catalogPrompt(
buildCatalogModelInput(tools),
constraints().maxToolsPerGroup,
fixedBundleGroupsJson(),
),
},
],
timestamp: Date.now(),
},
],
},
{
signal,
reasoningEffort: "low",
cacheRetention: "none",
sessionId: randomUUID(),
maxTokens: 12_000,
},
);
generationUsage = response.usage;
catalog = parseGeneratedCatalog(
responseText(response.content),
tools,
manifestHash,
constraints(),
`${ctx.model.provider}/${ctx.model.id}`,
);
if (!catalogPreservesBundleAssignments(catalog, bundleState.catalog, bundleState.coveredNames)) {
throw new Error("generated catalog changed one or more curated my-pi bundle assignments");
}
catalogSource = "model";
try {
writeCachedCatalog(cachePath, catalog);
} catch (error) {
console.warn(`my-pi: could not cache pi-tool-search catalog: ${error instanceof Error ? error.message : String(error)}`);
}
registerToolSearch();
return {
notice: `Generated and cached ${catalog.groups.length} groups while preserving the precomputed my-pi assignments.`,
usage: generationUsage,
};
} catch (error) {
return {
notice: `Model enrichment failed; using the precomputed bundle catalog plus deterministic unknown-tool groups (${error instanceof Error ? error.message : String(error)}).`,
...(generationUsage ? { usage: generationUsage } : {}),
};
}
}
function refreshState(ctx: Pick<ExtensionContext, "ui">, forceReset: boolean): void {
const nextConfig = readToolSearchConfig(agentDir);
const allTools = pi.getAllTools().filter((tool) => tool.name !== TOOL_SEARCH_NAME);
const availableNames = new Set(allTools.map((tool) => tool.name));
const nextPinned = new Set([...CORE_TOOLS, ...nextConfig.alwaysEnabled].filter((name) => availableNames.has(name)));
const hiddenTools = allTools.filter((tool) => !nextPinned.has(tool.name));
const nextConstraints = {
maxToolsPerGroup: Math.min(nextConfig.maxToolsPerGroup, nextConfig.maxDynamicTools),
groupOverrides: nextConfig.groupOverrides,
};
const nextHash = buildManifestHash(hiddenTools, nextConstraints);
const nextPolicySignature = JSON.stringify({
alwaysEnabled: [...nextPinned].sort(),
maxActiveGroups: nextConfig.maxActiveGroups,
maxDynamicTools: nextConfig.maxDynamicTools,
showToolSearchFooterStatus: nextConfig.showToolSearchFooterStatus,
});
const catalogChanged = forceReset || nextHash !== manifestHash;
const policyChanged = nextPolicySignature !== policySignature;
config = nextConfig;
tools = hiddenTools;
pinnedTools.clear();
for (const name of nextPinned) pinnedTools.add(name);
manifestHash = nextHash;
policySignature = nextPolicySignature;
if (catalogChanged) {
activeGroups.clear();
clock = 0;
attemptedGenerationHash = undefined;
installBaseCatalog(true);
}
let capacityChanged = false;
while (activeGroups.size > config.maxActiveGroups || dynamicToolCount() > config.maxDynamicTools) {
const evicted = leastRecentlyUsedGroup();
if (!evicted) break;
activeGroups.delete(evicted);
capacityChanged = true;
}
if (catalogChanged || policyChanged || capacityChanged) {
registerToolSearch();
applyActiveTools();
}
updateStatus(ctx);
}
pi.registerCommand("tool-search-rebuild", {
description: "Invalidate the generated tool-group catalog; rebuild lazily on the next tool_search call",
handler: async (_args, ctx) => {
try {
unlinkSync(cachePath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
activeGroups.clear();
attemptedGenerationHash = undefined;
installBaseCatalog(false);
registerToolSearch();
applyActiveTools();
updateStatus(ctx);
if (ctx.hasUI) {
const message =
bundleState.unknownTools.length === 0
? "Restored the precomputed my-pi tool-group catalog; no model rebuild is needed"
: "Restored my-pi groups; unrecognized tools may be enriched on the next tool_search call";
ctx.ui.notify(message, "info");
}
},
});
pi.on("session_start", (_event, ctx) => {
refreshState(ctx, true);
});
pi.on("turn_start", (_event, ctx) => {
refreshState(ctx, false);
});
pi.on("tool_execution_start", (event) => {
const group = catalog.tools.find((tool) => tool.name === event.toolName)?.primaryGroup;
if (group && activeGroups.has(group)) activeGroups.set(group, ++clock);
});
}