geo-ai-search-optimization 2.3.0 → 2.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "geo-ai-search-optimization",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "Install and run a Generative Engine Optimization (GEO)-first, SEO-supported Codex skill for website optimization.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -57,6 +57,8 @@ import { comparePages, renderCompareMarkdown, writeCompareOutput } from "./compa
57
57
  import { deepBenchmark, renderDeepBenchmarkMarkdown, writeDeepBenchmarkOutput } from "./deep-benchmark.js";
58
58
  import { quickSummary, renderSummaryMarkdown } from "./summary.js";
59
59
  import { fullPageAuditToCsv, batchFullPageAuditToCsv, deepBenchmarkToCsv, compareToCsv } from "./csv-export.js";
60
+ import { explain, renderExplainMarkdown, writeExplainOutput } from "./explain.js";
61
+ import { monitorPage, renderMonitorMarkdown, writeMonitorOutput } from "./monitor.js";
60
62
 
61
63
  export const SITE_OPS_HELP_LINES = [
62
64
  " geo-ai-search-optimization doctor [--json]",
@@ -104,7 +106,9 @@ export const SITE_OPS_HELP_LINES = [
104
106
  " geo-ai-search-optimization diagnose <url-or-dir-or-file> [--json] [--out <file>]",
105
107
  " geo-ai-search-optimization compare <url-or-file-A> <url-or-file-B> [--json] [--csv] [--out <file>]",
106
108
  " geo-ai-search-optimization deep-benchmark <url> --competitors <url1,url2,...> [--concurrency <n>] [--json] [--csv] [--out <file>]",
107
- " geo-ai-search-optimization summary <url-or-dir-or-file>"
109
+ " geo-ai-search-optimization summary <url-or-dir-or-file>",
110
+ " geo-ai-search-optimization explain <url-or-file> [--dimension <dim>] [--json] [--out <file>]",
111
+ " geo-ai-search-optimization monitor <url-or-file> [--threshold <n>] [--data-dir <dir>] [--json] [--out <file>]"
108
112
  ];
109
113
 
110
114
  const passthroughWriteOutput = async (outputPath) => outputPath;
@@ -710,6 +714,34 @@ const handleBatchFullPageAudit = createStructuredOutputCommandHandler({
710
714
  getOutputJson: (args) => hasFlag(args, "--json")
711
715
  });
712
716
 
717
+ const handleExplain = createStructuredOutputCommandHandler({
718
+ commandLabel: "explain",
719
+ execute: async (args) => {
720
+ const input = args.find((value) => !value.startsWith("-"));
721
+ if (!input) throw new Error("explain requires a URL or file path");
722
+ return explain(input, { dimension: getFlagValue(args, "--dimension") || undefined });
723
+ },
724
+ renderMarkdown: renderExplainMarkdown,
725
+ writeOutput: writeExplainOutput,
726
+ getOutputJson: (args) => hasFlag(args, "--json")
727
+ });
728
+
729
+ const handleMonitor = createStructuredOutputCommandHandler({
730
+ commandLabel: "monitor",
731
+ execute: async (args) => {
732
+ const input = args.find((value) => !value.startsWith("-"));
733
+ if (!input) throw new Error("monitor requires a URL or file path");
734
+ const thresholdValue = getFlagValue(args, "--threshold");
735
+ return monitorPage(input, {
736
+ threshold: thresholdValue ? parsePositiveInteger(thresholdValue, "--threshold") : 5,
737
+ dataDir: getFlagValue(args, "--data-dir") || undefined
738
+ });
739
+ },
740
+ renderMarkdown: renderMonitorMarkdown,
741
+ writeOutput: writeMonitorOutput,
742
+ getOutputJson: (args) => hasFlag(args, "--json")
743
+ });
744
+
713
745
  const handleDeepBenchmark = createStructuredOutputCommandHandler({
714
746
  commandLabel: "deep benchmark",
715
747
  execute: async (args) => {
@@ -915,5 +947,7 @@ export const SITE_OPS_COMMAND_HANDLERS = {
915
947
  diagnose: handleDiagnose,
916
948
  compare: handleCompareCsv,
917
949
  "deep-benchmark": handleDeepBenchmark,
918
- summary: handleSummary
950
+ summary: handleSummary,
951
+ explain: handleExplain,
952
+ monitor: handleMonitor
919
953
  };
package/src/explain.js ADDED
@@ -0,0 +1,268 @@
1
+ import { writeScanOutput } from "./scan.js";
2
+ import { fullPageAudit } from "./full-page-audit.js";
3
+
4
+ /**
5
+ * Explain: deep-dive into why a specific dimension scored low.
6
+ * Shows concrete evidence from the page content.
7
+ */
8
+
9
+ const DIMENSION_EXPLAINERS = {
10
+ base: {
11
+ label: "Base Audit",
12
+ explain: (details) => {
13
+ if (!details) return { factors: [], evidence: [] };
14
+ const factors = [];
15
+ const evidence = [];
16
+ const checks = details.score?.checks || [];
17
+ for (const check of checks) {
18
+ if (!check.passed) {
19
+ factors.push({ factor: check.label, impact: `-${check.maxPoints} points`, status: "missing" });
20
+ }
21
+ }
22
+ if (details.metadata?.title) evidence.push({ type: "title", value: details.metadata.title });
23
+ if (details.metadata?.metaDescription) evidence.push({ type: "metaDescription", value: details.metadata.metaDescription.slice(0, 160) });
24
+ if (details.directAnswerCandidate) evidence.push({ type: "firstParagraph", value: details.directAnswerCandidate.slice(0, 200) });
25
+ return { factors, evidence };
26
+ }
27
+ },
28
+ citability: {
29
+ label: "Citability",
30
+ explain: (details) => {
31
+ if (!details) return { factors: [], evidence: [] };
32
+ const factors = [];
33
+ if (details.claims?.claimDensity < 15) factors.push({ factor: `Claim density: ${details.claims.claimDensity}% (target: 15%+)`, impact: "Low factual content", status: "weak" });
34
+ if (details.claims?.statDensity < 5) factors.push({ factor: `Stat density: ${details.claims.statDensity}% (target: 5%+)`, impact: "Few statistics", status: "weak" });
35
+ if (details.entities?.entityDensity < 1) factors.push({ factor: `Entity density: ${details.entities.entityDensity}% (target: 1%+)`, impact: "Few named entities", status: "weak" });
36
+ if ((details.quotableSentences?.length || 0) < 3) factors.push({ factor: `Quotable sentences: ${details.quotableSentences?.length || 0} (target: 3+)`, impact: "Few citable passages", status: "weak" });
37
+ if (!details.structure?.hasList) factors.push({ factor: "No lists found", impact: "Content hard to scan", status: "missing" });
38
+ if (!details.structure?.hasTable) factors.push({ factor: "No tables found", impact: "No structured comparisons", status: "missing" });
39
+ const evidence = (details.quotableSentences || []).slice(0, 3).map((q) => ({ type: "quotable", value: q.sentence, score: q.score }));
40
+ return { factors, evidence };
41
+ }
42
+ },
43
+ eeat: {
44
+ label: "E-E-A-T",
45
+ explain: (details) => {
46
+ if (!details) return { factors: [], evidence: [] };
47
+ const factors = [];
48
+ const dims = details.dimensions || {};
49
+ for (const [key, data] of Object.entries(dims)) {
50
+ if (data.score < 30) {
51
+ factors.push({ factor: `${key}: ${data.score}/100`, impact: `No ${key} signals detected`, status: "critical" });
52
+ } else if (data.score < 60) {
53
+ factors.push({ factor: `${key}: ${data.score}/100`, impact: `Weak ${key} signals`, status: "weak" });
54
+ }
55
+ }
56
+ if ((details.authorPresence?.length || 0) === 0) {
57
+ factors.push({ factor: "No author attribution", impact: "AI can't identify who wrote this", status: "missing" });
58
+ }
59
+ const evidence = (details.authorPresence || []).map((s) => ({ type: "authorSignal", value: s }));
60
+ for (const [key, data] of Object.entries(dims)) {
61
+ for (const signal of (data.signals || []).slice(0, 2)) {
62
+ evidence.push({ type: key, value: `${signal.label}: ${signal.count}x — e.g. "${signal.examples[0] || ""}"` });
63
+ }
64
+ }
65
+ return { factors, evidence };
66
+ }
67
+ },
68
+ readability: {
69
+ label: "Readability",
70
+ explain: (details) => {
71
+ if (!details) return { factors: [], evidence: [] };
72
+ const factors = [];
73
+ if (details.fleschKincaidGrade > 12) factors.push({ factor: `Grade level: ${details.fleschKincaidGrade} (target: 8-10)`, impact: "Too complex for broad audience", status: "weak" });
74
+ if (details.sentenceLength?.avg > 25) factors.push({ factor: `Avg sentence: ${details.sentenceLength.avg} words (target: 15-20)`, impact: "Sentences too long", status: "weak" });
75
+ if (details.passiveVoice?.passiveRatio > 20) factors.push({ factor: `Passive voice: ${details.passiveVoice.passiveRatio}% (target: <15%)`, impact: "Unclear agency", status: "weak" });
76
+ if (details.sentenceLength?.distribution?.veryLong > 0) factors.push({ factor: `${details.sentenceLength.distribution.veryLong} sentences over 30 words`, impact: "Hard to parse", status: "weak" });
77
+ return { factors, evidence: [] };
78
+ }
79
+ },
80
+ headingStructure: {
81
+ label: "Heading Structure",
82
+ explain: (details) => {
83
+ if (!details) return { factors: [], evidence: [] };
84
+ const factors = [];
85
+ for (const issue of (details.hierarchy?.issues || [])) {
86
+ factors.push({ factor: issue.message, impact: issue.severity, status: issue.severity === "error" ? "critical" : "weak" });
87
+ }
88
+ if (details.questionHeadings?.count === 0) factors.push({ factor: "No question-style headings", impact: "AI can't extract Q&A answers", status: "missing" });
89
+ if (details.semanticCoverage?.coverageRatio < 30) factors.push({ factor: `Semantic coverage: ${details.semanticCoverage.coverageRatio}%`, impact: "Narrow topic coverage in headings", status: "weak" });
90
+ const evidence = (details.headings || []).slice(0, 8).map((h) => ({ type: `h${h.level}`, value: h.text }));
91
+ return { factors, evidence };
92
+ }
93
+ },
94
+ freshness: {
95
+ label: "Content Freshness",
96
+ explain: (details) => {
97
+ if (!details) return { factors: [], evidence: [] };
98
+ const factors = [];
99
+ if (details.datesFound === 0) factors.push({ factor: "No dates found anywhere on the page", impact: "AI can't assess content currency", status: "critical" });
100
+ if (details.age?.isOld) factors.push({ factor: `Content is ${details.age.modifiedAgeLabel} old`, impact: "AI deprioritizes stale content", status: "weak" });
101
+ if (details.age?.isStale) factors.push({ factor: `Last modified ${details.age.modifiedAgeLabel} ago`, impact: "Consider updating", status: "weak" });
102
+ const missingSignals = ["datePublished in structured data", "dateModified in structured data", "Visible freshness label"]
103
+ .filter((s) => !(details.freshnessSignals || []).includes(s));
104
+ for (const s of missingSignals) factors.push({ factor: `Missing: ${s}`, impact: "Incomplete freshness signals", status: "missing" });
105
+ const evidence = (details.dates || []).slice(0, 5).map((d) => ({ type: d.source, value: `${d.date} — "${d.raw}"` }));
106
+ return { factors, evidence };
107
+ }
108
+ },
109
+ security: {
110
+ label: "Security",
111
+ explain: (details) => {
112
+ if (!details) return { factors: [], evidence: [] };
113
+ const factors = [];
114
+ for (const check of (details.htmlChecks || [])) {
115
+ if (!check.passed && check.severity !== "info") {
116
+ factors.push({ factor: check.label, impact: check.detail || "Missing", status: check.severity === "error" ? "critical" : "weak" });
117
+ }
118
+ }
119
+ for (const header of (details.securityHeaders || [])) {
120
+ if (!header.present && header.critical) factors.push({ factor: `Missing: ${header.header}`, impact: "Critical security header", status: "critical" });
121
+ }
122
+ return { factors, evidence: [] };
123
+ }
124
+ },
125
+ socialMeta: {
126
+ label: "Social Meta",
127
+ explain: (details) => {
128
+ if (!details) return { factors: [], evidence: [] };
129
+ const factors = [];
130
+ const allTags = [...(details.openGraph?.tags || []), ...(details.twitter?.tags || [])];
131
+ for (const tag of allTags) {
132
+ if (tag.required && !tag.found) factors.push({ factor: `Missing: ${tag.tag}`, impact: "Required for social sharing", status: "missing" });
133
+ }
134
+ const evidence = allTags.filter((t) => t.found).map((t) => ({ type: t.tag, value: t.value?.slice(0, 80) || "" }));
135
+ return { factors, evidence };
136
+ }
137
+ },
138
+ internalLinks: {
139
+ label: "Internal Links",
140
+ explain: (details) => {
141
+ if (!details) return { factors: [], evidence: [] };
142
+ const factors = [];
143
+ if (details.internalLinks?.total === 0) factors.push({ factor: "No internal links", impact: "No site structure signals", status: "critical" });
144
+ if (details.anchorAnalysis?.empty > 0) factors.push({ factor: `${details.anchorAnalysis.empty} empty anchor text(s)`, impact: "Links with no description", status: "weak" });
145
+ if (details.anchorAnalysis?.generic > 0) factors.push({ factor: `${details.anchorAnalysis.generic} generic anchor text(s)`, impact: '"Click here" style links', status: "weak" });
146
+ const evidence = (details.internalLinks?.mostLinked || []).slice(0, 5).map((l) => ({ type: "internal", value: `${l.url} (${l.count}x)` }));
147
+ return { factors, evidence };
148
+ }
149
+ },
150
+ platformReady: {
151
+ label: "Platform Readiness",
152
+ explain: (details) => {
153
+ if (!details) return { factors: [], evidence: [] };
154
+ const factors = [];
155
+ for (const p of (details.platforms || [])) {
156
+ if (p.score < 50) {
157
+ const failed = (p.checks || []).filter((c) => !c.passed);
158
+ factors.push({ factor: `${p.platform}: ${p.score}/100`, impact: `Missing: ${failed.map((f) => f.label).join(", ")}`, status: "weak" });
159
+ }
160
+ }
161
+ return { factors, evidence: [] };
162
+ }
163
+ },
164
+ schema: {
165
+ label: "Schema Validation",
166
+ explain: (details) => {
167
+ if (!details) return { factors: [], evidence: [] };
168
+ const factors = [];
169
+ if (details.entityCount === 0) factors.push({ factor: "No JSON-LD found", impact: "AI can't understand page entities", status: "critical" });
170
+ for (const v of (details.validations || [])) {
171
+ for (const issue of v.issues) factors.push({ factor: `${v.type}: ${issue.message}`, impact: "Schema error", status: "critical" });
172
+ for (const enh of v.enhancements) factors.push({ factor: `${v.type}: ${enh.message}`, impact: "AI discoverability", status: "enhancement" });
173
+ }
174
+ return { factors, evidence: [] };
175
+ }
176
+ },
177
+ topics: {
178
+ label: "Topic Coverage",
179
+ explain: (details) => {
180
+ if (!details) return { factors: [], evidence: [] };
181
+ const factors = [];
182
+ if ((details.keywords?.length || 0) < 5) factors.push({ factor: "Few distinct keywords", impact: "Unclear topic focus", status: "weak" });
183
+ if ((details.topicClusters?.length || 0) < 3) factors.push({ factor: `Only ${details.topicClusters?.length || 0} topic cluster(s)`, impact: "Narrow topic coverage", status: "weak" });
184
+ const evidence = (details.keywords || []).slice(0, 5).map((k) => ({ type: "keyword", value: `"${k.term}" (${k.count}x, tfidf: ${k.tfidf})` }));
185
+ return { factors, evidence };
186
+ }
187
+ }
188
+ };
189
+
190
+ export async function explain(input, options = {}) {
191
+ const audit = await fullPageAudit(input, options);
192
+ const targetDim = options.dimension || null;
193
+
194
+ const explanations = {};
195
+
196
+ const dimsToExplain = targetDim
197
+ ? { [targetDim]: DIMENSION_EXPLAINERS[targetDim] }
198
+ : DIMENSION_EXPLAINERS;
199
+
200
+ for (const [key, explainer] of Object.entries(dimsToExplain)) {
201
+ if (!explainer) continue;
202
+ const score = audit.dimensions[key]?.score ?? 0;
203
+ const detail = audit.details?.[key] || null;
204
+ const { factors, evidence } = explainer.explain(detail);
205
+
206
+ explanations[key] = {
207
+ label: explainer.label,
208
+ score,
209
+ factorCount: factors.length,
210
+ factors,
211
+ evidence: evidence.slice(0, 8)
212
+ };
213
+ }
214
+
215
+ // Sort by score ascending (worst first)
216
+ const sorted = Object.entries(explanations)
217
+ .sort((a, b) => a[1].score - b[1].score);
218
+
219
+ return {
220
+ kind: "geo-explain",
221
+ input,
222
+ compositeScore: audit.compositeScore,
223
+ targetDimension: targetDim,
224
+ explanations: Object.fromEntries(sorted),
225
+ summary: targetDim
226
+ ? `${DIMENSION_EXPLAINERS[targetDim]?.label || targetDim}: ${explanations[targetDim]?.score ?? 0}/100. ${explanations[targetDim]?.factorCount || 0} factor(s) identified.`
227
+ : `Composite: ${audit.compositeScore}/100. ${sorted.filter(([, e]) => e.score < 40).length} critical dimension(s).`
228
+ };
229
+ }
230
+
231
+ export function renderExplainMarkdown(report) {
232
+ const lines = [
233
+ "# GEO Score Explanation",
234
+ "",
235
+ `- Input: \`${report.input}\``,
236
+ `- Composite Score: \`${report.compositeScore}/100\``,
237
+ `- Summary: ${report.summary}`,
238
+ ""
239
+ ];
240
+
241
+ for (const [key, exp] of Object.entries(report.explanations)) {
242
+ const icon = exp.score >= 70 ? "🟢" : exp.score >= 40 ? "🟡" : "🔴";
243
+ lines.push(`## ${icon} ${exp.label} — ${exp.score}/100`, "");
244
+
245
+ if (exp.factors.length > 0) {
246
+ lines.push("### Why this score?", "");
247
+ for (const f of exp.factors) {
248
+ const badge = f.status === "critical" ? "🔴" : f.status === "missing" ? "❌" : f.status === "enhancement" ? "💡" : "⚠️";
249
+ lines.push(`- ${badge} **${f.factor}** — ${f.impact}`);
250
+ }
251
+ lines.push("");
252
+ }
253
+
254
+ if (exp.evidence.length > 0) {
255
+ lines.push("### Evidence from page", "");
256
+ for (const e of exp.evidence) {
257
+ lines.push(`- [${e.type}] ${e.value}`);
258
+ }
259
+ lines.push("");
260
+ }
261
+ }
262
+
263
+ return lines.join("\n");
264
+ }
265
+
266
+ export async function writeExplainOutput(outputPath, content) {
267
+ return writeScanOutput(outputPath, content);
268
+ }
package/src/full-audit.js CHANGED
@@ -5,6 +5,7 @@ import { auditProject } from "./audit.js";
5
5
  import { analyzeCrawlers } from "./crawlers.js";
6
6
  import { validateLlmsTxt } from "./validate-llms.js";
7
7
  import { fullPageAudit } from "./full-page-audit.js";
8
+ import { analyzeSitemap } from "./sitemap.js";
8
9
 
9
10
  async function runSafe(label, fn) {
10
11
  try {
@@ -54,8 +55,23 @@ export async function fullAudit(input, options = {}) {
54
55
  : { ok: true, data: { score: 0, found: false, summary: "No llms.txt found in project." } }
55
56
  ]);
56
57
 
57
- // Phase 3: Sample page audits (if URLs or HTML files provided)
58
- const sampleUrls = options.sampleUrls || [];
58
+ // Phase 2.5: Sitemap analysis (if available)
59
+ let sitemapResult = null;
60
+ const sitemapPath = await findSpecialFile(root, "sitemap.xml");
61
+ if (sitemapPath) {
62
+ sitemapResult = await runSafe("sitemap", () => analyzeSitemap(sitemapPath));
63
+ }
64
+
65
+ // Phase 3: Sample page audits
66
+ // Auto-discover from sitemap if no explicit URLs provided
67
+ let sampleUrls = options.sampleUrls || [];
68
+ if (sampleUrls.length === 0 && options.autoSample !== false && sitemapResult?.ok) {
69
+ const entries = sitemapResult.data.entries || [];
70
+ const sitemapUrls = entries.map((e) => e.loc).filter(Boolean);
71
+ const maxAutoSample = options.maxAutoSample || 5;
72
+ sampleUrls = sitemapUrls.slice(0, maxAutoSample);
73
+ }
74
+
59
75
  const samplePages = [];
60
76
 
61
77
  if (sampleUrls.length > 0) {
package/src/index.js CHANGED
@@ -89,4 +89,6 @@ export { deepBenchmark, renderDeepBenchmarkMarkdown, writeDeepBenchmarkOutput }
89
89
  export { quickSummary, renderSummaryMarkdown } from "./summary.js";
90
90
  export { fetchWithRetry, fetchText, fetchTextSafe, fetchBatch } from "./fetch-utils.js";
91
91
  export { fullPageAuditToCsv, batchFullPageAuditToCsv, deepBenchmarkToCsv, compareToCsv, arrayToCsv } from "./csv-export.js";
92
+ export { explain, renderExplainMarkdown, writeExplainOutput } from "./explain.js";
93
+ export { monitorPage, renderMonitorMarkdown, writeMonitorOutput } from "./monitor.js";
92
94
  export { savePageSnapshot, listPageSnapshots, loadPageSnapshot, buildPageTrend, renderPageTrendMarkdown, writePageTrendOutput } from "./page-snapshot.js";
package/src/monitor.js ADDED
@@ -0,0 +1,144 @@
1
+ import { writeScanOutput } from "./scan.js";
2
+ import { fullPageAudit } from "./full-page-audit.js";
3
+ import { savePageSnapshot, buildPageTrend } from "./page-snapshot.js";
4
+
5
+ /**
6
+ * Monitor: run full-page-audit, save snapshot, compare with previous,
7
+ * and alert on score changes.
8
+ */
9
+
10
+ export async function monitorPage(input, options = {}) {
11
+ const threshold = options.threshold ?? 5;
12
+ const dataDir = options.dataDir;
13
+
14
+ // Get previous trend before new audit
15
+ const previousTrend = await buildPageTrend(input, { dataDir });
16
+ const previousScore = previousTrend.snapshotCount > 0
17
+ ? previousTrend.snapshots[previousTrend.snapshots.length - 1].compositeScore
18
+ : null;
19
+
20
+ // Run fresh audit
21
+ const audit = await fullPageAudit(input, options);
22
+ const currentScore = audit.compositeScore;
23
+
24
+ // Save snapshot
25
+ const saved = await savePageSnapshot(audit, { dataDir });
26
+
27
+ // Compute delta
28
+ const delta = previousScore !== null ? currentScore - previousScore : null;
29
+ const dropped = delta !== null && delta < -threshold;
30
+ const improved = delta !== null && delta > threshold;
31
+
32
+ // Build alert
33
+ let alert = null;
34
+ if (dropped) {
35
+ alert = {
36
+ level: "warning",
37
+ message: `Score dropped by ${Math.abs(delta)} points (${previousScore} → ${currentScore}). Threshold: ${threshold}.`,
38
+ dimensions: findDroppedDimensions(audit, previousTrend)
39
+ };
40
+ } else if (improved) {
41
+ alert = {
42
+ level: "success",
43
+ message: `Score improved by ${delta} points (${previousScore} → ${currentScore}).`,
44
+ dimensions: []
45
+ };
46
+ }
47
+
48
+ // Updated trend (including new snapshot)
49
+ const updatedTrend = await buildPageTrend(input, { dataDir });
50
+
51
+ return {
52
+ kind: "geo-monitor",
53
+ input,
54
+ currentScore,
55
+ previousScore,
56
+ delta,
57
+ dropped,
58
+ improved,
59
+ stable: !dropped && !improved,
60
+ threshold,
61
+ alert,
62
+ snapshotPath: saved.path,
63
+ snapshotCount: updatedTrend.snapshotCount,
64
+ trend: updatedTrend.trend,
65
+ compositeLabel: audit.compositeLabel,
66
+ dimensions: Object.fromEntries(
67
+ Object.entries(audit.dimensions).map(([k, v]) => [k, v.score])
68
+ ),
69
+ summary: alert
70
+ ? alert.message
71
+ : previousScore !== null
72
+ ? `Stable at ${currentScore}/100 (Δ${delta >= 0 ? "+" : ""}${delta}, within ±${threshold} threshold).`
73
+ : `First scan: ${currentScore}/100. Baseline established.`
74
+ };
75
+ }
76
+
77
+ function findDroppedDimensions(audit, previousTrend) {
78
+ if (!previousTrend.trend?.dimensionTrends) return [];
79
+
80
+ const dropped = [];
81
+ for (const [dim, trend] of Object.entries(previousTrend.trend.dimensionTrends)) {
82
+ const current = audit.dimensions[dim]?.score ?? 0;
83
+ if (current < trend.latest - 5) {
84
+ dropped.push({ dimension: dim, was: trend.latest, now: current, delta: current - trend.latest });
85
+ }
86
+ }
87
+
88
+ return dropped.sort((a, b) => a.delta - b.delta);
89
+ }
90
+
91
+ export function renderMonitorMarkdown(report) {
92
+ const lines = [
93
+ "# GEO Monitor Report",
94
+ "",
95
+ `- Input: \`${report.input}\``,
96
+ `- Current Score: \`${report.currentScore}/100\` (${report.compositeLabel})`,
97
+ `- Previous Score: \`${report.previousScore ?? "—"}\``,
98
+ `- Delta: \`${report.delta !== null ? (report.delta >= 0 ? "+" : "") + report.delta : "—"}\``,
99
+ `- Status: ${report.dropped ? "🔴 DROPPED" : report.improved ? "🟢 IMPROVED" : "➡️ STABLE"}`,
100
+ `- Snapshot: \`${report.snapshotPath}\``,
101
+ `- Total snapshots: \`${report.snapshotCount}\``,
102
+ `- Summary: ${report.summary}`,
103
+ ""
104
+ ];
105
+
106
+ if (report.alert) {
107
+ const icon = report.alert.level === "warning" ? "⚠️" : "✅";
108
+ lines.push(`## ${icon} Alert`, "", report.alert.message, "");
109
+
110
+ if (report.alert.dimensions?.length > 0) {
111
+ lines.push("### Dimensions that dropped", "");
112
+ for (const d of report.alert.dimensions) {
113
+ lines.push(`- 🔴 **${d.dimension}**: ${d.was} → ${d.now} (${d.delta})`);
114
+ }
115
+ lines.push("");
116
+ }
117
+ }
118
+
119
+ if (report.dimensions) {
120
+ lines.push("## Current Dimensions", "");
121
+ const sorted = Object.entries(report.dimensions).sort((a, b) => a[1] - b[1]);
122
+ for (const [dim, score] of sorted) {
123
+ const icon = score >= 70 ? "🟢" : score >= 40 ? "🟡" : "🔴";
124
+ lines.push(`- ${icon} ${dim}: ${score}/100`);
125
+ }
126
+ lines.push("");
127
+ }
128
+
129
+ if (report.trend) {
130
+ lines.push(
131
+ "## Trend",
132
+ "",
133
+ `- Direction: **${report.trend.direction}**`,
134
+ `- First: ${report.trend.firstScore} → Latest: ${report.trend.latestScore}`,
135
+ ""
136
+ );
137
+ }
138
+
139
+ return lines.join("\n");
140
+ }
141
+
142
+ export async function writeMonitorOutput(outputPath, content) {
143
+ return writeScanOutput(outputPath, content);
144
+ }