← Files Vocabloot Deck KitARCHIVED FILE

scripts/lib/agent.mjs

5.37 KB · Oct 2, 2026 · 00:34 UTC

↓ Download file

// The agent client: the model that runs the skill (Claude Code, Codex, any agent)
// answers the kit's requests itself, so a creator needs no API key for text.
// Same interface as the OpenAI client. When an answer is missing, the call writes
// a request file the agent can read and answer, and returns null; the pipeline
// lists the open requests instead of failing. Image generation is the one thing an
// agent cannot do here, so image() says what the two real options are.
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";

import { validateAgainst } from "./jsonschema.mjs";

/** work/<kind>/<slug>: the kind is the key's prefix, the slug is the rest, made file-safe. */
export function requestPaths(workDir, key) {
  const [kind, ...rest] = key.split(":");
  if (kind === "words") rest.splice(1, rest.length, "proposal"); // one list per deck, whatever the count
  const slug = rest
    .slice(1) // the deck id
    .join("-")
    .toLowerCase()
    .normalize("NFD")
    .replace(/[̀-ͯ]/g, "")
    .replace(/ß/g, "ss")
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-|-$/g, "")
    .slice(0, 60);
  const dir = path.join(workDir, kind);
  return {
    dir,
    request: path.join(dir, `${slug || "answer"}.request.md`),
    answer: path.join(dir, `${slug || "answer"}.json`),
  };
}

export class AgentClient {
  constructor(workDir) {
    this.workDir = workDir;
    /** Requests written this run, in order: { key, request, answer }. */
    this.missing = [];
    /** Answers that exist but do not match their schema: { key, answer, problems }. */
    this.rejected = [];
  }
  get isAgent() {
    return true;
  }

  read({ key, schema, sections }) {
    const paths = requestPaths(this.workDir, key);
    if (existsSync(paths.answer)) {
      let value;
      try {
        value = JSON.parse(readFileSync(paths.answer, "utf8"));
      } catch (e) {
        this.rejected.push({ key, answer: paths.answer, problems: [`not valid JSON: ${e.message}`] });
        return null;
      }
      const problems = schema ? validateAgainst(schema, value) : [];
      if (problems.length) {
        this.rejected.push({ key, answer: paths.answer, problems });
        return null;
      }
      return value;
    }
    mkdirSync(paths.dir, { recursive: true });
    writeFileSync(paths.request, renderRequest({ answerFile: paths.answer, workDir: this.workDir, schema, sections }));
    this.missing.push({ key, request: paths.request, answer: paths.answer });
    return null;
  }

  /** The same signature as OpenAIClient.chatJson; returns the parsed answer or null. */
  async chatJson({ key, system, user, schema }) {
    return this.read({
      key,
      schema,
      sections: [
        ["Instructions", system],
        ["Input", "```json\n" + pretty(user) + "\n```"],
      ],
    });
  }

  /** Asks the agent to look at the image itself; imagePath is relative to the deck folder. */
  async vision({ key, prompt, imagePath }) {
    const schema = {
      type: "object",
      additionalProperties: false,
      required: ["depicts", "reason"],
      properties: { depicts: { type: "boolean" }, reason: { type: "string" } },
    };
    return this.read({
      key,
      schema,
      sections: [
        [
          "Look at this image",
          `\`${imagePath}\` (open it and look at it before answering; do not guess from the file name)`,
        ],
        ["Question", prompt],
      ],
    });
  }

  async image() {
    throw new Error(
      "Image generation needs an image model. Either run with --provider none and make the images with any tool (the prompts are in prompts/stickers.json, files go to stickers/source/<slug>.png), or put OPENAI_API_KEY in your environment or a .env file and run with --provider openai.",
    );
  }
}

function pretty(user) {
  try {
    return JSON.stringify(JSON.parse(user), null, 2);
  } catch {
    return String(user);
  }
}

function renderRequest({ answerFile, workDir, schema, sections }) {
  const rel = path.relative(path.dirname(workDir), answerFile);
  const lines = [
    "# Vocabloot deck kit: an answer is needed",
    "",
    `Write your answer as one JSON object to \`${rel}\`. It must match the JSON schema at the end exactly: every required field present, no other fields, the given enum values only. No prose, no code fence, just the JSON.`,
    "",
  ];
  for (const [title, body] of sections) lines.push(`## ${title}`, "", body, "");
  if (schema) lines.push("## JSON schema", "", "```json", JSON.stringify(schema, null, 2), "```", "");
  return lines.join("\n");
}

/** The lines a script prints when requests are open, for the agent to act on. */
export function describeOpen(client, deckDir) {
  const out = [];
  if (client.rejected.length) {
    out.push(`${client.rejected.length} answer${client.rejected.length === 1 ? "" : "s"} did not match the schema:`);
    for (const r of client.rejected) {
      out.push(`  ${path.relative(deckDir, r.answer)}`);
      for (const p of r.problems.slice(0, 8)) out.push(`    ${p}`);
      if (r.problems.length > 8) out.push(`    and ${r.problems.length - 8} more`);
    }
  }
  if (client.missing.length) {
    out.push(
      `${client.missing.length} request${client.missing.length === 1 ? "" : "s"} to answer, then run the same command again:`,
    );
    for (const m of client.missing)
      out.push(`  ${path.relative(deckDir, m.request)}  ->  ${path.relative(deckDir, m.answer)}`);
  }
  return out;
}

SHA-256: 201b4cfe54c74c1f6e674add22f8631adab9bb0afed23cd0a34ae5ffa185c9e7