# Forage — Build Plan ## What We Are Proving Each phase proves something specific. Do not build phase N+1 until phase N has proven its thesis. | Phase | Proves | Delivers | |-------|--------|----------| | **P0** | The loop closes — signal in, re-rank out, observable in real time | Local server + seed data + Claude observes interactions | | **P1** | Claude can discover content without the user browsing — reactions alone drive the loop | Autonomous discovery agent + browse-tasks API + source registry | | **P2** | Semantic search works over content Forage finds on the real web | Embedding service + real web crawl | | **P3** | The MAB sharpens — exploration items hit more often over time | Adaptive exploration budget, centroid tracking, exploration-hit instrumentation | | **P4** | The surprise moment — cross-centroid discoveries emerge naturally | Multi-session preference evolution, intersection surfacing | --- ## Phase 0 — Close the Loop (MVP Demo) **Goal:** A running demo where a user interacts with a local feed page, signals are posted by the page itself, and Claude's Chrome extension observes visible ranking shifts. No real web crawl. No real embeddings. Proves the feedback loop. **What we build:** ### `forage-engine` (library crate) The reusable core. Wraps tidalDB with the foraging-specific schema, seed corpus, MAB layer, and public API. This is what transfers to other applications. ```rust pub struct ForageEngine { db: TidalDb } impl ForageEngine { pub fn ephemeral() -> Result pub fn persistent(data_dir: &Path) -> Result pub fn seed_default_corpus(&self) -> Result<()> pub fn signal(&self, user: u64, item: u64, kind: SignalKind) -> Result<()> pub fn signal_dwell(&self, user: u64, item: u64, duration_ms: u64) -> Result<()> pub fn feed(&self, user: u64, limit: usize) -> Result> pub fn all_items(&self) -> &[SeedItem] pub fn add_item(&self, item: ForageItemInput) -> Result // P1 } ``` ### `forage-server` (Axum binary) A thin HTTP wrapper over `forage-engine`. Serves: ``` POST /signal { user_id, item_id, signal_type, duration_ms? } GET /feed ?user=X&limit=7 GET /items (all items, for page render) GET / (serves the feed HTML page) ``` Runtime mode: - Persistent by default (`~/.forage/data`) - Optional `--ephemeral` mode for throwaway demo runs Schema on startup: - 100 seed items across 8 categories (tech, music, jazz, cooking, fitness, travel, science, literature) - Each item: title, url (placeholder), category, source, reading_time, description - Seeded RNG — same items every run, deterministic - 3 pre-built users: `cold` (no signals), `explorer` (light signals), `convergent` (heavy signals in 2 categories) Signal types registered: - `view` — half-life 7d, AllTime + 24h windows - `dwell` — half-life 3d (reading time is stronger signal than click) - `save` — half-life 30d (strong intent) - `skip` — half-life 1d (mild negative, decays fast) - `share` — half-life 14d (strongest positive) Ranking profiles: - `forage_default` — personalized, 14% exploration (~1 in 7), max_per_category:2 - `forage_explore` — heavy exploration, weighted toward underexplored categories - `forage_converge` — pure exploitation, no exploration MAB layer (thin wrapper over tidalDB query): ``` candidate_pool = RETRIEVE items FOR USER @u USING PROFILE forage_default LIMIT 20 exploit = candidate_pool[0..6] // top 6 by score explore = candidate_pool filtered by (category_signal_count < 5) // pick 1 from underexplored final = interleave(exploit, explore, ratio=0.14) // ~1 in 7 ``` Item labels returned in feed response: - `"match"` — near a known centroid - `"exploring"` — from exploration budget - `"trending"` — high velocity regardless of personalization - `"resurfaced"` — user had prior engagement, decayed, being re-checked ### Feed Page (`/`) Static HTML + minimal JS. No framework. - Grid of 7 item cards - Each card: title, source, category chip, reading time, description, label badge - Click card → `POST /signal {signal_type: "view"}`, open URL in new tab - Hover for >3s → `POST /signal {signal_type: "dwell", duration_ms: N}` - "Skip" button on card → `POST /signal {signal_type: "skip"}` - "Save" button → `POST /signal {signal_type: "save"}` - Auto-refresh feed every 5s (or on any signal write) - Visual: ranking shift animation when feed re-orders ### What Claude Does in P0 Claude uses the Chrome extension **lightly** — as an observer, not a puppeteer. The feed page handles signal posting itself via JS `fetch()`. Claude's role: 1. `navigate` to `localhost:4242` — one call 2. `read_page` to snapshot the initial feed state — one call 3. Wait while a human (or scripted JS) interacts with the feed for 10+ interactions 4. `read_page` again to snapshot the final feed state — one call 5. Report: what categories dominated before vs. after, which exploration items appeared, how the labels shifted Three MCP tool calls per session. That is the ceiling. The interesting loop — signal → re-rank → new feed — runs entirely in the browser without Claude's involvement. Claude observes the outcome, it does not produce it. This is the demo. This is the proof-of-concept that makes the thesis visible. **Deliverables:** - `applications/forage/engine/` — `ForageEngine` library crate (tidalDB + MAB + schema) - `applications/forage/server/` — thin Axum binary wrapping the engine - `applications/forage/server/static/index.html` — feed page (plain HTML/JS, signals via fetch()) - CORS headers on the server so the feed page can post signals without browser errors --- ## Phase 1 — Autonomous Discovery Loop **Goal:** Claude discovers content proactively. The user never browses. The loop closes entirely through reactions to what Claude finds. **Thesis:** A personalized feed can be driven without the user visiting a single page. Claude browses on behalf of the user, Forage ranks what it finds, and the user's reactions (save/skip/dwell) teach Claude where to look next. The feedback signal is not visits — it is choices. **The loop:** ``` Background task in forage-server ↓ emits browse-tasks (topics weighted by user preference + tag affinity, source list) Claude (--chrome, persistent session) ↓ navigates sources → finds article links ↓ reads each article in full ↓ analyses: topics, entities, content type, summary, quality ↓ POST /capture with enriched metadata forage-server ↓ stores rich metadata, fires view signals, broadcasts via SSE Feed page (localhost:4242) ↓ shows enriched cards (tags, content type, entities, Claude summary) live User reacts (save / skip / dwell) ↓ signals update preference vector AND tag affinity counters Next browse-tasks call ↓ returns topics + tag weights → Claude targets specific subtopics next cycle Loop repeats with higher precision ``` **Why Claude's enrichment matters here:** A JavaScript content script extracts what the page declares about itself. Claude reads and understands what the page actually says. These are different things. - `` on a jazz article: *"The latest from Blue Note Records"* - Claude's analysis: `topics: ["hard bop", "trumpet"], entities: ["Lee Morgan", "Blue Note"], content_type: "review", summary: "A career retrospective on Lee Morgan's 1960s Blue Note recordings, focusing on his development of the hard bop trumpet style."` The preference model runs on Claude's output, not the page's self-description. This is what makes tag-level personalization possible before real embeddings arrive in P2. --- ### What we build #### Source Registry (in `forage-engine`) A hardcoded per-category list of seed URLs Claude can navigate to find articles. Each source is a page where the top-level links are articles (list pages, front pages, RSS-style feeds). ``` technology: news.ycombinator.com, lobste.rs science: phys.org, news.ycombinator.com?q=science jazz: pitchfork.com/reviews/albums, allaboutjazz.com/news travel: theguardian.com/travel, cntraveler.com/latest-news cooking: seriouseats.com, bonappetit.com/recipe design: designobserver.com, dezeen.com/news history: historytoday.com, smithsonianmag.com/history health: health.harvard.edu/blog, theatlantic.com/health ``` `ForageEngine` gains: ```rust pub fn browse_tasks(&self, user_id: u64, limit_per_topic: usize) -> BrowsePlan ``` `BrowsePlan` contains: - `topics: Vec` — ordered by preference weight + tag affinity, cold-start gets equal weight across all 8 - `limit_per_topic: usize` — how many articles to capture per source - `should_run: bool` — false if last discovery was recent and feed has ≥5 items - `tag_hints: Vec` — top tags from saved/dwelled items the agent should bias toward within each topic (e.g. `["modal jazz", "improvisation"]` tells Claude to prefer theory-heavy jazz sources over jazz news) #### `GET /browse-tasks` (forage-server) Returns a `BrowsePlan` as JSON for user 1: ```json { "should_run": true, "interval_minutes": 30, "limit_per_topic": 5, "tag_hints": ["modal jazz", "improvisation", "music theory"], "topics": [ { "name": "jazz", "priority": 0.72, "sources": ["pitchfork.com/reviews/albums", "allaboutjazz.com/news"] }, { "name": "technology", "priority": 0.51, "sources": ["news.ycombinator.com", "lobste.rs"] }, { "name": "science", "priority": 0.28, "sources": ["phys.org"] } ] } ``` `tag_hints` comes from the top tags across the user's positively-signaled items (save + dwell ≥15s). The agent uses these to bias which articles it chooses to read in depth within each source — skipping news roundups and prioritizing analysis pieces that match the hints. Cold-start response: all 8 categories at equal `priority: 0.125`, 2 sources each, `tag_hints: []`. #### `POST /discovery/heartbeat` (forage-server) Agent calls this on every cycle start. Server records `agent_last_seen` timestamp. Used by feed page to show connection status. #### `GET /discovery/status` (forage-server) ```json { "agent_connected": true, "last_discovery_at": "2026-02-24T10:30:00Z", "items_found_last_run": 23, "next_run_in_minutes": 12 } ``` `agent_connected: true` when `agent_last_seen` is within the last 5 minutes. #### Discovery state in `AppState` ```rust pub struct DiscoveryState { pub last_discovery_at: Mutex>, pub agent_last_seen: Mutex>, pub items_last_run: Mutex, } ``` Added to `AppState` alongside `engine` and `events`. Handlers update it; feed page polls it. #### Feed page status indicator A small status bar below the header: - `● Active — last run 4 min ago` (green dot) — `agent_connected: true` - `○ Agent not connected` (grey dot) — no heartbeat in 5 min - `⟳ Discovering...` (spinning) — between heartbeat and items appearing #### Enriched capture payload `ForageItemInput` and `POST /capture` gain Claude-specific fields: ```rust pub struct ForageItemInput { pub url: String, pub title: String, pub source: String, pub category: String, pub reading_time_min: u32, pub description: String, // Claude-enriched fields (all optional; empty = not provided) pub tags: Vec, // specific subtopics: ["modal jazz", "music theory", "john coltrane"] pub entities: Vec, // named entities: ["John Coltrane", "Blue Note Records"] pub content_type: String, // "analysis" | "news" | "tutorial" | "opinion" | "review" | "interview" | "research" | "" pub summary: String, // Claude's 2-sentence summary of what the article actually says } ``` All fields serialized into item metadata storage. `tags` stored as `"tags"` (comma-separated string) so existing metadata retrieval works without schema changes. `CaptureReq` in `handlers.rs` gains the same optional fields with `#[serde(default)]`. #### Tag affinity in `ForageEngine` `top_tags(user_id, limit) -> Vec` — scans metadata of positively-signaled items (save + strong dwell), splits the `"tags"` metadata field, returns the top-N by frequency. Used to populate `tag_hints` in `BrowsePlan`. No schema changes to tidalDB. Tag affinity runs entirely over item metadata; the preference vector stays 8-dimensional and tracks category-level signal. Tags are a secondary signal that guides the agent's article selection within a source, not a replacement for the embedding. #### Enriched feed cards Feed cards gain three new display elements: - **Tag chips** — top 3 tags from the item, rendered as small outlined badges below the description. Tap a tag → future feed cards filtered/boosted for that tag (stored as a `localStorage` tag preference that biases the next `/browse-tasks` call via a `?prefer_tags=` query param) - **Content type badge** — right of the category chip, distinct color: `analysis` (blue), `tutorial` (green), `news` (grey), `opinion` (amber), `review` (purple) - **Claude summary** — shown instead of the meta description when non-empty; clearly signals what Claude learned from reading, not what the page says about itself #### The discovery agent prompt A file at `applications/forage/agent.md` — the instruction set Claude runs with `--chrome`. Core loop: 1. `GET localhost:4242/browse-tasks` 2. If `should_run: false` → wait `interval_minutes`, repeat from step 1 3. `POST localhost:4242/discovery/heartbeat` 4. For each topic (in priority order): - For each source URL: - Navigate to the source page - Find article links on the page (exclude nav, footer, sidebar links; prefer main content area) - **Select** up to `limit_per_topic` articles that appear relevant to `tag_hints` (if hints are present); prefer depth over breadth — analysis and tutorial pieces over news roundups - For each selected article: - Navigate to the article - **Read the full page text** (`get_page_text`) - **Analyse**: - `title` — headline (from `

` if better than ``) - `canonical_url` — from `<link rel="canonical">` - `reading_time_min` — word count ÷ 200, rounded up - `tags` — 2–5 specific subtopic tags, lowercase, singular nouns or short phrases (e.g. `"modal jazz"` not `"jazz"`) - `entities` — up to 5 named people, companies, technologies, or places central to the article - `content_type` — one of: `analysis`, `news`, `tutorial`, `opinion`, `review`, `interview`, `research` - `summary` — 2 sentences: what the article argues or reports, not what the site says about it - Skip if: title is empty, contains "Sign In" / "Subscribe" / "Login" / "Create Account", or URL is localhost / chrome:// - `POST localhost:4242/capture` with all enriched fields - Wait 1–2 seconds (politeness) 5. Wait `interval_minutes` minutes 6. Repeat **What Claude must NOT do:** summarise the meta description. The point is that Claude reads the article and describes what it actually contains. A meta description that says "Read our latest article on jazz" is useless. Claude's summary should say "Argues that Coltrane's 1965 transition to free jazz was less a rejection of hard bop than an extension of it into harmonic territory bebop had not explored." #### Invocation One shell script at repo root: ```bash #!/usr/bin/env bash # forage-discover.sh — start the Forage discovery agent # Prerequisites: forage-server running at localhost:4242, claude CLI with --chrome support claude --chrome "$(cat applications/forage/agent.md)" ``` User starts the system with two terminal tabs: ```bash # Tab 1 — server cargo run -p forage-server --manifest-path applications/forage/server/Cargo.toml # Tab 2 — agent ./forage-discover.sh ``` Then opens `localhost:4242` and reacts. --- ### Edge cases | Situation | Handled by | |-----------|------------| | Cold start (no prefs, no tags) | Equal weight all 8 categories, `tag_hints: []`, agent reads broadly | | Agent not running | Feed shows "Agent not connected"; `should_run: true` stays set | | Navigation 404 / timeout | Agent skips to next URL, cycle continues | | Paywall / login page | Agent skips on title check ("Sign In", blank, "Subscribe") | | Empty title | `POST /capture` returns 400; agent skips | | Duplicate URL | `add_item` idempotent via FNV-1a; same ID, no duplicate; enrichment not re-written | | Feed sparse (< 5 items) | `should_run: true` overrides interval immediately | | Two agents running | Both browse; idempotent captures; harmless double coverage | | Server restart | `last_discovery_at` resets to null; agent runs on next cycle | | Prefs shift mid-cycle | Current cycle finishes with old plan; next call picks up new weights | | Claude context grows | Agent processes one topic at a time, not all sources in one turn | | Rate limit (HTTP 429) | Agent skips source, logs, continues to next | | Article has no meta description | Agent uses first paragraph or derives from full read; summary field carries real content | | Tags on first article in a new category | Tags come from Claude's reading, not from existing tag history; tag affinity starts building immediately | | User taps tag chip on feed card | Stored as `localStorage` tag preference; next `/browse-tasks?prefer_tags=modal+jazz` biases hints | | Item enrichment fails (Claude unsure) | Fields default to empty string / empty array; `POST /capture` still succeeds; card renders with basic metadata only | --- ### What this does NOT build - A Chrome Web Store extension - Server-push to Claude (server cannot initiate Claude actions; Claude polls) - Per-user discovery (single user, user 1, as per multi-user scope constraint) - Configurable source lists via UI (source registry is hardcoded for P1) --- ### Acceptance criteria 1. Two terminal commands start the full system: one for the server, one for the agent 2. Within 5 minutes of starting, the feed contains ≥10 real articles discovered by Claude 3. Items appear in the feed in real-time via SSE as Claude captures them (no manual refresh) 4. Feed page shows `● Active` status while the agent is running 5. ≥80% of captured items have non-empty `tags`, `content_type`, and `summary` fields — Claude is analysing, not just extracting 6. At least one feed card's summary is observably different from and more informative than its meta description 7. After saving ≥5 items tagged `"modal jazz"`, the next `/browse-tasks` response includes `"modal jazz"` in `tag_hints` 8. After 20 user reactions (≥5 saves on jazz items), the next `/browse-tasks` response ranks jazz sources first 9. A navigation failure (404, timeout) during a discovery cycle does not crash the agent or the server 10. Re-running the agent after a server restart re-populates the feed within one cycle (items already in DB are not re-added) --- ## Phase 2 — Real Embeddings **Goal:** Semantic search and similarity-based recommendations over content Forage actually finds. **What changes:** A thin embedding sidecar (separate process, any language): ``` POST /embed { text: string } → { vector: f32[1536] } ``` Default: OpenAI `text-embedding-3-small`. Swappable. Forage calls this when writing new items. The text embedded is now significantly richer than P0's title-only approach. With Claude's enrichment from P1 available, the embedder receives: ``` {title} — {summary}. Topics: {tags}. Entities: {entities}. ``` This embeds Claude's understanding of the article, not the page's self-description. The preference centroid that emerges is a semantic model of what the user actually engages with. With real embeddings: - `SearchBuilder::semantic("jazz theory")` works for real - `SearchBuilder::similar_to(item_id)` produces genuine similarity - Preference vectors actually mean something — they are in embedding space The feed profile adds: - `semantic_boost: 0.3` — items semantically near preference centroid score higher - `similar_to_saved: true` — items near saved items get boosted **Proves:** The preference vector is not just a signal frequency map — it is a semantic model of what the user cares about, queryable by meaning. --- ## Phase 3 — Adaptive MAB **Goal:** The exploration budget adapts per-user based on their exploration-hit history. **What changes:** Track per-user: `exploration_hits` / `exploration_total` → hit rate. ``` if hit_rate > 0.5: exploration_ratio = 0.25 (adventurous user) if hit_rate < 0.2: exploration_ratio = 0.10 (convergent user) else: exploration_ratio = 0.14 (default) ``` UCB1 bonus on underexplored categories: ``` ucb_bonus = sqrt(2 * ln(total_signals) / category_signal_count) ``` Categories with few signals get a score boost, naturally surfacing exploration candidates higher. Instrumentation persists per-user exploration outcomes (`exploration_hits`, `exploration_total`) and feeds adaptation logic. **Proves:** The MAB is not static noise — it learns the user's exploration tolerance and adjusts. Power users feel the system getting bolder with them. --- ## Phase 4 — The Surprise Moment **Goal:** Cross-centroid discoveries emerge. Users find things at the intersection of two interests they did not know were related. **What changes:** Centroid intersection query: ``` centroids = top_2_active_centroids(user) midpoint = (centroid_a.vector + centroid_b.vector) / 2 intersection_candidates = ANN(midpoint, limit=5) inject 1 intersection candidate into every 7-item feed label it: "bridge: {category_a} × {category_b}" ``` Over time, if intersection items hit consistently, they form a new centroid — the user's interests have genuinely merged into a new territory. **Proves:** This is not a feature that could be added to a recommendation system after the fact. It is the natural consequence of having a semantic preference model that updates with the feedback loop. The "surprise moment" is an emergent property of the system working correctly. --- ## What We Are Not Building - A Chrome extension we publish to the Chrome Web Store (P0 is Claude-driven, not user-installed) - A mobile app - Multi-user / server-hosted version (single user, local process) - Content moderation, NSFW filtering, language filtering - Payment, accounts, authentication - A scraper that violates robots.txt or rate limits --- ## Phase 0 Acceptance Criteria The P0 demo is complete when: 1. `cargo run -p forage-server --manifest-path applications/forage/server/Cargo.toml` starts a server at `localhost:4242` 2. The feed page loads with 7 items across ≥3 categories 3. A user generates 10+ signals from the feed page (mix of views, skips, saves) while Claude observes before/after state 4. After 10 signals, the feed has visibly shifted toward the signaled categories 5. At least 1 item in the feed is labeled `exploring` (from the exploration budget) 6. The signal-to-re-rank latency is < 200ms (measured by feed refresh after `POST /signal`) 7. A second user (`?user=2`) with no signals gets a different, more exploratory feed than a user with 20+ signals If these 7 criteria are met, the loop is closed and the thesis is proven.