src / integration / preprocessor.ts
src / integration / preprocessor.ts
import type { ChatMessage, PromptPreprocessorController } from "@lmstudio/sdk";
import { configSchematics, splitConfigList } from "../config";
import { InternalStorage } from "../core/internalStorage";
import { Journal } from "../core/journal";
import { ApprovalStore, summarizeApprovals, type ApprovalRecord } from "../policy/approvalStore";
import { permissionModeFromLevel } from "../policy/permissions";
import { WorkspaceBoundary } from "../workspace/boundary";
import { resolveWorkspaceRoot } from "../workspaceRoot";
import {
MODE_HINT_TAG,
parseApprovalCommand,
renderDecisionMessage,
renderModeHint,
renderNoPendingMessage,
} from "./approvalCommands";
/**
* Appended to a user message when `workflowHintMode` asks for it (`marker`, the
* default, needs an `@agentic` mention; `always` adds it every time). Its size
* is ratcheted by tests/descriptions.test.ts alongside the tool schema block.
*/
export const WORKFLOW_HINT = `
<agentic-workspace>
Repository loop:
1. Orient: workspace_inspect overview, then workspace_notes read memory=true (.agentic/MEMORY.md).
2. Board: for multi-step work create a workspace_tasks board and checkpoint each verified step.
3. Inspect: read/search the exact files first; read output is line-numbered \`12 | code\`, so strip that prefix before reusing the text.
4. Edit: workspace_edit apply (create makes parent directories; replace needs search + replacement). Do not assume an edit succeeded unless its transaction status is applied.
5. Verify: workspace_command run for installs, builds and tests (executable + args, no shell); start with timeout_seconds 0 for servers.
6. Record: workspace_vcs add/commit, then append to notes or MEMORY.md what the next session needs.
Manual mode stages every mutation, Plan mode needs an approved workspace_plan; both resume only after the user types /accept <id>. Cite paths and transaction/job/plan/approval ids; workspace_inspect changes relists them after compression.
</agentic-workspace>`;
type HintMode = "off" | "marker" | "always";
function parseHintMode(value: string): HintMode {
return value === "always" || value === "off" ? value : "marker";
}
async function openApprovals(
ctl: PromptPreprocessorController,
defaultWorkspacePath: string,
protectedPatterns: string[],
): Promise<ApprovalStore | undefined> {
try {
const root = resolveWorkspaceRoot(ctl, defaultWorkspacePath);
const boundary = await WorkspaceBoundary.create(root, { protectedPatterns });
const storage = new InternalStorage(boundary);
await storage.initialize();
const journal = new Journal(storage);
await journal.initialize();
const approvals = new ApprovalStore(storage, journal);
await approvals.initialize();
return approvals;
} catch {
return undefined;
}
}
function setStatus(ctl: PromptPreprocessorController, status: "done" | "error", text: string): void {
try {
ctl.createStatus({ status, text });
} catch {
// Status UI is optional.
}
}
/**
* Adds approval handling and workflow hints without owning LM Studio's
* prediction loop, so this composes with prediction-loop plugins such as
* context compressors.
*/
export async function promptPreprocessor(
ctl: PromptPreprocessorController,
userMessage: ChatMessage,
): Promise<string | ChatMessage> {
const config = ctl.getPluginConfig(configSchematics);
const content = userMessage.getText();
if (/^\s*\/(compress|compact|usage)\b/i.test(content)) return userMessage;
const mode = permissionModeFromLevel(config.get("permissionMode"));
const hintMode = parseHintMode(config.get("workflowHintMode"));
const command = parseApprovalCommand(content);
const needsStore = command !== undefined || mode !== "auto";
const approvals = needsStore
? await openApprovals(ctl, config.get("defaultWorkspacePath"), splitConfigList(config.get("protectedPatterns")))
: undefined;
let text = content;
if (command) {
if (!approvals) {
setStatus(ctl, "error", `/${command.verb}: no workspace is configured.`);
text = renderNoPendingMessage(command.verb, command.id, "no workspace configured");
} else {
try {
const record =
command.verb === "accept"
? await approvals.approve(command.id, { reason: command.reason })
: await approvals.deny(command.id, { reason: command.reason });
setStatus(ctl, "done", `${command.verb === "accept" ? "Approved" : "Denied"} ${record.id}: ${record.title}`);
text = renderDecisionMessage(record, command.verb, command.reason, mode);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
setStatus(ctl, "error", `/${command.verb} failed: ${detail}`);
text = renderNoPendingMessage(command.verb, command.id, detail);
}
}
}
const wantsWorkflowHint =
hintMode === "always" || (hintMode === "marker" && /(^|\s)@agentic\b/i.test(content));
if (wantsWorkflowHint && !content.includes("<agentic-workspace>")) text += WORKFLOW_HINT;
// No store means no workspace, and then the provider registers no workspace_plan
// to point the model at — so no hint. A store whose state cannot be read still
// gets the mode hint (stateless) plus an error status; a plain message must never fail.
if (approvals && mode !== "auto" && !content.includes(MODE_HINT_TAG)) {
let records: ApprovalRecord[] = [];
try {
records = await approvals.list();
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
setStatus(ctl, "error", `Approval state unreadable: ${detail}`);
}
const { pendingPlan, activePlan, pendingApprovals } = summarizeApprovals(records);
const hint = renderModeHint({
mode,
...(pendingPlan ? { pendingPlan: { id: pendingPlan.id, title: pendingPlan.title } } : {}),
...(activePlan
? {
activePlan: {
id: activePlan.id,
title: activePlan.title,
...(activePlan.decidedAt ? { approvedAt: activePlan.decidedAt } : {}),
},
}
: {}),
pendingApprovals: pendingApprovals.map((record) => ({ id: record.id, title: record.title })),
});
if (hint) text += hint;
}
return text === content ? userMessage : text;
}
import type { ChatMessage, PromptPreprocessorController } from "@lmstudio/sdk";
import { configSchematics, splitConfigList } from "../config";
import { InternalStorage } from "../core/internalStorage";
import { Journal } from "../core/journal";
import { ApprovalStore, summarizeApprovals, type ApprovalRecord } from "../policy/approvalStore";
import { permissionModeFromLevel } from "../policy/permissions";
import { WorkspaceBoundary } from "../workspace/boundary";
import { resolveWorkspaceRoot } from "../workspaceRoot";
import {
MODE_HINT_TAG,
parseApprovalCommand,
renderDecisionMessage,
renderModeHint,
renderNoPendingMessage,
} from "./approvalCommands";
/**
* Appended to a user message when `workflowHintMode` asks for it (`marker`, the
* default, needs an `@agentic` mention; `always` adds it every time). Its size
* is ratcheted by tests/descriptions.test.ts alongside the tool schema block.
*/
export const WORKFLOW_HINT = `
<agentic-workspace>
Repository loop:
1. Orient: workspace_inspect overview, then workspace_notes read memory=true (.agentic/MEMORY.md).
2. Board: for multi-step work create a workspace_tasks board and checkpoint each verified step.
3. Inspect: read/search the exact files first; read output is line-numbered \`12 | code\`, so strip that prefix before reusing the text.
4. Edit: workspace_edit apply (create makes parent directories; replace needs search + replacement). Do not assume an edit succeeded unless its transaction status is applied.
5. Verify: workspace_command run for installs, builds and tests (executable + args, no shell); start with timeout_seconds 0 for servers.
6. Record: workspace_vcs add/commit, then append to notes or MEMORY.md what the next session needs.
Manual mode stages every mutation, Plan mode needs an approved workspace_plan; both resume only after the user types /accept <id>. Cite paths and transaction/job/plan/approval ids; workspace_inspect changes relists them after compression.
</agentic-workspace>`;
type HintMode = "off" | "marker" | "always";
function parseHintMode(value: string): HintMode {
return value === "always" || value === "off" ? value : "marker";
}
async function openApprovals(
ctl: PromptPreprocessorController,
defaultWorkspacePath: string,
protectedPatterns: string[],
): Promise<ApprovalStore | undefined> {
try {
const root = resolveWorkspaceRoot(ctl, defaultWorkspacePath);
const boundary = await WorkspaceBoundary.create(root, { protectedPatterns });
const storage = new InternalStorage(boundary);
await storage.initialize();
const journal = new Journal(storage);
await journal.initialize();
const approvals = new ApprovalStore(storage, journal);
await approvals.initialize();
return approvals;
} catch {
return undefined;
}
}
function setStatus(ctl: PromptPreprocessorController, status: "done" | "error", text: string): void {
try {
ctl.createStatus({ status, text });
} catch {
// Status UI is optional.
}
}
/**
* Adds approval handling and workflow hints without owning LM Studio's
* prediction loop, so this composes with prediction-loop plugins such as
* context compressors.
*/
export async function promptPreprocessor(
ctl: PromptPreprocessorController,
userMessage: ChatMessage,
): Promise<string | ChatMessage> {
const config = ctl.getPluginConfig(configSchematics);
const content = userMessage.getText();
if (/^\s*\/(compress|compact|usage)\b/i.test(content)) return userMessage;
const mode = permissionModeFromLevel(config.get("permissionMode"));
const hintMode = parseHintMode(config.get("workflowHintMode"));
const command = parseApprovalCommand(content);
const needsStore = command !== undefined || mode !== "auto";
const approvals = needsStore
? await openApprovals(ctl, config.get("defaultWorkspacePath"), splitConfigList(config.get("protectedPatterns")))
: undefined;
let text = content;
if (command) {
if (!approvals) {
setStatus(ctl, "error", `/${command.verb}: no workspace is configured.`);
text = renderNoPendingMessage(command.verb, command.id, "no workspace configured");
} else {
try {
const record =
command.verb === "accept"
? await approvals.approve(command.id, { reason: command.reason })
: await approvals.deny(command.id, { reason: command.reason });
setStatus(ctl, "done", `${command.verb === "accept" ? "Approved" : "Denied"} ${record.id}: ${record.title}`);
text = renderDecisionMessage(record, command.verb, command.reason, mode);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
setStatus(ctl, "error", `/${command.verb} failed: ${detail}`);
text = renderNoPendingMessage(command.verb, command.id, detail);
}
}
}
const wantsWorkflowHint =
hintMode === "always" || (hintMode === "marker" && /(^|\s)@agentic\b/i.test(content));
if (wantsWorkflowHint && !content.includes("<agentic-workspace>")) text += WORKFLOW_HINT;
// No store means no workspace, and then the provider registers no workspace_plan
// to point the model at — so no hint. A store whose state cannot be read still
// gets the mode hint (stateless) plus an error status; a plain message must never fail.
if (approvals && mode !== "auto" && !content.includes(MODE_HINT_TAG)) {
let records: ApprovalRecord[] = [];
try {
records = await approvals.list();
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
setStatus(ctl, "error", `Approval state unreadable: ${detail}`);
}
const { pendingPlan, activePlan, pendingApprovals } = summarizeApprovals(records);
const hint = renderModeHint({
mode,
...(pendingPlan ? { pendingPlan: { id: pendingPlan.id, title: pendingPlan.title } } : {}),
...(activePlan
? {
activePlan: {
id: activePlan.id,
title: activePlan.title,
...(activePlan.decidedAt ? { approvedAt: activePlan.decidedAt } : {}),
},
}
: {}),
pendingApprovals: pendingApprovals.map((record) => ({ id: record.id, title: record.title })),
});
if (hint) text += hint;
}
return text === content ? userMessage : text;
}