katman-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +13 -0
- package/README.md +160 -0
- package/dist/cli.js +71 -0
- package/dist/context.js +43 -0
- package/dist/crawlers.js +86 -0
- package/dist/index.js +25 -0
- package/dist/license.js +448 -0
- package/dist/meta.js +28 -0
- package/dist/net.js +128 -0
- package/dist/server.js +17 -0
- package/dist/state.js +81 -0
- package/dist/tools/account/account.js +64 -0
- package/dist/tools/account/cli.js +21 -0
- package/dist/tools/account/index.js +48 -0
- package/dist/tools/account/sync.js +142 -0
- package/dist/tools/account/upsell.js +290 -0
- package/dist/tools/crawl/audit/analyze.js +719 -0
- package/dist/tools/crawl/audit/bots.js +114 -0
- package/dist/tools/crawl/audit/checks.js +126 -0
- package/dist/tools/crawl/audit/html.js +382 -0
- package/dist/tools/crawl/audit/index.js +364 -0
- package/dist/tools/crawl/audit/model.js +1 -0
- package/dist/tools/crawl/audit/report.js +199 -0
- package/dist/tools/crawl/audit/robots.js +199 -0
- package/dist/tools/crawl/audit/sitemap.js +147 -0
- package/dist/tools/crawl/cli.js +106 -0
- package/dist/tools/crawl/common/advice.js +61 -0
- package/dist/tools/crawl/common/findings.js +57 -0
- package/dist/tools/crawl/common/routes.js +80 -0
- package/dist/tools/crawl/common/text.js +89 -0
- package/dist/tools/crawl/common/urls.js +64 -0
- package/dist/tools/crawl/index.js +226 -0
- package/dist/tools/crawl/indexnow.js +271 -0
- package/dist/tools/crawl/keywords.js +178 -0
- package/dist/tools/crawl/scan/framework.js +645 -0
- package/dist/tools/crawl/scan/index.js +175 -0
- package/dist/tools/crawl/scan/project.js +180 -0
- package/dist/tools/crawl/scan/routes.js +354 -0
- package/dist/tools/crawl/scan/signals.js +414 -0
- package/dist/tools/method/cli.js +42 -0
- package/dist/tools/method/core/brief.js +302 -0
- package/dist/tools/method/core/extract.js +435 -0
- package/dist/tools/method/core/generate.js +504 -0
- package/dist/tools/method/core/lint.js +556 -0
- package/dist/tools/method/core/plan.js +405 -0
- package/dist/tools/method/core/prompt.js +150 -0
- package/dist/tools/method/core/questions.js +196 -0
- package/dist/tools/method/core/report.js +182 -0
- package/dist/tools/method/core/research-run.js +148 -0
- package/dist/tools/method/core/research.js +448 -0
- package/dist/tools/method/core/snippets.js +292 -0
- package/dist/tools/method/core/start.js +277 -0
- package/dist/tools/method/data/anatomy.js +141 -0
- package/dist/tools/method/data/appendices.js +202 -0
- package/dist/tools/method/data/cloudflare.js +192 -0
- package/dist/tools/method/data/evidence.js +137 -0
- package/dist/tools/method/data/geo-jobs.js +128 -0
- package/dist/tools/method/data/index.js +19 -0
- package/dist/tools/method/data/landing.js +67 -0
- package/dist/tools/method/data/lessons.js +127 -0
- package/dist/tools/method/data/measurement.js +142 -0
- package/dist/tools/method/data/method.js +141 -0
- package/dist/tools/method/data/offsite.js +148 -0
- package/dist/tools/method/data/pages.js +167 -0
- package/dist/tools/method/data/prompts.js +633 -0
- package/dist/tools/method/data/publish.js +113 -0
- package/dist/tools/method/data/render.js +117 -0
- package/dist/tools/method/data/research.js +131 -0
- package/dist/tools/method/data/roadmap.js +105 -0
- package/dist/tools/method/data/traps.js +74 -0
- package/dist/tools/method/data/verify.js +88 -0
- package/dist/tools/method/data/viral-list.js +39 -0
- package/dist/tools/method/index.js +209 -0
- package/dist/tools/method/lib/calls.js +53 -0
- package/dist/tools/method/lib/framework.js +525 -0
- package/dist/tools/method/lib/l10n.js +17 -0
- package/dist/tools/method/lib/routes.js +118 -0
- package/dist/tools/method/lib/state.js +454 -0
- package/dist/tools/method/lib/text.js +165 -0
- package/dist/tools/method/mcp/prompts.js +79 -0
- package/dist/tools/method/mcp/resources.js +50 -0
- package/dist/tools/visibility/analyze.js +463 -0
- package/dist/tools/visibility/cli.js +86 -0
- package/dist/tools/visibility/estimate.js +105 -0
- package/dist/tools/visibility/index.js +108 -0
- package/dist/tools/visibility/judge.js +156 -0
- package/dist/tools/visibility/manual.js +275 -0
- package/dist/tools/visibility/models.js +277 -0
- package/dist/tools/visibility/monitoring.js +165 -0
- package/dist/tools/visibility/openrouter.js +449 -0
- package/dist/tools/visibility/render.js +295 -0
- package/dist/tools/visibility/run.js +684 -0
- package/dist/tools/visibility/summary.js +164 -0
- package/dist/types.js +3 -0
- package/dist/util.js +92 -0
- package/package.json +54 -0
- package/server.json +34 -0
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
import { listFiles, loadConfig, loadResearch, logEvent, readJson, stamp, writeJson, writeText } from "../../state.js";
|
|
2
|
+
import { mapLimit } from "../../net.js";
|
|
3
|
+
import { truncate } from "../../util.js";
|
|
4
|
+
import { analyzeAnswer, buildContext, canonicalCompetitor, extractLinks, mentions, normalizeHost, } from "./analyze.js";
|
|
5
|
+
import { estimateRun, money, taskCost } from "./estimate.js";
|
|
6
|
+
import { buildJudgeBody, buildJudgeMessages, parseJudge } from "./judge.js";
|
|
7
|
+
import { engineOf, profileFor, resolveJudge, resolvePanel, vendorOf } from "./models.js";
|
|
8
|
+
import { OpenRouterError, buildAnswerBody, costOf, fetchKeyInfo, getApiKey, isInCatalog, loadCatalog, parseCompletion, postChat, priceOf, redact, searchUnitPrice, tuningFor, } from "./openrouter.js";
|
|
9
|
+
import { PROXY_NOTE, renderDryRun, renderMeasurements, renderRunReport, shortFamily } from "./render.js";
|
|
10
|
+
import { addDays, diffRuns, summarize } from "./summary.js";
|
|
11
|
+
import { composeOneSentence } from "../../util.js";
|
|
12
|
+
export const DEFAULT_MAX_COST_USD = 3;
|
|
13
|
+
export const DEFAULT_TEMPERATURE = 0.7;
|
|
14
|
+
export const MAX_SAMPLES = 5;
|
|
15
|
+
const ACTIVE = new Map();
|
|
16
|
+
function activeMessage(a) {
|
|
17
|
+
return `A visibility run started at ${a.started.slice(11, 16)} UTC is still in progress (${a.done} of ${a.total} answers done). Its results will be saved to .katman/runs/${a.id}.json and .katman/measurements.md when it finishes; don't start another one (it would spend the credits twice).`;
|
|
18
|
+
}
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Research checks
|
|
21
|
+
const isFill = (s) => !s || !s.trim() || /\[FILL\]/i.test(s);
|
|
22
|
+
export function researchProblems(r) {
|
|
23
|
+
if (!r)
|
|
24
|
+
return ["No .katman/research.json yet."];
|
|
25
|
+
const p = [];
|
|
26
|
+
if (isFill(r.brand?.name))
|
|
27
|
+
p.push("research.json has no brand name (brand.name).");
|
|
28
|
+
if (!normalizeHost(r.site?.domain || r.site?.url || ""))
|
|
29
|
+
p.push("research.json has no site domain (site.domain / site.url).");
|
|
30
|
+
const usable = (r.questions ?? []).filter((q) => q && !isFill(q.text));
|
|
31
|
+
if (!usable.length)
|
|
32
|
+
p.push("research.json has no question set (questions: 12 unbranded + 2 diagnostic).");
|
|
33
|
+
return p;
|
|
34
|
+
}
|
|
35
|
+
export function researchHelp(problems) {
|
|
36
|
+
return [
|
|
37
|
+
"# Visibility run: research needed first",
|
|
38
|
+
"",
|
|
39
|
+
...problems.map((x) => `- ${x}`),
|
|
40
|
+
"",
|
|
41
|
+
"The run asks the assistants your question set: 12 questions that don't name the brand (situation, tool, best-of, alternative, language/market, problem) plus 2 diagnostics (\"What is [brand]?\", \"What is [brand] ([domain])?\"), book ch. 15.",
|
|
42
|
+
"Next: call `katman_research` with `generate_questions: true` (it needs the brand name, domain and your 10–12 customer situations), then run `katman_visibility_run` again with `dry_run: true`.",
|
|
43
|
+
].join("\n");
|
|
44
|
+
}
|
|
45
|
+
export function identityOf(r) {
|
|
46
|
+
const o = r.brand.oneLiner;
|
|
47
|
+
const composed = composeOneSentence(o, 2) ?? "";
|
|
48
|
+
const sentence = !isFill(r.brand.sentence) ? r.brand.sentence : composed || undefined;
|
|
49
|
+
return { sentence, notX: isFill(r.brand.notX) ? undefined : r.brand.notX, category: isFill(r.brand.category) ? undefined : r.brand.category };
|
|
50
|
+
}
|
|
51
|
+
export function keyHelp(e) {
|
|
52
|
+
return [
|
|
53
|
+
"## Set up an OpenRouter key (runs use your own credits; Katman never stores or prints the key)",
|
|
54
|
+
`1. Create a key at https://openrouter.ai/keys and add credit${e ? `: a run like this costs about ${money(e.total.expected)}, at most ${money(e.total.high)}` : ""}. Set a credit limit on the key to cap what it can spend.`,
|
|
55
|
+
"2. Put it in this MCP server's `env` and restart the server:",
|
|
56
|
+
' - Cursor (`.cursor/mcp.json` or `~/.cursor/mcp.json`): `"katman": {"command": "npx", "args": ["-y", "katman-mcp"], "env": {"OPENROUTER_API_KEY": "${env:OPENROUTER_API_KEY}"}}` with the key exported in your shell (or the key itself in place of `${env:…}`).',
|
|
57
|
+
" - Claude Code: `claude mcp add katman --scope user -e OPENROUTER_API_KEY=sk-or-... -- npx -y katman-mcp`",
|
|
58
|
+
' - VS Code (`.vscode/mcp.json`): `"env": {"OPENROUTER_API_KEY": "${input:openrouter_api_key}"}` with a password `inputs` entry.',
|
|
59
|
+
" - Windsurf/Devin, Codex, Gemini CLI, Zed, Cline: add `OPENROUTER_API_KEY` under the server's `env` (Gemini CLI strips inherited *KEY* variables, so list it explicitly). `KATMAN_OPENROUTER_API_KEY` also works.",
|
|
60
|
+
"3. Never paste the key into the chat or commit it.",
|
|
61
|
+
"",
|
|
62
|
+
"No-cost path: `katman_manual_check` gives the questions to ask ChatGPT, Claude, Gemini, Perplexity, Copilot or Grok yourself (private window, logged out, web search on) and records the answers you paste back.",
|
|
63
|
+
].join("\n");
|
|
64
|
+
}
|
|
65
|
+
const clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, n));
|
|
66
|
+
async function readRuns(root) {
|
|
67
|
+
const files = (await listFiles(root, "runs")).filter((f) => f.endsWith(".json"));
|
|
68
|
+
const out = [];
|
|
69
|
+
for (const f of files) {
|
|
70
|
+
const r = await readJson(root, `runs/${f}`);
|
|
71
|
+
if (r && Array.isArray(r.answers) && typeof r.ts === "string")
|
|
72
|
+
out.push({ file: f, run: r });
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
/** Latest API (not manual) run, optionally before a given run id. */
|
|
77
|
+
export async function latestApiRun(root, beforeId) {
|
|
78
|
+
const runs = (await readRuns(root)).filter(({ file, run }) => !file.startsWith("manual-") && run.answers.some((a) => a.source === "api"));
|
|
79
|
+
runs.sort((a, b) => a.run.ts.localeCompare(b.run.ts));
|
|
80
|
+
for (let i = runs.length - 1; i >= 0; i--) {
|
|
81
|
+
if (beforeId && runs[i].run.id >= beforeId)
|
|
82
|
+
continue;
|
|
83
|
+
return runs[i].run;
|
|
84
|
+
}
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
function lastActualFor(prev, spec) {
|
|
88
|
+
if (!prev || !prev.models.some((m) => m.id === spec.id && m.search === spec.search))
|
|
89
|
+
return undefined;
|
|
90
|
+
const costs = prev.answers
|
|
91
|
+
.filter((a) => !a.error && a.source === "api" && a.family === spec.family && typeof a.costUsd === "number")
|
|
92
|
+
.map((a) => a.costUsd);
|
|
93
|
+
return costs.length >= 3 ? costs.reduce((s, x) => s + x, 0) / costs.length : undefined;
|
|
94
|
+
}
|
|
95
|
+
export function estimateFor(models, p) {
|
|
96
|
+
return estimateRun({
|
|
97
|
+
models: models.map((spec) => {
|
|
98
|
+
const price = priceOf(p.catalog, spec.id);
|
|
99
|
+
return { spec, price, profile: profileFor(spec).profile, searchUnit: searchUnitPrice(spec, price), lastActual: lastActualFor(p.previous, spec) };
|
|
100
|
+
}),
|
|
101
|
+
questions: p.questions.length,
|
|
102
|
+
scoredQuestions: p.questions.filter((q) => q.type !== "diagnostic" && !p.branded.has(q.id)).length,
|
|
103
|
+
samples: p.samples,
|
|
104
|
+
judge: p.judgeModel ? { model: p.judgeModel, price: priceOf(p.catalog, p.judgeModel) } : undefined,
|
|
105
|
+
followUps: p.followUps,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
export async function buildPlan(root, args, deps = {}) {
|
|
109
|
+
const research = await loadResearch(root);
|
|
110
|
+
const problems = researchProblems(research);
|
|
111
|
+
if (!research || problems.length)
|
|
112
|
+
return { error: { text: researchHelp(problems), isError: true, structured: { status: "research_needed", problems } } };
|
|
113
|
+
const config = await loadConfig(root);
|
|
114
|
+
const notes = [];
|
|
115
|
+
const ctx = buildContext(research);
|
|
116
|
+
const domain = normalizeHost(research.site.domain || research.site.url);
|
|
117
|
+
let questions = (research.questions ?? []).filter((q) => q && typeof q.text === "string" && q.text.trim());
|
|
118
|
+
const fill = questions.filter((q) => isFill(q.text));
|
|
119
|
+
if (fill.length)
|
|
120
|
+
notes.push(`Skipped ${fill.length} question(s) still containing [FILL]: ${fill.map((q) => q.id).join(", ")}.`);
|
|
121
|
+
questions = questions.filter((q) => !isFill(q.text));
|
|
122
|
+
if (args.question_ids?.length) {
|
|
123
|
+
const want = new Set(args.question_ids.map((s) => s.trim()));
|
|
124
|
+
const unknown = [...want].filter((id) => !questions.some((q) => q.id === id));
|
|
125
|
+
if (unknown.length) {
|
|
126
|
+
return { error: { text: `Unknown question id(s): ${unknown.join(", ")}. Available: ${questions.map((q) => q.id).join(", ")}.`, isError: true } };
|
|
127
|
+
}
|
|
128
|
+
questions = questions.filter((q) => want.has(q.id));
|
|
129
|
+
}
|
|
130
|
+
const branded = new Set(questions.filter((q) => q.type !== "diagnostic" && mentions(ctx, q.text)).map((q) => q.id));
|
|
131
|
+
if (branded.size) {
|
|
132
|
+
notes.push(`${[...branded].join(", ")} name${branded.size === 1 ? "s" : ""} the brand, so ${branded.size === 1 ? "it is" : "they are"} scored like a diagnostic (not in the rates). Rewrite without the brand to measure discovery (book ch. 7).`);
|
|
133
|
+
}
|
|
134
|
+
const siteLangs = (research.site.languages ?? []).map((l) => l.toLowerCase().split(/[-_]/)[0]).filter(Boolean);
|
|
135
|
+
const langOf = new Map(questions.map((q) => [q.id, (q.lang || siteLangs[0] || "en").toLowerCase().split(/[-_]/)[0]]));
|
|
136
|
+
const measured = new Set(langOf.values());
|
|
137
|
+
const missing = [...new Set(siteLangs)].filter((l) => !measured.has(l));
|
|
138
|
+
if (missing.length) {
|
|
139
|
+
notes.push(`The site is published in ${[...new Set(siteLangs)].join(", ")} but no question is in ${missing.join(", ")}: a zero there would be unmeasured, not real. Add questions per language with katman_research (set each question's lang).`);
|
|
140
|
+
}
|
|
141
|
+
const multiLanguage = measured.size > 1;
|
|
142
|
+
const fetchImpl = deps.fetchImpl;
|
|
143
|
+
const now = deps.now?.() ?? new Date();
|
|
144
|
+
const catalog0 = await loadCatalog(root, { fetchImpl, now, offline: deps.offline });
|
|
145
|
+
const isAvailable = (slug) => isInCatalog(catalog0, slug);
|
|
146
|
+
const panel = resolvePanel({ input: args.models, config, googleGrounding: !!args.allow_google_grounding, isAvailable });
|
|
147
|
+
if (panel.errors.length)
|
|
148
|
+
return { error: { text: `# Visibility run: model selection problem\n\n${panel.errors.map((e) => `- ${e}`).join("\n")}`, isError: true } };
|
|
149
|
+
if (!panel.models.length)
|
|
150
|
+
return { error: { text: "# Visibility run: no model available\n\nNone of the requested assistant models is available on OpenRouter right now.\n\n" + panel.notes.map((n) => `- ${n}`).join("\n"), isError: true } };
|
|
151
|
+
const judgeModel = args.judge === false ? undefined : resolveJudge(config, isAvailable);
|
|
152
|
+
const withEndpoints = await loadCatalog(root, { fetchImpl, now, offline: deps.offline, endpointIds: panel.models.filter((m) => engineOf(m) === "native").map((m) => m.id) });
|
|
153
|
+
// The second load reads the cache the first one just wrote: keep the first load's provenance.
|
|
154
|
+
const catalog = { ...withEndpoints, source: catalog0.source === "live" ? "live" : withEndpoints.source, warning: catalog0.warning ?? withEndpoints.warning };
|
|
155
|
+
if (catalog.warning)
|
|
156
|
+
notes.push(catalog.warning);
|
|
157
|
+
notes.push(...panel.notes);
|
|
158
|
+
if (panel.models.some((m) => vendorOf(m.id) === "google" && m.search === "native")) {
|
|
159
|
+
notes.push("Google grounding is on (allow_google_grounding). Google's Gemini API grounding terms restrict storing and analysing grounded results; you are responsible for compliance. Katman stores excerpts and sources in .katman/runs/.");
|
|
160
|
+
}
|
|
161
|
+
if (panel.models.some((m) => m.family.includes("Exa search"))) {
|
|
162
|
+
notes.push("Gemini answers use Exa search (a proxy index), not Google grounding: Google's grounding terms forbid caching or analysing grounded results. Set allow_google_grounding: true to use Google's own grounding at your own responsibility.");
|
|
163
|
+
}
|
|
164
|
+
for (const m of panel.models) {
|
|
165
|
+
const ep = catalog.endpoints[m.id];
|
|
166
|
+
if (engineOf(m) === "native" && vendorOf(m.id) !== "perplexity" && ep && ep.tags.length && !ep.nativeSearchTags.length) {
|
|
167
|
+
notes.push(`${m.id}: no OpenRouter endpoint runs native search for it right now; OpenRouter would fall back to Exa.`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const tunings = new Map(panel.models.map((m) => [m.id, tuningFor(m, catalog.models.get(m.id), catalog.endpoints[m.id])]));
|
|
171
|
+
const previous = await latestApiRun(root);
|
|
172
|
+
const samples = clamp(Math.floor(args.samples ?? 1), 1, MAX_SAMPLES);
|
|
173
|
+
const temperature = clamp(args.temperature ?? config.temperature ?? DEFAULT_TEMPERATURE, 0, 2);
|
|
174
|
+
const maxCost = Math.max(0.05, args.max_cost_usd ?? DEFAULT_MAX_COST_USD);
|
|
175
|
+
const concurrency = clamp(Math.floor(args.concurrency ?? 4), 1, 8);
|
|
176
|
+
const followUps = !!args.follow_up_sources;
|
|
177
|
+
const partial = { catalog, questions, branded, samples, followUps, judgeModel, previous };
|
|
178
|
+
const estimate = estimateFor(panel.models, partial);
|
|
179
|
+
return {
|
|
180
|
+
plan: {
|
|
181
|
+
root, research, config, ctx, domain, questions, branded, langOf, multiLanguage, panel, models: panel.models, catalog, tunings,
|
|
182
|
+
judgeModel, estimate, samples, temperature, maxCost, concurrency, followUps, notes, previous,
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// Dry run
|
|
188
|
+
function alternativesFor(plan, args) {
|
|
189
|
+
const out = [];
|
|
190
|
+
const isAvailable = (slug) => isInCatalog(plan.catalog, slug);
|
|
191
|
+
for (const preset of ["economy", "probe"]) {
|
|
192
|
+
if (plan.panel.preset === preset)
|
|
193
|
+
continue;
|
|
194
|
+
const p = resolvePanel({ input: preset, config: plan.config, googleGrounding: !!args.allow_google_grounding, isAvailable });
|
|
195
|
+
if (p.errors.length || !p.models.length)
|
|
196
|
+
continue;
|
|
197
|
+
const e = estimateFor(p.models, plan);
|
|
198
|
+
if (e.total.expected >= plan.estimate.total.expected)
|
|
199
|
+
continue;
|
|
200
|
+
const label = preset === "economy" ? `"economy" (same 5 assistants and indexes, smaller models)` : `"probe" (${p.models.map((m) => shortFamily(m.family)).join(" + ")} only)`;
|
|
201
|
+
out.push({ label, call: `models: "${preset}"`, expected: e.total.expected, high: e.total.high });
|
|
202
|
+
}
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
export async function dryRun(plan, args, deps = {}) {
|
|
206
|
+
const { key, source } = getApiKey(deps.env ?? process.env);
|
|
207
|
+
let info;
|
|
208
|
+
if (key)
|
|
209
|
+
info = await fetchKeyInfo(key, deps.fetchImpl);
|
|
210
|
+
const active = ACTIVE.get(plan.root);
|
|
211
|
+
const e = plan.estimate;
|
|
212
|
+
const text = renderDryRun({
|
|
213
|
+
brand: plan.research.brand.name,
|
|
214
|
+
domain: plan.domain,
|
|
215
|
+
questions: plan.questions,
|
|
216
|
+
scoredCount: plan.questions.filter((q) => q.type !== "diagnostic" && !plan.branded.has(q.id)).length,
|
|
217
|
+
diagnosticCount: plan.questions.filter((q) => q.type === "diagnostic").length,
|
|
218
|
+
brandedCount: plan.branded.size,
|
|
219
|
+
models: plan.models,
|
|
220
|
+
estimate: e,
|
|
221
|
+
maxCost: plan.maxCost,
|
|
222
|
+
samples: plan.samples,
|
|
223
|
+
temperature: plan.temperature,
|
|
224
|
+
concurrency: plan.concurrency,
|
|
225
|
+
followUps: plan.followUps,
|
|
226
|
+
key: { present: !!key, source, limitRemaining: info?.limitRemaining, usage: info?.usage, error: info && !info.ok ? info.error : undefined },
|
|
227
|
+
catalog: { source: plan.catalog.source, fetchedAt: plan.catalog.fetchedAt },
|
|
228
|
+
alternatives: e.total.expected > plan.maxCost || e.total.high > plan.maxCost ? alternativesFor(plan, args) : [],
|
|
229
|
+
notes: plan.notes,
|
|
230
|
+
keyHelp: key ? (info && !info.ok && info.status === 401 ? "The key was rejected by OpenRouter (401). Create a new key at https://openrouter.ai/keys and update the MCP server's env.\n\n" + keyHelp(e) : undefined) : keyHelp(e),
|
|
231
|
+
activeRun: active ? activeMessage(active) : undefined,
|
|
232
|
+
lastRun: plan.previous ? lastRunHeadline(plan.previous) : undefined,
|
|
233
|
+
});
|
|
234
|
+
return {
|
|
235
|
+
text,
|
|
236
|
+
structured: {
|
|
237
|
+
status: "dry_run",
|
|
238
|
+
answers: e.answers,
|
|
239
|
+
questions: plan.questions.length,
|
|
240
|
+
models: plan.models.map((m) => ({ id: m.id, family: m.family, search: m.search })),
|
|
241
|
+
estimateUsd: { low: round(e.total.low), expected: round(e.total.expected), high: round(e.total.high) },
|
|
242
|
+
maxCostUsd: plan.maxCost,
|
|
243
|
+
fitsCap: e.total.expected <= plan.maxCost,
|
|
244
|
+
keyPresent: !!key,
|
|
245
|
+
keyLimitRemainingUsd: info?.limitRemaining ?? null,
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
const round = (n, d = 4) => Math.round(n * 10 ** d) / 10 ** d;
|
|
250
|
+
function lastRunHeadline(r) {
|
|
251
|
+
const b = Object.values(r.summary?.byFamily ?? {});
|
|
252
|
+
const answered = r.summary?.answered ?? 0;
|
|
253
|
+
const citedOf = b.reduce((t, x) => t + x.answered, 0);
|
|
254
|
+
return {
|
|
255
|
+
ts: r.ts,
|
|
256
|
+
answered,
|
|
257
|
+
mentioned: b.reduce((t, x) => t + x.mentioned, 0),
|
|
258
|
+
cited: b.reduce((t, x) => t + x.cited, 0),
|
|
259
|
+
citedOf,
|
|
260
|
+
cost: r.totalCostUsd,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
/** Run id from a timestamp, suffixed (-2, -3…) when a run file with that name already exists. */
|
|
264
|
+
export async function uniqueRunId(root, base) {
|
|
265
|
+
const taken = new Set(await listFiles(root, "runs"));
|
|
266
|
+
let id = base;
|
|
267
|
+
for (let n = 2; taken.has(`${id}.json`); n++)
|
|
268
|
+
id = `${base}-${n}`;
|
|
269
|
+
return id;
|
|
270
|
+
}
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
// Real run
|
|
273
|
+
const FOLLOW_UP = {
|
|
274
|
+
en: "Which articles or pages did you use for this answer? List the URLs.",
|
|
275
|
+
tr: "Bu cevabı yazarken hangi makaleleri veya sayfaları kullandın? URL'lerini listele.",
|
|
276
|
+
de: "Welche Artikel oder Seiten hast du für diese Antwort verwendet? Liste die URLs auf.",
|
|
277
|
+
fr: "Quels articles ou pages as-tu utilisés pour cette réponse ? Liste les URL.",
|
|
278
|
+
es: "¿Qué artículos o páginas usaste para esta respuesta? Enumera las URL.",
|
|
279
|
+
};
|
|
280
|
+
function blankRecord(t, samples) {
|
|
281
|
+
const a = {
|
|
282
|
+
questionId: t.q.id,
|
|
283
|
+
question: t.q.text,
|
|
284
|
+
questionType: t.q.type,
|
|
285
|
+
model: t.spec.id,
|
|
286
|
+
family: t.spec.family,
|
|
287
|
+
source: "api",
|
|
288
|
+
surface: `openrouter:${engineOf(t.spec)}`,
|
|
289
|
+
ts: new Date().toISOString(),
|
|
290
|
+
mentioned: false,
|
|
291
|
+
recommendedFirst: false,
|
|
292
|
+
rank: null,
|
|
293
|
+
cited: false,
|
|
294
|
+
namedProducts: [],
|
|
295
|
+
competitorsNamed: [],
|
|
296
|
+
citations: [],
|
|
297
|
+
answerExcerpt: "",
|
|
298
|
+
lang: t.lang,
|
|
299
|
+
};
|
|
300
|
+
if (samples > 1)
|
|
301
|
+
a.sample = t.sample;
|
|
302
|
+
return a;
|
|
303
|
+
}
|
|
304
|
+
function describeError(e) {
|
|
305
|
+
if (e instanceof OpenRouterError)
|
|
306
|
+
return truncate(`${e.kind}${e.status ? ` ${e.status}` : ""}: ${e.message}`, 220);
|
|
307
|
+
return truncate(redact(e instanceof Error ? e.message : String(e)), 220);
|
|
308
|
+
}
|
|
309
|
+
export async function realRun(plan, deps = {}) {
|
|
310
|
+
const { key } = getApiKey(deps.env ?? process.env);
|
|
311
|
+
if (!key)
|
|
312
|
+
return { text: "# Visibility run: no OpenRouter key\n\n" + keyHelp(plan.estimate), isError: true, structured: { status: "no_key" } };
|
|
313
|
+
const running = ACTIVE.get(plan.root);
|
|
314
|
+
if (running)
|
|
315
|
+
return { text: activeMessage(running), structured: { status: "in_progress", ...running } };
|
|
316
|
+
// Take the lock before the first await, so a retried tool call can't start a second, paid run.
|
|
317
|
+
const now = deps.now ?? (() => new Date());
|
|
318
|
+
const active = { id: "starting", started: now().toISOString(), done: 0, total: plan.samples * plan.questions.length * plan.models.length };
|
|
319
|
+
ACTIVE.set(plan.root, active);
|
|
320
|
+
try {
|
|
321
|
+
return await executeRun(plan, deps, key, active, now);
|
|
322
|
+
}
|
|
323
|
+
finally {
|
|
324
|
+
if (ACTIVE.get(plan.root) === active)
|
|
325
|
+
ACTIVE.delete(plan.root);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
async function executeRun(plan, deps, key, active, now) {
|
|
329
|
+
const e = plan.estimate;
|
|
330
|
+
if (e.total.expected > plan.maxCost) {
|
|
331
|
+
return {
|
|
332
|
+
text: [
|
|
333
|
+
"# Visibility run: not started (over the cost cap)",
|
|
334
|
+
"",
|
|
335
|
+
`Expected cost ${money(e.total.expected)} (range ${money(e.total.low)}–${money(e.total.high)}) is above max_cost_usd ${money(plan.maxCost)}. Nothing was spent.`,
|
|
336
|
+
`Run the dry run to see the options, then ask the user before raising the cap (e.g. max_cost_usd: ${Math.ceil(e.total.high * 2) / 2}) or choosing models: "economy" / "probe".`,
|
|
337
|
+
].join("\n"),
|
|
338
|
+
isError: true,
|
|
339
|
+
structured: { status: "refused_over_cap", expectedUsd: round(e.total.expected), maxCostUsd: plan.maxCost },
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
const fetchImpl = deps.fetchImpl;
|
|
343
|
+
const info = await fetchKeyInfo(key, fetchImpl);
|
|
344
|
+
if (!info.ok && info.status === 401) {
|
|
345
|
+
return { text: "# Visibility run: key rejected\n\nOpenRouter rejected the key (401). Nothing was spent.\n\n" + keyHelp(e), isError: true, structured: { status: "key_rejected" } };
|
|
346
|
+
}
|
|
347
|
+
if (info.ok && typeof info.limitRemaining === "number" && info.limitRemaining < e.total.low) {
|
|
348
|
+
return {
|
|
349
|
+
text: `# Visibility run: not started\n\nThis key has ${money(info.limitRemaining)} of its credit limit left, less than the lowest estimate (${money(e.total.low)}). Raise the key's limit at https://openrouter.ai/keys or add credit, then try again. Nothing was spent.`,
|
|
350
|
+
isError: true,
|
|
351
|
+
structured: { status: "key_limit_too_low", limitRemainingUsd: info.limitRemaining },
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
const started = now();
|
|
355
|
+
const runId = await uniqueRunId(plan.root, stamp(started));
|
|
356
|
+
const tasks = [];
|
|
357
|
+
for (let s = 1; s <= plan.samples; s++) {
|
|
358
|
+
for (const q of plan.questions)
|
|
359
|
+
for (const spec of plan.models)
|
|
360
|
+
tasks.push({ q, spec, sample: s, lang: plan.langOf.get(q.id) ?? "en" });
|
|
361
|
+
}
|
|
362
|
+
active.id = runId;
|
|
363
|
+
active.started = started.toISOString();
|
|
364
|
+
active.total = tasks.length;
|
|
365
|
+
const rows = new Map(e.rows.map((r) => [`${r.spec.id}|${r.spec.search}`, r]));
|
|
366
|
+
const prices = new Map(plan.models.map((m) => [m.id, priceOf(plan.catalog, m.id)]));
|
|
367
|
+
const judgePrice = plan.judgeModel ? priceOf(plan.catalog, plan.judgeModel) : undefined;
|
|
368
|
+
const chatOpts = { fetchImpl, sleepFn: deps.sleepFn };
|
|
369
|
+
const guard = { spent: 0, reserved: 0 };
|
|
370
|
+
const costs = { answers: 0, followUps: 0, judge: 0 };
|
|
371
|
+
const runtime = [];
|
|
372
|
+
const surfaceOf = new Map();
|
|
373
|
+
const triedPlugin = new Set();
|
|
374
|
+
const dead = new Map();
|
|
375
|
+
let stopReason;
|
|
376
|
+
let stopShort;
|
|
377
|
+
let userCancelled = false;
|
|
378
|
+
let judgeFailures = 0;
|
|
379
|
+
let truncated = 0;
|
|
380
|
+
let vetoed = 0;
|
|
381
|
+
const signal = deps.signal;
|
|
382
|
+
const onAbort = () => {
|
|
383
|
+
const reason = String(signal?.reason ?? "");
|
|
384
|
+
if (/time(d)?\s*out|timeout/i.test(reason))
|
|
385
|
+
runtime.push("The MCP client timed out while waiting; the run finished in the background.");
|
|
386
|
+
else
|
|
387
|
+
userCancelled = true;
|
|
388
|
+
};
|
|
389
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
390
|
+
if (signal?.aborted)
|
|
391
|
+
onAbort();
|
|
392
|
+
const identity = identityOf(plan.research);
|
|
393
|
+
const ask = async (spec, messages, lang, maxTokens) => {
|
|
394
|
+
const tuning = plan.tunings.get(spec.id);
|
|
395
|
+
const price = prices.get(spec.id);
|
|
396
|
+
const surface = surfaceOf.get(spec.id) ?? "tool";
|
|
397
|
+
let json;
|
|
398
|
+
try {
|
|
399
|
+
json = await postChat(key, buildAnswerBody(spec, messages, tuning, { temperature: plan.temperature, lang, surface, maxTokens }), chatOpts);
|
|
400
|
+
}
|
|
401
|
+
catch (err) {
|
|
402
|
+
const searchOn = engineOf(spec) !== "none" && vendorOf(spec.id) !== "perplexity";
|
|
403
|
+
// 400s are not billed: any task whose server-tool request is rejected may retry once on the legacy web plugin.
|
|
404
|
+
if (err instanceof OpenRouterError && err.kind === "bad_request" && searchOn && surface === "tool") {
|
|
405
|
+
json = await postChat(key, buildAnswerBody(spec, messages, tuning, { temperature: plan.temperature, lang, surface: "plugin", maxTokens }), chatOpts);
|
|
406
|
+
surfaceOf.set(spec.id, "plugin");
|
|
407
|
+
if (!triedPlugin.has(spec.id)) {
|
|
408
|
+
triedPlugin.add(spec.id);
|
|
409
|
+
runtime.push(`${spec.id}: OpenRouter rejected the web-search server tool (${truncate(err.message, 120)}); used the legacy web plugin instead.`);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
else
|
|
413
|
+
throw err;
|
|
414
|
+
}
|
|
415
|
+
const c = parseCompletion(json);
|
|
416
|
+
return { c, cost: costOf(c, price, searchUnitPrice(spec, price)) };
|
|
417
|
+
};
|
|
418
|
+
const judge = async (t, answer) => {
|
|
419
|
+
if (!plan.judgeModel || !judgePrice)
|
|
420
|
+
return { verdict: null, cost: 0 };
|
|
421
|
+
const messages = buildJudgeMessages({
|
|
422
|
+
brandName: plan.research.brand.name,
|
|
423
|
+
alternateNames: plan.research.brand.alternateNames,
|
|
424
|
+
domain: plan.domain,
|
|
425
|
+
competitors: (plan.research.competitors ?? []).map((c) => c.name).filter(Boolean),
|
|
426
|
+
question: t.q.text,
|
|
427
|
+
questionType: t.q.type,
|
|
428
|
+
answer,
|
|
429
|
+
identity: t.q.type === "diagnostic" ? identity : undefined,
|
|
430
|
+
});
|
|
431
|
+
const info = plan.catalog.models.get(plan.judgeModel);
|
|
432
|
+
let cost = 0;
|
|
433
|
+
for (const structured of [true, false]) {
|
|
434
|
+
try {
|
|
435
|
+
const json = await postChat(key, buildJudgeBody(plan.judgeModel, messages, info, structured), { ...chatOpts, timeoutMs: 60_000, attempts: 2 });
|
|
436
|
+
const c = parseCompletion(json);
|
|
437
|
+
cost += costOf(c, judgePrice, 0);
|
|
438
|
+
const v = parseJudge(c.text);
|
|
439
|
+
if (v)
|
|
440
|
+
return { verdict: v, cost };
|
|
441
|
+
}
|
|
442
|
+
catch (err) {
|
|
443
|
+
if (err instanceof OpenRouterError && (err.kind === "credits" || err.kind === "auth"))
|
|
444
|
+
throw Object.assign(err, { judgeCost: cost });
|
|
445
|
+
if (!(err instanceof OpenRouterError && err.kind === "bad_request"))
|
|
446
|
+
break;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return { verdict: null, cost };
|
|
450
|
+
};
|
|
451
|
+
const stopFor = (err, spec) => {
|
|
452
|
+
if (err.kind === "credits" && !stopReason) {
|
|
453
|
+
stopShort = "out of credits (402)";
|
|
454
|
+
stopReason = `OpenRouter returned 402 (${truncate(err.message, 140)}): out of credits or the key's spending limit is reached. Add credit or raise the key limit at https://openrouter.ai/settings/credits, then re-run.`;
|
|
455
|
+
}
|
|
456
|
+
if (err.kind === "auth" && !stopReason) {
|
|
457
|
+
stopShort = "key rejected (401)";
|
|
458
|
+
stopReason = "OpenRouter rejected the key (401) during the run.";
|
|
459
|
+
}
|
|
460
|
+
if (err.kind === "not_found" && !dead.has(spec.id)) {
|
|
461
|
+
dead.set(spec.id, `not run: ${spec.id} is not available on OpenRouter (404)`);
|
|
462
|
+
runtime.push(`${spec.id} returned 404 (model not found); its remaining questions were not asked. The next run picks the next model in the family from the live catalog.`);
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
const runTask = async (t) => {
|
|
466
|
+
const rec = blankRecord(t, plan.samples);
|
|
467
|
+
if (stopReason)
|
|
468
|
+
return { ...rec, error: `not run: stopped, ${stopShort ?? "run stopped"}` };
|
|
469
|
+
if (userCancelled)
|
|
470
|
+
return { ...rec, error: "not run: cancelled by the user" };
|
|
471
|
+
const deadReason = dead.get(t.spec.id);
|
|
472
|
+
if (deadReason)
|
|
473
|
+
return { ...rec, error: deadReason };
|
|
474
|
+
const row = rows.get(`${t.spec.id}|${t.spec.search}`);
|
|
475
|
+
const scoredQ = t.q.type !== "diagnostic" && !plan.branded.has(t.q.id);
|
|
476
|
+
const cost = taskCost(row, e, plan.followUps && scoredQ);
|
|
477
|
+
if (guard.spent + guard.reserved + cost.high > plan.maxCost) {
|
|
478
|
+
return { ...rec, error: `skipped: cost cap ${money(plan.maxCost)} reached (spent ${money(guard.spent)})` };
|
|
479
|
+
}
|
|
480
|
+
guard.reserved += cost.expected;
|
|
481
|
+
let actual = 0;
|
|
482
|
+
try {
|
|
483
|
+
let answer;
|
|
484
|
+
try {
|
|
485
|
+
answer = await ask(t.spec, [{ role: "user", content: t.q.text }], t.lang);
|
|
486
|
+
}
|
|
487
|
+
catch (err) {
|
|
488
|
+
if (err instanceof OpenRouterError)
|
|
489
|
+
stopFor(err, t.spec);
|
|
490
|
+
return { ...rec, error: describeError(err) };
|
|
491
|
+
}
|
|
492
|
+
actual += answer.cost;
|
|
493
|
+
costs.answers += answer.cost;
|
|
494
|
+
const c = answer.c;
|
|
495
|
+
const engine = engineOf(t.spec);
|
|
496
|
+
const surfaceTag = (surfaceOf.get(t.spec.id) === "plugin" ? "plugin:" : "") + engine;
|
|
497
|
+
const base = {
|
|
498
|
+
...rec,
|
|
499
|
+
model: c.model || t.spec.id,
|
|
500
|
+
surface: `openrouter:${surfaceTag}${c.provider ? `@${c.provider}` : ""}`,
|
|
501
|
+
ts: new Date().toISOString(),
|
|
502
|
+
searched: engine !== "none" && (c.webSearches > 0 || c.sources.length > 0),
|
|
503
|
+
costUsd: round(answer.cost, 6),
|
|
504
|
+
};
|
|
505
|
+
if (plan.tunings.get(t.spec.id)?.temperature)
|
|
506
|
+
base.temperature = plan.temperature;
|
|
507
|
+
if (!c.text.trim())
|
|
508
|
+
return { ...base, error: "empty answer (charged but not counted)" };
|
|
509
|
+
if (c.finishReason === "length")
|
|
510
|
+
truncated++;
|
|
511
|
+
let followUpSources = [];
|
|
512
|
+
if (plan.followUps && scoredQ && !mentions(plan.ctx, c.text) && !stopReason) {
|
|
513
|
+
try {
|
|
514
|
+
const fu = await ask(t.spec, [
|
|
515
|
+
{ role: "user", content: t.q.text },
|
|
516
|
+
{ role: "assistant", content: c.text },
|
|
517
|
+
{ role: "user", content: FOLLOW_UP[t.lang] ?? FOLLOW_UP.en },
|
|
518
|
+
], t.lang);
|
|
519
|
+
actual += fu.cost;
|
|
520
|
+
costs.followUps += fu.cost;
|
|
521
|
+
base.costUsd = round((base.costUsd ?? 0) + fu.cost, 6);
|
|
522
|
+
followUpSources = [...fu.c.sources, ...extractLinks(fu.c.text)];
|
|
523
|
+
}
|
|
524
|
+
catch (err) {
|
|
525
|
+
if (err instanceof OpenRouterError)
|
|
526
|
+
stopFor(err, t.spec);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
let verdict = null;
|
|
530
|
+
if (plan.judgeModel && !stopReason) {
|
|
531
|
+
try {
|
|
532
|
+
const j = await judge(t, c.text);
|
|
533
|
+
verdict = j.verdict;
|
|
534
|
+
actual += j.cost;
|
|
535
|
+
costs.judge += j.cost;
|
|
536
|
+
if (!verdict)
|
|
537
|
+
judgeFailures++;
|
|
538
|
+
}
|
|
539
|
+
catch (err) {
|
|
540
|
+
const jc = err.judgeCost ?? 0;
|
|
541
|
+
actual += jc;
|
|
542
|
+
costs.judge += jc;
|
|
543
|
+
judgeFailures++;
|
|
544
|
+
if (err instanceof OpenRouterError)
|
|
545
|
+
stopFor(err, t.spec);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
const r = analyzeAnswer(plan.ctx, { text: c.text, sources: c.sources, followUpSources, questionType: t.q.type, judge: verdict });
|
|
549
|
+
if (r.vetoed)
|
|
550
|
+
vetoed++;
|
|
551
|
+
const out = {
|
|
552
|
+
...base,
|
|
553
|
+
mentioned: r.mentioned,
|
|
554
|
+
recommendedFirst: r.recommendedFirst,
|
|
555
|
+
rank: r.rank,
|
|
556
|
+
cited: r.cited,
|
|
557
|
+
namedProducts: r.namedProducts,
|
|
558
|
+
competitorsNamed: r.competitorsNamed,
|
|
559
|
+
citations: r.citations,
|
|
560
|
+
answerExcerpt: r.answerExcerpt,
|
|
561
|
+
};
|
|
562
|
+
if (r.identityCorrect !== undefined)
|
|
563
|
+
out.identityCorrect = r.identityCorrect;
|
|
564
|
+
return out;
|
|
565
|
+
}
|
|
566
|
+
finally {
|
|
567
|
+
guard.reserved -= cost.expected;
|
|
568
|
+
guard.spent += actual;
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
let answers;
|
|
572
|
+
try {
|
|
573
|
+
answers = await mapLimit(tasks, plan.concurrency, async (t) => {
|
|
574
|
+
const r = await runTask(t);
|
|
575
|
+
active.done++;
|
|
576
|
+
deps.onProgress?.(active.done, tasks.length, `${shortFamily(t.spec.family)} · ${t.q.id}${r.error ? ` (${r.error.split(":")[0]})` : r.mentioned ? " · named" : ""}`);
|
|
577
|
+
return r;
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
finally {
|
|
581
|
+
signal?.removeEventListener("abort", onAbort);
|
|
582
|
+
}
|
|
583
|
+
const { summary, extras } = summarize(answers, {
|
|
584
|
+
branded: plan.branded,
|
|
585
|
+
ownDomains: plan.ctx.ownDomains,
|
|
586
|
+
isBrand: (n) => mentions(plan.ctx, n),
|
|
587
|
+
canonicalCompetitor: (n) => canonicalCompetitor(plan.ctx, n),
|
|
588
|
+
});
|
|
589
|
+
const run = {
|
|
590
|
+
id: runId,
|
|
591
|
+
ts: started.toISOString(),
|
|
592
|
+
brand: plan.research.brand.name,
|
|
593
|
+
domain: plan.domain,
|
|
594
|
+
models: plan.models,
|
|
595
|
+
temperature: plan.temperature,
|
|
596
|
+
answers,
|
|
597
|
+
summary,
|
|
598
|
+
totalCostUsd: round(guard.spent, 6),
|
|
599
|
+
};
|
|
600
|
+
const runFile = `.katman/runs/${runId}.json`;
|
|
601
|
+
await writeJson(plan.root, `runs/${runId}.json`, run);
|
|
602
|
+
await regenerateMeasurements(plan.root, plan.research, plan.ctx, now());
|
|
603
|
+
const diff = plan.previous ? diffRuns(plan.previous, run, plan.branded) : undefined;
|
|
604
|
+
const warnings = [...plan.notes, ...runtime];
|
|
605
|
+
if (extras.citationBlind.length)
|
|
606
|
+
warnings.push(`${extras.citationBlind.join(", ")} returned no sources at all in this run, so ${extras.citationBlind.length === 1 ? "it is" : "they are"} left out of the cited rate.`);
|
|
607
|
+
if (extras.unsearched)
|
|
608
|
+
warnings.push(`${extras.unsearched} answer${extras.unsearched === 1 ? " was" : "s were"} written without a web search (the model answered from memory). Consumer apps decide the same way; generic questions are often answered from memory with big brands.`);
|
|
609
|
+
if (truncated)
|
|
610
|
+
warnings.push(`${truncated} answer${truncated === 1 ? "" : "s"} hit the token limit and ${truncated === 1 ? "was" : "were"} cut off; a brand named at the very end could be missed.`);
|
|
611
|
+
if (judgeFailures)
|
|
612
|
+
warnings.push(`The judge failed on ${judgeFailures} answer${judgeFailures === 1 ? "" : "s"}; the product order there comes from the heuristic (list order).`);
|
|
613
|
+
if (vetoed)
|
|
614
|
+
warnings.push(`${vetoed} name-only match${vetoed === 1 ? " was" : "es were"} judged to be the ordinary word, not your brand, and not counted as named.`);
|
|
615
|
+
const mismatched = [...new Set(answers.filter((a) => !a.error && a.source === "api").filter((a) => !plan.models.some((m) => a.model === m.id || a.model.startsWith(m.id))).map((a) => a.model))];
|
|
616
|
+
if (mismatched.length)
|
|
617
|
+
warnings.push(`OpenRouter answered with a different model than requested: ${mismatched.join(", ")}.`);
|
|
618
|
+
if (extras.errors)
|
|
619
|
+
warnings.push(`${extras.errors} call${extras.errors === 1 ? "" : "s"} failed after retries (marked "err"; see the run file).`);
|
|
620
|
+
if (extras.skipped) {
|
|
621
|
+
const reasons = new Map();
|
|
622
|
+
for (const a of answers) {
|
|
623
|
+
if (!a.error || !/^(skipped|not run)/i.test(a.error))
|
|
624
|
+
continue;
|
|
625
|
+
const r = a.error.replace(/\s*\(spent[^)]*\)$/, "");
|
|
626
|
+
reasons.set(r, (reasons.get(r) ?? 0) + 1);
|
|
627
|
+
}
|
|
628
|
+
warnings.push(`${extras.skipped} answer${extras.skipped === 1 ? " was" : "s were"} not run (marked "skip"): ${[...reasons.entries()].map(([r, n]) => `${n} × ${r}`).join("; ")}.`);
|
|
629
|
+
}
|
|
630
|
+
if (userCancelled && !stopReason)
|
|
631
|
+
stopReason = "the run was cancelled; calls already running were finished and saved.";
|
|
632
|
+
if (!stopReason && answers.some((a) => a.error?.startsWith("skipped: cost cap")))
|
|
633
|
+
stopReason = `the cost cap (${money(plan.maxCost)}) was reached before every question was asked.`;
|
|
634
|
+
if (!stopReason && dead.size)
|
|
635
|
+
stopReason = `${[...dead.keys()].join(", ")} returned 404 (model not found), so its questions were not asked.`;
|
|
636
|
+
const nextRun = addDays(run.ts, 14);
|
|
637
|
+
const text = renderRunReport({
|
|
638
|
+
run, extras, diff, questions: plan.questions, branded: plan.branded, costs, runFile, warnings,
|
|
639
|
+
stopped: stopReason, nextRun, multiLanguage: plan.multiLanguage,
|
|
640
|
+
});
|
|
641
|
+
const answered = answers.filter((a) => !a.error).length;
|
|
642
|
+
await logEvent(plan.root, `katman_visibility_run ${runId}: ${answered}/${answers.length} answers, named ${Math.round(summary.mentionRate * 100)}% of ${summary.answered} scored, cited ${Math.round(summary.citationRate * 100)}%, cost ${money(run.totalCostUsd ?? 0)}${stopReason ? " (incomplete)" : ""}`);
|
|
643
|
+
return {
|
|
644
|
+
text,
|
|
645
|
+
structured: {
|
|
646
|
+
status: stopReason ? "partial" : "complete",
|
|
647
|
+
runId,
|
|
648
|
+
runFile,
|
|
649
|
+
answers: answers.length,
|
|
650
|
+
answered,
|
|
651
|
+
scored: summary.answered,
|
|
652
|
+
mentionRate: summary.mentionRate,
|
|
653
|
+
citationRate: summary.citationRate,
|
|
654
|
+
recommendedFirstRate: summary.recommendedFirstRate,
|
|
655
|
+
byFamily: summary.byFamily,
|
|
656
|
+
byLanguage: extras.byLanguage,
|
|
657
|
+
topCompetitors: summary.topCompetitors,
|
|
658
|
+
topCitedDomains: summary.topCitedDomains,
|
|
659
|
+
totalCostUsd: run.totalCostUsd,
|
|
660
|
+
delta: diff ? { previousRun: diff.previousId, pairs: diff.pairs, before: diff.before, after: diff.after } : null,
|
|
661
|
+
nextRunDate: nextRun,
|
|
662
|
+
stoppedReason: stopReason ?? null,
|
|
663
|
+
note: PROXY_NOTE,
|
|
664
|
+
},
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
export async function regenerateMeasurements(root, research, ctx, now = new Date()) {
|
|
668
|
+
const runs = (await readRuns(root)).map((r) => r.run);
|
|
669
|
+
const md = renderMeasurements(runs, { brand: research.brand.name, domain: normalizeHost(research.site.domain || research.site.url), ownDomains: ctx.ownDomains, now });
|
|
670
|
+
return writeText(root, "measurements.md", md);
|
|
671
|
+
}
|
|
672
|
+
/** Entry point shared by the MCP tool and the CLI. */
|
|
673
|
+
export async function visibilityRun(root, args, deps = {}) {
|
|
674
|
+
const { plan, error } = await buildPlan(root, args, deps);
|
|
675
|
+
if (error || !plan)
|
|
676
|
+
return error ?? { text: "Could not plan the run.", isError: true };
|
|
677
|
+
if (args.dry_run !== false)
|
|
678
|
+
return dryRun(plan, args, deps);
|
|
679
|
+
return realRun(plan, deps);
|
|
680
|
+
}
|
|
681
|
+
/** For tests: the in-memory run lock. */
|
|
682
|
+
export function _activeRuns() {
|
|
683
|
+
return ACTIVE;
|
|
684
|
+
}
|