Spec 006: Intelligence Tool¶
Problem¶
The predecessor system (Rising Intelligence) is a 4-service Kafka-centric system (Collector, Persister, Trends, Brief) that collects tech/AI/AWS signals, detects trends, and generates briefs. It works, but the architecture is disproportionate to the problem: single operator, home network, ~500 events/day. Kafka, Postgres, Redis, Docker Compose, Prisma, protobuf, OpenTelemetry — the infrastructure cost dominates the value delivered.
More importantly, the data is locked inside the pipeline. Agents can't query it. The brief is generated via Kafka request/response, not callable from a CLI or tool. The system produces intelligence but doesn't make it accessible.
Goal¶
Redesign as a lightweight tool in tools/intelligence/ that:
- Collects data continuously from curated sources (RSS, HN, EDGAR, Earnings)
- Exposes intelligence as CLI commands and MCP tools that agents can invoke
- Runs on SQLite — no Postgres, no Redis, no Kafka, no Docker required
The collector runs as a daemon. Everything else is on-demand query. Agents synthesize their own analysis from the raw data — the tool provides the data, not the interpretation.
Non-Goals¶
- Multi-user access or concurrent write safety (single operator)
- Sub-minute data freshness (5-minute poll intervals are fine)
- Dashboard UI (CLI output replaces Grafana)
- Event replay or stream processing (SQLite is the store, not an event log)
- Backward compatibility with the predecessor system
Architecture¶
┌──────────────────────────────────────────────────────────────┐
│ tools/intelligence/ │
│ │
│ ┌─────────────────┐ ┌──────────────────────────┐ │
│ │ intel collect │────────▶│ intelligence.db │ │
│ │ │ write │ │ │
│ │ - poll feeds │ │ events │ │
│ │ - extract │ │ events_fts │ │
│ │ - classify │ │ checkpoints │ │
│ │ - checkpoint │ │ collector_health │ │
│ └─────────────────┘ │ │ │
│ └──────────┬───────────────┘ │
│ │ read │
│ ┌─────────────────┐ │ │
│ │ intel trends │◀───────────────────┤ │
│ │ intel search │◀───────────────────┤ │
│ │ intel events │◀───────────────────┤ │
│ │ intel sources │◀───────────────────┤ │
│ │ intel forecast │◀───────────────────┤ │
│ │ intel topics │◀───────────────────┤ │
│ │ intel stats │◀───────────────────┤ │
│ │ intel pack │◀───────────────────┤ │
│ │ intel journal │◀───────────────────┤ │
│ │ intel audit │◀───────────────────┘ │
│ └─────────────────┘ │
│ │ │
│ ▼ │
│ JSON (stdout) ──▶ agent context / human reading │
└──────────────────────────────────────────────────────────────┘
Two concerns, one binary:
| Concern | Runs as | Reads/Writes |
|---|---|---|
| Collector daemon | Long-running process (intel collect) |
Writes events + checkpoints |
| Query tools | On-demand CLI commands | Reads events (read-only) |
Both share the same SQLite file. SQLite WAL mode handles this safely — readers don't block writers, writers don't block readers — without coordination infrastructure.
SQLite Configuration¶
Configuration is split into two concerns: one-time database initialization and per-connection setup.
Database initialization (run once at creation, or via migration with VACUUM):
PRAGMA journal_mode = WAL;
PRAGMA auto_vacuum = INCREMENTAL;
PRAGMA application_id = 0x494E5445; -- 'INTE' in ASCII; identifies this as an intel DB
auto_vacuum = INCREMENTAL must be set before any tables are created. Changing this on an existing database requires setting the pragma and then running VACUUM to reorganize. In incremental mode, free pages are not reclaimed at commit — reclamation requires an explicit PRAGMA incremental_vacuum call (exposed via intel db incremental-vacuum).
application_id is a 32-bit signed big-endian integer stored in the database file header (bytes 68–71). It allows tools like file(1) and the tool's own code to distinguish an intel database from a generic SQLite file. This is separate from user_version, which is reserved for schema migration versioning. The value 0x494E5445 is the ASCII encoding of INTE. Current SQLite uses only the lower 31 bits of application_id; higher bits are ignored — choose IDs accordingly (the chosen value fits within 31 bits). Check magic.txt in the SQLite source to avoid collisions with other assigned application IDs. On DB open, db.ts should verify the application_id matches (or is zero, for a fresh database) before proceeding — this catches "wrong file" errors early.
Writer connection (collector daemon):
-- Set via better-sqlite3: new Database(path, { timeout: 5000 })
PRAGMA synchronous = NORMAL;
PRAGMA foreign_keys = ON;
PRAGMA trusted_schema = OFF;
PRAGMA wal_autocheckpoint = 1000; -- explicit; from collector.wal_autocheckpoint config
-- Optional: cap on-disk WAL/journal size left behind after checkpoints/resets.
-- Does not prevent WAL growth under checkpoint starvation; it just avoids
-- leaving huge WAL files on disk once a reset or truncate checkpoint occurs.
-- Negative = no limit (SQLite default -1); zero = truncate to minimum after
-- each commit/WAL reset/checkpoint event. A finite cap (e.g., 64–256 MB) is
-- recommended over -1 to bound disk usage after content spikes.
PRAGMA journal_size_limit = 67108864; -- 64 MB; from collector.journal_size_limit_bytes config
Reader connections (CLI commands, MCP tool calls):
-- Set via better-sqlite3: new Database(path, { readonly: true, timeout: 5000 })
PRAGMA query_only = ON;
PRAGMA trusted_schema = OFF;
PRAGMA trusted_schema = OFF is a defense-in-depth measure recommended in SQLite's security documentation. It prevents SQL functions embedded in schema definitions (triggers, views, CHECK constraints) from running with elevated trust. Since the database ingests arbitrary web content and the schema is fully controlled by this tool, there is no legitimate need for trusted schema, and the downside risk is minimal.
readonly: true prevents accidental writes at the library level. PRAGMA query_only = ON adds a SQLite-level guard — any CREATE/INSERT/UPDATE/DELETE attempt fails with SQLITE_READONLY. Together they mechanically enforce the "tool provides data; collector is the only writer" principle.
Pragma verification: Pragmas may behave differently across SQLite versions and builds; for correctness-critical pragmas, set the value and read it back. For all correctness-critical pragmas (journal_mode, wal_autocheckpoint, query_only, trusted_schema, foreign_keys, synchronous, journal_size_limit), set the value and then read it back in the same connection setup. If the read-back value doesn't match, log a warning and surface it in intel stats. This catches version mismatches (e.g., a pragma not supported by the embedded SQLite) and configuration drift without silent failure. Example: after PRAGMA trusted_schema = OFF, execute PRAGMA trusted_schema and assert the result is 0.
Lock-wait semantics: better-sqlite3's options.timeout is the canonical "wait before SQLITE_BUSY" knob (default 5000ms). Do not also set PRAGMA busy_timeout — the library handles this, and having two conflicting values causes confusion. Even with a timeout, SQLITE_BUSY can still occur in WAL mode during checkpoint cleanup, crash recovery, or when SQLite detects a potential deadlock (in which case the busy handler is skipped entirely). All CLI commands and MCP tool calls must treat SQLITE_BUSY as a first-class, expected error — not an exceptional crash — with bounded retries (2–4 attempts, exponential backoff + jitter) and an actionable error message. See Error Taxonomy below.
Durability trade-off: synchronous = NORMAL is a deliberate choice. In WAL mode, NORMAL syncs the WAL before each checkpoint but skips extra syncs after most commits. The risk profile is narrow: durability is preserved across application crashes and normal process termination. The only scenario where committed transactions can be lost is an OS-level crash or power failure before the WAL is synced — and even then, the database remains consistent (no corruption), just missing the most recent uncheckpointed commits. This is acceptable for a home-lab tool collecting publicly available data. For stronger guarantees, set synchronous = FULL at a performance cost (configurable via collector.synchronous in config).
Operational constraints:
- WAL mode requires all processes on the same host — it relies on shared-memory coordination and is not safe over network filesystems (NFS, SMB). The database is not portable via Dropbox/iCloud sync.
- WAL produces
-waland-shmsidecar files alongside the database. Naive file copies can produce a corrupt backup — useintel db backup(which usesbetter-sqlite3's online backup API) instead. - Short-lived read transactions: WAL checkpoints can be starved by long-running readers — perpetual overlapping readers can prevent checkpoint completion and cause unbounded WAL growth. Every CLI command and MCP tool call must follow: open connection → query → close. Pagination uses cursor tokens between separate short transactions, not a single long streaming read.
- Concurrent process awareness: The collector daemon, MCP server, and CLI commands can all run simultaneously — this is by design. However, avoid running high-frequency CLI queries concurrently with the MCP server, as overlapping readers can prevent checkpoint completion. Under normal usage (intermittent CLI calls, occasional MCP tool invocations), reader gaps are naturally present and checkpoints proceed without issue.
- Single checkpoint authority: Only the collector writer connection may run checkpoint, vacuum, prune, backup, and other write-adjacent maintenance operations. This eliminates the risk of two connections attempting to checkpoint or write simultaneously — a concurrency shape that can trigger corruption due to the "WAL-reset bug" (present in SQLite 3.7.0 through 3.51.2, where two processes checkpointing at the same instant can rarely corrupt the database). CLI
intel dbmaintenance commands coordinate with the daemon: if the daemon is running, they send the request via the daemon's control channel (see below); if it is not running, they open their own writer connection. This makes "two writers at the same instant" structurally impossible. (The WAL-reset bug is fixed in 3.52.0 and backported to 3.44.6, 3.50.7, and 3.51.3.) - Daemon control channel: The collector daemon listens on a Unix domain socket (
$XDG_RUNTIME_DIR/intel/control.sockor$TMPDIR/intel-control-<pid>.sock). CLI maintenance commands (intel db checkpoint,intel db prune,intel db vacuum,intel db backup, etc.) check for a running daemon via PID file and, if found, send the maintenance request over this socket rather than opening a separate writer connection. This is local process control, not a service — no network, no auth, no serialization protocol beyond newline-delimited JSON.
sequenceDiagram
participant CLI as intel db (CLI)
participant Daemon as intel collect (daemon)
participant DB as SQLite (writer conn)
CLI->>Daemon: maintenance request (unix socket)
Daemon->>DB: checkpoint/vacuum/prune/backup (single writer)
DB-->>Daemon: result (busy flag/pages or backup progress)
Daemon-->>CLI: structured JSON result
- SQLite version awareness: The embedded SQLite version (from
better-sqlite3's bundled build) is recorded at startup and surfaced inintel stats. The WAL-reset bug fix is present only if the version satisfies one of:>= 3.52.0, or3.51.x with patch >= 3, or3.50.x with patch >= 7, or3.44.x with patch >= 6. If the version does not satisfy any of these conditions, the tool logs a warning at startup and enforces stricter single-writer discipline (no concurrent checkpoint paths; daemon mediation required for all maintenance). Pin to abetter-sqlite3version that bundles a patched SQLite when available. - WAL auto-checkpoint policy: SQLite's default auto-checkpoint threshold is 1000 WAL pages, controlled by
PRAGMA wal_autocheckpoint. This is set explicitly at writer connection startup (not left implicit) and exposed ascollector.wal_autocheckpointin config (default: 1000). Lowering the value reduces peak WAL size but increases checkpoint frequency; raising it reduces checkpoint overhead but allows larger WAL files. Theintel db checkpointcommand reports the checkpoint result triple: busy flag, total WAL pages, and checkpointed pages. - Periodic optimizer: The collector daemon should run
PRAGMA optimizeperiodically (e.g., daily) to let SQLite update internal statistics used by the query planner. This is recommended by SQLite for long-lived connections and has negligible cost.
Data Model¶
SQLite Schema¶
-- Collected events (append-only, deduped by event_id)
CREATE TABLE events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id TEXT UNIQUE NOT NULL, -- stable per-source ID
source TEXT NOT NULL CHECK(source IN ('rss','hackernews','edgar','earnings')),
feed TEXT, -- feed name/identifier
url TEXT,
canonical_url TEXT, -- normalized URL for cross-source dedup
title TEXT,
content TEXT, -- extracted article body
author TEXT,
published_at TEXT, -- ISO 8601 UTC
fetched_at TEXT NOT NULL, -- ISO 8601 UTC
topics TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(topics)),
tags TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(tags)),
score INTEGER DEFAULT 0, -- engagement (upvotes, likes)
comments INTEGER DEFAULT 0,
source_meta TEXT CHECK (source_meta IS NULL OR json_valid(source_meta)),
content_flags TEXT CHECK (content_flags IS NULL OR json_valid(content_flags)),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX idx_events_source_fetched ON events(source, fetched_at);
CREATE INDEX idx_events_fetched ON events(fetched_at);
CREATE INDEX idx_events_published ON events(published_at);
CREATE INDEX idx_events_canonical_url ON events(canonical_url) WHERE canonical_url IS NOT NULL;
-- Full-text search
CREATE VIRTUAL TABLE events_fts USING fts5(
title,
content,
content='events',
content_rowid='id',
tokenize="unicode61 tokenchars '-_' remove_diacritics 1"
);
-- Keep FTS in sync via triggers
CREATE TRIGGER events_fts_insert AFTER INSERT ON events BEGIN
INSERT INTO events_fts(rowid, title, content) VALUES (new.id, new.title, new.content);
END;
CREATE TRIGGER events_fts_delete AFTER DELETE ON events BEGIN
INSERT INTO events_fts(events_fts, rowid, title, content)
VALUES ('delete', old.id, old.title, old.content);
END;
CREATE TRIGGER events_fts_update AFTER UPDATE OF title, content ON events BEGIN
INSERT INTO events_fts(events_fts, rowid, title, content)
VALUES ('delete', old.id, old.title, old.content);
INSERT INTO events_fts(rowid, title, content)
VALUES (new.id, new.title, new.content);
END;
-- FTS tokenizer notes:
-- unicode61 with tokenchars '-_' treats hyphens and underscores as token characters,
-- so "CVE-2026-12345", "k8s-gateway", and "bedrock_agents" are matched as whole tokens.
-- remove_diacritics normalizes accented characters for consistent matching.
-- Porter stemming is deliberately omitted: it can produce surprising results for
-- technical acronyms (RAG, EKS) and domain-specific terms. If stemming is needed
-- later, wrap as: tokenize="porter unicode61 tokenchars '-_' remove_diacritics 1"
--
-- FTS5's trigram tokenizer enables substring matching and LIKE/GLOB optimization
-- but increases index size. Available as a future opt-in if "search inside tokens"
-- becomes a requirement.
-- Normalized topic index for fast topic queries and trend computation
CREATE TABLE event_topics (
event_id TEXT NOT NULL,
topic TEXT NOT NULL,
confidence REAL, -- classifier confidence (0.0-1.0)
score REAL, -- raw classifier score before thresholding
PRIMARY KEY (event_id, topic),
FOREIGN KEY (event_id) REFERENCES events(event_id) ON DELETE CASCADE
);
CREATE INDEX idx_event_topics_topic ON event_topics(topic);
-- Per-source polling cursors
CREATE TABLE checkpoints (
source TEXT NOT NULL,
feed TEXT NOT NULL DEFAULT '', -- feed identifier within source
cursor TEXT NOT NULL, -- last-seen ID or timestamp
updated_at TEXT NOT NULL,
PRIMARY KEY (source, feed)
);
-- Collector health: updated each poll cycle, even when no new events are found
CREATE TABLE collector_health (
source TEXT NOT NULL,
feed TEXT NOT NULL DEFAULT '',
last_polled_at TEXT NOT NULL, -- ISO 8601 UTC
last_success_at TEXT, -- last poll that completed without error
last_error TEXT, -- most recent error message, if any
events_in_poll INTEGER NOT NULL DEFAULT 0, -- events found in last poll
http_etag TEXT, -- ETag from last successful response
http_last_modified TEXT, -- Last-Modified from last successful response
PRIMARY KEY (source, feed)
);
-- Tool operational metadata (key-value store for timestamps, state)
CREATE TABLE tool_metadata (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
-- Operator journal entries
CREATE TABLE journal_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content TEXT NOT NULL,
tags TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(tags)),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX idx_journal_entries_created ON journal_entries(created_at);
-- Data retention: events older than retention_days are prunable
-- Enforced by `intel db prune`
Topic Storage¶
Topics are stored in two places: as a JSON array in events.topics and in the event_topics side table. The collector populates both at insert time.
Canonicality: events.topics (the JSON array) is the canonical representation — it's what the classifier produced and what the API returns. event_topics is a derived index optimized for queries and trend computation. If the two diverge (e.g., after a reclassification run that updates only the JSON column), intel db rebuild-topic-index rebuilds event_topics from the JSON using json_each. This unidirectional flow (JSON → side table) eliminates the risk of circular divergence.
Schema versioning: db.ts manages migrations from a migrations/ directory and tracks the current version via PRAGMA user_version (a SQLite-reserved integer that the engine itself does not use, leaving it entirely to applications). Migrations are applied in deterministic numeric order (001, 002, ...) and each migration runs within a transaction: PRAGMA user_version is set only after the migration transaction commits successfully, so a partial migration never bumps the version. On startup, db.ts compares PRAGMA user_version against available migration files and applies any pending ones sequentially. The intel db schema command shows the current user_version (schema version), application_id, and whether migrations are pending.
Note on timestamp comparisons: All time-range filters must use strftime('%Y-%m-%dT%H:%M:%fZ', ...) — not datetime() — because datetime() returns YYYY-MM-DD HH:MM:SS which won't compare correctly against the stored YYYY-MM-DDTHH:MM:SS.000Z format.
-- Events matching a topic (uses indexed side table)
SELECT e.* FROM events e
JOIN event_topics et ON et.event_id = e.event_id
WHERE et.topic = 'aws.bedrock'
AND e.fetched_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-7 days');
-- Topic volume in a time window (uses indexed side table)
SELECT et.topic, COUNT(*) AS volume
FROM event_topics et
JOIN events e ON e.event_id = et.event_id
WHERE e.fetched_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-60 minutes')
GROUP BY et.topic
ORDER BY volume DESC;
-- Topic volume with cross-source dedup (count distinct stories, not distinct events)
SELECT et.topic, COUNT(DISTINCT COALESCE(e.canonical_url, e.event_id)) AS volume
FROM event_topics et
JOIN events e ON e.event_id = et.event_id
WHERE e.fetched_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-60 minutes')
GROUP BY et.topic
ORDER BY volume DESC;
CLI Interface¶
Single binary: intel. All commands output JSON to stdout by default (agent-friendly). Human-readable output via --format text.
Output Contract¶
All JSON outputs use a consistent envelope so agents can parse responses predictably:
{
"tool": "intel",
"schema_version": "v2",
"status": "ok",
"data": { },
"warnings": [],
"next_cursor": null
}
Size controls prevent context explosions when agents consume output:
--limit N— cap result count (default varies per command)--cursor <token>— resume from a previous result set
Cursor semantics: Each paginated command defines deterministic ordering so that cursors are stable even when new events arrive between pages:
intel events:ORDER BY fetched_at DESC, event_id DESCintel search:ORDER BY fts_rank ASC, event_id DESC(FTS5 rank/bm25: lower is better)intel trends: not paginated (bounded result set)
The cursor token encodes the last-seen sort key tuple (e.g., { fetched_at, event_id }) and resumes with a strict "less than" filter. This prevents duplicate or missing rows across pages. Cursor tokens are opaque base64-encoded JSON and include schema_version and command fields alongside the sort keys. This allows future changes to ordering or pagination logic to be detected (stale cursor → clear error) rather than silently producing wrong results.
Every paginated response includes explicit pagination metadata so agents can decide whether to fetch more without inferring from cursor presence:
{
"returned_count": 20,
"limit_applied": 20,
"truncated": true,
"next_cursor": "eyJmZXRjaGVkX2F0Ijoi..."
}
truncated: true means more results exist beyond the current page. returned_count is the number of items in this response. limit_applied is the effective limit used (explicit or default).
Error taxonomy: Error responses use the same envelope with "status": "error" and a structured error object so agents can decide whether to retry or escalate without parsing stack traces:
{
"tool": "intel",
"schema_version": "v2",
"status": "error",
"error": {
"code": "SQLITE_BUSY",
"message": "Database is busy (WAL checkpoint/write contention).",
"retryable": true,
"suggested_action": "Retry in a moment, or stop collector before running maintenance commands."
}
}
Error codes: SQLITE_BUSY (retryable — lock contention), INVALID_QUERY (not retryable — bad parameters), DB_NOT_FOUND (not retryable — database file missing), COLLECTOR_NOT_RUNNING (not retryable — daemon required for this command), INVALID_CURSOR (not retryable — cursor is malformed, expired, or from a different schema version), INTERNAL_ERROR (not retryable — unexpected failure). MCP tool calls return the same error structure within the JSON-RPC error response.
SQLITE_BUSY suggested actions are context-dependent: for read-only query commands, suggested_action is "Retry in a moment." (the contention is transient). For intel db maintenance commands that require writer access, suggested_action is "Stop the collector daemon before running this maintenance command, or let the daemon handle it via the control channel."
Two-stage retrieval — list commands return IDs + short snippets by default. Full content is only returned when fetching a specific event by ID (intel events --id ...). This keeps agent context lean.
Safe Snippets¶
Even though the tool provides data (not interpretation), downstream agents often paste snippets into prompts. All text output is sanitized by default:
- Strip control characters and zero-width characters
- Escape
< >to prevent prompt delimiter confusion - Truncate to bounded length (configurable, default 500 chars for snippets)
- Flag suspected prompt-injection markers (
[INST],<|system|>, etc.) in asnippet_flagsfield
Raw unsanitized content is available via --raw for commands that return event content.
intel collect¶
Start the collector daemon. Polls all configured feeds on their intervals, extracts content, classifies topics, writes to SQLite.
intel collect # foreground (Ctrl+C to stop)
intel collect --daemon # background (writes PID file)
intel collect --once # single poll cycle, then exit
intel collect status # check if daemon is running
intel collect stop # stop background daemon
intel trends¶
Compute trending topics from recent events. No pre-computed snapshots needed — runs windowed aggregation at query time.
intel trends # top 10 topics, 60m window
intel trends --window 15m # 15-minute window
intel trends --top 20 # top 20
intel trends --since 24h # trends over last 24 hours
intel trends --dedup none # count all events (skip canonical_url dedup)
Output:
[
{
"topic": "aws.bedrock",
"score": 8.2,
"volume": 12,
"prev_volume": 3,
"acceleration": 3.0,
"evidence_count": 12,
"top_titles": ["AWS Announces Bedrock Agents GA", "..."]
}
]
Scoring algorithm (carried forward from Trends service):
volume = count of distinct stories (by canonical_url, falling back to event_id)
acceleration = prev_volume > 0
? (volume - prev_volume) / prev_volume
: volume > 0 ? 1.0 : 0.0
score = volume + max(0, acceleration * volume)
Volume uses cross-source dedup: if the same article appears via both HN and RSS (same canonical_url), it counts as one story, not two. This prevents multi-source duplication from inflating trend scores.
Score is normalized to 0-10 scale across all topics in the result set.
intel search <query>¶
Full-text search across collected events using FTS5.
intel search "bedrock agents" # search title + content
intel search "CVE-2026" --since 7d # with time bound
intel search "kubernetes" --source rss # filter by source
intel search "lambda" --topic aws.lambda # filter by topic
intel search "RAG" --limit 5 # limit results
Search results use FTS5's snippet() function to extract contextual fragments around matches, rather than returning full content or naive substrings. snippet() selects fragments that maximize distinct query term coverage and favors sentence boundaries — this is better than manual truncation because it stays relevant to the query and reduces context size.
Results are ordered by FTS5's hidden rank column, which defaults to bm25(). Lower values indicate better matches — sort ascending (ORDER BY rank), not descending. Using the rank column directly (rather than an explicit bm25() call) is faster because FTS5 can optimize early termination with LIMIT clauses. The result exposes the score as fts_rank to make the "lower is better" semantics explicit to agents. The query pattern is:
SELECT e.event_id, e.title, e.url, e.source, e.published_at, e.topics,
snippet(events_fts, 0, '[', ']', '...', 32) AS snippet,
events_fts.rank AS fts_rank
FROM events_fts
JOIN events e ON e.id = events_fts.rowid
WHERE events_fts MATCH ?
ORDER BY fts_rank ASC, e.event_id DESC
LIMIT ?;
Note: snippet() max token count must be 1–64; the tool validates this bound in CLI arg parsing, defaulting to 32. Snippet markers use [ and ] instead of <b>/</b> to avoid conflicting with the safe-snippet policy that escapes < and >.
Output:
[
{
"event_id": "rss:aws-blog:abc123",
"title": "Building RAG Applications with Bedrock",
"url": "https://aws.amazon.com/blogs/...",
"source": "rss",
"published_at": "2026-03-13T10:00:00Z",
"topics": ["aws.bedrock", "ai.rag"],
"snippet": "...retrieval augmented generation using Amazon [Bedrock]...",
"fts_rank": -4.12
}
]
intel events¶
Browse events with filters. List mode returns IDs + snippets; single-event mode returns full content.
intel events # last 20 events (IDs + snippets)
intel events --topic aws.bedrock # by topic
intel events --source hackernews # by source
intel events --since 3d # by time
intel events --limit 50 # more results
intel events --cursor <token> # paginate from previous result
intel events --id rss:aws-blog:abc123 # single event (full content)
intel sources¶
Check data source health and freshness.
Output:
[
{
"source": "rss",
"feed": "AWS Blog",
"last_polled_at": "2026-03-13T14:30:00Z",
"last_success_at": "2026-03-13T14:30:00Z",
"last_error": null,
"events_in_last_poll": 3,
"events_24h": 12,
"events_total": 3420,
"status": "healthy"
}
]
Source health is derived from the collector_health table (updated every poll cycle, even when no new events are found) joined with event counts. The status field is computed: "healthy" if last_success_at is within 2x the feed's poll interval, "stale" if older, "error" if last_error is non-null.
intel topics¶
List configured topics from the allowlist.
intel topics # all topics
intel topics --active # only topics with events in last 7d
intel topics --match "bedrock" # filter by keyword
intel stats¶
Database overview.
Output:
{
"database": "~/.local/share/intel/intelligence.db",
"size_mb": 42.3,
"sqlite_version": "3.51.3",
"write_library_version": "3.51.3",
"application_id": "0x494E5445",
"schema_version": 1,
"wal_pages": 42,
"wal_autocheckpoint": 1000,
"pragma_verified": true,
"events_total": 15230,
"events_24h": 487,
"events_7d": 3201,
"sources": 12,
"sources_healthy": 10,
"sources_stale": 1,
"sources_error": 1,
"topics_total": 66,
"topics_active_7d": 38,
"oldest_event": "2026-02-01T00:00:00Z",
"newest_event": "2026-03-13T14:35:00Z",
"collector_running": true,
"last_collect_at": "2026-03-13T14:30:00Z",
"retention_days": 30,
"topic_review_last_completed": "2026-01-15T10:00:00Z",
"topic_review_overdue": true
}
intel pack¶
Produce a minimal evidence bundle suitable for downstream agent synthesis. This is not a brief — it is a bounded, pre-assembled dataset that an agent can use to write one. The tool provides evidence; the agent provides interpretation.
intel pack # default: last 6h, top 10 trends
intel pack --since 24h # wider window
intel pack --top 5 # fewer trends
intel pack --max-events 3 # cap evidence events per trend
Output:
{
"window": { "start": "2026-03-13T08:00:00Z", "end": "2026-03-13T14:00:00Z" },
"trends": [
{
"topic": "aws.bedrock",
"score": 8.2,
"volume": 12,
"acceleration": 3.0,
"events": [
{
"event_id": "rss:aws-blog:abc123",
"title": "AWS Announces Bedrock Agents GA",
"url": "https://aws.amazon.com/blogs/...",
"source": "rss",
"published_at": "2026-03-13T10:00:00Z",
"snippet": "Amazon Web Services announced general availability..."
}
]
}
],
"source_health": [
{ "source": "rss", "feed": "AWS Blog", "status": "healthy", "events_in_window": 8 }
]
}
The pack is bounded: default max 10 trends, max 5 events per trend, snippets truncated to 500 chars. All text is sanitized per the safe snippets contract.
intel db¶
Database maintenance operations. These are escape hatches for operational issues, not part of normal use.
intel db schema # show schema version, application_id, + pending migrations
intel db rebuild-fts # drop and rebuild FTS index from events table
intel db rebuild-topic-index # rebuild event_topics from events.topics JSON
intel db optimize-fts # merge FTS index segments for faster queries
intel db checkpoint # force WAL checkpoint (default: PASSIVE)
intel db checkpoint --mode truncate # PASSIVE | FULL | RESTART | TRUNCATE
intel db prune # delete events older than retention_days
intel db vacuum # run INCREMENTAL auto_vacuum
intel db incremental-vacuum # reclaim free pages (PRAGMA incremental_vacuum)
intel db backup <path> # online backup to target path (default: incremental)
intel db backup <path> --mode vacuum-into # defragmented archival snapshot
intel db quick-check # fast integrity check (PRAGMA quick_check)
Writer coordination: All intel db commands that mutate the database (checkpoint, prune, vacuum, backup, rebuild-fts, rebuild-topic-index, optimize-fts) require writer access. If the collector daemon is running, these commands send the request to the daemon via its Unix socket control channel and stream the result back. If the daemon is not running, they open a direct writer connection. This enforces the single-checkpoint-authority invariant (see Operational Constraints) and prevents concurrent writer conflicts.
rebuild-fts uses FTS5's 'rebuild' command to discard the full-text index and reconstruct it from the content table. Use after schema migrations, if the FTS index becomes inconsistent, or if triggers were temporarily disabled. Note: triggers only keep the FTS index consistent for changes after the triggers exist — if the content table was populated before triggers were created (e.g., during a migration), a manual rebuild is required.
rebuild-topic-index deletes all rows from event_topics and repopulates them from events.topics JSON using json_each. Use if the two representations diverge, or after reclassification changes. Runs in bounded batches to avoid large write transactions.
optimize-fts runs FTS5's 'optimize' special insert command, which merges all index b-tree segments into a single segment for minimal disk usage and fastest query performance. This can take significant time on large indexes; run it after large prunes, bulk reclassifications, or FTS rebuilds — not as a routine operation. At the expected scale (~15K events/month), this is fast enough to run ad hoc.
checkpoint forces a WAL checkpoint. Modes match SQLite's checkpoint API:
- PASSIVE (default): checkpoint as many frames as possible without waiting for readers. Safe to run at any time.
- FULL: wait for readers to finish, then checkpoint all frames. May block briefly.
- RESTART: like FULL, but also resets the WAL file. Ensures the next writer starts at the beginning.
- TRUNCATE: like RESTART, but also truncates the WAL file to zero bytes. Best for reclaiming disk space.
The command reports the checkpoint result triple: busy flag (whether the checkpoint was blocked by a concurrent reader/writer), total WAL pages, and checkpointed pages. SQLite's default auto-checkpoint (every ~1000 WAL pages, controlled by PRAGMA wal_autocheckpoint) runs PASSIVE checkpoints automatically — the checkpoint command is for when you need explicit control. Run --mode truncate opportunistically after intel db prune and intel db backup to reclaim WAL space during these natural maintenance windows.
incremental-vacuum runs PRAGMA incremental_vacuum to reclaim free pages left by deletions. Run after intel db prune to shrink the database file. Accepts an optional --pages N to limit the number of pages reclaimed per invocation.
backup uses better-sqlite3's .backup() API (wrapping SQLite's Online Backup API) to create a consistent snapshot of the live database without blocking readers. When the daemon is running, this executes on the daemon's writer connection via the control channel, ensuring no concurrent writer conflicts. When the daemon is stopped, it opens a direct connection.
Backup restart behavior: The Online Backup API read-locks the source only during each sqlite3_backup_step() call. If a different connection modifies the source database between steps, the backup restarts from the beginning on the next step. Frequent restarts can prevent completion entirely. This is why daemon-mediated backup (single writer) is not merely "nice" but operationally necessary — it ensures the backup connection is the only writer, preventing restart loops.
backup --mode vacuum-into is available as an alternative that uses VACUUM INTO <path>. This produces a minimal-sized, defragmented copy and purges deleted content (including free pages), but is not incremental and uses more CPU. Prefer this for archival snapshots where size matters; prefer the default online backup for routine operational snapshots where speed matters.
quick-check runs PRAGMA quick_check, a faster alternative to PRAGMA integrity_check that skips some index consistency checks but detects common corruption. Use periodically or after unexpected crashes. The collector daemon should run quick_check automatically at startup if a stale PID file is detected (indicating a previous unclean shutdown) and log the result. If corruption is detected, the daemon should refuse to start and surface the error clearly, rather than silently operating on a damaged database.
intel journal¶
Operator journal for recording observations, decisions, and notes alongside the intelligence data. Stub — full specification TBD.
intel journal add "Observed unusual correlation between ai.training and compute.gpu"
intel journal list # recent entries
intel journal list --since 7d # entries from last 7 days
intel journal list --tag review # entries with a specific tag
intel training-set¶
Manage labeled training data for the topic classifier. Fully specified in spec 019.
intel training-set list # show training set summary
intel training-set add <event-id> # Deferred (2026-03-26): superseded by reservoir sampling (spec 019)
intel training-set export # export labeled data for classifier training
intel training-set progress # show labeling progress per topic
intel classifier¶
Classifier management and evaluation commands. Core commands (evaluate, train) fully specified in spec 019.
intel classifier evaluate # run classifier against labeled training set
intel classifier train # retrain classifier from current training set
intel classifier test <text> # Classify text and print topic scores (debugging)
intel classifier status # Show model version, training date, topic count, scoring engine
Collector Design¶
Source Adapters¶
Carry forward the adapter pattern from the predecessor system. Each adapter implements:
interface SourceAdapter {
source: string;
fetch(checkpoint: Checkpoint | null): AsyncGenerator<RawEvent>;
}
Adapters to implement (priority order):
- RSS/Atom — generic feed polling with
rss-parser. Handles most sources. Supports conditional GET viaETagandIf-Modified-Sinceheaders: the adapter stores the server'sETagandLast-Modifiedresponse headers per feed incollector_healthand sends them on subsequent requests. When the server returns304 Not Modified, the adapter skips parsing entirely. This reduces bandwidth, avoids re-downloading unchanged feeds, and keeps request volumes polite. - Hacker News — HN API with new/top/best modes. 100ms inter-request delay.
- EDGAR — SEC filing RSS with form-type extraction and CIK parsing. Must comply with SEC EDGAR access requirements:
- User-Agent: every request must include a descriptive header:
intel-collector/0.1.0 (<operator-email>). The operator email is configured inconfig.yamlundercollector.edgar_contact. - Rate limit: hard cap at 10 requests/second (SEC-monitored maximum). The adapter should self-limit well below this — 2 req/s default with configurable ceiling. Config validation must enforce
edgar_max_rps <= 10; values above 10 are rejected at startup. - Startup validation: if any feed has
source: edgar, the collector must validate thatcollector.edgar_contactis set and non-empty, and thatcollector.edgar_max_rpsdoes not exceed 10. Fail fast with a clear error if either check fails — running EDGAR requests without a declared User-Agent risks IP blocks, and exceeding 10 rps risks rate limiting or blocking by the SEC. - Carry forward: the predecessor's
secUserAgentmechanism and EDGAR form-type parsing hooks. - Earnings — SEC filings adapter tracking 10-K and 10-Q form types from a configurable roster of tech companies. Uses the same SEC EDGAR API infrastructure and rate-limiting as the EDGAR adapter. Added via migration 002.
Each adapter manages its own checkpoint (last-seen cursor) stored in the checkpoints table.
Content Extraction¶
Carry forward from content-fetcher.ts:
- Fetch article URL → extract readable content via
@mozilla/readability+jsdom - jsdom security: Readability does not sanitize content, and jsdom can execute scripts and fetch remote resources. Always construct jsdom with
runScripts: "outside-only"(never"dangerously") andresources: undefined(default, no remote resource loading). This prevents fetched pages from executing code or phoning home during extraction. - Content is untrusted input: Readability explicitly states it does not perform security-related input sanitization — interactive/scripting content may persist in output. Even though this tool returns plain text (not HTML), extracted content feeds agent prompts, making prompt injection the relevant threat. The safe snippets contract (control char stripping, injection marker flagging, bounded length) is therefore a correctness requirement, not a nice-to-have.
- Per-domain rate limiting (1 request/second per hostname)
- Blocked domain list (paywalls, login walls)
- Content length bounds (min 100 chars, max 50K chars)
- 10-second timeout per fetch
- Graceful fallback: if extraction fails, keep title + URL only
- Content flags: The
content_flagsJSON field records extraction status so downstream agents can assess content trustworthiness. Flags include:"extracted"(full content available),"blocked_domain"(skipped due to domain blocklist),"truncated"(exceeded max length),"fetch_failed"(HTTP or parse error),"injection_markers"(suspected prompt injection markers detected). Example:["extracted", "truncated"].
Topic Classification¶
Carry forward from topics.allowlist.yaml:
- ~100 topics with keyword and regex matchers
- Priority-based resolution (highest priority wins when multiple match)
- Top 5 topics per event
- Context-aware matching for short acronyms (RAG, EKS require surrounding terms)
- Market-gate filtering for high-volume feeds (entity term matching)
The topics.yaml config file moves to tools/intelligence/config/topics.yaml.
Canonical URL Normalization¶
When an event has a URL, the collector computes canonical_url by:
- Stripping tracking parameters (
utm_*,ref,source,via) - Removing trailing slashes and fragments
- Lowercasing the hostname
This enables cross-source story dedup: if HN and an RSS feed both link to the same article, canonical_url will match and trend volume counts distinct stories rather than distinct events.
Poll Loop¶
for each feed in config:
if time_since_last_poll(feed) < feed.poll_interval:
continue
checkpoint = load_checkpoint(feed)
events = []
for event in adapter.fetch(checkpoint): -- async: network I/O
event.canonical_url = normalize_url(event.url)
event.topics = classify(event.title, event.content)
events.push(event)
for batch in chunk(events, BATCH_SIZE): -- commit every N events
BEGIN TRANSACTION -- sync: DB writes only
for event in batch:
upsert_event(event) -- ON CONFLICT: enrichment update (see below)
upsert_event_topics(event) -- one row per topic
update_checkpoint(feed, last_event_in_batch)
COMMIT
update_collector_health(feed, events.length) -- outside batch loop
sleep(jitter(1000, 3000)) -- inter-feed pause
Batch-size cap: Events are committed in batches (default 100 events per transaction, configurable via collector.batch_size). This prevents one unexpectedly chatty feed from creating a single massive write transaction — large transactions create large WAL files because the WAL can't be reset mid-transaction. The checkpoint is updated per batch, so a crash mid-feed loses at most one batch worth of events, which will be re-fetched on the next poll cycle.
Async/sync boundary: better-sqlite3's .transaction() wrappers do not work with async functions — they commit before awaited work completes. All network I/O (feed fetching, content extraction) must happen outside the transaction. The transaction wraps only the synchronous SQLite inserts.
Enrichment update policy: RSS items sometimes update titles or descriptions after initial publication while keeping the same GUID/link. A strict ON CONFLICT DO NOTHING would permanently miss improvements — especially if content extraction previously failed. Instead, use an enrichment upsert: on conflict, fill previously-null fields without overwriting existing data.
INSERT INTO events (event_id, source, feed, url, canonical_url, title, content, author,
published_at, fetched_at, topics, tags, score, comments,
source_meta, content_flags)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(event_id) DO UPDATE SET
content = COALESCE(events.content, excluded.content),
canonical_url = COALESCE(events.canonical_url, excluded.canonical_url),
content_flags = COALESCE(events.content_flags, excluded.content_flags),
score = MAX(events.score, excluded.score),
comments = MAX(events.comments, excluded.comments),
fetched_at = excluded.fetched_at
WHERE events.content IS NULL AND excluded.content IS NOT NULL
OR events.canonical_url IS NULL AND excluded.canonical_url IS NOT NULL
OR events.content_flags IS NULL AND excluded.content_flags IS NOT NULL
OR excluded.score > events.score
OR excluded.comments > events.comments;
created_at is never updated — it always reflects "first seen." fetched_at updates to the latest refresh timestamp only when enrichment actually occurs (the WHERE clause gates this). This preserves append-only semantics for most data while maintaining "best available event record" quality.
Data Retention¶
Events older than retention_days (default 30) are pruned periodically. The collector handles this automatically, or it can be triggered manually with intel db prune.
Privacy note: PRAGMA secure_delete = ON scrubs deleted content from ordinary tables, but does not guarantee removal from FTS5 shadow tables — SQLite's own documentation warns that secure_delete only scrubs ordinary tables and that virtual tables (including FTS5) may leave forensic traces in their shadow tables. FTS5 has its own 'secure-delete' configuration option (set via INSERT INTO events_fts(events_fts, rank) VALUES('secure-delete', 1)) which prevents deleted entries from being reconstructable, but is slower and still requires SQLite core PRAGMA secure_delete = ON for full file-level protection. If "pruned means unrecoverable" becomes a requirement, the full stack is: FTS5 secure-delete=1 + PRAGMA secure_delete = ON + periodic intel db rebuild-fts + VACUUM. These are not enabled by default (performance cost). For this tool's use case (publicly available data, single operator), the default retention policy is sufficient.
FTS5 secure-delete compatibility warning: If FTS5 secure-delete=1 is ever enabled, be aware that after rows are updated or deleted with this mode active, older SQLite/FTS5 versions (pre-3.42.0) may refuse to read the FTS table unless it is rebuilt. This is a one-way format change — once rows are deleted with secure-delete enabled, the index is no longer backward-compatible with older SQLite builds.
Service Management¶
The collector runs as a long-lived foreground process (intel collect). OS-native service managers handle lifecycle concerns — start on boot, restart on crash, log routing — so the --daemon flag is unnecessary under managed supervision.
macOS (launchd)¶
Template plist at tools/intelligence/service/launchd/com.intel.collector.plist. Key properties:
KeepAlive: true— launchd restarts the process whenever it exits (crash or clean)RunAtLoad: true— starts automatically on user loginThrottleInterval: 10— minimum 10 seconds between restarts, preventing crash loopsProcessType: Background— signals macOS this is a low-priority background task
Logs route to ~/Library/Logs/intel-collector.log via StandardOutPath/StandardErrorPath. The collector writes all diagnostic output to stderr, which launchd captures to the same file.
Install: ./service/install.sh resolves the intel binary path and $HOME into the plist template, copies it to ~/Library/LaunchAgents/, and bootstraps the service via launchctl.
Commands:
# Status
launchctl print gui/$(id -u)/com.intel.collector
# Manual stop/start
launchctl kickstart -k gui/$(id -u)/com.intel.collector # restart
launchctl bootout gui/$(id -u)/com.intel.collector # stop
# Logs
tail -f ~/Library/Logs/intel-collector.log
Linux (systemd)¶
User unit at tools/intelligence/service/systemd/intel-collector.service. Key directives:
Restart=on-failure— restarts on non-zero exit; clean shutdowns (SIGTERM → exit 0) stay downRestartSec=10— 10-second delay between restart attemptsStartLimitIntervalSec=60,StartLimitBurst=5— after 5 failures within 60 seconds, systemd stops retrying (prevents infinite crash loops)ProtectHome=read-only,ProtectSystem=strict— security hardening; only~/.local/share/inteland runtime dirs are writableMemoryMax=512M— hard memory ceiling
Logs route to the systemd journal. The collector's stderr output is captured automatically.
Install: ./service/install.sh copies the unit to ~/.config/systemd/user/, enables it, and starts it.
Commands:
# Status
systemctl --user status intel-collector
# Manual control
systemctl --user restart intel-collector
systemctl --user stop intel-collector
# Logs
journalctl --user -u intel-collector -f
# Survive logout (run once)
loginctl enable-linger $USER
Forecast Scheduled Jobs¶
The forecast learning loop (spec 007, section J2) requires periodic intel forecast --snapshot and intel forecast evaluate invocations. Without scheduling, topic weights remain at the default 1.0 and the confidence learning loop never runs.
Two additional launchd plists and four systemd units handle this:
| Job | Schedule | launchd plist | systemd timer + service |
|---|---|---|---|
| Snapshot | Weekly (Sundays 03:00) | com.intel.forecast-snapshot.plist |
intel-forecast-snapshot.timer + .service |
| Evaluate | Daily (04:00) | com.intel.forecast-evaluate.plist |
intel-forecast-evaluate.timer + .service |
Key differences from the collector service:
- One-shot, not daemon: No
KeepAlive(launchd) /Type=oneshot(systemd). The process runs, completes, and exits. - Calendar-triggered:
StartCalendarInterval(launchd) /OnCalendar(systemd) replacesRunAtLoad/KeepAlive. - Shared log file: Both jobs log to
~/Library/Logs/intel-forecast.log(macOS). On Linux, output routes to the systemd journal under separate unit names. - Persistent timers (systemd):
Persistent=trueensures missed runs (e.g., machine was off) execute on next boot.
The same install.sh script handles all three services — collector, forecast-snapshot, and forecast-evaluate — in a single install/uninstall invocation.
Commands (macOS):
# Status
launchctl print gui/$(id -u)/com.intel.forecast-snapshot
launchctl print gui/$(id -u)/com.intel.forecast-evaluate
# Manual trigger
launchctl kickstart gui/$(id -u)/com.intel.forecast-snapshot
launchctl kickstart gui/$(id -u)/com.intel.forecast-evaluate
# Logs
tail -f ~/Library/Logs/intel-forecast.log
Commands (Linux):
# Status
systemctl --user list-timers 'intel-forecast-*'
# Manual trigger
systemctl --user start intel-forecast-snapshot.service
systemctl --user start intel-forecast-evaluate.service
# Logs
journalctl --user -u intel-forecast-snapshot
journalctl --user -u intel-forecast-evaluate
Why Not --daemon¶
The --daemon flag and PID file management exist for manual operation without a service manager. Under launchd/systemd supervision, the collector runs in foreground mode:
- The supervisor owns process lifecycle (start, stop, restart) — a self-daemonized process confuses the supervisor's process tracking
- Crash restart is handled by the supervisor, not by the collector re-spawning itself
- Log capture works naturally when stdout/stderr are connected to the supervisor
- PID files become redundant since the supervisor tracks the process directly
The --daemon flag remains available for environments without a service manager (e.g., quick manual testing).
Interaction with Control Channel¶
The control channel (Unix socket) continues to work under service management. CLI commands like intel db checkpoint and intel collect status detect the running daemon via PID file and route maintenance requests through the socket. The PID file is written by the control channel's start() method regardless of whether the process was launched by a supervisor or manually.
MCP Server¶
The CLI commands map 1:1 to MCP tools. An MCP server wrapper (intel mcp) exposes them for direct agent integration without shelling out.
Tools exposed:
| Tool | Maps to | Description |
|---|---|---|
intel_trends |
intel trends |
Get trending tech topics with scores |
intel_search |
intel search |
Full-text search across collected intelligence |
intel_events |
intel events |
Browse events by topic, source, time |
intel_sources |
intel sources |
Check data source freshness and health |
intel_topics |
intel topics |
List configured intelligence topics |
intel_stats |
intel stats |
Database statistics and health overview |
intel_pack |
intel pack |
Bounded evidence bundle for agent synthesis |
intel_forecast |
intel forecast |
Forward-looking predictions from topic co-movement and lifecycle analysis (see spec 007) |
intel_forecast_snapshot |
intel forecast --summary |
Compact forecast snapshot for agent consumption (top scenarios, chains, change points) |
intel_forecast_evaluate |
intel forecast evaluate |
Evaluate forecast accuracy against actual outcomes |
The MCP server reads the same SQLite file as the CLI. No separate process needed beyond the collector daemon.
Transport: stdio (JSON-RPC over stdin/stdout). The MCP stdio spec requires:
- Messages are newline-delimited JSON-RPC; messages must not contain embedded newlines.
- The server must not write anything to stdout that is not a valid MCP message.
- Diagnostic output goes to stderr only.
Built with @modelcontextprotocol/sdk using the standard StdioServerTransport. These invariants are testable: acceptance tests should verify that intel mcp stdout is parseable as MCP messages only and that logs route to stderr.
Connection management: The MCP server opens a reader connection (readonly: true, PRAGMA query_only = ON) and keeps it for the lifetime of the server process. Each tool call executes a short query and returns — no long-held transactions across calls.
Remote MCP: If remote access is needed later, Streamable HTTP transport can be added as a second transport. This requires additional security controls (origin validation, bind to localhost, authentication) and is explicitly out of scope for v1.
Configuration¶
Single config file: ~/.config/intel/config.yaml
# Database location
database: ~/.local/share/intel/intelligence.db
# Collector settings
collector:
poll_interval_seconds: 300 # default interval between polls
fetch_content: true # extract article content via Readability
content_timeout_ms: 10000
max_content_length: 50000
retention_days: 30 # prune events older than this
edgar_contact: user@example.com # required: email for SEC EDGAR User-Agent
synchronous: normal # normal (faster) or full (crash-safe); see SQLite Configuration
edgar_max_rps: 2 # EDGAR rate limit (max 10, default 2)
batch_size: 100 # max events per write transaction
wal_autocheckpoint: 1000 # WAL pages before auto-checkpoint (SQLite default)
journal_size_limit_bytes: 67108864 # max on-disk WAL/journal size after checkpoint (64 MB; -1 = no limit)
blocked_domains: # skip content extraction for these
- medium.com
- bloomberg.com
# Feed definitions
feeds:
- url: https://aws.amazon.com/blogs/aws/feed/
source: rss
name: AWS Blog
category: aws
poll_interval: 600
- url: https://aws.amazon.com/blogs/compute/feed/
source: rss
name: AWS Compute Blog
category: aws
poll_interval: 600
- url: https://openai.com/blog/rss/
source: rss
name: OpenAI Blog
category: ai
poll_interval: 900
request_options: # optional per-feed HTTP overrides
headers:
User-Agent: "intel-collector/0.1.0"
- source: hackernews
name: Hacker News
modes: [new, top]
poll_interval: 300
max_items: 100
- source: edgar
name: SEC EDGAR
form_types: [8-K, 10-K, 10-Q]
poll_interval: 900
entities: ["AMZN", "GOOG", "MSFT", "AAPL", "META", "NVDA"]
- source: earnings
name: SEC Earnings
form_types: [10-K, 10-Q]
poll_interval: 900
tickers: ["AMZN", "GOOG", "MSFT", "AAPL", "META", "NVDA"]
# Topic classification
topics_file: ~/.config/intel/topics.yaml # or embed inline
# Output defaults
output:
format: json # json | text
timezone: America/New_York # for text output timestamps
Package Structure¶
tools/intelligence/
├── package.json
├── tsconfig.json
├── README.md
├── config/
│ ├── topics.yaml # default topic allowlist
│ └── feeds.example.yaml # example feed config
├── src/
│ ├── bin.ts # CLI entry point (commander/yargs)
│ ├── db.ts # SQLite schema init + migrations
│ ├── config.ts # YAML config loading + zod validation
│ ├── collector/
│ │ ├── index.ts # poll loop orchestration
│ │ ├── adapters/
│ │ │ ├── types.ts # SourceAdapter interface
│ │ │ ├── rss.ts # RSS/Atom adapter
│ │ │ ├── hackernews.ts # HN API adapter
│ │ │ ├── edgar.ts # SEC EDGAR adapter
│ │ │ └── earnings.ts # SEC Earnings (10-K/10-Q) adapter
│ │ ├── content-fetcher.ts # Readability extraction
│ │ ├── topic-classifier.ts # allowlist matching
│ │ └── checkpoints.ts # cursor persistence
│ ├── queries/
│ │ ├── trends.ts # windowed topic scoring
│ │ ├── search.ts # FTS5 queries
│ │ ├── events.ts # filtered event browsing
│ │ ├── sources.ts # source health queries
│ │ ├── pack.ts # evidence bundle assembly
│ │ ├── forecast.ts # forward-looking predictions (see spec 007)
│ │ ├── topics.ts # topic listing
│ │ └── stats.ts # database statistics
│ ├── mcp/
│ │ └── server.ts # MCP server (stdio transport)
│ ├── control/
│ │ └── channel.ts # Unix socket control channel (daemon ↔ CLI)
│ └── util/
│ ├── time.ts # duration parsing, formatting
│ ├── text.ts # sanitization, truncation
│ └── url.ts # canonical URL normalization
├── service/
│ ├── install.sh # cross-platform service installer
│ ├── launchd/
│ │ ├── com.intel.collector.plist # macOS launchd agent — collector daemon
│ │ ├── com.intel.forecast-snapshot.plist # macOS launchd agent — weekly forecast snapshot
│ │ └── com.intel.forecast-evaluate.plist # macOS launchd agent — daily forecast evaluate
│ └── systemd/
│ ├── intel-collector.service # Linux systemd user unit — collector daemon
│ ├── intel-forecast-snapshot.service # Linux systemd unit — forecast snapshot (oneshot)
│ ├── intel-forecast-snapshot.timer # Linux systemd timer — weekly (Sun 03:00)
│ ├── intel-forecast-evaluate.service # Linux systemd unit — forecast evaluate (oneshot)
│ └── intel-forecast-evaluate.timer # Linux systemd timer — daily (04:00)
├── tests/
│ ├── topics.test.ts # topic classification precision
│ ├── trends.test.ts # trend computation
│ ├── search.test.ts # FTS queries
│ ├── sanitization.test.ts # injection patterns, control chars, MCP framing
│ └── mcp-stdio.test.ts # protocol conformance (stdout is MCP-only)
└── migrations/
└── 001-initial.sql # schema creation
What Carries Forward¶
Must keep (core value)¶
| Component | Source | Why |
|---|---|---|
| RSS adapter + checkpoints | apps/collector/src/adapters/rss.ts |
Feed polling with dedup is the foundation |
| HN adapter (new/top/best) | apps/collector/src/adapters/hackernews.ts |
HN-specific pagination and ranking modes |
| Content fetcher | apps/collector/src/content-fetcher.ts |
Readability extraction dramatically improves topic precision |
| Topic classifier | infra/config/topics.allowlist.yaml |
100+ curated topics with priority resolution |
| Market-gate filtering | apps/collector/src/adapters/rss.ts |
Prevents high-volume feeds from overwhelming signal |
| Topic precision tests | apps/collector/tests/topics.test.ts |
Validates classification quality |
Simplified¶
| Was | Becomes |
|---|---|
| Kafka event streaming | Direct SQLite writes |
| Redis window counters | SQL aggregation at query time |
| Postgres + Prisma | SQLite + better-sqlite3 |
| Proto contracts | TypeScript interfaces + JSON |
| 4 Docker services | 1 Node.js process |
| Docker env vars | Single YAML config |
| npm workspaces monorepo | Single package |
Dropped¶
| Component | Why |
|---|---|
| Kafka | No streaming needed; single writer, single reader |
| Redis | SQLite handles dedup, aggregation, and caching |
| Postgres | SQLite handles the data volumes (~500 events/day) |
| Prisma | Direct SQL is simpler for 5 tables |
| Protobuf | JSON everywhere; no cross-language contracts needed |
| Docker Compose | Single process; install and run directly |
| OpenTelemetry | Overkill for single-process tool; structured logs are enough |
| Grafana dashboards | CLI provides the view; intel trends replaces a dashboard |
| Persister service | Collector writes directly to SQLite |
| Trends service | Computed at query time from events table |
| Consumer lag tracking | No consumers; collector IS the writer |
| Story-level dedup (Redis) | Replaced by canonical_url column + SQL dedup in trend queries |
| Discovery candidates | Can be added later if needed |
Companion Skill¶
A skill at skills/intelligence/SKILL.md should describe how agents use the tool:
- When to check trends before making recommendations
- How to search for prior art before writing
- How to use events and trends data to ground research tasks
- How to integrate intelligence into other workflows
This is a follow-up to the tool implementation.
Dependencies¶
{
"dependencies": {
"better-sqlite3": "^11.0.0",
"rss-parser": "^3.13.0",
"@mozilla/readability": "^0.5.0",
"jsdom": "^24.0.0",
"yaml": "^2.4.0",
"zod": "^3.23.0",
"commander": "^12.0.0",
"@modelcontextprotocol/sdk": "^1.0.0"
},
"devDependencies": {
"typescript": "^5.5.0",
"vitest": "^2.0.0",
"@types/better-sqlite3": "^7.6.0",
"@types/jsdom": "^21.0.0"
}
}
Dependency Notes¶
better-sqlite3 is a synchronous native addon. Prebuilt binaries are available for LTS Node.js versions on common platforms. Two operational considerations:
- Cross-platform distribution may require native compilation if prebuilts are unavailable. Pin to a Node.js LTS version to minimize this friction.
- Synchronous API means long-running queries block the event loop. At the expected data volumes (~500 events/day, ~15K total) with proper indexing, this is not a concern. If query times ever become an issue,
better-sqlite3supports worker threads for offloading.
rss-parser supports passing requestOptions (including custom headers and timeouts) to its internal HTTP client. The per-feed request_options config leverages this for feeds that require specific User-Agent or authentication headers. HTTP behavior (timeouts, redirects, user agent, retry/backoff) should be unified behind a shared fetch layer used by both RSS fetching and content extraction, so throttling and error handling stay consistent.
Acceptance Criteria¶
intel collect --oncepolls all configured feeds and writes events to SQLiteintel trendsreturns scored topics from recent eventsintel search "bedrock"returns matching events with FTS5 rankingintel events --topic aws.bedrock --since 7dreturns filtered eventsintel sourcesshows per-source freshnessintel mcpstarts an MCP server exposing all query tools- Topic classification passes the same precision tests as the predecessor system
- All output defaults to JSON;
--format textproduces human-readable output - All JSON output uses the response envelope with
schema_version,status,data,warnings, andnext_cursor - Text snippets in list output are sanitized (control chars stripped, injection markers flagged)
intel packproduces a bounded evidence bundle with trends, supporting events, and source health- Cross-source events sharing a canonical URL are counted as one story in trend volume
- EDGAR adapter sends compliant User-Agent header and respects configured rate limit
- Topic queries use the
event_topicsside table; bothevent_topicsrows andevents.topicsJSON are populated at insert time intel db rebuild-ftsreconstructs the FTS index from the events table- All database connections use short-lived transactions; no long-held read locks across pagination boundaries
- Reader connections open with
readonly: trueand setPRAGMA query_only = ON; writer connection does not intel mcpstdout contains only valid MCP messages (newline-delimited JSON-RPC); all logs route to stderrintel db backup <path>produces a consistent database copy using the online backup API- Schema version is tracked via
PRAGMA user_version;intel db schemareports current version and pending migrations - EDGAR feeds fail fast at startup if
collector.edgar_contactis not configured - Cursor-based pagination produces no duplicate or missing rows when new events arrive between pages
collector_healthtable is updated every poll cycle, even when no new events are found- MCP stdio protocol conformance is verified by a CI test: spawn
intel mcp, send a valid JSON-RPC request, assert stdout contains only valid MCP messages and no stray output (the most common stdio integration failure mode) intel db rebuild-topic-indexreconstructsevent_topicsfromevents.topicsJSONcontent_flagsfield records extraction status (extracted, blocked_domain, truncated, fetch_failed, injection_markers) for each event- RSS adapter sends
If-None-Match/If-Modified-Sinceheaders when prior ETag/Last-Modified values are available, and skips parsing on304 Not Modified - Text sanitization has unit tests covering: known prompt injection patterns (
[INST],<|system|>, etc.), control character stripping, zero-width character removal, output remains valid JSON, and output does not break newline-delimited MCP framing intel dbmaintenance commands coordinate with a running collector daemon via the control channel; they do not open a competing writer connection when the daemon is activeintel statsreports the embedded SQLite version; a startup warning is logged if the version does not satisfy the WAL-reset bug fix conditions (>= 3.52.0 or a known backport: 3.51.x >= .3, 3.50.x >= .7, 3.44.x >= .6)SQLITE_BUSYerrors from CLI and MCP produce structured, retryable error responses (not raw stack traces) with a context-dependent suggested action (read queries → "retry soon"; maintenance commands → "stop collector or use control channel")PRAGMA application_idis set on DB initialization;intel db schemaandintel statsdisplay it;db.tsrejects databases with a non-matching, non-zeroapplication_id- All correctness-critical pragmas are verified by set-and-read-back at connection setup;
intel statsreportspragma_verifiedstatus - Re-ingested events with previously-null fields (content, canonical_url, content_flags) are enriched rather than silently ignored;
created_atis never updated - Cursor tokens include
schema_versionandcommand; a stale or mismatched cursor returnsINVALID_CURSORerror (not silently wrong results) intel db backup --mode vacuum-intoproduces a defragmented archival snapshot viaVACUUM INTO- Collector daemon runs
PRAGMA quick_checkon startup when a stale PID file indicates unclean shutdown; refuses to start if corruption is detected - Cursor stability CI test: simulate new events arriving between paginated reads and assert no duplicate or missing rows
- Any snippet or content returned in default JSON output passes sanitization: control chars stripped,
content_flagsset for known injection markers, no embedded newlines that would break newline-delimited MCP framing - Config validation enforces
edgar_max_rps <= 10; values above 10 are rejected at startup with an explicit error referencing SEC fair access policy
Success Metrics¶
| Metric | Target | How to measure |
|---|---|---|
| Freshness | p95 newest-event age per source < 2x poll interval | intel sources --stale |
| Coverage | Events/day within 20% of predecessor at same config | intel stats daily |
| Topic precision | Predecessor test suite passes unchanged | vitest topics.test.ts |
| Context efficiency | Avg bytes returned per list command < 10KB | Instrument CLI output size |
| Reliability | No unhandled "database is locked" errors under normal operation; all SQLITE_BUSY errors produce structured retryable responses | Structured log monitoring |
Open Questions¶
-
application_idregistration: Should the chosenapplication_idvalue (0x494E5445) be registered or cross-referenced against SQLite'smagic.txtto avoid accidental collisions with other assigned IDs? This is low-risk (the value is uncommon) but worth confirming before v1 ships. -
FTS5
secure-deleteopt-in: Willsecure-delete=1ever be enabled for the FTS5 index? If yes, note that after rows are updated/deleted with secure-delete active, older SQLite/FTS5 versions (pre-3.42.0) may refuse to read the table unless it is rebuilt. This is a one-way format change. Decision: not enabled by default; document the compatibility cost if it becomes a requirement. -
VACUUM INTOfor archival backups: Shouldintel db backup --mode vacuum-intobe the default for periodic archival snapshots? It produces a minimal-sized, defragmented copy and purges deleted content, but is heavier-weight than the online backup API. Current design offers it as an explicit opt-in (--mode vacuum-into); the default remains the incremental online backup for routine use.