Files

146 lines
5.0 KiB
TypeScript

import {
chmodSync,
closeSync,
fsyncSync,
mkdirSync,
openSync,
readFileSync,
renameSync,
rmSync,
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",
"ctx_execute",
"ctx_execute_file",
"ctx_batch_execute",
],
showToolSearchFooterStatus: false,
maxActiveGroups: 5,
maxToolsPerGroup: 8,
maxDynamicTools: 28,
groupOverrides: {},
} as const;
export const BUNDLE_TOOL_SEARCH_DEFAULTS_VERSION = 2;
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 (existing === undefined) {
toolSearch.bundleDefaultsVersion = BUNDLE_TOOL_SEARCH_DEFAULTS_VERSION;
changed = true;
}
if (!changed) return "unchanged";
settings.toolSearch = toolSearch;
mkdirSync(dirname(targetPath), { recursive: true });
const temporaryPath = `${targetPath}.my-pi-${process.pid}-${Math.random().toString(16).slice(2)}.tmp`;
let descriptor: number | undefined;
try {
descriptor = openSync(temporaryPath, "wx", 0o600);
writeFileSync(descriptor, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
fsyncSync(descriptor);
closeSync(descriptor);
descriptor = undefined;
renameSync(temporaryPath, targetPath);
chmodSync(targetPath, 0o600);
} catch (error) {
if (descriptor !== undefined) closeSync(descriptor);
rmSync(temporaryPath, { force: true });
throw error;
}
return "updated";
}