Files
pikasTech-unidesk/scripts/src/health.ts
T
2026-06-26 07:52:30 +00:00

244 lines
8.1 KiB
TypeScript

import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { isAbsolute, join, relative } from "node:path";
import { repoRoot, rootPath } from "./config";
import type { RenderedCliResult } from "./output";
const DEFAULT_SKILLS_DIR = ".agents/skills";
const DEFAULT_SKILL_MD_LIMIT_BYTES = 10 * 1024;
interface HealthOptions {
skillsDir: string;
thresholdBytes: number;
json: boolean;
full: boolean;
}
interface SkillHealthItem {
skillId: string;
skillPath: string;
skillMdPath: string;
referencesDir: string;
bytes: number;
lines: number;
limitBytes: number;
overBytes: number;
status: "OK" | "ALERT";
action: string;
}
interface SkillHealthResult {
ok: boolean;
command: "health";
check: "skill-md-size";
thresholdBytes: number;
skillsDir: string;
summary: {
totalSkills: number;
okSkills: number;
alertSkills: number;
maxBytes: number;
maxSkillId: string | null;
};
alerts: SkillHealthItem[];
skills: SkillHealthItem[];
policy: {
limitBytes: number;
splitTarget: ".agents/skills/<skill_id>/references/*.md";
skillMdRole: string;
};
next: string[];
}
export function healthHelp(): Record<string, unknown> {
return {
command: "health",
output: "text by default; use --json, --full, or --raw for structured output",
usage: [
"bun scripts/cli.ts health",
"bun scripts/cli.ts health --json",
"bun scripts/cli.ts health --skills-dir .agents/skills --threshold-bytes 10240",
],
checks: [
{
name: "skill-md-size",
defaultThresholdBytes: DEFAULT_SKILL_MD_LIMIT_BYTES,
scope: ".agents/skills/*/SKILL.md",
failure: "SKILL.md over threshold",
action: "move lower-frequency detail into .agents/skills/<skill_id>/references/*.md and leave SKILL.md as high-frequency routing/workflow guidance plus reference links",
},
],
};
}
export function runHealthCommand(args: string[]): SkillHealthResult | RenderedCliResult {
const options = parseHealthOptions(args);
const result = scanSkillHealth(options);
if (options.json || options.full) return result;
return {
ok: result.ok,
command: "health",
contentType: "text/plain",
renderedText: renderSkillHealth(result),
};
}
function parseHealthOptions(args: string[]): HealthOptions {
const options: HealthOptions = {
skillsDir: DEFAULT_SKILLS_DIR,
thresholdBytes: DEFAULT_SKILL_MD_LIMIT_BYTES,
json: false,
full: false,
};
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--json") {
options.json = true;
} else if (arg === "--full" || arg === "--raw") {
options.full = true;
options.json = true;
} else if (arg === "--skills-dir") {
options.skillsDir = stringArg(arg, args[index + 1]);
index += 1;
} else if (arg === "--threshold-bytes") {
options.thresholdBytes = positiveIntArg(arg, args[index + 1], 1024 * 1024);
index += 1;
} else {
throw new Error(`unknown health option: ${arg}`);
}
}
return options;
}
function stringArg(name: string, raw: string | undefined): string {
if (raw === undefined || raw.trim().length === 0) throw new Error(`${name} requires a non-empty value`);
return raw;
}
function positiveIntArg(name: string, raw: string | undefined, max: number): number {
const value = Number(raw);
if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`);
return Math.min(value, max);
}
function resolvePath(path: string): string {
return isAbsolute(path) ? path : rootPath(path);
}
function displayPath(path: string): string {
const rel = relative(repoRoot, path);
return rel.length > 0 && !rel.startsWith("..") ? rel : path;
}
function scanSkillHealth(options: HealthOptions): SkillHealthResult {
const skillsRoot = resolvePath(options.skillsDir);
if (!existsSync(skillsRoot)) throw new Error(`health skills directory not found: ${displayPath(skillsRoot)}`);
const skillDirs = readdirSync(skillsRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => join(skillsRoot, entry.name))
.filter((dir) => existsSync(join(dir, "SKILL.md")))
.sort();
const skills = skillDirs.map((skillPath) => skillHealthItem(skillPath, options.thresholdBytes));
const alerts = skills.filter((item) => item.status === "ALERT");
const max = skills.reduce<SkillHealthItem | null>((current, item) => current === null || item.bytes > current.bytes ? item : current, null);
const ok = skills.length > 0 && alerts.length === 0;
return {
ok,
command: "health",
check: "skill-md-size",
thresholdBytes: options.thresholdBytes,
skillsDir: displayPath(skillsRoot),
summary: {
totalSkills: skills.length,
okSkills: skills.length - alerts.length,
alertSkills: alerts.length,
maxBytes: max?.bytes ?? 0,
maxSkillId: max?.skillId ?? null,
},
alerts,
skills,
policy: {
limitBytes: options.thresholdBytes,
splitTarget: ".agents/skills/<skill_id>/references/*.md",
skillMdRole: "Keep SKILL.md under the limit with only trigger-critical, highest-frequency workflow guidance; move lower-frequency detail into directly linked references.",
},
next: skills.length === 0
? [`No SKILL.md files were found under ${displayPath(skillsRoot)}; check --skills-dir.`]
: alerts.length === 0
? ["No skill split is required."]
: [
"Move lower-frequency detail into .agents/skills/<skill_id>/references/*.md.",
"Keep SKILL.md as a compact high-frequency workflow and navigation index.",
"Rerun: bun scripts/cli.ts health",
],
};
}
function skillHealthItem(skillPath: string, limitBytes: number): SkillHealthItem {
const skillMdPath = join(skillPath, "SKILL.md");
const text = readFileSync(skillMdPath, "utf8");
const bytes = statSync(skillMdPath).size;
const skillId = skillPath.split(/[\\/]/u).pop() ?? displayPath(skillPath);
const referencesDir = join(skillPath, "references");
const overBytes = Math.max(0, bytes - limitBytes);
return {
skillId,
skillPath: displayPath(skillPath),
skillMdPath: displayPath(skillMdPath),
referencesDir: displayPath(referencesDir),
bytes,
lines: text.split(/\r?\n/u).length,
limitBytes,
overBytes,
status: overBytes > 0 ? "ALERT" : "OK",
action: overBytes > 0 ? `split lower-frequency detail to ${displayPath(referencesDir)}` : "-",
};
}
function renderSkillHealth(result: SkillHealthResult): string {
const lines = [
`skill health (${result.ok ? "ok" : "alert"})`,
"",
renderTable(["SUMMARY", "VALUE"], [
["skills", String(result.summary.totalSkills)],
["limit", `${result.thresholdBytes} bytes`],
["alerts", String(result.summary.alertSkills)],
["largest", result.summary.maxSkillId === null ? "-" : `${result.summary.maxSkillId} ${result.summary.maxBytes} bytes`],
]),
"",
];
if (result.alerts.length > 0) {
lines.push(
"ALERTS",
renderTable(["SKILL", "BYTES", "OVER", "LINES", "ACTION"], result.alerts.map((item) => [
item.skillId,
String(item.bytes),
String(item.overBytes),
String(item.lines),
item.action,
])),
"",
);
} else {
lines.push("ALERTS", " none", "");
}
lines.push(
"Policy:",
` SKILL.md limit: ${result.thresholdBytes} bytes`,
" Split target: .agents/skills/<skill_id>/references/*.md",
" Keep SKILL.md to highest-frequency workflow/routing content and direct reference links.",
"",
"Next:",
...result.next.map((item) => ` ${item}`),
" bun scripts/cli.ts health --full",
);
return lines.join("\n");
}
function renderTable(headers: string[], rows: string[][]): string {
const normalized = rows.length === 0 ? [headers.map(() => "-")] : rows;
const widths = headers.map((header, index) => Math.max(header.length, ...normalized.map((row) => (row[index] ?? "").length)));
const renderRow = (row: string[]) => row.map((cell, index) => cell.padEnd(widths[index] ?? cell.length)).join(" ").trimEnd();
return [renderRow(headers), ...normalized.map(renderRow)].join("\n");
}