src / promptPreprocessor.ts
src / promptPreprocessor.ts
import type { ChatMessage, PromptPreprocessorController } from "@lmstudio/sdk";
import { parseActivation } from "./activation";
import { splitFrontmatter, type Catalog } from "./catalog";
import { configSchematics, expandHome, globalConfigSchematics } from "./config";
import {
hasSkillMarker,
renderActiveReference,
renderError,
renderHint,
renderMessage,
renderNote,
renderReadError,
renderSkillBlock,
} from "./render";
import { jevJudge, resolveApiKey } from "./judge";
import { pickSkills, type SkillPick } from "./router";
import { HISTORY_TEXT_CAP, type Judge, type Settings, type Skill, type SkillFs, type Turn } from "./types";
export interface PreprocessDeps {
catalog: Catalog;
fs: SkillFs;
/** Injected for tests. Production builds a jev judge that reads the plugin's jevApiKey setting. */
judge?: Judge;
}
function readSettings(ctl: PromptPreprocessorController): Settings {
const c = ctl.getPluginConfig(configSchematics);
return {
threshold: c.get("threshold"),
maxSkills: c.get("maxSkills"),
historyTurns: c.get("historyTurns"),
maxInjectedChars: c.get("maxInjectedChars"),
showHintWhenNoMatch: c.get("showHintWhenNoMatch"),
};
}
async function readBody(fs: SkillFs, skill: Skill): Promise<string> {
const text = (await fs.readFile(skill.skillMdPath)).toString("utf8");
return splitFrontmatter(text).body;
}
/** The user hit stop. Only the controller's signal means "cancelled"; a jev deadline surfaces as a normal error. */
function isAbort(signal: AbortSignal): boolean {
return signal.aborted;
}
export function createPreprocess(deps: PreprocessDeps) {
// Production judge re-reads the key from the latest ctl on every call; tests inject a fixed Judge.
let liveCtl: PromptPreprocessorController | undefined;
const productionJudge =
deps.judge ??
jevJudge(() => resolveApiKey(liveCtl!.getPluginConfig(configSchematics).get("jevApiKey")));
return async function preprocess(ctl: PromptPreprocessorController, userMessage: ChatMessage): Promise<string | ChatMessage> {
liveCtl = ctl;
try {
return await run(ctl, userMessage, deps.judge ?? productionJudge);
} catch (e) {
const reason = e instanceof Error ? e.message : String(e);
ctl.debug("skills: preprocessor failed, passing message through", reason, e);
return userMessage;
}
};
async function run(ctl: PromptPreprocessorController, userMessage: ChatMessage, judge: Judge): Promise<string | ChatMessage> {
const text = userMessage.getText();
const root = expandHome(ctl.getGlobalPluginConfig(globalConfigSchematics).get("skillsRoot"));
const index = await deps.catalog.load(root);
if (index.skills.length === 0) return userMessage;
const settings = readSettings(ctl);
const described = index.skills.filter(s => s.description !== "");
// 1. Explicit activation.
const act = parseActivation(text);
if (act) {
const skill = index.byName.get(act.name);
if (!skill) return `${text}\n\n${renderError(act.name, [...index.byName.values()])}`;
let body: string;
try {
body = await readBody(deps.fs, skill);
} catch (e) {
const reason = e instanceof Error ? e.message : String(e);
ctl.debug("skills: could not read SKILL.md for explicit activation", reason, e);
return `${text}\n\n${renderReadError(skill.name, reason)}`;
}
const block = renderSkillBlock(skill, body, { source: "explicit", maxInjectedChars: settings.maxInjectedChars });
cite(ctl, skill);
return renderMessage([block, renderNote()], act.rest);
}
// 2. Auto-routing.
if (settings.maxSkills === 0 || index.routable.length === 0) {
return settings.showHintWhenNoMatch ? renderMessage([renderHint(described)], text) : userMessage;
}
const history = (await ctl.pullHistory()).getMessagesArray();
const status = ctl.createStatus({ status: "loading", text: "Routing skills…" });
let picks: SkillPick[];
try {
const recentTurns = recent(history, settings.historyTurns);
const probabilities = await judge({ message: text, recentTurns }, index.routable, ctl.abortSignal);
picks = pickSkills(probabilities, settings);
} catch (e) {
if (isAbort(ctl.abortSignal)) {
status.setState({ status: "canceled", text: "Skill routing canceled" });
return userMessage;
}
const reason = e instanceof Error ? e.message : String(e);
ctl.debug("skills: jev routing failed", reason, e);
status.setState({ status: "done", text: `Skills: jev unavailable (${reason}); listed available skills instead` });
return renderMessage([renderHint(described)], text);
}
if (picks.length === 0) {
status.setState({ status: "done", text: "No skill applied" });
return settings.showHintWhenNoMatch ? renderMessage([renderHint(described)], text) : userMessage;
}
// 3. Render picks, deduplicating against earlier turns.
const blocks: string[] = [];
try {
const priorUserText = history.filter(m => m.getRole() === "user").map(m => m.getText());
for (const pick of picks) {
const skill = index.byName.get(pick.name);
if (!skill) continue;
if (priorUserText.some(t => hasSkillMarker(t, skill.name))) {
blocks.push(renderActiveReference(skill));
} else {
blocks.push(renderSkillBlock(skill, await readBody(deps.fs, skill), { source: "jev", p: pick.p, maxInjectedChars: settings.maxInjectedChars }));
cite(ctl, skill);
}
}
blocks.push(renderNote());
} catch (e) {
const reason = e instanceof Error ? e.message : String(e);
status.setState({ status: "error", text: `Skills: could not read a skill file (${reason})` });
throw e;
}
status.setState({ status: "done", text: `Applied skills: ${picks.map(p => p.name).join(", ")}` });
return renderMessage(blocks, text);
}
}
function recent(history: ChatMessage[], turns: number): Turn[] {
if (turns <= 0) return [];
return history.slice(-turns).map(m => ({ role: m.getRole(), text: m.getText().slice(0, HISTORY_TEXT_CAP) }));
}
function cite(ctl: PromptPreprocessorController, skill: Skill) {
ctl.createCitationBlock(skill.description || skill.name, { fileName: `${skill.name}/SKILL.md`, fileIdentifier: skill.skillMdPath });
}
import type { ChatMessage, PromptPreprocessorController } from "@lmstudio/sdk";
import { parseActivation } from "./activation";
import { splitFrontmatter, type Catalog } from "./catalog";
import { configSchematics, expandHome, globalConfigSchematics } from "./config";
import {
hasSkillMarker,
renderActiveReference,
renderError,
renderHint,
renderMessage,
renderNote,
renderReadError,
renderSkillBlock,
} from "./render";
import { jevJudge, resolveApiKey } from "./judge";
import { pickSkills, type SkillPick } from "./router";
import { HISTORY_TEXT_CAP, type Judge, type Settings, type Skill, type SkillFs, type Turn } from "./types";
export interface PreprocessDeps {
catalog: Catalog;
fs: SkillFs;
/** Injected for tests. Production builds a jev judge that reads the plugin's jevApiKey setting. */
judge?: Judge;
}
function readSettings(ctl: PromptPreprocessorController): Settings {
const c = ctl.getPluginConfig(configSchematics);
return {
threshold: c.get("threshold"),
maxSkills: c.get("maxSkills"),
historyTurns: c.get("historyTurns"),
maxInjectedChars: c.get("maxInjectedChars"),
showHintWhenNoMatch: c.get("showHintWhenNoMatch"),
};
}
async function readBody(fs: SkillFs, skill: Skill): Promise<string> {
const text = (await fs.readFile(skill.skillMdPath)).toString("utf8");
return splitFrontmatter(text).body;
}
/** The user hit stop. Only the controller's signal means "cancelled"; a jev deadline surfaces as a normal error. */
function isAbort(signal: AbortSignal): boolean {
return signal.aborted;
}
export function createPreprocess(deps: PreprocessDeps) {
// Production judge re-reads the key from the latest ctl on every call; tests inject a fixed Judge.
let liveCtl: PromptPreprocessorController | undefined;
const productionJudge =
deps.judge ??
jevJudge(() => resolveApiKey(liveCtl!.getPluginConfig(configSchematics).get("jevApiKey")));
return async function preprocess(ctl: PromptPreprocessorController, userMessage: ChatMessage): Promise<string | ChatMessage> {
liveCtl = ctl;
try {
return await run(ctl, userMessage, deps.judge ?? productionJudge);
} catch (e) {
const reason = e instanceof Error ? e.message : String(e);
ctl.debug("skills: preprocessor failed, passing message through", reason, e);
return userMessage;
}
};
async function run(ctl: PromptPreprocessorController, userMessage: ChatMessage, judge: Judge): Promise<string | ChatMessage> {
const text = userMessage.getText();
const root = expandHome(ctl.getGlobalPluginConfig(globalConfigSchematics).get("skillsRoot"));
const index = await deps.catalog.load(root);
if (index.skills.length === 0) return userMessage;
const settings = readSettings(ctl);
const described = index.skills.filter(s => s.description !== "");
// 1. Explicit activation.
const act = parseActivation(text);
if (act) {
const skill = index.byName.get(act.name);
if (!skill) return `${text}\n\n${renderError(act.name, [...index.byName.values()])}`;
let body: string;
try {
body = await readBody(deps.fs, skill);
} catch (e) {
const reason = e instanceof Error ? e.message : String(e);
ctl.debug("skills: could not read SKILL.md for explicit activation", reason, e);
return `${text}\n\n${renderReadError(skill.name, reason)}`;
}
const block = renderSkillBlock(skill, body, { source: "explicit", maxInjectedChars: settings.maxInjectedChars });
cite(ctl, skill);
return renderMessage([block, renderNote()], act.rest);
}
// 2. Auto-routing.
if (settings.maxSkills === 0 || index.routable.length === 0) {
return settings.showHintWhenNoMatch ? renderMessage([renderHint(described)], text) : userMessage;
}
const history = (await ctl.pullHistory()).getMessagesArray();
const status = ctl.createStatus({ status: "loading", text: "Routing skills…" });
let picks: SkillPick[];
try {
const recentTurns = recent(history, settings.historyTurns);
const probabilities = await judge({ message: text, recentTurns }, index.routable, ctl.abortSignal);
picks = pickSkills(probabilities, settings);
} catch (e) {
if (isAbort(ctl.abortSignal)) {
status.setState({ status: "canceled", text: "Skill routing canceled" });
return userMessage;
}
const reason = e instanceof Error ? e.message : String(e);
ctl.debug("skills: jev routing failed", reason, e);
status.setState({ status: "done", text: `Skills: jev unavailable (${reason}); listed available skills instead` });
return renderMessage([renderHint(described)], text);
}
if (picks.length === 0) {
status.setState({ status: "done", text: "No skill applied" });
return settings.showHintWhenNoMatch ? renderMessage([renderHint(described)], text) : userMessage;
}
// 3. Render picks, deduplicating against earlier turns.
const blocks: string[] = [];
try {
const priorUserText = history.filter(m => m.getRole() === "user").map(m => m.getText());
for (const pick of picks) {
const skill = index.byName.get(pick.name);
if (!skill) continue;
if (priorUserText.some(t => hasSkillMarker(t, skill.name))) {
blocks.push(renderActiveReference(skill));
} else {
blocks.push(renderSkillBlock(skill, await readBody(deps.fs, skill), { source: "jev", p: pick.p, maxInjectedChars: settings.maxInjectedChars }));
cite(ctl, skill);
}
}
blocks.push(renderNote());
} catch (e) {
const reason = e instanceof Error ? e.message : String(e);
status.setState({ status: "error", text: `Skills: could not read a skill file (${reason})` });
throw e;
}
status.setState({ status: "done", text: `Applied skills: ${picks.map(p => p.name).join(", ")}` });
return renderMessage(blocks, text);
}
}
function recent(history: ChatMessage[], turns: number): Turn[] {
if (turns <= 0) return [];
return history.slice(-turns).map(m => ({ role: m.getRole(), text: m.getText().slice(0, HISTORY_TEXT_CAP) }));
}
function cite(ctl: PromptPreprocessorController, skill: Skill) {
ctl.createCitationBlock(skill.description || skill.name, { fileName: `${skill.name}/SKILL.md`, fileIdentifier: skill.skillMdPath });
}