seo-intel 1.5.50 → 1.5.52

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/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.52 (2026-07-01)
4
+
5
+ ### New: per-project `sourcePath` — agent dispatch knows where the code lives
6
+ - Project configs can now declare `sourcePath` (an absolute path to the site's source — a git repo or a plain folder, both work; git-ness is auto-detected, never declared). Any local agent integration can use this to dispatch a fix directly into the right codebase instead of guessing.
7
+ - seo-intel itself self-registers its own install location to `~/.seo-intel/install.json` on every CLI run, so any local agent harness can find it without environment variables or hardcoded paths.
8
+
9
+ ### Updated: product logo — gold to intel-blue
10
+ - The product mark now uses the brand's intel-blue accent instead of gold, matching the dashboard and site. Shows up as the dashboard favicon and in the npm package.
11
+
12
+ ### New: `rescore <project> <url>` — close the agent loop
13
+ - Re-check a single URL's AI citability after a fix. Read-only re-measurement on the raw-HTML ("what bots see") lens, returning `before / after / delta` + the per-signal breakdown — so an agent (or you) can verify a change actually moved the score. Server-rendered fixes move it; JS-only fixes correctly don't.
14
+ - Available via the CLI (`--format json`) and the agent-harness (`run('rescore', project, { url })`).
15
+
16
+ ### Improved: `intel --for audit` now emits ranked opportunities
17
+ - The audit slice now includes a ranked `opportunities[]` (each tagged `side: fix | attack` with real value/status/confidence/suggested_action/proof) plus a `summary` (counts + per-signal averages) — decision-ready data any agent or dashboard can act on directly. Flows through to the MCP `get_intel` and the agent-harness automatically.
18
+
19
+ ### Renamed: `froggo.js` → `agent-harness.js`
20
+ - The agent integration entry point is now `seo-intel/agent-harness` (clearer name). The legacy `seo-intel/froggo` import still resolves to it, so existing code keeps working.
21
+
22
+ ### Claude Code plugin — install the MCP server *and* skill in one command
23
+ - Added a Claude Code plugin marketplace. Claude Code users can install seo-intel with `/plugin marketplace add Ukkometa/seo-intel` then `/plugin install seo-intel@ukkometa` — one step wires both the MCP server **and** the seo-intel skill, with no manual `claude mcp add` or config editing.
24
+ - The existing `claude mcp add seo-intel "npx seo-intel-mcp"` (and Cursor/Cline setup) still works unchanged.
25
+
26
+ ## 1.5.51 (2026-06-11)
27
+
28
+ ### Fixed — LM Studio extraction now actually works (it was silently degrading)
29
+ Local extraction via LM Studio never functioned: every page fell back to degraded (regex) mode, so entity-level signals (entity authority, intent, keywords) came back empty even when a model was loaded and serving. Four stacked bugs:
30
+
31
+ - **Preflight hit the wrong endpoint.** It probed `/api/v1/models` (LM Studio's *native* API, shape `{models:[{key}]}`) but parsed it as the OpenAI shape `{data:[{id}]}` — so it always concluded "no models loaded." Now uses the OpenAI-compatible `/v1/models`.
32
+ - **Inference hit the wrong endpoint.** It POSTed to `/api/v1/chat` (returns `400 'input' is required`). Now uses `/v1/chat/completions`.
33
+ - **Unsupported `response_format`.** It sent `{type:'json_object'}`, which LM Studio rejects (`must be 'json_schema' or 'text'`). Now sends `text` and relies on the existing JSON-extraction/repair pass.
34
+ - **A few bad pages disabled the whole model.** Content/parse failures (a small model returning unparseable JSON for one long page) were counted as host failures and retired the local model for the rest of the run. Now only *transport* failures (host unreachable/timeout/5xx) retire a host; a single unparseable page just degrades itself.
35
+
36
+ Point at a loaded LM Studio model with `LMSTUDIO_MODEL=<model-id>` (e.g. `google/gemma-4-e2b`). Note: very small models (≈2B) still struggle to emit clean JSON for very long pages — use a 4B+ extraction model (Gemma E4B, Qwen 3.5) for higher coverage, or see `seo-intel models`.
37
+
3
38
  ## 1.5.50 (2026-06-11)
4
39
 
5
40
  ### New MCP tool: `setup_project` — zero → configured → audited, entirely from chat
@@ -0,0 +1,851 @@
1
+ /**
2
+ * agent-harness.js — Agent-harness API for SEO Intel
3
+ *
4
+ * Single entry point any agent platform can "ride" (Hermes, MCP hosts, custom
5
+ * orchestrators). Self-describing via `capabilities` + `pipeline`. Three usage levels:
6
+ *
7
+ * 1. Unified runner (recommended):
8
+ * import { run, capabilities } from 'seo-intel/agent-harness';
9
+ * const result = await run('aeo', 'myproject');
10
+ *
11
+ * 2. Direct function imports:
12
+ * import { aeo, gapIntel } from 'seo-intel/agent-harness';
13
+ *
14
+ * 3. Deep imports (tree-shakeable):
15
+ * import { runAeoAnalysis } from 'seo-intel/aeo';
16
+ *
17
+ * Back-compat: the legacy `seo-intel/froggo` export still resolves here.
18
+ */
19
+
20
+ import { readFileSync, readdirSync, existsSync } from 'fs';
21
+ import { join, dirname } from 'path';
22
+ import { fileURLToPath } from 'url';
23
+ import { getDb, getActiveInsights, getSchemasByProject } from './db/db.js';
24
+
25
+ const __dirname = dirname(fileURLToPath(import.meta.url));
26
+
27
+ // ═══════════════════════════════════════════════════════════════════════════
28
+ // HELPERS (extracted from cli.js for agent use)
29
+ // ═══════════════════════════════════════════════════════════════════════════
30
+
31
+ /** Load project config by name. Returns null if not found. */
32
+ export function loadConfig(project) {
33
+ if (!project || !/^[a-z0-9_-]+$/i.test(project)) return null;
34
+ const path = join(__dirname, 'config', `${project}.json`);
35
+ try { return JSON.parse(readFileSync(path, 'utf8')); }
36
+ catch { return null; }
37
+ }
38
+
39
+ /** List all configured projects. */
40
+ export function listProjects() {
41
+ const dir = join(__dirname, 'config');
42
+ try {
43
+ return readdirSync(dir)
44
+ .filter(f => f.endsWith('.json') && f !== 'example.json')
45
+ .map(f => {
46
+ const name = f.replace('.json', '');
47
+ const config = loadConfig(name);
48
+ return {
49
+ name,
50
+ targetDomain: config?.target?.domain || null,
51
+ competitors: (config?.competitors || []).map(c => c.domain),
52
+ };
53
+ });
54
+ } catch { return []; }
55
+ }
56
+
57
+ /** Filter content pages (exclude app/dashboard URLs). */
58
+ export function isContentPage(url) {
59
+ if (url.includes('?')) return false;
60
+ const appPaths = ['/signup', '/login', '/register', '/onboarding', '/dashboard',
61
+ '/app/', '/swap', '/portfolio', '/send', '/rewards', '/perps', '/vaults'];
62
+ const appSubdomains = ['dashboard.', 'app.', 'customers.', 'console.'];
63
+ if (appPaths.some(p => url.includes(p))) return false;
64
+ if (appSubdomains.some(s => url.includes(s))) return false;
65
+ return true;
66
+ }
67
+
68
+ // ═══════════════════════════════════════════════════════════════════════════
69
+ // ANALYSIS MODULES (direct exports)
70
+ // ═══════════════════════════════════════════════════════════════════════════
71
+
72
+ export { runAeoAnalysis as aeo } from './analyses/aeo/index.js';
73
+ export { scorePage as scorePageCitability } from './analyses/aeo/scorer.js';
74
+ export { runGapIntel as gapIntel } from './analyses/gap-intel/index.js';
75
+ export { runWatch as watch, getWatchData } from './analyses/watch/index.js';
76
+ export { gatherBlogDraftContext as blogDraftContext } from './analyses/blog-draft/index.js';
77
+ export { buildBlogDraftPrompt as blogDraftPrompt } from './analyses/blog-draft/index.js';
78
+ export { runTemplatesAnalysis as templates } from './analyses/templates/index.js';
79
+
80
+ // ═══════════════════════════════════════════════════════════════════════════
81
+ // EXPORT MODULES (structured action lists)
82
+ // ═══════════════════════════════════════════════════════════════════════════
83
+
84
+ export { buildTechnicalActions as technicalActions } from './exports/technical.js';
85
+ export { buildCompetitiveActions as competitiveActions } from './exports/competitive.js';
86
+ export { buildSuggestiveActions as suggestiveActions } from './exports/suggestive.js';
87
+ export { getProjectDomains, getTargetDomains, getCompetitorDomains } from './exports/queries.js';
88
+
89
+ // ═══════════════════════════════════════════════════════════════════════════
90
+ // CRAWLER + DATA LAYER
91
+ // ═══════════════════════════════════════════════════════════════════════════
92
+
93
+ export { crawlDomain } from './crawler/index.js';
94
+ export { getDb, getActiveInsights } from './db/db.js';
95
+
96
+ // ═══════════════════════════════════════════════════════════════════════════
97
+ // DASHBOARD (embeddable HTML)
98
+ // ═══════════════════════════════════════════════════════════════════════════
99
+
100
+ export { generateMultiDashboard, generateHtmlDashboard } from './reports/generate-html.js';
101
+
102
+ /**
103
+ * Generate dashboard HTML as a string (for embedding in iframes/panels).
104
+ * Does NOT write to disk — returns HTML directly.
105
+ */
106
+ export async function getDashboardHtml(project) {
107
+ const db = getDb();
108
+ const config = loadConfig(project);
109
+ if (!config) return { error: `Project "${project}" not found` };
110
+
111
+ const { generateHtmlDashboard } = await import('./reports/generate-html.js');
112
+ const filePath = generateHtmlDashboard(db, project, config);
113
+ try {
114
+ return { html: readFileSync(filePath, 'utf8'), project };
115
+ } catch (e) {
116
+ return { error: e.message };
117
+ }
118
+ }
119
+
120
+ // ═══════════════════════════════════════════════════════════════════════════
121
+ // CAPABILITIES MANIFEST
122
+ // ═══════════════════════════════════════════════════════════════════════════
123
+
124
+ /**
125
+ * Machine-readable capability list. Agents can introspect what SEO Intel offers.
126
+ */
127
+ export const capabilities = [
128
+ {
129
+ id: 'crawl',
130
+ name: 'Site Crawler',
131
+ description: 'Crawl a website and store page structure, content, metadata, and schemas in SQLite',
132
+ requires: ['playwright'],
133
+ inputs: { project: 'string', options: { stealth: 'boolean', maxPages: 'number', scope: 'string' } },
134
+ outputs: { pages: 'number', domains: 'number', schemas: 'number' },
135
+ phase: 'collect',
136
+ tier: 'free',
137
+ },
138
+ {
139
+ id: 'extract',
140
+ name: 'Content Extractor',
141
+ description: 'Extract SEO signals from crawled pages using local LLM (keywords, entities, intent, CTAs)',
142
+ requires: ['ollama'],
143
+ inputs: { project: 'string', options: { model: 'string' } },
144
+ outputs: { keywords: 'array', entities: 'array', intent: 'string', cta: 'string' },
145
+ modelHint: 'light-local',
146
+ modelNote: 'Use gemma4:e2b (fast) or gemma4:e4b (balanced). Extraction is structured data work — heavy models waste resources.',
147
+ phase: 'extract',
148
+ tier: 'free',
149
+ dependsOn: ['crawl'],
150
+ },
151
+ {
152
+ id: 'aeo',
153
+ name: 'AI Citability Audit',
154
+ description: 'Score each page for AI citability (0-100) across 6 signals. Find pages search AI will ignore.',
155
+ requires: [],
156
+ inputs: { project: 'string', options: { format: 'json|brief' } },
157
+ outputs: { scores: 'array<PageScore>', summary: 'object', insights: 'array' },
158
+ phase: 'analyze',
159
+ tier: 'free',
160
+ dependsOn: ['crawl'],
161
+ },
162
+ {
163
+ id: 'rescore',
164
+ name: 'Re-score a URL (close the loop)',
165
+ description: "Re-check one URL's AI citability after a fix. Read-only re-measurement on the RAW-HTML (what-bots-see) lens — returns before/after/delta so an agent can verify its own change. If the fix is server-rendered the score moves; if JS-only, it correctly does not.",
166
+ requires: [],
167
+ inputs: { project: 'string', options: { url: 'string (required)' } },
168
+ outputs: { url: 'string', before: 'number', after: 'number', delta: 'number', improved: 'boolean', signals: 'object', lens: 'string' },
169
+ phase: 'verify',
170
+ tier: 'free',
171
+ dependsOn: ['aeo'],
172
+ },
173
+ {
174
+ id: 'watch',
175
+ name: 'Site Health Watch',
176
+ description: 'Detect changes between crawl runs — new/removed pages, status changes, title/content changes, health score',
177
+ requires: [],
178
+ inputs: { project: 'string' },
179
+ outputs: { snapshot: 'object', events: 'array<WatchEvent>', healthScore: 'number', trend: 'number' },
180
+ phase: 'analyze',
181
+ tier: 'free',
182
+ dependsOn: ['crawl'],
183
+ },
184
+ {
185
+ id: 'gap-intel',
186
+ name: 'Gap Intelligence',
187
+ description: 'Topic/content gap analysis — find what competitors cover that you don\'t',
188
+ requires: ['ollama'],
189
+ inputs: { project: 'string', options: { vs: 'string[]', type: 'string', limit: 'number', raw: 'boolean' } },
190
+ outputs: { gaps: 'array<TopicGap>', matrix: 'object', report: 'string' },
191
+ modelHint: 'light-local',
192
+ modelNote: 'gemma4:e4b handles topic clustering well. Cloud models add minimal value here.',
193
+ phase: 'analyze',
194
+ tier: 'pro',
195
+ dependsOn: ['crawl', 'extract'],
196
+ },
197
+ {
198
+ id: 'shallow',
199
+ name: 'Shallow Champion Attack',
200
+ description: 'Find competitor pages that are important but thin — easy to outwrite',
201
+ requires: [],
202
+ inputs: { project: 'string', options: { maxWords: 'number', maxDepth: 'number', format: 'json|brief' } },
203
+ outputs: { targets: 'array<Page>', byDomain: 'object' },
204
+ phase: 'analyze',
205
+ tier: 'pro',
206
+ dependsOn: ['crawl'],
207
+ },
208
+ {
209
+ id: 'decay',
210
+ name: 'Content Decay Arbitrage',
211
+ description: 'Find competitor pages decaying due to staleness — your freshness advantage',
212
+ requires: [],
213
+ inputs: { project: 'string', options: { months: 'number', format: 'json|brief' } },
214
+ outputs: { confirmedStale: 'array<Page>', unknownFreshness: 'array<Page>' },
215
+ phase: 'analyze',
216
+ tier: 'pro',
217
+ dependsOn: ['crawl'],
218
+ },
219
+ {
220
+ id: 'headings-audit',
221
+ name: 'Heading Architecture Audit',
222
+ description: 'Pull competitor heading structures — find topic gaps in H1-H3 hierarchy',
223
+ requires: [],
224
+ inputs: { project: 'string', options: { domain: 'string', depth: 'number', format: 'json|brief' } },
225
+ outputs: { pages: 'array<{url, headings: Heading[]>}' },
226
+ phase: 'analyze',
227
+ tier: 'pro',
228
+ dependsOn: ['crawl'],
229
+ },
230
+ {
231
+ id: 'orphans',
232
+ name: 'Orphan Entity Attack',
233
+ description: 'Find entities mentioned by competitors with no dedicated page — content opportunities',
234
+ requires: [],
235
+ inputs: { project: 'string', options: { format: 'json|brief' } },
236
+ outputs: { orphans: 'array<{entity, domains, suggestedUrl}>' },
237
+ phase: 'analyze',
238
+ tier: 'pro',
239
+ dependsOn: ['extract'],
240
+ },
241
+ {
242
+ id: 'entities',
243
+ name: 'Entity Coverage Map',
244
+ description: 'Semantic gap analysis at the entity level — concepts competitors mention that you don\'t',
245
+ requires: [],
246
+ inputs: { project: 'string', options: { minMentions: 'number', format: 'json|brief' } },
247
+ outputs: { gaps: 'array', shared: 'array', unique: 'array', summary: 'object' },
248
+ phase: 'analyze',
249
+ tier: 'pro',
250
+ dependsOn: ['extract'],
251
+ },
252
+ {
253
+ id: 'schemas',
254
+ name: 'Schema Intelligence',
255
+ description: 'Structured data competitive analysis — ratings, pricing, rich results gaps',
256
+ requires: [],
257
+ inputs: { project: 'string', options: { format: 'json|brief' } },
258
+ outputs: { coverageMatrix: 'object', gaps: 'array', ratings: 'array', pricing: 'array', actions: 'array' },
259
+ phase: 'analyze',
260
+ tier: 'free',
261
+ dependsOn: ['crawl'],
262
+ },
263
+ {
264
+ id: 'friction',
265
+ name: 'Intent & Friction Hijacking',
266
+ description: 'Find competitor pages with intent/CTA mismatch — high friction you can undercut',
267
+ requires: [],
268
+ inputs: { project: 'string', options: { format: 'json|brief' } },
269
+ outputs: { targets: 'array<FrictionTarget>', totalAnalyzed: 'number' },
270
+ phase: 'analyze',
271
+ tier: 'pro',
272
+ dependsOn: ['extract'],
273
+ },
274
+ {
275
+ id: 'brief',
276
+ name: 'Weekly Intel Brief',
277
+ description: 'What changed this week — competitor moves, new gaps, wins, actions',
278
+ requires: [],
279
+ inputs: { project: 'string', options: { days: 'number', format: 'json|brief' } },
280
+ outputs: { competitorMoves: 'array', keywordGaps: 'array', schemaGaps: 'array', actions: 'array' },
281
+ phase: 'report',
282
+ tier: 'pro',
283
+ dependsOn: ['crawl'],
284
+ },
285
+ {
286
+ id: 'velocity',
287
+ name: 'Content Velocity Tracker',
288
+ description: 'Publishing rate comparison — who\'s producing content fastest',
289
+ requires: [],
290
+ inputs: { project: 'string', options: { days: 'number', format: 'json|brief' } },
291
+ outputs: { velocities: 'array<DomainVelocity>', recentlyPublished: 'array', newPages: 'array' },
292
+ phase: 'analyze',
293
+ tier: 'pro',
294
+ dependsOn: ['crawl'],
295
+ },
296
+ {
297
+ id: 'js-delta',
298
+ name: 'JS Rendering Delta',
299
+ description: 'Compare raw HTML vs rendered DOM — find pages with hidden JS-only content',
300
+ requires: ['playwright'],
301
+ inputs: { project: 'string', options: { domain: 'string', maxPages: 'number', threshold: 'number', format: 'json|brief' } },
302
+ outputs: { results: 'array<RenderDelta>', summary: 'object' },
303
+ phase: 'analyze',
304
+ tier: 'pro',
305
+ dependsOn: ['crawl'],
306
+ },
307
+ {
308
+ id: 'export-actions',
309
+ name: 'Technical Action Export',
310
+ description: 'Generate prioritised technical SEO fix list from crawl data',
311
+ requires: [],
312
+ inputs: { project: 'string', options: { scope: 'string', format: 'json|brief' } },
313
+ outputs: { actions: 'array<Action>' },
314
+ phase: 'export',
315
+ tier: 'free',
316
+ dependsOn: ['crawl'],
317
+ },
318
+ {
319
+ id: 'competitive-actions',
320
+ name: 'Competitive Action Export',
321
+ description: 'Generate competitive intelligence action list — what to build based on competitor analysis',
322
+ requires: [],
323
+ inputs: { project: 'string', options: { format: 'json|brief' } },
324
+ outputs: { actions: 'array<Action>' },
325
+ phase: 'export',
326
+ tier: 'free',
327
+ dependsOn: ['crawl'],
328
+ },
329
+ {
330
+ id: 'suggest-usecases',
331
+ name: 'Use Case Suggestions',
332
+ description: 'AI-suggested pages/features to build based on competitor patterns',
333
+ requires: [],
334
+ inputs: { project: 'string', options: { format: 'json|brief' } },
335
+ outputs: { suggestions: 'array<Action>' },
336
+ phase: 'export',
337
+ tier: 'free',
338
+ dependsOn: ['crawl'],
339
+ },
340
+ {
341
+ id: 'blog-draft',
342
+ name: 'AEO Blog Draft Generator',
343
+ description: 'Generate AEO-optimised blog post drafts from Intelligence Ledger data',
344
+ requires: ['cloud-llm'],
345
+ inputs: { project: 'string', options: { topic: 'string', lang: 'string', model: 'string' } },
346
+ outputs: { draft: 'string', context: 'object' },
347
+ modelHint: 'cloud-medium',
348
+ modelNote: 'Sonnet or equivalent — needs creative + strategic reasoning for quality drafts.',
349
+ phase: 'create',
350
+ tier: 'pro',
351
+ dependsOn: ['crawl', 'extract', 'aeo'],
352
+ },
353
+ ];
354
+
355
+ /**
356
+ * Dependency graph for agent orchestration.
357
+ * Agents should follow this order: collect → extract → analyze → report → create
358
+ */
359
+ export const pipeline = {
360
+ phases: ['collect', 'extract', 'analyze', 'report', 'create'],
361
+ graph: {
362
+ crawl: [],
363
+ extract: ['crawl'],
364
+ aeo: ['crawl'],
365
+ rescore: ['aeo'],
366
+ watch: ['crawl'],
367
+ 'gap-intel': ['crawl', 'extract'],
368
+ shallow: ['crawl'],
369
+ decay: ['crawl'],
370
+ 'headings-audit': ['crawl'],
371
+ orphans: ['extract'],
372
+ entities: ['extract'],
373
+ schemas: ['crawl'],
374
+ friction: ['extract'],
375
+ brief: ['crawl'],
376
+ velocity: ['crawl'],
377
+ 'js-delta': ['crawl'],
378
+ 'export-actions': ['crawl'],
379
+ 'competitive-actions': ['crawl'],
380
+ 'suggest-usecases': ['crawl'],
381
+ 'blog-draft': ['crawl', 'extract', 'aeo'],
382
+ },
383
+ };
384
+
385
+ // ═══════════════════════════════════════════════════════════════════════════
386
+ // UNIFIED RUNNER
387
+ // ═══════════════════════════════════════════════════════════════════════════
388
+
389
+ /**
390
+ * Run any SEO Intel command and get structured JSON back.
391
+ *
392
+ * @param {string} command - Command ID (e.g. 'aeo', 'shallow', 'gap-intel')
393
+ * @param {string} project - Project name
394
+ * @param {object} [opts={}] - Command-specific options
395
+ * @returns {Promise<{ok: boolean, command: string, project: string, timestamp: string, data?: object, error?: string}>}
396
+ *
397
+ * Usage:
398
+ * const result = await run('aeo', 'carbium');
399
+ * const result = await run('gap-intel', 'carbium', { vs: ['helius.dev'] });
400
+ * const result = await run('shallow', 'carbium', { maxWords: 500 });
401
+ */
402
+ export async function run(command, project, opts = {}) {
403
+ const timestamp = new Date().toISOString();
404
+ const wrap = (data) => ({ ok: true, command, project, timestamp, data });
405
+ const fail = (error) => ({ ok: false, command, project, timestamp, error });
406
+
407
+ try {
408
+ const db = getDb();
409
+ const config = loadConfig(project);
410
+ if (!config && !['status'].includes(command)) {
411
+ return fail(`Project "${project}" not configured. Available: ${listProjects().map(p => p.name).join(', ')}`);
412
+ }
413
+
414
+ switch (command) {
415
+ // ── Analysis commands (return structured data) ──
416
+
417
+ case 'aeo': {
418
+ const { runAeoAnalysis } = await import('./analyses/aeo/index.js');
419
+ const result = await runAeoAnalysis(db, project, {
420
+ ...opts,
421
+ log: opts.log || (() => {}),
422
+ });
423
+ return wrap(result);
424
+ }
425
+
426
+ case 'rescore': {
427
+ if (!opts.url) return fail('rescore requires opts.url (the page to re-check)');
428
+ const { rescorePage } = await import('./analyses/aeo/rescore.js');
429
+ const result = await rescorePage(db, project, opts.url, { log: opts.log });
430
+ return wrap(result);
431
+ }
432
+
433
+ case 'watch': {
434
+ const { runWatch } = await import('./analyses/watch/index.js');
435
+ const result = runWatch(db, project, { log: opts.log || (() => {}) });
436
+ return wrap(result);
437
+ }
438
+
439
+ case 'gap-intel': {
440
+ const { runGapIntel } = await import('./analyses/gap-intel/index.js');
441
+ const vs = Array.isArray(opts.vs) ? opts.vs : (opts.vs ? opts.vs.split(',') : []);
442
+ const report = await runGapIntel(db, project, config, {
443
+ vs,
444
+ type: opts.type || 'all',
445
+ limit: opts.limit || 100,
446
+ raw: opts.raw || false,
447
+ log: opts.log || (() => {}),
448
+ });
449
+ return wrap({ report });
450
+ }
451
+
452
+ case 'shallow': {
453
+ const maxWords = parseInt(opts.maxWords) || 700;
454
+ const maxDepth = parseInt(opts.maxDepth) || 2;
455
+ const rows = db.prepare(`
456
+ SELECT p.url, p.click_depth, p.word_count, d.domain
457
+ FROM pages p JOIN domains d ON d.id = p.domain_id
458
+ WHERE d.project = ? AND d.role = 'competitor'
459
+ AND p.click_depth <= ? AND p.word_count <= ? AND p.word_count > 80
460
+ AND p.is_indexable = 1
461
+ ORDER BY p.click_depth ASC, p.word_count ASC
462
+ `).all(project, maxDepth, maxWords).filter(r => isContentPage(r.url));
463
+
464
+ return wrap({
465
+ targets: rows.map(r => ({ url: r.url, domain: r.domain, wordCount: r.word_count, clickDepth: r.click_depth })),
466
+ totalTargets: rows.length,
467
+ });
468
+ }
469
+
470
+ case 'decay': {
471
+ const monthsAgo = parseInt(opts.months) || 18;
472
+ const cutoffDate = new Date();
473
+ cutoffDate.setMonth(cutoffDate.getMonth() - monthsAgo);
474
+ const cutoff = cutoffDate.toISOString().split('T')[0];
475
+
476
+ const staleKnown = db.prepare(`
477
+ SELECT p.url, p.click_depth, p.word_count, p.modified_date, d.domain
478
+ FROM pages p JOIN domains d ON d.id = p.domain_id
479
+ WHERE d.project = ? AND d.role = 'competitor'
480
+ AND p.click_depth <= 2 AND p.word_count > 100
481
+ AND p.modified_date IS NOT NULL AND p.modified_date < ?
482
+ AND p.is_indexable = 1
483
+ ORDER BY p.modified_date ASC
484
+ `).all(project, cutoff).filter(r => isContentPage(r.url));
485
+
486
+ const staleUnknown = db.prepare(`
487
+ SELECT p.url, p.click_depth, p.word_count, d.domain
488
+ FROM pages p JOIN domains d ON d.id = p.domain_id
489
+ WHERE d.project = ? AND d.role = 'competitor'
490
+ AND p.click_depth <= 2 AND p.word_count BETWEEN 300 AND 1500
491
+ AND p.modified_date IS NULL AND p.published_date IS NULL
492
+ AND p.is_indexable = 1
493
+ ORDER BY p.word_count ASC LIMIT 20
494
+ `).all(project).filter(r => isContentPage(r.url));
495
+
496
+ return wrap({
497
+ confirmedStale: staleKnown.map(r => ({ url: r.url, domain: r.domain, wordCount: r.word_count, modifiedDate: r.modified_date, clickDepth: r.click_depth })),
498
+ unknownFreshness: staleUnknown.map(r => ({ url: r.url, domain: r.domain, wordCount: r.word_count, clickDepth: r.click_depth })),
499
+ monthsThreshold: monthsAgo,
500
+ });
501
+ }
502
+
503
+ case 'orphans': {
504
+ const extractions = db.prepare(`
505
+ SELECT e.primary_entities, p.url, d.domain
506
+ FROM extractions e JOIN pages p ON p.id = e.page_id JOIN domains d ON d.id = p.domain_id
507
+ WHERE d.project = ? AND d.role = 'competitor'
508
+ AND e.primary_entities IS NOT NULL AND e.primary_entities != ''
509
+ `).all(project);
510
+
511
+ const entityMap = new Map();
512
+ for (const row of extractions) {
513
+ let entities = [];
514
+ try { entities = JSON.parse(row.primary_entities); } catch {}
515
+ for (const entity of entities) {
516
+ const key = entity.toLowerCase().trim();
517
+ if (!entityMap.has(key)) entityMap.set(key, new Set());
518
+ entityMap.get(key).add(row.domain);
519
+ }
520
+ }
521
+
522
+ const allUrls = db.prepare(`
523
+ SELECT p.url FROM pages p JOIN domains d ON d.id = p.domain_id
524
+ WHERE d.project = ? AND d.role = 'competitor'
525
+ `).all(project).map(r => r.url.toLowerCase());
526
+
527
+ const orphans = [];
528
+ for (const [entity, domains] of entityMap.entries()) {
529
+ if (domains.size < 2) continue;
530
+ const slug = entity.replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
531
+ const hasDedicatedPage = allUrls.some(u => u.includes(slug));
532
+ if (!hasDedicatedPage) {
533
+ orphans.push({ entity, domains: [...domains], domainCount: domains.size, suggestedUrl: '/solutions/' + slug });
534
+ }
535
+ }
536
+ orphans.sort((a, b) => b.domainCount - a.domainCount);
537
+
538
+ return wrap({ orphans, totalOrphans: orphans.length });
539
+ }
540
+
541
+ case 'entities': {
542
+ const minMentions = parseInt(opts.minMentions) || 2;
543
+ const allExtractions = db.prepare(`
544
+ SELECT e.primary_entities, d.domain, d.role, p.url
545
+ FROM extractions e JOIN pages p ON p.id = e.page_id JOIN domains d ON d.id = p.domain_id
546
+ WHERE d.project = ? AND e.primary_entities IS NOT NULL AND e.primary_entities != '[]' AND e.primary_entities != ''
547
+ `).all(project);
548
+
549
+ const entityMap = new Map();
550
+ for (const row of allExtractions) {
551
+ let entities = [];
552
+ try { entities = JSON.parse(row.primary_entities); } catch { continue; }
553
+ for (const entity of entities) {
554
+ const key = entity.toLowerCase().trim();
555
+ if (key.length < 2) continue;
556
+ if (!entityMap.has(key)) entityMap.set(key, { target: new Set(), competitor: new Set(), owned: new Set() });
557
+ const e = entityMap.get(key);
558
+ if (row.role === 'target') e.target.add(row.domain);
559
+ else if (row.role === 'owned') e.owned.add(row.domain);
560
+ else e.competitor.add(row.domain);
561
+ }
562
+ }
563
+
564
+ const gaps = [], shared = [], unique = [];
565
+ for (const [entity, data] of entityMap) {
566
+ const compCount = data.competitor.size;
567
+ const hasTarget = data.target.size > 0 || data.owned.size > 0;
568
+ if (compCount >= minMentions && !hasTarget) gaps.push({ entity, competitorCount: compCount, domains: [...data.competitor] });
569
+ else if (compCount > 0 && hasTarget) shared.push({ entity, competitorCount: compCount, targetDomains: [...data.target, ...data.owned], competitorDomains: [...data.competitor] });
570
+ else if (compCount === 0 && hasTarget) unique.push({ entity, targetDomains: [...data.target, ...data.owned] });
571
+ }
572
+ gaps.sort((a, b) => b.competitorCount - a.competitorCount);
573
+
574
+ return wrap({ gaps, shared, unique, summary: { totalEntities: entityMap.size, gapCount: gaps.length, sharedCount: shared.length, uniqueCount: unique.length } });
575
+ }
576
+
577
+ case 'schemas': {
578
+ const rows = getSchemasByProject(db, project);
579
+ const byDomain = new Map();
580
+ for (const row of rows) {
581
+ if (!byDomain.has(row.domain)) byDomain.set(row.domain, []);
582
+ byDomain.get(row.domain).push(row);
583
+ }
584
+ const allTypes = [...new Set(rows.map(r => r.schema_type))].sort();
585
+
586
+ let targetDomain = null;
587
+ try { targetDomain = config?.target?.domain; } catch {}
588
+ const targetTypes = new Set((byDomain.get(targetDomain) || []).map(s => s.schema_type));
589
+ const compTypes = new Set(rows.filter(r => r.domain !== targetDomain).map(r => r.schema_type));
590
+ const schemaGaps = [...compTypes].filter(t => !targetTypes.has(t));
591
+ const exclusives = [...targetTypes].filter(t => !compTypes.has(t));
592
+
593
+ return wrap({
594
+ coverageMatrix: Object.fromEntries([...byDomain.entries()].map(([dom, schemas]) => [dom, schemas.map(s => ({ type: s.schema_type, url: s.url, name: s.name, rating: s.rating, price: s.price }))])),
595
+ gaps: schemaGaps,
596
+ exclusives,
597
+ ratings: rows.filter(r => r.rating !== null).map(r => ({ domain: r.domain, url: r.url, rating: r.rating, ratingCount: r.rating_count })),
598
+ pricing: rows.filter(r => r.price !== null).map(r => ({ domain: r.domain, url: r.url, price: r.price, currency: r.currency })),
599
+ summary: { totalSchemas: rows.length, uniqueTypes: allTypes.length, domainsWithSchemas: byDomain.size, gapCount: schemaGaps.length },
600
+ });
601
+ }
602
+
603
+ case 'friction': {
604
+ const rows = db.prepare(`
605
+ SELECT e.search_intent, e.cta_primary, e.pricing_tier, p.url, p.word_count, d.domain
606
+ FROM extractions e JOIN pages p ON p.id = e.page_id JOIN domains d ON d.id = p.domain_id
607
+ WHERE d.project = ? AND d.role = 'competitor'
608
+ AND e.search_intent IS NOT NULL AND e.cta_primary IS NOT NULL
609
+ ORDER BY d.domain
610
+ `).all(project).filter(r => isContentPage(r.url));
611
+
612
+ const highFrictionCTAs = ['enterprise', 'sales', 'contact', 'book a demo', 'request', 'talk to'];
613
+ const targets = rows.filter(r => {
614
+ const cta = (r.cta_primary || '').toLowerCase();
615
+ const intent = (r.search_intent || '').toLowerCase();
616
+ return highFrictionCTAs.some(f => cta.includes(f)) && (intent.includes('informational') || intent.includes('commercial'));
617
+ });
618
+
619
+ return wrap({
620
+ targets: targets.map(t => ({ url: t.url, domain: t.domain, searchIntent: t.search_intent, ctaPrimary: t.cta_primary, pricingTier: t.pricing_tier, wordCount: t.word_count })),
621
+ totalAnalyzed: rows.length,
622
+ totalHighFriction: targets.length,
623
+ });
624
+ }
625
+
626
+ case 'velocity': {
627
+ const days = parseInt(opts.days) || 30;
628
+ const cutoff = Date.now() - (days * 24 * 60 * 60 * 1000);
629
+
630
+ const newPages = db.prepare(`
631
+ SELECT d.domain, d.role, p.url, p.first_seen_at, p.published_date, p.word_count
632
+ FROM pages p JOIN domains d ON d.id = p.domain_id
633
+ WHERE d.project = ? AND p.first_seen_at > ? AND p.is_indexable = 1
634
+ ORDER BY p.first_seen_at DESC
635
+ `).all(project, cutoff).filter(r => isContentPage(r.url));
636
+
637
+ const totals = db.prepare(`
638
+ SELECT d.domain, d.role, COUNT(*) as total_pages
639
+ FROM pages p JOIN domains d ON d.id = p.domain_id
640
+ WHERE d.project = ? AND p.is_indexable = 1
641
+ GROUP BY d.domain
642
+ `).all(project);
643
+
644
+ const domainNewMap = {};
645
+ for (const np of newPages) {
646
+ if (!domainNewMap[np.domain]) domainNewMap[np.domain] = [];
647
+ domainNewMap[np.domain].push(np);
648
+ }
649
+
650
+ const velocities = totals.map(t => {
651
+ const newCount = (domainNewMap[t.domain] || []).length;
652
+ const ratePerWeek = days > 0 ? +(newCount / (days / 7)).toFixed(1) : 0;
653
+ return { domain: t.domain, role: t.role, totalPages: t.total_pages, newPages: newCount, ratePerWeek };
654
+ });
655
+
656
+ return wrap({ velocities, period: { days, cutoff: new Date(cutoff).toISOString() } });
657
+ }
658
+
659
+ case 'brief': {
660
+ const days = parseInt(opts.days) || 7;
661
+ const cutoff = Date.now() - (days * 24 * 60 * 60 * 1000);
662
+ const compDomains = (config?.competitors || []).map(c => c.domain);
663
+
664
+ const competitorMoves = [];
665
+ for (const comp of compDomains) {
666
+ const newPages = db.prepare(`
667
+ SELECT p.url, p.word_count FROM pages p JOIN domains d ON d.id = p.domain_id
668
+ WHERE d.domain = ? AND d.project = ? AND p.first_seen_at > ? AND p.is_indexable = 1
669
+ `).all(comp, project, cutoff).filter(r => isContentPage(r.url));
670
+
671
+ const changedPages = db.prepare(`
672
+ SELECT p.url, p.word_count FROM pages p JOIN domains d ON d.id = p.domain_id
673
+ WHERE d.domain = ? AND d.project = ? AND p.crawled_at > ? AND p.first_seen_at < ? AND p.is_indexable = 1
674
+ `).all(comp, project, cutoff, cutoff).filter(r => isContentPage(r.url));
675
+
676
+ competitorMoves.push({
677
+ domain: comp,
678
+ newPages: newPages.map(p => ({ url: p.url, wordCount: p.word_count })),
679
+ changedPages: changedPages.map(p => ({ url: p.url, wordCount: p.word_count })),
680
+ });
681
+ }
682
+
683
+ return wrap({ competitorMoves, period: { days, weekOf: new Date().toISOString().slice(0, 10) } });
684
+ }
685
+
686
+ // ── Export commands ──
687
+
688
+ case 'export-actions': {
689
+ const { buildTechnicalActions } = await import('./exports/technical.js');
690
+ return wrap({ actions: buildTechnicalActions(db, project) });
691
+ }
692
+
693
+ case 'competitive-actions': {
694
+ const { buildCompetitiveActions } = await import('./exports/competitive.js');
695
+ return wrap({ actions: buildCompetitiveActions(db, project, opts) });
696
+ }
697
+
698
+ case 'suggest-usecases': {
699
+ const { buildSuggestiveActions } = await import('./exports/suggestive.js');
700
+ return wrap({ actions: buildSuggestiveActions(db, project, opts) });
701
+ }
702
+
703
+ // ── Blog draft ──
704
+
705
+ case 'blog-draft': {
706
+ const { gatherBlogDraftContext, buildBlogDraftPrompt } = await import('./analyses/blog-draft/index.js');
707
+ const context = await gatherBlogDraftContext(db, project, opts.topic);
708
+ const prompt = buildBlogDraftPrompt(context, { config, lang: opts.lang || 'en', topic: opts.topic });
709
+ return wrap({ context, prompt });
710
+ }
711
+
712
+ // ── Intelligence Ledger ──
713
+
714
+ case 'insights': {
715
+ const insights = getActiveInsights(db, project);
716
+ return wrap({ insights, totalActive: insights.length });
717
+ }
718
+
719
+ // ── Headings Audit ──
720
+
721
+ case 'headings-audit': {
722
+ const maxDepth = parseInt(opts.depth) || 2;
723
+ const domainFilter = opts.domain ? 'AND d.domain = ?' : '';
724
+ const params = opts.domain ? [project, maxDepth, opts.domain] : [project, maxDepth];
725
+
726
+ const pages = db.prepare(`
727
+ SELECT p.id, p.url, p.word_count, p.click_depth, d.domain
728
+ FROM pages p JOIN domains d ON d.id = p.domain_id
729
+ WHERE d.project = ? AND d.role = 'competitor'
730
+ AND p.click_depth <= ? AND p.word_count > 200 ${domainFilter}
731
+ AND p.is_indexable = 1
732
+ ORDER BY d.domain, p.click_depth ASC
733
+ `).all(...params).filter(r => isContentPage(r.url));
734
+
735
+ const results = [];
736
+ for (const page of pages.slice(0, 30)) {
737
+ const headings = db.prepare('SELECT level, text FROM headings WHERE page_id = ? ORDER BY rowid ASC').all(page.id);
738
+ if (!headings.length) continue;
739
+ results.push({ url: page.url, domain: page.domain, wordCount: page.word_count, clickDepth: page.click_depth, headings: headings.map(h => ({ level: h.level, text: h.text })) });
740
+ }
741
+ return wrap({ pages: results, totalPages: results.length });
742
+ }
743
+
744
+ // ── JS Rendering Delta ──
745
+
746
+ case 'js-delta': {
747
+ // This requires Playwright — return instructions if called from agent
748
+ return fail('js-delta requires Playwright browser automation. Use the CLI: seo-intel js-delta ' + project + ' --format json');
749
+ }
750
+
751
+ // ── Templates ──
752
+
753
+ case 'templates': {
754
+ const { runTemplatesAnalysis } = await import('./analyses/templates/index.js');
755
+ const report = await runTemplatesAnalysis(project, {
756
+ log: opts.log || (() => {}),
757
+ minGroupSize: opts.minGroupSize || 10,
758
+ sampleSize: opts.sampleSize || 20,
759
+ });
760
+ return wrap(report);
761
+ }
762
+
763
+ // ── Crawl ──
764
+
765
+ case 'crawl': {
766
+ const { crawlDomain } = await import('./crawler/index.js');
767
+ const config_ = loadConfig(project);
768
+ if (!config_) return fail(`Project "${project}" not configured`);
769
+
770
+ const targetUrl = config_.target.url || `https://${config_.target.domain}`;
771
+ const maxPages = opts.maxPages || 200;
772
+ let pagesFound = 0;
773
+ const pageSummary = [];
774
+
775
+ for await (const page of crawlDomain(targetUrl, {
776
+ maxPages,
777
+ stealth: opts.stealth || false,
778
+ ...opts,
779
+ })) {
780
+ pagesFound++;
781
+ pageSummary.push({ url: page.url, status: page.statusCode, depth: page.depth, wordCount: page.wordCount || 0 });
782
+ if (opts.onPage) opts.onPage(page);
783
+ }
784
+
785
+ return wrap({ pagesFound, pages: pageSummary.slice(0, 50), targetUrl });
786
+ }
787
+
788
+ // ── Extract ──
789
+
790
+ case 'extract': {
791
+ const { extractPage, pingOllamaHost } = await import('./extractor/qwen.js');
792
+ const ollamaHost = process.env.OLLAMA_HOST || 'http://127.0.0.1:11434';
793
+ const model = opts.model || process.env.OLLAMA_MODEL || 'gemma4:e4b';
794
+
795
+ // Preflight check
796
+ const ping = await pingOllamaHost(ollamaHost, model).catch(() => null);
797
+ if (!ping) return fail(`Ollama not reachable at ${ollamaHost} or model "${model}" not available`);
798
+
799
+ // Get pages needing extraction
800
+ const pages = db.prepare(`
801
+ SELECT p.id, p.url, p.title, p.meta_desc, p.body_text, p.published_date, p.modified_date
802
+ FROM pages p JOIN domains d ON d.id = p.domain_id
803
+ LEFT JOIN extractions e ON e.page_id = p.id
804
+ WHERE d.project = ? AND p.status_code = 200 AND p.body_text IS NOT NULL AND p.body_text != ''
805
+ AND e.id IS NULL
806
+ ORDER BY p.click_depth ASC
807
+ LIMIT ?
808
+ `).all(project, opts.limit || 500);
809
+
810
+ if (!pages.length) return wrap({ extracted: 0, message: 'All pages already extracted' });
811
+
812
+ let extracted = 0, failed = 0;
813
+ for (const page of pages) {
814
+ try {
815
+ const headings = db.prepare('SELECT level, text FROM headings WHERE page_id = ?').all(page.id);
816
+ const schemas = db.prepare('SELECT schema_type FROM page_schemas WHERE page_id = ?').all(page.id);
817
+
818
+ await extractPage({
819
+ url: page.url,
820
+ title: page.title,
821
+ metaDesc: page.meta_desc,
822
+ headings: headings.map(h => ({ level: h.level, text: h.text })),
823
+ bodyText: page.body_text,
824
+ schemaTypes: schemas.map(s => s.schema_type),
825
+ publishedDate: page.published_date,
826
+ modifiedDate: page.modified_date,
827
+ });
828
+ extracted++;
829
+ if (opts.onExtract) opts.onExtract({ url: page.url, index: extracted });
830
+ } catch {
831
+ failed++;
832
+ }
833
+ }
834
+
835
+ return wrap({ extracted, failed, totalPending: pages.length });
836
+ }
837
+
838
+ // ── Status ──
839
+
840
+ case 'status': {
841
+ const projects = listProjects();
842
+ return wrap({ projects, totalProjects: projects.length });
843
+ }
844
+
845
+ default:
846
+ return fail(`Unknown command: "${command}". Available: ${capabilities.map(c => c.id).join(', ')}`);
847
+ }
848
+ } catch (e) {
849
+ return fail(e.message);
850
+ }
851
+ }
@@ -0,0 +1,59 @@
1
+ // rescore.js — re-check a single URL's AI citability after a fix.
2
+ //
3
+ // Read-only re-measurement (SEO Intel mutates nothing). Uses the lightweight
4
+ // HTTP crawl — the RAW-HTML / "what bots see" lens: if an agent's fix is
5
+ // server-rendered, the score moves; if it's JS-only, it correctly does not.
6
+ // This closes the agent loop: act → re-score → see the delta.
7
+ import { lightCrawl } from '../../crawler/light.js';
8
+
9
+ const bucket = (s) => (s == null ? null : s >= 75 ? 'good' : s >= 55 ? 'needs-work' : s >= 35 ? 'weak' : 'poor');
10
+
11
+ /**
12
+ * @param {object} db open SQLite handle (for the stored baseline)
13
+ * @param {string} project project name (scopes the baseline lookup)
14
+ * @param {string} url the URL to re-score
15
+ * @param {object} [opts] { log?: (msg)=>void }
16
+ * @returns {Promise<{url, status_code, lens, before, after, delta, improved, status_before, status_after, signals, note}>}
17
+ */
18
+ export async function rescorePage(db, project, url, opts = {}) {
19
+ // Baseline = most recent stored citability score (from the last full crawl).
20
+ let before = null;
21
+ try {
22
+ const row = db.prepare(`
23
+ SELECT c.score
24
+ FROM citability_scores c
25
+ JOIN pages p ON p.id = c.page_id
26
+ JOIN domains d ON d.id = p.domain_id
27
+ WHERE d.project = ? AND p.url = ?
28
+ ORDER BY c.scored_at DESC
29
+ LIMIT 1
30
+ `).get(project, url);
31
+ before = row ? row.score : null;
32
+ } catch { /* citability_scores table may not exist yet */ }
33
+
34
+ const r = await lightCrawl(url, {
35
+ maxPages: 1,
36
+ includeCitability: true,
37
+ sameOrigin: true,
38
+ onProgress: opts.log,
39
+ });
40
+
41
+ const page = (r.pages || [])[0] || null;
42
+ const cite = page && page.citability && !page.citability.error ? page.citability : null;
43
+ const after = cite ? cite.score : null;
44
+ const delta = before != null && after != null ? after - before : null;
45
+
46
+ return {
47
+ url,
48
+ status_code: page ? page.status_code : null,
49
+ lens: 'raw-html', // bot's-eye view — what crawlers / LLMs actually see
50
+ before, // last full-crawl score (baseline)
51
+ after, // current raw / bot-visible score
52
+ delta,
53
+ improved: delta != null ? delta > 0 : null,
54
+ status_before: bucket(before),
55
+ status_after: bucket(after),
56
+ signals: cite ? cite.breakdown : null,
57
+ note: cite ? null : ((page && page.citability && page.citability.error) || 'no citability computed for this URL'),
58
+ };
59
+ }
package/cli.js CHANGED
@@ -16,7 +16,7 @@ import { program } from 'commander';
16
16
  import { spawnSync } from 'child_process';
17
17
  import { readFileSync, writeFileSync, readdirSync, unlinkSync, existsSync, mkdirSync } from 'fs';
18
18
  import { dirname, join } from 'path';
19
- import { totalmem } from 'os';
19
+ import { totalmem, homedir } from 'os';
20
20
  import { fileURLToPath } from 'url';
21
21
  import chalk from 'chalk';
22
22
 
@@ -53,6 +53,24 @@ import { getCurrentVersion, checkForUpdates, printUpdateNotice, forceUpdateCheck
53
53
 
54
54
  const __dirname = dirname(fileURLToPath(import.meta.url));
55
55
 
56
+ // Self-register where this checkout/install lives, in ~/.seo-intel/install.json.
57
+ // Any local agent harness (Hermes, a future Claude Code plugin, anything) can
58
+ // read this one file to find SEO Intel — no env vars, no hardcoded paths, no
59
+ // per-harness wiring. Cheap to refresh on every run; last run to execute wins,
60
+ // which is the right default for the common one-checkout-per-machine case.
61
+ function registerInstallLocation() {
62
+ try {
63
+ const dir = join(homedir(), '.seo-intel');
64
+ mkdirSync(dir, { recursive: true });
65
+ writeFileSync(join(dir, 'install.json'), JSON.stringify({
66
+ root: __dirname,
67
+ version: getCurrentVersion(),
68
+ updatedAt: new Date().toISOString(),
69
+ }, null, 2));
70
+ } catch { /* non-fatal — harnesses fall back to PATH resolution */ }
71
+ }
72
+ registerInstallLocation();
73
+
56
74
  // Start background update check (non-blocking, never slows startup)
57
75
  checkForUpdates();
58
76
 
@@ -4634,6 +4652,39 @@ program
4634
4652
  }
4635
4653
  });
4636
4654
 
4655
+ // ── RESCORE (single-URL re-check, closes the agent loop) ──────────────────
4656
+ program
4657
+ .command('rescore <project> <url>')
4658
+ .description("Re-check one URL's AI citability after a fix — read-only, raw-HTML (bot's-eye) score + delta vs baseline")
4659
+ .option('--format <type>', 'Output format: brief or json', 'brief')
4660
+ .action(async (project, url, opts) => {
4661
+ const isJson = opts.format === 'json';
4662
+ const { getDb } = await import('./db/db.js');
4663
+ const { rescorePage } = await import('./analyses/aeo/rescore.js');
4664
+ const db = getDb();
4665
+ const res = await rescorePage(db, project, url, {
4666
+ log: isJson ? undefined : (m) => console.log(chalk.gray(' ' + m)),
4667
+ });
4668
+ if (isJson) {
4669
+ console.log(JSON.stringify(res, null, 2));
4670
+ return;
4671
+ }
4672
+ const tone = (s) => (s == null ? chalk.gray : s >= 60 ? chalk.green : s >= 35 ? chalk.yellow : chalk.red);
4673
+ console.log('');
4674
+ console.log(chalk.bold(` 🔁 Re-score — ${url}`));
4675
+ console.log(chalk.gray(` lens: raw HTML (what bots see) · HTTP ${res.status_code ?? '—'}`));
4676
+ if (res.after == null) {
4677
+ console.log(chalk.red(` ✗ ${res.note || 'could not score'}`));
4678
+ } else {
4679
+ const b = res.before == null ? chalk.gray('—') : tone(res.before)(String(res.before));
4680
+ const a = tone(res.after)(String(res.after));
4681
+ const arrow =
4682
+ res.delta == null ? '' : res.delta > 0 ? chalk.green(` ▲ +${res.delta}`) : res.delta < 0 ? chalk.red(` ▼ ${res.delta}`) : chalk.gray(' ±0');
4683
+ console.log(` citability ${b} → ${a}${arrow} (${res.status_before ?? '—'} → ${res.status_after ?? '—'})`);
4684
+ }
4685
+ console.log('');
4686
+ });
4687
+
4637
4688
  // ── SITE WATCH ──────────────────────────────────────────────────────────
4638
4689
  program
4639
4690
  .command('watch <project>')
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "project": "myproject",
3
+ "sourcePath": "/absolute/path/to/the/site/source — a repo or a plain folder, both work. Omit if you don't want agent dispatch to know where the code lives.",
3
4
  "context": {
4
5
  "siteName": "My Project",
5
6
  "url": "https://example.com",
package/extractor/qwen.js CHANGED
@@ -24,14 +24,17 @@ function modelMatches(available, target) {
24
24
  // ── LM Studio support (OpenAI-compatible API) ──────────────────────────────
25
25
 
26
26
  /**
27
- * Ping an LM Studio host. Uses GET /api/v1/models instead of Ollama's /api/tags.
27
+ * Ping an LM Studio host. Uses the OpenAI-compatible GET /v1/models (returns
28
+ * { data: [{ id }] } and lists models that are actually loaded/servable) —
29
+ * NOT the native /api/v1/models, which returns a different shape ({ models:
30
+ * [{ key }] }) and lists every downloaded model regardless of load state.
28
31
  */
29
32
  export async function pingLmStudioHost(host, model, timeoutMs = OLLAMA_PREFLIGHT_TIMEOUT_MS) {
30
33
  const controller = new AbortController();
31
34
  const timer = setTimeout(() => controller.abort(), timeoutMs);
32
35
 
33
36
  try {
34
- const res = await fetch(`${host}/api/v1/models`, { signal: controller.signal });
37
+ const res = await fetch(`${host}/v1/models`, { signal: controller.signal });
35
38
  if (!res.ok) {
36
39
  return { host, model, reachable: false, modelAvailable: false, type: 'lmstudio',
37
40
  error: `HTTP ${res.status} ${res.statusText}`.trim() };
@@ -62,14 +65,17 @@ async function callLmStudio(route, prompt) {
62
65
  const timeout = setTimeout(() => controller.abort(), OLLAMA_TIMEOUT_MS);
63
66
 
64
67
  try {
65
- const res = await fetch(`${route.host}/api/v1/chat`, {
68
+ const res = await fetch(`${route.host}/v1/chat/completions`, {
66
69
  signal: controller.signal,
67
70
  method: 'POST',
68
71
  headers: { 'Content-Type': 'application/json' },
69
72
  body: JSON.stringify({
70
73
  model: route.model,
71
74
  messages: [{ role: 'user', content: prompt }],
72
- response_format: { type: 'json_object' },
75
+ // LM Studio rejects OpenAI's 'json_object'; it accepts only 'json_schema'
76
+ // or 'text'. The prompt already demands JSON and the response is run
77
+ // through extractLastJsonObject/repairJson, so 'text' is the portable choice.
78
+ response_format: { type: 'text' },
73
79
  temperature: 0,
74
80
  max_tokens: 1200,
75
81
  stream: false,
@@ -427,10 +433,20 @@ JSON output:`;
427
433
  console.log(`[extractor] used ${route.label} for ${url}`);
428
434
  break;
429
435
  } catch (err) {
430
- route.failures = (route.failures || 0) + 1;
431
436
  console.warn(`[extractor] ${route.label} failed for ${url}: ${describeOllamaError(err, route)}`);
432
- if (route.failures >= OLLAMA_HOST_FAILURE_LIMIT) {
433
- removeRouteFromActivePool(runtimeState, route);
437
+ // Only TRANSPORT failures (host down/unreachable) should retire a host for
438
+ // the run. CONTENT failures (a small model returns unparseable JSON for one
439
+ // long page) must NOT disable the host — that page degrades on its own; the
440
+ // next page may extract fine. Conflating the two killed local extraction
441
+ // after a couple of hard pages.
442
+ const msg = String(err?.message || '');
443
+ const transportFail = err?.name === 'AbortError'
444
+ || /fetch failed|ECONNREFUSED|ENOTFOUND|EHOSTUNREACH|socket hang up|network|timeout|HTTP 5\d\d/i.test(msg);
445
+ if (transportFail) {
446
+ route.failures = (route.failures || 0) + 1;
447
+ if (route.failures >= OLLAMA_HOST_FAILURE_LIMIT) {
448
+ removeRouteFromActivePool(runtimeState, route);
449
+ }
434
450
  }
435
451
  }
436
452
  }
package/lib/intel.js CHANGED
@@ -108,11 +108,112 @@ function collectRaw(db, project) {
108
108
  };
109
109
  }
110
110
 
111
+ // Map a page's weakest citability signal to a concrete on-page fix.
112
+ const SIGNAL_FIX = {
113
+ 'schema coverage': 'Add JSON-LD schema (FAQPage / Product / Organization)',
114
+ 'qa proximity': 'Add a Q&A / FAQ section answering real questions',
115
+ 'entity authority': 'Add author + credentials and sameAs entity grounding',
116
+ 'structured claims': 'Add extractable, standalone factual claims',
117
+ 'answer density': 'Lead each section with a direct one-line answer',
118
+ 'freshness': 'Add visible published / updated dates',
119
+ };
120
+ const scoreBucket = (s) => (s >= 75 ? 'good' : s >= 55 ? 'needs-work' : s >= 35 ? 'weak' : 'poor');
121
+
122
+ /**
123
+ * Ranked, role-attributed opportunities from citability scores. Each row is
124
+ * dashboard-ready: real value/status/confidence + a concrete suggested fix.
125
+ * side='fix' = your own pages to improve; side='attack' = thin competitor pages.
126
+ */
127
+ function buildOpportunities(db, project) {
128
+ let rows = [];
129
+ try {
130
+ rows = db.prepare(`
131
+ SELECT p.url, p.title, d.domain, d.role,
132
+ c.score, c.entity_authority, c.structured_claims, c.answer_density,
133
+ c.qa_proximity, c.freshness, c.schema_coverage, c.ai_intents
134
+ FROM citability_scores c
135
+ JOIN pages p ON p.id = c.page_id
136
+ JOIN domains d ON d.id = p.domain_id
137
+ WHERE d.project = ?
138
+ ORDER BY c.score ASC
139
+ `).all(project);
140
+ } catch { return { opportunities: [], summary: null }; }
141
+
142
+ const opportunities = rows.map((r, i) => {
143
+ const own = r.role === 'target' || r.role === 'owned';
144
+ const signals = {
145
+ 'entity authority': r.entity_authority, 'structured claims': r.structured_claims,
146
+ 'answer density': r.answer_density, 'qa proximity': r.qa_proximity,
147
+ 'freshness': r.freshness, 'schema coverage': r.schema_coverage,
148
+ };
149
+ const ranked = Object.entries(signals).filter(([, v]) => v != null).sort((a, b) => a[1] - b[1]);
150
+ const weak = ranked.slice(0, 2).map(([k]) => k);
151
+ let intent = null;
152
+ try { intent = (JSON.parse(r.ai_intents || '[]') || [])[0] || null; } catch { /* ignore */ }
153
+ let label = r.title || r.url;
154
+ try { const u = new URL(r.url); label = u.hostname + (u.pathname === '/' ? '' : u.pathname); } catch { /* keep label */ }
155
+ return {
156
+ id: `cit:${i}`,
157
+ side: own ? 'fix' : 'attack',
158
+ role: r.role,
159
+ domain: r.domain,
160
+ url: r.url,
161
+ title: r.title || null,
162
+ finding: label,
163
+ kind: 'citability',
164
+ score: r.score,
165
+ status: scoreBucket(r.score),
166
+ value: Math.round((100 - r.score) * (own ? 1 : 0.6)), // your low pages rank highest
167
+ confidence: +(ranked.length / 6).toFixed(2), // signal completeness
168
+ intent,
169
+ weak_signals: weak,
170
+ suggested_action: own ? (SIGNAL_FIX[weak[0]] || 'Improve on-page citability') : 'Outrank — thin competitor page',
171
+ proof: r.url,
172
+ };
173
+ }).sort((a, b) => b.value - a.value);
174
+
175
+ const owned = opportunities.filter((o) => o.side === 'fix');
176
+ const attack = opportunities.filter((o) => o.side === 'attack');
177
+ const avg = (arr) => (arr.length ? Math.round((arr.reduce((s, o) => s + o.score, 0) / arr.length) * 10) / 10 : null);
178
+
179
+ // Per-signal averages across YOUR pages — the AEO citability breakdown.
180
+ const ownedRows = rows.filter((r) => r.role === 'target' || r.role === 'owned');
181
+ const signalAvg = (key) => {
182
+ const vals = ownedRows.map((r) => r[key]).filter((v) => v != null);
183
+ return vals.length ? Math.round(vals.reduce((a, b) => a + b, 0) / vals.length) : null;
184
+ };
185
+ const signals = {
186
+ entity_authority: signalAvg('entity_authority'),
187
+ structured_claims: signalAvg('structured_claims'),
188
+ answer_density: signalAvg('answer_density'),
189
+ qa_proximity: signalAvg('qa_proximity'),
190
+ freshness: signalAvg('freshness'),
191
+ schema_coverage: signalAvg('schema_coverage'),
192
+ };
193
+
194
+ return {
195
+ opportunities,
196
+ summary: {
197
+ pages_scored: rows.length,
198
+ owned_pages: owned.length,
199
+ competitor_pages: attack.length,
200
+ avg_citability_owned: avg(owned),
201
+ avg_citability_all: avg(opportunities),
202
+ fix_opportunities: owned.length,
203
+ attack_targets: attack.length,
204
+ signals,
205
+ },
206
+ };
207
+ }
208
+
111
209
  function collectAudit(db, project) {
112
210
  const insights = getActiveInsights(db, project);
113
211
  let citability = null;
114
212
  try { citability = getCitabilityScores(db, project); } catch { /* citability_scores table may not exist if AEO never run */ }
213
+ const { opportunities, summary } = buildOpportunities(db, project);
115
214
  return {
215
+ summary,
216
+ opportunities,
116
217
  citability,
117
218
  insights: {
118
219
  keyword_gaps: insights.keyword_gaps,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seo-intel",
3
- "version": "1.5.50",
3
+ "version": "1.5.52",
4
4
  "description": "Local Ahrefs-style SEO competitor intelligence. Crawl → SQLite → cloud analysis.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -25,7 +25,8 @@
25
25
  },
26
26
  "exports": {
27
27
  ".": "./cli.js",
28
- "./froggo": "./froggo.js",
28
+ "./agent-harness": "./agent-harness.js",
29
+ "./froggo": "./agent-harness.js",
29
30
  "./aeo": "./analyses/aeo/index.js",
30
31
  "./aeo/scorer": "./analyses/aeo/scorer.js",
31
32
  "./gap-intel": "./analyses/gap-intel/index.js",
@@ -44,6 +45,7 @@
44
45
  },
45
46
  "files": [
46
47
  "cli.js",
48
+ "agent-harness.js",
47
49
  "server.js",
48
50
  "scheduler.js",
49
51
  "seo-audit.js",