src / mcp / skillStatus.ts
src / mcp / skillStatus.ts
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { getProjectRoot } from "../core-bundle.mjs";
import type { McpResultPreset } from "./core-bundle.mjs";
/**
* 'analyse-image' is this repo's own skill; 'image-display' is the cross-plugin result-visibility
* skill maintained centrally in made-for-bionic-core and synced in via scripts/sync-shared-skills.mjs
* (see skills/image-display/). Both are checked/notified the same way, so a tool-result-level
* pointer to image-display is actually verified and surfaced — not just a passive mention in
* SKILL.md's body that an agent could skip.
*/
export type SkillCheckName = "analyse-image" | "image-display";
export type SkillStatus =
| { state: "disabled" }
| { state: "current" }
| { state: "not-installed"; installScriptPath: string }
/** userModified=true means the installed copy no longer matches the last hash *we* wrote
* (.synced-hash) — a human edited it, so syncSkillToInstallTarget() must not overwrite it. */
| { state: "stale"; installScriptPath: string; userModified: boolean };
// Unsloth Studio and VS Code Copilot both read from ~/.agents/skills (see
// tool-description-skill-concept.md); only Bionic uses its own ~/.lmstudio/skills.
export function installedSkillRoot(preset: McpResultPreset, homeDir: string | undefined, skillName: SkillCheckName): string {
const home = homeDir ?? os.homedir();
return preset.name === "bionic" ? path.join(home, ".lmstudio", "skills", skillName) : path.join(home, ".agents", "skills", skillName);
}
export function sha256File(filePath: string): string {
return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
}
/**
* Compares the installed SKILL.md (client-side) against the LOCAL plugin-directory cache's
* canonical SKILL.<preset>.md (skills/<skillName>/) by content hash. That local cache is expected
* to already be fresh — callers must run `ensureLocalSkillCache()` first (see skillSync.ts); this
* function itself never touches the network. Never throws: any read error (missing canonical
* file, permission issue, ...) is treated as "current" so a broken check can never nag the agent
* or block the actual tool call.
*
* `installScriptPath` always points at this repo's own `skills/analyse-image/install/` scripts,
* regardless of `skillName` — 'image-display' has no install/ scripts of its own (see
* made-for-bionic-core/skills/image-display/), it's installed piggyback by the same scripts that
* install 'analyse-image' (see those scripts' "Also install the shared 'image-display' skill" step).
*/
export function checkSkillStatus(preset: McpResultPreset, useSkills: boolean, skillName: SkillCheckName, options?: { projectRoot?: string; homeDir?: string }): SkillStatus {
if (!useSkills) return { state: "disabled" };
const projectRoot = options?.projectRoot ?? getProjectRoot();
const skillDir = path.join(projectRoot, "skills", skillName);
const installScriptPath = path.join(projectRoot, "skills", "analyse-image", "install", `install_${preset.name}_skills.sh`);
const targetDir = installedSkillRoot(preset, options?.homeDir, skillName);
const installedSkillPath = path.join(targetDir, "SKILL.md");
try {
if (!fs.existsSync(installedSkillPath)) return { state: "not-installed", installScriptPath };
const canonicalSkillPath = path.join(skillDir, `SKILL.${preset.name}.md`);
const installedHash = sha256File(installedSkillPath);
if (sha256File(canonicalSkillPath) === installedHash) return { state: "current" };
const syncedHashPath = path.join(targetDir, ".synced-hash");
const lastSyncedHash = fs.existsSync(syncedHashPath) ? fs.readFileSync(syncedHashPath, "utf-8").trim() : null;
return { state: "stale", installScriptPath, userModified: lastSyncedHash !== installedHash };
} catch {
return { state: "current" };
}
}
/**
* Surfaces at most one notice per server process lifetime (one MCP server run ~= one client
* session) — repeating it on every tool call would be exactly the nagging behavior we want to
* avoid. "disabled" never produces a notice. "current" normally doesn't either — EXCEPT for
* 'image-display': real-world Bionic traces (2026-10-09) showed the agent never opens that skill
* on its own even when it's correctly installed — a passive "## Related skills" mention in
* SKILL.md's body is not enough to get it read. So 'image-display' gets a one-time reminder even
* when "current", pushing the agent to actually read it instead of assuming the mention sufficed.
*/
export function createSkillNoticeProvider(status: SkillStatus, skillName: SkillCheckName): () => string | undefined {
let shown = false;
return () => {
if (shown) return undefined;
if (status.state === "disabled") return undefined;
if (status.state === "current") {
if (skillName !== "image-display") return undefined;
shown = true;
return "Note: before deciding how to present this result (browser vs. chat, what the user actually sees), read skill 'image-display' once this session if you haven't already — it's easy to miss since it's only referenced passively from SKILL.md's 'Related skills' section.";
}
shown = true;
if (status.state === "not-installed") {
return `Note: the '${skillName}' skill is not installed for this client yet. Ask the user whether to install it by running \`${status.installScriptPath}\`, or whether to set USE_SKILLS=false in this server's environment to stop seeing this note.`;
}
if (status.userModified) {
return `Note: the installed '${skillName}' skill was manually edited and differs from the current version. Your edits were left untouched. Ask the user to re-run \`${status.installScriptPath}\` if they want the update instead.`;
}
return `Note: the installed '${skillName}' skill is out of date. Ask the user to re-run \`${status.installScriptPath}\` to update it.`;
};
}
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { getProjectRoot } from "../core-bundle.mjs";
import type { McpResultPreset } from "./core-bundle.mjs";
/**
* 'analyse-image' is this repo's own skill; 'image-display' is the cross-plugin result-visibility
* skill maintained centrally in made-for-bionic-core and synced in via scripts/sync-shared-skills.mjs
* (see skills/image-display/). Both are checked/notified the same way, so a tool-result-level
* pointer to image-display is actually verified and surfaced — not just a passive mention in
* SKILL.md's body that an agent could skip.
*/
export type SkillCheckName = "analyse-image" | "image-display";
export type SkillStatus =
| { state: "disabled" }
| { state: "current" }
| { state: "not-installed"; installScriptPath: string }
/** userModified=true means the installed copy no longer matches the last hash *we* wrote
* (.synced-hash) — a human edited it, so syncSkillToInstallTarget() must not overwrite it. */
| { state: "stale"; installScriptPath: string; userModified: boolean };
// Unsloth Studio and VS Code Copilot both read from ~/.agents/skills (see
// tool-description-skill-concept.md); only Bionic uses its own ~/.lmstudio/skills.
export function installedSkillRoot(preset: McpResultPreset, homeDir: string | undefined, skillName: SkillCheckName): string {
const home = homeDir ?? os.homedir();
return preset.name === "bionic" ? path.join(home, ".lmstudio", "skills", skillName) : path.join(home, ".agents", "skills", skillName);
}
export function sha256File(filePath: string): string {
return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
}
/**
* Compares the installed SKILL.md (client-side) against the LOCAL plugin-directory cache's
* canonical SKILL.<preset>.md (skills/<skillName>/) by content hash. That local cache is expected
* to already be fresh — callers must run `ensureLocalSkillCache()` first (see skillSync.ts); this
* function itself never touches the network. Never throws: any read error (missing canonical
* file, permission issue, ...) is treated as "current" so a broken check can never nag the agent
* or block the actual tool call.
*
* `installScriptPath` always points at this repo's own `skills/analyse-image/install/` scripts,
* regardless of `skillName` — 'image-display' has no install/ scripts of its own (see
* made-for-bionic-core/skills/image-display/), it's installed piggyback by the same scripts that
* install 'analyse-image' (see those scripts' "Also install the shared 'image-display' skill" step).
*/
export function checkSkillStatus(preset: McpResultPreset, useSkills: boolean, skillName: SkillCheckName, options?: { projectRoot?: string; homeDir?: string }): SkillStatus {
if (!useSkills) return { state: "disabled" };
const projectRoot = options?.projectRoot ?? getProjectRoot();
const skillDir = path.join(projectRoot, "skills", skillName);
const installScriptPath = path.join(projectRoot, "skills", "analyse-image", "install", `install_${preset.name}_skills.sh`);
const targetDir = installedSkillRoot(preset, options?.homeDir, skillName);
const installedSkillPath = path.join(targetDir, "SKILL.md");
try {
if (!fs.existsSync(installedSkillPath)) return { state: "not-installed", installScriptPath };
const canonicalSkillPath = path.join(skillDir, `SKILL.${preset.name}.md`);
const installedHash = sha256File(installedSkillPath);
if (sha256File(canonicalSkillPath) === installedHash) return { state: "current" };
const syncedHashPath = path.join(targetDir, ".synced-hash");
const lastSyncedHash = fs.existsSync(syncedHashPath) ? fs.readFileSync(syncedHashPath, "utf-8").trim() : null;
return { state: "stale", installScriptPath, userModified: lastSyncedHash !== installedHash };
} catch {
return { state: "current" };
}
}
/**
* Surfaces at most one notice per server process lifetime (one MCP server run ~= one client
* session) — repeating it on every tool call would be exactly the nagging behavior we want to
* avoid. "disabled" never produces a notice. "current" normally doesn't either — EXCEPT for
* 'image-display': real-world Bionic traces (2026-10-09) showed the agent never opens that skill
* on its own even when it's correctly installed — a passive "## Related skills" mention in
* SKILL.md's body is not enough to get it read. So 'image-display' gets a one-time reminder even
* when "current", pushing the agent to actually read it instead of assuming the mention sufficed.
*/
export function createSkillNoticeProvider(status: SkillStatus, skillName: SkillCheckName): () => string | undefined {
let shown = false;
return () => {
if (shown) return undefined;
if (status.state === "disabled") return undefined;
if (status.state === "current") {
if (skillName !== "image-display") return undefined;
shown = true;
return "Note: before deciding how to present this result (browser vs. chat, what the user actually sees), read skill 'image-display' once this session if you haven't already — it's easy to miss since it's only referenced passively from SKILL.md's 'Related skills' section.";
}
shown = true;
if (status.state === "not-installed") {
return `Note: the '${skillName}' skill is not installed for this client yet. Ask the user whether to install it by running \`${status.installScriptPath}\`, or whether to set USE_SKILLS=false in this server's environment to stop seeing this note.`;
}
if (status.userModified) {
return `Note: the installed '${skillName}' skill was manually edited and differs from the current version. Your edits were left untouched. Ask the user to re-run \`${status.installScriptPath}\` if they want the update instead.`;
}
return `Note: the installed '${skillName}' skill is out of date. Ask the user to re-run \`${status.installScriptPath}\` to update it.`;
};
}