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,277 @@
|
|
|
1
|
+
export const MODELS_VERIFIED_ON = "2026-09-30";
|
|
2
|
+
export const VENDORS = ["openai", "anthropic", "google", "perplexity", "x-ai"];
|
|
3
|
+
export const FAMILY_LABEL = {
|
|
4
|
+
openai: "ChatGPT (OpenAI)",
|
|
5
|
+
anthropic: "Claude",
|
|
6
|
+
google: "Gemini",
|
|
7
|
+
perplexity: "Perplexity",
|
|
8
|
+
"x-ai": "Grok",
|
|
9
|
+
};
|
|
10
|
+
/** Gemini answered with Exa search instead of Google grounding (the default, see above). */
|
|
11
|
+
export const GEMINI_EXA_LABEL = "Gemini (Exa search, proxy)";
|
|
12
|
+
/** "full" panel (flagship models): the first slug in each chain that is live on OpenRouter is used. The default panel is "economy" (see resolvePanel). */
|
|
13
|
+
export const DEFAULT_CHAINS = {
|
|
14
|
+
openai: ["openai/gpt-6.1-sol", "openai/gpt-6-sol", "openai/gpt-5.6-sol", "openai/gpt-6-luna"],
|
|
15
|
+
anthropic: ["anthropic/claude-sonnet-5.5", "anthropic/claude-sonnet-5", "anthropic/claude-haiku-4.5"],
|
|
16
|
+
google: ["google/gemini-3.8-flash", "google/gemini-3.7-flash", "google/gemini-3.5-flash-lite"],
|
|
17
|
+
perplexity: ["perplexity/sonar", "perplexity/sonar-pro"],
|
|
18
|
+
"x-ai": ["x-ai/grok-4.7", "x-ai/grok-4.6", "x-ai/grok-4.3", "x-ai/grok-4.20"],
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* "economy" preset: the same five families and the same native indexes, smaller models.
|
|
22
|
+
* Roughly half the cost of "full"; retrieval (what the headline "named" rate depends on) is unchanged.
|
|
23
|
+
*/
|
|
24
|
+
export const ECONOMY_CHAINS = {
|
|
25
|
+
openai: ["openai/gpt-6-luna", "openai/gpt-5.6-luna", "openai/gpt-6.1-sol"],
|
|
26
|
+
anthropic: ["anthropic/claude-haiku-4.5", "anthropic/claude-sonnet-5.5"],
|
|
27
|
+
google: ["google/gemini-3.8-flash", "google/gemini-3.5-flash-lite", "google/gemini-3.7-flash"],
|
|
28
|
+
perplexity: ["perplexity/sonar", "perplexity/sonar-pro"],
|
|
29
|
+
"x-ai": ["x-ai/grok-4.20", "x-ai/grok-4.3", "x-ai/grok-4.7"],
|
|
30
|
+
};
|
|
31
|
+
/** Cheap model with structured outputs that extracts the recommended products from an answer. */
|
|
32
|
+
export const JUDGE_CHAIN = ["openai/gpt-6-luna", "google/gemini-3.1-flash-lite", "openai/gpt-5.4-nano"];
|
|
33
|
+
/** "probe": the two families that carry the outcome (ChatGPT sends the traffic, Perplexity returns the most sources). */
|
|
34
|
+
export const PROBE_VENDORS = ["openai", "perplexity"];
|
|
35
|
+
export const PRESETS = ["full", "economy", "probe"];
|
|
36
|
+
/** Built-in prices (USD per token / per search) used only when the live catalog is unreachable. Verified 2026-09-30. */
|
|
37
|
+
export const FALLBACK_PRICES = {
|
|
38
|
+
"openai/gpt-6.1-sol": { prompt: 2e-6, completion: 10e-6, webSearch: 0.01 },
|
|
39
|
+
"openai/gpt-6-sol": { prompt: 2e-6, completion: 10e-6, webSearch: 0.01 },
|
|
40
|
+
"openai/gpt-5.6-sol": { prompt: 2e-6, completion: 10e-6, webSearch: 0.01 },
|
|
41
|
+
"openai/gpt-6-luna": { prompt: 0.1e-6, completion: 0.5e-6, webSearch: 0.01 },
|
|
42
|
+
"openai/gpt-5.6-luna": { prompt: 0.2e-6, completion: 1.2e-6, webSearch: 0.01 },
|
|
43
|
+
"openai/gpt-5.4-nano": { prompt: 0.2e-6, completion: 1.25e-6, webSearch: 0.01 },
|
|
44
|
+
"openai/gpt-chat-latest": { prompt: 5e-6, completion: 30e-6, webSearch: 0.01 },
|
|
45
|
+
"anthropic/claude-sonnet-5.5": { prompt: 2e-6, completion: 10e-6, webSearch: 0.01 },
|
|
46
|
+
"anthropic/claude-sonnet-5": { prompt: 2e-6, completion: 10e-6, webSearch: 0.01 },
|
|
47
|
+
"anthropic/claude-haiku-4.5": { prompt: 1e-6, completion: 5e-6, webSearch: 0.01 },
|
|
48
|
+
"google/gemini-3.8-flash": { prompt: 0.75e-6, completion: 3.75e-6, webSearch: 0.014 },
|
|
49
|
+
"google/gemini-3.7-flash": { prompt: 0.75e-6, completion: 3.75e-6, webSearch: 0.014 },
|
|
50
|
+
"google/gemini-3.5-flash-lite": { prompt: 0.3e-6, completion: 2.5e-6, webSearch: 0.014 },
|
|
51
|
+
"google/gemini-3.1-flash-lite": { prompt: 0.25e-6, completion: 1.5e-6, webSearch: 0.014 },
|
|
52
|
+
"perplexity/sonar": { prompt: 1e-6, completion: 1e-6, webSearch: 0.005 },
|
|
53
|
+
"perplexity/sonar-pro": { prompt: 3e-6, completion: 15e-6, webSearch: 0.005 },
|
|
54
|
+
"x-ai/grok-4.7": { prompt: 2e-6, completion: 6e-6, webSearch: 0.005 },
|
|
55
|
+
"x-ai/grok-4.6": { prompt: 2e-6, completion: 6e-6, webSearch: 0.005 },
|
|
56
|
+
"x-ai/grok-4.3": { prompt: 1.25e-6, completion: 2.5e-6, webSearch: 0.005 },
|
|
57
|
+
"x-ai/grok-4.20": { prompt: 1.25e-6, completion: 2.5e-6, webSearch: 0.005 },
|
|
58
|
+
};
|
|
59
|
+
/** Exa via OpenRouter: $0.007 per search request (auto mode, up to 10 results), docs 2026-09-30. */
|
|
60
|
+
export const EXA_PRICE_PER_SEARCH = 0.007;
|
|
61
|
+
/** Server-tool search budget per answer. Honoured natively by Anthropic and by OpenRouter-run engines (Exa). */
|
|
62
|
+
export const MAX_SEARCHES_PER_ANSWER = 2;
|
|
63
|
+
export const COST_PROFILES = {
|
|
64
|
+
"openai:native": {
|
|
65
|
+
inputTokens: [15000, 60000], outputTokens: [500, 2400], searches: [1, 5],
|
|
66
|
+
basis: "OpenAI native search, measured 5 Sep 2026: 30–61k input tokens (search content) and 2–6 searches per answer",
|
|
67
|
+
},
|
|
68
|
+
"anthropic:native": {
|
|
69
|
+
inputTokens: [10000, 25000], outputTokens: [500, 2200], searches: [1, 2],
|
|
70
|
+
basis: "Claude native search, measured 5 Sep 2026: ~22k input tokens, ~2 searches per answer (capped at 2)",
|
|
71
|
+
},
|
|
72
|
+
"google:native": {
|
|
73
|
+
inputTokens: [100, 1000], outputTokens: [600, 2400], searches: [1, 3],
|
|
74
|
+
basis: "Gemini grounding, measured 5 Sep 2026: ~80 input tokens, ~2 grounded queries per answer",
|
|
75
|
+
},
|
|
76
|
+
"perplexity:native": {
|
|
77
|
+
inputTokens: [50, 200], outputTokens: [300, 1200], searches: [1, 1],
|
|
78
|
+
basis: "Perplexity built-in search, measured 5 Sep 2026: ~60 input, 500–900 output tokens, one request fee",
|
|
79
|
+
},
|
|
80
|
+
"x-ai:native": {
|
|
81
|
+
inputTokens: [30000, 70000], outputTokens: [500, 2400], searches: [1, 3],
|
|
82
|
+
basis: "Grok native search, measured 5 Sep 2026: 57–69k input tokens per answer",
|
|
83
|
+
},
|
|
84
|
+
exa: {
|
|
85
|
+
inputTokens: [300, 12000], outputTokens: [500, 2200], searches: [0, 2],
|
|
86
|
+
basis: "Exa via the server tool: 0–2 searches of 5 results (~1k tokens each), $0.007 per search",
|
|
87
|
+
},
|
|
88
|
+
none: {
|
|
89
|
+
inputTokens: [50, 200], outputTokens: [400, 1500], searches: [0, 0],
|
|
90
|
+
basis: "no web search",
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
export function vendorOf(slug) {
|
|
94
|
+
const v = slug.split("/")[0]?.toLowerCase();
|
|
95
|
+
return VENDORS.includes(v) ? v : undefined;
|
|
96
|
+
}
|
|
97
|
+
/** Family label for an arbitrary slug: our labels for the five vendors, else the vendor prefix. */
|
|
98
|
+
export function familyOf(slug, search = "native") {
|
|
99
|
+
const v = vendorOf(slug);
|
|
100
|
+
if (v === "google")
|
|
101
|
+
return search === "native" ? FAMILY_LABEL.google : GEMINI_EXA_LABEL;
|
|
102
|
+
if (v)
|
|
103
|
+
return FAMILY_LABEL[v];
|
|
104
|
+
return slug.split("/")[0] || slug;
|
|
105
|
+
}
|
|
106
|
+
/** Which search engine an answer from this spec uses: provider-native, Exa (OpenRouter-run) or none. */
|
|
107
|
+
export function engineOf(spec) {
|
|
108
|
+
if (spec.search === "none")
|
|
109
|
+
return "none";
|
|
110
|
+
if (spec.search === "plugin")
|
|
111
|
+
return "exa";
|
|
112
|
+
return vendorOf(spec.id) ? "native" : "exa";
|
|
113
|
+
}
|
|
114
|
+
export function profileFor(spec) {
|
|
115
|
+
const engine = engineOf(spec);
|
|
116
|
+
if (engine === "native") {
|
|
117
|
+
const key = `${vendorOf(spec.id)}:native`;
|
|
118
|
+
return { key, profile: COST_PROFILES[key] };
|
|
119
|
+
}
|
|
120
|
+
return { key: engine, profile: COST_PROFILES[engine] };
|
|
121
|
+
}
|
|
122
|
+
const FAMILY_ALIASES = {
|
|
123
|
+
chatgpt: "openai", openai: "openai", gpt: "openai",
|
|
124
|
+
claude: "anthropic", anthropic: "anthropic",
|
|
125
|
+
gemini: "google", google: "google",
|
|
126
|
+
perplexity: "perplexity", sonar: "perplexity",
|
|
127
|
+
grok: "x-ai", xai: "x-ai", "x-ai": "x-ai",
|
|
128
|
+
};
|
|
129
|
+
export function specFor(slug, opts = {}) {
|
|
130
|
+
const v = vendorOf(slug);
|
|
131
|
+
const search = !v ? "plugin" : v === "google" && !opts.googleGrounding ? "plugin" : "native";
|
|
132
|
+
return { id: slug, family: familyOf(slug, search), search };
|
|
133
|
+
}
|
|
134
|
+
function pickFromChain(chain, isAvailable) {
|
|
135
|
+
const skipped = [];
|
|
136
|
+
for (const slug of chain) {
|
|
137
|
+
const ok = isAvailable ? isAvailable(slug) : undefined;
|
|
138
|
+
if (ok === false) {
|
|
139
|
+
skipped.push(slug);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
return { slug, skipped };
|
|
143
|
+
}
|
|
144
|
+
return { skipped };
|
|
145
|
+
}
|
|
146
|
+
function chainPanel(chains, vendors, o, notes) {
|
|
147
|
+
const out = [];
|
|
148
|
+
for (const v of vendors) {
|
|
149
|
+
const { slug, skipped } = pickFromChain(chains[v], o.isAvailable);
|
|
150
|
+
if (!slug) {
|
|
151
|
+
notes.push(`${FAMILY_LABEL[v]} skipped: none of ${chains[v].join(", ")} is available on OpenRouter right now.`);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (skipped.length)
|
|
155
|
+
notes.push(`${FAMILY_LABEL[v]}: ${skipped.join(", ")} not in the OpenRouter catalog; using ${slug}.`);
|
|
156
|
+
out.push(specFor(slug, { googleGrounding: o.googleGrounding }));
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
/** Apply the Google-grounding rule to a configured spec (native Google search needs the explicit opt-in). */
|
|
161
|
+
function normalizeSpec(spec, o, notes) {
|
|
162
|
+
const v = vendorOf(spec.id);
|
|
163
|
+
if (v === "google" && spec.search === "native" && !o.googleGrounding) {
|
|
164
|
+
notes.push(`${spec.id}: Google grounding needs allow_google_grounding: true (Google's grounding terms); using Exa search instead.`);
|
|
165
|
+
return { ...spec, search: "plugin", family: GEMINI_EXA_LABEL };
|
|
166
|
+
}
|
|
167
|
+
if (v === "google" && o.googleGrounding && spec.search !== "none") {
|
|
168
|
+
return { ...spec, search: "native", family: spec.family === GEMINI_EXA_LABEL ? FAMILY_LABEL.google : spec.family };
|
|
169
|
+
}
|
|
170
|
+
if (!spec.family)
|
|
171
|
+
return { ...spec, family: familyOf(spec.id, spec.search) };
|
|
172
|
+
return spec;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Resolve the `models` argument into a panel. Base panel = `.katman/config.json` models when set,
|
|
176
|
+
* else the default chains. Tokens: presets (full, economy, probe), family names (chatgpt, claude,
|
|
177
|
+
* gemini, perplexity, grok) and OpenRouter slugs.
|
|
178
|
+
*/
|
|
179
|
+
export function resolvePanel(o) {
|
|
180
|
+
const notes = [];
|
|
181
|
+
const errors = [];
|
|
182
|
+
const configured = (o.config?.models ?? []).filter((m) => m && typeof m.id === "string" && m.id.includes("/"));
|
|
183
|
+
const base = configured.length
|
|
184
|
+
? configured.map((m) => normalizeSpec({ ...m, search: m.search ?? "native" }, o, notes))
|
|
185
|
+
: chainPanel(DEFAULT_CHAINS, VENDORS, o, notes);
|
|
186
|
+
if (configured.length) {
|
|
187
|
+
for (const m of base) {
|
|
188
|
+
if (o.isAvailable && o.isAvailable(m.id) === false)
|
|
189
|
+
notes.push(`${m.id} (from .katman/config.json) is not in the OpenRouter catalog; its calls will fail.`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const tokens = (Array.isArray(o.input) ? o.input : o.input ? [o.input] : [])
|
|
193
|
+
.flatMap((t) => String(t).split(","))
|
|
194
|
+
.map((t) => t.trim())
|
|
195
|
+
.filter(Boolean);
|
|
196
|
+
if (!tokens.length) {
|
|
197
|
+
if (configured.length)
|
|
198
|
+
return { models: base, notes, errors, preset: "config" };
|
|
199
|
+
// Default panel = "economy": same five assistants and native indexes, smaller models (≈ half the cost of "full").
|
|
200
|
+
const economyNotes = [];
|
|
201
|
+
return { models: chainPanel(ECONOMY_CHAINS, VENDORS, o, economyNotes), notes: economyNotes, errors, preset: "economy" };
|
|
202
|
+
}
|
|
203
|
+
const out = [];
|
|
204
|
+
const add = (m) => {
|
|
205
|
+
if (!out.some((x) => x.id === m.id && x.search === m.search))
|
|
206
|
+
out.push(m);
|
|
207
|
+
};
|
|
208
|
+
let preset = "custom";
|
|
209
|
+
for (const raw of tokens) {
|
|
210
|
+
const t = raw.toLowerCase();
|
|
211
|
+
if (t === "full" || t === "all" || t === "default") {
|
|
212
|
+
base.forEach(add);
|
|
213
|
+
if (tokens.length === 1)
|
|
214
|
+
preset = configured.length ? "config" : "full";
|
|
215
|
+
}
|
|
216
|
+
else if (t === "probe") {
|
|
217
|
+
const picked = base.filter((m) => PROBE_VENDORS.includes(vendorOf(m.id)));
|
|
218
|
+
if (!picked.length)
|
|
219
|
+
errors.push(`"probe" needs an OpenAI or Perplexity model in the panel.`);
|
|
220
|
+
picked.forEach(add);
|
|
221
|
+
if (tokens.length === 1)
|
|
222
|
+
preset = "probe";
|
|
223
|
+
}
|
|
224
|
+
else if (t === "economy") {
|
|
225
|
+
chainPanel(ECONOMY_CHAINS, VENDORS, o, notes).forEach(add);
|
|
226
|
+
if (tokens.length === 1)
|
|
227
|
+
preset = "economy";
|
|
228
|
+
}
|
|
229
|
+
else if (FAMILY_ALIASES[t]) {
|
|
230
|
+
const v = FAMILY_ALIASES[t];
|
|
231
|
+
const fromBase = base.filter((m) => vendorOf(m.id) === v);
|
|
232
|
+
if (fromBase.length)
|
|
233
|
+
fromBase.forEach(add);
|
|
234
|
+
else {
|
|
235
|
+
const { slug } = pickFromChain(DEFAULT_CHAINS[v], o.isAvailable);
|
|
236
|
+
if (slug)
|
|
237
|
+
add(specFor(slug, { googleGrounding: o.googleGrounding }));
|
|
238
|
+
else
|
|
239
|
+
errors.push(`No ${FAMILY_LABEL[v]} model is available on OpenRouter right now.`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
else if (raw.includes("/")) {
|
|
243
|
+
const known = base.find((m) => m.id.toLowerCase() === t);
|
|
244
|
+
const spec = known ?? specFor(raw, { googleGrounding: o.googleGrounding });
|
|
245
|
+
if (!known && o.isAvailable && o.isAvailable(raw) === false) {
|
|
246
|
+
errors.push(`${raw} is not in the OpenRouter catalog (check the slug at https://openrouter.ai/models).`);
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
add(normalizeSpec(spec, o, notes));
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
errors.push(`Unknown model "${raw}". Use a preset (full, economy, probe), a family (chatgpt, claude, gemini, perplexity, grok) or an OpenRouter slug like openai/gpt-6.1-sol.`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return { models: out, notes, errors, preset };
|
|
256
|
+
}
|
|
257
|
+
/** First judge model that is live, or the configured one. */
|
|
258
|
+
export function resolveJudge(config, isAvailable) {
|
|
259
|
+
if (config?.judgeModel && config.judgeModel.includes("/"))
|
|
260
|
+
return config.judgeModel;
|
|
261
|
+
return pickFromChain(JUDGE_CHAIN, isAvailable).slug ?? JUDGE_CHAIN[0];
|
|
262
|
+
}
|
|
263
|
+
/** Language → approximate user location for native search (unset for English: no single market). */
|
|
264
|
+
const LANG_COUNTRY = {
|
|
265
|
+
tr: "TR", de: "DE", fr: "FR", es: "ES", it: "IT", pt: "BR", nl: "NL", pl: "PL", ja: "JP", ko: "KR",
|
|
266
|
+
sv: "SE", da: "DK", fi: "FI", nb: "NO", no: "NO", cs: "CZ", ro: "RO", hu: "HU", el: "GR", id: "ID",
|
|
267
|
+
vi: "VN", th: "TH", uk: "UA", ru: "RU", he: "IL", ar: "SA",
|
|
268
|
+
};
|
|
269
|
+
export function countryForLang(lang) {
|
|
270
|
+
if (!lang)
|
|
271
|
+
return undefined;
|
|
272
|
+
const l = lang.toLowerCase().trim();
|
|
273
|
+
const [base, region] = l.split(/[-_]/);
|
|
274
|
+
if (region && /^[a-z]{2}$/.test(region))
|
|
275
|
+
return region.toUpperCase();
|
|
276
|
+
return LANG_COUNTRY[base];
|
|
277
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// katman_setup_monitoring: a GitHub Actions workflow that re-runs the visibility check (and the live
|
|
2
|
+
// audit) on a schedule with the user's own OPENROUTER_API_KEY secret, commits .katman/ and opens an
|
|
3
|
+
// issue when the mention rate drops. Zero cost to Katman. Writes only .github/workflows/katman.yml,
|
|
4
|
+
// only with write: true, and never overwrites without overwrite: true.
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { existsSync, promises as fs } from "node:fs";
|
|
7
|
+
import { loadConfig, loadResearch, logEvent } from "../../state.js";
|
|
8
|
+
import { normalizeSiteUrl } from "../../util.js";
|
|
9
|
+
import { money, suggestCap } from "./estimate.js";
|
|
10
|
+
import { buildPlan } from "./run.js";
|
|
11
|
+
export const WORKFLOW_PATH = ".github/workflows/katman.yml";
|
|
12
|
+
export const DEFAULT_SCHEDULE = "0 6 * * 1";
|
|
13
|
+
/** Open an issue when the mention rate falls by at least this many percentage points between two runs. */
|
|
14
|
+
export const DROP_POINTS = 10;
|
|
15
|
+
export function validCron(s) {
|
|
16
|
+
const parts = s.trim().split(/\s+/);
|
|
17
|
+
return parts.length === 5 && parts.every((p) => /^[\d*/,\-A-Za-z?#LW]+$/.test(p));
|
|
18
|
+
}
|
|
19
|
+
/** Runs per month for simple schedules (weekly on one day, fixed days of month, daily). */
|
|
20
|
+
export function runsPerMonth(cron) {
|
|
21
|
+
const [, , dom, , dow] = cron.trim().split(/\s+/);
|
|
22
|
+
if (dom === "*" && /^\d$/.test(dow ?? ""))
|
|
23
|
+
return 4.33;
|
|
24
|
+
if (dom === "*" && dow === "*")
|
|
25
|
+
return 30;
|
|
26
|
+
if (/^[\d,]+$/.test(dom ?? "") && dow === "*")
|
|
27
|
+
return dom.split(",").length;
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
export function buildWorkflowYaml(o) {
|
|
31
|
+
const models = o.models && /^[\w.,/:-]+$/.test(o.models) ? ` --models ${o.models}` : "";
|
|
32
|
+
const check = `npx -y katman-mcp check --root . --yes --max-cost ${o.maxCost}${models}`;
|
|
33
|
+
const site = o.siteUrl && /^https?:\/\/[^\s'"]+$/.test(o.siteUrl) ? o.siteUrl : undefined;
|
|
34
|
+
const y = [
|
|
35
|
+
"# Katman AI visibility monitoring (generated by katman-mcp; safe to edit).",
|
|
36
|
+
"# Re-asks your question set through OpenRouter with YOUR key (capped per run), commits .katman/",
|
|
37
|
+
`# and opens an issue when the share of answers that name you drops by ${DROP_POINTS}+ points.`,
|
|
38
|
+
"# `katman-mcp check` is a Katman Pro command: it needs the KATMAN_KEY repository secret (katman.pro/app).",
|
|
39
|
+
"name: Katman AI visibility",
|
|
40
|
+
"",
|
|
41
|
+
"on:",
|
|
42
|
+
" schedule:",
|
|
43
|
+
` - cron: "${o.schedule}"`,
|
|
44
|
+
" workflow_dispatch: {}",
|
|
45
|
+
"",
|
|
46
|
+
"permissions:",
|
|
47
|
+
" contents: write",
|
|
48
|
+
" issues: write",
|
|
49
|
+
"",
|
|
50
|
+
"concurrency:",
|
|
51
|
+
" group: katman-visibility",
|
|
52
|
+
" cancel-in-progress: false",
|
|
53
|
+
"",
|
|
54
|
+
"jobs:",
|
|
55
|
+
" katman:",
|
|
56
|
+
" runs-on: ubuntu-latest",
|
|
57
|
+
" timeout-minutes: 45",
|
|
58
|
+
" steps:",
|
|
59
|
+
" - uses: actions/checkout@v4",
|
|
60
|
+
" - uses: actions/setup-node@v4",
|
|
61
|
+
" with:",
|
|
62
|
+
" node-version: 20",
|
|
63
|
+
"",
|
|
64
|
+
` - name: AI visibility check (spends at most $${o.maxCost} of your OpenRouter credit)`,
|
|
65
|
+
" continue-on-error: true",
|
|
66
|
+
" env:",
|
|
67
|
+
" OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}",
|
|
68
|
+
" KATMAN_KEY: ${{ secrets.KATMAN_KEY }}",
|
|
69
|
+
` run: ${check}`,
|
|
70
|
+
];
|
|
71
|
+
if (o.includeAudit) {
|
|
72
|
+
y.push("", " - name: Live site audit (as AI crawlers see it)");
|
|
73
|
+
if (!site)
|
|
74
|
+
y.push(" if: ${{ vars.KATMAN_SITE_URL != '' }}");
|
|
75
|
+
y.push(" continue-on-error: true", " env:", site ? ` KATMAN_SITE_URL: \${{ vars.KATMAN_SITE_URL || '${site}' }}` : " KATMAN_SITE_URL: ${{ vars.KATMAN_SITE_URL }}", ' run: npx -y katman-mcp audit "$KATMAN_SITE_URL" --root .');
|
|
76
|
+
}
|
|
77
|
+
y.push("", " - name: Commit .katman changes", " run: |", ' git config user.name "katman-bot"', ' git config user.email "41898282+github-actions[bot]@users.noreply.github.com"', " git add -A .katman ':(exclude).katman/cache'", " if git diff --cached --quiet; then", ' echo "No .katman changes."', " else", ' git commit -m "katman: AI visibility check $(date -u +%F)"', " git pull --rebase --autostash", " git push", " fi", "", " - name: Compare the last two runs", " if: always()", " id: drop", " run: |", " node <<'EOF'", " const fs = require('fs');", " const path = require('path');", " const dir = '.katman/runs';", " const out = (k, v) => fs.appendFileSync(process.env.GITHUB_OUTPUT, `${k}=${v}\\n`);", " let files = [];", " try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.json') && !f.startsWith('manual-')).sort(); } catch {}", " const runs = files", " .map((f) => { try { return JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')); } catch { return null; } })", " .filter((r) => r && r.summary && r.summary.answered >= 5);", " if (runs.length < 2) { out('dropped', 'false'); process.exit(0); }", " const [prev, cur] = runs.slice(-2);", " const before = Math.round(prev.summary.mentionRate * 100);", " const after = Math.round(cur.summary.mentionRate * 100);", " out('before', before);", " out('after', after);", " out('run', cur.id);", " // Only a run made by this workflow run counts; otherwise the same drop would open an issue every week.", " const fresh = Date.now() - Date.parse(cur.ts) < 36 * 3600 * 1000;", ` out('dropped', String(fresh && before - after >= ${DROP_POINTS}));`, " EOF", "", " - name: Open an issue when the mention rate dropped", " if: always() && steps.drop.outputs.dropped == 'true'", " env:", " GH_TOKEN: ${{ github.token }}", " BEFORE: ${{ steps.drop.outputs.before }}", " AFTER: ${{ steps.drop.outputs.after }}", " RUN_ID: ${{ steps.drop.outputs.run }}", " run: |", " cat > katman-issue.md <<EOF", " AI assistants named you in ${AFTER}% of the unbranded answers in run ${RUN_ID}, down from ${BEFORE}% in the run before.", "", " - Who was recommended instead and which sources the assistants used: .katman/measurements.md", " - One run is a small sample; check whether the drop holds in the next run before changing course.", " - Confirm key questions by hand with katman_manual_check (logged out, web search on).", " EOF", ' gh issue create --title "Katman: AI assistants name you less often (${BEFORE}% → ${AFTER}%)" --body-file katman-issue.md');
|
|
78
|
+
return y.join("\n") + "\n";
|
|
79
|
+
}
|
|
80
|
+
export async function setupMonitoring(root, args) {
|
|
81
|
+
const schedule = (args.schedule ?? DEFAULT_SCHEDULE).trim();
|
|
82
|
+
if (!validCron(schedule)) {
|
|
83
|
+
return { text: `"${schedule}" is not a 5-field cron expression (minute hour day-of-month month day-of-week), e.g. "${DEFAULT_SCHEDULE}" (Mondays 06:00 UTC) or "0 6 1,15 * *" (twice a month).`, isError: true };
|
|
84
|
+
}
|
|
85
|
+
const includeAudit = args.include_audit !== false;
|
|
86
|
+
const research = await loadResearch(root);
|
|
87
|
+
const config = await loadConfig(root);
|
|
88
|
+
const notes = [];
|
|
89
|
+
// Estimate offline (cached catalog or built-in prices): setting up monitoring makes no network calls.
|
|
90
|
+
const { plan, error } = await buildPlan(root, { models: args.models, dry_run: true }, { offline: true });
|
|
91
|
+
let maxCost = args.max_cost_usd;
|
|
92
|
+
let costLine = "Cost per run: unknown until the research question set exists.";
|
|
93
|
+
if (plan) {
|
|
94
|
+
const e = plan.estimate;
|
|
95
|
+
maxCost ??= Math.max(2, suggestCap(e.total.high));
|
|
96
|
+
const perMonth = runsPerMonth(schedule);
|
|
97
|
+
costLine = `Cost per run: about ${money(e.total.expected)} (${money(e.total.low)}–${money(e.total.high)}, ${e.answers} answers), capped at ${money(maxCost)}${perMonth ? `; ≈ ${money(e.total.expected * perMonth)} a month on this schedule` : ""}. Paid from your OpenRouter credit; Katman costs nothing.`;
|
|
98
|
+
if (e.total.expected > maxCost)
|
|
99
|
+
notes.push(`The expected cost is above --max-cost ${maxCost}: every scheduled run would refuse to start. Raise max_cost_usd or use models: "economy" / "probe".`);
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
maxCost ??= 2;
|
|
103
|
+
notes.push(error?.text.includes("question set") || !research ? "Create the research question set first (katman_research with generate_questions: true) and commit .katman/research.json; the scheduled check needs it." : "Could not estimate the cost: " + (error?.text.split("\n").find((l) => l.startsWith("- ")) ?? "see katman_visibility_run with dry_run: true."));
|
|
104
|
+
}
|
|
105
|
+
let siteUrl;
|
|
106
|
+
try {
|
|
107
|
+
const raw = config.siteUrl || research?.site?.url;
|
|
108
|
+
siteUrl = raw ? normalizeSiteUrl(raw) : undefined;
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
siteUrl = undefined;
|
|
112
|
+
}
|
|
113
|
+
const yaml = buildWorkflowYaml({ schedule, includeAudit, maxCost, models: args.models, siteUrl });
|
|
114
|
+
let writeLine;
|
|
115
|
+
const target = path.join(root, WORKFLOW_PATH);
|
|
116
|
+
let isError = false;
|
|
117
|
+
if (args.write) {
|
|
118
|
+
if (existsSync(target) && !args.overwrite) {
|
|
119
|
+
writeLine = `**Not written:** ${WORKFLOW_PATH} already exists. Compare it with the YAML below, then pass overwrite: true to replace it.`;
|
|
120
|
+
isError = true;
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
124
|
+
await fs.writeFile(target, yaml, "utf8");
|
|
125
|
+
await logEvent(root, `katman_setup_monitoring wrote ${WORKFLOW_PATH} (schedule "${schedule}", max cost ${maxCost})`);
|
|
126
|
+
writeLine = `Written to ${WORKFLOW_PATH}.`;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
writeLine = `Not written (pass write: true to create ${WORKFLOW_PATH}).`;
|
|
131
|
+
}
|
|
132
|
+
const L = [
|
|
133
|
+
"# Scheduled AI visibility monitoring (GitHub Actions)",
|
|
134
|
+
"",
|
|
135
|
+
`Schedule \`${schedule}\` (UTC)${schedule === DEFAULT_SCHEDULE ? ": every Monday 06:00" : ""}. Each run: re-asks the question set (\`katman-mcp check\`)${includeAudit ? ", audits the live site as AI crawlers see it" : ""}, commits .katman/ changes, and opens an issue if the share of answers naming you drops by ${DROP_POINTS}+ points.`,
|
|
136
|
+
costLine,
|
|
137
|
+
"",
|
|
138
|
+
writeLine,
|
|
139
|
+
"",
|
|
140
|
+
"```yaml",
|
|
141
|
+
yaml.trimEnd(),
|
|
142
|
+
"```",
|
|
143
|
+
"",
|
|
144
|
+
"## Setup steps",
|
|
145
|
+
"",
|
|
146
|
+
"1. Add your keys as repository secrets: GitHub → Settings → Secrets and variables → Actions → New repository secret, name `OPENROUTER_API_KEY` (or `gh secret set OPENROUTER_API_KEY`), and `KATMAN_KEY` with your Katman Pro key from https://katman.pro/app → MCP (`gh secret set KATMAN_KEY`; `katman-mcp check` is a Pro command and stops without it). Give the OpenRouter key its own credit limit at https://openrouter.ai/keys, e.g. your monthly budget.",
|
|
147
|
+
includeAudit
|
|
148
|
+
? `2. Add a repository variable \`KATMAN_SITE_URL\` = your live URL (Variables tab, or \`gh variable set KATMAN_SITE_URL --body https://…\`)${siteUrl ? `; without it the workflow audits ${siteUrl}` : "; without it the audit step is skipped"}.`
|
|
149
|
+
: "2. (Audit step off.)",
|
|
150
|
+
"3. Commit `.github/workflows/katman.yml` and `.katman/research.json` (the check needs the question set).",
|
|
151
|
+
"4. If your organisation restricts the default token: Settings → Actions → General → Workflow permissions → Read and write (the workflow asks for contents: write and issues: write).",
|
|
152
|
+
"5. Test it: Actions → \"Katman AI visibility\" → Run workflow.",
|
|
153
|
+
"",
|
|
154
|
+
"Notes:",
|
|
155
|
+
"- The book re-measures every 14 days; for that cadence use schedule `0 6 1,15 * *`. Weekly runs show the trend sooner at twice the cost.",
|
|
156
|
+
"- A run whose estimate is above the cap refuses to start (nothing spent). A run that hits 402 (no credit) saves its partial results.",
|
|
157
|
+
"- .katman/cache/ is never committed; .katman/ never contains either key.",
|
|
158
|
+
...notes.map((n) => `- ${n}`),
|
|
159
|
+
];
|
|
160
|
+
return {
|
|
161
|
+
text: L.join("\n"),
|
|
162
|
+
isError,
|
|
163
|
+
structured: { status: args.write ? (isError ? "exists" : "written") : "preview", path: WORKFLOW_PATH, schedule, maxCostUsd: maxCost, includeAudit },
|
|
164
|
+
};
|
|
165
|
+
}
|