diff --git a/package.json b/package.json index f4548f9a4..9a6b12c35 100644 --- a/package.json +++ b/package.json @@ -24,15 +24,16 @@ "build:blog": "cd blog-site && npm run build && rm -rf ../public/blog && mkdir -p ../public/blog && cp -r dist/* ../public/blog/", "build:pro": "cd pro-test && npm install && npm run build", "build:openapi": "node -e \"require('fs').cpSync('docs/api/worldmonitor.openapi.yaml', 'public/openapi.yaml')\"", - "prebuild": "npm run build:openapi", + "build:agent-skills": "node scripts/build-agent-skills-index.mjs", + "prebuild": "npm run build:openapi && npm run build:agent-skills", "build": "npm run build:blog && tsc && vite build", "build:sidecar-sebuf": "node scripts/build-sidecar-sebuf.mjs", "build:desktop": "node scripts/build-sidecar-sebuf.mjs && node scripts/build-sidecar-handlers.mjs && tsc && vite build", - "build:full": "npm run build:openapi && npm run build:blog && cross-env-shell VITE_VARIANT=full \"tsc && vite build\"", - "build:tech": "npm run build:openapi && cross-env-shell VITE_VARIANT=tech \"tsc && vite build\"", - "build:finance": "npm run build:openapi && cross-env-shell VITE_VARIANT=finance \"tsc && vite build\"", - "build:happy": "npm run build:openapi && cross-env-shell VITE_VARIANT=happy \"tsc && vite build\"", - "build:commodity": "npm run build:openapi && cross-env-shell VITE_VARIANT=commodity \"tsc && vite build\"", + "build:full": "npm run build:openapi && npm run build:agent-skills && npm run build:blog && cross-env-shell VITE_VARIANT=full \"tsc && vite build\"", + "build:tech": "npm run build:openapi && npm run build:agent-skills && cross-env-shell VITE_VARIANT=tech \"tsc && vite build\"", + "build:finance": "npm run build:openapi && npm run build:agent-skills && cross-env-shell VITE_VARIANT=finance \"tsc && vite build\"", + "build:happy": "npm run build:openapi && npm run build:agent-skills && cross-env-shell VITE_VARIANT=happy \"tsc && vite build\"", + "build:commodity": "npm run build:openapi && npm run build:agent-skills && cross-env-shell VITE_VARIANT=commodity \"tsc && vite build\"", "typecheck": "tsc --noEmit", "typecheck:api": "tsc --noEmit -p tsconfig.api.json", "typecheck:all": "tsc --noEmit && tsc --noEmit -p tsconfig.api.json", diff --git a/public/.well-known/agent-skills/fetch-country-brief/SKILL.md b/public/.well-known/agent-skills/fetch-country-brief/SKILL.md new file mode 100644 index 000000000..ed9f5ea48 --- /dev/null +++ b/public/.well-known/agent-skills/fetch-country-brief/SKILL.md @@ -0,0 +1,81 @@ +--- +name: fetch-country-brief +version: 1 +description: Retrieve the current AI-generated strategic intelligence brief for a country, keyed by ISO 3166-1 alpha-2 code. +--- + +# fetch-country-brief + +Use this skill when the user asks for a summary of the current geopolitical, economic, or security situation in a specific country. The endpoint returns a fresh AI-generated brief composed from the latest news, market, conflict, and infrastructure signals World Monitor tracks for that country. + +## Authentication + +Server-to-server callers (agents, scripts, SDKs) MUST present an API key in the `X-WorldMonitor-Key` header. `Authorization: Bearer …` is for MCP/OAuth or Clerk JWTs — **not** raw API keys. + +``` +X-WorldMonitor-Key: wm_live_... +``` + +Browser requests from `worldmonitor.app` get a free pass via CORS Origin trust, but agents will never hit that path. Issue a key at https://www.worldmonitor.app/pro. + +## Endpoint + +``` +GET https://api.worldmonitor.app/api/intelligence/v1/get-country-intel-brief +``` + +## Parameters + +| Name | In | Required | Shape | Notes | +|---|---|---|---|---| +| `country_code` | query | yes | ISO 3166-1 alpha-2, uppercase (e.g. `US`, `IR`, `KE`) | Case-sensitive server-side. Lowercase is rejected with 400. | +| `framework` | query | no | free text, ≤ 2000 chars | Optional analytical framing appended to the system prompt (e.g. `"focus on energy security"`). | + +## Response shape + +```json +{ + "countryCode": "IR", + "countryName": "Iran", + "brief": "Multi-paragraph AI-generated brief …", + "model": "gpt-4o-mini", + "generatedAt": 1745421600000 +} +``` + +`generatedAt` is Unix epoch milliseconds. `model` identifies which LLM produced the text. + +## Worked example + +```bash +curl -s -H "X-WorldMonitor-Key: $WM_API_KEY" \ + 'https://api.worldmonitor.app/api/intelligence/v1/get-country-intel-brief?country_code=IR' \ + | jq -r '.brief' +``` + +With an analytical framework: + +```bash +curl -s --get -H "X-WorldMonitor-Key: $WM_API_KEY" \ + 'https://api.worldmonitor.app/api/intelligence/v1/get-country-intel-brief' \ + --data-urlencode 'country_code=TR' \ + --data-urlencode 'framework=focus on energy corridors and Black Sea shipping' +``` + +## Errors + +- `400` — `country_code` missing, not 2 letters, or not uppercase. +- `401` — missing `X-WorldMonitor-Key` (server-to-server callers). +- `429` — rate limited; retry with backoff. +- `5xx` — transient upstream model failure; retry once after 2s. + +## When NOT to use + +- For rankings or comparisons across countries, use `fetch-resilience-score` per country and aggregate client-side, or call the `GetResilienceRanking` RPC directly. +- For raw news events rather than synthesized narrative, use `SearchGdeltDocuments` (`/api/intelligence/v1/search-gdelt-documents`). + +## References + +- OpenAPI: [IntelligenceService.openapi.yaml](https://www.worldmonitor.app/openapi.yaml) — operation `GetCountryIntelBrief`. +- Auth matrix: https://www.worldmonitor.app/docs/usage-auth +- Documentation: https://www.worldmonitor.app/docs/documentation diff --git a/public/.well-known/agent-skills/fetch-resilience-score/SKILL.md b/public/.well-known/agent-skills/fetch-resilience-score/SKILL.md new file mode 100644 index 000000000..a6b62cf38 --- /dev/null +++ b/public/.well-known/agent-skills/fetch-resilience-score/SKILL.md @@ -0,0 +1,87 @@ +--- +name: fetch-resilience-score +version: 1 +description: Retrieve the composite country resilience score (0-100) and its domain/pillar breakdown for a single country. +--- + +# fetch-resilience-score + +Use this skill when the user asks how "resilient" a country is, or wants the numeric resilience score, trend, or per-domain breakdown. The score is a composite of economic, institutional, security, social, infrastructure, and environmental indicators, recomputed daily. + +## Authentication — required + +`/api/resilience/v1/get-resilience-score` is Pro-tier. Agents and other server-to-server callers MUST present an API key in the `X-WorldMonitor-Key` header. `Authorization: Bearer …` is for MCP/OAuth or Clerk JWTs — **not** raw API keys. + +``` +X-WorldMonitor-Key: wm_live_... +``` + +The key must be attached to a Pro subscription. Unauthenticated or free-tier requests return `401` / `403`. Issue a key at https://www.worldmonitor.app/pro. + +## Endpoint + +``` +GET https://api.worldmonitor.app/api/resilience/v1/get-resilience-score +``` + +## Parameters + +| Name | In | Required | Shape | +|---|---|---|---| +| `countryCode` | query | yes | ISO 3166-1 alpha-2, uppercase (e.g. `DE`, `KE`, `BR`) | + +## Response shape + +```json +{ + "countryCode": "DE", + "overallScore": 78.4, + "level": "HIGH", + "trend": "STABLE", + "change30d": -0.2, + "lowConfidence": false, + "imputationShare": 0.04, + "baselineScore": 79.1, + "stressScore": 78.4, + "stressFactor": 0.99, + "dataVersion": "2026-04-23", + "scoreInterval": { "lower": 76.1, "upper": 80.7 }, + "domains": [ { "name": "Economic", "score": 82.1, "…": "…" } ], + "pillars": [ { "name": "Fiscal Capacity", "score": 80.0, "…": "…" } ] +} +``` + +Key fields for agents: + +- `overallScore` (0–100): headline number. +- `level`: `LOW` / `MODERATE` / `HIGH` / `VERY_HIGH` — human-readable bucket. +- `change30d`: rolling 30-day delta. +- `scoreInterval`: `{lower, upper}` confidence band — quote this when the user asks for precision. +- `domains` / `pillars`: drill-down components if the user asks "why". + +## Worked example + +```bash +curl -s -H "X-WorldMonitor-Key: $WM_API_KEY" \ + 'https://api.worldmonitor.app/api/resilience/v1/get-resilience-score?countryCode=DE' \ + | jq '{country: .countryCode, score: .overallScore, level, trend, change30d}' +``` + +## Errors + +- `400` — `countryCode` missing or malformed. +- `401` — missing `X-WorldMonitor-Key`. +- `403` — key present but not attached to a Pro-tier subscription. +- `404` — country not yet scored (rare; some micro-states). +- `429` — per-key rate limit hit. + +## When NOT to use + +- For a sorted list across all countries, call `GetResilienceRanking` (`/api/resilience/v1/get-resilience-ranking`) instead of N per-country calls. +- For a narrative summary rather than a number, use `fetch-country-brief`. + +## References + +- OpenAPI: [ResilienceService.openapi.yaml](https://www.worldmonitor.app/openapi.yaml) — operation `GetResilienceScore`. +- Auth matrix: https://www.worldmonitor.app/docs/usage-auth +- Methodology: https://www.worldmonitor.app/docs/documentation diff --git a/public/.well-known/agent-skills/index.json b/public/.well-known/agent-skills/index.json new file mode 100644 index 000000000..fb51f1a53 --- /dev/null +++ b/public/.well-known/agent-skills/index.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://agentskills.io/schemas/v0.2.0/index.json", + "skills": [ + { + "name": "fetch-country-brief", + "type": "task", + "description": "Retrieve the current AI-generated strategic intelligence brief for a country, keyed by ISO 3166-1 alpha-2 code.", + "url": "https://worldmonitor.app/.well-known/agent-skills/fetch-country-brief/SKILL.md", + "sha256": "82a76d25398856adaf72b5977d58585259caae7ce8cad24f5c2603fb2a3c76e0" + }, + { + "name": "fetch-resilience-score", + "type": "task", + "description": "Retrieve the composite country resilience score (0-100) and its domain/pillar breakdown for a single country.", + "url": "https://worldmonitor.app/.well-known/agent-skills/fetch-resilience-score/SKILL.md", + "sha256": "693cc336e0f5212de6ee462be69e6d987d652320f7770e1db2316800e31f21c0" + } + ] +} diff --git a/public/.well-known/api-catalog b/public/.well-known/api-catalog index 63700cf95..277b4065c 100644 --- a/public/.well-known/api-catalog +++ b/public/.well-known/api-catalog @@ -29,6 +29,15 @@ "type": "application/json" } ] + }, + { + "anchor": "https://worldmonitor.app/", + "agent-skills-index": [ + { + "href": "https://worldmonitor.app/.well-known/agent-skills/index.json", + "type": "application/json" + } + ] } ] } diff --git a/scripts/build-agent-skills-index.mjs b/scripts/build-agent-skills-index.mjs new file mode 100644 index 000000000..f2e7afb45 --- /dev/null +++ b/scripts/build-agent-skills-index.mjs @@ -0,0 +1,106 @@ +#!/usr/bin/env node +// Emits public/.well-known/agent-skills/index.json per the Agent Skills +// Discovery RFC v0.2.0. Each entry points at a SKILL.md and carries a +// sha256 of that file's exact served bytes, so agents can verify the +// skill text hasn't changed since they last fetched it. +// +// Source of truth: public/.well-known/agent-skills//SKILL.md +// Output: public/.well-known/agent-skills/index.json +// +// Run locally via `npm run build:agent-skills`. CI re-runs this and +// diffs the output against the committed index.json to block drift. + +import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { resolve, dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import yaml from 'js-yaml'; + +const __filename = fileURLToPath(import.meta.url); +const ROOT = resolve(dirname(__filename), '..'); +const SKILLS_DIR = resolve(ROOT, 'public/.well-known/agent-skills'); +const INDEX_PATH = join(SKILLS_DIR, 'index.json'); +const PUBLIC_BASE = 'https://worldmonitor.app'; + +const SCHEMA = 'https://agentskills.io/schemas/v0.2.0/index.json'; +// Closing fence must be anchored to its own line so values that happen to +// start with `---` in the body can't prematurely terminate frontmatter. +const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---(?:\n|$)/; + +function sha256Hex(bytes) { + return createHash('sha256').update(bytes).digest('hex'); +} + +export function parseFrontmatter(md) { + const match = FRONTMATTER_RE.exec(md); + if (!match) return {}; + const parsed = yaml.load(match[1]); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Frontmatter must be a YAML mapping'); + } + return parsed; +} + +function collectSkills() { + const entries = readdirSync(SKILLS_DIR, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name) + .sort(); + + return entries.map((name) => { + const skillPath = join(SKILLS_DIR, name, 'SKILL.md'); + const stat = statSync(skillPath); + if (!stat.isFile()) { + throw new Error(`Expected ${skillPath} to exist and be a file`); + } + const bytes = readFileSync(skillPath); + const md = bytes.toString('utf-8'); + const fm = parseFrontmatter(md); + if (!fm.description) { + throw new Error(`${skillPath} missing "description" in frontmatter`); + } + if (fm.name && fm.name !== name) { + throw new Error( + `${skillPath} frontmatter name="${fm.name}" disagrees with directory "${name}"`, + ); + } + return { + name, + type: 'task', + description: fm.description, + url: `${PUBLIC_BASE}/.well-known/agent-skills/${name}/SKILL.md`, + sha256: sha256Hex(bytes), + }; + }); +} + +function build() { + const skills = collectSkills(); + if (skills.length === 0) { + throw new Error(`No skills found under ${SKILLS_DIR}`); + } + const index = { $schema: SCHEMA, skills }; + return JSON.stringify(index, null, 2) + '\n'; +} + +function main() { + const content = build(); + const check = process.argv.includes('--check'); + if (check) { + const current = readFileSync(INDEX_PATH, 'utf-8'); + if (current !== content) { + process.stderr.write( + 'agent-skills index.json is out of date. Run `npm run build:agent-skills`.\n', + ); + process.exit(1); + } + process.stdout.write('agent-skills index.json is up to date.\n'); + return; + } + writeFileSync(INDEX_PATH, content); + process.stdout.write(`Wrote ${INDEX_PATH}\n`); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +} diff --git a/tests/agent-skills-index.test.mjs b/tests/agent-skills-index.test.mjs new file mode 100644 index 000000000..dace3cb92 --- /dev/null +++ b/tests/agent-skills-index.test.mjs @@ -0,0 +1,115 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { readFileSync, readdirSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseFrontmatter } from '../scripts/build-agent-skills-index.mjs'; + +const __filename = fileURLToPath(import.meta.url); +const ROOT = resolve(dirname(__filename), '..'); +const INDEX_PATH = join(ROOT, 'public/.well-known/agent-skills/index.json'); +const SKILLS_DIR = join(ROOT, 'public/.well-known/agent-skills'); + +// Guards for the Agent Skills discovery manifest (#3310 / epic #3306). +// Agents trust the index.json sha256 fields; if they drift from the +// served SKILL.md bytes, every downstream verification check fails. +describe('agent readiness: agent-skills index', () => { + it('index.json is up to date relative to SKILL.md sources', () => { + // `--check` exits non-zero if rebuilding the index would change it. + execFileSync( + process.execPath, + ['scripts/build-agent-skills-index.mjs', '--check'], + { cwd: ROOT, stdio: 'pipe' }, + ); + }); + + const index = JSON.parse(readFileSync(INDEX_PATH, 'utf-8')); + + it('declares the RFC v0.2.0 schema', () => { + assert.equal(index.$schema, 'https://agentskills.io/schemas/v0.2.0/index.json'); + }); + + it('advertises at least two skills (epic #3306 acceptance floor)', () => { + assert.ok(Array.isArray(index.skills)); + assert.ok(index.skills.length >= 2, `expected >=2 skills, got ${index.skills.length}`); + }); + + it('every entry points at a real SKILL.md whose bytes match the declared sha256', () => { + for (const skill of index.skills) { + assert.ok(skill.name, 'skill entry missing name'); + assert.equal(skill.type, 'task'); + assert.ok(skill.description && skill.description.length > 0, `${skill.name} missing description`); + assert.match( + skill.url, + /^https:\/\/worldmonitor\.app\/\.well-known\/agent-skills\/[^/]+\/SKILL\.md$/, + `${skill.name} url must be the canonical absolute URL`, + ); + const local = join(SKILLS_DIR, skill.name, 'SKILL.md'); + const bytes = readFileSync(local); + const hex = createHash('sha256').update(bytes).digest('hex'); + assert.equal( + skill.sha256, + hex, + `${skill.name} sha256 does not match ${local}`, + ); + } + }); + + it('every SKILL.md directory is represented in the index (no orphans)', () => { + const dirs = readdirSync(SKILLS_DIR, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name) + .sort(); + const names = index.skills.map((s) => s.name).sort(); + assert.deepEqual(names, dirs, 'every skill directory must have an index entry'); + }); +}); + +// Parser-contract tests for parseFrontmatter(). The previous hand-rolled +// parser matched `\n---` anywhere, so a body line beginning with `---` +// silently truncated the frontmatter. It also split on the first colon +// without YAML semantics, so quoted-colon values became brittle. Lock in +// the replacement's semantics so future edits don't regress either. +describe('agent-skills index: frontmatter parser', () => { + it('closing fence must be on its own line (body `---` does not terminate)', () => { + const md = [ + '---', + 'name: demo', + 'description: covers body that starts with three dashes', + '---', + '', + '--- this dash line is body text, not a fence ---', + 'More body.', + ].join('\n'); + const fm = parseFrontmatter(md); + assert.equal(fm.name, 'demo'); + assert.equal(fm.description, 'covers body that starts with three dashes'); + }); + + it('values containing colons are preserved (not truncated)', () => { + const md = [ + '---', + 'name: demo', + 'description: "Retrieve X: the composite value at a point in time"', + '---', + '', + 'body', + ].join('\n'); + const fm = parseFrontmatter(md); + assert.equal( + fm.description, + 'Retrieve X: the composite value at a point in time', + ); + }); + + it('rejects non-mapping frontmatter (e.g. a YAML list)', () => { + const md = ['---', '- a', '- b', '---', '', 'body'].join('\n'); + assert.throws(() => parseFrontmatter(md), /YAML mapping/); + }); + + it('returns empty object when no frontmatter present', () => { + assert.deepEqual(parseFrontmatter('# Just a markdown heading\n'), {}); + }); +}); diff --git a/tests/deploy-config.test.mjs b/tests/deploy-config.test.mjs index 6cbb21ee4..e644edcf1 100644 --- a/tests/deploy-config.test.mjs +++ b/tests/deploy-config.test.mjs @@ -509,6 +509,7 @@ describe('agent readiness: homepage Link headers', () => { 'rel="http://www.iana.org/assignments/relation/oauth-protected-resource"', 'rel="http://www.iana.org/assignments/relation/oauth-authorization-server"', 'rel="mcp-server-card"', + 'rel="agent-skills-index"', ]; for (const rel of requiredRels) { assert.ok( diff --git a/vercel.json b/vercel.json index 8ccd7538e..f798355ab 100644 --- a/vercel.json +++ b/vercel.json @@ -112,14 +112,14 @@ "source": "/", "headers": [ { "key": "Cache-Control", "value": "no-cache, no-store, must-revalidate" }, - { "key": "Link", "value": "; rel=\"api-catalog\"; type=\"application/linkset+json\", ; rel=\"service-desc\"; type=\"application/vnd.oai.openapi\", ; rel=\"service-doc\"; type=\"text/html\", ; rel=\"status\"; type=\"application/json\", ; rel=\"http://www.iana.org/assignments/relation/oauth-protected-resource\", ; rel=\"http://www.iana.org/assignments/relation/oauth-authorization-server\", ; rel=\"mcp-server-card\"; anchor=\"/mcp\"" } + { "key": "Link", "value": "; rel=\"api-catalog\"; type=\"application/linkset+json\", ; rel=\"service-desc\"; type=\"application/vnd.oai.openapi\", ; rel=\"service-doc\"; type=\"text/html\", ; rel=\"status\"; type=\"application/json\", ; rel=\"http://www.iana.org/assignments/relation/oauth-protected-resource\", ; rel=\"http://www.iana.org/assignments/relation/oauth-authorization-server\", ; rel=\"mcp-server-card\"; anchor=\"/mcp\", ; rel=\"agent-skills-index\"; type=\"application/json\"" } ] }, { "source": "/index.html", "headers": [ { "key": "Cache-Control", "value": "no-cache, no-store, must-revalidate" }, - { "key": "Link", "value": "; rel=\"api-catalog\"; type=\"application/linkset+json\", ; rel=\"service-desc\"; type=\"application/vnd.oai.openapi\", ; rel=\"service-doc\"; type=\"text/html\", ; rel=\"status\"; type=\"application/json\", ; rel=\"http://www.iana.org/assignments/relation/oauth-protected-resource\", ; rel=\"http://www.iana.org/assignments/relation/oauth-authorization-server\", ; rel=\"mcp-server-card\"; anchor=\"/mcp\"" } + { "key": "Link", "value": "; rel=\"api-catalog\"; type=\"application/linkset+json\", ; rel=\"service-desc\"; type=\"application/vnd.oai.openapi\", ; rel=\"service-doc\"; type=\"text/html\", ; rel=\"status\"; type=\"application/json\", ; rel=\"http://www.iana.org/assignments/relation/oauth-protected-resource\", ; rel=\"http://www.iana.org/assignments/relation/oauth-authorization-server\", ; rel=\"mcp-server-card\"; anchor=\"/mcp\", ; rel=\"agent-skills-index\"; type=\"application/json\"" } ] }, {