src / mcp / sourceNotation.ts
src / mcp / sourceNotation.ts
/**
* aN is LM-Studio-only: it resolves through resolveImg2ImgSourceLMStudio(),
* which reads conversation.json / LM-Studio user-files (see core/tools.ts).
* Bionic/generic have no equivalent, so canvas/moodboard values using aN must be
* rejected before the handler ever sees them. Every other value (vN/pN/iN, a
* scratchpad filename, an absolute path) is left untouched — core/tools.ts
* resolves those itself against chat_media_state.json in the bound chat
* working dir (chatContextBridge.ts).
*
* unsloth is the one exception: its attachments are real (see made-for-bionic-core's
* unslothAttachments.ts/presets/unsloth.ts) — they're materialized into the SAME
* chat_media_state.json `attachments` list core/tools.ts already resolves aN against for the
* LM-Studio plugin path, so `assertNoAttachmentNotation()` no-ops for that preset and its
* `beforeToolCall()` hook resolves aN before the handler ever runs. The bare "a" and the literal
* placeholder "aN" both match this regex too — neither is a real number, so unsloth's hook
* treats them the same as any unresolvable number: it returns the current attachment inventory
* instead of running the tool, which doubles as an explicit "list my attachments" request.
*/
import type { McpResultPreset } from "./core-bundle.mjs";
export const ATTACHMENT_NOTATION = /^a([1-9]\d*|n)?$/i;
export class UnsupportedAttachmentNotationError extends Error {
constructor(value: string) {
super(
`Source "${value}" uses LM-Studio attachment notation (aN), which is not supported here. ` +
`Use load_file_attachment first, then pass the resulting scratchpad filename as the source instead.`
);
this.name = "UnsupportedAttachmentNotationError";
}
}
function rejectIfAttachmentNotation(value: unknown): void {
if (typeof value !== "string") return;
const trimmed = value.trim();
if (ATTACHMENT_NOTATION.test(trimmed)) {
throw new UnsupportedAttachmentNotationError(trimmed);
}
}
/** No-ops entirely for the unsloth preset (see module doc comment above). */
export function assertNoAttachmentNotation(args: Record<string, unknown>, preset: McpResultPreset): void {
if (preset.name === "unsloth") return;
const { canvas, moodboard } = args;
if (Array.isArray(canvas)) {
canvas.forEach(rejectIfAttachmentNotation);
} else {
rejectIfAttachmentNotation(canvas);
}
if (Array.isArray(moodboard)) {
moodboard.forEach(rejectIfAttachmentNotation);
} else if (typeof moodboard === "string") {
// moodboard also accepts a comma/space-separated string (see SourceNotationList in schemas.ts).
moodboard.split(/[\s,]+/).forEach(rejectIfAttachmentNotation);
}
}
/** Collects every aN-shaped token out of canvas/moodboard, deduplicated — used to feed
* McpResultPreset.beforeToolCall()'s `attachmentTokens` (unsloth only). */
export function collectAttachmentTokens(args: Record<string, unknown>): string[] {
const { canvas, moodboard } = args;
const tokens = new Set<string>();
const consider = (value: unknown): void => {
if (typeof value !== "string") return;
const trimmed = value.trim();
if (ATTACHMENT_NOTATION.test(trimmed)) tokens.add(trimmed);
};
if (Array.isArray(canvas)) canvas.forEach(consider);
else consider(canvas);
if (Array.isArray(moodboard)) moodboard.forEach(consider);
else if (typeof moodboard === "string") moodboard.split(/[\s,]+/).forEach(consider);
return [...tokens];
}
// No separate path-resolution step here: core/tools.ts's own handlers already parse and resolve
// canvas/moodboard themselves (notation, comma/space-separated string, or a scratchpad
// basename/absolute path). Re-resolving here duplicated that logic with a second implementation.
/**
* aN is LM-Studio-only: it resolves through resolveImg2ImgSourceLMStudio(),
* which reads conversation.json / LM-Studio user-files (see core/tools.ts).
* Bionic/generic have no equivalent, so canvas/moodboard values using aN must be
* rejected before the handler ever sees them. Every other value (vN/pN/iN, a
* scratchpad filename, an absolute path) is left untouched — core/tools.ts
* resolves those itself against chat_media_state.json in the bound chat
* working dir (chatContextBridge.ts).
*
* unsloth is the one exception: its attachments are real (see made-for-bionic-core's
* unslothAttachments.ts/presets/unsloth.ts) — they're materialized into the SAME
* chat_media_state.json `attachments` list core/tools.ts already resolves aN against for the
* LM-Studio plugin path, so `assertNoAttachmentNotation()` no-ops for that preset and its
* `beforeToolCall()` hook resolves aN before the handler ever runs. The bare "a" and the literal
* placeholder "aN" both match this regex too — neither is a real number, so unsloth's hook
* treats them the same as any unresolvable number: it returns the current attachment inventory
* instead of running the tool, which doubles as an explicit "list my attachments" request.
*/
import type { McpResultPreset } from "./core-bundle.mjs";
export const ATTACHMENT_NOTATION = /^a([1-9]\d*|n)?$/i;
export class UnsupportedAttachmentNotationError extends Error {
constructor(value: string) {
super(
`Source "${value}" uses LM-Studio attachment notation (aN), which is not supported here. ` +
`Use load_file_attachment first, then pass the resulting scratchpad filename as the source instead.`
);
this.name = "UnsupportedAttachmentNotationError";
}
}
function rejectIfAttachmentNotation(value: unknown): void {
if (typeof value !== "string") return;
const trimmed = value.trim();
if (ATTACHMENT_NOTATION.test(trimmed)) {
throw new UnsupportedAttachmentNotationError(trimmed);
}
}
/** No-ops entirely for the unsloth preset (see module doc comment above). */
export function assertNoAttachmentNotation(args: Record<string, unknown>, preset: McpResultPreset): void {
if (preset.name === "unsloth") return;
const { canvas, moodboard } = args;
if (Array.isArray(canvas)) {
canvas.forEach(rejectIfAttachmentNotation);
} else {
rejectIfAttachmentNotation(canvas);
}
if (Array.isArray(moodboard)) {
moodboard.forEach(rejectIfAttachmentNotation);
} else if (typeof moodboard === "string") {
// moodboard also accepts a comma/space-separated string (see SourceNotationList in schemas.ts).
moodboard.split(/[\s,]+/).forEach(rejectIfAttachmentNotation);
}
}
/** Collects every aN-shaped token out of canvas/moodboard, deduplicated — used to feed
* McpResultPreset.beforeToolCall()'s `attachmentTokens` (unsloth only). */
export function collectAttachmentTokens(args: Record<string, unknown>): string[] {
const { canvas, moodboard } = args;
const tokens = new Set<string>();
const consider = (value: unknown): void => {
if (typeof value !== "string") return;
const trimmed = value.trim();
if (ATTACHMENT_NOTATION.test(trimmed)) tokens.add(trimmed);
};
if (Array.isArray(canvas)) canvas.forEach(consider);
else consider(canvas);
if (Array.isArray(moodboard)) moodboard.forEach(consider);
else if (typeof moodboard === "string") moodboard.split(/[\s,]+/).forEach(consider);
return [...tokens];
}
// No separate path-resolution step here: core/tools.ts's own handlers already parse and resolve
// canvas/moodboard themselves (notation, comma/space-separated string, or a scratchpad
// basename/absolute path). Re-resolving here duplicated that logic with a second implementation.