Files
pi-imgen/extensions/imgen/tools/index.ts
T
mozempk c74b3c8b5e fix(render): satisfy the Renderer contract the commands declare
index.ts was written against assumed renderer signatures (render(req)) while
typst.ts exposed render(resolved, jobDir, outPath). It type-checked -- only
typeof === 'function' was ever asserted -- but would have failed at runtime.

typst.ts now exports the request-shaped render()/renderAllFormats() the
commands' Renderer interface declares; the low-level entry points are
renderOne()/renderFormats(). smartCrop() likewise adopts retouch.ts's richer
contract, returning the size read back from the written file.

Verified e2e: a3-portrait -> PNG+PDF, ig-story -> PNG, Italian progress.
2026-08-27 09:55:48 +02:00

1277 lines
56 KiB
TypeScript

/**
* tools/index.ts — the raw, agent-callable surface of pi-imgen.
*
* The slash commands in `commands/` are for the human at the keyboard: they open forms,
* show drafts, ask questions. THIS file is for the other lane — when he simply describes
* what he wants in the chat and the model has to do the work without a single dialog.
*
* Two rules shape everything below.
*
* 1. NO SECOND IMPLEMENTATION. Every tool is a thin parameter adapter over the exact
* function the corresponding command calls: `applyInstantRefinement()` and the
* injected `Renderer` for the instant lane, `runSocial()` / `writeSocialCaption()`
* for re-editions and captions, `runRetouch()` for every image operation,
* `backends/cpu.ts` for the vectoriser. If a tool here starts growing pipeline logic,
* that logic belongs in `commands/` or `backends/` and should be moved back.
*
* 2. ONE HEAVY JOB AT A TIME. `executionMode: "sequential"` on every tool that can reach
* the diffusion model. Two concurrent runs OOM a 16GB Mac mini before either finishes
* its VAE decode. `backends/serialize.ts` enforces the same rule one layer down; this
* is the belt to its braces, because the agent is perfectly capable of firing four
* tool calls in one batch.
*
* Every tool returns BOTH an image block (so the render appears inline in his terminal)
* and a text block naming the absolute paths (so the model has a handle to act on).
* Every failure THROWS: returning an error-shaped object never sets `isError` in pi.
*
* Progress text streamed through `onUpdate` is ITALIAN — he reads it. Tool descriptions,
* parameter descriptions and prompt guidelines are ENGLISH — the model reads those.
*/
import { existsSync, renameSync } from "node:fs";
import { basename, extname, isAbsolute, join, resolve } from "node:path";
import { Type } from "typebox";
import { StringEnum } from "@earendil-works/pi-ai";
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
import {
defineTool,
withFileMutationQueue,
type ExtensionAPI,
type ExtensionContext,
} from "@earendil-works/pi-coding-agent";
import type { ImgenConfig } from "../config.ts";
import type { Backend } from "../backends/types.ts";
import {
vectorize as vectorizeImpl,
type CpuOpResult,
type VectorizeOptions,
} from "../backends/cpu.ts";
import {
BLOCK_ROLES,
DesignSpecSchema,
FORMATS,
TEMPLATES,
type DesignSpec,
type Format,
type TemplateName,
} from "../design/spec.ts";
import { bodyFamilies, displayFamilies } from "../design/fonts.ts";
import { PALETTE_NAMES } from "../design/palettes.ts";
import { ensureNegative, scrubArtPrompt, type DirectorContext } from "../design/director.ts";
import {
ART_FILENAME,
createJob,
describeJob,
jobFile,
listJobs,
normaliseSlug,
openInFinder,
openJob,
outputFile,
saveSpec,
slugify,
tryLoadSpec,
type Job,
type JobSummary,
} from "../job.ts";
import {
generationSize,
needsUpscale,
upscaleFactor,
type PixelSize,
} from "../render/formats.ts";
import {
applyInstantRefinement,
exportFormats,
type PosterDeps,
type RenderOutcome,
type RenderRequest,
} from "../commands/poster.ts";
import {
runSocial,
writeSocialCaption,
SOCIAL_FORMATS,
type SocialDeps,
} from "../commands/social.ts";
import {
normaliseImagePath,
previewImage,
runRetouch,
type RetouchDeps,
type RetouchResult,
} from "../commands/retouch.ts";
import { S, bullets, duration, errorText, fileLabel, fill, type ErrorMessage } from "../ui/strings.ts";
// ---------------------------------------------------------------------------
// Italian, local to this module
//
// TODO: these belong in `S.tools` in ui/strings.ts, exactly like retouch.ts's `R` and
// logo.ts's `LOGO_S`. They are collected here, and nowhere else in the file, so moving
// them into the string table is one cut-and-paste.
// ---------------------------------------------------------------------------
const T = {
artDone: "Immagine pronta.",
artDraftNote: "È una prova veloce: serve a decidere l'aspetto, non a stampare.",
artNoLettering: "Nell'immagine non c'è nessuna scritta: le parole le compongo dopo, così restano perfette.",
specSaved: "Ho salvato le scelte di stile.",
specMissing: "In questa cartella non c'è nessuna scheda di stile (spec.json), quindi non so cosa comporre.",
specMissingFix: "Passa una scheda completa a typeset_spec, oppure rifai il lavoro con /poster.",
artMissing: "In questa cartella manca l'immagine di sfondo (art.png), quindi non posso comporre niente.",
artMissingFix: "Fai prima l'immagine con generate_art, oppure rifai il lavoro con /poster.",
jobUnknown: "Non ho nessun lavoro che si chiami «{slug}».",
jobUnknownFix: "Chiedimi list_jobs per vedere quelli che ci sono.",
nothingWritten: "Non è uscito nessun file.",
noJobs: "Non c'è ancora nessun lavoro: la cartella è vuota.",
jobsHeader: "Lavori più recenti:",
reEdition: "Ho fatto una copia nuova in «{cartella}»: quella di prima resta dov'è.",
vectorDone: "Ricalco finito: l'SVG lo puoi ingrandire quanto vuoi senza che si sgrani.",
captionAsk: "Il testo per i social lo scrivo solo se me lo chiedi.",
labels: {
editImage: "Modifico l'immagine",
removeBackground: "Tolgo lo sfondo",
upscale: "Ingrandisco l'immagine",
vectorize: "Ricalco con linee nitide",
listJobs: "Guardo i lavori fatti",
},
} as const;
// ---------------------------------------------------------------------------
// Dependencies — index.ts owns construction, this file owns adaptation only
// ---------------------------------------------------------------------------
/** The one CPU entry point that has no home in `/retouch`. Defaults to backends/cpu.ts. */
export interface VectorizePort {
vectorize(input: string, out: string, opts?: VectorizeOptions): Promise<CpuOpResult>;
}
/**
* Everything the tool surface needs, as the three dependency bundles the commands already
* define. Nothing is re-derived here: index.ts builds `PosterDeps`, `SocialDeps` and
* `RetouchDeps` once and hands the same objects to the commands and to `registerTools()`,
* which is what guarantees the two lanes cannot drift apart.
*/
export interface ToolsDeps {
/** The live config. A function when /presets can rewrite it under us. */
config: ImgenConfig | (() => ImgenConfig);
/** Supplies the backend, the Typst renderer, the director and the optional preflight. */
poster: PosterDeps;
/** Supplies the social renderer and the caption writer. */
social: SocialDeps;
/** Supplies the CPU ops, the smart cropper and the generative-edit path. */
retouch: RetouchDeps;
/** Defaults to `backends/cpu.ts`. */
cpu?: VectorizePort;
/** Defaults to job.ts's macOS-only Finder reveal. Injected for tests. */
openFolder?: (dir: string) => boolean;
now?: () => number;
}
function cfgOf(deps: ToolsDeps): ImgenConfig {
return typeof deps.config === "function" ? deps.config() : deps.config;
}
function backendOf(deps: ToolsDeps): Backend {
return deps.poster.backend;
}
function vectorizerOf(deps: ToolsDeps): VectorizePort {
return deps.cpu ?? { vectorize: vectorizeImpl };
}
// ---------------------------------------------------------------------------
// Shared plumbing
// ---------------------------------------------------------------------------
/** Draft artwork long edge, matching /poster's draft loop so a draft costs the same there. */
const DRAFT_LONG_EDGE = 512;
const DRAFT_STEPS = 4;
const FINAL_STEPS = 8;
/** A terminal cell is not 300 dpi: the fast loop renders small on purpose. */
const DRAFT_PPI = 72;
type Content = (TextContent | ImageContent)[];
/** An `onUpdate` we can call with plain Italian, whatever the tool's details type is. */
type Streamer = (message: string) => void;
function streamer<T>(
onUpdate: ((partial: { content: Content; details: T }) => void) | undefined,
): Streamer {
if (!onUpdate) return () => {};
return (message: string) => {
onUpdate({ content: [{ type: "text", text: message }], details: undefined as unknown as T });
};
}
function isAbort(e: unknown): boolean {
return e instanceof Error && (e.name === "AbortError" || e.name === "TimeoutError");
}
/**
* The only way a failure leaves this file. Prefers the Italian message the lower layers
* already carry (`BackendError.italian`, `RetouchError.italian`, `DirectorError.italian`),
* falls back to the string table, and always keeps the technical detail as `cause`.
*/
function fail(e: unknown, fallback: ErrorMessage): never {
if (isAbort(e)) throw e;
const italian = (e as { italian?: unknown } | null | undefined)?.italian;
const message = typeof italian === "string" && italian.trim() ? italian : errorText(fallback);
const detail = e instanceof Error ? e.message : String(e);
throw new Error(detail && detail !== message ? `${message}\n(${detail})` : message, { cause: e });
}
/** A refusal that is ours, not a lower layer's. Same shape, so callers cannot tell. */
function refuse(message: string, fix?: string): never {
throw new Error(errorText(fix ? { message, fix } : { message }));
}
function directorContextOf(ctx: ExtensionContext, signal?: AbortSignal): DirectorContext {
return {
modelRegistry: ctx.modelRegistry,
model: ctx.model,
...(signal ? { signal } : {}),
};
}
/** Resolve a path the model handed us: `~`, `file://`, relative-to-cwd, escaped spaces. */
function inputPath(raw: string, ctx: ExtensionContext): string {
return normaliseImagePath(raw, ctx.cwd);
}
/** Resolve an OUTPUT path: absolute wins, otherwise relative to the job folder. */
function outPath(raw: string | undefined, dir: string, fallbackName: string): string {
if (!raw) return join(dir, fallbackName);
const p = normaliseImagePath(raw, dir);
return isAbsolute(p) ? p : resolve(dir, p);
}
/**
* Opens an existing job, or refuses in Italian naming the slug the model invented.
* job.ts's own JobError is deliberately swallowed: "Non trovo la cartella" is true but
* useless to the model, whereas naming list_jobs tells it exactly how to recover.
*/
function requireJob(deps: ToolsDeps, slug: string): Job {
const key = normaliseSlug(slug);
if (key) {
try {
const job = openJob(cfgOf(deps), key);
if (existsSync(job.dir)) return job;
} catch {
/* fall through to the refusal below */
}
}
refuse(fill(T.jobUnknown, { slug }), T.jobUnknownFix);
}
/** The artwork a typeset pass should place: an explicit override, a photo, or art.png. */
function artworkFor(job: Job, spec: DesignSpec, override?: string): string {
if (override) {
if (!existsSync(override)) fail(new Error(override), S.errors.photoMissing(override));
return override;
}
if (spec.art.source === "photo" && spec.art.photoPath) {
if (!existsSync(spec.art.photoPath)) fail(new Error(spec.art.photoPath), S.errors.photoMissing(spec.art.photoPath));
return spec.art.photoPath;
}
if (!existsSync(job.artPath)) refuse(T.artMissing, T.artMissingFix);
return job.artPath;
}
/** spec.json is the one file this module writes directly; everything else is the renderer's. */
async function persistSpec(job: Job, spec: DesignSpec): Promise<DesignSpec> {
const path = jobFile(job, "spec.json");
await withFileMutationQueue(path, async () => saveSpec(job, spec));
return spec;
}
/** An inline preview plus the machine-readable path list, in that order. */
async function present(paths: readonly string[], text: string): Promise<Content> {
const content: Content = [];
const first = paths.find((p) => /\.(png|jpe?g|webp|svg)$/i.test(p) && existsSync(p));
if (first) {
const image = await previewImage(first);
if (image) content.push(image);
}
content.push({ type: "text", text });
return content;
}
/** Italian for him, then the absolute paths on their own lines for the model. */
function report(italian: string, paths: readonly string[], extra?: string): string {
const parts = [italian];
if (paths.length) parts.push("", bullets(paths.map((p) => `${basename(p)} — ${fileLabel(basename(p))}`)));
if (extra) parts.push("", extra);
if (paths.length) parts.push("", ...paths);
return parts.join("\n");
}
function isPrint(format: Format): boolean {
return format === "a3-portrait" || format === "a4-portrait";
}
function sizeLabel(size: PixelSize): string {
return `${size.width}x${size.height}`;
}
// ---------------------------------------------------------------------------
// Shared parameter fragments
// ---------------------------------------------------------------------------
const FormatParam = StringEnum([...FORMATS], {
description: "Output format. Print sizes are a3-portrait / a4-portrait; the rest are screen sizes.",
});
const SlugParam = Type.String({
description: "Job folder name under the output directory, as returned by list_jobs.",
});
/**
* The text blocks, exhaustive over BLOCK_ROLES by construction. An empty string REMOVES
* the block — there is no null in a tool schema the providers all agree on.
*/
const BlocksParam = Type.Object(
{
title: Type.Optional(Type.String()),
subtitle: Type.Optional(Type.String()),
date: Type.Optional(Type.String({ description: "Verbatim as it should be typeset, e.g. «12 settembre 2026»." })),
venue: Type.Optional(Type.String()),
details: Type.Optional(Type.String()),
price: Type.Optional(Type.String()),
footer: Type.Optional(Type.String()),
},
{
description:
"Italian text, verbatim. Only the roles you pass are touched; an empty string removes that block. " +
"This is DATA, never pixels: accents and dates are always typeset correctly.",
},
);
/**
* Compile-time proof that BlocksParam covers every role in the fixed contract. Adding a
* role to BLOCK_ROLES without adding it here is a type error, not a silently ignored field.
*/
type BlocksCoverAllRoles =
Exclude<(typeof BLOCK_ROLES)[number], keyof (typeof BlocksParam)["properties"]> extends never ? true : never;
const BLOCKS_ARE_EXHAUSTIVE: BlocksCoverAllRoles = true;
void BLOCKS_ARE_EXHAUSTIVE;
/**
* The whole DesignSpec as a tool parameter — the fixed contract, reused verbatim rather
* than restated. `$id` is dropped: a nested `$id` inside a tool schema confuses providers.
*/
const SpecParam = Type.Object(
{ ...DesignSpecSchema.properties },
{
description:
"A complete DesignSpec. The art prompt must be ENGLISH and describe ARTWORK ONLY — " +
"never ask the model for text, words or lettering.",
},
);
// ═══════════════════════════════════════════════════════════════════════════
// generate_art — the SLOW lane. Artwork only, never a letter.
// ═══════════════════════════════════════════════════════════════════════════
const GenerateArtParams = Type.Object({
prompt: Type.String({
description:
"ENGLISH description of the ARTWORK ONLY: subject, style, light, palette, composition. " +
"Never ask for text, a title, a date, a poster layout or any lettering — the words are " +
"typeset afterwards by typeset_spec.",
}),
negative: Type.Optional(Type.String({
description: "Extra things to keep out. The mandatory anti-lettering terms are always added.",
})),
format: Type.Optional(FormatParam),
tier: Type.Optional(StringEnum(["draft", "final"], {
description: "draft = small and fast, to judge composition. final = full local resolution. Default draft.",
default: "draft",
})),
seed: Type.Optional(Type.Integer({
minimum: 0,
maximum: 2_147_483_647,
description: "Pin it to reproduce a draft exactly at final quality. Omit for a new image.",
})),
steps: Type.Optional(Type.Integer({ minimum: 1, maximum: 50, description: "Sampling steps. Defaults per tier." })),
slug: Type.Optional(Type.String({ description: "Existing job folder to paint into. Omit to make a new one." })),
title: Type.Optional(Type.String({ description: "Used to name a new job folder when slug is omitted." })),
upscale_for_print: Type.Optional(Type.Boolean({
description:
"Enlarge the finished artwork to the print target. Only meaningful with tier=final on a print format. " +
"Default: true for final print jobs, false otherwise.",
})),
});
export interface GenerateArtDetails {
path: string;
slug: string;
dir: string;
width: number;
height: number;
seed: number;
model: string;
tier: "draft" | "final";
format: Format;
elapsedMs: number;
notes: string[];
}
export function createGenerateArtTool(deps: ToolsDeps) {
return defineTool({
name: "generate_art",
label: S.toolLabels.generateArt,
description:
"Paint the ARTWORK for a poster, cover or background with the local diffusion model. " +
"generate_art writes art.png (or art-draft.png) into a job folder and does nothing else: " +
"it never draws a single letter, because every word is typeset afterwards by typeset_spec. " +
"Runs locally; a draft takes seconds, a final render takes minutes.",
promptSnippet:
"generate_art — paint lettering-free artwork with the local diffusion model into a job folder.",
promptGuidelines: [
"Use generate_art whenever an image has to be painted from a description. Write its `prompt` in ENGLISH and describe only the picture — subject, style, light, colour, composition.",
"Never ask generate_art for a title, a date, a headline, a logo lockup or 'a poster with text on it'. Words are DATA: put them in typeset_spec's `blocks`, which is why accents and dates always come out right.",
"Call generate_art with tier=draft first when the user is still deciding. Once he approves, call it again with the SAME `seed` and tier=final to get the identical image at full quality.",
"generate_art is expensive and holds the whole machine: never call it twice in one batch, and never call it at all for a change of colour, font, layout or wording — typeset_spec does those for free.",
],
parameters: GenerateArtParams,
// Two concurrent diffusion runs OOM a 16GB machine. Never parallel.
executionMode: "sequential",
async execute(_id, params, signal, onUpdate, ctx) {
const cfg = cfgOf(deps);
const say = streamer<GenerateArtDetails>(onUpdate);
const started = deps.now ? deps.now() : Date.now();
const notes: string[] = [];
const tier: "draft" | "final" = params.tier === "final" ? "final" : "draft";
const format = (params.format ?? "a3-portrait") as Format;
// The prompt is scrubbed with the DIRECTOR's own function, not a local regex: there
// is exactly one definition of "this prompt is asking for lettering".
const prompt = scrubArtPrompt(params.prompt, undefined, []);
const negative = ensureNegative(params.negative ?? "", []);
const requested = params.slug ? normaliseSlug(params.slug) : "";
const job = createJob(cfg, requested || slugify(params.title ?? "immagine"), { reuse: true });
const print = { bleedMm: cfg.print.bleedMm, dpi: cfg.print.dpi };
const size =
tier === "draft" ? generationSize(format, print, DRAFT_LONG_EDGE) : generationSize(format, print);
const seed = params.seed ?? Math.floor(Math.random() * 2_147_483_647);
const target = jobFile(job, tier === "draft" ? "art-draft.png" : ART_FILENAME);
say(tier === "draft" ? S.progress.draft : S.progress.finalArt);
let produced;
try {
produced = await withFileMutationQueue(target, () =>
backendOf(deps).generate(
{
prompt,
negative,
seed,
width: size.width,
height: size.height,
steps: params.steps ?? (tier === "draft" ? DRAFT_STEPS : FINAL_STEPS),
tier,
outPath: target,
},
(msg) => say(msg),
signal,
),
);
} catch (e) {
fail(e, S.errors.generationFailed(e instanceof Error ? e.message : undefined));
}
let path = produced.path;
let width = produced.width;
let height = produced.height;
// Print needs far more pixels than 16GB can generate in one pass. The enlargement
// goes through runRetouch() so it uses the SAME Real-ESRGAN-first policy (and the
// same fallbacks) as /retouch — there is no second upscaler in this codebase.
const wantUpscale = params.upscale_for_print ?? (tier === "final" && isPrint(format));
if (wantUpscale && needsUpscale(format, { width, height }, print)) {
say(S.progress.upscaling);
const factor = upscaleFactor(format, print, { width, height });
try {
const up = await runRetouch(
deps.retouch,
{ action: "upscale", input: path, outDir: job.dir, factor },
{ onProgress: (msg) => say(msg), ...(signal ? { signal } : {}) },
);
const first = up.outputs[0];
if (first) {
await withFileMutationQueue(job.artPath, async () => {
renameSync(first.path, job.artPath);
});
path = job.artPath;
width = first.width;
height = first.height;
}
notes.push(...up.notes);
} catch (e) {
if (isAbort(e)) throw e;
// A slightly soft poster beats no poster: keep what we painted and say so.
notes.push(errorText(S.errors.upscaleFailed));
}
}
const details: GenerateArtDetails = {
path,
slug: job.slug,
dir: job.dir,
width,
height,
seed: produced.seed,
model: produced.model,
tier,
format,
elapsedMs: (deps.now ? deps.now() : Date.now()) - started,
notes,
};
const italian = [
T.artDone,
tier === "draft" ? T.artDraftNote : T.artNoLettering,
...(notes.length ? ["", ...notes] : []),
"",
fill(S.progress.elapsed, { tempo: duration(details.elapsedMs) }),
].join("\n");
const text = report(
italian,
[path],
`slug=${job.slug} seed=${produced.seed} size=${sizeLabel({ width, height })} tier=${tier} ` +
`format=${format}\nNext: typeset_spec with slug=${job.slug} to put the real text on it.`,
);
return { content: await present([path], text), details };
},
});
}
// ═══════════════════════════════════════════════════════════════════════════
// typeset_spec — the INSTANT lane. No model, ever.
// ═══════════════════════════════════════════════════════════════════════════
const TypesetSpecParams = Type.Object({
slug: Type.Optional(Type.String({
description: "Job folder to edit. Defaults to spec.slug when a full `spec` is supplied.",
})),
spec: Type.Optional(SpecParam),
palette: Type.Optional(StringEnum(PALETTE_NAMES, {
description: "Named palette to switch to. Colours locked by a brand preset are kept.",
})),
preset: Type.Optional(Type.String({ description: "Brand preset key whose locked colours must survive the change." })),
display_font: Type.Optional(StringEnum(displayFamilies(), { description: "Headline family." })),
body_font: Type.Optional(StringEnum(bodyFamilies(), { description: "Body family." })),
template: Type.Optional(StringEnum([...TEMPLATES], { description: "Layout template." })),
format: Type.Optional(FormatParam),
blocks: Type.Optional(BlocksParam),
art_path: Type.Optional(Type.String({ description: "Artwork to place instead of the job's art.png." })),
pdf: Type.Optional(Type.Boolean({ description: "Also write the print PDF. Default false — this is the fast loop." })),
draft: Type.Optional(Type.Boolean({ description: "Render small and fast for on-screen judging. Default true." })),
});
export interface TypesetSpecDetails {
slug: string;
dir: string;
specPath: string;
files: string[];
warnings: string[];
changed: string[];
usedModel: false;
elapsedMs: number;
}
export function createTypesetSpecTool(deps: ToolsDeps) {
return defineTool({
name: "typeset_spec",
label: S.toolLabels.typeset,
description:
"Set or change the design of a job — palette, fonts, layout template, format, and the actual " +
"Italian text — and typeset it with Typst over the existing artwork. typeset_spec NEVER runs the " +
"diffusion model: it is a pure edit of spec.json plus one sub-second Typst pass, so changing a " +
"colour, a font or a word is free. Supply a full `spec` to establish the design, or individual " +
"fields to adjust an existing one.",
promptSnippet:
"typeset_spec — change colours, fonts, layout or wording and re-typeset instantly; no model runs.",
promptGuidelines: [
"Use typeset_spec for EVERY change that is not the artwork itself: colour, font, layout, format, and any wording. It costs nothing and takes under a second, so prefer it freely and iterate.",
"Never call generate_art to fix a typo, a date, a colour or a font — call typeset_spec. The artwork carries no lettering, so the words are always a re-typeset away.",
"Put the user's Italian text into typeset_spec's `blocks` verbatim, accents included. Do not translate it, do not rewrite it, and do not invent a subtitle or a price he did not give you.",
"After generate_art has painted a new job, call typeset_spec once with a full `spec` to establish palette, fonts, template and blocks; afterwards pass only the fields that change.",
"typeset_spec renders a fast on-screen proof by default. When the user wants the finished, printable files, follow it with render_spec.",
],
parameters: TypesetSpecParams,
// Pure CPU: a Typst pass may safely run beside anything else.
executionMode: "parallel",
async execute(_id, params, signal, onUpdate, ctx) {
const cfg = cfgOf(deps);
const say = streamer<TypesetSpecDetails>(onUpdate);
const started = deps.now ? deps.now() : Date.now();
const changed: string[] = [];
const slug = normaliseSlug(params.slug ?? params.spec?.slug ?? "");
if (!slug) refuse(T.specMissing, T.specMissingFix);
// A supplied spec establishes the design; otherwise we edit the saved one.
const job = params.spec ? createJob(cfg, slug, { reuse: true }) : requireJob(deps, slug);
let spec = params.spec ? ({ ...(params.spec as DesignSpec), slug: job.slug }) : tryLoadSpec(job);
if (!spec) refuse(T.specMissing, T.specMissingFix);
if (params.spec) changed.push("spec");
const preset = params.preset ? cfg.presets[params.preset] : undefined;
if (params.preset && !preset) fail(new Error(params.preset), S.errors.presetUnknown(params.preset));
// Each edit goes through the command's own `applyInstantRefinement`, which is typed
// against InstantAction — the compiler is what stops "art" leaking into this lane.
if (params.palette) {
spec = applyInstantRefinement(spec, "colours", { palette: params.palette }, preset);
changed.push("colours");
}
if (params.display_font || params.body_font) {
spec = applyInstantRefinement(spec, "fonts", {
fonts: {
display: params.display_font ?? spec.fonts.display,
body: params.body_font ?? spec.fonts.body,
},
});
changed.push("fonts");
}
if (params.template) {
spec = applyInstantRefinement(spec, "layout", { template: params.template as TemplateName });
changed.push("layout");
}
if (params.blocks && Object.keys(params.blocks).length > 0) {
// "" means "drop this block"; job.ts's updateBlocks takes null for that.
const patch: Record<string, string | null> = {};
for (const [role, value] of Object.entries(params.blocks)) {
if (typeof value !== "string") continue;
patch[role] = value.trim() === "" ? null : value;
}
spec = applyInstantRefinement(spec, "text", { blocks: patch });
changed.push("text");
}
if (params.format && params.format !== spec.format) {
spec = { ...spec, format: params.format as Format };
changed.push("format");
}
await persistSpec(job, spec);
const artPath = artworkFor(job, spec, params.art_path ? inputPath(params.art_path, ctx) : undefined);
const draft = params.draft ?? true;
say(S.progress.typesetting);
const req: RenderRequest = {
spec,
artPath,
outDir: job.dir,
formats: [spec.format],
config: cfg,
pdf: params.pdf ?? false,
...(draft ? { ppi: DRAFT_PPI } : {}),
...(signal ? { signal } : {}),
onProgress: (msg) => say(msg),
};
let outcome: RenderOutcome;
try {
outcome = await deps.poster.renderer.render(req);
} catch (e) {
fail(e, S.errors.renderFailed(e instanceof Error ? e.message : undefined));
}
if (!outcome.files.length) refuse(T.nothingWritten);
const details: TypesetSpecDetails = {
slug: job.slug,
dir: job.dir,
specPath: jobFile(job, "spec.json"),
files: [...outcome.files],
warnings: [...(outcome.warnings ?? [])],
changed,
usedModel: false,
elapsedMs: (deps.now ? deps.now() : Date.now()) - started,
};
const italian = [
T.specSaved,
...(draft ? [S.warnings.draftOnly] : []),
...(details.warnings.length ? ["", ...details.warnings] : []),
].join("\n");
const text = report(
italian,
details.files,
`slug=${job.slug} spec=${details.specPath} changed=${changed.join(",") || "nothing"}\n` +
`No model ran. Call render_spec for the finished, printable set.`,
);
return { content: await present(details.files, text), details };
},
});
}
// ═══════════════════════════════════════════════════════════════════════════
// render_spec — the finished deliverables of a saved job.
// ═══════════════════════════════════════════════════════════════════════════
const RenderSpecParams = Type.Object({
slug: SlugParam,
formats: Type.Optional(Type.Array(FormatParam, {
description: "Which formats to write. Default: the job's own format plus every social size.",
})),
new_title: Type.Optional(Type.String({ description: "Re-edition: the new title, verbatim Italian." })),
new_date: Type.Optional(Type.String({ description: "Re-edition: the new date, verbatim as it should be typeset." })),
fork: Type.Optional(Type.Boolean({
description: "Re-editions write into a NEW folder and leave the old one untouched. Default true.",
})),
caption: Type.Optional(Type.Boolean({
description:
"Write caption.txt with Italian social copy. Set true ONLY after the user has explicitly asked for it.",
})),
pdf: Type.Optional(Type.Boolean({ description: "Write the print PDF for print formats. Default true." })),
open_folder: Type.Optional(Type.Boolean({ description: "Reveal the folder in Finder when done. Default false." })),
});
export interface RenderSpecDetails {
slug: string;
dir: string;
forked: boolean;
formats: Format[];
files: string[];
warnings: string[];
captionPath?: string;
usedModel: boolean;
elapsedMs: number;
}
export function createRenderSpecTool(deps: ToolsDeps) {
return defineTool({
name: "render_spec",
label: S.toolLabels.export,
description:
"Produce the finished files for a saved job: the print PDF and PNG plus every social size, " +
"typeset from spec.json over the existing artwork. render_spec runs no diffusion model. " +
"Pass new_title / new_date to make a re-edition of last year's poster — same artwork, new words, " +
"written into a brand-new folder so the original survives.",
promptSnippet:
"render_spec — export a saved job to print PDF + all social sizes, or fork it into a re-edition.",
promptGuidelines: [
"Use render_spec when the user wants the actual deliverables — the A3 PDF, the Instagram sizes, the Facebook cover — from a job that already has a spec and artwork.",
"Use render_spec with new_title / new_date for 'same poster, new date'. It reuses the artwork, forks into a new folder by default, and costs no model time; do not call generate_art for this.",
"Never set render_spec's `caption` to true on your own initiative. Ask the user first whether he also wants social copy, and only pass caption=true after he says yes — his own wording is never rewritten silently.",
"render_spec is the last step. If the design still needs a change, call typeset_spec first and render_spec afterwards.",
],
parameters: RenderSpecParams,
// Typst plus optionally one caption call. No diffusion: safe to run in parallel.
executionMode: "parallel",
async execute(_id, params, signal, onUpdate, ctx) {
const cfg = cfgOf(deps);
const say = streamer<RenderSpecDetails>(onUpdate);
const started = deps.now ? deps.now() : Date.now();
const dctx = directorContextOf(ctx, signal);
const job = requireJob(deps, params.slug);
const spec = tryLoadSpec(job);
if (!spec) refuse(T.specMissing, T.specMissingFix);
const reEdition = Boolean(params.new_title?.trim() || params.new_date?.trim());
let details: RenderSpecDetails;
if (reEdition) {
// A re-edition is /social's job, verbatim: it forks the folder, rewrites the two
// blocks and re-renders. One implementation, called from both lanes.
const requested = (params.formats as Format[] | undefined)?.filter((f) => FORMATS.includes(f));
const result = await runSocial(
{
slug: job.slug,
formats: requested?.length ? requested : SOCIAL_FORMATS,
...(params.new_title?.trim() ? { newTitle: params.new_title.trim() } : {}),
...(params.new_date?.trim() ? { newDate: params.new_date.trim() } : {}),
fork: params.fork ?? true,
caption: params.caption === true,
onProgress: (msg) => say(msg),
},
deps.social,
dctx,
).catch((e: unknown) => fail(e, S.errors.renderFailed(e instanceof Error ? e.message : undefined)));
details = {
slug: result.slug,
dir: result.dir,
forked: result.forked,
formats: result.formats,
files: result.files,
warnings: result.warnings,
...(result.captionPath ? { captionPath: result.captionPath } : {}),
usedModel: result.usedModel,
elapsedMs: result.elapsedMs,
};
} else {
const asked = (params.formats as Format[] | undefined)?.filter((f) => FORMATS.includes(f)) ?? [];
const formats = asked.length ? asked : exportFormats(spec.format);
const artPath = artworkFor(job, spec);
say(S.progress.exporting);
let outcome: RenderOutcome;
try {
outcome = await deps.poster.renderer.renderAllFormats({
spec,
artPath,
outDir: job.dir,
formats,
config: cfg,
pdf: params.pdf ?? true,
...(signal ? { signal } : {}),
onProgress: (msg) => say(msg),
});
} catch (e) {
fail(e, S.errors.renderFailed(e instanceof Error ? e.message : undefined));
}
if (!outcome.files.length) refuse(T.nothingWritten);
const warnings = [...(outcome.warnings ?? [])];
// The PDF is the one file a tipografia will reject silently. Check it when we can.
if (deps.poster.preflight && isPrint(spec.format)) {
const pdf = jobFile(job, outputFile(spec.format, "pdf"));
if (existsSync(pdf)) {
say(S.progress.checkingPdf);
const check = await deps.poster.preflight(pdf).catch(() => ({ ok: true, problems: [] }));
if (!check.ok) warnings.push(errorText(S.errors.preflightFailed(check.problems)));
}
}
details = {
slug: job.slug,
dir: job.dir,
forked: false,
formats: [...formats],
files: [...outcome.files],
warnings,
usedModel: false,
elapsedMs: (deps.now ? deps.now() : Date.now()) - started,
};
// Captions are opt-in, always. See the guideline on this tool.
if (params.caption === true) {
say(S.progress.caption);
try {
const written = await writeSocialCaption(job.slug, deps.social, dctx);
details.captionPath = written.path;
details.files = [...details.files, written.path];
details.usedModel = true;
} catch (e) {
if (isAbort(e)) throw e;
warnings.push(errorText(S.errors.directorUnavailable));
}
}
}
if (params.open_folder === true) {
say(S.progress.openingFolder);
(deps.openFolder ?? openInFinder)(details.dir);
}
const italian = [
S.done.header,
fill(S.done.folder, { cartella: details.dir }),
...(details.forked ? [fill(T.reEdition, { cartella: details.dir })] : []),
...(isPrint(spec.format) && (params.pdf ?? true)
? [S.done.printNote, ...(cfg.print.cropMarks ? [] : [S.done.printNoCropMarks])]
: []),
...(details.captionPath ? [] : [T.captionAsk]),
...(details.warnings.length ? ["", ...details.warnings] : []),
"",
fill(S.done.elapsed, { tempo: duration(details.elapsedMs) }),
].join("\n");
const text = report(
italian,
details.files,
`slug=${details.slug} dir=${details.dir} forked=${details.forked} ` +
`formats=${details.formats.join(",")} usedModel=${details.usedModel}`,
);
return { content: await present(details.files, text), details };
},
});
}
// ═══════════════════════════════════════════════════════════════════════════
// Image operations — every one of them a thin adapter over runRetouch()
// ═══════════════════════════════════════════════════════════════════════════
/** Shared shape of a /retouch-backed tool result. */
async function retouchResult(res: RetouchResult, italian: string, extra?: string): Promise<{
content: Content;
details: RetouchResult;
}> {
if (!res.outputs.length) refuse(T.nothingWritten);
const paths = res.outputs.map((o) => o.path);
const body = [
italian,
...(res.notes.length ? ["", ...res.notes] : []),
].join("\n");
const machine = [
...res.outputs.map((o) => `${o.path} (${o.width}x${o.height})`),
`dir=${res.dir} tool=${res.tool}`,
...(extra ? [extra] : []),
].join("\n");
return { content: await present(paths, report(body, [], machine)), details: res };
}
// --- edit_image -------------------------------------------------------------
const EditImageParams = Type.Object({
image_path: Type.String({ description: "Absolute path (or one relative to the cwd) of the image to edit." }),
instruction: Type.String({
description: "What to change, in the user's own words. It is translated into an English art prompt first.",
}),
strength: Type.Optional(Type.Number({
minimum: 0.05,
maximum: 1,
description: "How far from the original, 0..1. Default 0.55.",
})),
out_dir: Type.Optional(Type.String({ description: "Where to write. Defaults to a job folder under the output directory." })),
});
export function createEditImageTool(deps: ToolsDeps) {
return defineTool({
name: "edit_image",
label: T.labels.editImage,
description:
"Edit an existing image generatively with the local diffusion model (img2img). edit_image is the " +
"FRAGILE one: Draw Things upstream issue #121 makes img2img crash on 16GB Macs, and this is a 16GB " +
"Mac. When it fails it says so as a known bug of the program. remove_background, upscale_image and " +
"vectorize_image never touch the model and always work.",
promptSnippet:
"edit_image — generative img2img edit of an existing image (fragile on this machine; prefer CPU tools).",
promptGuidelines: [
"Use edit_image only when the user genuinely wants the CONTENT of an existing photo changed. For removing a background use remove_background, for enlarging use upscale_image, for a crisp logo outline use vectorize_image — those are CPU-only and reliable.",
"If edit_image reports the known img2img defect, do not retry it. Offer to paint a fresh image with generate_art instead, which is more reliable and usually better.",
"edit_image runs the diffusion model: never call it alongside generate_art in the same batch.",
],
parameters: EditImageParams,
// img2img is a full diffusion run. Never parallel.
executionMode: "sequential",
async execute(_id, params, signal, onUpdate, ctx) {
const say = streamer<RetouchResult>(onUpdate);
const res = await runRetouch(
deps.retouch,
{
action: "edit",
input: inputPath(params.image_path, ctx),
instruction: params.instruction,
...(params.strength !== undefined ? { strength: params.strength } : {}),
...(params.out_dir ? { outDir: inputPath(params.out_dir, ctx) } : {}),
},
{
onProgress: (msg) => say(msg),
...(signal ? { signal } : {}),
directorContext: directorContextOf(ctx, signal),
},
).catch((e: unknown) => fail(e, S.errors.generationFailed(e instanceof Error ? e.message : undefined)));
return retouchResult(res, "Immagine modificata.");
},
});
}
// --- remove_background ------------------------------------------------------
const RemoveBackgroundParams = Type.Object({
image_path: Type.String({ description: "Absolute path (or one relative to the cwd) of the image." }),
crop_to_subject: Type.Optional(Type.Boolean({
description: "Also crop to the subject's bounding box. Right for a logo, wrong for compositing. Default false.",
})),
out_dir: Type.Optional(Type.String({ description: "Where to write. Defaults to a job folder under the output directory." })),
});
export function createRemoveBackgroundTool(deps: ToolsDeps) {
return defineTool({
name: "remove_background",
label: T.labels.removeBackground,
description:
"Cut the subject out of an image and write a transparent RGBA PNG. remove_background uses Apple " +
"Vision first and rembg as a fallback: no diffusion model, no download, always available even when " +
"the image backend is broken.",
promptSnippet: "remove_background — cut out the subject and write a transparent PNG (CPU only).",
promptGuidelines: [
"Use remove_background whenever the user wants a logo, a face or an object lifted off its background, or wants a PNG he can lay over any colour.",
"Pass crop_to_subject=true to remove_background when the result is a logo or an icon that should sit tight in its box; leave it off when the cut-out has to be composited back at its original position.",
"remove_background outputs a PNG whatever extension is asked for — a JPEG cannot hold the alpha channel that is the whole point.",
],
parameters: RemoveBackgroundParams,
// Pure CPU (Vision / rembg): safe beside other work.
executionMode: "parallel",
async execute(_id, params, signal, onUpdate, ctx) {
const say = streamer<RetouchResult>(onUpdate);
const res = await runRetouch(
deps.retouch,
{
action: "background",
input: inputPath(params.image_path, ctx),
...(params.crop_to_subject !== undefined ? { cropToSubject: params.crop_to_subject } : {}),
...(params.out_dir ? { outDir: inputPath(params.out_dir, ctx) } : {}),
},
{ onProgress: (msg) => say(msg), ...(signal ? { signal } : {}) },
).catch((e: unknown) => fail(e, S.errors.backgroundRemovalFailed));
return retouchResult(res, "Sfondo tolto: il PNG è trasparente.");
},
});
}
// --- upscale_image ----------------------------------------------------------
const UpscaleImageParams = Type.Object({
image_path: Type.String({ description: "Absolute path (or one relative to the cwd) of the image." }),
factor: Type.Optional(Type.Number({
minimum: 1.1,
maximum: 8,
description: "How much bigger. Default 2. Use 4 for an A3 print from a small original.",
})),
out_dir: Type.Optional(Type.String({ description: "Where to write. Defaults to a job folder under the output directory." })),
});
export function createUpscaleImageTool(deps: ToolsDeps) {
return defineTool({
name: "upscale_image",
label: T.labels.upscale,
description:
"Enlarge an image without it going soft. upscale_image runs the ncnn/Vulkan Real-ESRGAN binary — " +
"no diffusion model — and only falls back to the image backend when that binary is missing.",
promptSnippet: "upscale_image — enlarge an image with Real-ESRGAN (CPU/GPU, no diffusion model).",
promptGuidelines: [
"Use upscale_image when a photo is too small for print, or when a draft has to reach A3 resolution. Factor 2 is the sane default; 4 is for a genuinely small original.",
"upscale_image can fall back to the diffusion backend when the Real-ESRGAN binary is absent, so treat it as heavy work: do not batch it with generate_art or edit_image.",
"A failed upscale is never fatal — if upscale_image reports it could not enlarge, the original is still perfectly good for social sizes.",
],
parameters: UpscaleImageParams,
// Real-ESRGAN is a second heavy process, and its fallback path is the diffusion
// backend. backends/serialize.ts already queues it; this keeps the agent honest too.
executionMode: "sequential",
async execute(_id, params, signal, onUpdate, ctx) {
const say = streamer<RetouchResult>(onUpdate);
const res = await runRetouch(
deps.retouch,
{
action: "upscale",
input: inputPath(params.image_path, ctx),
...(params.factor !== undefined ? { factor: params.factor } : {}),
...(params.out_dir ? { outDir: inputPath(params.out_dir, ctx) } : {}),
},
{ onProgress: (msg) => say(msg), ...(signal ? { signal } : {}) },
).catch((e: unknown) => fail(e, S.errors.upscaleFailed));
return retouchResult(res, "Immagine ingrandita.");
},
});
}
// --- vectorize_image --------------------------------------------------------
const VectorizeImageParams = Type.Object({
image_path: Type.String({ description: "Absolute path (or one relative to the cwd) of the raster to trace." }),
out_path: Type.Optional(Type.String({ description: "Where to write the .svg. Defaults to a job folder." })),
mode: Type.Optional(StringEnum(["color", "bw"], {
description: "bw gives the flat silhouette a logo usually wants. Default color.",
default: "color",
})),
max_colors: Type.Optional(Type.Integer({ minimum: 1, maximum: 256, description: "Hard cap on colours. The logo lever." })),
simplify: Type.Optional(Type.Number({ minimum: 0, maximum: 10, description: "Curve simplification tolerance in px, try 1-2.5." })),
filter_speckle: Type.Optional(Type.Integer({ minimum: 0, maximum: 128, description: "Discard patches smaller than N px. Default 4." })),
palette: Type.Optional(Type.Array(Type.String({ pattern: "^#[0-9a-fA-F]{6}$" }), {
description: "Force a fixed palette, e.g. the brand colours.",
})),
background: Type.Optional(Type.String({
pattern: "^#[0-9a-fA-F]{6}$",
description: "Flatten transparency onto this colour before tracing.",
})),
});
export interface VectorizeDetails extends CpuOpResult {
svgPath: string;
source: string;
}
export function createVectorizeImageTool(deps: ToolsDeps) {
return defineTool({
name: "vectorize_image",
label: T.labels.vectorize,
description:
"Trace a raster image into a clean SVG with vtracer. vectorize_image is what turns a generated logo " +
"into the file a printer can enlarge to any size without pixels; it uses no diffusion model. " +
"Failure is never fatal — the raster stays perfectly usable on screen.",
promptSnippet: "vectorize_image — trace a raster logo into a scalable SVG with vtracer (CPU only).",
promptGuidelines: [
"Use vectorize_image after remove_background when the user wants a logo he can hand to a printer, put on a T-shirt, or blow up to any size.",
"Pass mode=bw and a small max_colors to vectorize_image for a logo: a mark wants a flat silhouette, not a hundred gradient layers.",
"If vectorize_image reports vtracer is missing, do not improvise another tracer — say the raster PNG is still fine for screen use and name the one-line install the error gives.",
],
parameters: VectorizeImageParams,
// Pure CPU: safe beside other work.
executionMode: "parallel",
async execute(_id, params, signal, onUpdate, ctx) {
const cfg = cfgOf(deps);
const say = streamer<VectorizeDetails>(onUpdate);
const source = inputPath(params.image_path, ctx);
if (!existsSync(source)) fail(new Error(source), S.errors.photoMissing(source));
const base = basename(source, extname(source));
const dir = params.out_path
? cfg.outputDir
: createJob(cfg, slugify(`ricalco ${base}`), { reuse: true }).dir;
const target = outPath(params.out_path, dir, `${base}.svg`);
say(S.progress.vectorizing);
const opts: VectorizeOptions = {
config: cfg,
onProgress: (msg) => say(msg),
...(signal ? { signal } : {}),
...(params.mode ? { mode: params.mode as "color" | "bw" } : {}),
...(params.max_colors !== undefined ? { maxColors: params.max_colors } : {}),
...(params.simplify !== undefined ? { simplify: params.simplify } : {}),
...(params.filter_speckle !== undefined ? { filterSpeckle: params.filter_speckle } : {}),
...(params.palette?.length ? { palette: [...params.palette] } : {}),
...(params.background ? { background: params.background } : {}),
};
const res = await withFileMutationQueue(target, () => vectorizerOf(deps).vectorize(source, target, opts))
.catch((e: unknown) => fail(e, S.errors.vectorizeFailed));
const details: VectorizeDetails = { ...res, svgPath: res.path, source };
const italian = [T.vectorDone, ...(res.notes.length ? ["", ...res.notes] : [])].join("\n");
const text = report(italian, [res.path], `source=${source} tool=${res.tool}`);
// sharp rasterises the SVG for the inline preview; if it cannot, present() falls
// back to the text block alone, which still carries the path.
return { content: await present([res.path, source], text), details };
},
});
}
// ═══════════════════════════════════════════════════════════════════════════
// list_jobs — what is already on disk
// ═══════════════════════════════════════════════════════════════════════════
const ListJobsParams = Type.Object({
slug: Type.Optional(Type.String({ description: "Show one job in full instead of listing them." })),
query: Type.Optional(Type.String({ description: "Filter by title or slug, accent-insensitive." })),
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100, description: "How many to list. Default 25." })),
renderable_only: Type.Optional(Type.Boolean({
description: "Only jobs with a readable spec.json, i.e. the ones that can be re-rendered. Default false.",
})),
});
export interface ListJobsDetails {
jobs: JobSummary[];
outputDir: string;
}
export function createListJobsTool(deps: ToolsDeps) {
return defineTool({
name: "list_jobs",
label: T.labels.listJobs,
description:
"List the poster and logo jobs already on disk: slug, title, date, format, and which files each " +
"folder holds. list_jobs is how you find the slug that render_spec and typeset_spec need, and how " +
"you check whether a job still has its artwork before promising a re-render.",
promptSnippet: "list_jobs — list existing jobs on disk with their slugs, titles and produced files.",
promptGuidelines: [
"Call list_jobs before render_spec or typeset_spec whenever you do not already know the exact slug. Never guess a slug — the folder names are Italian and accent-folded.",
"Use list_jobs to answer 'what did I make last time?' and to find the poster a re-edition should fork from.",
"list_jobs reports hasArt: a job without art.png cannot be re-typeset, so say so instead of calling typeset_spec and failing.",
],
parameters: ListJobsParams,
executionMode: "parallel",
async execute(_id, params, _signal, _onUpdate, _ctx) {
const cfg = cfgOf(deps);
const all = listJobs(cfg, {
limit: params.slug ? 200 : (params.limit ?? 25),
...(params.renderable_only ? { withSpecOnly: true } : {}),
});
const wanted = params.slug ? normaliseSlug(params.slug) : "";
const needle = params.query?.trim().toLowerCase() ?? "";
const jobs = all.filter((j) => {
if (wanted) return j.slug === wanted;
if (!needle) return true;
return `${j.slug} ${j.title ?? ""}`.toLowerCase().includes(needle);
});
const details: ListJobsDetails = { jobs, outputDir: cfg.outputDir };
if (!jobs.length) {
const italian = wanted ? fill(T.jobUnknown, { slug: params.slug ?? "" }) : T.noJobs;
return {
content: [{ type: "text", text: `${italian}\noutputDir=${cfg.outputDir} jobs=0` }] as Content,
details,
};
}
const lines = jobs.map((j) => {
const flags = [
j.hasSpec ? "spec" : "no-spec",
j.hasArt ? "art" : "no-art",
...(j.hasCaption ? ["caption"] : []),
].join("/");
return `${describeJob(j)} — ${j.slug} [${flags}] ${j.outputs.length} file`;
});
const machine = jobs
.map((j) =>
`slug=${j.slug} dir=${j.dir} format=${j.format ?? "?"} template=${j.template ?? "?"} ` +
`hasSpec=${j.hasSpec} hasArt=${j.hasArt} hasCaption=${j.hasCaption} ` +
`outputs=${j.outputs.join(",") || "-"}`,
)
.join("\n");
const text = [T.jobsHeader, bullets(lines), "", machine].join("\n");
// One job asked for by name: show it, since he almost certainly means "that one".
const preview =
jobs.length === 1 && jobs[0]
? jobs[0].outputs.filter((f) => f.toLowerCase().endsWith(".png")).map((f) => join(jobs[0]!.dir, f))
: [];
return { content: await present(preview, text), details };
},
});
}
// ---------------------------------------------------------------------------
// Registration
// ---------------------------------------------------------------------------
/** Every tool, in the order the pipeline uses them. index.ts may register a subset. */
export function createTools(deps: ToolsDeps) {
return [
createGenerateArtTool(deps),
createTypesetSpecTool(deps),
createRenderSpecTool(deps),
createEditImageTool(deps),
createRemoveBackgroundTool(deps),
createUpscaleImageTool(deps),
createVectorizeImageTool(deps),
createListJobsTool(deps),
];
}
/**
* Registers the whole agent-callable surface. Safe to call from the extension factory —
* nothing here starts a process, a socket, a watcher or a timer.
*/
export function registerTools(pi: ExtensionAPI, deps: ToolsDeps): void {
for (const tool of createTools(deps)) pi.registerTool(tool);
}
export default registerTools;