metatroncubeswdev 32563e87f9 AI runs on our own model server (Ollama): address and token in Server settings
With a model server address set, runs send every model slot (opus/sonnet/haiku/subagent)
to the chosen model and never send the Anthropic key there. Tested locally with
qwen3.6:35b-a3b through the Agent SDK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 03:22:27 -04:00

217 lines
10 KiB
JavaScript

// Runs the project's Claude Code skills (account-audit, weekly-plan, weekly-report, …) headlessly
// through the Claude Agent SDK, one job at a time, with a strict tool allow-list.
// Publishing tools are always blocked: nothing goes live from the dashboard.
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import { query } from "@anthropic-ai/claude-agent-sdk";
import { PROJECT_ROOT, DATA_DIR, AI } from "./config.js";
import { getClient, listDocs, safeSlug } from "./clients.js";
import { toPdf } from "../../tools/report-pdf/build.mjs";
const JOB_DIR = path.join(DATA_DIR, "jobs");
fs.mkdirSync(JOB_DIR, { recursive: true });
export const JOB_TYPES = {
"account-audit": { label: "Account audit", needsClient: true, prompt: (c) => `/account-audit ${c.name}` },
"weekly-plan": { label: "Weekly content plan", needsClient: true, prompt: (c) => `/weekly-plan ${c.name}` },
"month-plan": { label: "Next month's content calendar", needsClient: true, prompt: (c) => {
const d = new Date(); const next = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1));
const month = next.toLocaleString("en-US", { month: "long", year: "numeric", timeZone: "UTC" });
return `/weekly-plan ${c.name}
Plan all of ${month}, not just next week: every week that starts in ${month}, one calendar file per week (clients/${c.slug}/calendar/YYYY-Www.md). Check content_plan first so nothing is doubled, and add every planned post to the dashboard Calendar with plan_add (status idea or brief).`;
} },
"weekly-report": { label: "Weekly report", needsClient: true, prompt: (c) => `/weekly-report ${c.name}` },
"assign-tasks": { label: "Assign this week's tasks", needsClient: false, prompt: () => `/assign-tasks` },
custom: { label: "Custom request", needsClient: false, prompt: (c, text) => (c ? `Client: ${c.name} (clients/${c.slug}/).\n\n${text}` : text) },
};
const HEADLESS_RULES = `You are running headlessly from the agency dashboard. Nobody can answer questions mid-run.
- Don't stop to ask. Make sensible assumptions, finish the job, and list open questions for the team inside the files you write (and in your final message).
- Never publish, schedule or post anything, and never create or change records in Odoo from this run.
- Only write inside clients/, team/ and reports/. Export finished client-facing reports to PDF with the project's PDF builder.
- End with a short summary: what you produced (file paths) and anything the team must check.`;
const READ_TOOLS = [
"Read", "Glob", "Grep", "WebSearch", "WebFetch", "Skill", "TodoWrite",
"Edit(clients/**)", "Write(clients/**)", "Edit(team/**)", "Write(team/**)", "Edit(reports/**)", "Write(reports/**)",
"Bash(node tools/report-pdf/build.mjs:*)",
"mcp__social-manager__list_accounts", "mcp__social-manager__instagram_insights", "mcp__social-manager__instagram_recent_posts",
"mcp__social-manager__instagram_comments", "mcp__social-manager__facebook_page_insights", "mcp__social-manager__facebook_recent_posts",
"mcp__social-manager__audience_demographics", "mcp__social-manager__instagram_mentions", "mcp__social-manager__competitor_lookup", "mcp__social-manager__competitors",
"mcp__social-manager__list_connections", "mcp__social-manager__suggested_post_times", "mcp__social-manager__content_library", "mcp__social-manager__brand_kit",
"mcp__social-manager__caption_check", "mcp__social-manager__content_plan",
// Calendar items are plans for the team (idea → brief → …), never posts: nothing is scheduled or published from a run.
"mcp__social-manager__plan_add", "mcp__social-manager__plan_update",
"mcp__odoo-mcp__search_records", "mcp__odoo-mcp__read_records", "mcp__odoo-mcp__read_record_by_id", "mcp__odoo-mcp__count_records",
];
const BLOCKED_TOOLS = ["mcp__social-manager__instagram_publish", "mcp__social-manager__facebook_publish"];
const jobs = new Map();
const queue = [];
let running = null;
const jobFile = (id) => path.join(JOB_DIR, `${id}.json`);
const persist = (job) => fs.writeFileSync(jobFile(job.id), JSON.stringify({ ...job, abort: undefined }));
// Load history; anything that was mid-run when the server stopped is marked interrupted.
for (const f of fs.readdirSync(JOB_DIR).filter((x) => x.endsWith(".json"))) {
try {
const job = JSON.parse(fs.readFileSync(path.join(JOB_DIR, f), "utf8"));
if (job.status === "running" || job.status === "queued") Object.assign(job, { status: "interrupted", finishedAt: job.finishedAt || new Date().toISOString() });
jobs.set(job.id, job);
} catch { /* ignore corrupt job files */ }
}
const publicJob = ({ abort, ...job }, { withLog = false } = {}) => (withLog ? job : { ...job, log: undefined, logLines: job.log?.length || 0 });
export function listJobs({ slug } = {}) {
return [...jobs.values()]
.filter((j) => !slug || j.slug === slug)
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
.slice(0, 100)
.map((j) => publicJob(j));
}
export function getJob(id) {
const job = jobs.get(id);
return job ? publicJob(job, { withLog: true }) : null;
}
export function createJob({ type, slug, text }, user) {
const def = JOB_TYPES[type];
if (!def) throw Object.assign(new Error("Unknown job type."), { status: 400 });
const client = slug ? (safeSlug(slug), getClient(slug)) : null;
if (def.needsClient && !client) throw Object.assign(new Error("Pick a client."), { status: 400 });
if (type === "custom" && !String(text || "").trim()) throw Object.assign(new Error("Describe what you need."), { status: 400 });
const job = {
id: crypto.randomUUID(),
type,
label: def.label,
slug: client?.slug || null,
clientName: client?.name || null,
prompt: def.prompt(client, String(text || "").trim()),
status: "queued",
createdBy: user.email,
createdAt: new Date().toISOString(),
log: [],
};
jobs.set(job.id, job);
persist(job);
queue.push(job.id);
pump();
return publicJob(job);
}
export function cancelJob(id) {
const job = jobs.get(id);
if (!job) return null;
if (job.status === "queued") {
queue.splice(queue.indexOf(id), 1);
Object.assign(job, { status: "cancelled", finishedAt: new Date().toISOString() });
persist(job);
} else if (job.status === "running") job.abort?.abort();
return publicJob(job);
}
function log(job, kind, text) {
job.log.push({ t: new Date().toISOString(), kind, text: String(text).slice(0, 4000) });
if (job.log.length % 5 === 0) persist(job);
}
function describeTool(block) {
const i = block.input || {};
const target = i.file_path || i.path || i.pattern || i.command || i.skill || i.query || i.url || i.account || "";
return `${block.name.replace(/^mcp__([^_]+(?:-[^_]+)*)__/, "$1 · ")}${target ? ` — ${String(target).replace(PROJECT_ROOT, "").replace(/^[\\/]+/, "").replace(/\\/g, "/").slice(0, 160)}` : ""}`;
}
function docStamp(slug) {
if (!slug) return new Map();
return new Map(listDocs(slug).map((d) => [d.path, d.updatedAt]));
}
/** Runs on our own model server (Ollama etc.): every model slot points at the chosen model, and the Anthropic key stays home. */
function runEnv() {
if (!process.env.ANTHROPIC_BASE_URL) return undefined;
const env = { ...process.env, ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN || "ollama", CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1" };
delete env.ANTHROPIC_API_KEY;
for (const k of ["ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL", "CLAUDE_CODE_SUBAGENT_MODEL"]) env[k] = AI.model;
return env;
}
async function run(job) {
Object.assign(job, { status: "running", startedAt: new Date().toISOString() });
job.abort = new AbortController();
persist(job);
const before = docStamp(job.slug);
log(job, "info", `Started: ${job.prompt.split("\n")[0]}`);
try {
for await (const msg of query({
prompt: job.prompt,
options: {
cwd: PROJECT_ROOT,
settingSources: ["project"],
skills: "all",
model: AI.model,
maxTurns: AI.maxTurns,
env: runEnv(),
permissionMode: "dontAsk",
allowedTools: READ_TOOLS,
disallowedTools: BLOCKED_TOOLS,
abortController: job.abort,
systemPrompt: { type: "preset", preset: "claude_code", append: HEADLESS_RULES },
stderr: (data) => { if (/error/i.test(data)) log(job, "stderr", data.trim()); },
},
})) {
if (msg.type === "assistant") {
for (const block of msg.message?.content || []) {
if (block.type === "text" && block.text.trim()) log(job, "text", block.text.trim());
else if (block.type === "tool_use") log(job, "tool", describeTool(block));
}
} else if (msg.type === "result") {
job.costUsd = msg.total_cost_usd ?? null;
job.turns = msg.num_turns ?? null;
if (msg.subtype === "success" && !msg.is_error) {
job.result = msg.result;
job.status = "succeeded";
} else {
job.status = "failed";
job.error = msg.result || msg.subtype;
}
}
}
if (job.status === "running") job.status = "succeeded";
} catch (e) {
job.status = job.abort.signal.aborted ? "cancelled" : "failed";
job.error = e.message;
log(job, "error", e.message);
}
// Which documents changed? Make sure each changed client/internal report has a fresh PDF.
if (job.slug) {
const changed = listDocs(job.slug).filter((d) => d.kind !== "setup" && before.get(d.path) !== d.updatedAt);
job.files = changed.map((d) => d.path);
for (const d of changed.filter((x) => (x.kind === "audit" || x.kind === "report") && (!x.pdf || x.pdf.stale))) {
try {
toPdf(path.join(safeSlug(job.slug), d.path));
log(job, "info", `PDF exported: ${d.path.replace(/\.md$/, ".pdf")}`);
} catch (e) {
log(job, "error", `PDF export failed for ${d.path}: ${e.message}`);
}
}
}
job.finishedAt = new Date().toISOString();
log(job, "info", `Finished: ${job.status}${job.costUsd != null ? ` · $${job.costUsd.toFixed(2)}` : ""}`);
delete job.abort;
persist(job);
}
async function pump() {
if (running || !queue.length) return;
const job = jobs.get(queue.shift());
if (!job || job.status !== "queued") return pump();
running = job;
try { await run(job); } finally { running = null; pump(); }
}