src / mcp / sourceNotation.ts
src / mcp / sourceNotation.ts
/**
* aN is LM-Studio-only: it resolves through the LM-Studio attachment pool in
* chat_media_state.json's `attachments` list (see core/tools.ts). Bionic/generic have no
* equivalent notion of "attachment" — every image reaches it as an already-materialized
* iN/pN/vN entry or a fresh a1 created by syncAttachmentsToState() from a Bionic-side
* attach_file call, which core/tools.ts resolves itself. To keep the same guardrail as
* generate-image/process-image, explicit aN notations in `targets` are rejected up front
* rather than silently resolved.
*
* 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, 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 notation (iN/pN) as a target 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);
}
}
/**
* analyse_image/detect_object/annotate_image all use a single `targets` field (array of
* aN/iN/vN/pN notations, or a comma/space-separated string — see FlexibleTargetsList in
* core/tools.ts) instead of generate-image/process-image's separate canvas/moodboard fields.
* 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 { targets } = args;
if (Array.isArray(targets)) {
targets.forEach(rejectIfAttachmentNotation);
} else if (typeof targets === "string") {
targets.split(/[\s,]+/).forEach(rejectIfAttachmentNotation);
}
}
/** Collects every aN-shaped token out of `targets`, deduplicated — used to feed
* McpResultPreset.beforeToolCall()'s `attachmentTokens` (unsloth only). */
export function collectAttachmentTokens(args: Record<string, unknown>): string[] {
const { targets } = 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(targets)) targets.forEach(consider);
else if (typeof targets === "string") targets.split(/[\s,]+/).forEach(consider);
return [...tokens];
}
// No separate path-resolution step here: core/tools.ts's own handlers (handleAnalyseImage,
// handleDetectObject, handleAnnotateImage) already parse and resolve every `targets` shape
// themselves — native array, comma/space-separated string, and JSON-array-shaped string alike —
// including the scratchpad-basename/absolute-path fallback. Re-resolving here duplicated that
// logic with a second, less complete implementation and broke on JSON-array-shaped strings.
/**
* aN is LM-Studio-only: it resolves through the LM-Studio attachment pool in
* chat_media_state.json's `attachments` list (see core/tools.ts). Bionic/generic have no
* equivalent notion of "attachment" — every image reaches it as an already-materialized
* iN/pN/vN entry or a fresh a1 created by syncAttachmentsToState() from a Bionic-side
* attach_file call, which core/tools.ts resolves itself. To keep the same guardrail as
* generate-image/process-image, explicit aN notations in `targets` are rejected up front
* rather than silently resolved.
*
* 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, 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 notation (iN/pN) as a target 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);
}
}
/**
* analyse_image/detect_object/annotate_image all use a single `targets` field (array of
* aN/iN/vN/pN notations, or a comma/space-separated string — see FlexibleTargetsList in
* core/tools.ts) instead of generate-image/process-image's separate canvas/moodboard fields.
* 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 { targets } = args;
if (Array.isArray(targets)) {
targets.forEach(rejectIfAttachmentNotation);
} else if (typeof targets === "string") {
targets.split(/[\s,]+/).forEach(rejectIfAttachmentNotation);
}
}
/** Collects every aN-shaped token out of `targets`, deduplicated — used to feed
* McpResultPreset.beforeToolCall()'s `attachmentTokens` (unsloth only). */
export function collectAttachmentTokens(args: Record<string, unknown>): string[] {
const { targets } = 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(targets)) targets.forEach(consider);
else if (typeof targets === "string") targets.split(/[\s,]+/).forEach(consider);
return [...tokens];
}
// No separate path-resolution step here: core/tools.ts's own handlers (handleAnalyseImage,
// handleDetectObject, handleAnnotateImage) already parse and resolve every `targets` shape
// themselves — native array, comma/space-separated string, and JSON-array-shaped string alike —
// including the scratchpad-basename/absolute-path fallback. Re-resolving here duplicated that
// logic with a second, less complete implementation and broke on JSON-array-shaped strings.