Skip to the content

Capsid menu

app/lib/operator/api.server.ts

632 lines and 34 functions in DrDustinEdwards/dustinedwards-info, at commit 1590ba4 on 2026-10-24. This file on GitHub.

Functions

The functions of api.server.ts
FunctionLinesComplexityCoveredCRAPMutation
readSlug75 to 895No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
listMentionsTool92 to 1336No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:111>111 to 1111No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:118>118 to 1301No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
decideMentionTool136 to 1769No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
runTool180 to 38451No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:266>266 to 2741No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:287>287 to 2871No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:307>307 to 3071No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:328>328 to 3332No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:349>349 to 3491No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:362>362 to 3622No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:368>368 to 3681No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
translate386 to 43812No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
listPosts440 to 4601No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:446>446 to 4584No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
getPost462 to 4885No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
savePostTool490 to 5368No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
getProcedureTool538 to 5443No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
getPublicationTool546 to 5658No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
refreshCitationsTool571 to 6036No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:572>572 to 5722No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:576>576 to 5762No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
key579 to 5791No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:581>581 to 5811No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
saveProcedureTool605 to 6217No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
getCvTool623 to 6294No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
getDictionaryTool631 to 6395No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
getPageTool641 to 6474No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
getLlmsTool649 to 6532No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
getPhageTool655 to 6634No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
getRegistryTool665 to 6776No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
getRosterTool679 to 6874No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
deletePostTool689 to 7164No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code

Imports

It imports app/db/cv.ts, app/db/dictionary.ts, app/db/index.ts, app/db/pages.ts, app/db/phages.ts, app/db/procedures.ts, app/db/publications.ts, app/db/registry.ts, app/db/roster.ts, app/db/schema.ts, app/kb/procedures/parse.mjs, app/kb/procedures/save.server.ts, app/kb/registry/compile.mjs, app/kb/registry/kinds.mjs, app/kb/registry/save.server.ts, app/lib/backup/d1-backup.server.ts, app/lib/citations.server.ts, app/lib/content/slug.mjs, app/lib/cv/save.server.ts, app/lib/dictionary/save.server.ts, app/lib/editor/converge.mjs, app/lib/editor/github.server.ts, app/lib/editor/publish-policy.mjs, app/lib/editor/publish.server.ts, app/lib/editor/write-path.server.ts, app/lib/error-message.mjs, app/lib/llms/save.server.ts, app/lib/operator/auth.server.ts, app/lib/operator/descriptors.ts, app/lib/operator/sync-tools.server.ts, app/lib/operator/upload-media.server.ts, app/lib/pages/save.server.ts, app/lib/phages/save.server.ts, app/lib/publications/parse.mjs, app/lib/publications/save.server.ts, app/lib/publications/sources.server.ts, app/lib/roster/save.server.ts, app/lib/search/zero-result.server.ts, app/lib/webmention/decide.server.ts. Imported by app/routes/admin._index.tsx, app/routes/api.operator.ts.

Source

// Every write calls the same functions the browser editor's action calls; nothing here re-implements a gate.

import { DIVERGENCE_ERROR_NAME } from "~/lib/editor/converge.mjs";

import { getAdminPostRow, listPostsForOperator, listWebmentionsForAdmin } from "~/db";
import { webmentions as webmentionsTable } from "~/db/schema";
import {
  currentHead,
  deletePost,
  savePost,
  EditorError,
  GitHubError,
  PolicyError,
  type Actor,
} from "~/lib/editor/publish.server";
import { UNCHANGED_NOTE } from "~/lib/editor/write-path.server";
import { SLUG_MAX_LENGTH, SLUG_PATTERN, postPath } from "~/lib/content/slug.mjs";
import { decideMention } from "~/lib/webmention/decide.server";
import { readState } from "~/lib/editor/publish-policy.mjs";
import { readFile } from "~/lib/editor/github.server";

import { backupD1 } from "~/lib/backup/d1-backup.server";
import type { OperatorEnv } from "./auth.server";
import type { ToolName, ToolResult } from "./descriptors";
import {
  backupMedia,
  syncAsk,
  syncDictionary,
  syncMedia,
  syncLlms,
  syncCv,
  syncCvPdf,
  syncPages,
  syncPosts,
  syncProcedures,
  syncPublications,
  syncPhages,
  syncRegistry,
  syncRoster,
  syncStatus,
} from "./sync-tools.server";
import { uploadMediaTool } from "./upload-media.server";
import { errorMessage } from "~/lib/error-message.mjs";
import { listProceduresForOperator } from "~/db/procedures";
import { purgeZeroResults } from "~/lib/search/zero-result.server";
import { listPublicationIdentities, listPublishedPublications } from "~/db/publications";
import { refreshCitations } from "~/lib/citations.server";
import { checkPublicationSources } from "~/lib/publications/sources.server";
import { compilePublicationFor, fileIsDraft } from "~/lib/publications/save.server";
import { publicationPath } from "~/lib/publications/parse.mjs";
import { ProcedureInvalid, readProcedure, saveProcedure } from "~/kb/procedures/save.server";
import { procedurePath } from "~/kb/procedures/parse.mjs";
import { listCvRows } from "~/db/cv";
import { listPageRows } from "~/db/pages";
import { CvInvalid, readCvFile } from "~/lib/cv/save.server";
import { listDictionaryRows } from "~/db/dictionary";
import { readDictionary } from "~/lib/dictionary/save.server";
import { PageInvalid, readPage } from "~/lib/pages/save.server";
import { readLlms } from "~/lib/llms/save.server";
import { listPhageRows } from "~/db/phages";
import { PHAGE_SLUG, readPhage } from "~/lib/phages/save.server";
import { listRegistryRows } from "~/db/registry";
import { parseRegistrySlug } from "~/kb/registry/compile.mjs";
import { KINDS } from "~/kb/registry/kinds.mjs";
import { readRegistryItem } from "~/kb/registry/save.server";
import { listRosterRows } from "~/db/roster";
import { COHORT_SLUG, readRoster } from "~/lib/roster/save.server";

// Re-exported so the routes keep one import path for the operator surface.
export { TOOL_DESCRIPTORS, isToolName, toolNames } from "./descriptors";
export { syncStatus };

// Validated like the write path: the slug goes into a repository path via `encodeURI`, which does not
// escape `.`, `/` or `?`.
function readSlug(args: Record<string, unknown>, tool: string) {
  const slug = String(args.slug ?? "").trim();
  if (!slug) return { slug: "", error: `${tool} requires a slug.` };
  if (!SLUG_PATTERN.test(slug)) {
    return { slug: "", error: `${tool} requires a lowercase kebab-case slug.` };
  }
  // The length bound too: a read must not accept a slug the write schema refuses.
  if (slug.length > SLUG_MAX_LENGTH) {
    return {
      slug: "",
      error: `${tool} requires a slug of at most ${SLUG_MAX_LENGTH} characters.`,
    };
  }
  return { slug, error: "" };
}

// Unfiltered by default: a queue that hides its failures cannot tell "nothing arrived" from "all refused".
async function listMentionsTool(
  env: OperatorEnv,
  args: Record<string, unknown>,
): Promise<ToolResult> {
  const status = typeof args.status === "string" ? args.status.trim() : "";

  // A 400, not `[]`: an agent that mistyped a status would conclude the queue was empty. Checked
  // before the read, and against the schema's own enum, so a new status cannot be refused here.
  const allowed: readonly string[] = webmentionsTable.status.enumValues;
  if (status && !allowed.includes(status)) {
    return {
      ok: false,
      status: 400,
      error: `Unknown status ${JSON.stringify(status)}. One of: ${allowed.join(", ")}.`,
    };
  }

  const rows = await listWebmentionsForAdmin(env);

  const filtered = status ? rows.filter((row) => row.status === status) : rows;

  return {
    ok: true,
    data: {
      status: status || "all",
      count: filtered.length,
      mentions: filtered.map((row) => ({
        id: row.id,
        source: row.sourceUrl,
        target: row.targetSlug,
        status: row.status,
        author: row.authorName,
        authorUrl: row.authorUrl,
        excerpt: row.excerpt,
        failureReason: row.failureReason,
        receivedAt: row.receivedAt,
        verifiedAt: row.verifiedAt,
        decidedAt: row.decidedAt,
      })),
    },
  };
}

// Calls `decideMention` only: the write and the cache purge travel together there.
async function decideMentionTool(
  env: OperatorEnv,
  actor: Actor,
  args: Record<string, unknown>,
): Promise<ToolResult> {
  const id = typeof args.id === "number" ? args.id : Number(args.id);
  if (!Number.isInteger(id) || id <= 0) {
    return {
      ok: false,
      status: 400,
      error: `decide_mention needs a positive integer id, got ${JSON.stringify(args.id)}. ` +
        `Call list_mentions for the ids.`,
    };
  }

  const decision = typeof args.decision === "string" ? args.decision.trim() : "";
  if (decision !== "approve" && decision !== "reject" && decision !== "delete") {
    return {
      ok: false,
      status: 400,
      error: `decide_mention needs a decision of approve, reject or delete, got ` +
        `${JSON.stringify(args.decision)}.`,
    };
  }

  const result = await decideMention(env, id, decision, actor);

  // `changed: false` is an answer, not a 404, so a caller does not retry something that cannot succeed.
  return {
    ok: true,
    data: {
      id,
      decision,
      changed: result.changed,
      slug: result.slug,
      purged: result.changed ? `post:${result.slug}` : null,
      // True when the page's cache purge failed: the change is stored but the page stays stale.
      purgeFailed: result.purged === false,
    },
  };
}

// Errors are translated, not thrown: a gate rejection returns its own message, field and line, so an
// agent can fix its own mistake.
export async function runTool(
  env: OperatorEnv,
  actor: Actor,
  name: ToolName,
  args: Record<string, unknown>,
): Promise<ToolResult> {
  try {
    switch (name) {
      case "list_posts":
        return { ok: true, data: await listPosts(env) };
      case "get_post":
        return await getPost(env, args);
      case "save_post":
        return await savePostTool(env, actor, args);
      case "delete_post":
        return await deletePostTool(env, actor, args);
      case "sync_status":
        return { ok: true, data: await syncStatus(env) };

      case "sync_ask":
        return await syncAsk(env);

      case "sync_media":
        return await syncMedia(env);

      case "sync_posts":
        return await syncPosts(env);

      case "sync_procedures":
        return await syncProcedures(env);

      case "sync_dictionary":
        return await syncDictionary(env);

      case "sync_pages":
        return await syncPages(env);

      case "sync_cv":
        return await syncCv(env);

      case "sync_cv_pdf":
        return await syncCvPdf(env);

      case "sync_publications":
        return await syncPublications(env);

      case "sync_llms":
        return await syncLlms(env);

      case "sync_roster":
        return await syncRoster(env);

      case "sync_phages":
        return await syncPhages(env);

      case "sync_registry":
        return await syncRegistry(env);

      case "backup_media":
        return await backupMedia(env);

      case "backup_d1":
        return { ok: true, data: await backupD1(env) };

      case "upload_media":
        return await uploadMediaTool(env, args);

      case "list_mentions":
        return await listMentionsTool(env, args);

      case "decide_mention":
        return await decideMentionTool(env, actor, args);

      case "list_procedures": {
        const procedures = await listProceduresForOperator(env);
        return { ok: true, data: { headSha: await currentHead(env), count: procedures.length, procedures } };
      }

      case "get_procedure":
        return await getProcedureTool(env, args);

      case "save_procedure":
        return await saveProcedureTool(env, actor, args);

      case "list_publications": {
        const rows = await listPublicationIdentities(env);
        const publications = rows.map((row) => ({
          slug: row.slug,
          doi: (JSON.parse(row.record) as { doi: string | null }).doi,
          stage: row.stage,
          type: row.type,
          title: row.title,
          year: row.year,
          draft: row.status === "draft",
        }));
        return { ok: true, data: { headSha: await currentHead(env), count: publications.length, publications } };
      }

      case "get_publication":
        return await getPublicationTool(env, args);

      case "refresh_citations":
        return await refreshCitationsTool(env, args);

      case "check_publications": {
        const report = await checkPublicationSources(env);
        if (report.failures.length > 0) {
          const names = report.failures.map((f) => `${f.source} (${f.detail})`).join(", ");
          return { ok: false, status: 502, error: `Not every source could be read: ${names}.`, detail: report };
        }
        return { ok: true, data: report };
      }

      case "list_pages": {
        const pageRows = await listPageRows(env);
        return { ok: true, data: { headSha: await currentHead(env), count: pageRows.length, pages: pageRows } };
      }

      case "get_page":
        return await getPageTool(env, args);

      case "get_llms":
        return await getLlmsTool(env);


      case "list_cv": {
        const cvRows = await listCvRows(env);
        const files = cvRows.map(({ record: _record, ...row }) => row);
        return { ok: true, data: { headSha: await currentHead(env), count: files.length, files } };
      }

      case "get_cv":
        return await getCvTool(env, args);
      case "list_dictionary": {
        const entries = await listDictionaryRows(env);
        return { ok: true, data: { headSha: await currentHead(env), count: entries.length, entries } };
      }

      case "get_dictionary":
        return await getDictionaryTool(env, args);

      case "list_roster": {
        const rows = await listRosterRows(env);
        return {
          ok: true,
          data: {
            headSha: await currentHead(env),
            count: rows.length,
            cohorts: rows.map((row) => ({
              slug: row.slug,
              year: row.year,
              photo: row.cohort.photo?.src ?? null,
              researchers: row.cohort.researchers.length,
            })),
          },
        };
      }

      case "get_roster":
        return await getRosterTool(env, args);

      case "list_phages": {
        const rows = await listPhageRows(env);
        return {
          ok: true,
          data: {
            headSha: await currentHead(env),
            count: rows.length,
            // Only what the public table already shows: the name, year, host, county, PhagesDB record, paper and notes.
            phages: rows.map((row) => ({ slug: row.slug, ...row.phage })),
          },
        };
      }

      case "get_phage":
        return await getPhageTool(env, args);

      case "list_registry": {
        const kind = String(args.kind ?? "").trim();
        if (kind && !Object.hasOwn(KINDS, kind)) {
          return { ok: false, status: 400, error: `"${kind}" is not a registry kind; the kinds are ${Object.keys(KINDS).join(", ") || "none yet"}.` };
        }
        const rows = (await listRegistryRows(env)).filter((row) => !kind || row.kind === kind);
        return {
          ok: true,
          data: {
            headSha: await currentHead(env),
            count: rows.length,
            items: rows.map(({ syncedAt: _syncedAt, ...item }) => ({ slug: `${item.kind}/${item.id}`, ...item })),
          },
        };
      }

      case "get_registry":
        return await getRegistryTool(env, args);

      case "purge_zero_results": {
        const purged = await purgeZeroResults(env);
        return { ok: true, data: { purged } };
      }
    }
  } catch (error) {
    return translate(error);
  }
}

function translate(error: unknown): ToolResult {
  // Every message the validator gave, so an agent can fix its own edit; nothing was committed.
  if (error instanceof ProcedureInvalid || error instanceof PageInvalid || error instanceof CvInvalid) {
    return { ok: false, status: 422, error: error.message, detail: { errors: error.errors } };
  }

  // Names the policy so a caller can branch on it rather than string-match the prose.
  if (error instanceof PolicyError) {
    return {
      ok: false,
      status: 403,
      error: error.message,
      detail: { policy: error.policy },
    };
  }

  if (error instanceof EditorError) {
    return {
      ok: false,
      status: 422,
      error: error.message,
      detail: { field: error.field ?? null, line: error.line ?? null },
    };
  }

  // A divergence means the commit LANDED, so retrying is the one thing that does not help (500: the
  // caller did nothing wrong). Matched on `name`, not `instanceof`: the module can load in two build graphs.
  if (error instanceof Error && error.name === DIVERGENCE_ERROR_NAME) {
    return {
      ok: false,
      status: 500,
      error: error.message,
      detail: { code: "d1-divergence", committed: true, retrySave: false },
    };
  }

  if (error instanceof GitHubError) {
    return {
      ok: false,
      status: error.conflict ? 409 : 502,
      error: error.message,
      detail: { conflict: Boolean(error.conflict) },
    };
  }

  // Not a recognized failure: logged with its stack, since the caller receives only the message.
  console.error(JSON.stringify({ alert: "operator-tool-unrecognized-error", detail: errorMessage(error) }));
  return {
    ok: false,
    status: 500,
    error: errorMessage(error),
  };
}

async function listPosts(env: OperatorEnv) {
  // D1, not the repository: the rows are what the site serves.
  const rows = await listPostsForOperator(env);
  return {
    headSha: await currentHead(env),
    count: rows.length,
    posts: rows.map((p) => ({
      slug: p.slug,
      title: p.title,
      description: p.description,
      date: p.publishAt ? p.publishAt.toISOString().slice(0, 10) : null,
      publishAt: p.publishAt ? p.publishAt.toISOString() : null,
      draft: p.status === "draft",
      tags: p.tags,
      series: p.series,
      part: p.part,
      updated: p.updatedAt ? p.updatedAt.toISOString().slice(0, 10) : null,
      sourcePath: p.sourcePath,
    })),
  };
}

async function getPost(env: OperatorEnv, args: Record<string, unknown>): Promise<ToolResult> {
  const parsed = readSlug(args, "get_post");
  if (parsed.error) return { ok: false, status: 400, error: parsed.error };
  const slug = parsed.slug;

  const file = await readFile(env, postPath(slug));
  if (!file) return { ok: false, status: 404, error: `No post exists with slug "${slug}".` };
  const raw = file.content;

  const record = await getAdminPostRow(env, slug);
  const state = readState(raw);

  return {
    ok: true,
    data: {
      slug,
      raw,
      headSha: await currentHead(env),
      draft: state.draft,
      // Answered from the file, so a caller learns it before meeting a 403.
      firstPublished: state.firstPublished,
      operatorMayPublish: state.firstPublished !== null,
      html: record?.html ?? null,
      readingTimeMinutes: record?.readingTimeMinutes ?? null,
    },
  };
}

async function savePostTool(
  env: OperatorEnv,
  actor: Actor,
  args: Record<string, unknown>,
): Promise<ToolResult> {
  const parsed = readSlug(args, "save_post");
  if (parsed.error) return { ok: false, status: 400, error: parsed.error };
  const slug = parsed.slug;
  const raw = typeof args.raw === "string" ? args.raw : "";
  if (!raw.trim()) {
    return {
      ok: false,
      status: 400,
      error: "save_post requires `raw`: the complete markdown file, frontmatter included.",
    };
  }

  // No `expectedHeadSha` means unconditional, deliberately: an agent has no page to reload.
  const expectedHeadSha =
    typeof args.expectedHeadSha === "string" && args.expectedHeadSha
      ? args.expectedHeadSha
      : null;

  const existing = await readFile(env, postPath(slug));
  const isNew = args.isNew === undefined ? existing === null : args.isNew === true;

  const result = await savePost(env, { slug, raw, expectedHeadSha, isNew, actor });

  return {
    ok: true,
    data: {
      slug,
      commitSha: result.commitSha,
      unchanged: result.unchanged,
      ...(result.unchanged ? { note: UNCHANGED_NOTE } : {}),
      created: isNew,
      draft: result.record.draft,
      published: result.published,
      firstPublished: result.firstPublished,
      // The AI index cannot fail a save, so its outcome is reported rather than raised.
      askSync: result.askSync,
      // Other posts' related and backlinks lists this save rewrote, or why it could not: reported, never raised.
      neighbours: result.neighbours,
      purged: result.purged,
    },
  };
}

async function getProcedureTool(env: OperatorEnv, args: Record<string, unknown>): Promise<ToolResult> {
  const parsed = readSlug(args, "get_procedure");
  if (parsed.error) return { ok: false, status: 400, error: parsed.error };
  const procedure = await readProcedure(env, parsed.slug);
  if (!procedure) return { ok: false, status: 404, error: `No procedure exists with slug "${parsed.slug}".` };
  return { ok: true, data: { ...procedure, headSha: await currentHead(env) } };
}

async function getPublicationTool(env: OperatorEnv, args: Record<string, unknown>): Promise<ToolResult> {
  const slug = String(args.slug ?? "").trim();
  if (!slug || !SLUG_PATTERN.test(slug)) {
    return { ok: false, status: 400, error: "get_publication requires a lowercase kebab-case slug." };
  }
  const file = await readFile(env, publicationPath(slug));
  if (!file) return { ok: false, status: 404, error: `No publication exists with slug "${slug}".` };
  const compiled = await compilePublicationFor(env, slug, file.content);
  return {
    ok: true,
    data: {
      slug,
      raw: file.content,
      headSha: await currentHead(env),
      draft: compiled.ok ? compiled.draft : fileIsDraft(file.content),
      record: compiled.ok ? compiled.record : null,
      errors: compiled.ok ? [] : compiled.errors,
    },
  };
}

/**
 * The weekly refresh the watchdog calls. Every failure is returned, never swallowed: a partial refresh is a
 * 502 whose detail names each DOI that kept its old count, and the counts that did refresh are stored.
 */
async function refreshCitationsTool(env: OperatorEnv, args: Record<string, unknown>): Promise<ToolResult> {
  const published = (await listPublishedPublications(env)).flatMap((p) => (p.doi ? [p.doi] : []));
  let dois: string[];
  if (args.dois === undefined) {
    dois = published;
  } else if (Array.isArray(args.dois) && args.dois.every((d) => typeof d === "string" && d.trim() !== "")) {
    // Only a paper the site lists has a count to show, so any other DOI would store an orphan row and
    // spend an outbound call (Grok audit 2026-10-21, DE-7).
    const key = (doi: string) => doi.trim().toLowerCase();
    const listed = new Set(published.map(key));
    const unlisted = (args.dois as string[]).filter((d) => !listed.has(key(d)));
    if (unlisted.length > 0) {
      return {
        ok: false,
        status: 400,
        error: `refresh_citations refreshes only published papers' DOIs; not published: ${unlisted.slice(0, 5).join(", ")}.`,
      };
    }
    dois = [...new Set((args.dois as string[]).map(key))];
  } else {
    return { ok: false, status: 400, error: "refresh_citations takes dois as a list of DOI strings, or none." };
  }
  const result = await refreshCitations(env, dois);
  if (result.failures.length > 0) {
    return {
      ok: false,
      status: 502,
      error: `${result.failures.length} of ${dois.length} citation counts did not refresh; each kept the count it had.`,
      detail: result,
    };
  }
  return { ok: true, data: result };
}

async function saveProcedureTool(
  env: OperatorEnv,
  actor: Actor,
  args: Record<string, unknown>,
): Promise<ToolResult> {
  const parsed = readSlug(args, "save_procedure");
  if (parsed.error) return { ok: false, status: 400, error: parsed.error };
  const raw = typeof args.raw === "string" ? args.raw : "";
  if (!raw.trim()) {
    return { ok: false, status: 400, error: "save_procedure requires `raw`: the complete procedure file." };
  }
  const expectedHeadSha =
    typeof args.expectedHeadSha === "string" && args.expectedHeadSha ? args.expectedHeadSha : null;
  const isNew =
    args.isNew === undefined ? (await readFile(env, procedurePath(parsed.slug))) === null : args.isNew === true;
  return { ok: true, data: await saveProcedure(env, { slug: parsed.slug, raw, expectedHeadSha, isNew, actor }) };
}

async function getCvTool(env: OperatorEnv, args: Record<string, unknown>): Promise<ToolResult> {
  const slug = String(args.slug ?? "").trim();
  if (!slug) return { ok: false, status: 400, error: "get_cv requires a slug, such as grants or profile." };
  const file = await readCvFile(env, slug);
  if (!file) return { ok: false, status: 404, error: `No file exists for the CV file "${slug}".` };
  return { ok: true, data: { ...file, headSha: await currentHead(env) } };
}

async function getDictionaryTool(env: OperatorEnv, args: Record<string, unknown>): Promise<ToolResult> {
  const key = String(args.key ?? "").trim();
  if (!key || !SLUG_PATTERN.test(key)) {
    return { ok: false, status: 400, error: "get_dictionary requires a lowercase kebab-case key, such as capsid." };
  }
  const entry = await readDictionary(env, key);
  if (!entry) return { ok: false, status: 404, error: `No dictionary entry has the key "${key}".` };
  return { ok: true, data: { ...entry, headSha: await currentHead(env) } };
}

async function getPageTool(env: OperatorEnv, args: Record<string, unknown>): Promise<ToolResult> {
  const path = String(args.path ?? "").trim();
  if (!path) return { ok: false, status: 400, error: "get_page requires a path, such as /research/phages." };
  const page = await readPage(env, path);
  if (!page) return { ok: false, status: 404, error: `No file exists for the page "${path}".` };
  return { ok: true, data: { ...page, headSha: await currentHead(env) } };
}

async function getLlmsTool(env: OperatorEnv): Promise<ToolResult> {
  const llms = await readLlms(env);
  if (!llms) return { ok: false, status: 404, error: "No content/llms.txt exists in the repository." };
  return { ok: true, data: { ...llms, headSha: await currentHead(env) } };
}

async function getPhageTool(env: OperatorEnv, args: Record<string, unknown>): Promise<ToolResult> {
  const slug = String(args.slug ?? "").trim();
  if (!PHAGE_SLUG.test(slug)) {
    return { ok: false, status: 400, error: "get_phage requires a slug that is a phage's name in lower case, such as acorn15." };
  }
  const phage = await readPhage(env, slug);
  if (!phage) return { ok: false, status: 404, error: `No file exists for the phage ${slug}.` };
  return { ok: true, data: { ...phage, headSha: await currentHead(env) } };
}

async function getRegistryTool(env: OperatorEnv, args: Record<string, unknown>): Promise<ToolResult> {
  const slug = String(args.slug ?? "").trim();
  const address = parseRegistrySlug(slug);
  if (!address) {
    return { ok: false, status: 400, error: "get_registry requires a slug <kind>/<id> in lower-case letters, digits and hyphens, such as primer/m13-forward." };
  }
  if (!Object.hasOwn(KINDS, address.kind)) {
    return { ok: false, status: 400, error: `"${address.kind}" is not a registry kind; the kinds are ${Object.keys(KINDS).join(", ") || "none yet"}.` };
  }
  const item = await readRegistryItem(env, slug);
  if (!item) return { ok: false, status: 404, error: `No file exists for the registry item ${slug}.` };
  return { ok: true, data: { ...item, headSha: await currentHead(env) } };
}

async function getRosterTool(env: OperatorEnv, args: Record<string, unknown>): Promise<ToolResult> {
  const slug = String(args.slug ?? "").trim();
  if (!COHORT_SLUG.test(slug)) {
    return { ok: false, status: 400, error: "get_roster requires a slug that is a cohort's four-digit year, such as 2025." };
  }
  const cohort = await readRoster(env, slug);
  if (!cohort) return { ok: false, status: 404, error: `No file exists for the ${slug} cohort.` };
  return { ok: true, data: { ...cohort, headSha: await currentHead(env) } };
}

async function deletePostTool(
  env: OperatorEnv,
  actor: Actor,
  args: Record<string, unknown>,
): Promise<ToolResult> {
  const parsed = readSlug(args, "delete_post");
  if (parsed.error) return { ok: false, status: 400, error: parsed.error };
  const slug = parsed.slug;

  const result = await deletePost(env, {
    slug,
    expectedHeadSha:
      typeof args.expectedHeadSha === "string" && args.expectedHeadSha
        ? args.expectedHeadSha
        : null,
    actor,
  });

  return {
    ok: true,
    data: {
      slug,
      commitSha: result.commitSha,
      purged: result.purged,
      askRemoval: result.askRemoval,
    },
  };
}