SPB Git

spb/websensor

Public

WebSensor v0.1.0 — real-time web intelligence platform

Core (taxonomy, SSRF policy, canonical extraction, diff engines, heuristics, scoring, adaptive schedule),
db (SQL migrations + Drizzle), content-addressed zstd blob store, connectors (http, rss/atom, sitemap,
statuspage, github, jsonlist, discovery, scrapfly fallback), engine (scheduler, pipeline, Claude
interpretation, entities, novelty/clustering, silent changes, metrics), Fastify gateway (REST + WebSocket +
proxy), Next.js 16 frontend, 200-source registry, tests, docs, mld manifest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Sep 8, 2026) Browse files

142 changed files +18,939 −0

142 changed files
added .env.example +30 −0
@@ -0,0 +1,30 @@
1+# WebSensor — environment (never commit real values)
2+NODE_ENV=development
3+DATABASE_URL=postgres://localhost:5432/websensor
4+REDIS_URL=redis://127.0.0.1:6379
5+
6+# Content-addressed blob store (fs driver). S3/MinIO driver can be plugged in later.
7+BLOB_STORE_DIR=./data/blobs
8+
9+PUBLIC_BASE_URL=http://localhost:8260
10+API_PORT=8260
11+API_HOST=127.0.0.1
12+WEB_PORT=8261
13+WEB_URL=http://127.0.0.1:8261
14+ENGINE_METRICS_PORT=8262
15+
16+# Optional acquisition fallbacks
17+FIRECRAWL_API_KEY=
18+SCRAPFLY_API_KEY=
19+
20+# AI interpretation (heuristics run without any key)
21+ANTHROPIC_API_KEY=
22+WS_LLM_MODEL_FAST=claude-haiku-4-5
23+WS_LLM_MODEL_DEEP=claude-opus-5
24+WS_LLM_DAILY_CALL_BUDGET=600
25+WS_LLM_MIN_IMPORTANCE=35
26+
27+# Engine tuning
28+WS_FETCH_CONCURRENCY=16
29+WS_USER_AGENT=WebSensorBot/0.1 (+https://www.websensor.io/bot)
30+WS_SOURCES_FILE=./config/sources.yaml
added .gitignore +17 −0
@@ -0,0 +1,17 @@
1+node_modules/
2+.next/
3+dist/
4+*.tsbuildinfo
5+.env
6+.env.*
7+!.env.example
8+logs/
9+tmp/
10+coverage/
11+.DS_Store
12+.claude/
13+deploy/*.mld.json
14+data/
15+qa/test-results/
16+qa/.e2e-*
17+apps/web/next-env.d.ts
added CLAUDE.md +76 −0
@@ -0,0 +1,76 @@
1+# WebSensor — repository guide
2+
3+WebSensor (www.websensor.io) is a real-time web intelligence platform: a global sensor network for the changing
4+Web. It monitors official public sources, detects meaningful changes, classifies them, links entities, scores
5+importance, preserves evidence and publishes events through a WebSocket feed. The product brief that drove the
6+design is `docs/PRODUCT-BRIEF.md`; this file is the working guide for the code.
7+
8+## Layout (pnpm workspace, TypeScript ESM, Node ≥ 22.15)
9+- `packages/core` — taxonomy (event types + intrinsic severity, tiers, categories), ids, SSRF policy
10+ (`assertUrlAllowed`, `safeLookup`), hashing (sha256, simhash, shingles/Jaccard), canonical extraction
11+ (`canonicalizeHtml`: strips scripts/nav/footer/cookie chrome, timestamps, tokens, counters), diff engines
12+ (text / json / keyed list), stage-1 heuristics (`evaluateChange`, `describeChange`), scoring
13+ (importance components, confidence, novelty helpers, trending, activity anomaly), adaptive schedule.
14+ `@websensor/core/client` is the browser-safe subset (no `node:` imports) — client components must import it.
15+- `packages/db` — plain SQL migrations (`migrations/*.sql`, applied by `migrate()` with an advisory lock) +
16+ Drizzle schema for typed access. **`textArray()`** must be used for `= any(...)` / `&&` with JS arrays
17+ (Drizzle spreads arrays into parameter lists).
18+- `packages/store` — content-addressed blob store (`sha256/ab/cd/<hash>.zst`, zstd via `node:zlib`). Raw
19+ bodies, canonical representations and full diffs live there, never in Postgres. Interface ready for S3/MinIO.
20+- `packages/connectors` — connector SDK (`WebSensorConnector.fetch/normalize`), safe fetcher (conditional GET,
21+ manual redirects validated per hop, HTTP/2→1.1 pin on NGHTTP2 errors, browser-UA second attempt on
22+ 403/resets, size + time limits), connectors: `http` (HTML/JSON/text/HEAD), `rss` (RSS/Atom/RDF/JSON Feed),
23+ `sitemap` (index + news), `statuspage` (Atlassian v2 summary), `github` (releases/tags/commits Atom,
24+ advisories REST), `jsonlist` (keyed records from any JSON API; `{now-2h}` placeholders), `discovery`
25+ (robots sitemaps, `<link rel=alternate>`, well-known feed paths, linked status pages — every candidate is
26+ fetched and parsed), `scrapfly` (fallback, budgeted, only when `fallback.scrapfly` is set on the source).
27+- `apps/engine` — scheduler (`FOR UPDATE SKIP LOCKED` claims, global + per-host concurrency), pipeline
28+ (fetch → normalize → snapshot → diff → heuristics → change → event: entities, novelty, LLM interpretation,
29+ clustering, importance/confidence, silent-change detection, publish to Redis), registry sync from
30+ `config/sources.yaml`, discovery, connector health rollups, Prometheus metrics on :8262. CLI: `src/cli.ts`
31+ (`sync`, `discover`, `probe <domain>`, `run-once <sensor>`, `run-due [n]`).
32+- `apps/api` — Fastify gateway (:8260): REST `/api/v1/*`, WebSocket `/api/v1/live` (Redis pub/sub fan-out,
33+ channels `events:*`, `entity:*`, `source:*`, `watchlist:*`), `/api/v1/feed.rss`, `/api/health` `/api/ready` `/api/metrics`, apex→www
34+ redirect, and a reverse proxy to the Next.js app for everything else.
35+- `apps/web` — Next.js 16 frontend (:8261, loopback). Pages: live, breaking, explore, sources, entities,
36+ timelines, silent changes, watchlists, alerts, event detail with diff viewer, health, API docs.
37+- `config/sources.yaml` — the 200-organization registry (sources, curated sensors, product entities, discovery
38+ flags, fallbacks). `docs/connectors/*.md` document each connector family.
39+
40+## Rules
41+- Every URL the engine touches — seeds, discovered candidates, redirects, Scrapfly targets — goes through
42+ `assertUrlAllowed()`. Private ranges, metadata endpoints, `.maclustr.io`/`.ts.net` and single-label hosts are
43+ blocked; the dispatcher's DNS lookup only returns approved addresses.
44+- Raw evidence is immutable: snapshots and diffs are never rewritten. Re-interpretation creates a new row in
45+ `interpretations` (versioned); `events.interpretation` holds the latest.
46+- LLM cost control: heuristics first; Claude is called only for candidates above `WS_LLM_MIN_IMPORTANCE`, with a
47+ daily call budget; the deep model only above `WS_LLM_DEEP_MIN_IMPORTANCE`. Output is strict JSON
48+ (`output_config.format`), never free text. No key → heuristics only, the system still works.
49+- Feeds: items are "new" only if their key was never seen (sensor `state.seenKeys`) and they are not older than
50+ 14 days; items scrolling out of the window are never "removed". Sitemaps/statuspages: a >50 % shrink is
51+ treated as a partial response, not mass deletion. A 404 becomes `page_removed` only after
52+ `WS_DELETE_CONFIRMATIONS` checks separated by `WS_DELETE_SEPARATION_MIN`.
53+- Never label inference as fact: events carry `evidence_label` (OBSERVED / INFERRED / CONFIRMED / UNCONFIRMED)
54+ and the interpretation keeps `observed` and `inferred` apart. Silent changes are flagged, never asserted as
55+ "unannounced" without checking recent announcement-type events of the same source.
56+- Do not add sensors that are blocked with 403 by design (Akamai/Cloudflare bot management) unless the source
57+ has `fallback.scrapfly: true` and a tier ≥ C; Scrapfly is a budgeted fallback, not the foundation.
58+- All timestamps UTC. Ids are prefixed (`src_`, `sen_`, `snap_`, `chg_`, `evt_`, `clu_`, `ent_`…).
59+
60+## Dev
61+```
62+createdb websensor && cp .env.example .env
63+pnpm install
64+pnpm db:migrate # or let the engine migrate on start
65+npx tsx apps/engine/src/cli.ts sync # registry → DB
66+npx tsx apps/engine/src/cli.ts run-due 50
67+pnpm dev:api · pnpm dev:engine · pnpm dev:web
68+pnpm test · pnpm typecheck
69+```
70+Run CLI/engine from the repo root (config paths are relative to cwd).
71+
72+## Deploy (MacLustr)
73+`mld stage . websensor && mld deploy websensor --node M4M64b`. Manifest `deploy/websensor.mld.json` (secrets only
74+on M1M32). Processes: `websensor-api` (:8260, ngrok www.websensor.io), `websensor-web` (:8261 loopback),
75+`websensor-engine` (metrics :8262). Postgres 17 `websensor` + Redis local. Blob store `~/websensor-data/blobs`.
76+See `deploy/README.md`.
added README.md +33 −0
@@ -0,0 +1,33 @@
1+# WebSensor
2+
3+**Detect What Changed. Know Why It Matters.** — https://www.websensor.io
4+
5+WebSensor is a global sensor network for the changing Web. It continuously monitors official public sources
6+(newsrooms, status pages, changelogs, regulators, registries, APIs), preserves every observation, detects raw
7+changes, filters noise, interprets meaningful changes into events, links them to entities, scores their
8+importance and confidence, clusters related events and publishes them live over WebSocket.
9+
10+```
11+Sources → Sensors/Connectors → Fetch (conditional) → Snapshot (immutable) → Diff → Heuristics
12+ → Interpretation (Claude, budgeted) → Entities → Novelty/Clustering → Importance → Event
13+ → Redis stream → WebSocket gateway → www.websensor.io
14+```
15+
16+- 200 organizations, ~290 curated sensors at launch (`config/sources.yaml`), plus validated discovery.
17+- Connector families: HTTP (HTML/JSON/HEAD), RSS/Atom/JSON Feed, sitemaps, Statuspage, GitHub, JSON APIs
18+ (CISA KEV, NVD, Federal Register, USGS, HIBP, Google Cloud incidents…), Scrapfly fallback.
19+- Every event is auditable: event → change → snapshot A/B → fetch run → sensor → source, with raw evidence.
20+- Observed vs inferred is always labelled; silent (unannounced) changes are flagged.
21+
22+## Quick start
23+```
24+createdb websensor && cp .env.example .env && pnpm install
25+npx tsx apps/engine/src/cli.ts sync # load the registry
26+pnpm dev:api & pnpm dev:web & pnpm dev:engine
27+open http://localhost:8260
28+```
29+
30+Docs: `CLAUDE.md` (repo guide), `docs/ARCHITECTURE.md`, `docs/connectors/*.md`, `deploy/README.md`,
31+API docs at `/api` on the site. Public API: `GET /api/v1/events`, `/api/v1/entities/{id}`,
32+`/api/v1/sources/{id}`, `/api/v1/domains/{domain}/timeline`, `/api/v1/changes/{id}`, `wss://…/api/v1/live`,
33+`/api/v1/feed.rss`.
added apps/api/package.json +35 −0
@@ -0,0 +1,35 @@
1+{
2+ "name": "@websensor/api",
3+ "version": "0.1.0",
4+ "private": true,
5+ "type": "module",
6+ "scripts": {
7+ "dev": "tsx watch src/server.ts",
8+ "start": "tsx src/server.ts",
9+ "typecheck": "tsc -p tsconfig.json --noEmit",
10+ "test": "vitest run --passWithNoTests"
11+ },
12+ "dependencies": {
13+ "@fastify/cors": "^11.0.0",
14+ "@fastify/rate-limit": "^10.3.0",
15+ "@fastify/reply-from": "^12.6.5",
16+ "@fastify/websocket": "^11.2.0",
17+ "@websensor/core": "workspace:*",
18+ "@websensor/db": "workspace:*",
19+ "@websensor/store": "workspace:*",
20+ "fastify": "^5.4.0",
21+ "ioredis": "^5.6.0",
22+ "pino": "^9.7.0",
23+ "pino-pretty": "^13.0.0",
24+ "prom-client": "^15.1.0",
25+ "tsx": "^4.20.0",
26+ "ws": "^8.21.3",
27+ "zod": "^4.0.0"
28+ },
29+ "devDependencies": {
30+ "@types/node": "^24.0.0",
31+ "@types/ws": "^8.18.1",
32+ "typescript": "^5.9.3",
33+ "vitest": "^3.2.0"
34+ }
35+}
added apps/api/src/config.ts +14 −0
@@ -0,0 +1,14 @@
1+const env = process.env;
2+
3+export const config = {
4+ env: env.NODE_ENV ?? "development",
5+ logLevel: env.LOG_LEVEL ?? "info",
6+ port: Number(env.API_PORT ?? 8260),
7+ host: env.API_HOST ?? "127.0.0.1",
8+ redisUrl: env.REDIS_URL ?? "redis://127.0.0.1:6379",
9+ webUrl: env.WEB_URL ?? "http://127.0.0.1:8261",
10+ publicBaseUrl: env.PUBLIC_BASE_URL ?? "http://localhost:8260",
11+ canonicalHost: env.CANONICAL_HOST ?? "www.websensor.io",
12+ redirectApexToWww: (env.WS_REDIRECT_APEX ?? "1") !== "0",
13+ version: "0.1.0",
14+};
added apps/api/src/live.ts +129 −0
@@ -0,0 +1,129 @@
1+import type { FastifyInstance } from "fastify";
2+import type { WebSocket } from "ws";
3+import Redis from "ioredis";
4+import { FEED_CHANNELS } from "@websensor/core";
5+import { db, sql } from "@websensor/db";
6+import { config } from "./config";
7+
8+/**
9+ * WebSocket gateway `/api/v1/live`. One Redis subscriber fans out to every client; clients
10+ * pick channels: events:global · events:breaking · events:<ai|cyber|finance|health|government|
11+ * science|products|infrastructure> · entity:<id> · source:<id> · watchlist:<id>.
12+ * Protocol (JSON): client → {"subscribe":[…]} | {"unsubscribe":[…]} | {"ping":1}
13+ * server → {"type":"hello"} | {"type":"event", "channels":[…], "event":{…}} | {"type":"pong"}
14+ */
15+interface Client {
16+ ws: WebSocket;
17+ channels: Set<string>;
18+ watchlists: Map<string, { entities: Set<string>; sources: Set<string>; keywords: string[]; categories: Set<string> }>;
19+}
20+
21+const clients = new Set<Client>();
22+let sub: Redis | null = null;
23+let published = 0;
24+
25+export function liveStats(): { clients: number; published: number } {
26+ return { clients: clients.size, published };
27+}
28+
29+export async function registerLive(app: FastifyInstance): Promise<void> {
30+ sub = new Redis(config.redisUrl, { maxRetriesPerRequest: 3 });
31+ sub.on("error", (e) => app.log.warn({ err: e.message }, "redis sub error"));
32+ await sub.subscribe("ws:live");
33+ sub.on("message", (_ch, msg) => {
34+ let ev: Record<string, unknown>;
35+ try {
36+ ev = JSON.parse(msg) as Record<string, unknown>;
37+ } catch {
38+ return;
39+ }
40+ published++;
41+ const chans = channelsFor(ev);
42+ for (const c of clients) {
43+ const hit = [...chans].filter((ch) => c.channels.has(ch));
44+ for (const [wid, w] of c.watchlists) if (matchesWatchlist(ev, w)) hit.push(`watchlist:${wid}`);
45+ if (!hit.length) continue;
46+ if (c.ws.readyState === c.ws.OPEN) c.ws.send(JSON.stringify({ type: "event", channels: hit, event: ev }));
47+ }
48+ });
49+
50+ app.get("/api/v1/live", { websocket: true }, (socket) => {
51+ const client: Client = { ws: socket, channels: new Set(["events:global"]), watchlists: new Map() };
52+ clients.add(client);
53+ socket.send(JSON.stringify({ type: "hello", channels: [...client.channels], serverTime: new Date().toISOString() }));
54+ socket.on("message", async (raw: Buffer | string) => {
55+ let msg: { subscribe?: string[]; unsubscribe?: string[]; ping?: number };
56+ try {
57+ msg = JSON.parse(raw.toString()) as typeof msg;
58+ } catch {
59+ return;
60+ }
61+ if (msg.ping) socket.send(JSON.stringify({ type: "pong", t: Date.now() }));
62+ for (const ch of msg.subscribe ?? []) {
63+ if (typeof ch !== "string" || ch.length > 120 || client.channels.size > 64) continue;
64+ if (ch.startsWith("watchlist:")) await loadWatchlist(client, ch.slice(10));
65+ else client.channels.add(ch);
66+ }
67+ for (const ch of msg.unsubscribe ?? []) {
68+ client.channels.delete(ch);
69+ if (ch.startsWith("watchlist:")) client.watchlists.delete(ch.slice(10));
70+ }
71+ socket.send(JSON.stringify({ type: "subscribed", channels: [...client.channels, ...[...client.watchlists.keys()].map((w) => `watchlist:${w}`)] }));
72+ });
73+ const hb = setInterval(() => {
74+ if (socket.readyState === socket.OPEN) socket.send(JSON.stringify({ type: "heartbeat", t: Date.now() }));
75+ }, 25_000);
76+ socket.on("close", () => {
77+ clearInterval(hb);
78+ clients.delete(client);
79+ });
80+ socket.on("error", () => {
81+ clearInterval(hb);
82+ clients.delete(client);
83+ });
84+ });
85+}
86+
87+export function channelsFor(ev: Record<string, unknown>): Set<string> {
88+ const out = new Set<string>(["events:global"]);
89+ const importance = Number(ev.importance ?? 0);
90+ if (importance >= 80) out.add("events:breaking");
91+ if (ev.silent) out.add("events:silent");
92+ const cats = (ev.categories as string[] | undefined) ?? [];
93+ for (const [ch, list] of Object.entries(FEED_CHANNELS)) if (list.some((c) => cats.includes(c)) || cats.includes(ch)) out.add(`events:${ch}`);
94+ const src = ev.source as { id?: string } | undefined;
95+ if (src?.id) out.add(`source:${src.id}`);
96+ for (const e of (ev.entities as { id: string }[] | undefined) ?? []) out.add(`entity:${e.id}`);
97+ out.add(`type:${String(ev.type)}`);
98+ return out;
99+}
100+
101+async function loadWatchlist(client: Client, id: string): Promise<void> {
102+ const rows = await db.execute<{ kind: string; value: string }>(sql`select kind, value from watchlist_items where watchlist_id = ${id}`);
103+ const w = { entities: new Set<string>(), sources: new Set<string>(), keywords: [] as string[], categories: new Set<string>() };
104+ for (const r of rows.rows) {
105+ if (r.kind === "entity") w.entities.add(r.value);
106+ else if (r.kind === "source") w.sources.add(r.value);
107+ else if (r.kind === "keyword") w.keywords.push(r.value.toLowerCase());
108+ else if (r.kind === "category") w.categories.add(r.value);
109+ }
110+ client.watchlists.set(id, w);
111+}
112+
113+function matchesWatchlist(ev: Record<string, unknown>, w: { entities: Set<string>; sources: Set<string>; keywords: string[]; categories: Set<string> }): boolean {
114+ const src = ev.source as { id?: string } | undefined;
115+ if (src?.id && w.sources.has(src.id)) return true;
116+ for (const e of (ev.entities as { id: string }[] | undefined) ?? []) if (w.entities.has(e.id)) return true;
117+ for (const c of (ev.categories as string[] | undefined) ?? []) if (w.categories.has(c)) return true;
118+ if (w.keywords.length) {
119+ const hay = `${String(ev.title)} ${String(ev.summary)}`.toLowerCase();
120+ if (w.keywords.some((k) => hay.includes(k))) return true;
121+ }
122+ return false;
123+}
124+
125+export async function closeLive(): Promise<void> {
126+ for (const c of clients) c.ws.close(1001, "server shutdown");
127+ clients.clear();
128+ if (sub) await sub.quit().catch(() => undefined);
129+}
added apps/api/src/queries.ts +146 −0
@@ -0,0 +1,146 @@
1+import { db, sql, textArray } from "@websensor/db";
2+
3+/** Read-model helpers shared by REST routes. All return plain JSON-ready objects. */
4+
5+export interface EventFilters {
6+ after?: string;
7+ before?: string;
8+ category?: string;
9+ entity?: string;
10+ source?: string;
11+ domain?: string;
12+ sensor?: string;
13+ cluster?: string;
14+ importance_min?: number;
15+ confidence_min?: number;
16+ event_type?: string;
17+ silent_change?: boolean;
18+ q?: string;
19+ limit: number;
20+ cursor?: string;
21+ order?: "recent" | "importance";
22+}
23+
24+export const EVENT_SELECT = sql`
25+ e.id, e.slug, e.event_type, e.title, e.summary, e.why_it_matters, e.importance, e.confidence, e.novelty, e.categories, e.keywords,
26+ e.silent_change, e.evidence_label, e.url, e.detected_at, e.published_at, e.observed_from, e.processed_at, e.published_to_feed_at,
27+ e.detection_latency_ms, e.processing_latency_ms, e.cluster_id, e.sensor_id, e.source_id, e.change_id, e.old_snapshot_id, e.new_snapshot_id,
28+ e.importance_components, e.processing_version,
29+ json_build_object('id', s.id, 'name', s.name, 'domain', s.domain, 'tier', s.tier, 'categories', s.categories) as source,
30+ json_build_object('id', sen.id, 'name', sen.name, 'type', sen.type, 'connector', sen.connector, 'tier', sen.tier) as sensor,
31+ coalesce((select json_agg(json_build_object('id', en.id, 'name', en.name, 'type', en.type, 'role', ee.role) order by ee.role, en.name)
32+ from event_entities ee join entities en on en.id = ee.entity_id where ee.event_id = e.id), '[]'::json) as entities,
33+ (select event_count from event_clusters c where c.id = e.cluster_id) as cluster_size`;
34+
35+export async function listEvents(f: EventFilters): Promise<{ items: Record<string, unknown>[]; nextCursor: string | null }> {
36+ const conds = [sql`true`];
37+ if (f.after) conds.push(sql`e.detected_at > ${new Date(f.after)}`);
38+ if (f.before) conds.push(sql`e.detected_at < ${new Date(f.before)}`);
39+ if (f.category) conds.push(sql`${f.category} = any(e.categories)`);
40+ if (f.source) conds.push(sql`e.source_id = ${f.source}`);
41+ if (f.sensor) conds.push(sql`e.sensor_id = ${f.sensor}`);
42+ if (f.cluster) conds.push(sql`e.cluster_id = ${f.cluster}`);
43+ if (f.domain) conds.push(sql`s.domain = ${f.domain}`);
44+ if (f.entity) conds.push(sql`exists (select 1 from event_entities x where x.event_id = e.id and x.entity_id = ${f.entity})`);
45+ if (f.importance_min !== undefined) conds.push(sql`e.importance >= ${f.importance_min}`);
46+ if (f.confidence_min !== undefined) conds.push(sql`e.confidence >= ${f.confidence_min}`);
47+ if (f.event_type) conds.push(sql`e.event_type = any(${textArray(f.event_type.split(","))})`);
48+ if (f.silent_change !== undefined) conds.push(sql`e.silent_change = ${f.silent_change}`);
49+ if (f.q) conds.push(sql`e.search @@ websearch_to_tsquery('english', ${f.q})`);
50+ if (f.cursor) {
51+ const [ts, id] = decodeCursor(f.cursor);
52+ if (f.order === "importance") conds.push(sql`(e.importance, e.id) < (${Number(ts)}, ${id})`);
53+ else conds.push(sql`(e.detected_at, e.id) < (${new Date(Number(ts))}, ${id})`);
54+ }
55+ const order = f.order === "importance" ? sql`e.importance desc, e.id desc` : sql`e.detected_at desc, e.id desc`;
56+ const rows = await db.execute<Record<string, unknown>>(sql`select ${EVENT_SELECT} from events e join sources s on s.id = e.source_id join sensors sen on sen.id = e.sensor_id where ${sql.join(conds, sql` and `)} order by ${order} limit ${f.limit + 1}`);
57+ const items = rows.rows.slice(0, f.limit);
58+ const last = items[items.length - 1];
59+ const nextCursor = rows.rows.length > f.limit && last ? encodeCursor(f.order === "importance" ? String(last.importance) : String(new Date(last.detected_at as string).getTime()), String(last.id)) : null;
60+ return { items, nextCursor };
61+}
62+
63+export async function getEvent(idOrSlug: string): Promise<Record<string, unknown> | null> {
64+ const rows = await db.execute<Record<string, unknown>>(sql`select ${EVENT_SELECT}, e.interpretation from events e join sources s on s.id = e.source_id join sensors sen on sen.id = e.sensor_id where e.id = ${idOrSlug} or e.slug = ${idOrSlug} limit 1`);
65+ return rows.rows[0] ?? null;
66+}
67+
68+export async function relatedEvents(ev: Record<string, unknown>, limit = 8): Promise<Record<string, unknown>[]> {
69+ const rows = await db.execute<Record<string, unknown>>(sql`
70+ select ${EVENT_SELECT} from events e join sources s on s.id = e.source_id join sensors sen on sen.id = e.sensor_id
71+ where e.id <> ${String(ev.id)} and (e.cluster_id = ${String(ev.cluster_id ?? "")} or e.source_id = ${String(ev.source_id)} or exists (select 1 from event_entities a join event_entities b on a.entity_id = b.entity_id where a.event_id = e.id and b.event_id = ${String(ev.id)}))
72+ order by (e.cluster_id = ${String(ev.cluster_id ?? "")}) desc, e.detected_at desc limit ${limit}`);
73+ return rows.rows;
74+}
75+
76+export async function stats(): Promise<Record<string, unknown>> {
77+ const [r] = (
78+ await db.execute<Record<string, unknown>>(sql`
79+ select
80+ (select count(*) from sources where enabled) as sources,
81+ (select count(*) from sensors where enabled) as sensors,
82+ (select count(*) from entities) as entities,
83+ (select count(*) from snapshots) as snapshots,
84+ (select count(*) from events) as events_total,
85+ (select count(*) from events where detected_at >= now() - interval '24 hours') as events_24h,
86+ (select count(*) from events where detected_at >= now() - interval '24 hours' and silent_change) as silent_24h,
87+ (select count(*) from events where detected_at >= now() - interval '24 hours' and importance >= 80) as breaking_24h,
88+ (select count(*) from changes where detected_at >= now() - interval '24 hours') as changes_24h,
89+ (select coalesce(sum(checks),0) from metrics_daily where day = current_date) as checks_today,
90+ (select coalesce(sum(not_modified),0) from metrics_daily where day = current_date) as not_modified_today,
91+ (select coalesce(sum(bytes),0) from metrics_daily where day = current_date) as bytes_today,
92+ (select count(*) from sensor_runs where started_at >= now() - interval '1 hour') as checks_last_hour,
93+ (select count(*) from sensors where health = 'UP' and enabled) as sensors_up,
94+ (select count(*) from sensors where health in ('DEGRADED','ERROR','RATE_LIMITED') and enabled) as sensors_degraded,
95+ (select max(started_at) from sensor_runs) as last_check_at,
96+ (select max(detected_at) from events) as last_event_at,
97+ (select percentile_cont(0.5) within group (order by processing_latency_ms) from events where detected_at >= now() - interval '24 hours') as p50_processing_ms,
98+ (select percentile_cont(0.5) within group (order by detection_latency_ms) from events where detected_at >= now() - interval '24 hours' and detection_latency_ms is not null and detection_latency_ms < 86400000) as p50_detection_ms`)
99+ ).rows;
100+ return Object.fromEntries(Object.entries(r ?? {}).map(([k, v]) => [k, typeof v === "string" && /^\d+$/.test(v) ? Number(v) : v]));
101+}
102+
103+export async function trending(hours = 24, limit = 12): Promise<Record<string, unknown>[]> {
104+ const rows = await db.execute<Record<string, unknown>>(sql`
105+ with cur as (
106+ select ee.entity_id, count(*) as n, sum(e.importance) as imp, count(distinct e.source_id) as sources, sum(case when e.silent_change then 1 else 0 end) as silent, max(e.importance) as max_imp
107+ from events e join event_entities ee on ee.event_id = e.id where e.detected_at >= now() - make_interval(hours => ${hours}) group by ee.entity_id),
108+ prev as (
109+ select ee.entity_id, count(*) as n from events e join event_entities ee on ee.event_id = e.id
110+ where e.detected_at >= now() - make_interval(hours => ${hours * 2}) and e.detected_at < now() - make_interval(hours => ${hours}) group by ee.entity_id)
111+ select en.id, en.name, en.type, en.domain, cur.n::int as events, cur.imp::float as importance_sum, cur.sources::int as sources, cur.silent::int as silent, cur.max_imp::float as max_importance, coalesce(prev.n,0)::int as prev_events,
112+ round((18*(ln(1+cur.n)/ln(2)) + 0.35*(cur.imp/greatest(1,cur.n)) + 10*(ln(1+cur.sources)/ln(2)) + 12*least(2, case when coalesce(prev.n,0)=0 then 2 else cur.n::float/prev.n end) + 5*least(3,cur.silent))::numeric, 1)::float as score
113+ from cur join entities en on en.id = cur.entity_id left join prev on prev.entity_id = cur.entity_id
114+ order by score desc limit ${limit}`);
115+ return rows.rows;
116+}
117+
118+export async function sourceActivity(sourceId: string): Promise<Record<string, unknown>> {
119+ const [r] = (
120+ await db.execute<Record<string, unknown>>(sql`
121+ select
122+ (select count(*) from changes c join sensors s on s.id = c.sensor_id where s.source_id = ${sourceId} and c.detected_at >= now() - interval '2 hours')::int as changes_2h,
123+ (select count(*) from changes c join sensors s on s.id = c.sensor_id where s.source_id = ${sourceId} and c.detected_at >= now() - interval '14 days')::int as changes_14d,
124+ (select count(*) from events where source_id = ${sourceId} and detected_at >= now() - interval '24 hours')::int as events_24h,
125+ (select count(*) from events where source_id = ${sourceId} and detected_at >= now() - interval '14 days')::int as events_14d`)
126+ ).rows;
127+ const c2h = Number(r?.changes_2h ?? 0);
128+ const baselinePerHour = Number(r?.changes_14d ?? 0) / (14 * 24);
129+ const currentPerHour = c2h / 2;
130+ let anomaly = 0;
131+ if (baselinePerHour <= 0) anomaly = currentPerHour > 2 ? 70 : currentPerHour > 0 ? 40 : 0;
132+ else {
133+ const ratio = currentPerHour / baselinePerHour;
134+ anomaly = ratio <= 1 ? ratio * 30 : Math.min(100, 30 + 25 * Math.log2(ratio));
135+ }
136+ return { ...r, baseline_changes_per_day: Math.round(baselinePerHour * 24 * 10) / 10, activity_score: Math.round(anomaly * 10) / 10 };
137+}
138+
139+export function encodeCursor(a: string, b: string): string {
140+ return Buffer.from(`${a}|${b}`).toString("base64url");
141+}
142+export function decodeCursor(c: string): [string, string] {
143+ const s = Buffer.from(c, "base64url").toString("utf8");
144+ const i = s.indexOf("|");
145+ return [s.slice(0, i), s.slice(i + 1)];
146+}
added apps/api/src/routes.ts +349 −0
@@ -0,0 +1,349 @@
1+import type { FastifyInstance } from "fastify";
2+import { z } from "zod";
3+import { diffText, EVENT_TYPES, FEED_CHANNELS, newId } from "@websensor/core";
4+import { db, sql, textArray } from "@websensor/db";
5+import { getBlobStore } from "@websensor/store";
6+import { config } from "./config";
7+import { liveStats } from "./live";
8+import { getEvent, listEvents, relatedEvents, sourceActivity, stats, trending, EVENT_SELECT } from "./queries";
9+
10+const eventsQuery = z.object({
11+ after: z.string().optional(),
12+ before: z.string().optional(),
13+ category: z.string().optional(),
14+ entity: z.string().optional(),
15+ source: z.string().optional(),
16+ domain: z.string().optional(),
17+ sensor: z.string().optional(),
18+ cluster: z.string().optional(),
19+ importance_min: z.coerce.number().min(0).max(100).optional(),
20+ confidence_min: z.coerce.number().min(0).max(100).optional(),
21+ event_type: z.string().optional(),
22+ silent_change: z
23+ .enum(["true", "false"])
24+ .transform((v) => v === "true")
25+ .optional(),
26+ q: z.string().max(200).optional(),
27+ limit: z.coerce.number().int().min(1).max(200).default(50),
28+ cursor: z.string().optional(),
29+ order: z.enum(["recent", "importance"]).default("recent"),
30+});
31+
32+function ownerToken(headers: Record<string, unknown>): string | null {
33+ const t = headers["x-websensor-owner"];
34+ return typeof t === "string" && /^[A-Za-z0-9_-]{16,80}$/.test(t) ? t : null;
35+}
36+
37+export async function registerRoutes(app: FastifyInstance): Promise<void> {
38+ // ---- Health ---------------------------------------------------------------------------
39+ app.get("/api/health", async () => ({ status: "ok", service: "api", version: config.version, time: new Date().toISOString() }));
40+ app.get("/api/ready", async (_req, reply) => {
41+ const checks: Record<string, boolean | number> = {};
42+ try {
43+ await db.execute(sql`select 1`);
44+ checks.database = true;
45+ } catch {
46+ checks.database = false;
47+ }
48+ const engine = await db.execute<{ last: Date | null }>(sql`select max(started_at) as last from sensor_runs where started_at >= now() - interval '15 minutes'`);
49+ checks.engine_recent = Boolean(engine.rows[0]?.last);
50+ checks.ws_clients = liveStats().clients;
51+ return reply.status(checks.database ? 200 : 503).send({ status: checks.database ? "ready" : "degraded", checks });
52+ });
53+
54+ // ---- Events ---------------------------------------------------------------------------
55+ app.get("/api/v1/events", async (req) => {
56+ const q = eventsQuery.parse(req.query);
57+ return listEvents(q);
58+ });
59+ app.get<{ Params: { id: string } }>("/api/v1/events/:id", async (req, reply) => {
60+ const ev = await getEvent(req.params.id);
61+ if (!ev) return reply.status(404).send({ error: "not_found" });
62+ const [related, cluster, change, interp] = await Promise.all([
63+ relatedEvents(ev),
64+ ev.cluster_id ? db.execute<Record<string, unknown>>(sql`select * from event_clusters where id = ${String(ev.cluster_id)}`).then((r) => r.rows[0] ?? null) : null,
65+ ev.change_id ? db.execute<Record<string, unknown>>(sql`select id, kind, diff, signal, noise_ratio, magnitude, heuristic, detected_at, old_snapshot_id, new_snapshot_id from changes where id = ${String(ev.change_id)}`).then((r) => r.rows[0] ?? null) : null,
66+ db.execute<Record<string, unknown>>(sql`select version, model, created_at from interpretations where event_id = ${String(ev.id)} order by version`).then((r) => r.rows),
67+ ]);
68+ const snaps = await db.execute<Record<string, unknown>>(sql`select id, url, captured_at, http_status, content_type, content_length, content_hash, canonical_hash, etag, last_modified, title, mode, fetch_duration_ms, extraction_confidence from snapshots where id in (${String(ev.old_snapshot_id ?? "")}, ${String(ev.new_snapshot_id ?? "")})`);
69+ const sourceRel = await db.execute<Record<string, unknown>>(sql`select health, success_rate, avg_latency_ms, total_runs, raw_changes, meaningful_changes, last_check_at from (select s.health, ch.success_rate, s.avg_latency_ms, s.total_runs, s.raw_changes, s.meaningful_changes, s.last_check_at from sensors s left join connector_health ch on ch.connector = s.connector where s.id = ${String(ev.sensor_id)}) x`);
70+ return { event: ev, related, cluster, change, interpretations: interp, snapshots: snaps.rows, sensor_reliability: sourceRel.rows[0] ?? null };
71+ });
72+
73+ // ---- Changes / snapshots / diffs -------------------------------------------------------
74+ app.get<{ Params: { id: string } }>("/api/v1/changes/:id", async (req, reply) => {
75+ const r = await db.execute<Record<string, unknown>>(sql`select c.*, s.url as sensor_url, s.name as sensor_name, s.source_id from changes c join sensors s on s.id = c.sensor_id where c.id = ${req.params.id}`);
76+ const c = r.rows[0];
77+ if (!c) return reply.status(404).send({ error: "not_found" });
78+ let unified: string | null = null;
79+ if (c.diff_storage_key) unified = await getBlobStore().getText(String(c.diff_storage_key)).catch(() => null);
80+ return { change: c, unified };
81+ });
82+ app.get<{ Params: { id: string }; Querystring: { raw?: string } }>("/api/v1/snapshots/:id", async (req, reply) => {
83+ const r = await db.execute<Record<string, unknown>>(sql`select * from snapshots where id = ${req.params.id}`);
84+ const s = r.rows[0];
85+ if (!s) return reply.status(404).send({ error: "not_found" });
86+ const store = getBlobStore();
87+ if (req.query.raw === "1" && s.storage_key) {
88+ const buf = await store.get(String(s.storage_key));
89+ reply.header("content-type", String(s.content_type ?? "text/plain") + (String(s.content_type ?? "").includes("charset") ? "" : "; charset=utf-8"));
90+ reply.header("x-content-type-options", "nosniff");
91+ reply.header("content-security-policy", "default-src 'none'; style-src 'unsafe-inline'; img-src data:");
92+ return reply.send(buf);
93+ }
94+ const canonical = s.canonical_storage_key ? await store.getText(String(s.canonical_storage_key)).catch(() => null) : null;
95+ return { snapshot: s, canonical: canonical ? JSON.parse(canonical) : null };
96+ });
97+ app.get<{ Querystring: { a: string; b: string } }>("/api/v1/snapshots/compare", async (req, reply) => {
98+ const { a, b } = req.query;
99+ if (!a || !b) return reply.status(400).send({ error: "a and b required" });
100+ const rows = await db.execute<Record<string, unknown>>(sql`select id, captured_at, canonical_storage_key, mode, url, sensor_id from snapshots where id in (${a}, ${b})`);
101+ const sa = rows.rows.find((r) => r.id === a);
102+ const sb = rows.rows.find((r) => r.id === b);
103+ if (!sa || !sb) return reply.status(404).send({ error: "not_found" });
104+ const store = getBlobStore();
105+ const [ca, cb] = await Promise.all([store.getText(String(sa.canonical_storage_key)), store.getText(String(sb.canonical_storage_key))]);
106+ const ta = renderCanonical(JSON.parse(ca));
107+ const tb = renderCanonical(JSON.parse(cb));
108+ const d = diffText(ta, tb, `${a}@${String(sa.captured_at)}`, `${b}@${String(sb.captured_at)}`);
109+ return { a: sa, b: sb, before: ta, after: tb, diff: { unified: d.unified, stats: d.stats, added: d.added, removed: d.removed, modified: d.modified } };
110+ });
111+
112+ // ---- Sources & sensors ------------------------------------------------------------------
113+ app.get<{ Querystring: { category?: string; q?: string; limit?: string } }>("/api/v1/sources", async (req) => {
114+ const cat = req.query.category;
115+ const q = req.query.q;
116+ const rows = await db.execute<Record<string, unknown>>(sql`
117+ select s.*, (select count(*) from sensors x where x.source_id = s.id and x.enabled)::int as sensor_count,
118+ (select count(*) from events e where e.source_id = s.id)::int as event_count,
119+ (select count(*) from events e where e.source_id = s.id and e.detected_at >= now() - interval '24 hours')::int as events_24h,
120+ (select max(detected_at) from events e where e.source_id = s.id) as last_event_at,
121+ (select max(last_check_at) from sensors x where x.source_id = s.id) as last_check_at,
122+ (select count(*) from sensors x where x.source_id = s.id and x.enabled and x.health <> 'UP')::int as sensors_degraded
123+ from sources s where s.enabled ${cat ? sql`and ${cat} = any(s.categories)` : sql``} ${q ? sql`and (s.name ilike ${"%" + q + "%"} or s.domain ilike ${"%" + q + "%"})` : sql``}
124+ order by s.tier asc, events_24h desc, s.name asc limit ${Math.min(500, Number(req.query.limit ?? 300))}`);
125+ return { items: rows.rows };
126+ });
127+ app.get<{ Params: { id: string } }>("/api/v1/sources/:id", async (req, reply) => {
128+ const s = (await db.execute<Record<string, unknown>>(sql`select * from sources where id = ${req.params.id} or domain = ${req.params.id} limit 1`)).rows[0];
129+ if (!s) return reply.status(404).send({ error: "not_found" });
130+ const [sensors, ents, activity, candidates] = await Promise.all([
131+ db.execute<Record<string, unknown>>(sql`select id, name, url, type, connector, tier, health, enabled, next_check_at, last_check_at, last_change_at, last_event_at, last_status, last_error, consecutive_errors, total_runs, total_not_modified, raw_changes, meaningful_changes, avg_latency_ms, base_interval_seconds, etag is not null as has_etag, last_modified is not null as has_last_modified from sensors where source_id = ${String(s.id)} order by tier, name`).then((r) => r.rows),
132+ db.execute<Record<string, unknown>>(sql`select en.id, en.name, en.type, en.importance, en.event_count from source_entities se join entities en on en.id = se.entity_id where se.source_id = ${String(s.id)} order by en.type, en.name`).then((r) => r.rows),
133+ sourceActivity(String(s.id)),
134+ db.execute<Record<string, unknown>>(sql`select url, kind, evidence, score, status, found_at from discovery_candidates where source_id = ${String(s.id)} order by (score->>'value')::float desc nulls last limit 50`).then((r) => r.rows),
135+ ]);
136+ return { source: s, sensors, entities: ents, activity, discovery: candidates };
137+ });
138+ app.get<{ Params: { id: string } }>("/api/v1/sensors/:id", async (req, reply) => {
139+ const s = (await db.execute<Record<string, unknown>>(sql`select s.*, so.name as source_name, so.domain from sensors s join sources so on so.id = s.source_id where s.id = ${req.params.id}`)).rows[0];
140+ if (!s) return reply.status(404).send({ error: "not_found" });
141+ const [runs, snaps, changes] = await Promise.all([
142+ db.execute<Record<string, unknown>>(sql`select id, started_at, finished_at, http_status, outcome, error, duration_ms, bytes, fetch_method, snapshot_id from sensor_runs where sensor_id = ${String(s.id)} order by started_at desc limit 50`).then((r) => r.rows),
143+ db.execute<Record<string, unknown>>(sql`select id, captured_at, http_status, content_type, content_length, canonical_hash, title, mode, extraction_confidence from snapshots where sensor_id = ${String(s.id)} order by captured_at desc limit 50`).then((r) => r.rows),
144+ db.execute<Record<string, unknown>>(sql`select id, detected_at, kind, signal, noise_ratio, magnitude, meaningful, event_id, old_snapshot_id, new_snapshot_id, heuristic->>'eventType' as heuristic_type from changes where sensor_id = ${String(s.id)} order by detected_at desc limit 50`).then((r) => r.rows),
145+ ]);
146+ const { etag: _e, last_modified: _lm, state: _st, ...pub } = s as Record<string, unknown>;
147+ return { sensor: pub, runs, snapshots: snaps, changes };
148+ });
149+
150+ // ---- Entities ----------------------------------------------------------------------------
151+ app.get<{ Querystring: { type?: string; q?: string; limit?: string } }>("/api/v1/entities", async (req) => {
152+ const rows = await db.execute<Record<string, unknown>>(sql`
153+ select en.*, (select count(*) from events e join event_entities ee on ee.event_id = e.id where ee.entity_id = en.id and e.detected_at >= now() - interval '24 hours')::int as events_24h
154+ from entities en where true ${req.query.type ? sql`and en.type = ${req.query.type}` : sql``} ${req.query.q ? sql`and (en.search @@ plainto_tsquery('simple', ${req.query.q}) or en.name ilike ${"%" + req.query.q + "%"})` : sql``}
155+ order by en.event_count desc, en.importance desc, en.name limit ${Math.min(500, Number(req.query.limit ?? 200))}`);
156+ return { items: rows.rows };
157+ });
158+ app.get<{ Params: { id: string } }>("/api/v1/entities/:id", async (req, reply) => {
159+ const e = (await db.execute<Record<string, unknown>>(sql`select * from entities where id = ${req.params.id} or id = ${"org_" + req.params.id} limit 1`)).rows[0];
160+ if (!e) return reply.status(404).send({ error: "not_found" });
161+ const id = String(e.id);
162+ const [children, relations, sources, aliases, recent, byType] = await Promise.all([
163+ db.execute<Record<string, unknown>>(sql`select id, name, type, importance, event_count, last_event_at from entities where parent_id = ${id} order by event_count desc, name`).then((r) => r.rows),
164+ db.execute<Record<string, unknown>>(sql`select r.relation, r.from_id, r.to_id, f.name as from_name, t.name as to_name, t.type as to_type from entity_relations r join entities f on f.id = r.from_id join entities t on t.id = r.to_id where r.from_id = ${id} or r.to_id = ${id} limit 100`).then((r) => r.rows),
165+ db.execute<Record<string, unknown>>(sql`select s.id, s.name, s.domain, s.tier from source_entities se join sources s on s.id = se.source_id where se.entity_id = ${id}`).then((r) => r.rows),
166+ db.execute<{ alias: string }>(sql`select alias from entity_aliases where entity_id = ${id} order by alias`).then((r) => r.rows.map((x) => x.alias)),
167+ listEvents({ entity: id, limit: 30 }),
168+ db.execute<Record<string, unknown>>(sql`select e.event_type, count(*)::int as n from events e join event_entities ee on ee.event_id = e.id where ee.entity_id = ${id} group by 1 order by 2 desc`).then((r) => r.rows),
169+ ]);
170+ return { entity: e, children, relations, sources, aliases, recent: recent.items, by_type: byType };
171+ });
172+ app.get<{ Params: { id: string }; Querystring: { limit?: string; cursor?: string } }>("/api/v1/entities/:id/timeline", async (req) => {
173+ const id = req.params.id.startsWith("org_") || req.params.id.startsWith("prd_") ? req.params.id : `org_${req.params.id}`;
174+ return listEvents({ entity: id, limit: Math.min(200, Number(req.query.limit ?? 100)), cursor: req.query.cursor });
175+ });
176+
177+ // ---- Domains / URLs ----------------------------------------------------------------------
178+ app.get<{ Params: { domain: string }; Querystring: { limit?: string; cursor?: string } }>("/api/v1/domains/:domain/timeline", async (req) => {
179+ const d = req.params.domain.toLowerCase();
180+ const src = (await db.execute<{ id: string }>(sql`select id from sources where domain = ${d} or domain = ${"www." + d} or ${d} = 'www.' || domain limit 1`)).rows[0];
181+ const urls = await db.execute<Record<string, unknown>>(sql`select url, status, first_seen_at, last_seen_at, snapshot_count, change_count, sensor_id from urls where domain = ${d} or domain like ${"%." + d} order by change_count desc, last_seen_at desc limit 200`);
182+ const ev = src ? await listEvents({ source: src.id, limit: Math.min(200, Number(req.query.limit ?? 100)), cursor: req.query.cursor }) : { items: [], nextCursor: null };
183+ return { domain: d, source_id: src?.id ?? null, urls: urls.rows, events: ev.items, nextCursor: ev.nextCursor };
184+ });
185+ app.get<{ Querystring: { url: string } }>("/api/v1/urls/history", async (req, reply) => {
186+ if (!req.query.url) return reply.status(400).send({ error: "url required" });
187+ const u = (await db.execute<Record<string, unknown>>(sql`select * from urls where url = ${req.query.url}`)).rows[0];
188+ const history = await db.execute<Record<string, unknown>>(sql`
189+ select h.id, h.at, h.kind, h.snapshot_id, h.change_id, h.event_id, h.note, e.title as event_title, e.importance, e.event_type, e.slug as event_slug, c.kind as change_kind, c.signal
190+ from url_history h left join events e on e.id = h.event_id left join changes c on c.id = h.change_id where h.url = ${req.query.url} order by h.at desc limit 300`);
191+ const snaps = await db.execute<Record<string, unknown>>(sql`select id, captured_at, http_status, content_length, canonical_hash, title from snapshots where url = ${req.query.url} or sensor_id = (select sensor_id from urls where url = ${req.query.url}) order by captured_at desc limit 200`);
192+ return { url: u ?? { url: req.query.url }, history: history.rows, snapshots: snaps.rows };
193+ });
194+
195+ // ---- Explore / trending / stats ------------------------------------------------------------
196+ app.get("/api/v1/stats", async () => stats());
197+ app.get<{ Querystring: { hours?: string; limit?: string } }>("/api/v1/trending", async (req) => ({ items: await trending(Math.min(168, Number(req.query.hours ?? 24)), Math.min(50, Number(req.query.limit ?? 12))) }));
198+ app.get("/api/v1/explore", async () => {
199+ const [mostActive, biggest, silent, clusters, unusual, byType, byCategory] = await Promise.all([
200+ db.execute<Record<string, unknown>>(sql`select s.id, s.name, s.domain, count(*)::int as events_24h, max(e.importance)::float as max_importance from events e join sources s on s.id = e.source_id where e.detected_at >= now() - interval '24 hours' group by s.id, s.name, s.domain order by events_24h desc limit 10`).then((r) => r.rows),
201+ listEvents({ limit: 10, order: "importance", after: new Date(Date.now() - 48 * 3600e3).toISOString() }).then((r) => r.items),
202+ listEvents({ limit: 10, silent_change: true }).then((r) => r.items),
203+ db.execute<Record<string, unknown>>(sql`select c.*, (select json_build_object('id', s.id, 'name', s.name, 'domain', s.domain) from events e join sources s on s.id = e.source_id where e.id = c.primary_event_id) as source from event_clusters c where c.event_count >= 2 and c.last_at >= now() - interval '48 hours' order by c.max_importance desc, c.event_count desc limit 10`).then((r) => r.rows),
204+ db.execute<Record<string, unknown>>(sql`
205+ with cur as (select s.source_id, count(*) as n from changes c join sensors s on s.id = c.sensor_id where c.detected_at >= now() - interval '2 hours' group by s.source_id),
206+ base as (select s.source_id, count(*)::float / (14*24) as per_hour from changes c join sensors s on s.id = c.sensor_id where c.detected_at >= now() - interval '14 days' group by s.source_id)
207+ select so.id, so.name, so.domain, cur.n::int as changes_2h, round(coalesce(base.per_hour,0)::numeric*24,1)::float as baseline_per_day,
208+ round((case when coalesce(base.per_hour,0) = 0 then (case when cur.n/2.0 > 2 then 70 else 40 end) else least(100, case when cur.n/2.0/base.per_hour <= 1 then cur.n/2.0/base.per_hour*30 else 30 + 25*(ln(cur.n/2.0/base.per_hour)/ln(2)) end) end)::numeric, 1)::float as activity_score
209+ from cur join sources so on so.id = cur.source_id left join base on base.source_id = cur.source_id order by activity_score desc limit 10`).then((r) => r.rows),
210+ db.execute<Record<string, unknown>>(sql`select event_type, count(*)::int as n from events where detected_at >= now() - interval '7 days' group by 1 order by 2 desc`).then((r) => r.rows),
211+ db.execute<Record<string, unknown>>(sql`select c as category, count(*)::int as n from events e, unnest(e.categories) c where e.detected_at >= now() - interval '7 days' group by 1 order by 2 desc`).then((r) => r.rows),
212+ ]);
213+ return { most_active_sources: mostActive, biggest_changes: biggest, silent_changes: silent, clusters, unusual_activity: unusual, by_type: byType, by_category: byCategory, channels: FEED_CHANNELS, event_types: Object.fromEntries(Object.entries(EVENT_TYPES).map(([k, v]) => [k, v.label])) };
214+ });
215+ app.get<{ Querystring: { limit?: string; since?: string } }>("/api/v1/clusters", async (req) => {
216+ const rows = await db.execute<Record<string, unknown>>(sql`
217+ select c.*, (select json_agg(json_build_object('id', e.id, 'slug', e.slug, 'title', e.title, 'importance', e.importance, 'event_type', e.event_type, 'detected_at', e.detected_at, 'source_id', e.source_id, 'url', e.url) order by e.importance desc) from events e where e.cluster_id = c.id) as events
218+ from event_clusters c where c.last_at >= now() - make_interval(hours => ${Math.min(720, Number(req.query.since ?? 72))}) order by c.last_at desc limit ${Math.min(200, Number(req.query.limit ?? 50))}`);
219+ return { items: rows.rows };
220+ });
221+
222+ // ---- Search ----------------------------------------------------------------------------
223+ app.get<{ Querystring: { q?: string; limit?: string } }>("/api/v1/search", async (req) => {
224+ const q = (req.query.q ?? "").trim();
225+ if (q.length < 2) return { query: q, events: [], entities: [], sources: [], urls: [] };
226+ const lim = Math.min(50, Number(req.query.limit ?? 20));
227+ const [ev, ents, srcs, urls] = await Promise.all([
228+ listEvents({ q, limit: lim }).then((r) => r.items),
229+ db.execute<Record<string, unknown>>(sql`select id, name, type, domain, importance, event_count from entities where search @@ plainto_tsquery('simple', ${q}) or name ilike ${"%" + q + "%"} order by event_count desc, importance desc limit ${lim}`).then((r) => r.rows),
230+ db.execute<Record<string, unknown>>(sql`select id, name, domain, tier, categories from sources where name ilike ${"%" + q + "%"} or domain ilike ${"%" + q + "%"} limit ${lim}`).then((r) => r.rows),
231+ db.execute<Record<string, unknown>>(sql`select url, domain, status, change_count, last_seen_at from urls where url ilike ${"%" + q + "%"} order by change_count desc limit ${lim}`).then((r) => r.rows),
232+ ]);
233+ return { query: q, events: ev, entities: ents, sources: srcs, urls };
234+ });
235+
236+ // ---- Connector health ----------------------------------------------------------------------
237+ app.get("/api/v1/health/connectors", async () => {
238+ const [connectors, sensorsByHealth, worst, noisy, daily] = await Promise.all([
239+ db.execute<Record<string, unknown>>(sql`select * from connector_health order by connector`).then((r) => r.rows),
240+ db.execute<Record<string, unknown>>(sql`select health, count(*)::int as n from sensors where enabled group by health`).then((r) => r.rows),
241+ db.execute<Record<string, unknown>>(sql`select s.id, s.name, s.url, s.source_id, s.connector, s.health, s.consecutive_errors, s.last_error, s.last_status, s.last_check_at from sensors s where s.enabled and s.health <> 'UP' order by s.consecutive_errors desc, s.last_check_at desc limit 50`).then((r) => r.rows),
242+ db.execute<Record<string, unknown>>(sql`select s.id, s.name, s.source_id, s.url, s.raw_changes, s.meaningful_changes, case when s.raw_changes > 0 then round(1 - s.meaningful_changes::numeric / s.raw_changes, 3) else null end as noise_ratio from sensors s where s.raw_changes >= 5 order by noise_ratio desc nulls last, raw_changes desc limit 25`).then((r) => r.rows),
243+ db.execute<Record<string, unknown>>(sql`select * from metrics_daily order by day desc limit 30`).then((r) => r.rows),
244+ ]);
245+ return { connectors, sensors_by_health: sensorsByHealth, degraded_sensors: worst, noisy_sensors: noisy, daily, live: liveStats() };
246+ });
247+
248+ // ---- Watchlists & alerts (anonymous owner token, phase 1) ----------------------------------
249+ app.get("/api/v1/watchlists", async (req, reply) => {
250+ const owner = ownerToken(req.headers as Record<string, unknown>);
251+ if (!owner) return reply.status(401).send({ error: "owner token required (X-WebSensor-Owner)" });
252+ const rows = await db.execute<Record<string, unknown>>(sql`select w.*, coalesce((select json_agg(json_build_object('kind', i.kind, 'value', i.value, 'added_at', i.added_at)) from watchlist_items i where i.watchlist_id = w.id), '[]'::json) as items from watchlists w where owner_token = ${owner} order by created_at`);
253+ return { items: rows.rows };
254+ });
255+ app.post<{ Body: { name?: string; items?: { kind: string; value: string }[] } }>("/api/v1/watchlists", async (req, reply) => {
256+ const owner = ownerToken(req.headers as Record<string, unknown>);
257+ if (!owner) return reply.status(401).send({ error: "owner token required" });
258+ const body = z.object({ name: z.string().min(1).max(80).default("My watchlist"), items: z.array(z.object({ kind: z.enum(["entity", "source", "keyword", "category", "url"]), value: z.string().min(1).max(200) })).max(200).default([]) }).parse(req.body ?? {});
259+ const count = (await db.execute<{ n: string }>(sql`select count(*)::text as n from watchlists where owner_token = ${owner}`)).rows[0]?.n;
260+ if (Number(count) >= 20) return reply.status(429).send({ error: "too many watchlists" });
261+ const id = newId("wl");
262+ await db.execute(sql`insert into watchlists (id, owner_token, name) values (${id}, ${owner}, ${body.name})`);
263+ for (const it of body.items) await db.execute(sql`insert into watchlist_items (watchlist_id, kind, value) values (${id}, ${it.kind}, ${it.value}) on conflict do nothing`);
264+ return { id, name: body.name, items: body.items };
265+ });
266+ app.put<{ Params: { id: string }; Body: { name?: string; items?: { kind: string; value: string }[] } }>("/api/v1/watchlists/:id", async (req, reply) => {
267+ const owner = ownerToken(req.headers as Record<string, unknown>);
268+ if (!owner) return reply.status(401).send({ error: "owner token required" });
269+ const w = (await db.execute<{ id: string }>(sql`select id from watchlists where id = ${req.params.id} and owner_token = ${owner}`)).rows[0];
270+ if (!w) return reply.status(404).send({ error: "not_found" });
271+ const body = z.object({ name: z.string().min(1).max(80).optional(), items: z.array(z.object({ kind: z.enum(["entity", "source", "keyword", "category", "url"]), value: z.string().min(1).max(200) })).max(200).optional() }).parse(req.body ?? {});
272+ if (body.name) await db.execute(sql`update watchlists set name = ${body.name} where id = ${w.id}`);
273+ if (body.items) {
274+ await db.execute(sql`delete from watchlist_items where watchlist_id = ${w.id}`);
275+ for (const it of body.items) await db.execute(sql`insert into watchlist_items (watchlist_id, kind, value) values (${w.id}, ${it.kind}, ${it.value}) on conflict do nothing`);
276+ }
277+ return { ok: true };
278+ });
279+ app.delete<{ Params: { id: string } }>("/api/v1/watchlists/:id", async (req, reply) => {
280+ const owner = ownerToken(req.headers as Record<string, unknown>);
281+ if (!owner) return reply.status(401).send({ error: "owner token required" });
282+ await db.execute(sql`delete from watchlists where id = ${req.params.id} and owner_token = ${owner}`);
283+ return { ok: true };
284+ });
285+ app.get<{ Params: { id: string }; Querystring: { limit?: string } }>("/api/v1/watchlists/:id/events", async (req, reply) => {
286+ const owner = ownerToken(req.headers as Record<string, unknown>);
287+ if (!owner) return reply.status(401).send({ error: "owner token required" });
288+ const items = (await db.execute<{ kind: string; value: string }>(sql`select i.kind, i.value from watchlist_items i join watchlists w on w.id = i.watchlist_id where w.id = ${req.params.id} and w.owner_token = ${owner}`)).rows;
289+ if (!items.length) return reply.send({ items: [] });
290+ const ents = items.filter((i) => i.kind === "entity").map((i) => i.value);
291+ const srcs = items.filter((i) => i.kind === "source").map((i) => i.value);
292+ const cats = items.filter((i) => i.kind === "category").map((i) => i.value);
293+ const kws = items.filter((i) => i.kind === "keyword").map((i) => i.value);
294+ const conds = [] as ReturnType<typeof sql>[];
295+ if (ents.length) conds.push(sql`exists (select 1 from event_entities x where x.event_id = e.id and x.entity_id = any(${textArray(ents)}))`);
296+ if (srcs.length) conds.push(sql`e.source_id = any(${textArray(srcs)})`);
297+ if (cats.length) conds.push(sql`e.categories && ${textArray(cats)}`);
298+ for (const k of kws) conds.push(sql`(e.title ilike ${"%" + k + "%"} or e.summary ilike ${"%" + k + "%"})`);
299+ if (!conds.length) return { items: [] };
300+ const rows = await db.execute<Record<string, unknown>>(sql`select ${EVENT_SELECT} from events e join sources s on s.id = e.source_id join sensors sen on sen.id = e.sensor_id where ${sql.join(conds, sql` or `)} order by e.detected_at desc limit ${Math.min(200, Number(req.query.limit ?? 50))}`);
301+ return { items: rows.rows };
302+ });
303+
304+ app.get("/api/v1/alerts", async (req, reply) => {
305+ const owner = ownerToken(req.headers as Record<string, unknown>);
306+ if (!owner) return reply.status(401).send({ error: "owner token required" });
307+ return { items: (await db.execute<Record<string, unknown>>(sql`select * from alerts where owner_token = ${owner} order by created_at`)).rows };
308+ });
309+ app.post<{ Body: Record<string, unknown> }>("/api/v1/alerts", async (req, reply) => {
310+ const owner = ownerToken(req.headers as Record<string, unknown>);
311+ if (!owner) return reply.status(401).send({ error: "owner token required" });
312+ const body = z.object({ name: z.string().min(1).max(80), rule: z.object({ importance_min: z.number().min(0).max(100).optional(), event_types: z.array(z.string()).optional(), entities: z.array(z.string()).optional(), sources: z.array(z.string()).optional(), keywords: z.array(z.string()).optional(), silent_only: z.boolean().optional(), categories: z.array(z.string()).optional() }), channel: z.enum(["web"]).default("web") }).parse(req.body ?? {});
313+ const id = newId("alr");
314+ await db.execute(sql`insert into alerts (id, owner_token, name, rule, channel) values (${id}, ${owner}, ${body.name}, ${JSON.stringify(body.rule)}::jsonb, ${body.channel})`);
315+ return { id, ...body };
316+ });
317+ app.delete<{ Params: { id: string } }>("/api/v1/alerts/:id", async (req, reply) => {
318+ const owner = ownerToken(req.headers as Record<string, unknown>);
319+ if (!owner) return reply.status(401).send({ error: "owner token required" });
320+ await db.execute(sql`delete from alerts where id = ${req.params.id} and owner_token = ${owner}`);
321+ return { ok: true };
322+ });
323+
324+ // ---- Machine-readable feeds ------------------------------------------------------------------
325+ app.get<{ Querystring: { category?: string; importance_min?: string; silent_change?: string } }>("/api/v1/feed.rss", async (req, reply) => {
326+ const q = eventsQuery.parse({ ...req.query, limit: 50 });
327+ const { items } = await listEvents(q);
328+ const esc = (s: unknown): string => String(s ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
329+ const base = config.publicBaseUrl;
330+ const xml = `<?xml version="1.0" encoding="UTF-8"?>\n<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>WebSensor — ${esc(q.category ? q.category + " events" : "live events")}</title><link>${base}</link><description>Meaningful changes detected on the public Web by WebSensor.</description><atom:link href="${base}/api/v1/feed.rss" rel="self" type="application/rss+xml"/>${items
331+ .map((e) => `<item><title>${esc(e.title)}</title><link>${base}/event/${esc(e.slug)}</link><guid isPermaLink="false">${esc(e.id)}</guid><pubDate>${new Date(String(e.detected_at)).toUTCString()}</pubDate><category>${esc(e.event_type)}</category><description>${esc(e.summary)} (importance ${esc(e.importance)}, confidence ${esc(e.confidence)}${e.silent_change ? ", silent change" : ""}) — source: ${esc(e.url)}</description></item>`)
332+ .join("")}</channel></rss>`;
333+ reply.header("content-type", "application/rss+xml; charset=utf-8");
334+ return reply.send(xml);
335+ });
336+
337+ app.get("/api/v1", async () => ({
338+ name: "WebSensor API",
339+ version: "v1",
340+ docs: `${config.publicBaseUrl}/api`,
341+ endpoints: ["/api/v1/events", "/api/v1/events/{id|slug}", "/api/v1/changes/{id}", "/api/v1/snapshots/{id}", "/api/v1/snapshots/compare?a=&b=", "/api/v1/sources", "/api/v1/sources/{id}", "/api/v1/sensors/{id}", "/api/v1/entities", "/api/v1/entities/{id}", "/api/v1/entities/{id}/timeline", "/api/v1/domains/{domain}/timeline", "/api/v1/urls/history?url=", "/api/v1/search?q=", "/api/v1/stats", "/api/v1/trending", "/api/v1/explore", "/api/v1/clusters", "/api/v1/health/connectors", "/api/v1/watchlists", "/api/v1/alerts", "/api/v1/feed.rss", "wss://…/api/v1/live"],
342+ }));
343+}
344+
345+function renderCanonical(c: { mode: string; text?: string; items?: Record<string, unknown>[]; json?: unknown }): string {
346+ if (c.mode === "text") return c.text ?? "";
347+ if (c.mode === "list") return (c.items ?? []).map((i) => [i.title ?? i.url ?? i.key, i.url && i.title ? i.url : null, i.summary ? String(i.summary).slice(0, 300) : null].filter(Boolean).join(" — ")).join("\n");
348+ return JSON.stringify(c.json ?? null, null, 2);
349+}
added apps/api/src/server.ts +107 −0
@@ -0,0 +1,107 @@
1+import Fastify, { type FastifyReply, type FastifyRequest } from "fastify";
2+import cors from "@fastify/cors";
3+import rateLimit from "@fastify/rate-limit";
4+import websocket from "@fastify/websocket";
5+import replyFrom from "@fastify/reply-from";
6+import client from "prom-client";
7+import { newId } from "@websensor/core";
8+import { closeDb } from "@websensor/db";
9+import { config } from "./config";
10+import { closeLive, registerLive } from "./live";
11+import { registerRoutes } from "./routes";
12+
13+/**
14+ * WebSensor gateway: public entry point behind ngrok.
15+ * /api/v1/* REST + WebSocket (/api/v1/live)
16+ * /api/health /api/ready /api/metrics
17+ * everything else → Next.js frontend (loopback), with forwarded headers.
18+ * Also enforces the canonical host (websensor.io → www.websensor.io).
19+ */
20+const registry = new client.Registry();
21+client.collectDefaultMetrics({ register: registry, prefix: "websensor_api_" });
22+const httpRequests = new client.Counter({ name: "websensor_api_requests_total", help: "API requests", labelNames: ["route", "status"], registers: [registry] });
23+
24+export async function buildServer() {
25+ const app = Fastify({
26+ logger: {
27+ level: config.logLevel,
28+ ...(config.env !== "production" ? { transport: { target: "pino-pretty", options: { colorize: true, translateTime: "HH:MM:ss" } } } : {}),
29+ redact: ["req.headers.authorization", "req.headers.cookie", "req.headers['x-websensor-owner']"],
30+ },
31+ trustProxy: true,
32+ bodyLimit: 512 * 1024,
33+ genReqId: () => newId("req"),
34+ disableRequestLogging: config.env === "production",
35+ });
36+
37+ // Canonical host redirect (apex → www) for every path.
38+ app.addHook("onRequest", async (req, reply) => {
39+ const host = (req.headers.host ?? "").split(":")[0];
40+ if (config.redirectApexToWww && host && host !== config.canonicalHost && host === config.canonicalHost.replace(/^www\./, "")) {
41+ return reply.redirect(`https://${config.canonicalHost}${req.url}`, 301);
42+ }
43+ });
44+
45+ await app.register(cors, { origin: true, methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], allowedHeaders: ["content-type", "x-websensor-owner", "authorization"], exposedHeaders: ["x-request-id"] });
46+ await app.register(rateLimit, { max: 600, timeWindow: "1 minute", allowList: (req) => !req.url.startsWith("/api/") });
47+ await app.register(websocket, { options: { maxPayload: 64 * 1024 } });
48+
49+ app.addHook("onSend", async (req, reply) => {
50+ reply.header("x-request-id", req.id);
51+ if (req.url.startsWith("/api/")) {
52+ reply.header("cache-control", req.method === "GET" ? "public, max-age=5, stale-while-revalidate=30" : "no-store");
53+ httpRequests.inc({ route: req.routeOptions?.url ?? "unknown", status: String(reply.statusCode) });
54+ }
55+ });
56+
57+ app.setErrorHandler((err: Error & { statusCode?: number; validation?: unknown; issues?: unknown }, req, reply) => {
58+ if (err.name === "ZodError" || err.issues) return reply.status(400).send({ error: "invalid_request", details: err.issues });
59+ if (err.statusCode && err.statusCode < 500) return reply.status(err.statusCode).send({ error: err.message });
60+ req.log.error({ err }, "unhandled error");
61+ return reply.status(500).send({ error: "internal_error", request_id: req.id });
62+ });
63+
64+ app.get("/api/metrics", async (_req, reply) => {
65+ reply.header("content-type", registry.contentType);
66+ return registry.metrics();
67+ });
68+
69+ await registerRoutes(app);
70+ await registerLive(app);
71+
72+ // Frontend proxy — everything that is not /api/* goes to Next.js (loopback)
73+ // with forwarded headers so the app knows the public host/proto and the real client IP.
74+ await app.register(replyFrom, { base: config.webUrl, http: { requestOptions: { timeout: 60_000 } }, undici: { connections: 64, pipelining: 1 } });
75+ const forward = (req: FastifyRequest, reply: FastifyReply): FastifyReply => {
76+ if (req.url.startsWith("/api/")) return reply.status(404).send({ error: "not_found" });
77+ return reply.from(req.raw.url ?? "/", {
78+ rewriteRequestHeaders: (r, headers) => ({ ...headers, "x-forwarded-host": String(r.headers["x-forwarded-host"] ?? r.headers.host ?? ""), "x-forwarded-proto": String(r.headers["x-forwarded-proto"] ?? "https"), "x-real-ip": r.ip, "x-forwarded-for": r.ip }),
79+ });
80+ };
81+ const methods = ["GET", "HEAD", "POST", "PUT", "DELETE", "PATCH"] as const;
82+ app.route({ method: [...methods], url: "/", handler: forward });
83+ app.route({ method: [...methods], url: "/*", handler: forward });
84+
85+ return app;
86+}
87+
88+async function main(): Promise<void> {
89+ const app = await buildServer();
90+ await app.listen({ port: config.port, host: config.host });
91+ app.log.info({ port: config.port, web: config.webUrl, canonical: config.canonicalHost }, "websensor gateway listening");
92+ const shutdown = async (): Promise<void> => {
93+ await closeLive();
94+ await app.close();
95+ await closeDb();
96+ process.exit(0);
97+ };
98+ process.on("SIGINT", () => void shutdown());
99+ process.on("SIGTERM", () => void shutdown());
100+}
101+
102+if (process.argv[1] && import.meta.url.endsWith(process.argv[1].split("/").pop() ?? "")) {
103+ main().catch((e) => {
104+ console.error(e);
105+ process.exit(1);
106+ });
107+}
added apps/api/tsconfig.json +5 −0
@@ -0,0 +1,5 @@
1+{
2+ "extends": "../../tsconfig.base.json",
3+ "compilerOptions": { "types": ["node"] },
4+ "include": ["src/**/*.ts"]
5+}
added apps/engine/package.json +35 −0
@@ -0,0 +1,35 @@
1+{
2+ "name": "@websensor/engine",
3+ "version": "0.1.0",
4+ "private": true,
5+ "type": "module",
6+ "scripts": {
7+ "dev": "tsx watch src/index.ts",
8+ "start": "tsx src/index.ts",
9+ "registry:sync": "tsx src/cli.ts sync",
10+ "discover": "tsx src/cli.ts discover",
11+ "run-once": "tsx src/cli.ts run-once",
12+ "typecheck": "tsc -p tsconfig.json --noEmit",
13+ "test": "vitest run --passWithNoTests"
14+ },
15+ "dependencies": {
16+ "@anthropic-ai/sdk": "^0.124.0",
17+ "@websensor/connectors": "workspace:*",
18+ "@websensor/core": "workspace:*",
19+ "@websensor/db": "workspace:*",
20+ "@websensor/store": "workspace:*",
21+ "fastify": "^5.4.0",
22+ "ioredis": "^5.6.0",
23+ "pino": "^9.7.0",
24+ "pino-pretty": "^13.0.0",
25+ "prom-client": "^15.1.0",
26+ "tsx": "^4.20.0",
27+ "yaml": "^2.8.0",
28+ "zod": "^4.0.0"
29+ },
30+ "devDependencies": {
31+ "@types/node": "^24.0.0",
32+ "typescript": "^5.9.3",
33+ "vitest": "^3.2.0"
34+ }
35+}
added apps/engine/src/cli.ts +81 −0
@@ -0,0 +1,81 @@
1+import { closeDb, db, migrate, sensors, sources, sql } from "@websensor/db";
2+import { closeDispatcher, discoverDomain } from "@websensor/connectors";
3+import { loadRecent } from "./cluster";
4+import { config, log } from "./config";
5+import { closeRedis } from "./redis";
6+import { runDiscovery, syncRegistry } from "./registry";
7+import { runSensor } from "./pipeline";
8+import { diffText, evaluateChange } from "@websensor/core";
9+import { interpretChange, llmAvailable } from "./interpret";
10+
11+/**
12+ * Operator CLI:
13+ * tsx src/cli.ts sync — upsert sources/sensors/entities from config/sources.yaml
14+ * tsx src/cli.ts discover [sourceId…] — run discovery (+ promote validated endpoints)
15+ * tsx src/cli.ts probe <domain> — discovery dry-run for a domain (no DB writes)
16+ * tsx src/cli.ts run-once <sensorId> — run the full pipeline for one sensor now
17+ * tsx src/cli.ts run-due [n] — run up to n due sensors sequentially
18+ */
19+async function main(): Promise<void> {
20+ const [cmd, ...args] = process.argv.slice(2);
21+ await migrate(config.databaseUrl);
22+ switch (cmd) {
23+ case "sync":
24+ await syncRegistry();
25+ break;
26+ case "discover":
27+ await syncRegistry();
28+ await runDiscovery({ sourceIds: args.length ? args : undefined });
29+ break;
30+ case "probe": {
31+ const r = await discoverDomain(args[0]!, { probePages: true });
32+ for (const e of r) console.log(`${e.type.padEnd(10)} ${e.value.toFixed(2)} ${String(e.itemCount ?? "").padStart(5)} ${e.url} (${e.evidence})`);
33+ break;
34+ }
35+ case "run-once": {
36+ await loadRecent();
37+ const s = (await db.select().from(sensors).where(sql`id = ${args[0]}`))[0];
38+ if (!s) throw new Error(`sensor ${args[0]} not found`);
39+ const src = (await db.select().from(sources).where(sql`id = ${s.sourceId}`))[0]!;
40+ const out = await runSensor(s, src);
41+ console.log(`${s.id}: ${out}`);
42+ break;
43+ }
44+ case "run-due": {
45+ await loadRecent();
46+ const n = Number(args[0] ?? 20);
47+ const rows = await db.select().from(sensors).where(sql`enabled and next_check_at <= now()`).orderBy(sql`next_check_at asc`).limit(n);
48+ const srcs = new Map((await db.select().from(sources)).map((s) => [s.id, s]));
49+ for (const s of rows) {
50+ const out = await runSensor(s, srcs.get(s.sourceId)!);
51+ console.log(`${s.id.padEnd(50)} ${out}`);
52+ }
53+ break;
54+ }
55+ case "llm-test": {
56+ if (!llmAvailable()) throw new Error("ANTHROPIC_API_KEY not set");
57+ const before = "API Pricing\nInput: $10 / million tokens\nOutput: $30 / million tokens\nBatch API: 50% discount";
58+ const after = "API Pricing\nInput: $8 / million tokens\nOutput: $24 / million tokens\nBatch API: 50% discount\nPrompt caching: 90% discount on cached input";
59+ const diff = diffText(before, after);
60+ const heuristic = evaluateChange(diff, { sensorType: "HTML", url: "https://example-ai.com/pricing", sourceCategories: ["ai"], title: "API Pricing" });
61+ const t0 = Date.now();
62+ const out = await interpretChange({ sourceName: "Example AI", sourceCategories: ["ai"], url: "https://example-ai.com/pricing", sensorName: "pricing", sensorType: "HTML", heuristic, diff, prelimImportance: Number(args[0] ?? 60), title: "API Pricing" });
63+ console.log(JSON.stringify(out, null, 2), `\n${Date.now() - t0} ms`);
64+ break;
65+ }
66+ default:
67+ console.error("usage: cli.ts sync | discover [sourceId…] | probe <domain> | run-once <sensorId> | run-due [n]");
68+ process.exitCode = 1;
69+ }
70+}
71+
72+main()
73+ .catch((e) => {
74+ log.error({ err: (e as Error).stack ?? String(e) }, "cli failed");
75+ process.exitCode = 1;
76+ })
77+ .finally(async () => {
78+ await closeDispatcher();
79+ await closeRedis();
80+ await closeDb();
81+ });
added apps/engine/src/cluster.ts +135 −0
@@ -0,0 +1,135 @@
1+import { jaccard, newId, shingles } from "@websensor/core";
2+import { db, eventClusters, events, gte, sql, textArray } from "@websensor/db";
3+
4+/**
5+ * Novelty + clustering over a rolling in-memory window of recent events (loaded from
6+ * Postgres at startup). Similarity = Jaccard over word 3-shingles of title+summary.
7+ */
8+interface RecentEvent {
9+ id: string;
10+ clusterId: string | null;
11+ sourceId: string;
12+ sensorId: string;
13+ eventType: string;
14+ entityIds: string[];
15+ detectedAt: number;
16+ sh: Set<string>;
17+ importance: number;
18+}
19+
20+const WINDOW_MS = 72 * 3600e3;
21+const CLUSTER_WINDOW_MS = 6 * 3600e3;
22+let recent: RecentEvent[] = [];
23+let loaded = false;
24+
25+export async function loadRecent(): Promise<void> {
26+ const since = new Date(Date.now() - WINDOW_MS);
27+ const rows = await db.execute<{ id: string; cluster_id: string | null; source_id: string; sensor_id: string; event_type: string; title: string; summary: string; detected_at: Date; importance: number; entity_ids: string[] | null }>(sql`
28+ select e.id, e.cluster_id, e.source_id, e.sensor_id, e.event_type, e.title, e.summary, e.detected_at, e.importance,
29+ (select array_agg(entity_id) from event_entities ee where ee.event_id = e.id) as entity_ids
30+ from events e where e.detected_at >= ${since} order by e.detected_at desc limit 3000`);
31+ recent = rows.rows.map((r) => ({ id: r.id, clusterId: r.cluster_id, sourceId: r.source_id, sensorId: r.sensor_id, eventType: r.event_type, entityIds: r.entity_ids ?? [], detectedAt: new Date(r.detected_at).getTime(), sh: shingles(`${r.title}\n${r.summary}`), importance: r.importance }));
32+ loaded = true;
33+}
34+
35+function prune(): void {
36+ const cutoff = Date.now() - WINDOW_MS;
37+ recent = recent.filter((r) => r.detectedAt >= cutoff);
38+}
39+
40+export interface NoveltyResult {
41+ novelty: number;
42+ nearest: { id: string; similarity: number } | null;
43+ /** number of distinct sources reporting near-identical content */
44+ confirmations: number;
45+}
46+
47+export async function assessNovelty(text: string, sourceId: string): Promise<NoveltyResult> {
48+ if (!loaded) await loadRecent();
49+ prune();
50+ const sh = shingles(text);
51+ let best = 0;
52+ let nearest: RecentEvent | null = null;
53+ const confirmingSources = new Set<string>();
54+ for (const r of recent) {
55+ const s = jaccard(sh, r.sh);
56+ if (s > best) {
57+ best = s;
58+ nearest = r;
59+ }
60+ if (s >= 0.45 && r.sourceId !== sourceId) confirmingSources.add(r.sourceId);
61+ }
62+ return { novelty: Math.round((1 - best) * 100), nearest: nearest ? { id: nearest.id, similarity: Math.round(best * 100) / 100 } : null, confirmations: confirmingSources.size };
63+}
64+
65+export interface ClusterDecision {
66+ clusterId: string;
67+ created: boolean;
68+}
69+
70+/**
71+ * Attach to an existing open cluster when the event shares an entity (or the same source)
72+ * with a recent event and is textually related, or when it is the same event type on the
73+ * same source within 30 minutes (e.g. one launch touching six pages). Otherwise open one.
74+ */
75+export async function clusterEvent(ev: { id: string; sourceId: string; sensorId: string; eventType: string; entityIds: string[]; detectedAt: Date; title: string; summary: string; importance: number; categories: string[] }): Promise<ClusterDecision> {
76+ if (!loaded) await loadRecent();
77+ const sh = shingles(`${ev.title}\n${ev.summary}`);
78+ const now = ev.detectedAt.getTime();
79+ let bestCluster: string | null = null;
80+ let bestScore = 0;
81+ for (const r of recent) {
82+ if (!r.clusterId || now - r.detectedAt > CLUSTER_WINDOW_MS) continue;
83+ const sharedEntity = r.entityIds.some((e) => ev.entityIds.includes(e));
84+ const sameSource = r.sourceId === ev.sourceId;
85+ if (!sharedEntity && !sameSource) continue;
86+ const sim = jaccard(sh, r.sh);
87+ const closeInTime = now - r.detectedAt < 30 * 60e3;
88+ let score = 0;
89+ if (sim >= 0.22) score = sim + (sharedEntity ? 0.2 : 0);
90+ else if (sameSource && r.eventType === ev.eventType && closeInTime && r.sensorId !== ev.sensorId) score = 0.3;
91+ else if (sameSource && closeInTime && sim >= 0.12) score = 0.25;
92+ if (score > bestScore) {
93+ bestScore = score;
94+ bestCluster = r.clusterId;
95+ }
96+ }
97+ let clusterId: string;
98+ let created = false;
99+ if (bestCluster) {
100+ clusterId = bestCluster;
101+ await db.execute(sql`update event_clusters set event_count = event_count + 1, last_at = greatest(last_at, ${ev.detectedAt}), max_importance = greatest(max_importance, ${ev.importance}),
102+ entity_ids = (select array(select distinct unnest(entity_ids || ${textArray(ev.entityIds)}))),
103+ categories = (select array(select distinct unnest(categories || ${textArray(ev.categories)}))),
104+ title = case when ${ev.importance} > max_importance then ${ev.title} else title end,
105+ primary_event_id = case when ${ev.importance} > max_importance then ${ev.id} else primary_event_id end
106+ where id = ${clusterId}`);
107+ } else {
108+ clusterId = newId("clu");
109+ created = true;
110+ await db.insert(eventClusters).values({ id: clusterId, title: ev.title, summary: ev.summary, primaryEventId: ev.id, entityIds: ev.entityIds, categories: ev.categories, eventCount: 1, maxImportance: ev.importance, firstAt: ev.detectedAt, lastAt: ev.detectedAt });
111+ }
112+ recent.unshift({ id: ev.id, clusterId, sourceId: ev.sourceId, sensorId: ev.sensorId, eventType: ev.eventType, entityIds: ev.entityIds, detectedAt: now, sh, importance: ev.importance });
113+ if (recent.length > 5000) recent.length = 5000;
114+ return { clusterId, created };
115+}
116+
117+/** Recent events of the same source published through announcement-type sensors (for silent-change detection). */
118+export function recentAnnouncementSimilarity(sourceId: string, text: string, sinceMs: number): number {
119+ const sh = shingles(text);
120+ let best = 0;
121+ const cutoff = Date.now() - sinceMs;
122+ for (const r of recent) {
123+ if (r.sourceId !== sourceId || r.detectedAt < cutoff) continue;
124+ if (!/announcement|product_launch|model_release|software_release|repository_release|incident|outage|maintenance|security_advisory/.test(r.eventType)) continue;
125+ best = Math.max(best, jaccard(sh, r.sh));
126+ }
127+ return best;
128+}
129+
130+export async function recentClusterCount(sinceMs: number): Promise<number> {
131+ const rows = await db.select({ n: sql<number>`count(*)` }).from(eventClusters).where(gte(eventClusters.lastAt, new Date(Date.now() - sinceMs)));
132+ return Number(rows[0]?.n ?? 0);
133+}
134+
135+export { events };
added apps/engine/src/config.ts +40 −0
@@ -0,0 +1,40 @@
1+import pino from "pino";
2+
3+const env = process.env;
4+
5+export const config = {
6+ env: env.NODE_ENV ?? "development",
7+ logLevel: env.LOG_LEVEL ?? "info",
8+ databaseUrl: env.DATABASE_URL ?? "postgres://localhost:5432/websensor",
9+ redisUrl: env.REDIS_URL ?? "redis://127.0.0.1:6379",
10+ sourcesFile: env.WS_SOURCES_FILE ?? "./config/sources.yaml",
11+ entitiesFile: env.WS_ENTITIES_FILE ?? "./config/entities.yaml",
12+ fetchConcurrency: Number(env.WS_FETCH_CONCURRENCY ?? 16),
13+ perHostConcurrency: Number(env.WS_PER_HOST_CONCURRENCY ?? 2),
14+ metricsPort: Number(env.ENGINE_METRICS_PORT ?? 8262),
15+ metricsHost: env.ENGINE_METRICS_HOST ?? "127.0.0.1",
16+ processingVersion: "event-pipeline-v1",
17+ /** heuristic signal threshold to promote a raw change to an event */
18+ meaningfulSignal: Number(env.WS_MEANINGFUL_SIGNAL ?? 0.32),
19+ llm: {
20+ apiKey: env.ANTHROPIC_API_KEY ?? "",
21+ modelFast: env.WS_LLM_MODEL_FAST ?? "claude-haiku-4-5",
22+ modelDeep: env.WS_LLM_MODEL_DEEP ?? "claude-opus-5",
23+ dailyCallBudget: Number(env.WS_LLM_DAILY_CALL_BUDGET ?? 600),
24+ minImportance: Number(env.WS_LLM_MIN_IMPORTANCE ?? 35),
25+ deepMinImportance: Number(env.WS_LLM_DEEP_MIN_IMPORTANCE ?? 78),
26+ },
27+ discovery: {
28+ enabled: (env.WS_DISCOVERY ?? "1") !== "0",
29+ /** re-run discovery for a source after this many days */
30+ intervalDays: Number(env.WS_DISCOVERY_INTERVAL_DAYS ?? 7),
31+ },
32+ deletion: { confirmations: Number(env.WS_DELETE_CONFIRMATIONS ?? 3), minSeparationMin: Number(env.WS_DELETE_SEPARATION_MIN ?? 60) },
33+ version: "0.1.0",
34+};
35+
36+export const log = pino({
37+ level: config.logLevel,
38+ ...(config.env !== "production" ? { transport: { target: "pino-pretty", options: { colorize: true, translateTime: "HH:MM:ss" } } } : {}),
39+ base: { service: "websensor-engine" },
40+});
added apps/engine/src/entities.ts +62 −0
@@ -0,0 +1,62 @@
1+import { db, entityAliases, entities, eq, sourceEntities, sql, textArray } from "@websensor/db";
2+import { log } from "./config";
3+
4+/**
5+ * Entity resolution. The source's own entities are always attached (subject). Then aliases
6+ * of all known entities are matched against the event text (word-boundary, case-insensitive,
7+ * longest alias first) so products/models/regulators named in the change are linked too.
8+ */
9+interface AliasIndex {
10+ loadedAt: number;
11+ byLen: { alias: string; re: RegExp; entityId: string }[];
12+ importance: Map<string, number>;
13+}
14+
15+let index: AliasIndex | null = null;
16+
17+async function loadIndex(): Promise<AliasIndex> {
18+ if (index && Date.now() - index.loadedAt < 5 * 60_000) return index;
19+ const rows = await db.select({ alias: entityAliases.alias, entityId: entityAliases.entityId }).from(entityAliases);
20+ const ents = await db.select({ id: entities.id, importance: entities.importance }).from(entities);
21+ const byLen = rows
22+ .filter((r) => r.alias.length >= 3 && !/^\d+$/.test(r.alias))
23+ .map((r) => ({ alias: r.alias, entityId: r.entityId, re: new RegExp(`(^|[^\\p{L}\\p{N}])${escapeRe(r.alias)}(?=$|[^\\p{L}\\p{N}])`, "iu") }))
24+ .sort((a, b) => b.alias.length - a.alias.length);
25+ index = { loadedAt: Date.now(), byLen, importance: new Map(ents.map((e) => [e.id, e.importance])) };
26+ log.debug({ aliases: byLen.length }, "alias index loaded");
27+ return index;
28+}
29+
30+function escapeRe(s: string): string {
31+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
32+}
33+
34+export function invalidateEntityIndex(): void {
35+ index = null;
36+}
37+
38+export async function resolveEntities(input: { sourceId: string; text: string; hints: string[] }): Promise<{ subject: string[]; mentioned: string[]; importance: number }> {
39+ const idx = await loadIndex();
40+ const subject = (await db.select({ entityId: sourceEntities.entityId }).from(sourceEntities).where(eq(sourceEntities.sourceId, input.sourceId))).map((r) => r.entityId);
41+ // Only the organization (not every product) is a default subject; products are attached when named.
42+ const orgSubjects = subject.filter((s) => s.startsWith("org_"));
43+ const mentioned = new Set<string>();
44+ const hay = `${input.text}\n${input.hints.join("\n")}`;
45+ const hayLower = hay.toLowerCase();
46+ for (const a of idx.byLen) {
47+ if (mentioned.size >= 12) break;
48+ if (orgSubjects.includes(a.entityId)) continue;
49+ if (!hayLower.includes(a.alias)) continue;
50+ if (a.re.test(hay)) mentioned.add(a.entityId);
51+ }
52+ // Products of the source named in the text are subjects too.
53+ for (const s of subject) if (!s.startsWith("org_") && mentioned.has(s)) mentioned.delete(s), orgSubjects.push(s);
54+ const all = [...orgSubjects, ...mentioned];
55+ const importance = all.length ? Math.max(...all.map((e) => idx.importance.get(e) ?? 40)) : 40;
56+ return { subject: orgSubjects, mentioned: [...mentioned], importance };
57+}
58+
59+export async function bumpEntityCounters(entityIds: string[], at: Date): Promise<void> {
60+ if (!entityIds.length) return;
61+ await db.execute(sql`update entities set event_count = event_count + 1, last_event_at = ${at} where id = any(${textArray(entityIds)})`);
62+}
added apps/engine/src/index.ts +52 −0
@@ -0,0 +1,52 @@
1+import { closeDb, migrate } from "@websensor/db";
2+import { closeDispatcher } from "@websensor/connectors";
3+import { loadRecent } from "./cluster";
4+import { config, log } from "./config";
5+import { startMetricsServer } from "./metrics";
6+import { closeRedis } from "./redis";
7+import { runDiscovery, syncRegistry } from "./registry";
8+import { pruneOldRuns, rollupConnectorHealth, Scheduler } from "./scheduler";
9+
10+async function main(): Promise<void> {
11+ log.info({ env: config.env, version: config.version, llm: Boolean(config.llm.apiKey) }, "websensor engine starting");
12+ const applied = await migrate(config.databaseUrl);
13+ if (applied.length) log.info({ applied }, "migrations applied");
14+ await syncRegistry();
15+ await loadRecent();
16+ await startMetricsServer();
17+
18+ const scheduler = new Scheduler();
19+ await scheduler.start();
20+
21+ const timers: NodeJS.Timeout[] = [];
22+ timers.push(setInterval(() => rollupConnectorHealth().catch((e) => log.warn({ err: (e as Error).message }, "health rollup failed")), 60_000));
23+ timers.push(setInterval(() => pruneOldRuns().catch(() => undefined), 6 * 3600e3));
24+ await rollupConnectorHealth().catch(() => undefined);
25+
26+ if (config.discovery.enabled) {
27+ // Background: validate feeds/sitemaps/status pages for sources not discovered recently, then weekly.
28+ setTimeout(() => runDiscovery({ onlyMissing: true }).catch((e) => log.warn({ err: (e as Error).message }, "discovery failed")), 5_000);
29+ timers.push(setInterval(() => runDiscovery({ onlyMissing: true }).catch(() => undefined), 24 * 3600e3));
30+ }
31+
32+ let shuttingDown = false;
33+ const shutdown = async (signal: string): Promise<void> => {
34+ if (shuttingDown) return;
35+ shuttingDown = true;
36+ log.info({ signal }, "shutting down");
37+ for (const t of timers) clearInterval(t);
38+ await scheduler.stop();
39+ await closeDispatcher();
40+ await closeRedis();
41+ await closeDb();
42+ process.exit(0);
43+ };
44+ process.on("SIGINT", () => void shutdown("SIGINT"));
45+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
46+ process.on("unhandledRejection", (e) => log.error({ err: e instanceof Error ? e.stack : String(e) }, "unhandled rejection"));
47+}
48+
49+main().catch((e) => {
50+ log.fatal({ err: (e as Error).stack ?? String(e) }, "engine failed to start");
51+ process.exit(1);
52+});
added apps/engine/src/interpret.ts +153 −0
@@ -0,0 +1,153 @@
1+import Anthropic from "@anthropic-ai/sdk";
2+import { EVENT_TYPES, type DiffResult, type HeuristicResult } from "@websensor/core";
3+import { db, llmUsage, sql } from "@websensor/db";
4+import { config, log } from "./config";
5+import { bumpDaily, m } from "./metrics";
6+
7+/**
8+ * Stage-2 interpretation with Claude. Only called for candidates that already cleared the
9+ * heuristic bar and a preliminary importance threshold; a daily call budget caps spend.
10+ * Routine changes go to the fast model; high-importance candidates get the deep model.
11+ * Output is strictly structured (JSON schema) — never free text.
12+ */
13+export interface Interpretation {
14+ event_type: string;
15+ title: string;
16+ summary: string;
17+ why_it_matters: string;
18+ who_it_affects: string;
19+ observed: string;
20+ inferred: string;
21+ meaningful: boolean;
22+ severity: number;
23+ confidence: number;
24+ entities: string[];
25+ keywords: string[];
26+ announced: boolean | null;
27+ model: string;
28+ input_tokens: number;
29+ output_tokens: number;
30+}
31+
32+const client = config.llm.apiKey ? new Anthropic({ apiKey: config.llm.apiKey, timeout: 60_000, maxRetries: 1 }) : null;
33+
34+const SYSTEM = `You are the interpretation stage of WebSensor, a platform that monitors official public web sources and turns detected changes into precise, sober intelligence events.
35+You receive a detected change on one monitored endpoint: the source, the URL, the sensor kind, the heuristic pre-classification, extracted facts and the diff (before → after, or new/removed items).
36+Your job: decide whether the change is meaningful to a professional audience, classify it, and write a factual title and summary.
37+Rules:
38+- Never sensationalize. No marketing tone. State what is observed; keep inference clearly separate.
39+- "observed" must only contain facts visible in the diff. "inferred" may contain a cautious interpretation or be empty.
40+- Titles: ≤ 110 characters, start with the organization name, no trailing period, no emoji.
41+- Summary: 1–3 sentences, concrete (numbers, names, versions when present).
42+- meaningful=false for cosmetic edits, typos, navigation/footer churn, copyright years, tracking or timestamp noise, and generic marketing rewording with no new information.
43+- severity: 0–100 intrinsic importance of this kind of change for people who follow this organization (pricing/security/outage/model launch high; doc typo low).
44+- confidence: 0–100 how sure you are of the classification given the evidence.
45+- entities: organizations, products, models, APIs, drugs, standards explicitly named in the change (canonical names).
46+- announced: true if the diff itself is an announcement (news/blog/release item), false if it is a silent modification of an existing page, null if unclear.
47+Allowed event_type values: ${Object.keys(EVENT_TYPES).join(", ")}.`;
48+
49+const schema = {
50+ type: "object",
51+ additionalProperties: false,
52+ properties: {
53+ event_type: { type: "string", enum: Object.keys(EVENT_TYPES) },
54+ title: { type: "string" },
55+ summary: { type: "string" },
56+ why_it_matters: { type: "string" },
57+ who_it_affects: { type: "string" },
58+ observed: { type: "string" },
59+ inferred: { type: "string" },
60+ meaningful: { type: "boolean" },
61+ severity: { type: "integer", description: "0–100" },
62+ confidence: { type: "integer", description: "0–100" },
63+ entities: { type: "array", items: { type: "string" } },
64+ keywords: { type: "array", items: { type: "string" } },
65+ announced: { type: ["boolean", "null"] },
66+ },
67+ required: ["event_type", "title", "summary", "why_it_matters", "who_it_affects", "observed", "inferred", "meaningful", "severity", "confidence", "entities", "keywords", "announced"],
68+} as const;
69+
70+let budgetDay = "";
71+let budgetUsed = 0;
72+
73+async function loadBudget(): Promise<void> {
74+ const today = new Date().toISOString().slice(0, 10);
75+ if (budgetDay === today) return;
76+ budgetDay = today;
77+ const r = await db.execute<{ n: string }>(sql`select count(*)::text as n from llm_usage where at >= current_date`);
78+ budgetUsed = Number(r.rows[0]?.n ?? 0);
79+}
80+
81+export function llmAvailable(): boolean {
82+ return client !== null;
83+}
84+
85+export async function interpretChange(input: { sourceName: string; sourceCategories: string[]; url: string; sensorName: string; sensorType: string; heuristic: HeuristicResult; diff: DiffResult; prelimImportance: number; title?: string | null }): Promise<Interpretation | null> {
86+ if (!client) return null;
87+ await loadBudget();
88+ if (budgetUsed >= config.llm.dailyCallBudget) {
89+ log.warn({ used: budgetUsed }, "LLM daily budget exhausted — heuristics only");
90+ return null;
91+ }
92+ const deep = input.prelimImportance >= config.llm.deepMinImportance;
93+ const model = deep ? config.llm.modelDeep : config.llm.modelFast;
94+ const diffText = renderDiff(input.diff).slice(0, 14_000);
95+ const user = `SOURCE: ${input.sourceName} (${input.sourceCategories.join(", ") || "n/a"})
96+URL: ${input.url}
97+SENSOR: ${input.sensorName} [${input.sensorType}]${input.title ? `\nPAGE TITLE: ${input.title}` : ""}
98+HEURISTIC: type=${input.heuristic.eventType} signal=${input.heuristic.signal.toFixed(2)} magnitude=${input.heuristic.magnitude} noise=${input.heuristic.noiseRatio.toFixed(2)} reasons=${input.heuristic.reasons.join("; ") || "none"}
99+FACTS: ${input.heuristic.facts.map((f) => `${f.kind}: ${f.before ?? "∅"} → ${f.after ?? "∅"}`).join(" | ") || "none"}
100+
101+DIFF:
102+${diffText}`;
103+
104+ budgetUsed++;
105+ const started = Date.now();
106+ try {
107+ // Haiku 4.5 does not accept `effort`; the deep model (Opus 5) runs adaptive thinking at medium effort.
108+ const params: Anthropic.MessageCreateParamsNonStreaming = {
109+ model,
110+ max_tokens: 1500,
111+ system: [{ type: "text", text: SYSTEM, cache_control: { type: "ephemeral" } }],
112+ messages: [{ role: "user", content: user }],
113+ output_config: { format: { type: "json_schema", schema: schema as unknown as Record<string, unknown> }, ...(deep ? { effort: "medium" as const } : {}) },
114+ };
115+ const res = await client.messages.create(params);
116+ const text = res.content.find((b) => b.type === "text")?.text ?? "";
117+ if (res.stop_reason === "refusal" || !text) throw new Error(`no text (stop_reason=${res.stop_reason})`);
118+ const parsed = JSON.parse(text) as Omit<Interpretation, "model" | "input_tokens" | "output_tokens">;
119+ const usage = { input: res.usage.input_tokens + (res.usage.cache_read_input_tokens ?? 0) + (res.usage.cache_creation_input_tokens ?? 0), output: res.usage.output_tokens };
120+ m.llmCalls.inc({ model, ok: "true" });
121+ m.llmTokens.inc({ model, direction: "input" }, usage.input);
122+ m.llmTokens.inc({ model, direction: "output" }, usage.output);
123+ await db.insert(llmUsage).values({ model, purpose: "interpret", inputTokens: usage.input, outputTokens: usage.output, ok: true });
124+ await bumpDaily({ llm_calls: 1, llm_input_tokens: usage.input, llm_output_tokens: usage.output });
125+ log.debug({ model, ms: Date.now() - started, type: parsed.event_type, meaningful: parsed.meaningful }, "llm interpretation");
126+ if (!(parsed.event_type in EVENT_TYPES)) parsed.event_type = input.heuristic.eventType;
127+ parsed.severity = Math.max(0, Math.min(100, Math.round(Number(parsed.severity) || 0)));
128+ parsed.confidence = Math.max(0, Math.min(100, Math.round(Number(parsed.confidence) || 0)));
129+ return { ...parsed, model, input_tokens: usage.input, output_tokens: usage.output };
130+ } catch (e) {
131+ m.llmCalls.inc({ model, ok: "false" });
132+ await db.insert(llmUsage).values({ model, purpose: "interpret", inputTokens: 0, outputTokens: 0, ok: false }).catch(() => undefined);
133+ const err = e as Error & { status?: number };
134+ log.warn({ model, status: err.status, err: err.message }, "llm interpretation failed — falling back to heuristics");
135+ return null;
136+ }
137+}
138+
139+export function renderDiff(d: DiffResult): string {
140+ if (d.kind === "text") {
141+ const lines: string[] = [];
142+ for (const mo of d.modified.slice(0, 60)) lines.push(`- ${mo.before}\n+ ${mo.after}`);
143+ for (const r of d.removed.slice(0, 60)) lines.push(`- ${r}`);
144+ for (const a of d.added.slice(0, 80)) lines.push(`+ ${a}`);
145+ return lines.join("\n");
146+ }
147+ if (d.kind === "json") return d.unified;
148+ const lines: string[] = [];
149+ for (const a of d.added.slice(0, 30)) lines.push(`+ NEW: ${String(a.title ?? a.url ?? a.key)}${a.summary ? `\n ${String(a.summary).slice(0, 500)}` : ""}${a.url ? `\n ${String(a.url)}` : ""}${a.publishedAt ? `\n published ${String(a.publishedAt)}` : ""}`);
150+ for (const r of d.removed.slice(0, 30)) lines.push(`- REMOVED: ${String(r.title ?? r.url ?? r.key)}`);
151+ for (const mo of d.modified.slice(0, 30)) lines.push(`~ UPDATED: ${String(mo.after.title ?? mo.key)} (${mo.fields.join(", ")})\n before: ${mo.fields.map((f) => `${f}=${JSON.stringify(mo.before[f])}`).join(" ")}\n after: ${mo.fields.map((f) => `${f}=${JSON.stringify(mo.after[f])}`).join(" ")}`);
152+ return lines.join("\n");
153+}
added apps/engine/src/metrics.ts +46 −0
@@ -0,0 +1,46 @@
1+import Fastify from "fastify";
2+import client from "prom-client";
3+import { db, sql } from "@websensor/db";
4+import { config, log } from "./config";
5+
6+export const registry = new client.Registry();
7+client.collectDefaultMetrics({ register: registry, prefix: "websensor_engine_" });
8+
9+export const m = {
10+ checks: new client.Counter({ name: "websensor_checks_total", help: "Sensor checks", labelNames: ["connector", "outcome"], registers: [registry] }),
11+ bytes: new client.Counter({ name: "websensor_fetched_bytes_total", help: "Bytes fetched", labelNames: ["connector"], registers: [registry] }),
12+ changes: new client.Counter({ name: "websensor_changes_total", help: "Raw changes", labelNames: ["connector", "meaningful"], registers: [registry] }),
13+ events: new client.Counter({ name: "websensor_events_total", help: "Events published", labelNames: ["event_type", "silent"], registers: [registry] }),
14+ llmCalls: new client.Counter({ name: "websensor_llm_calls_total", help: "LLM calls", labelNames: ["model", "ok"], registers: [registry] }),
15+ llmTokens: new client.Counter({ name: "websensor_llm_tokens_total", help: "LLM tokens", labelNames: ["model", "direction"], registers: [registry] }),
16+ fetchDuration: new client.Histogram({ name: "websensor_fetch_duration_seconds", help: "Fetch latency", labelNames: ["connector"], buckets: [0.1, 0.25, 0.5, 1, 2, 5, 10, 25], registers: [registry] }),
17+ processingLatency: new client.Histogram({ name: "websensor_processing_latency_seconds", help: "Detected → published", buckets: [0.05, 0.1, 0.25, 0.5, 1, 2, 5, 15], registers: [registry] }),
18+ queueDue: new client.Gauge({ name: "websensor_sensors_due", help: "Sensors due for a check", registers: [registry] }),
19+ inflight: new client.Gauge({ name: "websensor_inflight_checks", help: "Checks in flight", registers: [registry] }),
20+ httpStatus: new client.Counter({ name: "websensor_http_status_total", help: "HTTP status codes", labelNames: ["status"], registers: [registry] }),
21+};
22+
23+/** Daily counters in Postgres (feed the public homepage stats). */
24+export async function bumpDaily(fields: Partial<Record<"checks" | "not_modified" | "bytes" | "raw_changes" | "events" | "silent_events" | "errors" | "llm_calls" | "llm_input_tokens" | "llm_output_tokens" | "scrapfly_calls", number>>): Promise<void> {
25+ const keys = Object.keys(fields) as (keyof typeof fields)[];
26+ if (!keys.length) return;
27+ const sets = keys.map((k) => sql.raw(`${k} = metrics_daily.${k} + ${Number(fields[k] ?? 0)}`));
28+ const cols = keys.map((k) => sql.raw(k));
29+ const vals = keys.map((k) => sql`${Number(fields[k] ?? 0)}`);
30+ try {
31+ await db.execute(sql`insert into metrics_daily (day, ${sql.join(cols, sql`, `)}) values (current_date, ${sql.join(vals, sql`, `)}) on conflict (day) do update set ${sql.join(sets, sql`, `)}`);
32+ } catch (e) {
33+ log.warn({ err: (e as Error).message }, "metrics_daily update failed");
34+ }
35+}
36+
37+export async function startMetricsServer(): Promise<void> {
38+ const app = Fastify({ logger: false });
39+ app.get("/metrics", async (_req, reply) => {
40+ reply.header("content-type", registry.contentType);
41+ return registry.metrics();
42+ });
43+ app.get("/health", async () => ({ status: "ok", service: "engine", version: config.version, time: new Date().toISOString() }));
44+ await app.listen({ port: config.metricsPort, host: config.metricsHost });
45+ log.info({ port: config.metricsPort }, "engine metrics listening");
46+}
added apps/engine/src/pipeline.ts +404 −0
Large diff (404 lines) is not loaded by default to keep the page fast.Load diff
added apps/engine/src/redis.ts +41 −0
Large diff (41 lines) is not loaded by default to keep the page fast.Load diff
added apps/engine/src/registry.ts +179 −0
Large diff (179 lines) is not loaded by default to keep the page fast.Load diff
added apps/engine/src/scheduler.ts +162 −0
Large diff (162 lines) is not loaded by default to keep the page fast.Load diff
added apps/engine/tsconfig.json +5 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/README.md +18 −0
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/eslint.config.mjs +6 −0
Large diff (6 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/next.config.ts +23 −0
Large diff (23 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/package.json +32 −0
Large diff (32 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/postcss.config.mjs +2 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/alerts/alerts.tsx +186 −0
Large diff (186 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/alerts/page.tsx +14 −0
Large diff (14 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/api/page.tsx +131 −0
Large diff (131 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/bot/page.tsx +36 −0
Large diff (36 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/breaking/page.tsx +17 −0
Large diff (17 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/category/[channel]/page.tsx +29 −0
Large diff (29 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/company/[id]/page.tsx +111 −0
Large diff (111 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/compare/page.tsx +24 −0
Large diff (24 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/domain/[domain]/page.tsx +51 −0
Large diff (51 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/entities/page.tsx +50 −0
Large diff (50 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/error.tsx +14 −0
Large diff (14 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/event/[slug]/page.tsx +254 −0
Large diff (254 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/explore/page.tsx +112 −0
Large diff (112 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/globals.css +161 −0
Large diff (161 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/health/page.tsx +107 −0
Large diff (107 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/icon.svg +1 −0
Large diff (1 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/layout.tsx +39 −0
Large diff (39 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/manifest.ts +14 −0
Large diff (14 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/not-found.tsx +15 −0
Large diff (15 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/opengraph-image.tsx +33 −0
Large diff (33 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/page.tsx +28 −0
Large diff (28 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/robots.ts +10 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/search/page.tsx +74 −0
Large diff (74 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/sensor/[id]/page.tsx +105 −0
Large diff (105 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/silent/page.tsx +17 −0
Large diff (17 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/sitemap.ts +19 −0
Large diff (19 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/source/[id]/page.tsx +134 −0
Large diff (134 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/sources/page.tsx +53 −0
Large diff (53 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/timeline/[id]/page.tsx +33 −0
Large diff (33 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/url/compare-form.tsx +35 −0
Large diff (35 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/url/page.tsx +72 −0
Large diff (72 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/watchlists/page.tsx +14 −0
Large diff (14 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/watchlists/watchlists.tsx +154 −0
Large diff (154 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/code.tsx +27 −0
Large diff (27 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/diff-viewer.tsx +165 −0
Large diff (165 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/event-row.tsx +48 −0
Large diff (48 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/footer.tsx +21 −0
Large diff (21 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/live-feed.tsx +135 −0
Large diff (135 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/nav.tsx +105 −0
Large diff (105 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/rail.tsx +54 −0
Large diff (54 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/stats-strip.tsx +21 −0
Large diff (21 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/theme.tsx +30 −0
Large diff (30 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/timeline.tsx +38 −0
Large diff (38 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/ui.tsx +167 −0
Large diff (167 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/watch-button.tsx +54 −0
Large diff (54 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/api.ts +491 −0
Large diff (491 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/format.ts +143 −0
Large diff (143 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/owner.ts +37 −0
Large diff (37 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/use-live.ts +80 −0
Large diff (80 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/tsconfig.json +21 −0
Large diff (21 lines) is not loaded by default to keep the page fast.Load diff
added config/sources.yaml +2088 −0
Large diff (2,088 lines) is not loaded by default to keep the page fast.Load diff
added deploy/README.md +44 −0
Large diff (44 lines) is not loaded by default to keep the page fast.Load diff
added deploy/websensor.mld.json.example +128 −0
Large diff (128 lines) is not loaded by default to keep the page fast.Load diff
added docs/ARCHITECTURE.md +49 −0
Large diff (49 lines) is not loaded by default to keep the page fast.Load diff
added docs/connectors/discovery.md +8 −0
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
added docs/connectors/github.md +10 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added docs/connectors/http.md +16 −0
Large diff (16 lines) is not loaded by default to keep the page fast.Load diff
added docs/connectors/jsonlist.md +12 −0
Large diff (12 lines) is not loaded by default to keep the page fast.Load diff
added docs/connectors/rss.md +15 −0
Large diff (15 lines) is not loaded by default to keep the page fast.Load diff
added docs/connectors/scrapfly.md +8 −0
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
added docs/connectors/sitemap.md +12 −0
Large diff (12 lines) is not loaded by default to keep the page fast.Load diff
added docs/connectors/statuspage.md +12 −0
Large diff (12 lines) is not loaded by default to keep the page fast.Load diff
added package.json +31 −0
Large diff (31 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/package.json +23 −0
Large diff (23 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors.test.ts +89 −0
Large diff (89 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/discovery.ts +150 −0
Large diff (150 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/fetcher.ts +238 −0
Large diff (238 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/github.ts +64 −0
Large diff (64 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/http.ts +100 −0
Large diff (100 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/index.ts +38 −0
Large diff (38 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/jsonlist.ts +89 −0
Large diff (89 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/rss.ts +161 −0
Large diff (161 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/scrapfly.ts +68 −0
Large diff (68 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/sitemap.ts +106 −0
Large diff (106 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/statuspage.ts +69 −0
Large diff (69 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/types.ts +28 −0
Large diff (28 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/xml.ts +65 −0
Large diff (65 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/tsconfig.json +5 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
added packages/core/package.json +26 −0
Large diff (26 lines) is not loaded by default to keep the page fast.Load diff
added packages/core/src/canonical.ts +196 −0
Large diff (196 lines) is not loaded by default to keep the page fast.Load diff
added packages/core/src/client.ts +8 −0
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
added packages/core/src/core.test.ts +131 −0
Large diff (131 lines) is not loaded by default to keep the page fast.Load diff
added packages/core/src/diff.ts +176 −0
Large diff (176 lines) is not loaded by default to keep the page fast.Load diff
added packages/core/src/hash.ts +65 −0
Large diff (65 lines) is not loaded by default to keep the page fast.Load diff
added packages/core/src/heuristics.ts +242 −0
Large diff (242 lines) is not loaded by default to keep the page fast.Load diff
added packages/core/src/ids.ts +36 −0
Large diff (36 lines) is not loaded by default to keep the page fast.Load diff
added packages/core/src/index.ts +10 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added packages/core/src/schedule.ts +53 −0
Large diff (53 lines) is not loaded by default to keep the page fast.Load diff
added packages/core/src/scoring.ts +116 −0
Large diff (116 lines) is not loaded by default to keep the page fast.Load diff
added packages/core/src/ssrf.ts +189 −0
Large diff (189 lines) is not loaded by default to keep the page fast.Load diff
added packages/core/src/taxonomy.ts +185 −0
Large diff (185 lines) is not loaded by default to keep the page fast.Load diff
added packages/core/src/types.ts +102 −0
Large diff (102 lines) is not loaded by default to keep the page fast.Load diff
added packages/core/tsconfig.json +5 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
added packages/db/migrations/0001_init.sql +361 −0
Large diff (361 lines) is not loaded by default to keep the page fast.Load diff
added packages/db/migrations/0002_scrapfly.sql +1 −0
Large diff (1 lines) is not loaded by default to keep the page fast.Load diff
added packages/db/migrations/0003_latency_bigint.sql +2 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added packages/db/package.json +31 −0
Large diff (31 lines) is not loaded by default to keep the page fast.Load diff
added packages/db/src/index.ts +50 −0
Large diff (50 lines) is not loaded by default to keep the page fast.Load diff
added packages/db/src/migrate.ts +50 −0
Large diff (50 lines) is not loaded by default to keep the page fast.Load diff
added packages/db/src/schema.ts +342 −0
Large diff (342 lines) is not loaded by default to keep the page fast.Load diff
added packages/db/tsconfig.json +5 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
added packages/store/package.json +21 −0
Large diff (21 lines) is not loaded by default to keep the page fast.Load diff
added packages/store/src/index.ts +69 −0
Large diff (69 lines) is not loaded by default to keep the page fast.Load diff
added packages/store/tsconfig.json +5 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
added pnpm-lock.yaml +6482 −0
Large diff (6,482 lines) is not loaded by default to keep the page fast.Load diff
added pnpm-workspace.yaml +8 −0
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/atom.xml +4 −0
Large diff (4 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/kev.json +3 −0
Large diff (3 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/rss-after.xml +5 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/rss-before.xml +5 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/sitemap.xml +6 −0
Large diff (6 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/statuspage.json +5 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
added tsconfig.base.json +17 −0
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme