SPB Git forge
7commits 1branches 0releases
229.0 KBsize
maindefault branch
12 days agolast push
TypeScript 91.8% HTML 3.2% JavaScript 3% SQL 1.4% CSS 0.7%

Social Runtime Crawler v0.1 — browser-native self-learning social crawler PoC

Monorepo (pnpm, strict TS via tsx, vitest): persistent Chromium sessions with human login,
network observer + SchemaProfiler + generic JSON entity miner, DOM mutation observer + in-page
semantic snapshot, page classifier, network/DOM surface merge with provenance, video detection
and frame sampling, world model, information-gain heuristic planner (+ optional Anthropic/local
LLM planner), loop detection, platform-model learning and connector compilation, PostgreSQL +
JSONL replay storage, research dashboard, YouTube and Reddit adapters.

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

67 changed files +6,657 −0

added .env.example +27 −0
@@ -0,0 +1,27 @@
1 +# Social Runtime Crawler — environment
2 +# Copy to .env (never commit .env). No platform passwords belong here or anywhere in the repo:
3 +# authentication is done by a human once in the headed browser (`pnpm login <platform>`).
4 +
5 +# PostgreSQL (prototype storage). Leave empty to run with the JSONL-only store.
6 +SRC_DATABASE_URL=postgres://localhost:5432/social_runtime
7 +
8 +# Where browser profiles, session logs and learned platform models live.
9 +SRC_DATA_DIR=./data
10 +
11 +# LLM planner (Tier 4). If no provider is configured the heuristic planner (Tier 1) is used alone.
12 +# anthropic → uses ANTHROPIC_API_KEY (or an `ant auth login` profile)
13 +# local → OpenAI-compatible local endpoint (e.g. llm-api.io on the MacLustr cluster)
14 +# none → heuristic only
15 +SRC_LLM_PROVIDER=none
16 +SRC_LLM_MODEL=claude-opus-5
17 +# ANTHROPIC_API_KEY=
18 +# SRC_LOCAL_LLM_URL=https://www.llm-api.io/v1
19 +# SRC_LOCAL_LLM_KEY=
20 +# SRC_LOCAL_LLM_MODEL=
21 +
22 +# Browser
23 +SRC_HEADLESS=false
24 +SRC_BROWSER_CHANNEL=chromium
25 +
26 +# Dashboard
27 +SRC_DASHBOARD_PORT=8340
added .gitignore +16 −0
@@ -0,0 +1,16 @@
1 +node_modules/
2 +dist/
3 +.env
4 +.env.local
5 +*.log
6 +.DS_Store
7 +
8 +# Runtime data: browser profiles hold authenticated sessions — never commit.
9 +data/browser_profiles/
10 +data/sessions/
11 +data/media/
12 +data/platform_model/*/
13 +!data/platform_model/.gitkeep
14 +
15 +# Compiled (learned) connectors are versioned intentionally; regression fixtures may be large.
16 +connectors/*/regression_tests/large/
added CLAUDE.md +332 −0
@@ -0,0 +1,332 @@
1 +# CLAUDE.md
2 +
3 +## Project Name
4 +
5 +**Social Runtime Crawler**
6 +
7 +Alternative internal names: SRC · SocialProbe · Social Runtime Mining · Social Browser Intelligence Engine · Living Social Crawler
8 +
9 +> Repository guide for contributors (humans and agents). Sections 1–87 are the founding specification.
10 +> Implementation status, commands and layout: see `README.md` and `docs/ARCHITECTURE.md`.
11 +> Repo conventions: pnpm workspaces, strict TypeScript run with `tsx` (no build step), vitest, `@src/*` packages, `pnpm typecheck && pnpm test` before committing.
12 +> In-page code (evaluated in the browser) must be plain JavaScript strings or `.page.js` files — TS transpilers inject helpers (`__name`) that do not exist in the page.
13 +> Never put credentials in the repo or in `.env`; authentication = human login into the persistent browser profile (`pnpm login <platform>`), profiles live in `data/browser_profiles/` (git-ignored).
14 +
15 +---
16 +
17 +# 1. Mission
18 +
19 +Build a new browser-native crawling system specifically designed for modern social-media platforms.
20 +
21 +This project must NOT behave like a traditional scraper that primarily performs direct HTTP fetches against static pages.
22 +
23 +Instead, it must treat each social platform as a **live interactive application runtime**.
24 +
25 +The crawler must:
26 +
27 +1. launch and maintain authenticated browser sessions;
28 +2. navigate social-media interfaces normally;
29 +3. observe what the logged-in account is legitimately allowed to see;
30 +4. intercept structured information delivered to the browser frontend;
31 +5. inspect DOM mutations;
32 +6. inspect network responses;
33 +7. inspect browser runtime state;
34 +8. detect dynamically loaded posts, profiles, videos, comments, pages and recommendations;
35 +9. capture media metadata and optionally public media artifacts;
36 +10. convert observations into normalized structured events;
37 +11. build an internal world model of each social platform;
38 +12. use an AI agent to decide what to explore next;
39 +13. learn how each platform works during exploration;
40 +14. progressively compile learned behavior into reusable platform connectors;
41 +15. detect connector breakage and return to exploration mode automatically.
42 +
43 +The long-term objective is to invent a new crawling paradigm: **Social Runtime Mining**. Instead of downloading documents from the Web, the system observes the execution of social applications and learns how information is surfaced through normal browser interaction.
44 +
45 +# 2. Core Principle
46 +
47 +Traditional crawler: `URL → HTTP GET → HTML → Parse → Follow links → Repeat`.
48 +
49 +Social Runtime Crawler:
50 +
51 +```text
52 +Authenticated Browser → Live Social Application → (DOM | Network | Visual)
53 +→ Runtime Observation Layer → Semantic World Model → AI Navigation Agent → Action Selection
54 +→ click / scroll / search / open profile / open post / play video / expand comments ↻
55 +```
56 +
57 +The crawler should continuously ask: *What action will reveal the most useful new information?* — not *What URL should I download next?*
58 +
59 +# 3. Scope
60 +
61 +Initial target platforms: Facebook, Instagram, TikTok, X / Twitter, YouTube, Reddit, LinkedIn, Threads.
62 +
63 +Prototype order — Phase 1: YouTube, Reddit · Phase 2: Facebook, Instagram · Phase 3: TikTok, X · Phase 4: LinkedIn, Threads. The architecture must nevertheless be platform-independent.
64 +
65 +# 4. Important Boundary
66 +
67 +The system must only process information visible to the authenticated account through normal use of the platform.
68 +
69 +The system must NOT be designed to: bypass authentication; defeat access controls; obtain private messages; access private profiles not visible to the account; discover hidden API credentials; defeat CAPTCHA systems; evade platform security mechanisms; bypass rate limits through deceptive identity rotation; exploit undocumented vulnerabilities; circumvent paywalls or privacy settings; impersonate users without authorization.
70 +
71 +Authentication state should be supplied manually during development. The crawler may observe and structure data already delivered to the browser.
72 +
73 +# 5. Primary Use Case
74 +
75 +A research account is logged into Facebook. The crawler: open Facebook → observe feed → detect visible post → extract post metadata → detect author/page/profile → detect media → detect interactions → detect comments if opened → follow visible public profile → inspect profile → discover other public posts → return to feed → continue.
76 +
77 +The same architecture must work for TikTok For You, Instagram Explore, YouTube recommendations, Reddit feeds, Facebook pages and public profiles, X timelines, LinkedIn public posts.
78 +
79 +# 6. Architectural Philosophy
80 +
81 +Build this as a set of cooperating systems. Never create a monolithic Playwright script.
82 +
83 +Main systems: Browser Runtime · Observation Layer · Entity Extraction Layer · Media Intelligence Layer · World Model · Navigation Agent · Information Gain Engine · Platform Adapter · Connector Learning Engine · Connector Compiler · Persistence Layer · Scheduler · Replay System · Monitoring · Research UI.
84 +
85 +# 7. Browser Runtime
86 +
87 +Node.js / TypeScript, Playwright, Chromium, Chrome DevTools Protocol. Primary controller: Playwright. Lower-level telemetry: CDP.
88 +
89 +Each account uses a persistent browser profile (`browser_profiles/<platform>-<alias>/`). Never persist passwords manually in project files. Authentication: launch browser → human login → save browser profile → reuse authenticated profile.
90 +
91 +# 8. Browser Sessions
92 +
93 +```ts
94 +interface SocialBrowserSession {
95 + sessionId: string; platform: Platform; profilePath: string; accountAlias: string;
96 + start(): Promise<void>; stop(): Promise<void>; navigate(url: string): Promise<void>; getCurrentState(): Promise<PageState>;
97 +}
98 +```
99 +
100 +Track: session_id, platform, account_alias, started_at, current_url, current_entity, navigation_depth, last_action, health.
101 +
102 +# 9. Observation Surfaces
103 +
104 +SURFACE_1_NETWORK · SURFACE_2_DOM · SURFACE_3_RUNTIME_STATE · SURFACE_4_ACCESSIBILITY · SURFACE_5_VISUAL · SURFACE_6_MEDIA · SURFACE_7_NAVIGATION.
105 +
106 +Each observation must contain provenance: `{ "value": "Example Creator", "source": "network", "confidence": 0.99, "observed_at": "..." }`.
107 +
108 +# 10. Network Observation Layer
109 +
110 +Observe browser-generated XHR, fetch, GraphQL, JSON, HTML fragments, WebSocket frames, SSE, media manifests, video metadata, image resources. Modules: NetworkObserver, ResponseClassifier, JsonDetector, GraphQLDetector, WebSocketObserver, MediaRequestObserver, SchemaProfiler.
111 +
112 +Do NOT hardcode endpoints during initial exploration. Fingerprint them: `{ hostname, method, content_type, response_shape_hash, observed_entity_types }`.
113 +
114 +# 11. Automatic Response Classification
115 +
116 +response → is JSON? → schema inference → contains repeated objects? → candidate entity detection → field analysis → relationship detection → confidence score.
117 +
118 +Detect post IDs, profile IDs, usernames, captions, timestamps, engagement counts, comment arrays, cursor values, pagination tokens, media URLs, thumbnail URLs, video manifests, author objects, recommendation metadata. Avoid assumptions about property names; use structure and semantic inference.
119 +
120 +# 12. Schema Discovery
121 +
122 +`SchemaProfiler` infers `abc → likely_identifier`, `xyz.foo → likely_display_name`, `xyz.bar → likely_username` from field name, value shape, frequency, co-occurrence, DOM evidence, visual evidence, LLM semantic inference.
123 +
124 +# 13. DOM Observation
125 +
126 +MutationObserver inside the page. Track elements added/removed, text changes, attributes, lazy-loaded sections, new feed cards, modal opening, comment expansion, video replacement, infinite-scroll additions. Inject a lightweight observer; emit deltas (`{ "event": "dom_nodes_added", "count": 14, "region": "feed" }`), never serialize the entire DOM continuously.
127 +
128 +# 14. Semantic DOM Representation
129 +
130 +Never send the full DOM blindly to an LLM. Generate a compact page representation (PAGE / VISIBLE ENTITIES [1] Page… [2] Post… / ACTIONS [A1]…). The AI agent chooses only semantic actions.
131 +
132 +# 15. Accessibility Surface
133 +
134 +Optional observer using accessible roles and labels (buttons, links, menus, tabs, search fields, video controls, follow buttons, profile links, comment controls). Prefer semantic locators over fragile generated CSS classes.
135 +
136 +# 16. Visual Surface
137 +
138 +Fallback, not primary. Capture viewport screenshots, content cards, video frames, profile headers, overlays — only when network confidence low, DOM confidence low, content image-based, text embedded in media, UI unknown, or video classification required.
139 +
140 +# 17. Video Intelligence
141 +
142 +`src/media/video/`: VideoDetector, VideoMetadataExtractor, VideoFrameSampler, SubtitleExtractor, TranscriptResolver, AudioMetadataExtractor, VideoFingerprint, MediaDeduplicator. Collect platform video ID, author, caption, description, hashtags, duration, thumbnail, public engagement metrics, creation timestamp, visible music/audio name, subtitles, resolution, delivery metadata.
143 +
144 +# 18. Video Frame Sampling
145 +
146 +Do NOT download every video. video detected → metadata → relevance estimate → if relevant: capture frames (start, 25 %, 50 %, 75 %, end; adaptive: scene change) → vision analysis → semantic representation. Store perceptual hashes for dedup.
147 +
148 +# 19. Video Semantic Record
149 +
150 +Normalized `{ media_type, platform, platform_media_id, author_entity_id, caption, topics[], entities[], language, transcript_available, visual_summary, engagement{views,likes,comments} }`.
151 +
152 +# 20. Public Profile Modeling
153 +
154 +Profiles become canonical entities `{ entity_type: "person", canonical_id, display_name, platform_profiles{}, professional_context, public_bio, topics[], sources[] }`. Do not infer sensitive attributes (religion, medical conditions, sexual orientation, private family information, home address, political ideology inferred from behavior) unless scope explicitly changes with a justified basis.
155 +
156 +# 21. Identity Resolution
157 +
158 +`IdentityResolver` signals: exact name, username similarity, verified links, official website, cross-linked accounts, same organization, bio similarity, profile photo similarity where appropriate, public external links. Never merge two people solely because names match; every merge requires confidence and evidence.
159 +
160 +# 22. Entity Types
161 +
162 +Person, Organization, Page, Profile, Channel, Account, Post, Comment, Video, Image, Topic, Hashtag, URL, Event, Location, Product, OrganizationRole. Relationships: AUTHORED, MENTIONED, REPLIED_TO, POSTED_BY, BELONGS_TO, LINKS_TO, FEATURES, HAS_PROFILE, REPRESENTS, DISCOVERED_FROM.
163 +
164 +# 23. Social World Model
165 +
166 +Graph-like in-memory representation of the current platform (Profile A —AUTHORED→ Post 1 …). This graph guides navigation.
167 +
168 +# 24. Navigation Agent
169 +
170 +The LLM must NOT manipulate raw selectors. It receives `{ goal, current_state, visible_entities[], available_actions[] }` and answers `{ action, expected_information_gain, reason }`.
171 +
172 +# 25. Action Vocabulary
173 +
174 +OPEN_ENTITY, OPEN_POST, OPEN_PROFILE, OPEN_PAGE, OPEN_CHANNEL, OPEN_VIDEO, OPEN_COMMENTS, SCROLL_DOWN, SCROLL_UP, SEARCH, FILTER, PLAY_VIDEO, PAUSE_VIDEO, EXPAND, COLLAPSE, BACK, FORWARD, RETURN_TO_FEED, WAIT_FOR_CONTENT, END_SESSION. Do not expose arbitrary browser JavaScript to the planner.
175 +
176 +# 26. Information Gain Engine
177 +
178 +`information_gain = novelty × relevance × expected_entity_yield × confidence × source_quality ÷ exploration_cost`, with penalties: already_seen, duplicate_content, navigation_loop, low_quality_source, low_relevance.
179 +
180 +# 27. Novelty Measurement
181 +
182 +Embeddings: novelty ≈ `1 − max_similarity(candidate, existing dataset)`. Use a local embedding model whenever possible.
183 +
184 +# 28. Agent Modes
185 +
186 +OBSERVE (feed only; study recommendation exposure) · RESEARCH (search + navigate public entities) · PROFILE (one public person) · TOPIC (a topic → people, organizations, posts, videos, channels, hashtags) · PLATFORM LEARNING (understand UI, runtime, entity structures, schemas, navigation).
187 +
188 +# 29. Connector Learning
189 +
190 +Do not immediately hardcode a Facebook connector; learn first. Store `platform_model/{page_types, entity_patterns, response_patterns, action_patterns, navigation_graph, selectors, media_patterns}.json`.
191 +
192 +# 30. Action → Observation Learning
193 +
194 +Every action records consequences (`before`/`after`: visible posts, network requests, new entities). Infer over time: `scroll_down → usually FeedResponseType3 → ~8 new posts → exposes cursor`.
195 +
196 +# 31. Automatic Connector Compilation
197 +
198 +Once confidence is high: learn platform → compile connector (`connectors/<platform>/manifest, page_classifier, network_patterns, entities, navigation, media, parser, regression_tests/`). The connector is learned state, not manually written truth.
199 +
200 +# 32. Self-Healing
201 +
202 +`expected entities = 20, observed = 0``CONNECTOR_DEGRADED` → exploration mode → re-inspect DOM/network → re-learn → update connector → regression tests → resume.
203 +
204 +# 33. Universal Social Event Format
205 +
206 +`{ event_id, event_type, platform, session_id, timestamp, entity{type, platform_id, canonical_id}, provenance[{surface, confidence}], discovered_via{action} }`.
207 +
208 +# 34. Event Types
209 +
210 +PAGE_OPENED · ENTITY_DISCOVERED/OBSERVED/UPDATED · POST_/COMMENT_/PROFILE_DISCOVERED · MEDIA_/VIDEO_/IMAGE_DISCOVERED · NETWORK_RESPONSE_OBSERVED · NETWORK_SCHEMA_DISCOVERED · DOM_CHANGED · ACTION_EXECUTED/FAILED · NAVIGATION_COMPLETED · CONNECTOR_PATTERN_LEARNED/DEGRADED/REPAIRED.
211 +
212 +# 35. Storage
213 +
214 +Prototype: PostgreSQL, Redis, local filesystem / S3-compatible object storage. Optional later: ClickHouse, OpenSearch, Qdrant, Neo4j. Do not add all databases prematurely.
215 +
216 +# 36. PostgreSQL Core Tables
217 +
218 +sessions, platform_accounts, entities, entity_aliases, observations, posts, media, videos, relationships, actions, network_responses, schema_patterns, connector_versions, crawl_jobs.
219 +
220 +# 37. Raw vs Normalized Data
221 +
222 +RAW OBSERVATION → NORMALIZATION → CANONICAL ENTITY. Raw observations must never be overwritten.
223 +
224 +# 38. Evidence Model
225 +
226 +Every canonical field needs provenance: `{ field, value, evidence[{observation_id, source}] }`.
227 +
228 +# 39. Deduplication
229 +
230 +Same platform ID, canonical URL, media fingerprint, normalized text, high embedding similarity, same author+timestamp+content. Never deduplicate people using fuzzy names alone.
231 +
232 +# 40. Crawl Budget
233 +
234 +`budget: { max_minutes, max_actions, max_profiles, max_posts, max_videos, max_depth }` plus max_video_processing_seconds, max_storage_mb, max_llm_tokens.
235 +
236 +# 41. Navigation Safety
237 +
238 +Maintain visited_entities, visited_urls, recent_actions, navigation_path, page_fingerprints. Detect loops (A → B → A → B) and break automatically.
239 +
240 +# 42. Platform Boundary
241 +
242 +`stay_on_platform = true`: external links may be recorded but not followed automatically.
243 +
244 +# 43. Search-Based Discovery
245 +
246 +Research mode supports native platform search: find search field → submit query → classify result tabs → inspect people/pages/posts/videos → select relevant entities.
247 +
248 +# 44–45. Feed-Based Discovery & Recommendation Dataset
249 +
250 +Preserve ranking: feed_session_id, feed_position, content_id, timestamp_seen, time_visible, interaction_performed, recommendation_context (`is_followed_account`, `is_sponsored`, `time_visible_ms`).
251 +
252 +# 46. Interaction Policy
253 +
254 +Default **read-only**. Allowed: scroll, search, open, expand, play video, navigate. Never automatically like, follow, comment, share, message, react, subscribe unless explicitly enabled for a controlled experiment.
255 +
256 +# 47. Media Capture Strategy
257 +
258 +LEVEL 0 metadata · 1 + thumbnail · 2 + selected frames (default) · 3 + transcript/subtitles · 4 full artifact only when required and permitted.
259 +
260 +# 48–50. Content Classification, Local AI, Model Hierarchy
261 +
262 +Local LLM/embeddings for topic, language, mentions, names, locations, category, summary, tags — never invent facts. Pipeline: deterministic parser → local embeddings → local classifier → larger LLM only where needed. Tiers: 1 rules/parsers · 2 embeddings · 3 small local LLM · 4 large reasoning model. Escalate only when needed.
263 +
264 +# 51. Initial Prototype Goal
265 +
266 +YouTube or Reddit first: start authenticated browser → search topic → detect result entities → navigate results → intercept network → compare network vs DOM extraction → capture posts/videos → normalize events → store in PostgreSQL → dashboard.
267 +
268 +# 52. Facebook Experimental Prototype
269 +
270 +Open authenticated Facebook → one known public page → observe → identify posts → intercept runtime traffic → open one post → expand visible comments → identify public profile/page links → open one → capture public info → return. Success: no Facebook-specific external API, no manual data entry, browser stays authenticated, all entities have provenance, actions recorded, video objects identified, schemas discovered automatically.
271 +
272 +# 53. Debug UI
273 +
274 +Panels: Browser Preview, Current Page State, Visible Entities, Available Actions, Agent Decision, Network Events, Detected Schemas, World Model, Media Queue, Recent Observations, Connector Confidence.
275 +
276 +# 54–55. Replay & Explainability
277 +
278 +Every session replayable from recorded observations (actions, URL changes, network metadata, DOM observations, screenshots, decisions). Each AI action stores goal, chosen action, expected gain, relevance, novelty, concise reason — no hidden chain-of-thought.
279 +
280 +# 56. Platform Adapter Interface
281 +
282 +`detectPageType, extractVisibleEntities, getAvailableActions, resolveEntityTarget, detectMedia, detectFeedItems, normalizeObservation`.
283 +
284 +# 57. Generic Engine vs Platform Logic
285 +
286 +Generic: browser lifecycle, network capture, DOM observation, event bus, planning, information gain, storage, media pipeline, identity resolution. Platform-specific: page recognition, semantic labels, feed identification, known entity classes, navigation hints, normalization quirks.
287 +
288 +# 58–60. Page Classification, Confidence, Unknown States
289 +
290 +Types: HOME_FEED, SEARCH_RESULTS, PROFILE, PUBLIC_PAGE, POST_DETAIL, VIDEO_DETAIL, CHANNEL, GROUP, COMMENT_VIEW, UNKNOWN. Signals: URL, visible text, ARIA landmarks, DOM structure, network schemas, adapter hints. Every inference carries confidence; below threshold → stronger classifier. Unknown pages are expected: capture semantic DOM + network summary → classify → generic actions → learn.
291 +
292 +# 61–63. Connector Knowledge Base, Shared Learning, Workers
293 +
294 +Persist learned behaviour (`{pattern, platform, schema_hash, likely_entity_type, confidence, observed_count}`). Workers share patterns through a Connector Knowledge Service → versioned platform model. Worker = Browser + Playwright + CDP + Observer + Media Processor + Action Executor; control plane = Scheduler, Queue, DB, Connector Knowledge, LLM Router, Dashboard.
295 +
296 +# 64–66. Cluster Deployment, Isolation, Crash Recovery
297 +
298 +One Mac node per platform worker (MacLustr), central PostgreSQL/Redis/object storage/API/dashboard. One browser profile per account; separate processes. Recover from browser/tab crash, timeouts, missing selectors, session expiration (pause job, `AUTH_REQUIRED`, manual re-auth — never bypass), unexpected modals, redesigns.
299 +
300 +# 67–69. Observability, Quality Metrics, Experimentation
301 +
302 +Track actions/min, entities/min, posts/min, videos/min, responses/min, duplicate ratio, LLM calls, average gain, connector confidence, CPU/RAM, storage, errors. KPIs: entity/field precision, recall on visible page, navigation success, duplicate rate, connector stability, media detection accuracy, cost per 1 000 entities, entities per browser-hour. Compare DOM only / Network only / DOM+Network / +Agent / +Vision.
303 +
304 +# 70–74. Research Question & Innovations
305 +
306 +Can this outperform traditional social crawling (less connector code, automatic adaptation, dynamic content, recommendation context, media, learned frontend APIs, surviving UI changes)? Innovations: Runtime API Discovery (action → response → entity), Social Runtime Graph (UI states, actions, schemas), Information-Gain Navigation, Connector Compiler (`social-runtime learn facebook` → "Platform learned … Confidence: 94 %"; `social-runtime crawl facebook --goal "Quebec public personalities"`).
307 +
308 +# 75. Development Phases
309 +
310 +0 repo · 1 persistent browser · 2 network observer · 3 DOM observer · 4 event normalization · 5 entity extraction · 6 semantic actions · 7 AI planner · 8 information gain · 9 media/video · 10 platform learning · 11 connector compilation · 12 self-healing · 13 distributed workers.
311 +
312 +# 76–77. Repository Structure & Stack
313 +
314 +`apps/{api,dashboard,worker}`, `packages/{browser,observers,agent,entities,media,connectors,platform-model,storage,events,shared}`, `connectors/<platform>/`, `data/`, `scripts/`, `docs/`, `docker/`. TypeScript, Node.js, Playwright, Chromium, CDP, PostgreSQL, Redis, React/Next.js dashboard, Docker where appropriate; optional Qdrant, ClickHouse, Neo4j, MinIO. Do not over-engineer the prototype.
315 +
316 +# 78–81. Coding Principles & Testing
317 +
318 +Strict TypeScript, small modules, typed events, DI where useful, structured logging, deterministic parsers before LLMs, clear adapters, unit + integration tests, record/replay fixtures. Avoid one giant platform script, hundreds of static CSS selectors, massive switch statements, platform logic inside the core, LLM-controlled arbitrary JS. Fixture-driven tests for schemas, DOM fragments, normalization, identity resolution, action selection, media detection, page classification; browser integration tests for profile reuse, navigation, scroll, search, open entity, interception, mutation capture, media detection.
319 +
320 +# 82–86. Data Review, Vision, Success
321 +
322 +Dashboard lets a human inspect raw observation, normalized value, canonical entity, evidence, surface, confidence and correct mappings (feeding learning). Vision: `"Find public Quebec personalities discussing AI"` → system understands platforms, navigates, observes runtime, discovers profiles, captures posts/videos, extracts entities, deduplicates identities, builds graph, returns dataset — without a hand-maintained scraper per page.
323 +
324 +First milestone succeeds when: one human login is reused; navigation by semantic actions; dynamic content observed; posts/profiles/media recognized; useful network payloads identified; network vs DOM evidence compared; video metadata captured; normalized events stored; next action chosen; loops avoided; at least one reusable platform pattern learned automatically.
325 +
326 +# 87. Final Engineering Rule
327 +
328 +When implementing any feature, always ask: *Are we teaching the crawler how the social application works, or merely hardcoding another scraper?*
329 +
330 +Prefer learning · observation · semantic actions · structured frontend data legitimately delivered to the browser · deterministic extraction over LLM guessing · provenance over assumptions · reusable platform knowledge over one-off automation.
331 +
332 +The end goal is not to automate Chrome. It is to **build an autonomous system capable of understanding and mining the observable runtime of social applications.**
added README.md +69 −0
@@ -0,0 +1,69 @@
1 +# Social Runtime Crawler
2 +
3 +Browser-native, self-learning crawler for modern social platforms. Instead of downloading documents, it **observes the runtime of a social application** through an authenticated browser (Playwright + Chromium), fuses several observation surfaces (network, DOM, media, navigation), builds a world model, lets an information-gain agent decide what to look at next, and progressively **learns how the platform works** (response schemas, page types, action → observation patterns) into a compiled connector.
4 +
5 +Full specification: [`CLAUDE.md`](./CLAUDE.md). Architecture notes: [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md).
6 +
7 +## Status — v0.1 proof of concept (2026-09-11)
8 +
9 +Implemented (Phases 0 → 8 of §75, plus media detection and platform learning):
10 +
11 +| Area | What works |
12 +|---|---|
13 +| Browser runtime | persistent Chromium profile per (platform, account), human-in-the-loop login, CDP session |
14 +| Network surface | XHR/fetch/GraphQL/WebSocket/media capture, `SchemaProfiler` (field semantics without property-name assumptions), generic JSON entity miner, endpoint fingerprints |
15 +| DOM surface | injected `MutationObserver` deltas by region, in-page semantic snapshot (links, articles, videos, search, expand controls) |
16 +| Page classification | URL hints + landmarks + density, confidence, `UNKNOWN` never crashes |
17 +| Entities | URL-grammar extraction, network ↔ DOM merge with per-field provenance, agreements/conflicts report, identity-resolution skeleton |
18 +| Agent | semantic action vocabulary, information-gain scoring, lexical novelty, loop detector, heuristic planner + optional LLM planner (Anthropic or local OpenAI-compatible) |
19 +| Media | video detection (DOM `<video>` + manifests/segments + video entities), frame sampling at LEVEL ≥ 2 |
20 +| Learning | `data/platform_model/<platform>/` (response, action, page, media patterns), degradation signal, connector manifest compilation |
21 +| Storage | PostgreSQL (raw observations, entities, media, actions, schemas, feed items, relationships) + JSONL replay log |
22 +| Dashboard | `pnpm dashboard` → http://localhost:8340 (decisions, page state, entities, schemas, media, connector confidence, world model, raw events) |
23 +| Adapters | YouTube, Reddit (Phase 1). Facebook/Instagram/TikTok/X/LinkedIn/Threads: not yet |
24 +
25 +Read-only by construction: no like/follow/comment/share/subscribe code path exists.
26 +
27 +## Quick start
28 +
29 +```bash
30 +pnpm install
31 +cp .env.example .env # set SRC_DATABASE_URL (createdb social_runtime) — optional
32 +pnpm db:migrate # creates the tables
33 +
34 +pnpm login youtube # opens Chromium, log in by hand, press Enter → profile saved
35 +pnpm crawl youtube --query "intelligence artificielle Québec" --goal "Discover public Quebec creators discussing AI" --minutes 10
36 +pnpm dashboard # research console
37 +
38 +pnpm learn youtube --minutes 15 # PLATFORM LEARNING mode + connector compilation → connectors/youtube/manifest.json
39 +pnpm src status youtube # "Platform learned: page types / entity types / schemas / confidence"
40 +pnpm replay # list sessions ; pnpm replay <session_id> [--type ACTION_PLANNED]
41 +pnpm test && pnpm typecheck
42 +```
43 +
44 +Flags for `crawl`: `--mode research|observe|topic|profile|learn`, `--seed <url>`, `--minutes`, `--actions`, `--profiles`, `--posts`, `--videos`, `--media 0-4` (capture level, default 1), `--headless`, `--account <alias>`.
45 +
46 +LLM planner: set `SRC_LLM_PROVIDER=anthropic` (uses `ANTHROPIC_API_KEY` or an `ant auth login` profile, model `claude-opus-5`) or `SRC_LLM_PROVIDER=local` with `SRC_LOCAL_LLM_URL` / `SRC_LOCAL_LLM_MODEL` (e.g. llm-api.io on the cluster). The LLM is only consulted when the heuristic is unsure, only sees the compact page state, and can only pick an existing action id.
47 +
48 +## Layout
49 +
50 +```
51 +apps/worker CLI + crawl engine (observe → plan → act → learn loop) + action executor
52 +apps/api dashboard server (node:http) + public/index.html
53 +packages/shared types (platforms, surfaces, entities, actions, budgets), utils, logger, config
54 +packages/events Universal Social Event Format, typed bus, JSONL replay log
55 +packages/browser SocialBrowserSession (persistent profile), interactive login
56 +packages/observers network (NetworkObserver, ResponseClassifier, SchemaProfiler), dom (DomObserver, PageSummarizer), PageClassifier
57 +packages/entities surface merge with provenance, confidence, IdentityResolver
58 +packages/media video detection, frame sampler
59 +packages/agent WorldModel, InformationGain, LoopDetector, planners, LLM clients
60 +packages/platform-model learned platform knowledge + connector compiler
61 +packages/storage PostgreSQL store + schema.sql
62 +packages/connectors adapter interface, generic DOM→entity extraction, YouTube + Reddit adapters
63 +connectors/<platform>/ compiled (learned) manifests — generated, not hand-written truth
64 +data/ browser_profiles/ (never commit), sessions/<id>/events.jsonl, platform_model/, media/
65 +```
66 +
67 +## Boundaries (§4)
68 +
69 +Only information the authenticated account can see through normal use. No bypass of authentication, access controls, CAPTCHA, rate limits, private content or privacy settings. Credentials are never typed or stored by the crawler; the browser profile is the only persisted authentication state and lives outside git.
added apps/api/package.json +10 −0
@@ -0,0 +1,10 @@
1 +{
2 + "name": "@src/api",
3 + "version": "0.1.0",
4 + "private": true,
5 + "type": "module",
6 + "dependencies": {
7 + "@src/shared": "workspace:*",
8 + "@src/storage": "workspace:*"
9 + }
10 +}
added apps/api/public/index.html +146 −0
@@ -0,0 +1,146 @@
1 +<!doctype html>
2 +<html lang="en">
3 +<head>
4 +<meta charset="utf-8" />
5 +<title>Social Runtime Crawler — research console</title>
6 +<meta name="viewport" content="width=device-width, initial-scale=1" />
7 +<style>
8 + :root { --bg:#0b0d10; --panel:#12161b; --line:#1f262e; --fg:#d7dee6; --dim:#7f8b97; --acc:#4cc2ff; --ok:#41d18d; --warn:#ffb454; --bad:#ff6b6b; }
9 + * { box-sizing:border-box; }
10 + body { margin:0; background:var(--bg); color:var(--fg); font:13px/1.45 "SF Mono", ui-monospace, Menlo, monospace; }
11 + header { display:flex; gap:16px; align-items:center; padding:10px 16px; border-bottom:1px solid var(--line); position:sticky; top:0; background:var(--bg); z-index:2; }
12 + header h1 { font-size:14px; margin:0; letter-spacing:.04em; color:var(--acc); }
13 + header select, header button { background:var(--panel); color:var(--fg); border:1px solid var(--line); padding:5px 8px; font:inherit; border-radius:4px; }
14 + main { display:grid; grid-template-columns: repeat(12, 1fr); gap:10px; padding:10px 16px; }
15 + section { background:var(--panel); border:1px solid var(--line); border-radius:6px; min-height:120px; display:flex; flex-direction:column; overflow:hidden; }
16 + section h2 { margin:0; padding:7px 10px; font-size:11px; text-transform:uppercase; letter-spacing:.08em; color:var(--dim); border-bottom:1px solid var(--line); display:flex; justify-content:space-between; }
17 + section .body { padding:8px 10px; overflow:auto; max-height:420px; white-space:pre-wrap; word-break:break-word; }
18 + .c3 { grid-column: span 3; } .c4 { grid-column: span 4; } .c6 { grid-column: span 6; } .c8 { grid-column: span 8; } .c12 { grid-column: span 12; }
19 + table { width:100%; border-collapse:collapse; } td, th { padding:3px 6px; border-bottom:1px solid var(--line); text-align:left; vertical-align:top; } th { color:var(--dim); font-weight:normal; }
20 + .tag { display:inline-block; padding:0 6px; border-radius:3px; background:#1a222b; color:var(--acc); margin-right:4px; font-size:11px; }
21 + .ok { color:var(--ok);} .warn { color:var(--warn);} .bad { color:var(--bad);} .dim { color:var(--dim);}
22 + .kpi { display:flex; gap:18px; flex-wrap:wrap; } .kpi div b { display:block; font-size:20px; color:var(--fg); } .kpi div { color:var(--dim); }
23 + .bar { height:6px; background:#1a222b; border-radius:3px; overflow:hidden; } .bar i { display:block; height:100%; background:var(--acc); }
24 + a { color:var(--acc); text-decoration:none; }
25 + .frames img { height:70px; margin:2px; border:1px solid var(--line); border-radius:3px; }
26 +</style>
27 +</head>
28 +<body>
29 +<header>
30 + <h1>SOCIAL RUNTIME CRAWLER</h1>
31 + <select id="session"></select>
32 + <button id="refresh">refresh</button>
33 + <label><input type="checkbox" id="auto" checked /> auto (5 s)</label>
34 + <span id="status" class="dim"></span>
35 +</header>
36 +<main>
37 + <section class="c12"><h2>Session <span id="goal" class="dim"></span></h2><div class="body kpi" id="kpi"></div></section>
38 + <section class="c6"><h2>Agent decisions <span class="dim">why did it do that?</span></h2><div class="body" id="decisions"></div></section>
39 + <section class="c6"><h2>Current page state <span class="dim">semantic DOM</span></h2><div class="body" id="page"></div></section>
40 + <section class="c6"><h2>Visible / recent entities <span id="entcount" class="dim"></span></h2><div class="body" id="entities"></div></section>
41 + <section class="c6"><h2>Detected network schemas <span class="dim">runtime API discovery</span></h2><div class="body" id="schemas"></div></section>
42 + <section class="c4"><h2>Media queue</h2><div class="body" id="media"></div></section>
43 + <section class="c4"><h2>Connector confidence <span class="dim">learned platform model</span></h2><div class="body" id="model"></div></section>
44 + <section class="c4"><h2>World model</h2><div class="body" id="world"></div></section>
45 + <section class="c12"><h2>Recent observations <span class="dim">raw event stream</span></h2><div class="body" id="events"></div></section>
46 +</main>
47 +<script>
48 +const $ = (id) => document.getElementById(id);
49 +const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
50 +const api = (p) => fetch("/api" + p).then((r) => r.json());
51 +let current = null;
52 +
53 +async function loadSessions() {
54 + const sessions = await api("/sessions");
55 + const sel = $("session");
56 + const prev = sel.value;
57 + sel.innerHTML = sessions.map((s) => `<option value="${esc(s.session_id)}">${esc(s.session_id)} · ${esc(s.platform)} · ${esc(s.mode ?? "")} · ${esc(s.health ?? "")}</option>`).join("");
58 + if (prev && sessions.some((s) => s.session_id === prev)) sel.value = prev;
59 + current = sel.value || null;
60 +}
61 +
62 +async function render() {
63 + if (!current) return;
64 + $("status").textContent = "loading…";
65 + const [summary, actions, entities, schemas, media, events, world] = await Promise.all([
66 + api(`/sessions/${current}/summary`), api(`/sessions/${current}/actions`), api(`/sessions/${current}/entities`),
67 + api(`/sessions/${current}/schemas`), api(`/sessions/${current}/media`), api(`/sessions/${current}/events?limit=120`), api(`/sessions/${current}/world`),
68 + ]);
69 + const job = summary.job ?? {}; const sum = summary.summary;
70 + $("goal").textContent = `${job.platform ?? ""} · ${job.mode ?? ""} · ${job.goal ?? ""}`;
71 + const pages = events.filter((e) => e.event_type === "PAGE_OPENED");
72 + const last = pages[0];
73 + $("kpi").innerHTML = [
74 + ["steps", actions.length], ["entities", entities.length], ["schemas", schemas.length], ["media", media.length],
75 + ["events", events.length + (events.length >= 120 ? "+" : "")], ["status", sum ? `ended: ${sum.ended_because}` : "running"],
76 + ["planner", actions[actions.length - 1]?.planner ?? "—"],
77 + ].map(([k, v]) => `<div><b>${esc(v)}</b>${k}</div>`).join("");
78 +
79 + $("decisions").innerHTML = `<table><tr><th>#</th><th>action</th><th>gain</th><th>nov</th><th>rel</th><th>planner</th><th>reason</th></tr>` +
80 + actions.slice().reverse().slice(0, 40).map((a) => {
81 + const act = a.action ?? { type: a.action_type, label: a.label };
82 + const gain = a.expected_information_gain ?? a.expected_gain ?? 0;
83 + return `<tr><td>${a.step}</td><td><span class="tag">${esc(act.type)}</span>${esc((act.label || "").slice(0, 70))}</td><td>${Number(gain).toFixed(3)}</td><td>${Number(a.novelty ?? 0).toFixed(2)}</td><td>${Number(a.relevance ?? 0).toFixed(2)}</td><td>${esc(a.planner)}</td><td class="dim">${esc(a.reason)}</td></tr>`;
84 + }).join("") + `</table>`;
85 +
86 + if (last) {
87 + const p = last.payload;
88 + $("page").innerHTML = `<div><span class="tag">${esc(p.page_type)}</span> conf ${Number(p.confidence).toFixed(2)} · <a href="${esc(p.url)}" target="_blank">${esc((p.url || "").slice(0, 90))}</a></div>
89 + <div class="dim">entities ${p.entities} · dom ${p.dom_entities} · network ${p.network_entities} · both ${p.both_surfaces} · field agreements ${p.field_agreements} · conflicts ${(p.field_conflicts || []).length} · videos ${p.videos}</div>
90 + <div class="dim">signals: ${esc((p.signals || []).join(", "))}</div><hr style="border-color:var(--line)"/>${esc(p.summary)}`;
91 + } else $("page").textContent = "no page yet";
92 +
93 + $("entcount").textContent = `${entities.length}`;
94 + $("entities").innerHTML = `<table><tr><th>type</th><th>name</th><th>author</th><th>metrics</th><th>sources</th><th>conf</th></tr>` +
95 + entities.slice(0, 80).map((e) => {
96 + const prov = e.provenance || (e.fields ? Object.values(e.fields).flatMap((f) => f.provenance || []) : []);
97 + const surfaces = [...new Set(prov.map((p) => p.surface))];
98 + const conf = e.confidence ?? Math.max(0, ...prov.map((p) => p.confidence));
99 + return `<tr><td><span class="tag">${esc(e.type ?? e.entity_type)}</span></td><td>${e.url ? `<a href="${esc(e.url)}" target="_blank">` : ""}${esc((e.name || e.text || e.text_excerpt || e.platform_id || "").slice(0, 80))}${e.url ? "</a>" : ""}</td><td class="dim">${esc((e.author || "").slice(0, 30))}</td><td class="dim">${esc(JSON.stringify(e.metrics || {}))}</td><td>${surfaces.map((s) => `<span class="tag">${esc(s)}</span>`).join("")}</td><td>${Number(conf).toFixed(2)}</td></tr>`;
100 + }).join("") + `</table>`;
101 +
102 + $("schemas").innerHTML = `<table><tr><th>endpoint</th><th>shape</th><th>entities</th><th>seen</th></tr>` +
103 + schemas.slice(0, 60).map((s) => {
104 + const fp = s.fingerprint || {}; const sc = s.schema || {};
105 + const types = (sc.candidate_entity_types || []).map((c) => `${c.type} ${Number(c.confidence).toFixed(2)}`).join(", ");
106 + return `<tr><td>${esc(fp.method)} ${esc(fp.hostname)}<span class="dim">${esc(fp.path_pattern)}</span>${fp.graphql_operation ? ` <span class="tag">${esc(fp.graphql_operation)}</span>` : ""}</td><td class="dim">${esc((s.shape_hash || fp.response_shape_hash || "").slice(0, 10))}</td><td>${esc(types)}</td><td>${esc(s.observed_count ?? "")}</td></tr>`;
107 + }).join("") + `</table>`;
108 +
109 + $("media").innerHTML = media.length ? media.slice(0, 30).map((m) => `<div><span class="tag">${esc(m.media_type)}</span>${esc((m.title || m.platform_media_id || "").slice(0, 60))} <span class="dim">${m.duration_s ? Math.round(m.duration_s) + "s" : ""} ${m.width ? m.width + "×" + m.height : ""} ${m.delivery?.kind ?? ""}</span>${(m.frames || []).length ? `<div class="frames">${m.frames.map((f) => `<img src="/media/${esc(f.split("/media/")[1] || "")}" />`).join("")}</div>` : ""}</div>`).join("") : "<span class='dim'>no media yet</span>";
110 +
111 + if (job.platform) {
112 + const pm = await api(`/platform-model/${job.platform}`);
113 + if (pm && pm.response_patterns) {
114 + const rps = Object.values(pm.response_patterns).filter((r) => Object.keys(r.likely_entity_types || {}).length).sort((a, b) => b.confidence - a.confidence);
115 + const conf = rps.length ? rps.reduce((s, r) => s + r.confidence, 0) / rps.length : 0;
116 + $("model").innerHTML = `<div class="kpi"><div><b>${Math.round(conf * 100)}%</b>schema confidence</div><div><b>${Object.keys(pm.page_types || {}).length}</b>page types</div><div><b>${rps.length}</b>entity schemas</div><div><b>${(pm.sessions_learned || []).length}</b>sessions</div></div>
117 + ${rps.slice(0, 12).map((r) => `<div style="margin-top:6px"><span class="dim">${esc(r.hostname)}${esc(r.path_pattern)}</span> ${Object.keys(r.likely_entity_types).map((t) => `<span class="tag">${esc(t)}</span>`).join("")}<div class="bar"><i style="width:${Math.round(r.confidence * 100)}%"></i></div></div>`).join("")}`;
118 + } else $("model").textContent = "no learned model yet";
119 + }
120 +
121 + const nodes = world.nodes || []; const byType = {};
122 + for (const n of nodes) byType[n.type] = (byType[n.type] || 0) + 1;
123 + $("world").innerHTML = `<div class="kpi"><div><b>${nodes.length}</b>nodes</div><div><b>${(world.edges || []).length}</b>edges</div><div><b>${nodes.filter((n) => n.visited).length}</b>visited</div></div>
124 + <div style="margin-top:8px">${Object.entries(byType).map(([t, n]) => `<span class="tag">${esc(t)} ${n}</span>`).join(" ")}</div>
125 + <div class="dim" style="margin-top:8px">${nodes.filter((n) => (n.surfaces || []).length > 1).length} nodes confirmed by ≥2 surfaces</div>`;
126 +
127 + $("events").innerHTML = `<table>` + events.slice(0, 120).map((e) => {
128 + const p = e.payload || {}; let brief = "";
129 + if (e.event_type === "NETWORK_RESPONSE_OBSERVED") brief = `${p.kind} ${p.hostname}${p.path_pattern} · ${p.entity_count} entities · ${p.body_size} B`;
130 + else if (e.event_type === "DOM_CHANGED") brief = `+${p.added} −${p.removed} ${JSON.stringify(p.regions)}${p.modal_opened ? " modal" : ""}`;
131 + else if (/DISCOVERED|OBSERVED/.test(e.event_type) && p.entity) brief = `${p.entity.type}: ${(p.entity.name || p.entity.platform_id || "").slice(0, 70)}`;
132 + else if (e.event_type === "ACTION_PLANNED") brief = `${p.action?.type} · ${p.reason}`;
133 + else brief = JSON.stringify(p).slice(0, 140);
134 + const cls = /FAILED|DEGRADED|LOOP|AUTH|ERROR/.test(e.event_type) ? "bad" : /LEARNED|REPAIRED|SCHEMA/.test(e.event_type) ? "ok" : "";
135 + return `<tr><td class="dim">${esc((e.ts || e.timestamp || "").slice(11, 19))}</td><td>#${e.step ?? 0}</td><td class="${cls}">${esc(e.event_type)}</td><td class="dim">${esc(brief)}</td></tr>`;
136 + }).join("") + `</table>`;
137 + $("status").textContent = `updated ${new Date().toLocaleTimeString()}`;
138 +}
139 +
140 +$("session").addEventListener("change", () => { current = $("session").value; render(); });
141 +$("refresh").addEventListener("click", () => loadSessions().then(render));
142 +setInterval(() => { if ($("auto").checked) loadSessions().then(render); }, 5000);
143 +loadSessions().then(render);
144 +</script>
145 +</body>
146 +</html>
added apps/api/src/server.ts +109 −0
@@ -0,0 +1,109 @@
1 +import http from "node:http";
2 +import fs from "node:fs";
3 +import path from "node:path";
4 +import { fileURLToPath } from "node:url";
5 +import { createLogger, loadConfig } from "@src/shared";
6 +import { JsonlEventLog } from "@src/events";
7 +import { PostgresStore } from "@src/storage";
8 +
9 +/**
10 + * Research/Debug dashboard API (§53, §82). Zero framework: node:http + a static page.
11 + * Reads PostgreSQL when configured, else the JSONL session logs.
12 + */
13 +const log = createLogger("dashboard");
14 +const cfg = loadConfig();
15 +const here = path.dirname(fileURLToPath(import.meta.url));
16 +const publicDir = path.join(here, "..", "public");
17 +let store: PostgresStore | undefined;
18 +try {
19 + if (cfg.databaseUrl) store = await PostgresStore.connect(cfg.databaseUrl);
20 +} catch (err) {
21 + log.warn("no database — serving JSONL logs", { err: (err as Error).message });
22 +}
23 +
24 +function json(res: http.ServerResponse, data: unknown, status = 200) {
25 + res.writeHead(status, { "content-type": "application/json; charset=utf-8", "access-control-allow-origin": "*" });
26 + res.end(JSON.stringify(data));
27 +}
28 +
29 +function sessionDirs(): string[] {
30 + return fs.existsSync(cfg.sessionsDir) ? fs.readdirSync(cfg.sessionsDir).filter((d) => fs.existsSync(path.join(cfg.sessionsDir, d, "events.jsonl"))).sort().reverse() : [];
31 +}
32 +
33 +function jsonlSessions() {
34 + return sessionDirs().map((id) => {
35 + const job = readJson(path.join(cfg.sessionsDir, id, "job.json")) as Record<string, unknown> | undefined;
36 + const summary = readJson(path.join(cfg.sessionsDir, id, "summary.json")) as Record<string, unknown> | undefined;
37 + return { session_id: id, platform: job?.platform, mode: job?.mode, goal: job?.goal, account_alias: job?.account_alias, started_at: fs.statSync(path.join(cfg.sessionsDir, id)).birthtime, health: summary ? "stopped" : "running", actions: summary?.steps ?? null, entities: summary?.entities ?? null, ended_because: summary?.ended_because };
38 + });
39 +}
40 +
41 +function readJson(file: string): unknown {
42 + try {
43 + return JSON.parse(fs.readFileSync(file, "utf8"));
44 + } catch {
45 + return undefined;
46 + }
47 +}
48 +
49 +const server = http.createServer(async (req, res) => {
50 + const url = new URL(req.url ?? "/", "http://localhost");
51 + const parts = url.pathname.split("/").filter(Boolean);
52 + try {
53 + if (parts[0] === "api") {
54 + if (parts[1] === "sessions" && !parts[2]) return json(res, store ? await store.listSessions() : jsonlSessions());
55 + if (parts[1] === "sessions" && parts[2]) {
56 + const id = parts[2];
57 + const dir = path.join(cfg.sessionsDir, id);
58 + const sub = parts[3];
59 + if (sub === "events") {
60 + const types = url.searchParams.get("types")?.split(",").filter(Boolean);
61 + const limit = Number(url.searchParams.get("limit") ?? 300);
62 + if (store) return json(res, await store.sessionEvents(id, { types, limit }));
63 + const evs = JsonlEventLog.read(dir).filter((e) => !types?.length || types.includes(e.event_type));
64 + return json(res, evs.slice(-limit).reverse());
65 + }
66 + if (sub === "actions") return json(res, store ? await store.sessionActions(id) : JsonlEventLog.read(dir).filter((e) => e.event_type === "ACTION_PLANNED").map((e) => ({ step: e.step, ...(e.payload as Record<string, unknown>) })));
67 + if (sub === "entities") {
68 + if (store) return json(res, await store.sessionEntities(id));
69 + const seen = new Map<string, unknown>();
70 + for (const e of JsonlEventLog.read(dir)) if (/DISCOVERED|ENTITY_OBSERVED/.test(e.event_type) && (e.payload as { entity?: { fingerprint: string } }).entity) seen.set((e.payload as { entity: { fingerprint: string } }).entity.fingerprint, { ...(e.payload as { entity: object }).entity, last_step: e.step, provenance: e.provenance });
71 + return json(res, [...seen.values()].reverse());
72 + }
73 + if (sub === "media") return json(res, store ? await store.sessionMedia(id) : JsonlEventLog.read(dir).filter((e) => e.event_type === "MEDIA_DISCOVERED").map((e) => e.payload));
74 + if (sub === "world") return json(res, readJson(path.join(dir, "world_model.json")) ?? { nodes: [], edges: [] });
75 + if (sub === "summary") return json(res, { job: readJson(path.join(dir, "job.json")), summary: readJson(path.join(dir, "summary.json")) });
76 + if (sub === "schemas") {
77 + const platform = (readJson(path.join(dir, "job.json")) as { platform?: string } | undefined)?.platform;
78 + if (store) return json(res, await store.schemas(platform));
79 + return json(res, JsonlEventLog.read(dir).filter((e) => e.event_type === "NETWORK_SCHEMA_DISCOVERED").map((e) => e.payload));
80 + }
81 + }
82 + if (parts[1] === "platform-model" && parts[2]) {
83 + const f = path.join(cfg.platformModelDir, parts[2], "platform_model.json");
84 + return json(res, readJson(f) ?? { error: "no model yet" });
85 + }
86 + if (parts[1] === "entity" && parts[2]) return json(res, store ? await store.entity(decodeURIComponent(parts[2])) : { error: "needs database" });
87 + if (parts[1] === "stats") return json(res, store ? await store.entityStats() : []);
88 + return json(res, { error: "not found" }, 404);
89 + }
90 + if (parts[0] === "media") {
91 + // serve sampled frames
92 + const file = path.join(cfg.mediaDir, ...parts.slice(1));
93 + if (!file.startsWith(cfg.mediaDir) || !fs.existsSync(file)) return json(res, { error: "not found" }, 404);
94 + res.writeHead(200, { "content-type": "image/jpeg" });
95 + fs.createReadStream(file).pipe(res);
96 + return;
97 + }
98 + const file = path.join(publicDir, parts.length ? parts.join("/") : "index.html");
99 + if (!file.startsWith(publicDir) || !fs.existsSync(file)) return json(res, { error: "not found" }, 404);
100 + const ext = path.extname(file);
101 + res.writeHead(200, { "content-type": ext === ".html" ? "text/html; charset=utf-8" : ext === ".js" ? "text/javascript" : ext === ".css" ? "text/css" : "application/octet-stream" });
102 + fs.createReadStream(file).pipe(res);
103 + } catch (err) {
104 + log.error("request failed", { url: req.url, err: (err as Error).message });
105 + json(res, { error: (err as Error).message }, 500);
106 + }
107 +});
108 +
109 +server.listen(cfg.dashboardPort, () => log.info(`dashboard → http://localhost:${cfg.dashboardPort} (${store ? "postgres" : "jsonl"})`));
added apps/worker/package.json +19 −0
@@ -0,0 +1,19 @@
1 +{
2 + "name": "@src/worker",
3 + "version": "0.1.0",
4 + "private": true,
5 + "type": "module",
6 + "dependencies": {
7 + "@src/shared": "workspace:*",
8 + "@src/events": "workspace:*",
9 + "@src/browser": "workspace:*",
10 + "@src/observers": "workspace:*",
11 + "@src/entities": "workspace:*",
12 + "@src/media": "workspace:*",
13 + "@src/agent": "workspace:*",
14 + "@src/platform-model": "workspace:*",
15 + "@src/storage": "workspace:*",
16 + "@src/connectors": "workspace:*",
17 + "playwright": "^1.63.0"
18 + }
19 +}
added apps/worker/src/cli.ts +153 −0
@@ -0,0 +1,153 @@
1 +import fs from "node:fs";
2 +import path from "node:path";
3 +import { DEFAULT_BUDGET, isPlatform, loadConfig, newId, type AgentMode, type CrawlJob, type MediaLevel, type Platform } from "@src/shared";
4 +import { interactiveLogin } from "@src/browser";
5 +import { JsonlEventLog } from "@src/events";
6 +import { PlatformModel, compileConnector } from "@src/platform-model";
7 +import { CrawlEngine } from "./engine.ts";
8 +
9 +/**
10 + * social-runtime CLI (§74):
11 + * pnpm src login <platform> [--account alias]
12 + * pnpm src crawl <platform> --goal "…" [--mode research|observe|topic|profile|learn] [--query "…"] [--seed url] [--minutes 10] [--actions 60] [--media 2] [--headless]
13 + * pnpm src learn <platform> [--minutes 15] (PLATFORM LEARNING MODE + connector compilation)
14 + * pnpm src replay <session_id> [--type EVENT_TYPE] (inspect why the crawler did what it did)
15 + * pnpm src status <platform> (learned model summary)
16 + */
17 +const [, , cmd, ...rest] = process.argv;
18 +const args = parseArgs(rest);
19 +const cfg = loadConfig();
20 +
21 +function parseArgs(list: string[]): { positional: string[]; flags: Record<string, string | boolean> } {
22 + const positional: string[] = [];
23 + const flags: Record<string, string | boolean> = {};
24 + for (let i = 0; i < list.length; i++) {
25 + const a = list[i]!;
26 + if (a.startsWith("--")) {
27 + const key = a.slice(2);
28 + const next = list[i + 1];
29 + if (next !== undefined && !next.startsWith("--")) {
30 + flags[key] = next;
31 + i++;
32 + } else flags[key] = true;
33 + } else positional.push(a);
34 + }
35 + return { positional, flags };
36 +}
37 +
38 +function platformArg(): Platform {
39 + const p = args.positional[0];
40 + if (!p || !isPlatform(p)) {
41 + console.error(`Usage: ${cmd} <platform> — one of youtube, reddit, facebook, instagram, tiktok, x, linkedin, threads`);
42 + process.exit(2);
43 + }
44 + return p;
45 +}
46 +
47 +function str(k: string, d?: string): string | undefined {
48 + const v = args.flags[k];
49 + return typeof v === "string" ? v : d;
50 +}
51 +function num(k: string, d: number): number {
52 + const v = args.flags[k];
53 + return typeof v === "string" && !Number.isNaN(Number(v)) ? Number(v) : d;
54 +}
55 +
56 +async function main() {
57 + switch (cmd) {
58 + case "login": {
59 + const platform = platformArg();
60 + await interactiveLogin({ platform, accountAlias: str("account", "research-01")!, profilesDir: cfg.profilesDir, channel: cfg.browserChannel });
61 + return;
62 + }
63 + case "crawl":
64 + case "learn": {
65 + const platform = platformArg();
66 + const isLearn = cmd === "learn";
67 + const mode = (str("mode", isLearn ? "learn" : "research") as AgentMode) ?? "research";
68 + const query = str("query") ?? str("q");
69 + const goal = str("goal") ?? (isLearn ? `Learn how ${platform} surfaces content: page types, entity structures, response schemas, navigation` : query ? `Discover public content about “${query}”` : `Observe the ${platform} feed`);
70 + if (args.flags.headless) cfg.headless = true;
71 + const job: CrawlJob = {
72 + job_id: newId("job"),
73 + platform,
74 + account_alias: str("account", "research-01")!,
75 + mode,
76 + goal,
77 + query,
78 + seed_url: str("seed"),
79 + budget: { ...DEFAULT_BUDGET, max_minutes: num("minutes", isLearn ? 15 : DEFAULT_BUDGET.max_minutes), max_actions: num("actions", isLearn ? 80 : DEFAULT_BUDGET.max_actions), max_profiles: num("profiles", DEFAULT_BUDGET.max_profiles), max_posts: num("posts", DEFAULT_BUDGET.max_posts), max_videos: num("videos", DEFAULT_BUDGET.max_videos), max_depth: num("depth", DEFAULT_BUDGET.max_depth) },
80 + media_level: Math.max(0, Math.min(4, num("media", 1))) as MediaLevel,
81 + stay_on_platform: true,
82 + read_only: true,
83 + };
84 + const engine = new CrawlEngine(cfg, job);
85 + const summary = await engine.run();
86 + printSummary(summary);
87 + return;
88 + }
89 + case "status": {
90 + const platform = platformArg();
91 + const model = new PlatformModel(platform, cfg.platformModelDir);
92 + const s = model.summary();
93 + console.log(renderLearned(s));
94 + if (args.flags.compile) console.log("compiled →", compileConnector(model, path.join(process.cwd(), "connectors", platform)));
95 + return;
96 + }
97 + case "replay": {
98 + const id = args.positional[0];
99 + if (!id) {
100 + const dirs = fs.existsSync(cfg.sessionsDir) ? fs.readdirSync(cfg.sessionsDir).sort() : [];
101 + console.log("sessions:\n" + dirs.map((d) => " " + d).join("\n"));
102 + return;
103 + }
104 + const events = JsonlEventLog.read(path.join(cfg.sessionsDir, id));
105 + const type = str("type");
106 + for (const ev of events) {
107 + if (type && ev.event_type !== type) continue;
108 + if (["ACTION_PLANNED", "ACTION_EXECUTED", "ACTION_FAILED", "PAGE_OPENED", "NETWORK_SCHEMA_DISCOVERED", "CONNECTOR_PATTERN_LEARNED", "CONNECTOR_DEGRADED", "LOOP_DETECTED", "AUTH_REQUIRED", "BUDGET_EXHAUSTED", "MEDIA_DISCOVERED"].includes(ev.event_type) || type) {
109 + const p = ev.payload as Record<string, unknown>;
110 + const brief = ev.event_type === "ACTION_PLANNED" ? `${(p.action as { type: string; label: string }).type} — ${(p.action as { label: string }).label} | gain=${Number(p.expected_information_gain).toFixed(3)} | ${p.planner} | ${p.reason}` : ev.event_type === "PAGE_OPENED" ? `${p.page_type} (${Number(p.confidence ?? 0).toFixed(2)}) ${p.url} · entities=${p.entities} dom=${p.dom_entities} net=${p.network_entities} both=${p.both_surfaces}` : ev.event_type === "NETWORK_SCHEMA_DISCOVERED" ? `${(p.fingerprint as { hostname: string; path_pattern: string }).hostname}${(p.fingerprint as { path_pattern: string }).path_pattern} → ${JSON.stringify((p.schema as { candidate_entity_types: unknown }).candidate_entity_types)}` : JSON.stringify(p).slice(0, 220);
111 + console.log(`${ev.timestamp.slice(11, 19)} #${ev.step ?? 0} ${ev.event_type.padEnd(26)} ${brief}`);
112 + }
113 + }
114 + return;
115 + }
116 + default:
117 + console.log(`social-runtime — Social Runtime Crawler
118 +
119 + pnpm src login <platform> [--account alias]
120 + pnpm src crawl <platform> --goal "…" [--query "…"] [--mode research|observe|topic|profile|learn] [--seed url] [--minutes 10] [--actions 60] [--media 0-4] [--headless]
121 + pnpm src learn <platform> [--minutes 15]
122 + pnpm src status <platform> [--compile]
123 + pnpm src replay [session_id] [--type EVENT_TYPE]
124 + pnpm dashboard → http://localhost:${cfg.dashboardPort}
125 +`);
126 + }
127 +}
128 +
129 +function printSummary(s: Awaited<ReturnType<CrawlEngine["run"]>>) {
130 + console.log(`
131 +Session ${s.session_id} (${s.platform}) — ended: ${s.ended_because}
132 + steps: ${s.steps} entities: ${s.entities} videos: ${s.videos}
133 + network responses: ${s.network_responses} distinct schemas: ${s.schemas} patterns learned: ${s.patterns_learned}
134 + world: ${JSON.stringify(s.world.by_type)} multi-surface: ${s.world.multi_surface}
135 +${renderLearned(s.connector)}
136 + log: ${s.session_dir}/events.jsonl
137 +`);
138 +}
139 +
140 +function renderLearned(s: ReturnType<PlatformModel["summary"]>): string {
141 + return `Platform ${s.platform} learned (${s.sessions} session${s.sessions === 1 ? "" : "s"})
142 + Page types: ${s.page_types}
143 + Entity types: ${s.entity_types}
144 + Navigation actions: ${s.navigation_actions}
145 + Network schemas: ${s.network_schemas}
146 + Media patterns: ${s.media_patterns}
147 + Confidence: ${s.confidence}%`;
148 +}
149 +
150 +main().catch((err) => {
151 + console.error(err);
152 + process.exit(1);
153 +});
added apps/worker/src/engine.ts +320 −0
@@ -0,0 +1,320 @@
1 +import fs from "node:fs";
2 +import path from "node:path";
3 +import {
4 + createLogger,
5 + nowIso,
6 + truncate,
7 + type AgentDecision,
8 + type AppConfig,
9 + type CrawlJob,
10 + type ObservedEntity,
11 + type PageState,
12 + type Platform,
13 +} from "@src/shared";
14 +import { EventBus, JsonlEventLog } from "@src/events";
15 +import { SocialBrowserSession } from "@src/browser";
16 +import { NetworkObserver, DomObserver, snapshotDom, classifyPage, type ClassifiedResponse } from "@src/observers";
17 +import { mergeSurfaces } from "@src/entities";
18 +import { detectVideos, sampleVideoFrames } from "@src/media";
19 +import { WorldModel, LoopDetector, HeuristicPlanner, LlmPlanner, createLlmClient, type Planner, type GainContext } from "@src/agent";
20 +import { PlatformModel, compileConnector } from "@src/platform-model";
21 +import { openStore, type PostgresStore } from "@src/storage";
22 +import { getAdapter, entitiesFromDom, buildActions, pageFingerprint, type SocialPlatformAdapter } from "@src/connectors";
23 +import { ActionExecutor } from "./executor.ts";
24 +
25 +const log = createLogger("engine");
26 +
27 +export interface CrawlSummary {
28 + session_id: string;
29 + platform: Platform;
30 + steps: number;
31 + entities: number;
32 + videos: number;
33 + network_responses: number;
34 + schemas: number;
35 + patterns_learned: number;
36 + ended_because: string;
37 + world: ReturnType<WorldModel["stats"]>;
38 + connector: ReturnType<PlatformModel["summary"]>;
39 + session_dir: string;
40 +}
41 +
42 +/**
43 + * The crawl loop (§2): observe → summarize → plan → act → learn, until the budget or the goal ends it.
44 + * Every subsystem is a separate package; this file only orchestrates.
45 + */
46 +export class CrawlEngine {
47 + private readonly bus = new EventBus();
48 + private readonly world = new WorldModel();
49 + private readonly loops = new LoopDetector();
50 + private readonly adapter: SocialPlatformAdapter;
51 + private readonly model: PlatformModel;
52 + private session!: SocialBrowserSession;
53 + private net!: NetworkObserver;
54 + private dom!: DomObserver;
55 + private store?: PostgresStore;
56 + private eventLog!: JsonlEventLog;
57 + private planner!: Planner;
58 + private executor!: ActionExecutor;
59 + private sessionDir!: string;
60 + private step = 0;
61 + private stepsWithoutNew = 0;
62 + private counts = { entities: 0, videos: 0, profiles: 0, posts: 0, videos_seen: 0, profiles_seen: 0, posts_seen: 0, patterns: 0, schemas: 0 };
63 + private degraded = false;
64 +
65 + constructor(private readonly cfg: AppConfig, private readonly job: CrawlJob) {
66 + this.adapter = getAdapter(job.platform);
67 + this.model = new PlatformModel(job.platform, cfg.platformModelDir);
68 + }
69 +
70 + async run(): Promise<CrawlSummary> {
71 + const { job, cfg } = this;
72 + this.session = new SocialBrowserSession({ platform: job.platform, accountAlias: job.account_alias, profilesDir: cfg.profilesDir, headless: cfg.headless, channel: cfg.browserChannel, bus: this.bus });
73 + this.sessionDir = path.join(cfg.sessionsDir, this.session.id);
74 + this.eventLog = new JsonlEventLog(this.sessionDir);
75 + this.eventLog.attach(this.bus);
76 + this.store = await openStore(cfg.databaseUrl);
77 + this.store?.attach(this.bus);
78 + fs.writeFileSync(path.join(this.sessionDir, "job.json"), JSON.stringify({ ...job, session_id: this.session.id }, null, 2));
79 +
80 + if (!this.session.hasProfile()) log.warn(`No saved browser profile for ${job.platform}/${job.account_alias}. Run: pnpm login ${job.platform} --account ${job.account_alias}`);
81 +
82 + const llm = createLlmClient(cfg);
83 + this.planner = llm ? new LlmPlanner(llm, new HeuristicPlanner(), job.budget.max_llm_tokens) : new HeuristicPlanner();
84 + log.info("planner", { planner: this.planner.name, mode: job.mode, goal: job.goal });
85 +
86 + await this.session.start();
87 + // SESSION_STARTED is emitted by the session; enrich the DB row with mode/goal.
88 + this.bus.emit({ event_type: "PAGE_OPENED", platform: job.platform, session_id: this.session.id, step: 0, payload: { url: "about:blank", mode: job.mode, goal: job.goal, budget: job.budget } });
89 + const page = this.session.getPage();
90 + this.net = new NetworkObserver({ page, bus: this.bus, sessionId: this.session.id, platform: job.platform, hints: this.adapter.classifierHints });
91 + this.net.attach();
92 + this.dom = new DomObserver({ page, bus: this.bus, sessionId: this.session.id, platform: job.platform });
93 + await this.dom.attach();
94 + this.executor = new ActionExecutor(page, this.adapter, { stayOnPlatform: job.stay_on_platform });
95 +
96 + const started = Date.now();
97 + let endedBecause = "unknown";
98 + try {
99 + // Seed navigation
100 + this.net.beginStep(0, "seed");
101 + this.dom.beginStep(0);
102 + const seed = job.seed_url ?? (job.query && job.mode !== "observe" ? this.adapter.searchUrl(job.query) : this.adapter.homeUrl);
103 + await this.session.navigate(seed);
104 + await page.waitForLoadState("networkidle", { timeout: 6000 }).catch(() => {});
105 + await this.adapter.dismissOverlays?.(page).catch(() => {});
106 + let state = await this.observe(await this.net.collectStep(), "seed");
107 + if (state.classification.page_type === "LOGIN") {
108 + this.session.setHealth("auth_required");
109 + this.bus.emit({ event_type: "AUTH_REQUIRED", platform: job.platform, session_id: this.session.id, step: 0, payload: { url: state.url, hint: `pnpm login ${job.platform} --account ${job.account_alias}` } });
110 + endedBecause = "auth_required";
111 + return this.finish(endedBecause, started);
112 + }
113 +
114 + while (true) {
115 + const elapsedMin = (Date.now() - started) / 60_000;
116 + const b = job.budget;
117 + if (this.step >= b.max_actions) { endedBecause = "max_actions"; break; }
118 + if (elapsedMin >= b.max_minutes) { endedBecause = "max_minutes"; break; }
119 + if (this.counts.profiles >= b.max_profiles) { endedBecause = "max_profiles"; break; }
120 + if (this.counts.posts >= b.max_posts) { endedBecause = "max_posts"; break; }
121 + if (this.counts.videos >= b.max_videos) { endedBecause = "max_videos"; break; }
122 + if (this.session.getInfo().health === "crashed") { endedBecause = "browser_crashed"; break; }
123 +
124 + this.step++;
125 + const decision = await this.plan(state);
126 + this.bus.emit({ event_type: "ACTION_PLANNED", platform: job.platform, session_id: this.session.id, step: this.step, payload: { action: decision.chosen_action, expected_information_gain: decision.expected_information_gain, novelty: decision.novelty, relevance: decision.relevance, reason: decision.reason, planner: decision.planner, top_scores: decision.scores.slice(0, 5) } });
127 + log.info(`step ${this.step}: ${decision.chosen_action.type} — ${truncate(decision.chosen_action.label, 80)}`, { gain: decision.expected_information_gain.toFixed(3), planner: decision.planner, reason: truncate(decision.reason, 100) });
128 + if (decision.chosen_action.type === "END_SESSION") { endedBecause = "agent_ended"; break; }
129 +
130 + const before = { url: state.url, page_type: state.classification.page_type, visible_entities: state.entities.length, world_nodes: this.world.nodes.size };
131 + this.net.beginStep(this.step, decision.chosen_action.id);
132 + this.dom.beginStep(this.step);
133 + const target = decision.chosen_action.target_ref ? state.entities.find((e) => e.ref === decision.chosen_action.target_ref) : undefined;
134 + const result = await this.executor.execute(decision.chosen_action, { entityUrl: target?.url });
135 + this.session.recordAction(decision.chosen_action.type, result.navigated && /^OPEN_/.test(decision.chosen_action.type) ? 1 : decision.chosen_action.type === "BACK" ? -1 : 0);
136 + if (target) this.world.markVisited(target.fingerprint, target.url);
137 + if (result.navigated) this.bus.emit({ event_type: "NAVIGATION_COMPLETED", platform: job.platform, session_id: this.session.id, step: this.step, payload: { from: result.url_before, to: result.url_after, action: decision.chosen_action.type, duration_ms: result.duration_ms } });
138 +
139 + const responses = await this.net.collectStep();
140 + const domDeltas = this.dom.collectStep();
141 + const prevState = state;
142 + state = await this.observe(responses, decision.chosen_action.id, target?.fingerprint);
143 + const newEntities = this.world.observe(state.entities, this.step, target?.fingerprint);
144 + this.stepsWithoutNew = newEntities.length ? 0 : this.stepsWithoutNew + 1;
145 + this.tally(newEntities);
146 + if (result.ok && result.navigated) this.tallyVisit(decision.chosen_action.type);
147 +
148 + const after = { url: state.url, page_type: state.classification.page_type, visible_entities: state.entities.length, new_entities: newEntities.length, network_responses: responses.length, dom_nodes_added: domDeltas.reduce((s, d) => s + d.added, 0), world_nodes: this.world.nodes.size };
149 + this.bus.emit({ event_type: result.ok ? "ACTION_EXECUTED" : "ACTION_FAILED", platform: job.platform, session_id: this.session.id, step: this.step, payload: { action: decision.chosen_action, ok: result.ok, error: result.error, before, after, duration_ms: result.duration_ms }, discovered_via: { action_id: decision.chosen_action.id } });
150 + await this.store?.recordAction(this.session.id, decision, before, after, result.ok, result.error, result.duration_ms).catch((e) => log.debug("recordAction failed", { err: (e as Error).message }));
151 + await this.persistStep(state, newEntities, responses);
152 +
153 + // Learning (§30, §71)
154 + const outcome = this.model.learnStep({ step: this.step, action_type: decision.chosen_action.type, action_id: decision.chosen_action.id, from_page_type: prevState.classification.page_type, to_page_type: state.classification.page_type, to_url: state.url, page_confidence: state.classification.confidence, entities_total: state.entities.length, new_entities: newEntities.length, entity_types: countTypes(state.entities), responses: responses.map((r) => ({ fingerprint: r.fingerprint, entity_count: r.entities.length, kind: r.kind, url: r.response.url, fields: r.schema?.fields.filter((f) => f.semantic[0]?.kind !== "unknown").slice(0, 60).map((f) => ({ path: f.path, semantic: f.semantic[0], example: f.examples[0] })) })), failed: !result.ok }, this.session.id);
155 + if (outcome.newPatterns.length) {
156 + this.counts.patterns += outcome.newPatterns.length;
157 + this.model.announce(this.bus, this.session.id, this.step, outcome.newPatterns);
158 + }
159 + this.model.save();
160 +
161 + // Degradation / self-healing signal (§32)
162 + const degradedNow = this.model.isDegraded(state.classification.page_type, state.entities.length, this.adapter.expectedEntities(state.classification.page_type));
163 + if (degradedNow && !this.degraded) {
164 + this.degraded = true;
165 + this.bus.emit({ event_type: "CONNECTOR_DEGRADED", platform: job.platform, session_id: this.session.id, step: this.step, payload: { page_type: state.classification.page_type, expected: this.adapter.expectedEntities(state.classification.page_type), observed: 0, url: state.url, action: "switching to exploration heuristics" } });
166 + } else if (!degradedNow && this.degraded && state.entities.length > 0) {
167 + this.degraded = false;
168 + this.bus.emit({ event_type: "CONNECTOR_REPAIRED", platform: job.platform, session_id: this.session.id, step: this.step, payload: { page_type: state.classification.page_type, observed: state.entities.length } });
169 + }
170 +
171 + // Loop safety (§41)
172 + this.loops.record(state.fingerprint, `${decision.chosen_action.type}:${decision.chosen_action.target_url ?? ""}`);
173 + const loop = this.loops.detect();
174 + if (loop.loop) {
175 + this.bus.emit({ event_type: "LOOP_DETECTED", platform: job.platform, session_id: this.session.id, step: this.step, payload: { pattern: loop.pattern, url: state.url } });
176 + log.warn("navigation loop detected — breaking out", { pattern: loop.pattern });
177 + await this.executor.execute({ id: "loopbreak", type: "RETURN_TO_FEED", label: "loop break", cost: 1, target_url: job.query ? this.adapter.searchUrl(job.query) : this.adapter.homeUrl }, {});
178 + state = await this.observe(await this.net.collectStep(), "loopbreak");
179 + }
180 + if (state.classification.page_type === "LOGIN") {
181 + this.session.setHealth("auth_required");
182 + this.bus.emit({ event_type: "AUTH_REQUIRED", platform: job.platform, session_id: this.session.id, step: this.step, payload: { url: state.url } });
183 + endedBecause = "auth_required";
184 + break;
185 + }
186 + if (this.stepsWithoutNew >= 10) { endedBecause = "no_new_information"; break; }
187 + }
188 + } catch (err) {
189 + endedBecause = `error: ${(err as Error).message.split("\n")[0]}`;
190 + log.error("engine error", { err: (err as Error).stack?.split("\n").slice(0, 3).join(" | ") });
191 + this.bus.emit({ event_type: "WORKER_ERROR", platform: job.platform, session_id: this.session.id, step: this.step, payload: { error: (err as Error).message } });
192 + }
193 + return this.finish(endedBecause, started);
194 + }
195 +
196 + /** Observe the current page across surfaces and build the compact PageState (§9, §14). */
197 + private async observe(responses: ClassifiedResponse[], via: string, discoveredFrom?: string): Promise<PageState> {
198 + const page = this.session.getPage();
199 + const snapshot = await snapshotDom(page).catch((err: Error) => {
200 + log.warn("dom snapshot failed", { err: err.message.split("\n")[0] });
201 + return undefined;
202 + });
203 + if (!snapshot) {
204 + return { url: page.url(), title: "", platform: this.job.platform, classification: { page_type: "UNKNOWN", confidence: 0.1, signals: ["snapshot failed"] }, entities: [], actions: [{ id: "A1", type: "WAIT_FOR_CONTENT", label: "Wait for content", cost: 1 }, { id: "A2", type: "RETURN_TO_FEED", label: "Return to feed", cost: 2, target_url: this.adapter.homeUrl }, { id: "A3", type: "END_SESSION", label: "End", cost: 0.5 }], media: [], summary_text: "", fingerprint: pageFingerprint(page.url(), []), captured_at: nowIso() };
205 + }
206 + const classification = classifyPage(snapshot, this.adapter.pageTypeHints);
207 + const domEntities = entitiesFromDom(snapshot, this.adapter);
208 + const netEntities = responses.flatMap((r) => r.entities);
209 + const merged = mergeSurfaces(netEntities, domEntities);
210 + // Keep the planner's list focused: DOM-visible entities first, then network-only ones that have a page URL.
211 + const entities = merged.merged.filter((e) => e.provenance.some((p) => p.surface === "dom") || e.url).slice(0, 80);
212 + const media = detectVideos({ platform: this.job.platform, snapshot, entities, responses, pageUrl: snapshot.url });
213 + const visited = new Set([...this.world.nodes.values()].filter((n) => n.visited).map((n) => n.fingerprint));
214 + const actions = buildActions(entities, snapshot, this.adapter, { query: this.job.query, visited });
215 + const summary = renderSummary(snapshot.title, classification.page_type, entities, actions);
216 + const state: PageState = { url: snapshot.url, title: snapshot.title, platform: this.job.platform, classification, entities, actions, media, summary_text: summary, fingerprint: pageFingerprint(snapshot.url, entities), captured_at: nowIso() };
217 +
218 + this.bus.emit({ event_type: "PAGE_OPENED", platform: this.job.platform, session_id: this.session.id, step: this.step, payload: { url: snapshot.url, title: snapshot.title, page_type: classification.page_type, confidence: classification.confidence, signals: classification.signals, entities: entities.length, dom_entities: domEntities.length, network_entities: netEntities.length, both_surfaces: merged.both, field_agreements: merged.field_agreements, field_conflicts: merged.field_conflicts.slice(0, 5), videos: media.length, actions: actions.length, summary }, provenance: [{ surface: "dom", confidence: classification.confidence }], discovered_via: { action_id: via } });
219 + this.bus.emit({ event_type: "PAGE_CLASSIFIED", platform: this.job.platform, session_id: this.session.id, step: this.step, payload: { url: snapshot.url, ...classification } });
220 + for (const e of entities) {
221 + const type = e.type === "video" ? "VIDEO_DISCOVERED" : e.type === "post" ? "POST_DISCOVERED" : e.type === "comment" ? "COMMENT_DISCOVERED" : e.type === "profile" || e.type === "channel" || e.type === "person" ? "PROFILE_DISCOVERED" : "ENTITY_OBSERVED";
222 + const known = this.world.nodes.has(e.fingerprint);
223 + this.bus.emit({ event_type: known ? "ENTITY_OBSERVED" : type, platform: this.job.platform, session_id: this.session.id, step: this.step, payload: { entity: { type: e.type, platform_id: e.platform_id, url: e.url, name: e.name, text: e.text, author: e.author, metrics: e.metrics, media: e.media, context: e.context, fingerprint: e.fingerprint, canonical_id: null }, fields: e.fields }, provenance: e.provenance, discovered_via: { action_id: via, url: snapshot.url } });
224 + }
225 + for (const m of media) if (m.width || m.duration_s || m.delivery) this.bus.emit({ event_type: "MEDIA_DISCOVERED", platform: this.job.platform, session_id: this.session.id, step: this.step, payload: { ...m } as Record<string, unknown>, provenance: m.provenance, discovered_via: { action_id: via } });
226 + if (this.step === 0) this.world.observe(entities, 0, discoveredFrom);
227 + return state;
228 + }
229 +
230 + private async plan(state: PageState): Promise<AgentDecision> {
231 + // Relevance is measured against the goal *and* the topic query (the query carries the domain words).
232 + const goal = this.job.query ? `${this.job.goal} ${this.job.query}` : this.job.goal;
233 + const ctx: GainContext = { goal, mode: this.job.mode, world: this.world, state, recentActionTypes: this.loops.recentActionTypes(), recentUrls: [...this.world.visitedUrls].slice(-10), stepsWithoutNewEntities: this.stepsWithoutNew, learnedYield: this.model.learnedYield() };
234 + return this.planner.plan(ctx, this.step);
235 + }
236 +
237 + /** Discovery counters (what we have seen). */
238 + private tally(fresh: ObservedEntity[]): void {
239 + for (const e of fresh) {
240 + this.counts.entities++;
241 + if (e.type === "video") this.counts.videos_seen++;
242 + else if (e.type === "post" || e.type === "comment") this.counts.posts_seen++;
243 + else if (e.type === "profile" || e.type === "channel" || e.type === "person" || e.type === "page") this.counts.profiles_seen++;
244 + }
245 + }
246 +
247 + /** Budget counters (§40) count pages we actually opened, not entities merely seen in a feed. */
248 + private tallyVisit(actionType: string): void {
249 + if (/^OPEN_(CHANNEL|PROFILE|PAGE)$/.test(actionType)) this.counts.profiles++;
250 + else if (/^OPEN_(POST|COMMENTS|ENTITY)$/.test(actionType)) this.counts.posts++;
251 + else if (actionType === "OPEN_VIDEO") this.counts.videos++;
252 + }
253 +
254 + private async persistStep(state: PageState, fresh: ObservedEntity[], responses: ClassifiedResponse[]): Promise<void> {
255 + const ts = nowIso();
256 + const frames: Record<string, string[]> = {};
257 + if (this.job.media_level >= 2) {
258 + const current = state.media.find((m) => m.width || m.delivery?.hostnames.length);
259 + if (current) {
260 + const files = await sampleVideoFrames(this.session.getPage(), current, this.cfg.mediaDir, this.job.media_level).catch(() => []);
261 + if (files.length) {
262 + frames[current.fingerprint] = files;
263 + this.bus.emit({ event_type: "VIDEO_DISCOVERED", platform: this.job.platform, session_id: this.session.id, step: this.step, payload: { fingerprint: current.fingerprint, title: current.title, frames: files, level: this.job.media_level }, provenance: [{ surface: "visual", confidence: 0.8 }] });
264 + }
265 + }
266 + }
267 + if (!this.store) return;
268 + try {
269 + await this.store.upsertEntities(state.entities, this.session.id, this.step, ts);
270 + await this.store.upsertMedia(state.media, frames, ts);
271 + await this.store.recordFeedItems(this.session.id, this.step, state, ts);
272 + const recentEdges = this.world.edges.filter((e) => e.step === this.step);
273 + await this.store.recordRelationships(this.session.id, recentEdges);
274 + } catch (err) {
275 + log.debug("persist failed", { err: (err as Error).message, fresh: fresh.length, responses: responses.length });
276 + }
277 + }
278 +
279 + private async finish(endedBecause: string, started: number): Promise<CrawlSummary> {
280 + this.model.save();
281 + const connector = this.model.summary();
282 + if (connector.confidence >= 40) {
283 + const manifest = compileConnector(this.model, path.join(process.cwd(), "connectors", this.job.platform));
284 + await this.store?.recordConnectorVersion(this.job.platform, connector, manifest).catch(() => {});
285 + }
286 + const world = this.world.stats();
287 + fs.writeFileSync(path.join(this.sessionDir, "world_model.json"), JSON.stringify(this.world.toJSON(), null, 2));
288 + const summary: CrawlSummary = { session_id: this.session.id, platform: this.job.platform, steps: this.step, entities: this.world.nodes.size, videos: this.counts.videos_seen, network_responses: this.net?.totalResponses ?? 0, schemas: this.net?.seenShapeHashes.size ?? 0, patterns_learned: this.counts.patterns, ended_because: endedBecause, world, connector, session_dir: this.sessionDir };
289 + this.bus.emit({ event_type: "BUDGET_EXHAUSTED", platform: this.job.platform, session_id: this.session.id, step: this.step, payload: { reason: endedBecause, elapsed_ms: Date.now() - started } });
290 + await this.session.stop();
291 + await this.bus.flush();
292 + fs.writeFileSync(path.join(this.sessionDir, "summary.json"), JSON.stringify(summary, null, 2));
293 + await this.eventLog.close();
294 + await this.store?.close();
295 + return summary;
296 + }
297 +}
298 +
299 +function countTypes(entities: ObservedEntity[]): Record<string, number> {
300 + const out: Record<string, number> = {};
301 + for (const e of entities) out[e.type] = (out[e.type] ?? 0) + 1;
302 + return out;
303 +}
304 +
305 +/** Textual semantic page representation (§14) — what a planner (or a human in the dashboard) reads. */
306 +export function renderSummary(title: string, pageType: string, entities: ObservedEntity[], actions: PageState["actions"]): string {
307 + const lines = [`PAGE: ${pageType} — ${truncate(title, 90)}`, "", "VISIBLE ENTITIES"];
308 + for (const e of entities.slice(0, 25)) {
309 + lines.push(`[${e.ref}] ${e.type}${e.context ? ` (${e.context})` : ""}`);
310 + if (e.name) lines.push(` Name: ${truncate(e.name, 100)}`);
311 + if (e.author) lines.push(` Author: ${truncate(e.author, 60)}`);
312 + if (e.media?.has_video) lines.push(` Video: yes${e.media.duration_s ? ` (${Math.round(e.media.duration_s)}s)` : ""}`);
313 + if (e.metrics && Object.keys(e.metrics).length) lines.push(` Metrics: ${Object.entries(e.metrics).map(([k, v]) => `${k}=${v}`).join(", ")}`);
314 + lines.push(` Sources: ${[...new Set(e.provenance.map((p) => p.surface))].join("+")}`);
315 + }
316 + if (entities.length > 25) lines.push(`… ${entities.length - 25} more`);
317 + lines.push("", "ACTIONS");
318 + for (const a of actions) lines.push(`[${a.id}] ${a.label}`);
319 + return lines.join("\n");
320 +}
added apps/worker/src/executor.ts +0 −0

Binary file not shown.

added connectors/youtube/manifest.json +165 −0
@@ -0,0 +1,165 @@
1 +{
2 + "platform": "youtube",
3 + "compiled_at": "2026-09-11T21:32:58.338Z",
4 + "learned_from_sessions": 4,
5 + "confidence": 45,
6 + "page_types": [
7 + {
8 + "page_type": "UNKNOWN",
9 + "url_patterns": [
10 + "/",
11 + "/results"
12 + ],
13 + "avg_entities": 0
14 + },
15 + {
16 + "page_type": "CHANNEL",
17 + "url_patterns": [
18 + "/@Mila-Quebec-AI-Institute"
19 + ],
20 + "avg_entities": 16
21 + },
22 + {
23 + "page_type": "SEARCH_RESULTS",
24 + "url_patterns": [
25 + "/results"
26 + ],
27 + "avg_entities": 29
28 + },
29 + {
30 + "page_type": "VIDEO_DETAIL",
31 + "url_patterns": [
32 + "/watch"
33 + ],
34 + "avg_entities": 39
35 + }
36 + ],
37 + "network_patterns": [],
38 + "navigation": [
39 + {
40 + "action": "WAIT_FOR_CONTENT",
41 + "from": "UNKNOWN",
42 + "to": {
43 + "UNKNOWN": 5
44 + },
45 + "avg_new_entities": 0,
46 + "observed": 5
47 + },
48 + {
49 + "action": "RETURN_TO_FEED",
50 + "from": "UNKNOWN",
51 + "to": {
52 + "UNKNOWN": 2
53 + },
54 + "avg_new_entities": 0,
55 + "observed": 2
56 + },
57 + {
58 + "action": "OPEN_CHANNEL",
59 + "from": "SEARCH_RESULTS",
60 + "to": {
61 + "CHANNEL": 1
62 + },
63 + "avg_new_entities": 14,
64 + "observed": 1
65 + },
66 + {
67 + "action": "SEARCH",
68 + "from": "CHANNEL",
69 + "to": {
70 + "SEARCH_RESULTS": 1
71 + },
72 + "avg_new_entities": 1,
73 + "observed": 1
74 + },
75 + {
76 + "action": "OPEN_VIDEO",
77 + "from": "SEARCH_RESULTS",
78 + "to": {
79 + "VIDEO_DETAIL": 3
80 + },
81 + "avg_new_entities": 25.7,
82 + "observed": 3
83 + },
84 + {
85 + "action": "OPEN_VIDEO",
86 + "from": "VIDEO_DETAIL",
87 + "to": {
88 + "VIDEO_DETAIL": 10
89 + },
90 + "avg_new_entities": 29.3,
91 + "observed": 10
92 + },
93 + {
94 + "action": "SEARCH",
95 + "from": "VIDEO_DETAIL",
96 + "to": {
97 + "SEARCH_RESULTS": 1
98 + },
99 + "avg_new_entities": 1,
100 + "observed": 1
101 + }
102 + ],
103 + "media": [
104 + {
105 + "hostname": "www.youtube.com",
106 + "kind": "media_segment",
107 + "observed_count": 12
108 + },
109 + {
110 + "hostname": "rr4---sn-q4fl6nde.googlevideo.com",
111 + "kind": "media_segment",
112 + "observed_count": 6
113 + },
114 + {
115 + "hostname": "i.ytimg.com",
116 + "kind": "image",
117 + "observed_count": 172
118 + },
119 + {
120 + "hostname": "rr5---sn-q4fl6n6z.googlevideo.com",
121 + "kind": "media_segment",
122 + "observed_count": 6
123 + },
124 + {
125 + "hostname": "rr1---sn-cxaaj5o5q5-t0ar.googlevideo.com",
126 + "kind": "media_segment",
127 + "observed_count": 11
128 + },
129 + {
130 + "hostname": "rr2---sn-cxaaj5o5q5-t0ay.googlevideo.com",
131 + "kind": "media_segment",
132 + "observed_count": 2
133 + },
134 + {
135 + "hostname": "rr3---sn-cxaaj5o5q5-t0ay.googlevideo.com",
136 + "kind": "media_segment",
137 + "observed_count": 1
138 + },
139 + {
140 + "hostname": "rr1---sn-cxaaj5o5q5-t0a6.googlevideo.com",
141 + "kind": "media_segment",
142 + "observed_count": 1
143 + },
144 + {
145 + "hostname": "rr5---sn-cxaaj5o5q5-t0ay.googlevideo.com",
146 + "kind": "media_segment",
147 + "observed_count": 1
148 + },
149 + {
150 + "hostname": "rr4---sn-cxaaj5o5q5-t0ay.googlevideo.com",
151 + "kind": "media_segment",
152 + "observed_count": 2
153 + },
154 + {
155 + "hostname": "rr1---sn-cxaaj5o5q5-t0ay.googlevideo.com",
156 + "kind": "media_segment",
157 + "observed_count": 3
158 + },
159 + {
160 + "hostname": "rr1---sn-cxaaj5o5q5-t0ad.googlevideo.com",
161 + "kind": "media_segment",
162 + "observed_count": 1
163 + }
164 + ]
165 +}
\ No newline at end of file
added data/platform_model/.gitkeep +0 −0
added docs/ARCHITECTURE.md +51 −0
@@ -0,0 +1,51 @@
1 +# Architecture notes — v0.1
2 +
3 +## The loop (apps/worker/src/engine.ts)
4 +
5 +```
6 +seed (search url / home) NetworkObserver.beginStep / DomObserver.beginStep
7 +
8 +observe() = snapshotDom → classifyPage → entitiesFromDom ⊕ mineEntities(network) → mergeSurfaces → detectVideos → buildActions → PageState
9 +
10 +plan() = scoreActions (information gain) → HeuristicPlanner | LlmPlanner (only when unsure)
11 +
12 +execute() = ActionExecutor (click link / scroll / search / expand / play / back …) ← the only place actions touch the browser
13 +
14 +collect network + DOM deltas of the step → observe() again → WorldModel.observe (fresh entities)
15 +
16 +PlatformModel.learnStep (action → responses → entities) → CONNECTOR_PATTERN_LEARNED / CONNECTOR_DEGRADED
17 +
18 +LoopDetector, budget, AUTH_REQUIRED checks → next step
19 +```
20 +
21 +Everything is published on the `EventBus` (Universal Social Event Format) and consumed by the JSONL log (replay) and the PostgreSQL store.
22 +
23 +## Surfaces and provenance
24 +
25 +Every `ObservedEntity` carries `provenance[]` and `fields{name → {value, provenance[]}}`. The merge (`packages/entities`) keeps both network and DOM evidence, counts field agreements and conflicts, and boosts confidence when two surfaces agree. `PAGE_OPENED` events record `dom_entities / network_entities / both_surfaces / field_agreements` so the "DOM vs network" experiment (§69) is measurable from the logs.
26 +
27 +## Generic vs platform-specific
28 +
29 +Generic: browser lifecycle, network capture, `SchemaProfiler`, JSON entity miner, DOM snapshot, page classifier, planner, information gain, world model, learning, storage, dashboard.
30 +
31 +Adapter (`packages/connectors/<platform>.ts`): hosts, home/search URL, URL grammar → entity type + id, page-type URL hints, id key names, rich-text collapsers (`runs`/`simpleText` on YouTube), noise-link filter, expected entity counts per page type, overlay dismissal.
32 +
33 +Adapters contain **no endpoints and no CSS classes**. The network layer fingerprints endpoints by `(hostname, path pattern, method, response shape hash)`.
34 +
35 +## Learned state
36 +
37 +`data/platform_model/<platform>/platform_model.json` (plus split files): response patterns (shape hash → likely entity types, confidence, triggered_by action), action patterns (avg new entities, target page types, usual shapes), page types (url patterns, avg entities), media patterns. `compileConnector()` writes `connectors/<platform>/manifest.json` when confidence ≥ 40 %.
38 +
39 +`PlatformModel.learnedYield()` feeds the expected-entity-yield term of the information-gain formula, so navigation improves with what the crawler has learned.
40 +
41 +## Degradation / self-healing (first step)
42 +
43 +`PlatformModel.isDegraded(pageType, observed, adapterExpected)` fires `CONNECTOR_DEGRADED` when a well-known page type yields zero entities; `CONNECTOR_REPAIRED` when extraction recovers. Full re-learning (re-inspect DOM/network, regenerate the connector, run regression tests) is Phase 12.
44 +
45 +## Next steps
46 +
47 +1. Run authenticated sessions on YouTube and Reddit, inspect field conflicts in the dashboard, tune adapters and the miner.
48 +2. Embeddings for novelty (local model) behind `WorldModel.novelty`.
49 +3. Facebook adapter (Phase 2) through PLATFORM LEARNING mode first: `pnpm learn facebook` once a GenericAdapter fallback exists.
50 +4. Record/replay fixtures from `sessions/<id>/events.jsonl` for regression tests of learned schemas.
51 +5. Distributed workers on MacLustr nodes (one browser profile per node), central PostgreSQL.
added package.json +29 −0
@@ -0,0 +1,29 @@
1 +{
2 + "name": "social-runtime-crawler",
3 + "version": "0.1.0",
4 + "private": true,
5 + "description": "Social Runtime Crawler — browser-native, self-learning crawler that mines the observable runtime of social applications.",
6 + "type": "module",
7 + "packageManager": "pnpm@11.1.2",
8 + "engines": {
9 + "node": ">=22"
10 + },
11 + "scripts": {
12 + "src": "tsx apps/worker/src/cli.ts",
13 + "login": "tsx apps/worker/src/cli.ts login",
14 + "crawl": "tsx apps/worker/src/cli.ts crawl",
15 + "learn": "tsx apps/worker/src/cli.ts learn",
16 + "replay": "tsx apps/worker/src/cli.ts replay",
17 + "dashboard": "tsx apps/api/src/server.ts",
18 + "db:migrate": "tsx packages/storage/src/migrate.ts",
19 + "typecheck": "tsc -p tsconfig.json --noEmit",
20 + "test": "vitest run",
21 + "test:watch": "vitest"
22 + },
23 + "devDependencies": {
24 + "@types/node": "^24.0.0",
25 + "tsx": "^4.20.0",
26 + "typescript": "^5.9.3",
27 + "vitest": "^3.2.0"
28 + }
29 +}
added packages/agent/package.json +16 −0
@@ -0,0 +1,16 @@
1 +{
2 + "name": "@src/agent",
3 + "version": "0.1.0",
4 + "private": true,
5 + "type": "module",
6 + "main": "./src/index.ts",
7 + "types": "./src/index.ts",
8 + "exports": {
9 + ".": "./src/index.ts"
10 + },
11 + "dependencies": {
12 + "@src/shared": "workspace:*",
13 + "@src/events": "workspace:*",
14 + "@anthropic-ai/sdk": "^0.125.0"
15 + }
16 +}
added packages/agent/src/InformationGain.ts +125 −0
@@ -0,0 +1,125 @@
1 +import { clamp01, jaccard, tokenize, type ActionScore, type AgentMode, type ObservedEntity, type PageState, type SemanticAction } from "@src/shared";
2 +import type { WorldModel } from "./WorldModel.ts";
3 +
4 +/**
5 + * Information Gain Engine (§26–§27):
6 + * gain = novelty × relevance × expected_entity_yield × confidence × source_quality ÷ cost
7 + * with penalties for already-seen / duplicate / loop / low-quality / low-relevance.
8 + */
9 +export interface GainContext {
10 + goal: string;
11 + mode: AgentMode;
12 + world: WorldModel;
13 + state: PageState;
14 + recentActionTypes: string[]; // last N action types
15 + recentUrls: string[];
16 + stepsWithoutNewEntities: number;
17 + /** Learned expectations: action type → average new entities produced (from the platform model). */
18 + learnedYield?: Record<string, number>;
19 +}
20 +
21 +const TYPE_YIELD: Record<string, number> = {
22 + OPEN_CHANNEL: 0.8,
23 + OPEN_PROFILE: 0.8,
24 + OPEN_PAGE: 0.75,
25 + OPEN_VIDEO: 0.65,
26 + OPEN_POST: 0.65,
27 + OPEN_COMMENTS: 0.55,
28 + OPEN_ENTITY: 0.6,
29 + SCROLL_DOWN: 0.6,
30 + SEARCH: 0.85,
31 + EXPAND: 0.4,
32 + PLAY_VIDEO: 0.3,
33 + SCROLL_UP: 0.05,
34 + BACK: 0.2,
35 + RETURN_TO_FEED: 0.3,
36 + FILTER: 0.4,
37 + WAIT_FOR_CONTENT: 0.1,
38 + END_SESSION: 0.0,
39 + FORWARD: 0.1,
40 + PAUSE_VIDEO: 0.0,
41 + COLLAPSE: 0.0,
42 +};
43 +
44 +export function relevanceOf(text: string | undefined, goal: string): number {
45 + if (!goal.trim()) return 0.6;
46 + if (!text) return 0.35;
47 + const g = tokenize(goal);
48 + const t = tokenize(text);
49 + if (g.size === 0 || t.size === 0) return 0.4;
50 + let hits = 0;
51 + for (const tok of g) if (t.has(tok) || [...t].some((x) => x.startsWith(tok.slice(0, 5)) && tok.length > 4)) hits++;
52 + const coverage = hits / g.size;
53 + return clamp01(0.3 + 0.7 * coverage + 0.2 * jaccard(g, t));
54 +}
55 +
56 +function entityOf(ctx: GainContext, a: SemanticAction): ObservedEntity | undefined {
57 + return a.target_ref ? ctx.state.entities.find((e) => e.ref === a.target_ref) : undefined;
58 +}
59 +
60 +export function scoreActions(ctx: GainContext, actions: SemanticAction[]): ActionScore[] {
61 + const scores: ActionScore[] = [];
62 + const currentRel = relevanceOf(ctx.state.summary_text.slice(0, 1500), ctx.goal);
63 + for (const a of actions) {
64 + const penalties: string[] = [];
65 + const e = entityOf(ctx, a);
66 + let novelty = 0.5;
67 + let relevance = 0.5;
68 + let confidence = 0.7;
69 + let source_quality = 0.7;
70 + let yieldExp = ctx.learnedYield?.[a.type] !== undefined ? clamp01(ctx.learnedYield[a.type]! / 10) : TYPE_YIELD[a.type] ?? 0.3;
71 +
72 + if (e) {
73 + novelty = ctx.world.novelty(e);
74 + relevance = relevanceOf(`${e.name ?? ""} ${e.text ?? ""} ${e.author ?? ""}`, ctx.goal);
75 + confidence = Math.max(0, ...e.provenance.map((p) => p.confidence));
76 + source_quality = e.provenance.some((p) => p.surface === "network") && e.provenance.some((p) => p.surface === "dom") ? 0.95 : 0.75;
77 + if (ctx.world.isVisited(e)) penalties.push("already_seen");
78 + if (/^(navigation|header|sidebar)/.test(e.context ?? "") && ctx.mode !== "learn") penalties.push("low_quality_source");
79 + if (!e.name && !e.text) penalties.push("low_confidence_entity");
80 + // Profiles/channels are hubs: raise yield when the goal is about people/orgs.
81 + if ((e.type === "channel" || e.type === "profile") && /personalit|people|person|creator|influenc|chaîne|channel|profil/i.test(ctx.goal)) yieldExp = Math.min(1, yieldExp + 0.15);
82 + } else {
83 + switch (a.type) {
84 + case "SCROLL_DOWN":
85 + relevance = currentRel;
86 + novelty = ctx.stepsWithoutNewEntities > 2 ? 0.2 : 0.6;
87 + if (ctx.recentActionTypes.slice(-4).every((t) => t === "SCROLL_DOWN") && ctx.recentActionTypes.length >= 4) penalties.push("navigation_loop");
88 + break;
89 + case "SEARCH":
90 + relevance = 0.9;
91 + novelty = ctx.recentActionTypes.includes("SEARCH") ? 0.15 : 0.9;
92 + if (ctx.state.classification.page_type === "SEARCH_RESULTS") penalties.push("already_seen");
93 + break;
94 + case "EXPAND":
95 + relevance = currentRel;
96 + novelty = 0.5;
97 + break;
98 + case "PLAY_VIDEO":
99 + relevance = currentRel;
100 + novelty = ctx.state.classification.page_type === "VIDEO_DETAIL" ? 0.6 : 0.3;
101 + break;
102 + case "BACK":
103 + case "RETURN_TO_FEED":
104 + relevance = 0.4;
105 + novelty = ctx.stepsWithoutNewEntities > 1 ? 0.6 : 0.2;
106 + if (ctx.recentActionTypes.slice(-2).includes(a.type)) penalties.push("navigation_loop");
107 + break;
108 + case "END_SESSION":
109 + relevance = 0.1;
110 + novelty = ctx.stepsWithoutNewEntities > 6 ? 0.9 : 0.05;
111 + break;
112 + default:
113 + break;
114 + }
115 + }
116 + if (ctx.mode === "observe" && a.type !== "SCROLL_DOWN" && a.type !== "WAIT_FOR_CONTENT" && a.type !== "END_SESSION") penalties.push("mode_observe_no_navigation");
117 + if (relevance < 0.35 && e) penalties.push("low_relevance");
118 +
119 + let gain = (novelty * relevance * yieldExp * confidence * source_quality) / Math.max(0.25, a.cost);
120 + for (const p of penalties) gain *= p === "already_seen" ? 0.05 : p === "navigation_loop" ? 0.15 : p === "mode_observe_no_navigation" ? 0.02 : 0.5;
121 +
122 + scores.push({ action_id: a.id, novelty, relevance, expected_entity_yield: yieldExp, confidence, source_quality, cost: a.cost, penalties, information_gain: gain });
123 + }
124 + return scores.sort((x, y) => y.information_gain - x.information_gain);
125 +}
added packages/agent/src/LoopDetector.ts +32 −0
@@ -0,0 +1,32 @@
1 +/**
2 + * Navigation safety (§41): keep recent page fingerprints / urls / actions and detect A→B→A→B patterns.
3 + */
4 +export class LoopDetector {
5 + private fingerprints: string[] = [];
6 + private actions: string[] = [];
7 + constructor(private readonly window = 12) {}
8 +
9 + record(pageFingerprint: string, actionKey: string): void {
10 + this.fingerprints.push(pageFingerprint);
11 + this.actions.push(actionKey);
12 + if (this.fingerprints.length > this.window) this.fingerprints.shift();
13 + if (this.actions.length > this.window) this.actions.shift();
14 + }
15 +
16 + /** True when the last four page states alternate (A B A B) or the same page repeats 4×. */
17 + detect(): { loop: boolean; pattern?: string } {
18 + const f = this.fingerprints;
19 + if (f.length >= 4) {
20 + const [a, b, c, d] = f.slice(-4);
21 + if (a === c && b === d && a !== b) return { loop: true, pattern: "ABAB" };
22 + if (a === b && b === c && c === d) return { loop: true, pattern: "AAAA" };
23 + }
24 + const acts = this.actions.slice(-6);
25 + if (acts.length === 6 && new Set(acts).size === 1 && !acts[0]!.startsWith("SCROLL_DOWN")) return { loop: true, pattern: "same_action×6" };
26 + return { loop: false };
27 + }
28 +
29 + recentActionTypes(): string[] {
30 + return this.actions.map((a) => a.split(":")[0]!);
31 + }
32 +}
added packages/agent/src/WorldModel.ts +124 −0
@@ -0,0 +1,124 @@
1 +import { jaccard, tokenize, type EntityType, type ObservedEntity, type RelationType } from "@src/shared";
2 +
3 +/**
4 + * Social World Model (§23): an in-memory graph of everything observed in this session.
5 + * Guides navigation (visited set, novelty) and becomes the seed of the persisted graph.
6 + */
7 +export interface WorldNode {
8 + fingerprint: string;
9 + type: EntityType;
10 + name?: string;
11 + url?: string;
12 + platform_id?: string;
13 + first_seen_step: number;
14 + last_seen_step: number;
15 + seen_count: number;
16 + visited: boolean; // we navigated to it
17 + surfaces: Set<string>;
18 + tokens: Set<string>;
19 +}
20 +
21 +export interface WorldEdge {
22 + from: string;
23 + to: string;
24 + type: RelationType;
25 + step: number;
26 +}
27 +
28 +export class WorldModel {
29 + readonly nodes = new Map<string, WorldNode>();
30 + readonly edges: WorldEdge[] = [];
31 + readonly visitedUrls = new Set<string>();
32 + private edgeKeys = new Set<string>();
33 +
34 + /** Register observed entities; returns those never seen before. */
35 + observe(entities: ObservedEntity[], step: number, discoveredFrom?: string): ObservedEntity[] {
36 + const fresh: ObservedEntity[] = [];
37 + for (const e of entities) {
38 + const n = this.nodes.get(e.fingerprint);
39 + if (n) {
40 + n.last_seen_step = step;
41 + n.seen_count++;
42 + if (!n.name && e.name) n.name = e.name;
43 + if (!n.url && e.url) n.url = e.url;
44 + for (const p of e.provenance) n.surfaces.add(p.surface);
45 + if (e.name || e.text) n.tokens = tokenize(`${e.name ?? ""} ${e.text ?? ""} ${e.author ?? ""}`);
46 + } else {
47 + this.nodes.set(e.fingerprint, {
48 + fingerprint: e.fingerprint,
49 + type: e.type,
50 + name: e.name,
51 + url: e.url,
52 + platform_id: e.platform_id,
53 + first_seen_step: step,
54 + last_seen_step: step,
55 + seen_count: 1,
56 + visited: false,
57 + surfaces: new Set(e.provenance.map((p) => p.surface)),
58 + tokens: tokenize(`${e.name ?? ""} ${e.text ?? ""} ${e.author ?? ""}`),
59 + });
60 + fresh.push(e);
61 + if (discoveredFrom) this.relate(discoveredFrom, e.fingerprint, "DISCOVERED_FROM", step);
62 + }
63 + if (e.author && e.author_url) {
64 + // author edge when we know the author's page
65 + this.relate(`author:${e.author_url}`, e.fingerprint, "AUTHORED", step);
66 + }
67 + }
68 + return fresh;
69 + }
70 +
71 + relate(from: string, to: string, type: RelationType, step: number): void {
72 + const k = `${from}→${to}:${type}`;
73 + if (this.edgeKeys.has(k)) return;
74 + this.edgeKeys.add(k);
75 + this.edges.push({ from, to, type, step });
76 + }
77 +
78 + markVisited(fingerprint: string | undefined, url: string | undefined): void {
79 + if (fingerprint) {
80 + const n = this.nodes.get(fingerprint);
81 + if (n) n.visited = true;
82 + }
83 + if (url) this.visitedUrls.add(url);
84 + }
85 +
86 + isVisited(e: ObservedEntity): boolean {
87 + return (this.nodes.get(e.fingerprint)?.visited ?? false) || (!!e.url && this.visitedUrls.has(e.url));
88 + }
89 +
90 + /** Lexical novelty: 1 − max Jaccard similarity to what we already know (embedding-ready seam, §27). */
91 + novelty(e: ObservedEntity): number {
92 + const t = tokenize(`${e.name ?? ""} ${e.text ?? ""} ${e.author ?? ""}`);
93 + if (t.size === 0) return 0.5;
94 + let max = 0;
95 + let checked = 0;
96 + for (const n of this.nodes.values()) {
97 + if (n.fingerprint === e.fingerprint) continue;
98 + if (n.tokens.size === 0) continue;
99 + const s = jaccard(t, n.tokens);
100 + if (s > max) max = s;
101 + if (++checked > 2000) break;
102 + }
103 + return 1 - max;
104 + }
105 +
106 + stats() {
107 + const byType: Record<string, number> = {};
108 + let visited = 0;
109 + let multi = 0;
110 + for (const n of this.nodes.values()) {
111 + byType[n.type] = (byType[n.type] ?? 0) + 1;
112 + if (n.visited) visited++;
113 + if (n.surfaces.size > 1) multi++;
114 + }
115 + return { nodes: this.nodes.size, edges: this.edges.length, visited, multi_surface: multi, by_type: byType };
116 + }
117 +
118 + toJSON() {
119 + return {
120 + nodes: [...this.nodes.values()].map((n) => ({ ...n, surfaces: [...n.surfaces], tokens: undefined })),
121 + edges: this.edges,
122 + };
123 + }
124 +}
added packages/agent/src/agent.test.ts +69 −0
@@ -0,0 +1,69 @@
1 +import { describe, expect, it } from "vitest";
2 +import type { ObservedEntity, PageState, SemanticAction } from "@src/shared";
3 +import { WorldModel } from "./WorldModel.ts";
4 +import { LoopDetector } from "./LoopDetector.ts";
5 +import { scoreActions, relevanceOf } from "./InformationGain.ts";
6 +import { HeuristicPlanner } from "./planner.ts";
7 +
8 +const ent = (ref: string, name: string, type: ObservedEntity["type"] = "video"): ObservedEntity => ({ ref, type, platform: "youtube", platform_id: ref, url: `https://youtube.com/watch?v=${ref}`, name, fields: {}, provenance: [{ surface: "dom", confidence: 0.9 }, { surface: "network", confidence: 0.95 }], fingerprint: `youtube:${type}:${ref}` });
9 +
10 +const state = (entities: ObservedEntity[], actions: SemanticAction[]): PageState => ({ url: "https://youtube.com/results?search_query=ai+quebec", title: "results", platform: "youtube", classification: { page_type: "SEARCH_RESULTS", confidence: 0.95, signals: [] }, entities, actions, media: [], summary_text: "AI Québec results", fingerprint: "fp", captured_at: new Date().toISOString() });
11 +
12 +describe("information gain + planner", () => {
13 + it("prefers relevant, unvisited, multi-surface entities and penalizes visited ones", async () => {
14 + const world = new WorldModel();
15 + const e1 = ent("v1", "Intelligence artificielle au Québec : table ronde");
16 + const e2 = ent("v2", "Recette de tarte aux pommes");
17 + world.observe([e1, e2], 0);
18 + world.markVisited(e2.fingerprint, e2.url);
19 + const actions: SemanticAction[] = [
20 + { id: "A1", type: "OPEN_VIDEO", target_ref: "v1", target_url: e1.url, label: "open v1", cost: 2 },
21 + { id: "A2", type: "OPEN_VIDEO", target_ref: "v2", target_url: e2.url, label: "open v2", cost: 2 },
22 + { id: "A3", type: "SCROLL_DOWN", label: "scroll", cost: 1 },
23 + { id: "A4", type: "END_SESSION", label: "end", cost: 0.5 },
24 + ];
25 + const st = state([e1, e2], actions);
26 + const scores = scoreActions({ goal: "intelligence artificielle Québec", mode: "research", world, state: st, recentActionTypes: [], recentUrls: [], stepsWithoutNewEntities: 0 }, actions);
27 + expect(scores[0]!.action_id).toBe("A1");
28 + const visited = scores.find((s) => s.action_id === "A2")!;
29 + expect(visited.penalties).toContain("already_seen");
30 + expect(scores.at(-1)!.action_id).toBe("A4");
31 + const d = await new HeuristicPlanner().plan({ goal: "intelligence artificielle Québec", mode: "research", world, state: st, recentActionTypes: [], recentUrls: [], stepsWithoutNewEntities: 0 }, 1);
32 + expect(d.chosen_action.id).toBe("A1");
33 + expect(d.reason.length).toBeGreaterThan(0);
34 + });
35 +
36 + it("relevance follows lexical overlap with the goal", () => {
37 + expect(relevanceOf("AI Quebec panel", "AI Quebec")).toBeGreaterThan(relevanceOf("Apple pie recipe", "AI Quebec"));
38 + });
39 +
40 + it("observe mode suppresses navigation", () => {
41 + const world = new WorldModel();
42 + const e1 = ent("v1", "AI");
43 + const actions: SemanticAction[] = [{ id: "A1", type: "OPEN_VIDEO", target_ref: "v1", target_url: e1.url, label: "open", cost: 2 }, { id: "A2", type: "SCROLL_DOWN", label: "scroll", cost: 1 }];
44 + const scores = scoreActions({ goal: "AI", mode: "observe", world, state: state([e1], actions), recentActionTypes: [], recentUrls: [], stepsWithoutNewEntities: 0 }, actions);
45 + expect(scores[0]!.action_id).toBe("A2");
46 + });
47 +});
48 +
49 +describe("LoopDetector", () => {
50 + it("detects ABAB", () => {
51 + const l = new LoopDetector();
52 + for (const f of ["a", "b", "a", "b"]) l.record(f, "OPEN_VIDEO:x");
53 + expect(l.detect().loop).toBe(true);
54 + });
55 + it("tolerates repeated scrolling", () => {
56 + const l = new LoopDetector();
57 + for (let i = 0; i < 6; i++) l.record(`p${i}`, "SCROLL_DOWN:");
58 + expect(l.detect().loop).toBe(false);
59 + });
60 +});
61 +
62 +describe("WorldModel novelty", () => {
63 + it("scores near-duplicates as low novelty", () => {
64 + const w = new WorldModel();
65 + w.observe([ent("a", "Intelligence artificielle Québec conférence 2026")], 0);
66 + expect(w.novelty(ent("b", "Intelligence artificielle Québec conférence 2026 partie 2"))).toBeLessThan(0.5);
67 + expect(w.novelty(ent("c", "Tarte aux pommes maison"))).toBeGreaterThan(0.9);
68 + });
69 +});
added packages/agent/src/index.ts +5 −0
@@ -0,0 +1,5 @@
1 +export * from "./WorldModel.ts";
2 +export * from "./InformationGain.ts";
3 +export * from "./LoopDetector.ts";
4 +export * from "./llm.ts";
5 +export * from "./planner.ts";
added packages/agent/src/llm.ts +78 −0
@@ -0,0 +1,78 @@
1 +import Anthropic from "@anthropic-ai/sdk";
2 +import { createLogger, type AppConfig } from "@src/shared";
3 +
4 +const log = createLogger("llm");
5 +
6 +/** Tier 3/4 model access (§49–§50). One tiny interface so the planner never depends on a vendor SDK. */
7 +export interface LlmClient {
8 + readonly name: string;
9 + complete(opts: { system: string; user: string; maxTokens?: number }): Promise<{ text: string; input_tokens: number; output_tokens: number }>;
10 +}
11 +
12 +class AnthropicClient implements LlmClient {
13 + readonly name: string;
14 + private client = new Anthropic();
15 + constructor(private readonly model: string) {
16 + this.name = `anthropic:${model}`;
17 + }
18 + async complete(opts: { system: string; user: string; maxTokens?: number }) {
19 + const res = await this.client.messages.create({
20 + model: this.model,
21 + max_tokens: opts.maxTokens ?? 2000,
22 + thinking: { type: "adaptive" },
23 + output_config: { effort: "low" }, // planning over a compact action list is routine work
24 + system: [{ type: "text", text: opts.system, cache_control: { type: "ephemeral" } }],
25 + messages: [{ role: "user", content: opts.user }],
26 + });
27 + if (res.stop_reason === "refusal") {
28 + log.warn("planner request refused", { category: res.stop_details?.category });
29 + return { text: "", input_tokens: res.usage.input_tokens, output_tokens: res.usage.output_tokens };
30 + }
31 + const text = res.content.filter((b) => b.type === "text").map((b) => (b as { text: string }).text).join("\n");
32 + return { text, input_tokens: res.usage.input_tokens, output_tokens: res.usage.output_tokens };
33 + }
34 +}
35 +
36 +/** OpenAI-compatible local endpoint (llm-api.io on the MacLustr cluster, llama.cpp, MLX…). */
37 +class LocalOpenAiCompatibleClient implements LlmClient {
38 + readonly name: string;
39 + constructor(private readonly baseUrl: string, private readonly model: string, private readonly apiKey?: string) {
40 + this.name = `local:${model}`;
41 + }
42 + async complete(opts: { system: string; user: string; maxTokens?: number }) {
43 + const res = await fetch(`${this.baseUrl.replace(/\/$/, "")}/chat/completions`, {
44 + method: "POST",
45 + headers: { "content-type": "application/json", ...(this.apiKey ? { authorization: `Bearer ${this.apiKey}` } : {}) },
46 + body: JSON.stringify({ model: this.model, max_tokens: opts.maxTokens ?? 1200, temperature: 0.2, messages: [{ role: "system", content: opts.system }, { role: "user", content: opts.user }] }),
47 + });
48 + if (!res.ok) throw new Error(`local llm ${res.status}: ${(await res.text()).slice(0, 200)}`);
49 + const data = (await res.json()) as { choices?: { message?: { content?: string } }[]; usage?: { prompt_tokens?: number; completion_tokens?: number } };
50 + return { text: data.choices?.[0]?.message?.content ?? "", input_tokens: data.usage?.prompt_tokens ?? 0, output_tokens: data.usage?.completion_tokens ?? 0 };
51 + }
52 +}
53 +
54 +export function createLlmClient(cfg: AppConfig): LlmClient | undefined {
55 + if (cfg.llmProvider === "anthropic") return new AnthropicClient(cfg.llmModel);
56 + if (cfg.llmProvider === "local") {
57 + if (!cfg.localLlmUrl || !cfg.localLlmModel) {
58 + log.warn("SRC_LLM_PROVIDER=local requires SRC_LOCAL_LLM_URL and SRC_LOCAL_LLM_MODEL — falling back to heuristic planner");
59 + return undefined;
60 + }
61 + return new LocalOpenAiCompatibleClient(cfg.localLlmUrl, cfg.localLlmModel, cfg.localLlmKey);
62 + }
63 + return undefined;
64 +}
65 +
66 +/** Extract the first JSON object from a model reply (tolerates fences and prose). */
67 +export function extractJson(text: string): Record<string, unknown> | undefined {
68 + const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/);
69 + const body = fence?.[1] ?? text;
70 + const start = body.indexOf("{");
71 + const end = body.lastIndexOf("}");
72 + if (start < 0 || end <= start) return undefined;
73 + try {
74 + return JSON.parse(body.slice(start, end + 1)) as Record<string, unknown>;
75 + } catch {
76 + return undefined;
77 + }
78 +}
added packages/agent/src/planner.ts +123 −0
@@ -0,0 +1,123 @@
1 +import { clamp01, createLogger, truncate, type ActionScore, type AgentDecision, type PageState, type SemanticAction } from "@src/shared";
2 +import { scoreActions, type GainContext } from "./InformationGain.ts";
3 +import { extractJson, type LlmClient } from "./llm.ts";
4 +
5 +const log = createLogger("planner");
6 +
7 +/**
8 + * Navigation Agent (§24). Two planners share the same contract:
9 + * - HeuristicPlanner (Tier 1): argmax information gain, deterministic.
10 + * - LlmPlanner (Tier 4): sees the compact page state + top-scored actions and picks one; the answer is
11 + * validated against the action list and falls back to the heuristic on any invalid output.
12 + * The LLM never sees selectors or raw DOM and cannot invent actions.
13 + */
14 +export interface Planner {
15 + readonly name: string;
16 + plan(ctx: GainContext, step: number): Promise<AgentDecision>;
17 + readonly tokensUsed: number;
18 +}
19 +
20 +export class HeuristicPlanner implements Planner {
21 + readonly name = "heuristic";
22 + readonly tokensUsed = 0;
23 + async plan(ctx: GainContext, step: number): Promise<AgentDecision> {
24 + const scores = scoreActions(ctx, ctx.state.actions);
25 + return decisionFromScores(ctx, step, scores, "heuristic", undefined);
26 + }
27 +}
28 +
29 +export function decisionFromScores(ctx: GainContext, step: number, scores: ActionScore[], planner: AgentDecision["planner"], reason?: string, chosenId?: string): AgentDecision {
30 + const best = chosenId ? scores.find((s) => s.action_id === chosenId) ?? scores[0]! : scores[0]!;
31 + const action = ctx.state.actions.find((a) => a.id === best.action_id)!;
32 + const why = reason ?? explain(ctx, action, best);
33 + return {
34 + step,
35 + goal: ctx.goal,
36 + chosen_action: action,
37 + expected_information_gain: best.information_gain,
38 + novelty: best.novelty,
39 + relevance: best.relevance,
40 + reason: why,
41 + planner,
42 + scores: scores.slice(0, 12),
43 + };
44 +}
45 +
46 +function explain(ctx: GainContext, a: SemanticAction, s: ActionScore): string {
47 + const e = a.target_ref ? ctx.state.entities.find((x) => x.ref === a.target_ref) : undefined;
48 + const parts: string[] = [];
49 + if (e) parts.push(`${e.type} not yet visited`);
50 + if (s.novelty > 0.7) parts.push("novel content");
51 + if (s.relevance > 0.7) parts.push("high relevance to goal");
52 + if (s.source_quality > 0.9) parts.push("confirmed by network + DOM");
53 + if (a.type === "SCROLL_DOWN") parts.push("reveal more feed items");
54 + if (a.type === "SEARCH") parts.push("native search seeds the topic");
55 + if (a.type === "END_SESSION") parts.push("no productive action left");
56 + if (s.penalties.length) parts.push(`penalties: ${s.penalties.join(",")}`);
57 + return parts.join("; ") || "best information gain";
58 +}
59 +
60 +const SYSTEM_PROMPT = `You are the navigation planner of a research crawler that observes public social-media content through a normal authenticated browser session (read-only: never like, follow, comment, message or subscribe).
61 +You receive a compact description of the current page, the entities visible on it, and a numbered list of allowed semantic actions with heuristic information-gain scores.
62 +Choose exactly one action id that best advances the goal while avoiding loops and already-visited entities.
63 +Reply with a single JSON object: {"action": "<id>", "expected_information_gain": <0..1>, "reason": "<one concise sentence>"}. No other text.`;
64 +
65 +export class LlmPlanner implements Planner {
66 + readonly name: string;
67 + tokensUsed = 0;
68 + private failures = 0;
69 + constructor(private readonly llm: LlmClient, private readonly fallback: Planner = new HeuristicPlanner(), private readonly maxTokensBudget = 200_000) {
70 + this.name = `llm(${llm.name})`;
71 + }
72 +
73 + async plan(ctx: GainContext, step: number): Promise<AgentDecision> {
74 + const scores = scoreActions(ctx, ctx.state.actions);
75 + if (this.tokensUsed > this.maxTokensBudget || this.failures >= 3) return decisionFromScores(ctx, step, scores, "fallback", "llm budget exhausted or repeated failures");
76 + // Escalate only when the heuristic is unsure (§50): close top scores, or unknown page.
77 + const top = scores[0]!;
78 + const second = scores[1];
79 + const unsure = !second || top.information_gain - second.information_gain < top.information_gain * 0.25 || ctx.state.classification.confidence < 0.6;
80 + if (!unsure) return decisionFromScores(ctx, step, scores, "heuristic");
81 +
82 + try {
83 + const user = renderPrompt(ctx, scores);
84 + const res = await this.llm.complete({ system: SYSTEM_PROMPT, user, maxTokens: 300 });
85 + this.tokensUsed += res.input_tokens + res.output_tokens;
86 + const json = extractJson(res.text);
87 + const id = typeof json?.action === "string" ? (json.action as string) : undefined;
88 + if (!id || !ctx.state.actions.some((a) => a.id === id)) {
89 + this.failures++;
90 + log.warn("llm returned invalid action, using heuristic", { reply: truncate(res.text, 120) });
91 + return decisionFromScores(ctx, step, scores, "fallback", "invalid llm output");
92 + }
93 + this.failures = 0;
94 + const d = decisionFromScores(ctx, step, scores, "llm", typeof json?.reason === "string" ? truncate(json.reason as string, 240) : undefined, id);
95 + if (typeof json?.expected_information_gain === "number") d.expected_information_gain = clamp01(json.expected_information_gain as number);
96 + return d;
97 + } catch (err) {
98 + this.failures++;
99 + log.warn("llm planner error, using heuristic", { err: (err as Error).message });
100 + return decisionFromScores(ctx, step, scores, "fallback", "llm error");
101 + }
102 + }
103 +}
104 +
105 +export function renderPrompt(ctx: GainContext, scores: ActionScore[]): string {
106 + const st: PageState = ctx.state;
107 + const lines: string[] = [];
108 + lines.push(`GOAL: ${ctx.goal}`, `MODE: ${ctx.mode}`, `PLATFORM: ${st.platform}`, `PAGE: ${st.classification.page_type} (${st.classification.confidence.toFixed(2)}) ${truncate(st.title, 80)}`, `URL: ${st.url}`, "");
109 + lines.push("VISIBLE ENTITIES");
110 + for (const e of st.entities.slice(0, 30)) {
111 + const flags = [ctx.world.isVisited(e) ? "visited" : "", e.media?.has_video ? "video" : "", e.metrics?.views ? `${e.metrics.views} views` : "", e.metrics?.score ? `score ${e.metrics.score}` : ""].filter(Boolean).join(", ");
112 + lines.push(`[${e.ref}] ${e.type}: ${truncate(e.name ?? e.text ?? e.platform_id, 90)}${e.author ? ` — by ${truncate(e.author, 40)}` : ""}${flags ? ` (${flags})` : ""}`);
113 + }
114 + if (st.entities.length > 30) lines.push(`… ${st.entities.length - 30} more`);
115 + lines.push("", "ACTIONS (id · label · heuristic gain)");
116 + const byId = new Map(scores.map((s) => [s.action_id, s]));
117 + for (const a of st.actions) {
118 + const s = byId.get(a.id);
119 + lines.push(`[${a.id}] ${a.label} · gain=${(s?.information_gain ?? 0).toFixed(3)}${s?.penalties.length ? ` · ${s.penalties.join(",")}` : ""}`);
120 + }
121 + lines.push("", `RECENT ACTIONS: ${ctx.recentActionTypes.slice(-6).join(" → ") || "none"}`, `STEPS WITHOUT NEW ENTITIES: ${ctx.stepsWithoutNewEntities}`);
122 + return lines.join("\n");
123 +}
added packages/browser/package.json +16 −0
@@ -0,0 +1,16 @@
1 +{
2 + "name": "@src/browser",
3 + "version": "0.1.0",
4 + "private": true,
5 + "type": "module",
6 + "main": "./src/index.ts",
7 + "types": "./src/index.ts",
8 + "exports": {
9 + ".": "./src/index.ts"
10 + },
11 + "dependencies": {
12 + "@src/shared": "workspace:*",
13 + "@src/events": "workspace:*",
14 + "playwright": "^1.63.0"
15 + }
16 +}
added packages/browser/src/index.ts +2 −0
@@ -0,0 +1,2 @@
1 +export * from "./session.ts";
2 +export * from "./login.ts";
added packages/browser/src/login.ts +37 −0
@@ -0,0 +1,37 @@
1 +import readline from "node:readline";
2 +import { createLogger, type Platform } from "@src/shared";
3 +import { SocialBrowserSession } from "./session.ts";
4 +
5 +const log = createLogger("login");
6 +
7 +export const LOGIN_URLS: Record<Platform, string> = {
8 + youtube: "https://www.youtube.com/",
9 + reddit: "https://www.reddit.com/login",
10 + facebook: "https://www.facebook.com/",
11 + instagram: "https://www.instagram.com/",
12 + tiktok: "https://www.tiktok.com/login",
13 + x: "https://x.com/login",
14 + linkedin: "https://www.linkedin.com/login",
15 + threads: "https://www.threads.net/login",
16 +};
17 +
18 +/**
19 + * Human-in-the-loop authentication (§7): open a headed browser on the platform,
20 + * let the operator log in, then persist the profile. No credentials are read, typed or stored by the crawler.
21 + */
22 +export async function interactiveLogin(opts: { platform: Platform; accountAlias: string; profilesDir: string; channel?: string }): Promise<void> {
23 + const session = new SocialBrowserSession({ ...opts, headless: false });
24 + await session.start();
25 + await session.navigate(LOGIN_URLS[opts.platform]);
26 + log.info("Browser is open. Log in manually, dismiss consent dialogs, then come back here.");
27 + await waitForEnter("Press <Enter> once you are logged in (the profile will be saved)… ");
28 + const page = session.getPage();
29 + const cookies = await session.getContext().cookies();
30 + log.info("profile saved", { platform: opts.platform, alias: opts.accountAlias, url: page.url(), cookies: cookies.length, profile: session.profilePath });
31 + await session.stop();
32 +}
33 +
34 +function waitForEnter(prompt: string): Promise<void> {
35 + const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
36 + return new Promise((res) => rl.question(prompt, () => (rl.close(), res())));
37 +}
added packages/browser/src/session.ts +131 −0
@@ -0,0 +1,131 @@
1 +import path from "node:path";
2 +import fs from "node:fs";
3 +import { chromium, type BrowserContext, type Page, type CDPSession } from "playwright";
4 +import { createLogger, newId, nowIso, type Platform, type SessionInfo } from "@src/shared";
5 +import type { EventBus } from "@src/events";
6 +
7 +const log = createLogger("browser");
8 +
9 +export interface SessionOptions {
10 + platform: Platform;
11 + accountAlias: string;
12 + profilesDir: string;
13 + headless: boolean;
14 + channel?: string; // "chromium" | "chrome" | "msedge"
15 + bus?: EventBus;
16 + viewport?: { width: number; height: number };
17 +}
18 +
19 +/**
20 + * One persistent Chromium profile per (platform, account) — §7/§8/§65.
21 + * Authentication is never automated: a human logs in once (`login` command); the profile keeps the cookies.
22 + */
23 +export class SocialBrowserSession {
24 + readonly sessionId = newId("sess");
25 + readonly platform: Platform;
26 + readonly accountAlias: string;
27 + readonly profilePath: string;
28 + private context?: BrowserContext;
29 + private page?: Page;
30 + private cdp?: CDPSession;
31 + private info: SessionInfo;
32 + private readonly opts: SessionOptions;
33 +
34 + constructor(opts: SessionOptions) {
35 + this.opts = opts;
36 + this.platform = opts.platform;
37 + this.accountAlias = opts.accountAlias;
38 + this.profilePath = path.join(opts.profilesDir, `${opts.platform}-${opts.accountAlias}`);
39 + this.info = {
40 + session_id: this.sessionId,
41 + platform: this.platform,
42 + account_alias: this.accountAlias,
43 + profile_path: this.profilePath,
44 + started_at: nowIso(),
45 + navigation_depth: 0,
46 + health: "starting",
47 + };
48 + }
49 +
50 + get id(): string {
51 + return this.sessionId;
52 + }
53 +
54 + hasProfile(): boolean {
55 + return fs.existsSync(path.join(this.profilePath, "Default")) || fs.existsSync(path.join(this.profilePath, "Local State"));
56 + }
57 +
58 + async start(): Promise<void> {
59 + fs.mkdirSync(this.profilePath, { recursive: true });
60 + const channel = this.opts.channel && this.opts.channel !== "chromium" ? this.opts.channel : undefined;
61 + this.context = await chromium.launchPersistentContext(this.profilePath, {
62 + headless: this.opts.headless,
63 + channel,
64 + viewport: this.opts.viewport ?? { width: 1380, height: 900 },
65 + locale: "fr-CA",
66 + timezoneId: "America/Toronto",
67 + args: ["--disable-blink-features=AutomationControlled", "--autoplay-policy=no-user-gesture-required"],
68 + ignoreDefaultArgs: ["--enable-automation"],
69 + });
70 + this.context.setDefaultTimeout(20_000);
71 + this.page = this.context.pages()[0] ?? (await this.context.newPage());
72 + // Close extra tabs the platform may open; we keep a single-tab runtime.
73 + this.context.on("page", (p) => {
74 + if (p !== this.page) p.close().catch(() => {});
75 + });
76 + this.page.on("crash", () => {
77 + this.info.health = "crashed";
78 + log.error("page crashed", { session: this.sessionId });
79 + });
80 + this.cdp = await this.context.newCDPSession(this.page);
81 + await this.cdp.send("Network.enable").catch(() => {});
82 + this.info.health = "healthy";
83 + log.info("session started", { session: this.sessionId, platform: this.platform, profile: this.profilePath, headless: this.opts.headless });
84 + this.opts.bus?.emit({
85 + event_type: "SESSION_STARTED",
86 + platform: this.platform,
87 + session_id: this.sessionId,
88 + payload: { account_alias: this.accountAlias, profile_path: this.profilePath, headless: this.opts.headless },
89 + });
90 + }
91 +
92 + async stop(): Promise<void> {
93 + this.info.health = "stopped";
94 + await this.context?.close().catch(() => {});
95 + this.opts.bus?.emit({ event_type: "SESSION_ENDED", platform: this.platform, session_id: this.sessionId, payload: { ...this.info } });
96 + log.info("session stopped", { session: this.sessionId });
97 + }
98 +
99 + getPage(): Page {
100 + if (!this.page) throw new Error("session not started");
101 + return this.page;
102 + }
103 +
104 + getContext(): BrowserContext {
105 + if (!this.context) throw new Error("session not started");
106 + return this.context;
107 + }
108 +
109 + getCdp(): CDPSession | undefined {
110 + return this.cdp;
111 + }
112 +
113 + getInfo(): SessionInfo {
114 + return { ...this.info, current_url: this.page?.url() };
115 + }
116 +
117 + setHealth(h: SessionInfo["health"]): void {
118 + this.info.health = h;
119 + }
120 +
121 + recordAction(label: string, depthDelta = 0): void {
122 + this.info.last_action = label;
123 + this.info.navigation_depth = Math.max(0, this.info.navigation_depth + depthDelta);
124 + }
125 +
126 + async navigate(url: string): Promise<void> {
127 + const page = this.getPage();
128 + await page.goto(url, { waitUntil: "domcontentloaded", timeout: 45_000 });
129 + this.info.current_url = page.url();
130 + }
131 +}
added packages/connectors/package.json +16 −0
@@ -0,0 +1,16 @@
1 +{
2 + "name": "@src/connectors",
3 + "version": "0.1.0",
4 + "private": true,
5 + "type": "module",
6 + "main": "./src/index.ts",
7 + "types": "./src/index.ts",
8 + "exports": {
9 + ".": "./src/index.ts"
10 + },
11 + "dependencies": {
12 + "@src/shared": "workspace:*",
13 + "@src/observers": "workspace:*",
14 + "playwright": "^1.63.0"
15 + }
16 +}
added packages/connectors/src/adapter.ts +182 −0
@@ -0,0 +1,182 @@
1 +import type { Page } from "playwright";
2 +import {
3 + canonicalUrl,
4 + parseCount,
5 + parseDuration,
6 + shortHash,
7 + truncate,
8 + type EntityType,
9 + type Evidenced,
10 + type ObservedEntity,
11 + type PageType,
12 + type Platform,
13 + type Provenance,
14 + type SemanticAction,
15 +} from "@src/shared";
16 +import type { ClassifierHints, DomCandidate, DomSnapshot, PageTypeHint } from "@src/observers";
17 +
18 +/**
19 + * SocialPlatformAdapter (§56/§57). Adapters hold the *platform-specific* knowledge:
20 + * page recognition, semantic labels, entity URL grammar, normalization quirks.
21 + * Everything generic (browser lifecycle, observers, planner, storage) lives outside.
22 + */
23 +export interface UrlEntityRule {
24 + pattern: RegExp; // tested against pathname+search of an absolute URL
25 + type: EntityType;
26 + /** Extract platform id from the match. */
27 + id: (m: RegExpMatchArray, url: URL) => string | undefined;
28 + confidence: number;
29 +}
30 +
31 +export interface SocialPlatformAdapter {
32 + readonly platform: Platform;
33 + readonly homeUrl: string;
34 + readonly hosts: RegExp;
35 + readonly pageTypeHints: PageTypeHint[];
36 + readonly urlEntityRules: UrlEntityRule[];
37 + readonly classifierHints: ClassifierHints;
38 + /** Build a search URL for native platform search (§43). */
39 + searchUrl(query: string): string;
40 + /** Extra filters when turning DOM links into entities (e.g. skip menu links). */
41 + isNoiseLink?(href: URL, text: string): boolean;
42 + /** Human labels for the planner. */
43 + entityLabel(type: EntityType): string;
44 + /** Optional per-page tweaks after generic extraction. */
45 + refineEntities?(entities: ObservedEntity[], snapshot: DomSnapshot): ObservedEntity[];
46 + /** Expected minimum entities on a known page type — used for degradation detection (§32). */
47 + expectedEntities(pageType: PageType): number;
48 + /** Optional: dismiss consent / cookie dialogs (read-only UI hygiene). */
49 + dismissOverlays?(page: Page): Promise<void>;
50 +}
51 +
52 +const OPEN_ACTION: Partial<Record<EntityType, SemanticAction["type"]>> = {
53 + video: "OPEN_VIDEO",
54 + post: "OPEN_POST",
55 + comment: "OPEN_COMMENTS",
56 + profile: "OPEN_PROFILE",
57 + channel: "OPEN_CHANNEL",
58 + page: "OPEN_PAGE",
59 + community: "OPEN_PAGE",
60 + person: "OPEN_PROFILE",
61 + organization: "OPEN_PAGE",
62 +};
63 +
64 +/**
65 + * Generic DOM → entity extraction, driven by the adapter's URL grammar (§57: "feed identification,
66 + * known entity classes" are platform knowledge; the walk itself is generic).
67 + */
68 +export function entitiesFromDom(snapshot: DomSnapshot, adapter: SocialPlatformAdapter): ObservedEntity[] {
69 + const out = new Map<string, ObservedEntity>();
70 + let n = 0;
71 + for (const c of snapshot.candidates) {
72 + if (!c.href) continue;
73 + let url: URL;
74 + try {
75 + url = new URL(c.href);
76 + } catch {
77 + continue;
78 + }
79 + if (!adapter.hosts.test(url.hostname)) continue;
80 + if (adapter.isNoiseLink?.(url, c.text)) continue;
81 + const target = url.pathname + url.search;
82 + for (const rule of adapter.urlEntityRules) {
83 + const m = target.match(rule.pattern);
84 + if (!m) continue;
85 + const id = rule.id(m, url);
86 + if (!id) break;
87 + const fingerprint = `${adapter.platform}:${rule.type}:${id}`;
88 + const prov: Provenance[] = [{ surface: "dom", confidence: rule.confidence, detail: `${c.kind} in ${c.region}` }];
89 + const ev = (value: unknown): Evidenced => ({ value, provenance: prov });
90 + const existing = out.get(fingerprint);
91 + const text = c.text && c.text.length > 1 ? dedupeRepeatedText(c.text) : undefined;
92 + if (existing) {
93 + // Merge: a longer text is a better title for the same link target.
94 + if (text && (!existing.name || text.length > existing.name.length) && !/^\d+:\d\d/.test(text)) {
95 + existing.name = truncate(text, 200);
96 + existing.fields.title = ev(text);
97 + }
98 + if (c.meta?.duration && !existing.media?.duration_s) existing.media = { ...(existing.media ?? { has_video: rule.type === "video", has_image: false }), duration_s: parseDuration(c.meta.duration) };
99 + if (c.meta?.views && !existing.metrics?.views) existing.metrics = { ...(existing.metrics ?? {}), views: parseCount(c.meta.views) };
100 + continue;
101 + }
102 + const fields: Record<string, Evidenced> = { platform_id: ev(id), url: ev(url.toString()) };
103 + if (text) fields.title = ev(text);
104 + if (c.meta?.author) fields.author = ev(c.meta.author);
105 + if (c.meta?.duration) fields.duration = ev(c.meta.duration);
106 + if (c.meta?.views) fields.views_text = ev(c.meta.views);
107 + if (c.meta?.published) fields.published_text = ev(c.meta.published);
108 + const metrics: ObservedEntity["metrics"] = {};
109 + const views = parseCount(c.meta?.views);
110 + if (views !== undefined) (rule.type === "video" ? (metrics.views = views) : (metrics.score = views));
111 + const comments = parseCount(c.meta?.comments);
112 + if (comments !== undefined) metrics.comments = comments;
113 + out.set(fingerprint, {
114 + ref: `D${++n}`,
115 + type: rule.type,
116 + platform: adapter.platform,
117 + platform_id: id,
118 + url: canonicalUrl(url.toString()),
119 + name: text ? truncate(text, 200) : undefined,
120 + author: c.meta?.author ? truncate(c.meta.author, 120) : undefined,
121 + metrics: Object.keys(metrics).length ? metrics : undefined,
122 + media: rule.type === "video" ? { has_video: true, has_image: false, duration_s: parseDuration(c.meta?.duration) } : undefined,
123 + published_text: c.meta?.published,
124 + context: `${c.region}${c.visible ? " (visible)" : ""} #${c.index}`,
125 + fields,
126 + provenance: prov,
127 + fingerprint,
128 + });
129 + break;
130 + }
131 + }
132 + const list = [...out.values()];
133 + return adapter.refineEntities ? adapter.refineEntities(list, snapshot) : list;
134 +}
135 +
136 +/** Turn entities + DOM controls into the semantic action list the planner may choose from (§25). */
137 +export function buildActions(entities: ObservedEntity[], snapshot: DomSnapshot, adapter: SocialPlatformAdapter, opts: { query?: string; maxOpen?: number; visited: Set<string> } ): SemanticAction[] {
138 + const actions: SemanticAction[] = [];
139 + let n = 0;
140 + const id = () => `A${++n}`;
141 + const openable = entities
142 + .filter((e) => e.url && OPEN_ACTION[e.type] && !opts.visited.has(e.fingerprint))
143 + .slice(0, opts.maxOpen ?? 25);
144 + for (const e of openable) {
145 + actions.push({
146 + id: id(),
147 + type: OPEN_ACTION[e.type]!,
148 + target_ref: e.ref,
149 + target_url: e.url,
150 + label: `Open ${adapter.entityLabel(e.type)} ${e.ref}: ${truncate(e.name ?? e.platform_id ?? e.url, 70)}`,
151 + cost: 2,
152 + });
153 + }
154 + const canScroll = snapshot.scroll.y + snapshot.scroll.viewport < snapshot.scroll.height - 50;
155 + if (canScroll) actions.push({ id: id(), type: "SCROLL_DOWN", label: "Scroll down to reveal more content", cost: 1 });
156 + if (snapshot.scroll.y > 0) actions.push({ id: id(), type: "SCROLL_UP", label: "Scroll up", cost: 1 });
157 + const expandButtons = snapshot.candidates.filter((c) => c.kind === "button" && c.visible).slice(0, 3);
158 + for (const b of expandButtons) actions.push({ id: id(), type: "EXPAND", label: `Expand: ${truncate(b.text, 60)}`, target_url: b.locator_hint, cost: 1.5 });
159 + if (snapshot.video_elements.length && !snapshot.video_elements.some((v) => v.playing)) actions.push({ id: id(), type: "PLAY_VIDEO", label: "Play the visible video (to observe media delivery)", cost: 1.5 });
160 + if (opts.query) actions.push({ id: id(), type: "SEARCH", query: opts.query, label: `Search the platform for “${opts.query}”`, cost: 2 });
161 + actions.push({ id: id(), type: "BACK", label: "Go back", cost: 1.5 });
162 + actions.push({ id: id(), type: "RETURN_TO_FEED", target_url: adapter.homeUrl, label: "Return to the home feed", cost: 2 });
163 + actions.push({ id: id(), type: "END_SESSION", label: "End the session (nothing valuable left)", cost: 0.5 });
164 + return actions;
165 +}
166 +
167 +/** Link text often repeats its accessible label ("Mila Mila", "Title Title 12:34") — keep one copy. */
168 +export function dedupeRepeatedText(text: string): string {
169 + const t = text.replace(/\s+/g, " ").trim();
170 + const half = Math.floor(t.length / 2);
171 + for (let cut = half; cut >= 4; cut--) {
172 + const a = t.slice(0, cut).trim();
173 + const rest = t.slice(cut).trim();
174 + if (rest.startsWith(a) && (rest.length === a.length || rest.length - a.length < 40)) return rest.length === a.length ? a : `${a} ${rest.slice(a.length).trim()}`.trim();
175 + }
176 + return t;
177 +}
178 +
179 +export function pageFingerprint(url: string, entities: ObservedEntity[]): string {
180 + const ids = entities.map((e) => e.fingerprint).sort().slice(0, 40).join("|");
181 + return shortHash(canonicalUrl(url) + "::" + ids, 16);
182 +}
added packages/connectors/src/index.ts +22 −0
@@ -0,0 +1,22 @@
1 +import type { Platform } from "@src/shared";
2 +import type { SocialPlatformAdapter } from "./adapter.ts";
3 +import { youtubeAdapter } from "./youtube.ts";
4 +import { redditAdapter } from "./reddit.ts";
5 +
6 +export * from "./adapter.ts";
7 +export { youtubeAdapter, redditAdapter };
8 +
9 +const ADAPTERS: Partial<Record<Platform, SocialPlatformAdapter>> = {
10 + youtube: youtubeAdapter,
11 + reddit: redditAdapter,
12 +};
13 +
14 +export function getAdapter(platform: Platform): SocialPlatformAdapter {
15 + const a = ADAPTERS[platform];
16 + if (!a) throw new Error(`No adapter for platform "${platform}" yet (Phase 1 = youtube, reddit). Use PLATFORM LEARNING mode to bootstrap one.`);
17 + return a;
18 +}
19 +
20 +export function hasAdapter(platform: Platform): boolean {
21 + return !!ADAPTERS[platform];
22 +}
added packages/connectors/src/reddit.ts +41 −0
@@ -0,0 +1,41 @@
1 +import type { EntityType, PageType } from "@src/shared";
2 +import type { ClassifierHints } from "@src/observers";
3 +import type { SocialPlatformAdapter, UrlEntityRule } from "./adapter.ts";
4 +
5 +/** Reddit adapter — Phase 1 platform (new web UI, `shreddit-*` custom elements, GraphQL + `.json` payloads). */
6 +const classifierHints: ClassifierHints = {
7 + platform: "reddit",
8 + idKeys: { postId: "post", commentId: "comment", subredditId: "community", authorId: "profile" },
9 + urlForId: (type: EntityType, id: string) => {
10 + if (type === "post" && /^t3_/.test(id)) return `https://www.reddit.com/comments/${id.slice(3)}`;
11 + if (type === "post" && /^[a-z0-9]{5,8}$/.test(id)) return `https://www.reddit.com/comments/${id}`;
12 + return undefined;
13 + },
14 +};
15 +
16 +const urlEntityRules: UrlEntityRule[] = [
17 + { pattern: /^\/r\/([\w]+)\/comments\/([a-z0-9]+)\/[^/]*\/([a-z0-9]+)/, type: "comment", id: (m) => `t1_${m[3]}`, confidence: 0.9 },
18 + { pattern: /^\/r\/([\w]+)\/comments\/([a-z0-9]+)/, type: "post", id: (m) => `t3_${m[2]}`, confidence: 0.95 },
19 + { pattern: /^\/comments\/([a-z0-9]+)/, type: "post", id: (m) => `t3_${m[1]}`, confidence: 0.9 },
20 + { pattern: /^\/(?:user|u)\/([\w-]+)\/?(?:\?|$)/, type: "profile", id: (m) => m[1]?.toLowerCase(), confidence: 0.92 },
21 + { pattern: /^\/r\/([\w]+)\/?(?:\?|$)/, type: "community", id: (m) => m[1]?.toLowerCase(), confidence: 0.92 },
22 +];
23 +
24 +export const redditAdapter: SocialPlatformAdapter = {
25 + platform: "reddit",
26 + homeUrl: "https://www.reddit.com/",
27 + hosts: /(^|\.)reddit\.com$|^redd\.it$/,
28 + pageTypeHints: [
29 + { pattern: /^\/search\/?\?/, page_type: "SEARCH_RESULTS", confidence: 0.95 },
30 + { pattern: /\/comments\//, page_type: "POST_DETAIL", confidence: 0.95 },
31 + { pattern: /^\/(user|u)\//, page_type: "PROFILE", confidence: 0.92 },
32 + { pattern: /^\/r\/[\w]+\/?(\?|$|\/(hot|new|top|rising))/, page_type: "GROUP", confidence: 0.9 },
33 + { pattern: /^\/?(\?|$)|^\/(popular|all|best)/, page_type: "HOME_FEED", confidence: 0.9 },
34 + ],
35 + urlEntityRules,
36 + classifierHints,
37 + searchUrl: (q) => `https://www.reddit.com/search/?q=${encodeURIComponent(q)}`,
38 + isNoiseLink: (href, text) => /^\/(settings|premium|coins|policies|help|topics|best\/communities|answers)/.test(href.pathname) || /^(Home|Popular|All|Accueil|Populaire)$/i.test(text),
39 + entityLabel: (t) => ({ post: "post", comment: "comment", profile: "user", community: "subreddit" } as Partial<Record<EntityType, string>>)[t] ?? t,
40 + expectedEntities: (t: PageType) => ({ HOME_FEED: 6, SEARCH_RESULTS: 5, POST_DETAIL: 3, GROUP: 6, PROFILE: 3 } as Partial<Record<PageType, number>>)[t] ?? 0,
41 +};
added packages/connectors/src/youtube.ts +86 −0
@@ -0,0 +1,86 @@
1 +import type { Page } from "playwright";
2 +import type { EntityType, ObservedEntity, PageType } from "@src/shared";
3 +import type { ClassifierHints, DomSnapshot } from "@src/observers";
4 +import type { SocialPlatformAdapter, UrlEntityRule } from "./adapter.ts";
5 +
6 +/**
7 + * YouTube adapter — Phase 1 platform. Holds only platform knowledge:
8 + * URL grammar, page hints, rich-text quirks (`runs` / `simpleText`) and id key names.
9 + * Nothing here hardcodes a YouTube endpoint: the network layer fingerprints them.
10 + */
11 +const collapseYouTubeText = (v: unknown): string | undefined => {
12 + if (!v || typeof v !== "object") return undefined;
13 + const o = v as Record<string, unknown>;
14 + if (typeof o.simpleText === "string") return o.simpleText;
15 + if (Array.isArray(o.runs)) {
16 + const s = o.runs.map((r) => (r && typeof r === "object" && typeof (r as Record<string, unknown>).text === "string" ? (r as Record<string, string>).text : "")).join("");
17 + return s || undefined;
18 + }
19 + if (typeof o.content === "string") return o.content;
20 + return undefined;
21 +};
22 +
23 +const classifierHints: ClassifierHints = {
24 + platform: "youtube",
25 + textCollapsers: [collapseYouTubeText],
26 + idKeys: { videoId: "video", channelId: "channel", commentId: "comment", playlistId: "post" },
27 + urlForId: (type: EntityType, id: string) => {
28 + if (type === "video") return `https://www.youtube.com/watch?v=${id}`;
29 + if (type === "channel") return `https://www.youtube.com/channel/${id}`;
30 + return undefined;
31 + },
32 +};
33 +
34 +const urlEntityRules: UrlEntityRule[] = [
35 + { pattern: /^\/watch\?(?:.*&)?v=([A-Za-z0-9_-]{11})/, type: "video", id: (m) => m[1], confidence: 0.95 },
36 + { pattern: /^\/shorts\/([A-Za-z0-9_-]{11})/, type: "video", id: (m) => m[1], confidence: 0.95 },
37 + { pattern: /^\/(@[\w.-]{3,60})(?:\/|$|\?)/, type: "channel", id: (m) => m[1], confidence: 0.92 },
38 + { pattern: /^\/channel\/(UC[\w-]{20,})/, type: "channel", id: (m) => m[1], confidence: 0.95 },
39 + { pattern: /^\/c\/([\w.-]+)/, type: "channel", id: (m) => `c/${m[1]}`, confidence: 0.85 },
40 + { pattern: /^\/user\/([\w.-]+)/, type: "channel", id: (m) => `user/${m[1]}`, confidence: 0.85 },
41 + { pattern: /^\/post\/([\w-]+)/, type: "post", id: (m) => m[1], confidence: 0.9 },
42 + { pattern: /^\/hashtag\/([\w-]+)/, type: "hashtag", id: (m) => m[1]?.toLowerCase(), confidence: 0.9 },
43 +];
44 +
45 +export const youtubeAdapter: SocialPlatformAdapter = {
46 + platform: "youtube",
47 + homeUrl: "https://www.youtube.com/",
48 + hosts: /(^|\.)youtube\.com$|^youtu\.be$/,
49 + pageTypeHints: [
50 + { pattern: /^\/results\?/, page_type: "SEARCH_RESULTS", confidence: 0.95 },
51 + { pattern: /^\/watch\?/, page_type: "VIDEO_DETAIL", confidence: 0.95 },
52 + { pattern: /^\/shorts\//, page_type: "VIDEO_DETAIL", confidence: 0.9 },
53 + { pattern: /^\/(@[\w.-]+|channel\/|c\/|user\/)/, page_type: "CHANNEL", confidence: 0.92 },
54 + { pattern: /^\/post\//, page_type: "POST_DETAIL", confidence: 0.9 },
55 + { pattern: /^\/feed\//, page_type: "HOME_FEED", confidence: 0.85 },
56 + { pattern: /^\/?$/, page_type: "HOME_FEED", confidence: 0.9 },
57 + ],
58 + urlEntityRules,
59 + classifierHints,
60 + searchUrl: (q) => `https://www.youtube.com/results?search_query=${encodeURIComponent(q)}`,
61 + isNoiseLink: (href, text) => /^\/(feed\/(library|history|subscriptions)|premium|account|reporthistory|paid_memberships|t\/|about|howyoutubeworks|new|creators|ads)/.test(href.pathname) || /^(Accueil|Home|Shorts|Abonnements|Subscriptions|Vous|You|Historique|History)$/i.test(text),
62 + entityLabel: (t) => ({ video: "video", channel: "channel", post: "community post", comment: "comment", hashtag: "hashtag" } as Partial<Record<EntityType, string>>)[t] ?? t,
63 + refineEntities: (entities: ObservedEntity[], snapshot: DomSnapshot) => {
64 + // On a watch page, the entity for the current video should be first and marked as "current".
65 + const m = snapshot.url.match(/[?&]v=([A-Za-z0-9_-]{11})/);
66 + if (m) {
67 + const cur = entities.find((e) => e.type === "video" && e.platform_id === m[1]);
68 + if (cur) {
69 + cur.context = "current video";
70 + if (snapshot.h1 && (!cur.name || cur.name.length < snapshot.h1.length)) cur.name = snapshot.h1;
71 + return [cur, ...entities.filter((e) => e !== cur)];
72 + }
73 + }
74 + return entities;
75 + },
76 + expectedEntities: (t: PageType) => ({ HOME_FEED: 8, SEARCH_RESULTS: 8, VIDEO_DETAIL: 5, CHANNEL: 4 } as Partial<Record<PageType, number>>)[t] ?? 0,
77 + dismissOverlays: async (page: Page) => {
78 + // Consent / "Sign in" nags — clicking a *dismiss* control is read-only UI hygiene, not an interaction with content.
79 + for (const sel of ['button[aria-label*="Accept" i]', 'button[aria-label*="Accepter" i]', 'tp-yt-paper-dialog #dismiss-button', 'ytd-popup-container button[aria-label*="No thanks" i]', 'ytd-popup-container button[aria-label*="Non merci" i]']) {
80 + const b = page.locator(sel).first();
81 + if (await b.isVisible().catch(() => false)) {
82 + await b.click({ timeout: 2000 }).catch(() => {});
83 + }
84 + }
85 + },
86 +};
added packages/entities/package.json +15 −0
@@ -0,0 +1,15 @@
1 +{
2 + "name": "@src/entities",
3 + "version": "0.1.0",
4 + "private": true,
5 + "type": "module",
6 + "main": "./src/index.ts",
7 + "types": "./src/index.ts",
8 + "exports": {
9 + ".": "./src/index.ts"
10 + },
11 + "dependencies": {
12 + "@src/shared": "workspace:*",
13 + "@src/events": "workspace:*"
14 + }
15 +}
added packages/entities/src/entities.test.ts +38 −0
@@ -0,0 +1,38 @@
1 +import { describe, expect, it } from "vitest";
2 +import type { ObservedEntity } from "@src/shared";
3 +import { mergeSurfaces, resolveIdentity, entityConfidence } from "./index.ts";
4 +
5 +const mk = (surface: "dom" | "network", over: Partial<ObservedEntity>): ObservedEntity => ({ ref: "x", type: "video", platform: "youtube", platform_id: "v1", fingerprint: "youtube:video:v1", fields: { title: { value: over.name, provenance: [{ surface, confidence: surface === "network" ? 0.95 : 0.85 }] } }, provenance: [{ surface, confidence: surface === "network" ? 0.95 : 0.85 }], ...over });
6 +
7 +describe("mergeSurfaces", () => {
8 + it("merges the same entity from network and DOM, keeping both provenances", () => {
9 + const net = [mk("network", { name: "IA au Québec — table ronde", author: "Chaîne", metrics: { views: 1200000 } })];
10 + const dom = [mk("dom", { name: "IA au Québec — table ronde", url: "https://youtube.com/watch?v=v1" })];
11 + const r = mergeSurfaces(net, dom);
12 + expect(r.merged).toHaveLength(1);
13 + expect(r.both).toBe(1);
14 + expect(r.field_agreements).toBe(1);
15 + const e = r.merged[0]!;
16 + expect(new Set(e.provenance.map((p) => p.surface))).toEqual(new Set(["dom", "network"]));
17 + expect(e.author).toBe("Chaîne");
18 + expect(e.url).toContain("v1");
19 + expect(e.ref).toBe("E1");
20 + expect(entityConfidence(e)).toBeGreaterThan(0.95);
21 + });
22 + it("reports conflicts", () => {
23 + const r = mergeSurfaces([mk("network", { name: "Titre A" })], [mk("dom", { name: "Complètement autre" })]);
24 + expect(r.field_conflicts).toHaveLength(1);
25 + });
26 +});
27 +
28 +describe("resolveIdentity", () => {
29 + it("never merges on display name alone", () => {
30 + const d = resolveIdentity({ id: "a", display_name: "Simon B", urls: [], usernames: [] }, { id: "b", display_name: "Simon B", urls: [], usernames: [] });
31 + expect(d.merge).toBe(false);
32 + });
33 + it("merges with a shared canonical url + username", () => {
34 + const d = resolveIdentity({ id: "a", display_name: "Simon B", urls: ["https://example.com"], usernames: ["@simon"] }, { id: "b", urls: ["https://example.com"], usernames: ["simon"] });
35 + expect(d.merge).toBe(true);
36 + expect(d.evidence).toContain("same canonical url");
37 + });
38 +});
added packages/entities/src/index.ts +135 −0
@@ -0,0 +1,135 @@
1 +import { clamp01, type Evidenced, type ObservedEntity, type Provenance } from "@src/shared";
2 +
3 +/**
4 + * Entity Extraction Layer (§5 step 6, §37–§39): merge the same entity seen on several surfaces,
5 + * keep provenance per field, compare network vs DOM evidence.
6 + */
7 +
8 +export interface MergeReport {
9 + merged: ObservedEntity[];
10 + network_only: number;
11 + dom_only: number;
12 + both: number;
13 + field_agreements: number;
14 + field_conflicts: { fingerprint: string; field: string; network: unknown; dom: unknown }[];
15 +}
16 +
17 +const COMPARABLE_FIELDS = ["title", "author", "duration", "views_text"] as const;
18 +
19 +function normalizeForCompare(v: unknown): string {
20 + return String(v ?? "").toLowerCase().replace(/\s+/g, " ").trim();
21 +}
22 +
23 +export function mergeSurfaces(network: ObservedEntity[], dom: ObservedEntity[]): MergeReport {
24 + const byFp = new Map<string, ObservedEntity>();
25 + const report: MergeReport = { merged: [], network_only: 0, dom_only: 0, both: 0, field_agreements: 0, field_conflicts: [] };
26 +
27 + for (const e of dom) byFp.set(e.fingerprint, cloneEntity(e));
28 + for (const n of network) {
29 + const d = byFp.get(n.fingerprint);
30 + if (!d) {
31 + byFp.set(n.fingerprint, cloneEntity(n));
32 + report.network_only++;
33 + continue;
34 + }
35 + report.both++;
36 + // Combine provenance and fields
37 + d.provenance = dedupeProv([...d.provenance, ...n.provenance]);
38 + for (const [k, nv] of Object.entries(n.fields)) {
39 + const dv = d.fields[k];
40 + if (!dv) {
41 + d.fields[k] = nv;
42 + continue;
43 + }
44 + if ((COMPARABLE_FIELDS as readonly string[]).includes(k)) {
45 + const a = normalizeForCompare(dv.value);
46 + const b = normalizeForCompare(nv.value);
47 + if (a && b && (a === b || a.includes(b) || b.includes(a))) report.field_agreements++;
48 + else if (a && b) report.field_conflicts.push({ fingerprint: n.fingerprint, field: k, network: nv.value, dom: dv.value });
49 + }
50 + d.fields[k] = { value: preferValue(dv, nv), provenance: dedupeProv([...dv.provenance, ...nv.provenance]) };
51 + }
52 + d.name = d.name && n.name ? (n.name.length >= d.name.length ? n.name : d.name) : (d.name ?? n.name);
53 + d.text = d.text ?? n.text;
54 + d.author = d.author ?? n.author;
55 + // Structured network numbers beat numbers scraped from card text.
56 + d.metrics = { ...(d.metrics ?? {}), ...(n.metrics ?? {}) };
57 + if (Object.keys(d.metrics).length === 0) d.metrics = undefined;
58 + d.media = d.media || n.media ? { has_video: !!(d.media?.has_video || n.media?.has_video), has_image: !!(d.media?.has_image || n.media?.has_image), duration_s: d.media?.duration_s ?? n.media?.duration_s, thumbnail_url: d.media?.thumbnail_url ?? n.media?.thumbnail_url } : undefined;
59 + d.url = d.url ?? n.url;
60 + }
61 + report.dom_only = dom.length - report.both;
62 + // Re-number refs so the planner sees E1..En in a stable order (DOM order first, then network-only).
63 + let i = 0;
64 + for (const e of byFp.values()) e.ref = `E${++i}`;
65 + report.merged = [...byFp.values()];
66 + return report;
67 +}
68 +
69 +function preferValue(a: Evidenced, b: Evidenced): unknown {
70 + const ca = Math.max(...a.provenance.map((p) => p.confidence));
71 + const cb = Math.max(...b.provenance.map((p) => p.confidence));
72 + if (typeof a.value === "string" && typeof b.value === "string" && Math.abs(ca - cb) < 0.1) return a.value.length >= b.value.length ? a.value : b.value;
73 + return cb > ca ? b.value : a.value;
74 +}
75 +
76 +function dedupeProv(list: Provenance[]): Provenance[] {
77 + const seen = new Map<string, Provenance>();
78 + for (const p of list) {
79 + const k = p.surface + "|" + (p.detail ?? "");
80 + const prev = seen.get(k);
81 + if (!prev || prev.confidence < p.confidence) seen.set(k, p);
82 + }
83 + return [...seen.values()].sort((a, b) => b.confidence - a.confidence);
84 +}
85 +
86 +function cloneEntity(e: ObservedEntity): ObservedEntity {
87 + return { ...e, fields: { ...e.fields }, provenance: [...e.provenance], metrics: e.metrics ? { ...e.metrics } : undefined, media: e.media ? { ...e.media } : undefined };
88 +}
89 +
90 +/** Overall entity confidence = best provenance, boosted when two surfaces agree (§59). */
91 +export function entityConfidence(e: ObservedEntity): number {
92 + const surfaces = new Set(e.provenance.map((p) => p.surface));
93 + const best = Math.max(0, ...e.provenance.map((p) => p.confidence));
94 + return clamp01(best + (surfaces.size > 1 ? 0.05 : 0) - (!e.name && !e.text ? 0.2 : 0));
95 +}
96 +
97 +/**
98 + * IdentityResolver (§21) — minimal skeleton. Never merges on names alone: requires at least
99 + * one strong signal (same canonical url, cross-link, same platform id). Fuzzy names only add evidence.
100 + */
101 +export interface IdentityCandidate {
102 + id: string;
103 + display_name?: string;
104 + urls: string[];
105 + usernames: string[];
106 +}
107 +
108 +export interface MatchDecision {
109 + candidate_a: string;
110 + candidate_b: string;
111 + match_probability: number;
112 + evidence: string[];
113 + merge: boolean;
114 +}
115 +
116 +export function resolveIdentity(a: IdentityCandidate, b: IdentityCandidate): MatchDecision {
117 + const evidence: string[] = [];
118 + let p = 0;
119 + const urlsA = new Set(a.urls.map((u) => u.toLowerCase()));
120 + if (b.urls.some((u) => urlsA.has(u.toLowerCase()))) {
121 + evidence.push("same canonical url");
122 + p += 0.7;
123 + }
124 + const uA = new Set(a.usernames.map((u) => u.toLowerCase().replace(/^@/, "")));
125 + if (b.usernames.some((u) => uA.has(u.toLowerCase().replace(/^@/, "")))) {
126 + evidence.push("same username");
127 + p += 0.35;
128 + }
129 + if (a.display_name && b.display_name && a.display_name.toLowerCase() === b.display_name.toLowerCase()) {
130 + evidence.push("same display name (weak)");
131 + p += 0.1;
132 + }
133 + const prob = clamp01(p);
134 + return { candidate_a: a.id, candidate_b: b.id, match_probability: prob, evidence, merge: prob >= 0.8 && evidence.some((e) => !e.includes("weak")) };
135 +}
added packages/events/package.json +14 −0
@@ -0,0 +1,14 @@
1 +{
2 + "name": "@src/events",
3 + "version": "0.1.0",
4 + "private": true,
5 + "type": "module",
6 + "main": "./src/index.ts",
7 + "types": "./src/index.ts",
8 + "exports": {
9 + ".": "./src/index.ts"
10 + },
11 + "dependencies": {
12 + "@src/shared": "workspace:*"
13 + }
14 +}
added packages/events/src/index.ts +128 −0
@@ -0,0 +1,128 @@
1 +import fs from "node:fs";
2 +import path from "node:path";
3 +import { newId, nowIso, type Platform, type Provenance } from "@src/shared";
4 +
5 +/** Universal Social Event Format (§33, §34). */
6 +export type SocialEventType =
7 + | "SESSION_STARTED"
8 + | "SESSION_ENDED"
9 + | "PAGE_OPENED"
10 + | "PAGE_CLASSIFIED"
11 + | "ENTITY_DISCOVERED"
12 + | "ENTITY_OBSERVED"
13 + | "ENTITY_UPDATED"
14 + | "POST_DISCOVERED"
15 + | "COMMENT_DISCOVERED"
16 + | "PROFILE_DISCOVERED"
17 + | "MEDIA_DISCOVERED"
18 + | "VIDEO_DISCOVERED"
19 + | "IMAGE_DISCOVERED"
20 + | "FEED_ITEM_OBSERVED"
21 + | "NETWORK_RESPONSE_OBSERVED"
22 + | "NETWORK_SCHEMA_DISCOVERED"
23 + | "WEBSOCKET_FRAME_OBSERVED"
24 + | "DOM_CHANGED"
25 + | "ACTION_PLANNED"
26 + | "ACTION_EXECUTED"
27 + | "ACTION_FAILED"
28 + | "NAVIGATION_COMPLETED"
29 + | "LOOP_DETECTED"
30 + | "BUDGET_EXHAUSTED"
31 + | "AUTH_REQUIRED"
32 + | "CONNECTOR_PATTERN_LEARNED"
33 + | "CONNECTOR_DEGRADED"
34 + | "CONNECTOR_REPAIRED"
35 + | "WORKER_ERROR";
36 +
37 +export interface SocialEvent<T = Record<string, unknown>> {
38 + event_id: string;
39 + event_type: SocialEventType;
40 + platform: Platform;
41 + session_id: string;
42 + step?: number;
43 + timestamp: string;
44 + payload: T;
45 + provenance?: Provenance[];
46 + discovered_via?: { action?: string; action_id?: string; url?: string };
47 +}
48 +
49 +export type EventHandler = (ev: SocialEvent) => void | Promise<void>;
50 +
51 +/**
52 + * Typed in-process event bus. Observers publish, storage / learner / dashboard subscribe.
53 + * Handlers are awaited sequentially so persistence stays ordered.
54 + */
55 +export class EventBus {
56 + private handlers: { type: SocialEventType | "*"; fn: EventHandler }[] = [];
57 + private queue: Promise<void> = Promise.resolve();
58 + public count = 0;
59 +
60 + on(type: SocialEventType | "*", fn: EventHandler): () => void {
61 + const h = { type, fn };
62 + this.handlers.push(h);
63 + return () => {
64 + this.handlers = this.handlers.filter((x) => x !== h);
65 + };
66 + }
67 +
68 + emit<T extends Record<string, unknown>>(
69 + partial: Omit<SocialEvent<T>, "event_id" | "timestamp"> & { event_id?: string; timestamp?: string },
70 + ): SocialEvent<T> {
71 + const ev: SocialEvent<T> = {
72 + event_id: partial.event_id ?? newId("ev"),
73 + timestamp: partial.timestamp ?? nowIso(),
74 + ...partial,
75 + };
76 + this.count++;
77 + const targets = this.handlers.filter((h) => h.type === "*" || h.type === ev.event_type);
78 + this.queue = this.queue.then(async () => {
79 + for (const h of targets) {
80 + try {
81 + await h.fn(ev as SocialEvent);
82 + } catch (err) {
83 + process.stderr.write(`[events] handler error on ${ev.event_type}: ${(err as Error).message}\n`);
84 + }
85 + }
86 + });
87 + return ev;
88 + }
89 +
90 + /** Wait for all queued handlers (call before shutdown). */
91 + flush(): Promise<void> {
92 + return this.queue;
93 + }
94 +}
95 +
96 +/** Append-only JSONL session log — the raw record every replay (§54) is built from. */
97 +export class JsonlEventLog {
98 + private stream: fs.WriteStream;
99 + readonly file: string;
100 +
101 + constructor(sessionDir: string) {
102 + fs.mkdirSync(sessionDir, { recursive: true });
103 + this.file = path.join(sessionDir, "events.jsonl");
104 + this.stream = fs.createWriteStream(this.file, { flags: "a" });
105 + }
106 +
107 + write(ev: SocialEvent): void {
108 + this.stream.write(JSON.stringify(ev) + "\n");
109 + }
110 +
111 + attach(bus: EventBus): () => void {
112 + return bus.on("*", (ev) => this.write(ev));
113 + }
114 +
115 + close(): Promise<void> {
116 + return new Promise((res) => this.stream.end(res));
117 + }
118 +
119 + static read(sessionDir: string): SocialEvent[] {
120 + const file = path.join(sessionDir, "events.jsonl");
121 + if (!fs.existsSync(file)) return [];
122 + return fs
123 + .readFileSync(file, "utf8")
124 + .split("\n")
125 + .filter(Boolean)
126 + .map((l) => JSON.parse(l) as SocialEvent);
127 + }
128 +}
added packages/media/package.json +16 −0
@@ -0,0 +1,16 @@
1 +{
2 + "name": "@src/media",
3 + "version": "0.1.0",
4 + "private": true,
5 + "type": "module",
6 + "main": "./src/index.ts",
7 + "types": "./src/index.ts",
8 + "exports": {
9 + ".": "./src/index.ts"
10 + },
11 + "dependencies": {
12 + "@src/shared": "workspace:*",
13 + "@src/events": "workspace:*",
14 + "playwright": "^1.63.0"
15 + }
16 +}
added packages/media/src/index.ts +117 −0
@@ -0,0 +1,117 @@
1 +import fs from "node:fs";
2 +import path from "node:path";
3 +import type { Page } from "playwright";
4 +import { createLogger, shortHash, sleep, type MediaLevel, type ObservedEntity, type ObservedMedia, type Platform, type Provenance } from "@src/shared";
5 +import type { ClassifiedResponse, DomSnapshot } from "@src/observers";
6 +
7 +const log = createLogger("media");
8 +
9 +/**
10 + * Media Intelligence Layer (§17–§19, §47). Video detection fuses three surfaces:
11 + * - DOM <video> elements (playback state, dimensions, duration)
12 + * - network media manifests / segments (delivery kind + CDN hostnames)
13 + * - entities of type video (platform id, title, author, thumbnail)
14 + * Frame sampling (LEVEL 2) captures a few element screenshots at fixed positions — no download of the artifact.
15 + */
16 +export function detectVideos(opts: { platform: Platform; snapshot: DomSnapshot; entities: ObservedEntity[]; responses: ClassifiedResponse[]; pageUrl: string }): ObservedMedia[] {
17 + const out = new Map<string, ObservedMedia>();
18 + const mediaResponses = opts.responses.filter((r) => r.kind === "media_manifest" || r.kind === "media_segment");
19 + const hostnames = [...new Set(mediaResponses.map((r) => r.fingerprint.hostname))];
20 + const manifest = mediaResponses.find((r) => r.kind === "media_manifest");
21 + const deliveryKind: "progressive" | "hls" | "dash" | "unknown" = manifest
22 + ? /m3u8|mpegurl/i.test(manifest.response.url + manifest.response.content_type)
23 + ? "hls"
24 + : /mpd|dash/i.test(manifest.response.url + manifest.response.content_type)
25 + ? "dash"
26 + : "unknown"
27 + : mediaResponses.length
28 + ? "progressive"
29 + : "unknown";
30 +
31 + // 1) Current video (the one playing / present in DOM) — bind it to the current-page video entity when there is one.
32 + const current = opts.entities.find((e) => e.type === "video" && (e.context === "current video" || (e.url && samePage(e.url, opts.pageUrl))));
33 + for (const v of opts.snapshot.video_elements) {
34 + if (!(v.width > 0 || v.duration || v.current_src)) continue;
35 + const id = current?.platform_id ?? shortHash(v.current_src ?? v.src ?? opts.pageUrl, 12);
36 + const fp = `${opts.platform}:video:${id}`;
37 + const prov: Provenance[] = [{ surface: "dom", confidence: 0.9, detail: "video element" }];
38 + if (mediaResponses.length) prov.push({ surface: "network", confidence: 0.85, detail: `${mediaResponses.length} media responses` });
39 + if (current) prov.push(...current.provenance);
40 + out.set(fp, {
41 + media_type: "video",
42 + platform: opts.platform,
43 + platform_media_id: current?.platform_id ?? undefined,
44 + url: v.current_src ?? v.src,
45 + page_url: opts.pageUrl,
46 + title: current?.name ?? opts.snapshot.h1,
47 + author: current?.author,
48 + duration_s: v.duration ?? current?.media?.duration_s,
49 + width: v.width || undefined,
50 + height: v.height || undefined,
51 + thumbnail_url: v.poster ?? current?.media?.thumbnail_url,
52 + delivery: { kind: deliveryKind, manifest_url: manifest?.response.url, hostnames },
53 + fingerprint: fp,
54 + provenance: prov,
55 + });
56 + }
57 + // 2) Video entities visible on the page (feed cards, search results): metadata-level media records.
58 + for (const e of opts.entities) {
59 + if (e.type !== "video" || out.has(e.fingerprint)) continue;
60 + out.set(e.fingerprint, {
61 + media_type: "video",
62 + platform: opts.platform,
63 + platform_media_id: e.platform_id,
64 + page_url: e.url,
65 + title: e.name,
66 + author: e.author,
67 + duration_s: e.media?.duration_s,
68 + thumbnail_url: e.media?.thumbnail_url,
69 + fingerprint: e.fingerprint,
70 + provenance: e.provenance,
71 + });
72 + }
73 + return [...out.values()];
74 +}
75 +
76 +function samePage(a: string, b: string): boolean {
77 + try {
78 + const ua = new URL(a);
79 + const ub = new URL(b);
80 + return ua.pathname === ub.pathname && ua.searchParams.get("v") === ub.searchParams.get("v");
81 + } catch {
82 + return false;
83 + }
84 +}
85 +
86 +/**
87 + * VideoFrameSampler (§18): LEVEL ≥ 2 — screenshot the <video> element at 0 / 25 / 50 / 75 / 100 %.
88 + * Seeking a video the account is legitimately watching is normal viewer behaviour.
89 + * Returns file paths; perceptual hashing is left as a follow-up (MediaDeduplicator seam).
90 + */
91 +export async function sampleVideoFrames(page: Page, media: ObservedMedia, mediaDir: string, level: MediaLevel, maxFrames = 5): Promise<string[]> {
92 + if (level < 2) return [];
93 + const video = page.locator("video").first();
94 + if (!(await video.isVisible().catch(() => false))) return [];
95 + const dir = path.join(mediaDir, media.platform, media.platform_media_id ?? shortHash(media.fingerprint, 10));
96 + fs.mkdirSync(dir, { recursive: true });
97 + const duration = media.duration_s ?? (await video.evaluate((v: HTMLVideoElement) => (Number.isFinite(v.duration) ? v.duration : 0)).catch(() => 0));
98 + const positions = [0, 0.25, 0.5, 0.75, 0.98].slice(0, maxFrames);
99 + const files: string[] = [];
100 + for (const p of positions) {
101 + try {
102 + if (duration > 2) {
103 + await video.evaluate((v: HTMLVideoElement, t: number) => {
104 + v.currentTime = t;
105 + }, Math.min(duration - 0.5, duration * p));
106 + await sleep(600);
107 + }
108 + const file = path.join(dir, `frame_${Math.round(p * 100)}.jpg`);
109 + await video.screenshot({ path: file, type: "jpeg", quality: 70, timeout: 5000 });
110 + files.push(file);
111 + if (duration <= 2) break;
112 + } catch (err) {
113 + log.debug("frame capture failed", { err: (err as Error).message });
114 + }
115 + }
116 + return files;
117 +}
added packages/observers/package.json +16 −0
@@ -0,0 +1,16 @@
1 +{
2 + "name": "@src/observers",
3 + "version": "0.1.0",
4 + "private": true,
5 + "type": "module",
6 + "main": "./src/index.ts",
7 + "types": "./src/index.ts",
8 + "exports": {
9 + ".": "./src/index.ts"
10 + },
11 + "dependencies": {
12 + "@src/shared": "workspace:*",
13 + "@src/events": "workspace:*",
14 + "playwright": "^1.63.0"
15 + }
16 +}
added packages/observers/src/PageClassifier.test.ts +22 −0
@@ -0,0 +1,22 @@
1 +import { describe, expect, it } from "vitest";
2 +import { classifyPage } from "./PageClassifier.ts";
3 +import type { DomSnapshot } from "./dom/PageSummarizer.ts";
4 +import { youtubeAdapter, redditAdapter } from "@src/connectors";
5 +
6 +const base = (url: string, extra: Partial<DomSnapshot> = {}): DomSnapshot => ({ url, title: "t", landmarks: ["main", "navigation"], candidates: [], video_elements: [], text_excerpt: "", scroll: { y: 0, height: 5000, viewport: 900 }, has_login_form: false, dialog_open: false, ...extra });
7 +
8 +describe("classifyPage", () => {
9 + it("uses adapter url hints", () => {
10 + expect(classifyPage(base("https://www.youtube.com/results?search_query=ai"), youtubeAdapter.pageTypeHints).page_type).toBe("SEARCH_RESULTS");
11 + expect(classifyPage(base("https://www.youtube.com/watch?v=dQw4w9WgXcQ"), youtubeAdapter.pageTypeHints).page_type).toBe("VIDEO_DETAIL");
12 + expect(classifyPage(base("https://www.youtube.com/@someone/videos"), youtubeAdapter.pageTypeHints).page_type).toBe("CHANNEL");
13 + expect(classifyPage(base("https://www.reddit.com/r/quebec/comments/abc123/titre/"), redditAdapter.pageTypeHints).page_type).toBe("POST_DETAIL");
14 + expect(classifyPage(base("https://www.reddit.com/r/quebec/"), redditAdapter.pageTypeHints).page_type).toBe("GROUP");
15 + });
16 + it("flags login pages and unknown pages without throwing", () => {
17 + expect(classifyPage(base("https://example.com/x", { has_login_form: true })).page_type).toBe("LOGIN");
18 + const u = classifyPage(base("https://example.com/whatever"));
19 + expect(u.page_type).toBe("UNKNOWN");
20 + expect(u.confidence).toBeLessThan(0.5);
21 + });
22 +});
added packages/observers/src/PageClassifier.ts +53 −0
@@ -0,0 +1,53 @@
1 +import type { PageClassification, PageType } from "@src/shared";
2 +import type { DomSnapshot } from "./dom/PageSummarizer.ts";
3 +
4 +/** A URL-pattern hint supplied by a platform adapter (§58). */
5 +export interface PageTypeHint {
6 + pattern: RegExp; // tested against pathname + search
7 + page_type: PageType;
8 + confidence: number;
9 +}
10 +
11 +/**
12 + * Generic page classifier combining URL hints, DOM landmarks and content density (§58–§60).
13 + * Returns UNKNOWN with low confidence rather than throwing — unknown pages are expected.
14 + */
15 +export function classifyPage(snapshot: DomSnapshot, hints: PageTypeHint[] = []): PageClassification {
16 + const signals: string[] = [];
17 + let url: URL | undefined;
18 + try {
19 + url = new URL(snapshot.url);
20 + } catch {
21 + /* ignore */
22 + }
23 + const scores = new Map<PageType, number>();
24 + const bump = (t: PageType, c: number, why: string) => {
25 + scores.set(t, Math.max(scores.get(t) ?? 0, c));
26 + signals.push(`${why}→${t}`);
27 + };
28 +
29 + if (snapshot.has_login_form) bump("LOGIN", 0.9, "password field");
30 +
31 + if (url) {
32 + const target = url.pathname + url.search;
33 + for (const h of hints) if (h.pattern.test(target)) bump(h.page_type, h.confidence, `url:${h.pattern.source.slice(0, 30)}`);
34 + if (target === "/" || target === "") bump("HOME_FEED", 0.6, "root path");
35 + if (/search|results|\?q=|\?search_query=/i.test(target)) bump("SEARCH_RESULTS", 0.7, "search in url");
36 + }
37 +
38 + const links = snapshot.candidates.filter((c) => c.kind === "link");
39 + const articles = snapshot.candidates.filter((c) => c.kind === "article");
40 + const mainLinks = links.filter((c) => c.region === "main" || c.region === "feed").length;
41 + const hasVideo = snapshot.video_elements.some((v) => v.width > 0 || v.duration);
42 + const commentsRegion = snapshot.candidates.some((c) => c.region === "comments");
43 +
44 + if (hasVideo && snapshot.video_elements.length === 1 && snapshot.landmarks.length > 2) bump("VIDEO_DETAIL", 0.6, "single video element");
45 + if (commentsRegion && (hasVideo || articles.length <= 2)) bump(hasVideo ? "VIDEO_DETAIL" : "POST_DETAIL", 0.55, "comments region");
46 + if (articles.length >= 5 && !hasVideo) bump("HOME_FEED", 0.5, `${articles.length} articles`);
47 + if (mainLinks >= 15 && articles.length >= 5 && scores.get("SEARCH_RESULTS") === undefined) bump("HOME_FEED", 0.45, "dense feed");
48 + if (snapshot.candidates.some((c) => c.kind === "search" && c.visible) && snapshot.candidates.some((c) => c.kind === "heading" && /result|résultat/i.test(c.text))) bump("SEARCH_RESULTS", 0.65, "results heading");
49 +
50 + if (scores.size === 0) return { page_type: "UNKNOWN", confidence: 0.2, signals: ["no signal"] };
51 + const [best, conf] = [...scores.entries()].sort((a, b) => b[1] - a[1])[0]!;
52 + return { page_type: best, confidence: Math.min(0.99, conf), signals: signals.slice(0, 12) };
53 +}
added packages/observers/src/dom/DomObserver.ts +134 −0
@@ -0,0 +1,134 @@
1 +import type { Page } from "playwright";
2 +import { createLogger, type Platform } from "@src/shared";
3 +import type { EventBus } from "@src/events";
4 +
5 +const log = createLogger("dom");
6 +
7 +export interface DomDelta {
8 + added: number;
9 + removed: number;
10 + text_changes: number;
11 + attr_changes: number;
12 + regions: Record<string, number>; // landmark/role region → nodes added
13 + new_videos: number;
14 + new_articles: number;
15 + new_links: number;
16 + modal_opened: boolean;
17 + url: string;
18 + at: number;
19 +}
20 +
21 +/**
22 + * Injected before any page script (§13). Buffers MutationObserver deltas and flushes
23 + * them to Node every 500 ms through an exposed binding. Never serializes the whole DOM.
24 + */
25 +const INJECTED = String.raw`
26 +(() => {
27 + if (window.__srcDomObserverInstalled) return;
28 + window.__srcDomObserverInstalled = true;
29 + const state = { added: 0, removed: 0, text_changes: 0, attr_changes: 0, regions: {}, new_videos: 0, new_articles: 0, new_links: 0, modal_opened: false };
30 + const regionOf = (node) => {
31 + let el = node.nodeType === 1 ? node : node.parentElement;
32 + let hops = 0;
33 + while (el && hops++ < 25) {
34 + const role = el.getAttribute && (el.getAttribute('role') || '');
35 + const tag = el.tagName ? el.tagName.toLowerCase() : '';
36 + if (role === 'main' || tag === 'main') return 'main';
37 + if (role === 'feed' ) return 'feed';
38 + if (role === 'dialog' || el.getAttribute && el.getAttribute('aria-modal') === 'true') return 'dialog';
39 + if (role === 'navigation' || tag === 'nav') return 'navigation';
40 + if (role === 'complementary' || tag === 'aside') return 'sidebar';
41 + if (tag === 'header' || role === 'banner') return 'header';
42 + if (tag === 'ytd-comments' || (el.id && /comment/i.test(el.id))) return 'comments';
43 + el = el.parentElement;
44 + }
45 + return 'unknown';
46 + };
47 + const count = (node) => {
48 + if (node.nodeType !== 1) return;
49 + const el = node;
50 + const tag = el.tagName.toLowerCase();
51 + if (tag === 'video' || el.querySelector && el.querySelector('video')) state.new_videos++;
52 + if (tag === 'article' || el.getAttribute('role') === 'article' || (el.querySelectorAll && el.querySelectorAll('article,[role=article]').length)) state.new_articles++;
53 + if (tag === 'a' ) state.new_links++; else if (el.querySelectorAll) state.new_links += Math.min(50, el.querySelectorAll('a[href]').length);
54 + if (el.getAttribute('role') === 'dialog' || el.getAttribute('aria-modal') === 'true') state.modal_opened = true;
55 + };
56 + const obs = new MutationObserver((muts) => {
57 + for (const m of muts) {
58 + if (m.type === 'childList') {
59 + if (m.addedNodes.length) {
60 + state.added += m.addedNodes.length;
61 + const r = regionOf(m.target);
62 + state.regions[r] = (state.regions[r] || 0) + m.addedNodes.length;
63 + m.addedNodes.forEach(count);
64 + }
65 + state.removed += m.removedNodes.length;
66 + } else if (m.type === 'characterData') state.text_changes++;
67 + else if (m.type === 'attributes') state.attr_changes++;
68 + }
69 + });
70 + const start = () => obs.observe(document.documentElement, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['src', 'href', 'aria-expanded', 'aria-hidden', 'hidden', 'class'] });
71 + if (document.documentElement) start(); else document.addEventListener('DOMContentLoaded', start);
72 + setInterval(() => {
73 + if (!state.added && !state.removed && !state.text_changes && !state.attr_changes) return;
74 + const snapshot = Object.assign({}, state, { url: location.href, at: Date.now() });
75 + state.added = 0; state.removed = 0; state.text_changes = 0; state.attr_changes = 0; state.regions = {}; state.new_videos = 0; state.new_articles = 0; state.new_links = 0; state.modal_opened = false;
76 + if (window.__srcEmitDomDelta) window.__srcEmitDomDelta(snapshot);
77 + }, 500);
78 +})();
79 +`;
80 +
81 +export class DomObserver {
82 + private deltas: DomDelta[] = [];
83 + private stepDeltas: DomDelta[] = [];
84 + private step = 0;
85 + private installed = false;
86 +
87 + constructor(private readonly opts: { page: Page; bus: EventBus; sessionId: string; platform: Platform }) {}
88 +
89 + async attach(): Promise<void> {
90 + if (this.installed) return;
91 + const { page } = this.opts;
92 + await page.exposeBinding("__srcEmitDomDelta", (_src, delta: DomDelta) => this.onDelta(delta));
93 + await page.addInitScript(INJECTED);
94 + // For the page already loaded, install immediately too.
95 + await page.evaluate(INJECTED).catch(() => {});
96 + this.installed = true;
97 + log.debug("dom observer attached");
98 + }
99 +
100 + beginStep(step: number): void {
101 + this.step = step;
102 + this.stepDeltas = [];
103 + }
104 +
105 + collectStep(): DomDelta[] {
106 + return [...this.stepDeltas];
107 + }
108 +
109 + private onDelta(delta: DomDelta): void {
110 + this.deltas.push(delta);
111 + this.stepDeltas.push(delta);
112 + if (this.deltas.length > 5000) this.deltas.splice(0, 1000);
113 + if (delta.added + delta.removed < 3 && delta.text_changes < 3 && !delta.modal_opened) return; // noise
114 + this.opts.bus.emit({
115 + event_type: "DOM_CHANGED",
116 + platform: this.opts.platform,
117 + session_id: this.opts.sessionId,
118 + step: this.step,
119 + payload: {
120 + added: delta.added,
121 + removed: delta.removed,
122 + text_changes: delta.text_changes,
123 + attr_changes: delta.attr_changes,
124 + regions: delta.regions,
125 + new_videos: delta.new_videos,
126 + new_articles: delta.new_articles,
127 + new_links: delta.new_links,
128 + modal_opened: delta.modal_opened,
129 + url: delta.url,
130 + },
131 + provenance: [{ surface: "dom", confidence: 0.9 }],
132 + });
133 + }
134 +}
added packages/observers/src/dom/PageSummarizer.ts +50 −0
@@ -0,0 +1,50 @@
1 +import fs from "node:fs";
2 +import path from "node:path";
3 +import { fileURLToPath } from "node:url";
4 +import type { Page } from "playwright";
5 +
6 +/**
7 + * Raw DOM candidates scanned in-page (§13–§15). Generic: relies on links, landmarks, ARIA roles,
8 + * media elements and text density — never on generated class names. Adapters refine these later.
9 + *
10 + * The in-page code lives in `snapshot.page.js` (plain JS) because TypeScript transpilers inject
11 + * helpers such as `__name` into function bodies, which do not exist inside the page.
12 + */
13 +export interface DomCandidate {
14 + kind: "link" | "article" | "video" | "image" | "heading" | "search" | "button";
15 + href?: string;
16 + text: string; // visible text (trimmed)
17 + aria_label?: string;
18 + role?: string;
19 + region: string; // main / feed / sidebar / dialog / header / navigation / comments / unknown
20 + index: number; // document order among candidates
21 + visible: boolean; // within viewport
22 + top: number; // bounding rect top (for feed position)
23 + meta?: Record<string, string>; // duration, views, author text found in the same card
24 + locator_hint: string; // a stable-ish CSS selector path we can use to click (prefers href / aria-label)
25 +}
26 +
27 +export interface DomSnapshot {
28 + url: string;
29 + title: string;
30 + lang?: string;
31 + h1?: string;
32 + landmarks: string[];
33 + candidates: DomCandidate[];
34 + video_elements: { src?: string; current_src?: string; duration?: number; width: number; height: number; playing: boolean; poster?: string }[];
35 + text_excerpt: string; // first ~600 chars of main text
36 + scroll: { y: number; height: number; viewport: number };
37 + has_login_form: boolean;
38 + dialog_open: boolean;
39 +}
40 +
41 +const here = path.dirname(fileURLToPath(import.meta.url));
42 +const SNAPSHOT_SOURCE = fs
43 + .readFileSync(path.join(here, "snapshot.page.js"), "utf8")
44 + .replace(/^\s*\/\/.*$/gm, "") // strip comment lines so the string is a bare function expression
45 + .trim();
46 +
47 +export async function snapshotDom(page: Page, opts: { maxCandidates?: number } = {}): Promise<DomSnapshot> {
48 + const max = opts.maxCandidates ?? 250;
49 + return (await page.evaluate(`(${SNAPSHOT_SOURCE})(${max})`)) as DomSnapshot;
50 +}
added packages/observers/src/dom/snapshot.page.js +143 −0
@@ -0,0 +1,143 @@
1 +// In-page DOM snapshot. Kept as plain JavaScript (not TS) so no bundler helper (`__name`) leaks into the page.
2 +// Evaluated by PageSummarizer.ts as `(SOURCE)(MAX)`; must remain a single function expression.
3 +(MAX) => {
4 + const clean = (s) => (s ?? "").replace(/\s+/g, " ").trim();
5 + const vh = window.innerHeight;
6 + const regionOf = (node) => {
7 + let el = node;
8 + let hops = 0;
9 + while (el && hops++ < 30) {
10 + const role = el.getAttribute("role") ?? "";
11 + const tag = el.tagName.toLowerCase();
12 + if (role === "dialog" || el.getAttribute("aria-modal") === "true") return "dialog";
13 + if (tag === "ytd-comments" || /comment/i.test(el.id) || role === "comment") return "comments";
14 + if (role === "feed") return "feed";
15 + if (role === "main" || tag === "main" || el.id === "primary" || el.id === "contents" || el.id === "main-content") return "main";
16 + if (role === "navigation" || tag === "nav" || el.id === "guide") return "navigation";
17 + if (role === "complementary" || tag === "aside" || el.id === "secondary" || el.id === "related") return "sidebar";
18 + if (tag === "header" || role === "banner" || el.id === "masthead") return "header";
19 + el = el.parentElement;
20 + }
21 + return "unknown";
22 + };
23 + const cssPath = (el) => {
24 + const href = el.getAttribute("href");
25 + const tag = el.tagName.toLowerCase();
26 + if (href) return `${tag}[href="${href.replace(/"/g, '\\"')}"]`;
27 + const aria = el.getAttribute("aria-label");
28 + if (aria) return `${tag}[aria-label="${aria.replace(/"/g, '\\"').slice(0, 80)}"]`;
29 + if (el.id) return `#${CSS.escape(el.id)}`;
30 + const parts = [];
31 + let cur = el;
32 + let hops = 0;
33 + while (cur && hops++ < 6 && cur !== document.body) {
34 + const p = cur.parentElement;
35 + const idx = p ? Array.from(p.children).indexOf(cur) + 1 : 1;
36 + parts.unshift(`${cur.tagName.toLowerCase()}:nth-child(${idx})`);
37 + cur = p;
38 + }
39 + return parts.join(">");
40 + };
41 + const isVisible = (el) => {
42 + const r = el.getBoundingClientRect();
43 + const st = getComputedStyle(el);
44 + return r.width > 0 && r.height > 0 && st.visibility !== "hidden" && st.display !== "none";
45 + };
46 + const cardMeta = (el) => {
47 + let card = el;
48 + let hops = 0;
49 + const isCard = (n) => n.tagName === "ARTICLE" || n.getAttribute("role") === "article" || n.tagName === "LI" || /-(renderer|item|lockup|post)$|^shreddit-post$|view-model$/i.test(n.tagName) || /(^|\s)(card|post|feed-item|lockup)(\s|$)/i.test(typeof n.className === "string" ? n.className : "");
50 + while (card && hops++ < 6 && !isCard(card)) card = card.parentElement;
51 + // Only trust a card scope of bounded size; a huge container would bleed another item's numbers into this one.
52 + const scope = card && clean(card.textContent).length < 1500 ? card : el;
53 + const text = clean(scope.textContent).slice(0, 1200);
54 + const out = {};
55 + const dur = text.match(/(?:^|\s)(\d{1,2}:\d{2}(?::\d{2})?)(?=\s|$)/);
56 + if (dur) out.duration = dur[1];
57 + const views = text.match(/([\d.,\s]+\s?[kKmMbB]?)\s*(vues|views|visionnements|upvotes|points|votes|likes|j'aime)/i);
58 + if (views) out.views = views[0];
59 + const comments = text.match(/([\d.,\s]+\s?[kKmMbB]?)\s*(comment|commentaire|repl|répon)/i);
60 + if (comments) out.comments = comments[0];
61 + const ago = text.match(/(il y a\s+[\w\s]+?|\d+\s+(seconds?|minutes?|hours?|days?|weeks?|months?|years?|h|min|j|sem|mois|ans?)\s+ago|\b\d+\s*(h|min|j|d|w|mo|y)\b)/i);
62 + if (ago) out.published = ago[0];
63 + const authorLink = scope.querySelector('a[href*="/@"], a[href^="/user/"], a[href^="/u/"], a[href*="/channel/"], a[href^="/c/"], a[href*="/r/"]');
64 + if (authorLink && authorLink !== el) out.author = clean(authorLink.textContent) || (authorLink.getAttribute("href") ?? "");
65 + return out;
66 + };
67 +
68 + const seen = new Set();
69 + const candidates = [];
70 + let index = 0;
71 + const push = (c) => {
72 + const key = c.kind + "|" + (c.href ?? "") + "|" + c.text.slice(0, 80);
73 + if (seen.has(key)) return;
74 + seen.add(key);
75 + candidates.push({ ...c, index: index++ });
76 + };
77 +
78 + for (const a of Array.from(document.querySelectorAll("a[href]"))) {
79 + if (candidates.length >= MAX) break;
80 + if (!isVisible(a)) continue;
81 + const href = a.href;
82 + if (!href || href.startsWith("javascript:")) continue;
83 + const img = a.querySelector("img");
84 + const text = clean(a.textContent) || clean(a.getAttribute("aria-label")) || clean(a.getAttribute("title")) || clean(img ? img.alt : "");
85 + if (!text && !/watch|shorts|comments|status|reel|video|\/p\//.test(href)) continue;
86 + const r = a.getBoundingClientRect();
87 + push({ kind: "link", href, text: text.slice(0, 200), aria_label: clean(a.getAttribute("aria-label")) || undefined, role: a.getAttribute("role") ?? undefined, region: regionOf(a), visible: r.top < vh && r.bottom > 0, top: Math.round(r.top + window.scrollY), meta: cardMeta(a), locator_hint: cssPath(a) });
88 + }
89 + for (const art of Array.from(document.querySelectorAll("article,[role=article],shreddit-post,ytd-rich-item-renderer,ytd-video-renderer,ytd-compact-video-renderer,ytd-reel-item-renderer,ytd-comment-thread-renderer"))) {
90 + if (candidates.length >= MAX) break;
91 + if (!isVisible(art)) continue;
92 + const r = art.getBoundingClientRect();
93 + const link = art.querySelector("a[href]");
94 + push({ kind: "article", href: link ? link.href : undefined, text: clean(art.textContent).slice(0, 300), role: art.getAttribute("role") ?? art.tagName.toLowerCase(), region: regionOf(art), visible: r.top < vh && r.bottom > 0, top: Math.round(r.top + window.scrollY), meta: cardMeta(art), locator_hint: cssPath(link ?? art) });
95 + }
96 + for (const h of Array.from(document.querySelectorAll("h1,h2,[role=heading]")).slice(0, 30)) {
97 + if (!isVisible(h)) continue;
98 + const r = h.getBoundingClientRect();
99 + const text = clean(h.textContent);
100 + if (!text) continue;
101 + push({ kind: "heading", text: text.slice(0, 200), region: regionOf(h), visible: r.top < vh && r.bottom > 0, top: Math.round(r.top + window.scrollY), locator_hint: cssPath(h) });
102 + }
103 + for (const s of Array.from(document.querySelectorAll('input[type=search],input[name*=search i],input[placeholder*=search i],input[placeholder*=recherch i],[role=searchbox],[role=combobox][aria-label*=search i],[role=combobox][aria-label*=recherch i]'))) {
104 + if (!isVisible(s)) continue;
105 + const r = s.getBoundingClientRect();
106 + push({ kind: "search", text: clean(s.getAttribute("placeholder") ?? s.getAttribute("aria-label")) || "search", region: regionOf(s), visible: r.top < vh, top: Math.round(r.top + window.scrollY), locator_hint: cssPath(s) });
107 + }
108 + for (const b of Array.from(document.querySelectorAll("button,[role=button],summary,[aria-expanded]"))) {
109 + if (candidates.length >= MAX) break;
110 + if (!isVisible(b)) continue;
111 + const label = clean(b.getAttribute("aria-label")) || clean(b.textContent);
112 + if (!/more|plus|show|afficher|voir|replies|réponses|comments|commentaires|expand|load|charger|read more|lire la suite|\.\.\./i.test(label)) continue;
113 + const r = b.getBoundingClientRect();
114 + push({ kind: "button", text: label.slice(0, 120), aria_label: clean(b.getAttribute("aria-label")) || undefined, role: b.getAttribute("role") ?? "button", region: regionOf(b), visible: r.top < vh && r.bottom > 0, top: Math.round(r.top + window.scrollY), locator_hint: cssPath(b) });
115 + }
116 +
117 + const videos = Array.from(document.querySelectorAll("video")).map((v) => ({
118 + src: v.getAttribute("src") ?? undefined,
119 + current_src: v.currentSrc || undefined,
120 + duration: Number.isFinite(v.duration) ? v.duration : undefined,
121 + width: v.videoWidth,
122 + height: v.videoHeight,
123 + playing: !v.paused && !v.ended && v.readyState > 2,
124 + poster: v.poster || undefined,
125 + }));
126 +
127 + const main = document.querySelector("main,[role=main],#primary,#main-content,#contents") ?? document.body;
128 + const landmarks = Array.from(document.querySelectorAll("[role],main,nav,aside,header,footer,form")).map((e) => e.getAttribute("role") ?? e.tagName.toLowerCase());
129 + const h1 = document.querySelector("h1");
130 + return {
131 + url: location.href,
132 + title: document.title,
133 + lang: document.documentElement.lang || undefined,
134 + h1: clean(h1 ? h1.textContent : "") || undefined,
135 + landmarks: Array.from(new Set(landmarks)).slice(0, 25),
136 + candidates,
137 + video_elements: videos,
138 + text_excerpt: clean(main.textContent).slice(0, 600),
139 + scroll: { y: window.scrollY, height: document.documentElement.scrollHeight, viewport: vh },
140 + has_login_form: !!document.querySelector("input[type=password]"),
141 + dialog_open: !!document.querySelector("[role=dialog]:not([aria-hidden=true]),[aria-modal=true]"),
142 + };
143 +}
added packages/observers/src/index.ts +6 −0
@@ -0,0 +1,6 @@
1 +export * from "./network/SchemaProfiler.ts";
2 +export * from "./network/ResponseClassifier.ts";
3 +export * from "./network/NetworkObserver.ts";
4 +export * from "./dom/DomObserver.ts";
5 +export * from "./dom/PageSummarizer.ts";
6 +export * from "./PageClassifier.ts";
added packages/observers/src/network/NetworkObserver.ts +181 −0
@@ -0,0 +1,181 @@
1 +import type { Page, Response, WebSocket } from "playwright";
2 +import { createLogger, newId, nowIso, type Platform } from "@src/shared";
3 +import type { EventBus } from "@src/events";
4 +import { classifyResponse, type CapturedResponse, type ClassifiedResponse, type ClassifierHints } from "./ResponseClassifier.ts";
5 +
6 +const log = createLogger("network");
7 +
8 +const MAX_BODY = 3 * 1024 * 1024; // 3 MB per JSON body
9 +const IGNORED_HOST = /doubleclick|googlesyndication|google-analytics|googletagmanager|facebook\.com\/tr|scorecardresearch|sentry|datadog|hotjar|adservice|\/log_event|\/ptracking|\/csi_204|\/generate_204|\/youtubei\/v1\/log|\/api\/stats/i;
10 +
11 +export interface NetworkObserverOptions {
12 + page: Page;
13 + bus: EventBus;
14 + sessionId: string;
15 + platform: Platform;
16 + hints: ClassifierHints;
17 + keepBodies?: boolean; // keep JSON bodies in memory for the current step
18 +}
19 +
20 +/**
21 + * NetworkObserver (§10): hooks Playwright response/websocket events, captures JSON/GraphQL/media
22 + * responses and runs them through the classifier. Results are buffered per step and published on the bus.
23 + */
24 +export class NetworkObserver {
25 + private stepBuffer: ClassifiedResponse[] = [];
26 + private currentStep = 0;
27 + private currentActionId?: string;
28 + private detached: (() => void)[] = [];
29 + private inflight = new Set<Promise<void>>();
30 + readonly seenShapeHashes = new Map<string, number>();
31 + public totalResponses = 0;
32 +
33 + constructor(private readonly opts: NetworkObserverOptions) {}
34 +
35 + attach(): void {
36 + const { page } = this.opts;
37 + const onResponse = (res: Response) => {
38 + const p = this.handleResponse(res).catch((e) => log.debug("response handling failed", { err: (e as Error).message }));
39 + this.inflight.add(p);
40 + p.finally(() => this.inflight.delete(p));
41 + };
42 + const onWs = (ws: WebSocket) => this.handleWebSocket(ws);
43 + page.on("response", onResponse);
44 + page.on("websocket", onWs);
45 + this.detached.push(() => page.off("response", onResponse), () => page.off("websocket", onWs));
46 + }
47 +
48 + detach(): void {
49 + for (const d of this.detached) d();
50 + this.detached = [];
51 + }
52 +
53 + /** Called by the engine before each action. */
54 + beginStep(step: number, actionId?: string): void {
55 + this.currentStep = step;
56 + this.currentActionId = actionId;
57 + this.stepBuffer = [];
58 + }
59 +
60 + /** Wait for in-flight bodies then return what this step produced. */
61 + async collectStep(): Promise<ClassifiedResponse[]> {
62 + await Promise.allSettled([...this.inflight]);
63 + return [...this.stepBuffer];
64 + }
65 +
66 + private async handleResponse(res: Response): Promise<void> {
67 + const req = res.request();
68 + const url = res.url();
69 + if (IGNORED_HOST.test(url)) return;
70 + const resourceType = req.resourceType();
71 + if (["font", "stylesheet", "script"].includes(resourceType)) return;
72 + const headers = res.headers();
73 + const ct = headers["content-type"] ?? "";
74 + const isJsonLike = /json|javascript|text\/plain/.test(ct) && ["xhr", "fetch", "other"].includes(resourceType);
75 + const isMedia = /video|audio|mpegurl|dash/.test(ct) || /videoplayback|\.m3u8|\.mpd/.test(url);
76 + const isFragment = /text\/html/.test(ct) && resourceType !== "document";
77 + if (!isJsonLike && !isMedia && !isFragment && resourceType !== "image") return;
78 + if (resourceType === "image") {
79 + // Only count images from media CDNs (thumbnails) — keep metadata only.
80 + if (!/ytimg|redd\.it|redditmedia|fbcdn|cdninstagram|tiktokcdn|twimg|licdn/i.test(url)) return;
81 + }
82 +
83 + const captured: CapturedResponse = {
84 + request_id: newId("req"),
85 + url,
86 + method: req.method(),
87 + status: res.status(),
88 + content_type: ct,
89 + resource_type: resourceType,
90 + body_size: Number(headers["content-length"] ?? 0),
91 + post_data: req.postData()?.slice(0, 4000) ?? undefined,
92 + captured_at: nowIso(),
93 + step: this.currentStep,
94 + };
95 + if (isJsonLike || isFragment) {
96 + try {
97 + const buf = await res.body();
98 + captured.body_size = buf.length;
99 + if (buf.length <= MAX_BODY) captured.body = buf.toString("utf8");
100 + } catch {
101 + // body may be unavailable (redirects, preflight) — keep metadata only
102 + }
103 + }
104 + this.totalResponses++;
105 + const classified = classifyResponse(captured, this.opts.hints);
106 + if (classified.kind === "other" && !captured.body) return;
107 + this.stepBuffer.push(classified);
108 +
109 + const fp = classified.fingerprint;
110 + const firstSeen = !this.seenShapeHashes.has(fp.response_shape_hash);
111 + this.seenShapeHashes.set(fp.response_shape_hash, (this.seenShapeHashes.get(fp.response_shape_hash) ?? 0) + 1);
112 +
113 + this.opts.bus.emit({
114 + event_type: "NETWORK_RESPONSE_OBSERVED",
115 + platform: this.opts.platform,
116 + session_id: this.opts.sessionId,
117 + step: this.currentStep,
118 + payload: {
119 + request_id: captured.request_id,
120 + url: captured.url,
121 + method: captured.method,
122 + status: captured.status,
123 + kind: classified.kind,
124 + content_type: fp.content_type,
125 + body_size: captured.body_size,
126 + shape_hash: fp.response_shape_hash,
127 + hostname: fp.hostname,
128 + path_pattern: fp.path_pattern,
129 + graphql_operation: fp.graphql_operation,
130 + entity_count: classified.entities.length,
131 + entity_types: fp.observed_entity_types,
132 + confidence: classified.confidence,
133 + },
134 + provenance: [{ surface: "network", confidence: classified.confidence }],
135 + discovered_via: { action_id: this.currentActionId },
136 + });
137 +
138 + if (firstSeen && classified.schema && (classified.entities.length > 0 || classified.schema.candidate_entity_types.length > 0)) {
139 + this.opts.bus.emit({
140 + event_type: "NETWORK_SCHEMA_DISCOVERED",
141 + platform: this.opts.platform,
142 + session_id: this.opts.sessionId,
143 + step: this.currentStep,
144 + payload: {
145 + fingerprint: fp,
146 + schema: {
147 + shape_hash: classified.schema.shape_hash,
148 + root_type: classified.schema.root_type,
149 + repeated_object_paths: classified.schema.repeated_object_paths,
150 + candidate_entity_types: classified.schema.candidate_entity_types,
151 + object_count: classified.schema.object_count,
152 + fields: classified.schema.fields
153 + .filter((f) => f.semantic[0]?.kind !== "unknown")
154 + .slice(0, 80)
155 + .map((f) => ({ path: f.path, semantic: f.semantic[0], types: f.types, example: f.examples[0] })),
156 + },
157 + sample_url: captured.url,
158 + },
159 + provenance: [{ surface: "network", confidence: classified.confidence }],
160 + });
161 + }
162 + }
163 +
164 + private handleWebSocket(ws: WebSocket): void {
165 + const url = ws.url();
166 + let frames = 0;
167 + ws.on("framereceived", (frame) => {
168 + frames++;
169 + if (frames > 200) return; // don't flood the log
170 + const payload = typeof frame.payload === "string" ? frame.payload : frame.payload.toString("utf8");
171 + this.opts.bus.emit({
172 + event_type: "WEBSOCKET_FRAME_OBSERVED",
173 + platform: this.opts.platform,
174 + session_id: this.opts.sessionId,
175 + step: this.currentStep,
176 + payload: { url, size: payload.length, looks_json: /^\s*[[{]/.test(payload), preview: payload.slice(0, 200) },
177 + provenance: [{ surface: "network", confidence: 0.6 }],
178 + });
179 + });
180 + }
181 +}
added packages/observers/src/network/ResponseClassifier.ts +264 −0
@@ -0,0 +1,264 @@
1 +import {
2 + canonicalUrl,
3 + parseCount,
4 + parseDuration,
5 + shortHash,
6 + truncate,
7 + walkJson,
8 + type EntityType,
9 + type Evidenced,
10 + type NetworkFingerprint,
11 + type ObservedEntity,
12 + type Platform,
13 + type Provenance,
14 + type SchemaProfile,
15 +} from "@src/shared";
16 +import { profileJson } from "./SchemaProfiler.ts";
17 +
18 +export interface CapturedResponse {
19 + request_id: string;
20 + url: string;
21 + method: string;
22 + status: number;
23 + content_type: string;
24 + resource_type: string;
25 + body_size: number;
26 + body?: string; // only kept for JSON / GraphQL / small HTML fragments
27 + post_data?: string;
28 + timing_ms?: number;
29 + captured_at: string;
30 + step?: number;
31 +}
32 +
33 +export interface ClassifiedResponse {
34 + response: CapturedResponse;
35 + kind: "json" | "graphql" | "html_fragment" | "media_manifest" | "media_segment" | "image" | "other";
36 + fingerprint: NetworkFingerprint;
37 + schema?: SchemaProfile;
38 + entities: ObservedEntity[]; // generic candidate entities mined from the payload
39 + confidence: number;
40 +}
41 +
42 +/** Text collapsers let adapters describe how a platform nests rich text (e.g. YouTube `runs`/`simpleText`). */
43 +export type TextCollapser = (v: unknown) => string | undefined;
44 +
45 +export interface ClassifierHints {
46 + platform: Platform;
47 + textCollapsers?: TextCollapser[];
48 + /** Key names (case-insensitive substrings) that strongly denote an entity id on this platform. */
49 + idKeys?: Record<string, EntityType>;
50 + /** Build a canonical page URL from a platform id. */
51 + urlForId?: (type: EntityType, id: string) => string | undefined;
52 +}
53 +
54 +export function pathPattern(url: string): { hostname: string; path_pattern: string } {
55 + try {
56 + const u = new URL(url);
57 + const path = u.pathname
58 + .split("/")
59 + .map((seg) => (/^\d+$/.test(seg) || /^[A-Za-z0-9_-]{11,}$/.test(seg) || /^t[0-9]_[a-z0-9]+$/.test(seg) ? "*" : seg))
60 + .join("/");
61 + return { hostname: u.hostname, path_pattern: path || "/" };
62 + } catch {
63 + return { hostname: "", path_pattern: url };
64 + }
65 +}
66 +
67 +export function classifyKind(r: CapturedResponse): ClassifiedResponse["kind"] {
68 + const ct = r.content_type.toLowerCase();
69 + const u = r.url.toLowerCase();
70 + if (/\.m3u8|\.mpd|mpegurl|dash\+xml|manifest/.test(u + " " + ct)) return "media_manifest";
71 + if (/videoplayback|\.ts(\?|$)|\.m4s|\.mp4|video\/|audio\//.test(u + " " + ct)) return "media_segment";
72 + if (ct.startsWith("image/")) return "image";
73 + if (ct.includes("json") || (r.body && /^\s*[[{]/.test(r.body))) {
74 + if (/graphql/i.test(u) || (r.post_data && /"query"\s*:|operationName|doc_id|fb_api_req_friendly_name/.test(r.post_data))) return "graphql";
75 + return "json";
76 + }
77 + if (ct.includes("text/html") && r.resource_type !== "document") return "html_fragment";
78 + return "other";
79 +}
80 +
81 +function graphqlOperation(r: CapturedResponse): string | undefined {
82 + if (!r.post_data) return undefined;
83 + const m = r.post_data.match(/(?:"operationName"\s*:\s*"|fb_api_req_friendly_name=)([A-Za-z0-9_]+)/);
84 + return m?.[1];
85 +}
86 +
87 +function looseJson(body: string): unknown | undefined {
88 + // Some platforms prefix JSON with anti-hijack tokens (")]}'", "for (;;);") or send NDJSON.
89 + const cleaned = body.replace(/^\)\]\}'\s*/, "").replace(/^for \(;;\);/, "").trim();
90 + try {
91 + return JSON.parse(cleaned);
92 + } catch {
93 + const lines = cleaned.split("\n").filter((l) => l.trim().startsWith("{"));
94 + if (lines.length > 1) {
95 + const parsed = lines.map((l) => {
96 + try {
97 + return JSON.parse(l);
98 + } catch {
99 + return undefined;
100 + }
101 + }).filter(Boolean);
102 + return parsed.length ? parsed : undefined;
103 + }
104 + return undefined;
105 + }
106 +}
107 +
108 +/**
109 + * Generic JSON entity miner: find objects that carry an id-like field plus some
110 + * displayable content, without knowing the platform's property names.
111 + */
112 +export function mineEntities(root: unknown, hints: ClassifierHints, step?: number): ObservedEntity[] {
113 + const collapsers = hints.textCollapsers ?? [];
114 + const collapse = (v: unknown): string | undefined => {
115 + if (typeof v === "string") return v;
116 + if (typeof v === "number") return String(v);
117 + for (const c of collapsers) {
118 + const s = c(v);
119 + if (s) return s;
120 + }
121 + return undefined;
122 + };
123 + const idKeyEntries = Object.entries(hints.idKeys ?? {}).map(([k, t]) => [k.toLowerCase(), t] as const);
124 + const found = new Map<string, ObservedEntity>();
125 + let refCounter = 0;
126 +
127 + walkJson(root, (v, path) => {
128 + if (!v || typeof v !== "object" || Array.isArray(v)) return;
129 + const obj = v as Record<string, unknown>;
130 + const keys = Object.keys(obj);
131 + // 1) find an id
132 + let idKey: string | undefined;
133 + let idType: EntityType | undefined;
134 + for (const k of keys) {
135 + const kl = k.toLowerCase();
136 + const hint = idKeyEntries.find(([hk]) => kl === hk);
137 + if (hint && (typeof obj[k] === "string" || typeof obj[k] === "number")) {
138 + idKey = k;
139 + idType = hint[1];
140 + break;
141 + }
142 + }
143 + if (!idKey) {
144 + for (const k of keys) {
145 + if (/(^id$|Id$|_id$|^videoId$|^channelId$|^postId$|^name$)/.test(k) && k !== "name" && typeof obj[k] === "string" && /^[A-Za-z0-9_-]{4,64}$/.test(obj[k] as string)) {
146 + idKey = k;
147 + break;
148 + }
149 + }
150 + }
151 + if (!idKey) return;
152 + const id = String(obj[idKey]);
153 +
154 + // 2) displayable content nearby (shallow)
155 + const pick = (re: RegExp) => {
156 + for (const k of keys) if (re.test(k)) {
157 + const s = collapse(obj[k]);
158 + if (s && s.length > 0) return s;
159 + }
160 + return undefined;
161 + };
162 + const title = pick(/^(title|headline|name|displayName|fullName|display_name)$/i);
163 + const text = pick(/^(text|body|caption|description|selftext|descriptionSnippet|content|message)$/i);
164 + const author = pick(/^(author|ownerText|shortBylineText|longBylineText|author_name|username|channelName|user_name|screen_name|handle)$/i);
165 + const thumb = (() => {
166 + let out: string | undefined;
167 + walkJson(obj, (x, p) => {
168 + if (out) return false;
169 + if (p.length > 4) return false;
170 + if (typeof x === "string" && /^https?:\/\/.*(\.(jpe?g|png|webp)|ytimg|thumbnail|preview|avatar)/i.test(x) && /(thumb|image|img|picture|avatar|preview|url)/i.test(p.join("."))) out = x;
171 + });
172 + return out;
173 + })();
174 + const durationText = pick(/^(lengthText|duration|length|durationText|video_duration)$/i);
175 + const viewsText = pick(/^(viewCountText|shortViewCountText|views|view_count|viewCount|ups|score|num_comments|likeCount|like_count)$/i);
176 +
177 + if (!title && !text && !idType) return; // an id alone is not an entity
178 +
179 + // 3) type guess
180 + let type: EntityType = idType ?? "post";
181 + if (!idType) {
182 + if (/video/i.test(idKey) || durationText || /video|shorts|watch/i.test(keys.join(" "))) type = "video";
183 + else if (/channel|user|author|owner|profile/i.test(idKey) || (title && !text && /subscriber|follower|handle|username/i.test(keys.join(" ")))) type = "profile";
184 + else if (/comment|reply/i.test(idKey) || /parent_id|replyCount|depth/i.test(keys.join(" "))) type = "comment";
185 + }
186 +
187 + const url = hints.urlForId?.(type, id) ?? (pick(/^(url|permalink|canonicalUrl|href|link)$/i) ?? undefined);
188 + const fingerprint = `${hints.platform}:${type}:${id}`;
189 + if (found.has(fingerprint)) return;
190 +
191 + const prov: Provenance[] = [{ surface: "network", confidence: idType ? 0.95 : 0.7, detail: path.join(".") }];
192 + const ev = (value: unknown): Evidenced => ({ value, provenance: prov });
193 + const fields: Record<string, Evidenced> = { platform_id: ev(id) };
194 + if (title) fields.title = ev(title);
195 + if (text) fields.text = ev(text);
196 + if (author) fields.author = ev(author);
197 + if (thumb) fields.thumbnail_url = ev(thumb);
198 + if (durationText) fields.duration = ev(durationText);
199 + if (viewsText) fields.views_text = ev(viewsText);
200 +
201 + const metrics: ObservedEntity["metrics"] = {};
202 + const views = parseCount(viewsText);
203 + if (views !== undefined) {
204 + if (type === "video") metrics.views = views;
205 + else metrics.score = views;
206 + }
207 +
208 + found.set(fingerprint, {
209 + ref: `N${++refCounter}`,
210 + type,
211 + platform: hints.platform,
212 + platform_id: id,
213 + url: url ? canonicalUrl(url.startsWith("http") ? url : `https://${hints.platform === "x" ? "x.com" : hints.platform + ".com"}${url}`) : undefined,
214 + name: title ? truncate(title, 200) : undefined,
215 + text: text ? truncate(text, 500) : undefined,
216 + author: author ? truncate(author, 120) : undefined,
217 + metrics: Object.keys(metrics).length ? metrics : undefined,
218 + media: type === "video" ? { has_video: true, has_image: !!thumb, duration_s: parseDuration(durationText), thumbnail_url: thumb } : thumb ? { has_video: false, has_image: true, thumbnail_url: thumb } : undefined,
219 + context: `network ${path.slice(0, 4).join(".")}`,
220 + fields,
221 + provenance: prov,
222 + fingerprint,
223 + });
224 + if (found.size >= 400) return false;
225 + });
226 +
227 + return [...found.values()];
228 +}
229 +
230 +export function classifyResponse(r: CapturedResponse, hints: ClassifierHints): ClassifiedResponse {
231 + const kind = classifyKind(r);
232 + const { hostname, path_pattern } = pathPattern(r.url);
233 + const base: NetworkFingerprint = {
234 + hostname,
235 + path_pattern,
236 + method: r.method,
237 + content_type: r.content_type.split(";")[0] ?? "",
238 + response_shape_hash: "",
239 + is_graphql: kind === "graphql",
240 + graphql_operation: kind === "graphql" ? graphqlOperation(r) : undefined,
241 + observed_entity_types: [],
242 + };
243 + let schema: SchemaProfile | undefined;
244 + let entities: ObservedEntity[] = [];
245 + let confidence = 0.3;
246 +
247 + if ((kind === "json" || kind === "graphql") && r.body) {
248 + const parsed = looseJson(r.body);
249 + if (parsed !== undefined) {
250 + schema = profileJson(parsed);
251 + base.response_shape_hash = schema.shape_hash;
252 + entities = mineEntities(parsed, hints, r.step);
253 + const types = new Set<EntityType>();
254 + for (const e of entities) types.add(e.type);
255 + for (const c of schema.candidate_entity_types) if (c.confidence >= 0.6) types.add(c.type);
256 + base.observed_entity_types = [...types];
257 + confidence = entities.length ? 0.9 : schema.candidate_entity_types.length ? 0.6 : 0.4;
258 + }
259 + } else {
260 + base.response_shape_hash = shortHash(`${kind}:${hostname}:${path_pattern}:${base.content_type}`, 16);
261 + if (kind === "media_manifest" || kind === "media_segment") confidence = 0.8;
262 + }
263 + return { response: r, kind, fingerprint: base, schema, entities, confidence };
264 +}
added packages/observers/src/network/SchemaProfiler.test.ts +69 −0
@@ -0,0 +1,69 @@
1 +import { describe, expect, it } from "vitest";
2 +import { profileJson, guessFieldSemantics, shapeSignature } from "./SchemaProfiler.ts";
3 +import { mineEntities, classifyResponse, type CapturedResponse } from "./ResponseClassifier.ts";
4 +import { youtubeAdapter } from "@src/connectors";
5 +
6 +// Fixture shaped like an innertube search response (property names are *not* assumed by the profiler).
7 +const youtubeLike = {
8 + contents: {
9 + sectionList: {
10 + items: [
11 + { videoRenderer: { videoId: "dQw4w9WgXcQ", title: { runs: [{ text: "IA au Québec — table ronde" }] }, ownerText: { runs: [{ text: "Chaîne Exemple" }] }, lengthText: { simpleText: "12:34" }, viewCountText: { simpleText: "1,2 M de vues" }, thumbnail: { thumbnails: [{ url: "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg", width: 480, height: 360 }] } } },
12 + { videoRenderer: { videoId: "abcdefghijk", title: { runs: [{ text: "Montréal AI meetup" }] }, ownerText: { runs: [{ text: "Autre Chaîne" }] }, lengthText: { simpleText: "1:02:03" }, viewCountText: { simpleText: "48 k vues" }, thumbnail: { thumbnails: [{ url: "https://i.ytimg.com/vi/abcdefghijk/hqdefault.jpg", width: 480, height: 360 }] } } },
13 + { channelRenderer: { channelId: "UC1234567890abcdefghij", title: { simpleText: "Québec IA" }, subscriberCountText: { simpleText: "12 k abonnés" } } },
14 + ],
15 + },
16 + continuation: { token: "EpMBEgVoZWxsbxqJAVNCU0NBUXFsY1ZvVFVIZG9kMlV5UmtsbGRuZG1kMkkyYjBOaGRIZG9UbGxSVUZGVmxWMFVrMWFTV2xhVjFwWGRGUlY" },
17 + },
18 +};
19 +
20 +describe("SchemaProfiler", () => {
21 + it("infers field semantics from value shapes and key names", () => {
22 + expect(guessFieldSemantics("videoId", ["dQw4w9WgXcQ", "abcdefghijk"])[0]!.kind).toBe("identifier");
23 + expect(guessFieldSemantics("url", ["https://i.ytimg.com/vi/x/hq.jpg"])[0]!.kind).toBe("thumbnail_url");
24 + expect(guessFieldSemantics("simpleText", ["12:34", "1:02:03"])[0]!.kind).toBe("duration");
25 + expect(guessFieldSemantics("handle", ["@simon", "@bob"])[0]!.kind).toBe("username");
26 + expect(guessFieldSemantics("created_utc", [1700000000, 1700000500])[0]!.kind).toBe("timestamp");
27 + });
28 +
29 + it("produces a stable shape hash independent of values", () => {
30 + const a = shapeSignature({ id: "1", name: "a", tags: ["x"] });
31 + const b = shapeSignature({ name: "zzz", id: "999", tags: ["y", "z"] });
32 + expect(a).toBe(b);
33 + });
34 +
35 + it("finds repeated objects and candidate entity lists", () => {
36 + const p = profileJson(youtubeLike);
37 + expect(p.repeated_object_paths).toContain("contents.sectionList.items");
38 + expect(p.fields.some((f) => f.path.endsWith("videoId") && f.semantic[0]!.kind === "identifier")).toBe(true);
39 + expect(p.candidate_entity_types.length).toBeGreaterThan(0);
40 + });
41 +});
42 +
43 +describe("mineEntities (generic JSON entity miner)", () => {
44 + it("extracts videos and channels with collapsed rich text and provenance", () => {
45 + const ents = mineEntities(youtubeLike, youtubeAdapter.classifierHints);
46 + const videos = ents.filter((e) => e.type === "video");
47 + const channels = ents.filter((e) => e.type === "channel");
48 + expect(videos).toHaveLength(2);
49 + expect(channels).toHaveLength(1);
50 + const v = videos.find((e) => e.platform_id === "dQw4w9WgXcQ")!;
51 + expect(v.name).toBe("IA au Québec — table ronde");
52 + expect(v.author).toBe("Chaîne Exemple");
53 + expect(v.media?.duration_s).toBe(12 * 60 + 34);
54 + expect(v.metrics?.views).toBe(1_200_000);
55 + expect(v.url).toBe("https://youtube.com/watch?v=dQw4w9WgXcQ");
56 + expect(v.provenance[0]!.surface).toBe("network");
57 + expect(Object.keys(v.fields)).toContain("platform_id");
58 + });
59 +
60 + it("classifies a JSON response end-to-end", () => {
61 + const r: CapturedResponse = { request_id: "req_1", url: "https://www.youtube.com/youtubei/v1/search?prettyPrint=false", method: "POST", status: 200, content_type: "application/json; charset=UTF-8", resource_type: "xhr", body_size: 100, body: JSON.stringify(youtubeLike), captured_at: new Date().toISOString() };
62 + const c = classifyResponse(r, youtubeAdapter.classifierHints);
63 + expect(c.kind).toBe("json");
64 + expect(c.entities.length).toBe(3);
65 + expect(c.fingerprint.observed_entity_types).toContain("video");
66 + expect(c.fingerprint.path_pattern).toBe("/youtubei/v1/search");
67 + expect(c.confidence).toBeGreaterThan(0.8);
68 + });
69 +});
added packages/observers/src/network/SchemaProfiler.ts +199 −0
@@ -0,0 +1,199 @@
1 +import {
2 + shortHash,
3 + type EntityType,
4 + type SchemaField,
5 + type SchemaProfile,
6 + type SemanticFieldGuess,
7 + type SemanticFieldKind,
8 +} from "@src/shared";
9 +
10 +/**
11 + * SchemaProfiler (§11, §12): infers structure and field semantics of a JSON payload
12 + * without assuming property names. Purely deterministic (Tier 1).
13 + */
14 +
15 +const ID_KEY = /(^|_|\b)(id|ids|uid|guid|key|pk)$|Id$|ID$|_id$/;
16 +const USERNAME_KEY = /(user|screen)?name$|handle|login|slug|^author$|owner/i;
17 +const TITLE_KEY = /title|headline|subject/i;
18 +const TEXT_KEY = /text|body|caption|description|content|message|selftext|snippet/i;
19 +const URL_KEY = /url|href|link|permalink|uri/i;
20 +const MEDIA_KEY = /video|stream|manifest|playback|media|mp4|hls|dash|audio/i;
21 +const THUMB_KEY = /thumb|thumbnail|poster|preview|avatar|icon|image|img|picture|photo/i;
22 +const TIME_KEY = /time|date|created|published|updated|_at$|timestamp|utc/i;
23 +const COUNT_KEY = /count|views|likes|score|ups|downs|comments|shares|subscribers|followers|favorites|reposts|replies|num_/i;
24 +const DURATION_KEY = /duration|length/i;
25 +const CURSOR_KEY = /cursor|continuation|token|after|before|next|page_?info|offset/i;
26 +
27 +function typeOf(v: unknown): string {
28 + if (v === null) return "null";
29 + if (Array.isArray(v)) return "array";
30 + return typeof v;
31 +}
32 +
33 +export function guessFieldSemantics(key: string, values: unknown[]): SemanticFieldGuess[] {
34 + const guesses: Map<SemanticFieldKind, number> = new Map();
35 + const add = (k: SemanticFieldKind, c: number) => guesses.set(k, Math.max(guesses.get(k) ?? 0, c));
36 + const strings = values.filter((v): v is string => typeof v === "string");
37 + const numbers = values.filter((v): v is number => typeof v === "number");
38 + const bools = values.filter((v) => typeof v === "boolean");
39 + const n = values.length || 1;
40 +
41 + if (bools.length / n > 0.8) add("boolean", 0.95);
42 +
43 + // value-shape signals
44 + const urlRatio = strings.filter((s) => /^https?:\/\//.test(s)).length / n;
45 + if (urlRatio > 0.7) {
46 + add("url", 0.85);
47 + const mediaRatio = strings.filter((s) => /\.(m3u8|mpd|mp4|webm|m4a|mp3)(\?|$)|videoplayback|\/video\//i.test(s)).length / n;
48 + // Value evidence is more specific than the key name: a URL whose values look like images *is* a thumbnail url.
49 + if (mediaRatio > 0.5) add("media_url", 0.93);
50 + const thumbRatio = strings.filter((s) => /\.(jpe?g|png|webp|gif)(\?|$)|thumbnail|ytimg|preview|avatar/i.test(s)).length / n;
51 + if (thumbRatio > 0.5) add("thumbnail_url", 0.92);
52 + }
53 + const handleRatio = strings.filter((s) => /^@[\w.]{2,40}$/.test(s) || /^u\/[\w-]+$/.test(s)).length / n;
54 + if (handleRatio > 0.6) add("username", 0.9);
55 + const isoRatio = strings.filter((s) => /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(s)).length / n;
56 + if (isoRatio > 0.6) add("timestamp", 0.95);
57 + const epochRatio = numbers.filter((x) => x > 1_000_000_000 && x < 4_000_000_000).length / n;
58 + if (epochRatio > 0.6) add("timestamp", 0.75);
59 + const durationRatio = strings.filter((s) => /^(\d+:)?\d{1,2}:\d{2}$/.test(s) || /^PT\d/.test(s)).length / n;
60 + if (durationRatio > 0.6) add("duration", 0.9);
61 + const idLike = strings.filter((s) => /^[A-Za-z0-9_-]{6,64}$/.test(s) && !/\s/.test(s) && !/^[a-z]+$/.test(s)).length / n;
62 + const longText = strings.filter((s) => s.length > 60 || /\s.*\s/.test(s)).length / n;
63 + const countLike = strings.filter((s) => /^[\d.,\s]+\s*[kKmMbB]?(\s|$)/.test(s) && /\d/.test(s) && s.length < 40).length / n;
64 +
65 + // key-name signals (weaker than value signals, but combine)
66 + if (ID_KEY.test(key)) add("identifier", idLike > 0.5 ? 0.95 : numbers.length / n > 0.5 ? 0.85 : 0.6);
67 + else if (idLike > 0.8 && strings.length === values.length) add("identifier", 0.55);
68 + if (USERNAME_KEY.test(key) && strings.length) add(handleRatio > 0.3 ? "username" : "display_name", 0.7);
69 + if (TITLE_KEY.test(key) && strings.length) add("title", 0.85);
70 + if (TEXT_KEY.test(key) && strings.length) add("text", longText > 0.3 ? 0.85 : 0.6);
71 + else if (longText > 0.7 && strings.length === values.length) add("text", 0.5);
72 + if (URL_KEY.test(key)) add("url", urlRatio > 0.5 ? 0.9 : 0.55);
73 + if (MEDIA_KEY.test(key) && urlRatio > 0.3) add("media_url", 0.7);
74 + if (THUMB_KEY.test(key) && urlRatio > 0.3) add("thumbnail_url", 0.8);
75 + if (TIME_KEY.test(key)) add("timestamp", 0.7);
76 + if (COUNT_KEY.test(key)) add("count", numbers.length / n > 0.5 || countLike > 0.5 ? 0.9 : 0.5);
77 + else if (numbers.length / n > 0.8 && numbers.every((x) => Number.isInteger(x) && x >= 0) && epochRatio < 0.5) add("count", 0.4);
78 + if (DURATION_KEY.test(key)) add("duration", 0.75);
79 + if (CURSOR_KEY.test(key) && idLike > 0.3) add("cursor", 0.75);
80 + if (/^[A-Za-z0-9+/=_-]{80,}$/.test(strings[0] ?? "")) add("cursor", 0.6);
81 +
82 + if (guesses.size === 0) add("unknown", 0.3);
83 + return [...guesses.entries()]
84 + .map(([kind, confidence]) => ({ kind, confidence }))
85 + .sort((a, b) => b.confidence - a.confidence)
86 + .slice(0, 3);
87 +}
88 +
89 +/** Structural shape signature: sorted keys + value types, arrays collapsed to their first element. Ids/values excluded. */
90 +export function shapeSignature(v: unknown, depth = 0): string {
91 + if (depth > 6) return "…";
92 + if (Array.isArray(v)) return `[${v.length ? shapeSignature(v[0], depth + 1) : ""}]`;
93 + if (v && typeof v === "object") {
94 + const keys = Object.keys(v as object).sort().slice(0, 40);
95 + return `{${keys.map((k) => `${k}:${shapeSignature((v as Record<string, unknown>)[k], depth + 1)}`).join(",")}}`;
96 + }
97 + return typeOf(v);
98 +}
99 +
100 +interface Collected {
101 + types: Set<string>;
102 + values: unknown[];
103 + seen: number;
104 +}
105 +
106 +/**
107 + * Profile a JSON payload. Fields are aggregated across all objects sharing a path
108 + * (array indices collapsed to []), so repeated objects (feed items) surface naturally.
109 + */
110 +export function profileJson(root: unknown, opts: { maxNodes?: number } = {}): SchemaProfile {
111 + const maxNodes = opts.maxNodes ?? 20_000;
112 + const fields = new Map<string, Collected>();
113 + const arrayObjectCounts = new Map<string, number>(); // path → number of object elements
114 + let objectCount = 0;
115 + let nodes = 0;
116 +
117 + const visit = (v: unknown, pathParts: string[], depth: number) => {
118 + if (nodes++ > maxNodes || depth > 30) return;
119 + if (Array.isArray(v)) {
120 + const objs = v.filter((x) => x && typeof x === "object" && !Array.isArray(x)).length;
121 + if (objs >= 2) arrayObjectCounts.set(pathParts.join("."), (arrayObjectCounts.get(pathParts.join(".")) ?? 0) + objs);
122 + for (const item of v.slice(0, 200)) visit(item, [...pathParts, "[]"], depth + 1);
123 + return;
124 + }
125 + if (v && typeof v === "object") {
126 + objectCount++;
127 + for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
128 + const p = [...pathParts, k].join(".");
129 + const c = fields.get(p) ?? { types: new Set(), values: [], seen: 0 };
130 + c.types.add(typeOf(val));
131 + c.seen++;
132 + if (c.values.length < 25 && (typeof val !== "object" || val === null)) c.values.push(val);
133 + fields.set(p, c);
134 + visit(val, [...pathParts, k], depth + 1);
135 + }
136 + }
137 + };
138 + visit(root, [], 0);
139 +
140 + const schemaFields: SchemaField[] = [];
141 + for (const [p, c] of fields) {
142 + const key = p.split(".").filter((s) => s !== "[]").pop() ?? p;
143 + const scalarValues = c.values;
144 + const semantic = scalarValues.length ? guessFieldSemantics(key, scalarValues) : [{ kind: "unknown" as SemanticFieldKind, confidence: 0.2 }];
145 + schemaFields.push({
146 + path: p,
147 + types: [...c.types],
148 + semantic,
149 + examples: scalarValues.slice(0, 3).map((x) => String(x).slice(0, 80)),
150 + frequency: Math.min(1, c.seen / Math.max(1, objectCount)),
151 + });
152 + }
153 +
154 + const repeated = [...arrayObjectCounts.entries()].sort((a, b) => b[1] - a[1]).map(([p]) => p);
155 + const candidates = detectEntityCandidates(repeated, schemaFields);
156 +
157 + return {
158 + shape_hash: shortHash(shapeSignature(root), 16),
159 + root_type: Array.isArray(root) ? "array" : root && typeof root === "object" ? "object" : "scalar",
160 + repeated_object_paths: repeated.slice(0, 20),
161 + fields: schemaFields.slice(0, 400),
162 + candidate_entity_types: candidates,
163 + object_count: objectCount,
164 + };
165 +}
166 +
167 +/** From repeated-object arrays, guess which entity type each array holds by the semantics of its child fields. */
168 +function detectEntityCandidates(repeated: string[], fields: SchemaField[]): SchemaProfile["candidate_entity_types"] {
169 + const out: SchemaProfile["candidate_entity_types"] = [];
170 + for (const p of repeated.slice(0, 30)) {
171 + const prefix = p ? p + ".[]." : "[].";
172 + const children = fields.filter((f) => f.path.startsWith(prefix));
173 + if (children.length === 0) continue;
174 + const has = (kind: SemanticFieldKind, minConf = 0.5) => children.some((f) => f.semantic.some((s) => s.kind === kind && s.confidence >= minConf));
175 + const keyHas = (re: RegExp) => children.some((f) => re.test(f.path.slice(prefix.length)));
176 + const id = has("identifier");
177 + if (!id) continue;
178 + let type: EntityType | undefined;
179 + let conf = 0.5;
180 + if (keyHas(/video|duration|length|watch|views?/i) || has("duration")) {
181 + type = "video";
182 + conf = 0.75;
183 + } else if (keyHas(/channel|subscri|owner|author.*(name|url)|user_?name|handle|screen_name/i) && !has("text") && !has("title")) {
184 + type = "profile";
185 + conf = 0.65;
186 + } else if (keyHas(/comment|repl|parent_id|depth/i) && has("text")) {
187 + type = "comment";
188 + conf = 0.7;
189 + } else if (has("text") || has("title")) {
190 + type = "post";
191 + conf = has("count") ? 0.75 : 0.6;
192 + } else if (keyHas(/community|subreddit|group|page/i)) {
193 + type = "community";
194 + conf = 0.55;
195 + }
196 + if (type) out.push({ type, confidence: conf, path: p });
197 + }
198 + return out.sort((a, b) => b.confidence - a.confidence).slice(0, 10);
199 +}
added packages/platform-model/package.json +15 −0
@@ -0,0 +1,15 @@
1 +{
2 + "name": "@src/platform-model",
3 + "version": "0.1.0",
4 + "private": true,
5 + "type": "module",
6 + "main": "./src/index.ts",
7 + "types": "./src/index.ts",
8 + "exports": {
9 + ".": "./src/index.ts"
10 + },
11 + "dependencies": {
12 + "@src/shared": "workspace:*",
13 + "@src/events": "workspace:*"
14 + }
15 +}
added packages/platform-model/src/index.ts +254 −0
@@ -0,0 +1,254 @@
1 +import fs from "node:fs";
2 +import path from "node:path";
3 +import { clamp01, createLogger, nowIso, type EntityType, type NetworkFingerprint, type PageType, type Platform } from "@src/shared";
4 +import type { EventBus, SocialEvent } from "@src/events";
5 +
6 +const log = createLogger("platform-model");
7 +
8 +/**
9 + * Connector Learning Engine (§29–§31, §61, §71): persists what the crawler learns about a platform
10 + * into data/platform_model/<platform>/*.json and turns repeated observations into confidence.
11 + */
12 +export interface ResponsePattern {
13 + shape_hash: string;
14 + hostname: string;
15 + path_pattern: string;
16 + method: string;
17 + graphql_operation?: string;
18 + likely_entity_types: Partial<Record<EntityType, number>>; // type → observed count
19 + observed_count: number;
20 + entity_yield_total: number;
21 + first_seen: string;
22 + last_seen: string;
23 + confidence: number;
24 + triggered_by: Record<string, number>; // action type → count (runtime API discovery, §71)
25 + sample_url?: string;
26 + fields?: { path: string; semantic?: { kind: string; confidence: number }; example?: string }[];
27 +}
28 +
29 +export interface ActionPattern {
30 + action_type: string;
31 + from_page_type: PageType | "ANY";
32 + observed_count: number;
33 + avg_new_entities: number;
34 + avg_network_responses: number;
35 + to_page_types: Record<string, number>;
36 + usual_shape_hashes: Record<string, number>;
37 + failures: number;
38 +}
39 +
40 +export interface PageTypeKnowledge {
41 + page_type: PageType;
42 + observed_count: number;
43 + url_patterns: Record<string, number>;
44 + avg_entities: number;
45 + entity_types: Record<string, number>;
46 + avg_confidence: number;
47 +}
48 +
49 +export interface PlatformModelFile {
50 + platform: Platform;
51 + version: number;
52 + updated_at: string;
53 + response_patterns: Record<string, ResponsePattern>;
54 + action_patterns: Record<string, ActionPattern>; // key: `${action_type}@${page_type}`
55 + page_types: Record<string, PageTypeKnowledge>;
56 + media_patterns: Record<string, { hostname: string; kind: string; observed_count: number }>;
57 + sessions_learned: string[];
58 +}
59 +
60 +export interface StepOutcome {
61 + step: number;
62 + action_type: string;
63 + action_id: string;
64 + from_page_type: PageType;
65 + to_page_type: PageType;
66 + to_url: string;
67 + page_confidence: number;
68 + entities_total: number;
69 + new_entities: number;
70 + entity_types: Record<string, number>;
71 + responses: { fingerprint: NetworkFingerprint; entity_count: number; kind: string; url: string; fields?: ResponsePattern["fields"] }[];
72 + failed: boolean;
73 +}
74 +
75 +export class PlatformModel {
76 + readonly file: string;
77 + private model: PlatformModelFile;
78 + private dirty = false;
79 +
80 + constructor(readonly platform: Platform, modelDir: string) {
81 + const dir = path.join(modelDir, platform);
82 + fs.mkdirSync(dir, { recursive: true });
83 + this.file = path.join(dir, "platform_model.json");
84 + this.model = fs.existsSync(this.file)
85 + ? (JSON.parse(fs.readFileSync(this.file, "utf8")) as PlatformModelFile)
86 + : { platform, version: 1, updated_at: nowIso(), response_patterns: {}, action_patterns: {}, page_types: {}, media_patterns: {}, sessions_learned: [] };
87 + }
88 +
89 + get data(): PlatformModelFile {
90 + return this.model;
91 + }
92 +
93 + /** Learn from one executed step (action → observation, §30). */
94 + learnStep(o: StepOutcome, sessionId: string): { newPatterns: string[] } {
95 + const m = this.model;
96 + if (!m.sessions_learned.includes(sessionId)) m.sessions_learned.push(sessionId);
97 + const newPatterns: string[] = [];
98 + const now = nowIso();
99 +
100 + // Action patterns
101 + for (const key of [`${o.action_type}@${o.from_page_type}`, `${o.action_type}@ANY`]) {
102 + const ap: ActionPattern = m.action_patterns[key] ?? { action_type: o.action_type, from_page_type: key.endsWith("@ANY") ? "ANY" : o.from_page_type, observed_count: 0, avg_new_entities: 0, avg_network_responses: 0, to_page_types: {}, usual_shape_hashes: {}, failures: 0 };
103 + const n = ap.observed_count;
104 + ap.avg_new_entities = (ap.avg_new_entities * n + o.new_entities) / (n + 1);
105 + ap.avg_network_responses = (ap.avg_network_responses * n + o.responses.length) / (n + 1);
106 + ap.observed_count = n + 1;
107 + ap.to_page_types[o.to_page_type] = (ap.to_page_types[o.to_page_type] ?? 0) + 1;
108 + if (o.failed) ap.failures++;
109 + for (const r of o.responses) if (r.entity_count > 0) ap.usual_shape_hashes[r.fingerprint.response_shape_hash] = (ap.usual_shape_hashes[r.fingerprint.response_shape_hash] ?? 0) + 1;
110 + m.action_patterns[key] = ap;
111 + }
112 +
113 + // Response patterns (runtime API discovery)
114 + for (const r of o.responses) {
115 + const h = r.fingerprint.response_shape_hash;
116 + if (!h) continue;
117 + if (r.kind === "media_manifest" || r.kind === "media_segment" || r.kind === "image") {
118 + const mk = `${r.fingerprint.hostname}:${r.kind}`;
119 + const mp = m.media_patterns[mk] ?? { hostname: r.fingerprint.hostname, kind: r.kind, observed_count: 0 };
120 + mp.observed_count++;
121 + m.media_patterns[mk] = mp;
122 + continue;
123 + }
124 + let rp = m.response_patterns[h];
125 + if (!rp) {
126 + rp = { shape_hash: h, hostname: r.fingerprint.hostname, path_pattern: r.fingerprint.path_pattern, method: r.fingerprint.method, graphql_operation: r.fingerprint.graphql_operation, likely_entity_types: {}, observed_count: 0, entity_yield_total: 0, first_seen: now, last_seen: now, confidence: 0, triggered_by: {}, sample_url: r.url, fields: r.fields };
127 + m.response_patterns[h] = rp;
128 + if (r.entity_count > 0 || r.fingerprint.observed_entity_types.length) newPatterns.push(h);
129 + }
130 + rp.observed_count++;
131 + rp.last_seen = now;
132 + rp.entity_yield_total += r.entity_count;
133 + rp.triggered_by[o.action_type] = (rp.triggered_by[o.action_type] ?? 0) + 1;
134 + for (const t of r.fingerprint.observed_entity_types) rp.likely_entity_types[t] = (rp.likely_entity_types[t] ?? 0) + 1;
135 + if (!rp.fields && r.fields) rp.fields = r.fields;
136 + // confidence grows with repeated observation and consistent entity yield
137 + const consistency = rp.entity_yield_total > 0 ? Math.min(1, rp.entity_yield_total / rp.observed_count / 5) : 0.1;
138 + rp.confidence = clamp01(1 - Math.exp(-rp.observed_count / 4)) * (0.5 + 0.5 * consistency);
139 + }
140 +
141 + // Page types
142 + const pk: PageTypeKnowledge = m.page_types[o.to_page_type] ?? { page_type: o.to_page_type, observed_count: 0, url_patterns: {}, avg_entities: 0, entity_types: {}, avg_confidence: 0 };
143 + const n = pk.observed_count;
144 + pk.avg_entities = (pk.avg_entities * n + o.entities_total) / (n + 1);
145 + pk.avg_confidence = (pk.avg_confidence * n + o.page_confidence) / (n + 1);
146 + pk.observed_count = n + 1;
147 + try {
148 + const u = new URL(o.to_url);
149 + const pat = u.pathname.split("/").map((s) => (/^[A-Za-z0-9_-]{8,}$/.test(s) || /^\d+$/.test(s) ? "*" : s)).join("/") || "/";
150 + pk.url_patterns[pat] = (pk.url_patterns[pat] ?? 0) + 1;
151 + } catch {
152 + /* ignore */
153 + }
154 + for (const [t, c] of Object.entries(o.entity_types)) pk.entity_types[t] = (pk.entity_types[t] ?? 0) + c;
155 + m.page_types[o.to_page_type] = pk;
156 +
157 + m.updated_at = now;
158 + this.dirty = true;
159 + return { newPatterns };
160 + }
161 +
162 + /** Learned average entity yield per action type — feeds the information-gain engine. */
163 + learnedYield(): Record<string, number> {
164 + const out: Record<string, number> = {};
165 + for (const ap of Object.values(this.model.action_patterns)) if (ap.from_page_type === "ANY" && ap.observed_count >= 2) out[ap.action_type] = ap.avg_new_entities;
166 + return out;
167 + }
168 +
169 + /**
170 + * Degradation check (§32): on a page type we know well, observing far fewer entities than usual
171 + * (and than the adapter expects) signals a broken connector.
172 + */
173 + isDegraded(pageType: PageType, observed: number, adapterExpected: number): boolean {
174 + const pk = this.model.page_types[pageType];
175 + const expected = Math.max(adapterExpected, pk && pk.observed_count >= 5 ? pk.avg_entities * 0.3 : 0);
176 + return expected >= 3 && observed === 0;
177 + }
178 +
179 + /** Overall connector confidence (§74 summary). */
180 + summary() {
181 + const rps = Object.values(this.model.response_patterns).filter((r) => Object.keys(r.likely_entity_types).length);
182 + const entityTypes = new Set<string>();
183 + for (const r of rps) for (const t of Object.keys(r.likely_entity_types)) entityTypes.add(t);
184 + for (const p of Object.values(this.model.page_types)) for (const t of Object.keys(p.entity_types)) entityTypes.add(t);
185 + const conf = rps.length ? rps.reduce((s, r) => s + r.confidence, 0) / rps.length : 0;
186 + const pageConf = Object.values(this.model.page_types).reduce((s, p) => s + p.avg_confidence, 0) / Math.max(1, Object.keys(this.model.page_types).length);
187 + return {
188 + platform: this.platform,
189 + page_types: Object.keys(this.model.page_types).length,
190 + entity_types: entityTypes.size,
191 + navigation_actions: new Set(Object.values(this.model.action_patterns).map((a) => a.action_type)).size,
192 + network_schemas: rps.length,
193 + media_patterns: Object.keys(this.model.media_patterns).length,
194 + confidence: Math.round(100 * clamp01(0.6 * conf + 0.4 * pageConf)),
195 + sessions: this.model.sessions_learned.length,
196 + };
197 + }
198 +
199 + save(): void {
200 + if (!this.dirty) return;
201 + fs.writeFileSync(this.file, JSON.stringify(this.model, null, 2));
202 + // Also split into the documented files (§29) for humans / other workers.
203 + const dir = path.dirname(this.file);
204 + fs.writeFileSync(path.join(dir, "response_patterns.json"), JSON.stringify(Object.values(this.model.response_patterns).sort((a, b) => b.confidence - a.confidence), null, 2));
205 + fs.writeFileSync(path.join(dir, "action_patterns.json"), JSON.stringify(Object.values(this.model.action_patterns), null, 2));
206 + fs.writeFileSync(path.join(dir, "page_types.json"), JSON.stringify(Object.values(this.model.page_types), null, 2));
207 + fs.writeFileSync(path.join(dir, "media_patterns.json"), JSON.stringify(Object.values(this.model.media_patterns), null, 2));
208 + this.dirty = false;
209 + log.debug("platform model saved", { file: this.file });
210 + }
211 +
212 + /** Emit CONNECTOR_PATTERN_LEARNED for freshly learned response shapes. */
213 + announce(bus: EventBus, sessionId: string, step: number, hashes: string[]): void {
214 + for (const h of hashes) {
215 + const rp = this.model.response_patterns[h];
216 + if (!rp) continue;
217 + bus.emit({
218 + event_type: "CONNECTOR_PATTERN_LEARNED",
219 + platform: this.platform,
220 + session_id: sessionId,
221 + step,
222 + payload: { pattern: "network_response", shape_hash: h, hostname: rp.hostname, path_pattern: rp.path_pattern, graphql_operation: rp.graphql_operation, likely_entity_types: rp.likely_entity_types, observed_count: rp.observed_count, confidence: rp.confidence },
223 + });
224 + }
225 + }
226 +}
227 +
228 +/** Compile a learned model into a connector manifest (§31/§74) — first version: declarative YAML-ish JSON. */
229 +export function compileConnector(model: PlatformModel, outDir: string): string {
230 + const s = model.summary();
231 + const d = model.data;
232 + fs.mkdirSync(outDir, { recursive: true });
233 + const manifest = {
234 + platform: model.platform,
235 + compiled_at: nowIso(),
236 + learned_from_sessions: d.sessions_learned.length,
237 + confidence: s.confidence,
238 + page_types: Object.values(d.page_types).map((p) => ({ page_type: p.page_type, url_patterns: Object.entries(p.url_patterns).sort((a, b) => b[1] - a[1]).slice(0, 5).map(([k]) => k), avg_entities: Math.round(p.avg_entities * 10) / 10 })),
239 + network_patterns: Object.values(d.response_patterns)
240 + .filter((r) => r.confidence >= 0.2 && Object.keys(r.likely_entity_types).length)
241 + .sort((a, b) => b.confidence - a.confidence)
242 + .map((r) => ({ shape_hash: r.shape_hash, status: r.confidence >= 0.5 ? "stable" : "provisional", hostname: r.hostname, path_pattern: r.path_pattern, method: r.method, graphql_operation: r.graphql_operation, likely_entity_types: r.likely_entity_types, confidence: Math.round(r.confidence * 100) / 100, triggered_by: r.triggered_by, key_fields: (r.fields ?? []).filter((f) => f.semantic && f.semantic.confidence >= 0.7).slice(0, 25) })),
243 + navigation: Object.values(d.action_patterns).filter((a) => a.from_page_type !== "ANY").map((a) => ({ action: a.action_type, from: a.from_page_type, to: a.to_page_types, avg_new_entities: Math.round(a.avg_new_entities * 10) / 10, observed: a.observed_count })),
244 + media: Object.values(d.media_patterns),
245 + };
246 + const file = path.join(outDir, "manifest.json");
247 + fs.writeFileSync(file, JSON.stringify(manifest, null, 2));
248 + return file;
249 +}
250 +
251 +/** Subscribe to the bus for lightweight bookkeeping (pattern announcements are produced by the engine). */
252 +export function attachPlatformModel(model: PlatformModel, bus: EventBus): () => void {
253 + return bus.on("SESSION_ENDED", (_ev: SocialEvent) => model.save());
254 +}
added packages/shared/package.json +12 −0
@@ -0,0 +1,12 @@
1 +{
2 + "name": "@src/shared",
3 + "version": "0.1.0",
4 + "private": true,
5 + "type": "module",
6 + "main": "./src/index.ts",
7 + "types": "./src/index.ts",
8 + "exports": {
9 + ".": "./src/index.ts"
10 + },
11 + "dependencies": {}
12 +}
added packages/shared/src/config.ts +56 −0
@@ -0,0 +1,56 @@
1 +import path from "node:path";
2 +import fs from "node:fs";
3 +
4 +/** Minimal .env loader (no dependency). Does not override already-set variables. */
5 +export function loadDotEnv(cwd = process.cwd()): void {
6 + const file = path.join(cwd, ".env");
7 + if (!fs.existsSync(file)) return;
8 + for (const line of fs.readFileSync(file, "utf8").split("\n")) {
9 + const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/i);
10 + if (!m) continue;
11 + const key = m[1]!;
12 + let val = m[2]!;
13 + if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) val = val.slice(1, -1);
14 + if (process.env[key] === undefined) process.env[key] = val;
15 + }
16 +}
17 +
18 +export interface AppConfig {
19 + dataDir: string;
20 + profilesDir: string;
21 + sessionsDir: string;
22 + platformModelDir: string;
23 + mediaDir: string;
24 + databaseUrl?: string;
25 + headless: boolean;
26 + browserChannel: string;
27 + llmProvider: "none" | "anthropic" | "local";
28 + llmModel: string;
29 + localLlmUrl?: string;
30 + localLlmKey?: string;
31 + localLlmModel?: string;
32 + dashboardPort: number;
33 +}
34 +
35 +export function loadConfig(): AppConfig {
36 + loadDotEnv();
37 + const dataDir = path.resolve(process.env.SRC_DATA_DIR ?? "./data");
38 + const cfg: AppConfig = {
39 + dataDir,
40 + profilesDir: path.join(dataDir, "browser_profiles"),
41 + sessionsDir: path.join(dataDir, "sessions"),
42 + platformModelDir: path.join(dataDir, "platform_model"),
43 + mediaDir: path.join(dataDir, "media"),
44 + databaseUrl: process.env.SRC_DATABASE_URL || undefined,
45 + headless: /^(1|true|yes)$/i.test(process.env.SRC_HEADLESS ?? "false"),
46 + browserChannel: process.env.SRC_BROWSER_CHANNEL ?? "chromium",
47 + llmProvider: (process.env.SRC_LLM_PROVIDER as AppConfig["llmProvider"]) ?? "none",
48 + llmModel: process.env.SRC_LLM_MODEL ?? "claude-opus-5",
49 + localLlmUrl: process.env.SRC_LOCAL_LLM_URL,
50 + localLlmKey: process.env.SRC_LOCAL_LLM_KEY,
51 + localLlmModel: process.env.SRC_LOCAL_LLM_MODEL,
52 + dashboardPort: Number(process.env.SRC_DASHBOARD_PORT ?? 8340),
53 + };
54 + for (const d of [cfg.profilesDir, cfg.sessionsDir, cfg.platformModelDir, cfg.mediaDir]) fs.mkdirSync(d, { recursive: true });
55 + return cfg;
56 +}
added packages/shared/src/index.ts +4 −0
@@ -0,0 +1,4 @@
1 +export * from "./types.ts";
2 +export * from "./util.ts";
3 +export * from "./logger.ts";
4 +export * from "./config.ts";
added packages/shared/src/logger.ts +37 −0
@@ -0,0 +1,37 @@
1 +/** Structured logger: one JSON line per record on stderr, human line on stdout when SRC_LOG_PRETTY=1. */
2 +export type LogLevel = "debug" | "info" | "warn" | "error";
3 +
4 +const LEVELS: Record<LogLevel, number> = { debug: 10, info: 20, warn: 30, error: 40 };
5 +const minLevel = LEVELS[(process.env.SRC_LOG_LEVEL as LogLevel) ?? "info"] ?? 20;
6 +const pretty = process.env.SRC_LOG_PRETTY !== "0";
7 +
8 +export interface Logger {
9 + debug(msg: string, data?: Record<string, unknown>): void;
10 + info(msg: string, data?: Record<string, unknown>): void;
11 + warn(msg: string, data?: Record<string, unknown>): void;
12 + error(msg: string, data?: Record<string, unknown>): void;
13 + child(bindings: Record<string, unknown>): Logger;
14 +}
15 +
16 +function emit(level: LogLevel, scope: string, bindings: Record<string, unknown>, msg: string, data?: Record<string, unknown>) {
17 + if (LEVELS[level] < minLevel) return;
18 + const rec = { t: new Date().toISOString(), level, scope, msg, ...bindings, ...(data ?? {}) };
19 + if (pretty) {
20 + const extra = { ...bindings, ...(data ?? {}) };
21 + const tail = Object.keys(extra).length ? " " + JSON.stringify(extra) : "";
22 + const line = `${rec.t.slice(11, 19)} ${level.toUpperCase().padEnd(5)} [${scope}] ${msg}${tail}`;
23 + (level === "error" || level === "warn" ? process.stderr : process.stdout).write(line + "\n");
24 + } else {
25 + process.stderr.write(JSON.stringify(rec) + "\n");
26 + }
27 +}
28 +
29 +export function createLogger(scope: string, bindings: Record<string, unknown> = {}): Logger {
30 + return {
31 + debug: (m, d) => emit("debug", scope, bindings, m, d),
32 + info: (m, d) => emit("info", scope, bindings, m, d),
33 + warn: (m, d) => emit("warn", scope, bindings, m, d),
34 + error: (m, d) => emit("error", scope, bindings, m, d),
35 + child: (b) => createLogger(scope, { ...bindings, ...b }),
36 + };
37 +}
added packages/shared/src/types.ts +311 −0
@@ -0,0 +1,311 @@
1 +/**
2 + * Core vocabulary of the Social Runtime Crawler.
3 + * Everything here is platform-independent. Platform quirks live in adapters.
4 + */
5 +
6 +export const PLATFORMS = [
7 + "youtube",
8 + "reddit",
9 + "facebook",
10 + "instagram",
11 + "tiktok",
12 + "x",
13 + "linkedin",
14 + "threads",
15 +] as const;
16 +export type Platform = (typeof PLATFORMS)[number];
17 +
18 +export function isPlatform(v: string): v is Platform {
19 + return (PLATFORMS as readonly string[]).includes(v);
20 +}
21 +
22 +/** Observation surfaces (§9). */
23 +export type Surface =
24 + | "network"
25 + | "dom"
26 + | "runtime_state"
27 + | "accessibility"
28 + | "visual"
29 + | "media"
30 + | "navigation";
31 +
32 +export interface Provenance {
33 + surface: Surface;
34 + confidence: number; // 0..1
35 + observation_id?: string;
36 + detail?: string;
37 +}
38 +
39 +/** Page classification (§58). */
40 +export type PageType =
41 + | "HOME_FEED"
42 + | "SEARCH_RESULTS"
43 + | "PROFILE"
44 + | "PUBLIC_PAGE"
45 + | "POST_DETAIL"
46 + | "VIDEO_DETAIL"
47 + | "CHANNEL"
48 + | "GROUP"
49 + | "COMMENT_VIEW"
50 + | "LOGIN"
51 + | "UNKNOWN";
52 +
53 +export interface PageClassification {
54 + page_type: PageType;
55 + confidence: number;
56 + signals: string[];
57 +}
58 +
59 +/** Entity ontology (§22). */
60 +export type EntityType =
61 + | "person"
62 + | "organization"
63 + | "page"
64 + | "profile"
65 + | "channel"
66 + | "account"
67 + | "post"
68 + | "comment"
69 + | "video"
70 + | "image"
71 + | "topic"
72 + | "hashtag"
73 + | "url"
74 + | "event"
75 + | "location"
76 + | "product"
77 + | "organization_role"
78 + | "community";
79 +
80 +export type RelationType =
81 + | "AUTHORED"
82 + | "MENTIONED"
83 + | "REPLIED_TO"
84 + | "POSTED_BY"
85 + | "BELONGS_TO"
86 + | "LINKS_TO"
87 + | "FEATURES"
88 + | "HAS_PROFILE"
89 + | "REPRESENTS"
90 + | "DISCOVERED_FROM";
91 +
92 +/** A value with provenance (§9, §38). */
93 +export interface Evidenced<T = unknown> {
94 + value: T;
95 + provenance: Provenance[];
96 +}
97 +
98 +/**
99 + * An entity as observed on a page during one step.
100 + * `ref` is a short, stable-within-a-step handle exposed to the planner (E1, E2…).
101 + */
102 +export interface ObservedEntity {
103 + ref: string;
104 + type: EntityType;
105 + platform: Platform;
106 + platform_id?: string; // e.g. YouTube videoId, Reddit t3_xxx
107 + url?: string;
108 + name?: string; // display name / title
109 + text?: string; // caption / body excerpt
110 + author?: string;
111 + author_url?: string;
112 + metrics?: Partial<Record<"views" | "likes" | "comments" | "shares" | "score" | "subscribers" | "followers", number>>;
113 + media?: { has_video: boolean; has_image: boolean; duration_s?: number; thumbnail_url?: string };
114 + published_text?: string;
115 + context?: string; // e.g. "feed item #12", "search result", "comment author"
116 + fields: Record<string, Evidenced>;
117 + provenance: Provenance[];
118 + /** Deterministic fingerprint used for dedup across steps (platform + id or url). */
119 + fingerprint: string;
120 +}
121 +
122 +/** Semantic action vocabulary (§25). */
123 +export type ActionType =
124 + | "OPEN_ENTITY"
125 + | "OPEN_POST"
126 + | "OPEN_PROFILE"
127 + | "OPEN_PAGE"
128 + | "OPEN_CHANNEL"
129 + | "OPEN_VIDEO"
130 + | "OPEN_COMMENTS"
131 + | "SCROLL_DOWN"
132 + | "SCROLL_UP"
133 + | "SEARCH"
134 + | "FILTER"
135 + | "PLAY_VIDEO"
136 + | "PAUSE_VIDEO"
137 + | "EXPAND"
138 + | "COLLAPSE"
139 + | "BACK"
140 + | "FORWARD"
141 + | "RETURN_TO_FEED"
142 + | "WAIT_FOR_CONTENT"
143 + | "END_SESSION";
144 +
145 +export interface SemanticAction {
146 + id: string; // A1, A2…
147 + type: ActionType;
148 + target_ref?: string; // entity ref for OPEN_* actions
149 + target_url?: string;
150 + query?: string; // for SEARCH
151 + label: string; // human/LLM readable
152 + cost: number; // relative exploration cost (1 = scroll)
153 +}
154 +
155 +export interface ActionScore {
156 + action_id: string;
157 + novelty: number;
158 + relevance: number;
159 + expected_entity_yield: number;
160 + confidence: number;
161 + source_quality: number;
162 + cost: number;
163 + penalties: string[];
164 + information_gain: number;
165 +}
166 +
167 +export interface AgentDecision {
168 + step: number;
169 + goal: string;
170 + chosen_action: SemanticAction;
171 + expected_information_gain: number;
172 + novelty: number;
173 + relevance: number;
174 + reason: string; // concise explanation only — never chain-of-thought
175 + planner: "heuristic" | "llm" | "fallback";
176 + scores: ActionScore[];
177 +}
178 +
179 +export type AgentMode = "observe" | "research" | "profile" | "topic" | "learn";
180 +
181 +export interface CrawlBudget {
182 + max_minutes: number;
183 + max_actions: number;
184 + max_profiles: number;
185 + max_posts: number;
186 + max_videos: number;
187 + max_depth: number;
188 + max_llm_tokens: number;
189 + max_storage_mb: number;
190 +}
191 +
192 +export const DEFAULT_BUDGET: CrawlBudget = {
193 + max_minutes: 10,
194 + max_actions: 60,
195 + max_profiles: 25,
196 + max_posts: 200,
197 + max_videos: 100,
198 + max_depth: 5,
199 + max_llm_tokens: 200_000,
200 + max_storage_mb: 500,
201 +};
202 +
203 +export type MediaLevel = 0 | 1 | 2 | 3 | 4;
204 +
205 +export interface CrawlJob {
206 + job_id: string;
207 + platform: Platform;
208 + account_alias: string;
209 + mode: AgentMode;
210 + goal: string;
211 + seed_url?: string;
212 + query?: string;
213 + budget: CrawlBudget;
214 + media_level: MediaLevel;
215 + stay_on_platform: boolean;
216 + read_only: true; // §46 — prototype is strictly read-only
217 +}
218 +
219 +/** Compact page state handed to the planner (§14). */
220 +export interface PageState {
221 + url: string;
222 + title: string;
223 + platform: Platform;
224 + classification: PageClassification;
225 + entities: ObservedEntity[];
226 + actions: SemanticAction[];
227 + media: ObservedMedia[];
228 + summary_text: string; // the textual semantic DOM representation
229 + fingerprint: string; // page fingerprint (url + visible entity ids) for loop detection
230 + captured_at: string;
231 +}
232 +
233 +export interface ObservedMedia {
234 + media_type: "video" | "image" | "audio";
235 + platform: Platform;
236 + platform_media_id?: string;
237 + url?: string;
238 + page_url?: string;
239 + title?: string;
240 + author?: string;
241 + duration_s?: number;
242 + width?: number;
243 + height?: number;
244 + thumbnail_url?: string;
245 + delivery?: { kind: "progressive" | "hls" | "dash" | "unknown"; manifest_url?: string; hostnames: string[] };
246 + fingerprint: string;
247 + provenance: Provenance[];
248 +}
249 +
250 +/** Network fingerprint (§10). */
251 +export interface NetworkFingerprint {
252 + hostname: string;
253 + path_pattern: string; // path with numeric / id-like segments replaced by *
254 + method: string;
255 + content_type: string;
256 + response_shape_hash: string;
257 + is_graphql: boolean;
258 + graphql_operation?: string;
259 + observed_entity_types: EntityType[];
260 +}
261 +
262 +export interface SchemaField {
263 + path: string; // dotted path, arrays as []
264 + types: string[];
265 + semantic: SemanticFieldGuess[];
266 + examples: string[];
267 + frequency: number; // 0..1 across profiled objects
268 +}
269 +
270 +export type SemanticFieldKind =
271 + | "identifier"
272 + | "username"
273 + | "display_name"
274 + | "title"
275 + | "text"
276 + | "url"
277 + | "media_url"
278 + | "thumbnail_url"
279 + | "timestamp"
280 + | "count"
281 + | "duration"
282 + | "cursor"
283 + | "boolean"
284 + | "unknown";
285 +
286 +export interface SemanticFieldGuess {
287 + kind: SemanticFieldKind;
288 + confidence: number;
289 +}
290 +
291 +export interface SchemaProfile {
292 + shape_hash: string;
293 + root_type: "object" | "array" | "scalar";
294 + repeated_object_paths: string[]; // arrays of similar objects → candidate entity lists
295 + fields: SchemaField[];
296 + candidate_entity_types: { type: EntityType; confidence: number; path: string }[];
297 + object_count: number;
298 +}
299 +
300 +export interface SessionInfo {
301 + session_id: string;
302 + platform: Platform;
303 + account_alias: string;
304 + profile_path: string;
305 + started_at: string;
306 + current_url?: string;
307 + current_entity?: string;
308 + navigation_depth: number;
309 + last_action?: string;
310 + health: "starting" | "healthy" | "degraded" | "auth_required" | "crashed" | "stopped";
311 +}
added packages/shared/src/util.test.ts +34 −0
@@ -0,0 +1,34 @@
1 +import { describe, expect, it } from "vitest";
2 +import { parseCount, parseDuration, tokenize, canonicalUrl } from "./util.ts";
3 +
4 +describe("parseCount", () => {
5 + it("handles fr/en thousands, decimals, suffixes and unicode spaces", () => {
6 + expect(parseCount("3 624 visionnements")).toBe(3624);
7 + expect(parseCount("3 624 vues")).toBe(3624);
8 + expect(parseCount("12,345 views")).toBe(12345);
9 + expect(parseCount("1,2 M de vues")).toBe(1_200_000);
10 + expect(parseCount("48 k vues")).toBe(48_000);
11 + expect(parseCount("483")).toBe(483);
12 + expect(parseCount("17 k")).toBe(17_000);
13 + expect(parseCount("no number")).toBeUndefined();
14 + });
15 +});
16 +describe("parseDuration", () => {
17 + it("parses mm:ss and h:mm:ss", () => {
18 + expect(parseDuration("12:34")).toBe(754);
19 + expect(parseDuration("1:02:03")).toBe(3723);
20 + });
21 +});
22 +describe("tokenize", () => {
23 + it("keeps 2-letter domain tokens and drops stopwords", () => {
24 + const t = tokenize("Discover public Quebec creators discussing AI");
25 + expect(t.has("ai")).toBe(true);
26 + expect(t.has("quebec")).toBe(true);
27 + expect(t.has("public")).toBe(false);
28 + });
29 +});
30 +describe("canonicalUrl", () => {
31 + it("strips tracking params and www", () => {
32 + expect(canonicalUrl("https://www.youtube.com/watch?v=abc&si=xyz&t=10")).toBe("https://youtube.com/watch?v=abc");
33 + });
34 +});
added packages/shared/src/util.ts +125 −0
@@ -0,0 +1,125 @@
1 +import { createHash, randomUUID } from "node:crypto";
2 +
3 +export function nowIso(): string {
4 + return new Date().toISOString();
5 +}
6 +
7 +export function newId(prefix: string): string {
8 + return `${prefix}_${randomUUID().replace(/-/g, "").slice(0, 20)}`;
9 +}
10 +
11 +export function sha1(input: string): string {
12 + return createHash("sha1").update(input).digest("hex");
13 +}
14 +
15 +export function shortHash(input: string, len = 12): string {
16 + return sha1(input).slice(0, len);
17 +}
18 +
19 +export function clamp01(n: number): number {
20 + if (Number.isNaN(n)) return 0;
21 + return Math.max(0, Math.min(1, n));
22 +}
23 +
24 +export function truncate(s: string | undefined, max: number): string {
25 + if (!s) return "";
26 + const t = s.replace(/\s+/g, " ").trim();
27 + return t.length > max ? t.slice(0, max - 1) + "…" : t;
28 +}
29 +
30 +export function sleep(ms: number): Promise<void> {
31 + return new Promise((r) => setTimeout(r, ms));
32 +}
33 +
34 +/** Parse "1.2M", "483", "12 k", "3,4 M de vues" → number. Returns undefined when nothing numeric. */
35 +export function parseCount(text: string | undefined | null): number | undefined {
36 + if (!text) return undefined;
37 + const m = text.replace(/ /g, " ").replace(/\s+/g, " ").match(/(\d{1,3}(?:[ ,.]\d{3})+|\d+(?:[.,]\d+)?)\s*([kKmMbBG])?(?![a-z])/i);
38 + // matches "3 624", "12,345", "1,2 M", "48 k", "483" (all unicode spaces normalised first)
39 + if (!m) return undefined;
40 + let raw = m[1]!;
41 + const suffix = (m[2] ?? "").toLowerCase();
42 + if (/^\d{1,3}(?:[ ,.]\d{3})+$/.test(raw) && !suffix) raw = raw.replace(/[ ,.]/g, ""); // thousands groups
43 + else raw = raw.replace(/ /g, "").replace(",", "."); // decimal comma (fr)
44 + let n = Number(raw);
45 + if (Number.isNaN(n)) return undefined;
46 + if (suffix === "k") n *= 1e3;
47 + else if (suffix === "m") n *= 1e6;
48 + else if (suffix === "b" || suffix === "g") n *= 1e9;
49 + return Math.round(n);
50 +}
51 +
52 +/** Parse "12:34" / "1:02:03" → seconds. */
53 +export function parseDuration(text: string | undefined | null): number | undefined {
54 + if (!text) return undefined;
55 + const m = text.trim().match(/^(?:(\d+):)?(\d{1,2}):(\d{2})$/);
56 + if (!m) return undefined;
57 + const h = m[1] ? Number(m[1]) : 0;
58 + return h * 3600 + Number(m[2]) * 60 + Number(m[3]);
59 +}
60 +
61 +const STOPWORDS = new Set(["the", "and", "for", "with", "that", "this", "from", "les", "des", "une", "sur", "pour", "dans", "est", "qui", "que", "de", "la", "le", "du", "en", "un", "et", "au", "of", "to", "in", "on", "is", "it", "or", "by", "an", "as", "at", "be", "we", "vs", "ce", "se", "sa", "son", "ses", "ne", "pas", "plus", "how", "what", "why", "who", "public", "discover", "discussing", "about"]);
62 +
63 +/** Tokenize for cheap lexical similarity (novelty fallback when no embedding model is present). */
64 +export function tokenize(text: string): Set<string> {
65 + return new Set(
66 + text
67 + .toLowerCase()
68 + .normalize("NFD")
69 + .replace(/[̀-ͯ]/g, "")
70 + .split(/[^a-z0-9#@]+/)
71 + // keep 2-letter tokens: "ai", "ia", "qc" carry meaning in this domain
72 + .filter((t) => t.length >= 2 && !STOPWORDS.has(t)),
73 + );
74 +}
75 +
76 +export function jaccard(a: Set<string>, b: Set<string>): number {
77 + if (a.size === 0 || b.size === 0) return 0;
78 + let inter = 0;
79 + for (const t of a) if (b.has(t)) inter++;
80 + return inter / (a.size + b.size - inter);
81 +}
82 +
83 +export function canonicalUrl(url: string): string {
84 + try {
85 + const u = new URL(url);
86 + u.hash = "";
87 + // strip common tracking params
88 + for (const k of [...u.searchParams.keys()]) {
89 + if (/^(utm_|fbclid|gclid|igshid|si|feature|pp|ref_src|ref_url|t)$/i.test(k)) u.searchParams.delete(k);
90 + }
91 + u.hostname = u.hostname.toLowerCase().replace(/^(www|m|mobile)\./, "");
92 + let s = u.toString();
93 + if (s.endsWith("/")) s = s.slice(0, -1);
94 + return s;
95 + } catch {
96 + return url;
97 + }
98 +}
99 +
100 +export function safeJsonParse(text: string): unknown | undefined {
101 + try {
102 + return JSON.parse(text);
103 + } catch {
104 + return undefined;
105 + }
106 +}
107 +
108 +/** Walk any JSON value depth-first. Visitor receives (value, path). Return false to stop descending. */
109 +export function walkJson(
110 + value: unknown,
111 + visitor: (v: unknown, path: string[]) => boolean | void,
112 + path: string[] = [],
113 + depth = 0,
114 +): void {
115 + if (depth > 40) return;
116 + const cont = visitor(value, path);
117 + if (cont === false) return;
118 + if (Array.isArray(value)) {
119 + value.forEach((v, i) => walkJson(v, visitor, [...path, `[${i}]`], depth + 1));
120 + } else if (value && typeof value === "object") {
121 + for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
122 + walkJson(v, visitor, [...path, k], depth + 1);
123 + }
124 + }
125 +}
added packages/storage/package.json +19 −0
@@ -0,0 +1,19 @@
1 +{
2 + "name": "@src/storage",
3 + "version": "0.1.0",
4 + "private": true,
5 + "type": "module",
6 + "main": "./src/index.ts",
7 + "types": "./src/index.ts",
8 + "exports": {
9 + ".": "./src/index.ts"
10 + },
11 + "dependencies": {
12 + "@src/shared": "workspace:*",
13 + "@src/events": "workspace:*",
14 + "pg": "^8.16.0"
15 + },
16 + "devDependencies": {
17 + "@types/pg": "^8.15.0"
18 + }
19 +}
added packages/storage/src/index.ts +0 −0

Binary file not shown.

added packages/storage/src/migrate.ts +12 −0
@@ -0,0 +1,12 @@
1 +import { loadConfig } from "@src/shared";
2 +import { PostgresStore } from "./index.ts";
3 +
4 +const cfg = loadConfig();
5 +if (!cfg.databaseUrl) {
6 + console.error("SRC_DATABASE_URL is not set");
7 + process.exit(1);
8 +}
9 +const store = await PostgresStore.connect(cfg.databaseUrl);
10 +await store.migrate();
11 +await store.close();
12 +console.log("migrated", cfg.databaseUrl.replace(/\/\/.*@/, "//***@"));
added packages/storage/src/schema.sql +180 −0
@@ -0,0 +1,180 @@
1 +-- Social Runtime Crawler — prototype schema (§36). Raw observations are append-only; canonical tables are derived.
2 +CREATE TABLE IF NOT EXISTS sessions (
3 + session_id text PRIMARY KEY,
4 + platform text NOT NULL,
5 + account_alias text NOT NULL,
6 + mode text,
7 + goal text,
8 + started_at timestamptz NOT NULL DEFAULT now(),
9 + ended_at timestamptz,
10 + health text,
11 + stats jsonb
12 +);
13 +
14 +-- Every event, raw (§37). Never updated.
15 +CREATE TABLE IF NOT EXISTS observations (
16 + event_id text PRIMARY KEY,
17 + session_id text NOT NULL REFERENCES sessions(session_id) ON DELETE CASCADE,
18 + platform text NOT NULL,
19 + event_type text NOT NULL,
20 + step integer,
21 + ts timestamptz NOT NULL,
22 + payload jsonb NOT NULL,
23 + provenance jsonb,
24 + discovered_via jsonb
25 +);
26 +CREATE INDEX IF NOT EXISTS observations_session_idx ON observations(session_id, step);
27 +CREATE INDEX IF NOT EXISTS observations_type_idx ON observations(event_type);
28 +
29 +CREATE TABLE IF NOT EXISTS entities (
30 + fingerprint text PRIMARY KEY,
31 + platform text NOT NULL,
32 + entity_type text NOT NULL,
33 + platform_id text,
34 + url text,
35 + name text,
36 + text_excerpt text,
37 + author text,
38 + metrics jsonb,
39 + media jsonb,
40 + fields jsonb NOT NULL DEFAULT '{}'::jsonb, -- field → {value, provenance[]} (§38 evidence model)
41 + confidence real,
42 + first_seen timestamptz NOT NULL DEFAULT now(),
43 + last_seen timestamptz NOT NULL DEFAULT now(),
44 + seen_count integer NOT NULL DEFAULT 1,
45 + first_session text,
46 + canonical_id text
47 +);
48 +CREATE INDEX IF NOT EXISTS entities_platform_type_idx ON entities(platform, entity_type);
49 +
50 +CREATE TABLE IF NOT EXISTS entity_observations (
51 + id bigserial PRIMARY KEY,
52 + fingerprint text NOT NULL REFERENCES entities(fingerprint) ON DELETE CASCADE,
53 + session_id text NOT NULL,
54 + step integer,
55 + ts timestamptz NOT NULL,
56 + surfaces text[] NOT NULL,
57 + snapshot jsonb NOT NULL
58 +);
59 +CREATE INDEX IF NOT EXISTS entity_observations_fp_idx ON entity_observations(fingerprint);
60 +
61 +CREATE TABLE IF NOT EXISTS media (
62 + fingerprint text PRIMARY KEY,
63 + platform text NOT NULL,
64 + media_type text NOT NULL,
65 + platform_media_id text,
66 + page_url text,
67 + url text,
68 + title text,
69 + author text,
70 + duration_s real,
71 + width integer,
72 + height integer,
73 + thumbnail_url text,
74 + delivery jsonb,
75 + frames text[],
76 + provenance jsonb,
77 + first_seen timestamptz NOT NULL DEFAULT now(),
78 + last_seen timestamptz NOT NULL DEFAULT now()
79 +);
80 +
81 +CREATE TABLE IF NOT EXISTS relationships (
82 + id bigserial PRIMARY KEY,
83 + session_id text NOT NULL,
84 + from_fp text NOT NULL,
85 + to_fp text NOT NULL,
86 + rel_type text NOT NULL,
87 + step integer,
88 + UNIQUE (from_fp, to_fp, rel_type)
89 +);
90 +
91 +CREATE TABLE IF NOT EXISTS actions (
92 + id bigserial PRIMARY KEY,
93 + session_id text NOT NULL REFERENCES sessions(session_id) ON DELETE CASCADE,
94 + step integer NOT NULL,
95 + action_id text,
96 + action_type text NOT NULL,
97 + label text,
98 + target_url text,
99 + planner text,
100 + expected_gain real,
101 + novelty real,
102 + relevance real,
103 + reason text,
104 + scores jsonb,
105 + before_state jsonb,
106 + after_state jsonb,
107 + success boolean,
108 + error text,
109 + duration_ms integer,
110 + ts timestamptz NOT NULL DEFAULT now()
111 +);
112 +CREATE INDEX IF NOT EXISTS actions_session_idx ON actions(session_id, step);
113 +
114 +CREATE TABLE IF NOT EXISTS network_responses (
115 + request_id text PRIMARY KEY,
116 + session_id text NOT NULL,
117 + step integer,
118 + url text NOT NULL,
119 + method text,
120 + status integer,
121 + kind text,
122 + content_type text,
123 + body_size integer,
124 + shape_hash text,
125 + hostname text,
126 + path_pattern text,
127 + graphql_operation text,
128 + entity_count integer,
129 + entity_types text[],
130 + confidence real,
131 + ts timestamptz NOT NULL
132 +);
133 +CREATE INDEX IF NOT EXISTS network_responses_shape_idx ON network_responses(shape_hash);
134 +
135 +CREATE TABLE IF NOT EXISTS schema_patterns (
136 + platform text NOT NULL,
137 + shape_hash text NOT NULL,
138 + fingerprint jsonb NOT NULL,
139 + schema jsonb NOT NULL,
140 + sample_url text,
141 + observed_count integer NOT NULL DEFAULT 1,
142 + first_seen timestamptz NOT NULL DEFAULT now(),
143 + last_seen timestamptz NOT NULL DEFAULT now(),
144 + PRIMARY KEY (platform, shape_hash)
145 +);
146 +
147 +CREATE TABLE IF NOT EXISTS feed_items (
148 + id bigserial PRIMARY KEY,
149 + session_id text NOT NULL,
150 + step integer,
151 + page_url text,
152 + page_type text,
153 + feed_position integer,
154 + fingerprint text NOT NULL,
155 + entity_type text,
156 + visible boolean,
157 + ts timestamptz NOT NULL
158 +);
159 +
160 +CREATE TABLE IF NOT EXISTS connector_versions (
161 + id bigserial PRIMARY KEY,
162 + platform text NOT NULL,
163 + compiled_at timestamptz NOT NULL DEFAULT now(),
164 + confidence integer,
165 + summary jsonb,
166 + manifest_path text
167 +);
168 +
169 +CREATE TABLE IF NOT EXISTS crawl_jobs (
170 + job_id text PRIMARY KEY,
171 + session_id text,
172 + platform text NOT NULL,
173 + mode text,
174 + goal text,
175 + budget jsonb,
176 + status text,
177 + created_at timestamptz NOT NULL DEFAULT now(),
178 + finished_at timestamptz,
179 + result jsonb
180 +);
added pnpm-lock.yaml +1381 −0
@@ -0,0 +1,1381 @@
1 +lockfileVersion: '9.0'
2 +
3 +settings:
4 + autoInstallPeers: true
5 + excludeLinksFromLockfile: false
6 +
7 +importers:
8 +
9 + .:
10 + devDependencies:
11 + '@types/node':
12 + specifier: ^24.0.0
13 + version: 24.13.4
14 + tsx:
15 + specifier: ^4.20.0
16 + version: 4.23.13
17 + typescript:
18 + specifier: ^5.9.3
19 + version: 5.9.3
20 + vitest:
21 + specifier: ^3.2.0
22 + version: 3.2.7(@types/node@24.13.4)(tsx@4.23.13)
23 +
24 + apps/api:
25 + dependencies:
26 + '@src/shared':
27 + specifier: workspace:*
28 + version: link:../../packages/shared
29 + '@src/storage':
30 + specifier: workspace:*
31 + version: link:../../packages/storage
32 +
33 + apps/worker:
34 + dependencies:
35 + '@src/agent':
36 + specifier: workspace:*
37 + version: link:../../packages/agent
38 + '@src/browser':
39 + specifier: workspace:*
40 + version: link:../../packages/browser
41 + '@src/connectors':
42 + specifier: workspace:*
43 + version: link:../../packages/connectors
44 + '@src/entities':
45 + specifier: workspace:*
46 + version: link:../../packages/entities
47 + '@src/events':
48 + specifier: workspace:*
49 + version: link:../../packages/events
50 + '@src/media':
51 + specifier: workspace:*
52 + version: link:../../packages/media
53 + '@src/observers':
54 + specifier: workspace:*
55 + version: link:../../packages/observers
56 + '@src/platform-model':
57 + specifier: workspace:*
58 + version: link:../../packages/platform-model
59 + '@src/shared':
60 + specifier: workspace:*
61 + version: link:../../packages/shared
62 + '@src/storage':
63 + specifier: workspace:*
64 + version: link:../../packages/storage
65 + playwright:
66 + specifier: ^1.63.0
67 + version: 1.63.0
68 +
69 + packages/agent:
70 + dependencies:
71 + '@anthropic-ai/sdk':
72 + specifier: ^0.125.0
73 + version: 0.125.0
74 + '@src/events':
75 + specifier: workspace:*
76 + version: link:../events
77 + '@src/shared':
78 + specifier: workspace:*
79 + version: link:../shared
80 +
81 + packages/browser:
82 + dependencies:
83 + '@src/events':
84 + specifier: workspace:*
85 + version: link:../events
86 + '@src/shared':
87 + specifier: workspace:*
88 + version: link:../shared
89 + playwright:
90 + specifier: ^1.63.0
91 + version: 1.63.0
92 +
93 + packages/connectors:
94 + dependencies:
95 + '@src/observers':
96 + specifier: workspace:*
97 + version: link:../observers
98 + '@src/shared':
99 + specifier: workspace:*
100 + version: link:../shared
101 + playwright:
102 + specifier: ^1.63.0
103 + version: 1.63.0
104 +
105 + packages/entities:
106 + dependencies:
107 + '@src/events':
108 + specifier: workspace:*
109 + version: link:../events
110 + '@src/shared':
111 + specifier: workspace:*
112 + version: link:../shared
113 +
114 + packages/events:
115 + dependencies:
116 + '@src/shared':
117 + specifier: workspace:*
118 + version: link:../shared
119 +
120 + packages/media:
121 + dependencies:
122 + '@src/events':
123 + specifier: workspace:*
124 + version: link:../events
125 + '@src/shared':
126 + specifier: workspace:*
127 + version: link:../shared
128 + playwright:
129 + specifier: ^1.63.0
130 + version: 1.63.0
131 +
132 + packages/observers:
133 + dependencies:
134 + '@src/events':
135 + specifier: workspace:*
136 + version: link:../events
137 + '@src/shared':
138 + specifier: workspace:*
139 + version: link:../shared
140 + playwright:
141 + specifier: ^1.63.0
142 + version: 1.63.0
143 +
144 + packages/platform-model:
145 + dependencies:
146 + '@src/events':
147 + specifier: workspace:*
148 + version: link:../events
149 + '@src/shared':
150 + specifier: workspace:*
151 + version: link:../shared
152 +
153 + packages/shared: {}
154 +
155 + packages/storage:
156 + dependencies:
157 + '@src/events':
158 + specifier: workspace:*
159 + version: link:../events
160 + '@src/shared':
161 + specifier: workspace:*
162 + version: link:../shared
163 + pg:
164 + specifier: ^8.16.0
165 + version: 8.23.0
166 + devDependencies:
167 + '@types/pg':
168 + specifier: ^8.15.0
169 + version: 8.23.1
170 +
171 +packages:
172 +
173 + '@anthropic-ai/sdk@0.125.0':
174 + resolution: {integrity: sha512-Hq5wYlXupzJ9M1Fzqjqa3hObcuigEXVZJqSYDgeZGM3wF4qrNERM5Z1OOAeoT9rlod8awJ3uYyJjbQXp1ckIGg==}
175 + hasBin: true
176 + peerDependencies:
177 + zod: ^3.25.0 || ^4.0.0
178 + peerDependenciesMeta:
179 + zod:
180 + optional: true
181 +
182 + '@babel/runtime@7.29.7':
183 + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
184 + engines: {node: '>=6.9.0'}
185 +
186 + '@esbuild/aix-ppc64@0.28.2':
187 + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}
188 + engines: {node: '>=18'}
189 + cpu: [ppc64]
190 + os: [aix]
191 +
192 + '@esbuild/android-arm64@0.28.2':
193 + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==}
194 + engines: {node: '>=18'}
195 + cpu: [arm64]
196 + os: [android]
197 +
198 + '@esbuild/android-arm@0.28.2':
199 + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==}
200 + engines: {node: '>=18'}
201 + cpu: [arm]
202 + os: [android]
203 +
204 + '@esbuild/android-x64@0.28.2':
205 + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==}
206 + engines: {node: '>=18'}
207 + cpu: [x64]
208 + os: [android]
209 +
210 + '@esbuild/darwin-arm64@0.28.2':
211 + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==}
212 + engines: {node: '>=18'}
213 + cpu: [arm64]
214 + os: [darwin]
215 +
216 + '@esbuild/darwin-x64@0.28.2':
217 + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==}
218 + engines: {node: '>=18'}
219 + cpu: [x64]
220 + os: [darwin]
221 +
222 + '@esbuild/freebsd-arm64@0.28.2':
223 + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==}
224 + engines: {node: '>=18'}
225 + cpu: [arm64]
226 + os: [freebsd]
227 +
228 + '@esbuild/freebsd-x64@0.28.2':
229 + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==}
230 + engines: {node: '>=18'}
231 + cpu: [x64]
232 + os: [freebsd]
233 +
234 + '@esbuild/linux-arm64@0.28.2':
235 + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==}
236 + engines: {node: '>=18'}
237 + cpu: [arm64]
238 + os: [linux]
239 +
240 + '@esbuild/linux-arm@0.28.2':
241 + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==}
242 + engines: {node: '>=18'}
243 + cpu: [arm]
244 + os: [linux]
245 +
246 + '@esbuild/linux-ia32@0.28.2':
247 + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==}
248 + engines: {node: '>=18'}
249 + cpu: [ia32]
250 + os: [linux]
251 +
252 + '@esbuild/linux-loong64@0.28.2':
253 + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==}
254 + engines: {node: '>=18'}
255 + cpu: [loong64]
256 + os: [linux]
257 +
258 + '@esbuild/linux-mips64el@0.28.2':
259 + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==}
260 + engines: {node: '>=18'}
261 + cpu: [mips64el]
262 + os: [linux]
263 +
264 + '@esbuild/linux-ppc64@0.28.2':
265 + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==}
266 + engines: {node: '>=18'}
267 + cpu: [ppc64]
268 + os: [linux]
269 +
270 + '@esbuild/linux-riscv64@0.28.2':
271 + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==}
272 + engines: {node: '>=18'}
273 + cpu: [riscv64]
274 + os: [linux]
275 +
276 + '@esbuild/linux-s390x@0.28.2':
277 + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==}
278 + engines: {node: '>=18'}
279 + cpu: [s390x]
280 + os: [linux]
281 +
282 + '@esbuild/linux-x64@0.28.2':
283 + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==}
284 + engines: {node: '>=18'}
285 + cpu: [x64]
286 + os: [linux]
287 +
288 + '@esbuild/netbsd-arm64@0.28.2':
289 + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==}
290 + engines: {node: '>=18'}
291 + cpu: [arm64]
292 + os: [netbsd]
293 +
294 + '@esbuild/netbsd-x64@0.28.2':
295 + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==}
296 + engines: {node: '>=18'}
297 + cpu: [x64]
298 + os: [netbsd]
299 +
300 + '@esbuild/openbsd-arm64@0.28.2':
301 + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==}
302 + engines: {node: '>=18'}
303 + cpu: [arm64]
304 + os: [openbsd]
305 +
306 + '@esbuild/openbsd-x64@0.28.2':
307 + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==}
308 + engines: {node: '>=18'}
309 + cpu: [x64]
310 + os: [openbsd]
311 +
312 + '@esbuild/openharmony-arm64@0.28.2':
313 + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==}
314 + engines: {node: '>=18'}
315 + cpu: [arm64]
316 + os: [openharmony]
317 +
318 + '@esbuild/sunos-x64@0.28.2':
319 + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==}
320 + engines: {node: '>=18'}
321 + cpu: [x64]
322 + os: [sunos]
323 +
324 + '@esbuild/win32-arm64@0.28.2':
325 + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==}
326 + engines: {node: '>=18'}
327 + cpu: [arm64]
328 + os: [win32]
329 +
330 + '@esbuild/win32-ia32@0.28.2':
331 + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==}
332 + engines: {node: '>=18'}
333 + cpu: [ia32]
334 + os: [win32]
335 +
336 + '@esbuild/win32-x64@0.28.2':
337 + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==}
338 + engines: {node: '>=18'}
339 + cpu: [x64]
340 + os: [win32]
341 +
342 + '@jridgewell/sourcemap-codec@1.6.0':
343 + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
344 +
345 + '@napi-rs/lzma-linux-x64-gnu@1.5.1':
346 + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
347 + engines: {node: ^22.20 || ^24.12 || >=25}
348 + cpu: [x64]
349 + os: [linux]
350 + libc: [glibc]
351 +
352 + '@rollup/rollup-android-arm-eabi@4.63.1':
353 + resolution: {integrity: sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==}
354 + cpu: [arm]
355 + os: [android]
356 +
357 + '@rollup/rollup-android-arm64@4.63.1':
358 + resolution: {integrity: sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==}
359 + cpu: [arm64]
360 + os: [android]
361 +
362 + '@rollup/rollup-darwin-arm64@4.63.1':
363 + resolution: {integrity: sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==}
364 + cpu: [arm64]
365 + os: [darwin]
366 +
367 + '@rollup/rollup-darwin-x64@4.63.1':
368 + resolution: {integrity: sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==}
369 + cpu: [x64]
370 + os: [darwin]
371 +
372 + '@rollup/rollup-freebsd-arm64@4.63.1':
373 + resolution: {integrity: sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==}
374 + cpu: [arm64]
375 + os: [freebsd]
376 +
377 + '@rollup/rollup-freebsd-x64@4.63.1':
378 + resolution: {integrity: sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==}
379 + cpu: [x64]
380 + os: [freebsd]
381 +
382 + '@rollup/rollup-linux-arm-gnueabihf@4.63.1':
383 + resolution: {integrity: sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==}
384 + cpu: [arm]
385 + os: [linux]
386 + libc: [glibc]
387 +
388 + '@rollup/rollup-linux-arm-musleabihf@4.63.1':
389 + resolution: {integrity: sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==}
390 + cpu: [arm]
391 + os: [linux]
392 + libc: [musl]
393 +
394 + '@rollup/rollup-linux-arm64-gnu@4.63.1':
395 + resolution: {integrity: sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==}
396 + cpu: [arm64]
397 + os: [linux]
398 + libc: [glibc]
399 +
400 + '@rollup/rollup-linux-arm64-musl@4.63.1':
401 + resolution: {integrity: sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==}
402 + cpu: [arm64]
403 + os: [linux]
404 + libc: [musl]
405 +
406 + '@rollup/rollup-linux-loong64-gnu@4.63.1':
407 + resolution: {integrity: sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==}
408 + cpu: [loong64]
409 + os: [linux]
410 + libc: [glibc]
411 +
412 + '@rollup/rollup-linux-loong64-musl@4.63.1':
413 + resolution: {integrity: sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==}
414 + cpu: [loong64]
415 + os: [linux]
416 + libc: [musl]
417 +
418 + '@rollup/rollup-linux-ppc64-gnu@4.63.1':
419 + resolution: {integrity: sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==}
420 + cpu: [ppc64]
421 + os: [linux]
422 + libc: [glibc]
423 +
424 + '@rollup/rollup-linux-ppc64-musl@4.63.1':
425 + resolution: {integrity: sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==}
426 + cpu: [ppc64]
427 + os: [linux]
428 + libc: [musl]
429 +
430 + '@rollup/rollup-linux-riscv64-gnu@4.63.1':
431 + resolution: {integrity: sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==}
432 + cpu: [riscv64]
433 + os: [linux]
434 + libc: [glibc]
435 +
436 + '@rollup/rollup-linux-riscv64-musl@4.63.1':
437 + resolution: {integrity: sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==}
438 + cpu: [riscv64]
439 + os: [linux]
440 + libc: [musl]
441 +
442 + '@rollup/rollup-linux-s390x-gnu@4.63.1':
443 + resolution: {integrity: sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==}
444 + cpu: [s390x]
445 + os: [linux]
446 + libc: [glibc]
447 +
448 + '@rollup/rollup-linux-x64-gnu@4.63.1':
449 + resolution: {integrity: sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==}
450 + cpu: [x64]
451 + os: [linux]
452 + libc: [glibc]
453 +
454 + '@rollup/rollup-linux-x64-musl@4.63.1':
455 + resolution: {integrity: sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==}
456 + cpu: [x64]
457 + os: [linux]
458 + libc: [musl]
459 +
460 + '@rollup/rollup-openbsd-x64@4.63.1':
461 + resolution: {integrity: sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==}
462 + cpu: [x64]
463 + os: [openbsd]
464 +
465 + '@rollup/rollup-openharmony-arm64@4.63.1':
466 + resolution: {integrity: sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==}
467 + cpu: [arm64]
468 + os: [openharmony]
469 +
470 + '@rollup/rollup-win32-arm64-msvc@4.63.1':
471 + resolution: {integrity: sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==}
472 + cpu: [arm64]
473 + os: [win32]
474 +
475 + '@rollup/rollup-win32-ia32-msvc@4.63.1':
476 + resolution: {integrity: sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==}
477 + cpu: [ia32]
478 + os: [win32]
479 +
480 + '@rollup/rollup-win32-x64-gnu@4.63.1':
481 + resolution: {integrity: sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==}
482 + cpu: [x64]
483 + os: [win32]
484 +
485 + '@rollup/rollup-win32-x64-msvc@4.63.1':
486 + resolution: {integrity: sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==}
487 + cpu: [x64]
488 + os: [win32]
489 +
490 + '@stablelib/base64@1.0.1':
491 + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
492 +
493 + '@types/chai@5.2.3':
494 + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
495 +
496 + '@types/deep-eql@4.0.2':
497 + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
498 +
499 + '@types/estree@1.0.9':
500 + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
501 +
502 + '@types/node@24.13.4':
503 + resolution: {integrity: sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==}
504 +
505 + '@types/pg@8.23.1':
506 + resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==}
507 +
508 + '@vitest/expect@3.2.7':
509 + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==}
510 +
511 + '@vitest/mocker@3.2.7':
512 + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==}
513 + peerDependencies:
514 + msw: ^2.4.9
515 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0
516 + peerDependenciesMeta:
517 + msw:
518 + optional: true
519 + vite:
520 + optional: true
521 +
522 + '@vitest/pretty-format@3.2.7':
523 + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==}
524 +
525 + '@vitest/runner@3.2.7':
526 + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==}
527 +
528 + '@vitest/snapshot@3.2.7':
529 + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==}
530 +
531 + '@vitest/spy@3.2.7':
532 + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==}
533 +
534 + '@vitest/utils@3.2.7':
535 + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==}
536 +
537 + assertion-error@2.0.1:
538 + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
539 + engines: {node: '>=12'}
540 +
541 + cac@6.7.14:
542 + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
543 + engines: {node: '>=8'}
544 +
545 + chai@5.3.3:
546 + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
547 + engines: {node: '>=18'}
548 +
549 + check-error@2.1.3:
550 + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
551 + engines: {node: '>= 16'}
552 +
553 + debug@4.4.3:
554 + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
555 + engines: {node: '>=6.0'}
556 + peerDependencies:
557 + supports-color: '*'
558 + peerDependenciesMeta:
559 + supports-color:
560 + optional: true
561 +
562 + deep-eql@5.0.2:
563 + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
564 + engines: {node: '>=6'}
565 +
566 + es-module-lexer@1.7.0:
567 + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
568 +
569 + esbuild@0.28.2:
570 + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==}
571 + engines: {node: '>=18'}
572 + hasBin: true
573 +
574 + estree-walker@3.0.3:
575 + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
576 +
577 + expect-type@1.4.0:
578 + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
579 + engines: {node: '>=12.0.0'}
580 +
581 + fast-sha256@1.3.0:
582 + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==}
583 +
584 + fdir@6.5.0:
585 + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
586 + engines: {node: '>=12.0.0'}
587 + peerDependencies:
588 + picomatch: ^3 || ^4
589 + peerDependenciesMeta:
590 + picomatch:
591 + optional: true
592 +
593 + fsevents@2.3.3:
594 + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
595 + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
596 + os: [darwin]
597 +
598 + js-tokens@9.0.1:
599 + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
600 +
601 + json-schema-to-ts@3.1.1:
602 + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==}
603 + engines: {node: '>=16'}
604 +
605 + loupe@3.2.1:
606 + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
607 +
608 + magic-string@0.30.21:
609 + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
610 +
611 + ms@2.1.3:
612 + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
613 +
614 + nanoid@3.3.19:
615 + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==}
616 + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
617 + hasBin: true
618 +
619 + pathe@2.0.3:
620 + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
621 +
622 + pathval@2.0.1:
623 + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
624 + engines: {node: '>= 14.16'}
625 +
626 + pg-cloudflare@1.4.0:
627 + resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==}
628 +
629 + pg-connection-string@2.14.0:
630 + resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==}
631 +
632 + pg-int8@1.0.1:
633 + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
634 + engines: {node: '>=4.0.0'}
635 +
636 + pg-pool@3.14.0:
637 + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==}
638 + peerDependencies:
639 + pg: '>=8.0'
640 +
641 + pg-protocol@1.16.0:
642 + resolution: {integrity: sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==}
643 +
644 + pg-types@2.2.0:
645 + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
646 + engines: {node: '>=4'}
647 +
648 + pg@8.23.0:
649 + resolution: {integrity: sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==}
650 + engines: {node: '>= 16.0.0'}
651 + peerDependencies:
652 + pg-native: '>=3.0.1'
653 + peerDependenciesMeta:
654 + pg-native:
655 + optional: true
656 +
657 + pgpass@1.0.5:
658 + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
659 +
660 + picocolors@1.1.1:
661 + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
662 +
663 + picomatch@4.0.7:
664 + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
665 + engines: {node: '>=12'}
666 +
667 + playwright-core@1.63.0:
668 + resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==}
669 + engines: {node: '>=20'}
670 + hasBin: true
671 +
672 + playwright@1.63.0:
673 + resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==}
674 + engines: {node: '>=20'}
675 + hasBin: true
676 +
677 + postcss@8.5.28:
678 + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
679 + engines: {node: ^10 || ^12 || >=14}
680 +
681 + postgres-array@2.0.0:
682 + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
683 + engines: {node: '>=4'}
684 +
685 + postgres-bytea@1.0.1:
686 + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==}
687 + engines: {node: '>=0.10.0'}
688 +
689 + postgres-date@1.0.7:
690 + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==}
691 + engines: {node: '>=0.10.0'}
692 +
693 + postgres-interval@1.2.0:
694 + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
695 + engines: {node: '>=0.10.0'}
696 +
697 + rollup@4.63.1:
698 + resolution: {integrity: sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==}
699 + engines: {node: '>=18.0.0', npm: '>=8.0.0'}
700 + hasBin: true
701 +
702 + siginfo@2.0.0:
703 + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
704 +
705 + source-map-js@1.2.1:
706 + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
707 + engines: {node: '>=0.10.0'}
708 +
709 + split2@4.2.0:
710 + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
711 + engines: {node: '>= 10.x'}
712 +
713 + stackback@0.0.2:
714 + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
715 +
716 + standardwebhooks@1.1.1:
717 + resolution: {integrity: sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==}
718 +
719 + std-env@3.10.0:
720 + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
721 +
722 + strip-literal@3.1.0:
723 + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
724 +
725 + tinybench@2.9.0:
726 + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
727 +
728 + tinyexec@0.3.2:
729 + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
730 +
731 + tinyglobby@0.2.17:
732 + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
733 + engines: {node: '>=12.0.0'}
734 +
735 + tinypool@1.1.1:
736 + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}
737 + engines: {node: ^18.0.0 || >=20.0.0}
738 +
739 + tinyrainbow@2.0.0:
740 + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
741 + engines: {node: '>=14.0.0'}
742 +
743 + tinyspy@4.0.6:
744 + resolution: {integrity: sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==}
745 + engines: {node: '>=14.0.0'}
746 +
747 + ts-algebra@2.0.0:
748 + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==}
749 +
750 + tsx@4.23.13:
751 + resolution: {integrity: sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==}
752 + engines: {node: '>=18.0.0'}
753 + hasBin: true
754 +
755 + typescript@5.9.3:
756 + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
757 + engines: {node: '>=14.17'}
758 + hasBin: true
759 +
760 + undici-types@7.18.2:
761 + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
762 +
763 + vite-node@3.2.4:
764 + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
765 + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
766 + hasBin: true
767 +
768 + vite@7.3.6:
769 + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==}
770 + engines: {node: ^20.19.0 || >=22.12.0}
771 + hasBin: true
772 + peerDependencies:
773 + '@types/node': ^20.19.0 || >=22.12.0
774 + jiti: '>=1.21.0'
775 + less: ^4.0.0
776 + lightningcss: ^1.21.0
777 + sass: ^1.70.0
778 + sass-embedded: ^1.70.0
779 + stylus: '>=0.54.8'
780 + sugarss: ^5.0.0
781 + terser: ^5.16.0
782 + tsx: ^4.8.1
783 + yaml: ^2.4.2
784 + peerDependenciesMeta:
785 + '@types/node':
786 + optional: true
787 + jiti:
788 + optional: true
789 + less:
790 + optional: true
791 + lightningcss:
792 + optional: true
793 + sass:
794 + optional: true
795 + sass-embedded:
796 + optional: true
797 + stylus:
798 + optional: true
799 + sugarss:
800 + optional: true
801 + terser:
802 + optional: true
803 + tsx:
804 + optional: true
805 + yaml:
806 + optional: true
807 +
808 + vitest@3.2.7:
809 + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==}
810 + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
811 + hasBin: true
812 + peerDependencies:
813 + '@edge-runtime/vm': '*'
814 + '@types/debug': ^4.1.12
815 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
816 + '@vitest/browser': 3.2.7
817 + '@vitest/ui': 3.2.7
818 + happy-dom: '*'
819 + jsdom: '*'
820 + peerDependenciesMeta:
821 + '@edge-runtime/vm':
822 + optional: true
823 + '@types/debug':
824 + optional: true
825 + '@types/node':
826 + optional: true
827 + '@vitest/browser':
828 + optional: true
829 + '@vitest/ui':
830 + optional: true
831 + happy-dom:
832 + optional: true
833 + jsdom:
834 + optional: true
835 +
836 + why-is-node-running@2.3.0:
837 + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
838 + engines: {node: '>=8'}
839 + hasBin: true
840 +
841 + xtend@4.0.2:
842 + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
843 + engines: {node: '>=0.4'}
844 +
845 +snapshots:
846 +
847 + '@anthropic-ai/sdk@0.125.0':
848 + dependencies:
849 + json-schema-to-ts: 3.1.1
850 + standardwebhooks: 1.1.1
851 +
852 + '@babel/runtime@7.29.7': {}
853 +
854 + '@esbuild/aix-ppc64@0.28.2':
855 + optional: true
856 +
857 + '@esbuild/android-arm64@0.28.2':
858 + optional: true
859 +
860 + '@esbuild/android-arm@0.28.2':
861 + optional: true
862 +
863 + '@esbuild/android-x64@0.28.2':
864 + optional: true
865 +
866 + '@esbuild/darwin-arm64@0.28.2':
867 + optional: true
868 +
869 + '@esbuild/darwin-x64@0.28.2':
870 + optional: true
871 +
872 + '@esbuild/freebsd-arm64@0.28.2':
873 + optional: true
874 +
875 + '@esbuild/freebsd-x64@0.28.2':
876 + optional: true
877 +
878 + '@esbuild/linux-arm64@0.28.2':
879 + optional: true
880 +
881 + '@esbuild/linux-arm@0.28.2':
882 + optional: true
883 +
884 + '@esbuild/linux-ia32@0.28.2':
885 + optional: true
886 +
887 + '@esbuild/linux-loong64@0.28.2':
888 + optional: true
889 +
890 + '@esbuild/linux-mips64el@0.28.2':
891 + optional: true
892 +
893 + '@esbuild/linux-ppc64@0.28.2':
894 + optional: true
895 +
896 + '@esbuild/linux-riscv64@0.28.2':
897 + optional: true
898 +
899 + '@esbuild/linux-s390x@0.28.2':
900 + optional: true
901 +
902 + '@esbuild/linux-x64@0.28.2':
903 + optional: true
904 +
905 + '@esbuild/netbsd-arm64@0.28.2':
906 + optional: true
907 +
908 + '@esbuild/netbsd-x64@0.28.2':
909 + optional: true
910 +
911 + '@esbuild/openbsd-arm64@0.28.2':
912 + optional: true
913 +
914 + '@esbuild/openbsd-x64@0.28.2':
915 + optional: true
916 +
917 + '@esbuild/openharmony-arm64@0.28.2':
918 + optional: true
919 +
920 + '@esbuild/sunos-x64@0.28.2':
921 + optional: true
922 +
923 + '@esbuild/win32-arm64@0.28.2':
924 + optional: true
925 +
926 + '@esbuild/win32-ia32@0.28.2':
927 + optional: true
928 +
929 + '@esbuild/win32-x64@0.28.2':
930 + optional: true
931 +
932 + '@jridgewell/sourcemap-codec@1.6.0': {}
933 +
934 + '@napi-rs/lzma-linux-x64-gnu@1.5.1':
935 + optional: true
936 +
937 + '@rollup/rollup-android-arm-eabi@4.63.1':
938 + optional: true
939 +
940 + '@rollup/rollup-android-arm64@4.63.1':
941 + optional: true
942 +
943 + '@rollup/rollup-darwin-arm64@4.63.1':
944 + optional: true
945 +
946 + '@rollup/rollup-darwin-x64@4.63.1':
947 + optional: true
948 +
949 + '@rollup/rollup-freebsd-arm64@4.63.1':
950 + optional: true
951 +
952 + '@rollup/rollup-freebsd-x64@4.63.1':
953 + optional: true
954 +
955 + '@rollup/rollup-linux-arm-gnueabihf@4.63.1':
956 + optional: true
957 +
958 + '@rollup/rollup-linux-arm-musleabihf@4.63.1':
959 + optional: true
960 +
961 + '@rollup/rollup-linux-arm64-gnu@4.63.1':
962 + optional: true
963 +
964 + '@rollup/rollup-linux-arm64-musl@4.63.1':
965 + optional: true
966 +
967 + '@rollup/rollup-linux-loong64-gnu@4.63.1':
968 + optional: true
969 +
970 + '@rollup/rollup-linux-loong64-musl@4.63.1':
971 + optional: true
972 +
973 + '@rollup/rollup-linux-ppc64-gnu@4.63.1':
974 + optional: true
975 +
976 + '@rollup/rollup-linux-ppc64-musl@4.63.1':
977 + optional: true
978 +
979 + '@rollup/rollup-linux-riscv64-gnu@4.63.1':
980 + optional: true
981 +
982 + '@rollup/rollup-linux-riscv64-musl@4.63.1':
983 + optional: true
984 +
985 + '@rollup/rollup-linux-s390x-gnu@4.63.1':
986 + optional: true
987 +
988 + '@rollup/rollup-linux-x64-gnu@4.63.1':
989 + optional: true
990 +
991 + '@rollup/rollup-linux-x64-musl@4.63.1':
992 + optional: true
993 +
994 + '@rollup/rollup-openbsd-x64@4.63.1':
995 + optional: true
996 +
997 + '@rollup/rollup-openharmony-arm64@4.63.1':
998 + optional: true
999 +
1000 + '@rollup/rollup-win32-arm64-msvc@4.63.1':
1001 + optional: true
1002 +
1003 + '@rollup/rollup-win32-ia32-msvc@4.63.1':
1004 + optional: true
1005 +
1006 + '@rollup/rollup-win32-x64-gnu@4.63.1':
1007 + optional: true
1008 +
1009 + '@rollup/rollup-win32-x64-msvc@4.63.1':
1010 + optional: true
1011 +
1012 + '@stablelib/base64@1.0.1': {}
1013 +
1014 + '@types/chai@5.2.3':
1015 + dependencies:
1016 + '@types/deep-eql': 4.0.2
1017 + assertion-error: 2.0.1
1018 +
1019 + '@types/deep-eql@4.0.2': {}
1020 +
1021 + '@types/estree@1.0.9': {}
1022 +
1023 + '@types/node@24.13.4':
1024 + dependencies:
1025 + undici-types: 7.18.2
1026 +
1027 + '@types/pg@8.23.1':
1028 + dependencies:
1029 + '@types/node': 24.13.4
1030 + pg-protocol: 1.16.0
1031 + pg-types: 2.2.0
1032 +
1033 + '@vitest/expect@3.2.7':
1034 + dependencies:
1035 + '@types/chai': 5.2.3
1036 + '@vitest/spy': 3.2.7
1037 + '@vitest/utils': 3.2.7
1038 + chai: 5.3.3
1039 + tinyrainbow: 2.0.0
1040 +
1041 + '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@24.13.4)(tsx@4.23.13))':
1042 + dependencies:
1043 + '@vitest/spy': 3.2.7
1044 + estree-walker: 3.0.3
1045 + magic-string: 0.30.21
1046 + optionalDependencies:
1047 + vite: 7.3.6(@types/node@24.13.4)(tsx@4.23.13)
1048 +
1049 + '@vitest/pretty-format@3.2.7':
1050 + dependencies:
1051 + tinyrainbow: 2.0.0
1052 +
1053 + '@vitest/runner@3.2.7':
1054 + dependencies:
1055 + '@vitest/utils': 3.2.7
1056 + pathe: 2.0.3
1057 + strip-literal: 3.1.0
1058 +
1059 + '@vitest/snapshot@3.2.7':
1060 + dependencies:
1061 + '@vitest/pretty-format': 3.2.7
1062 + magic-string: 0.30.21
1063 + pathe: 2.0.3
1064 +
1065 + '@vitest/spy@3.2.7':
1066 + dependencies:
1067 + tinyspy: 4.0.6
1068 +
1069 + '@vitest/utils@3.2.7':
1070 + dependencies:
1071 + '@vitest/pretty-format': 3.2.7
1072 + loupe: 3.2.1
1073 + tinyrainbow: 2.0.0
1074 +
1075 + assertion-error@2.0.1: {}
1076 +
1077 + cac@6.7.14: {}
1078 +
1079 + chai@5.3.3:
1080 + dependencies:
1081 + assertion-error: 2.0.1
1082 + check-error: 2.1.3
1083 + deep-eql: 5.0.2
1084 + loupe: 3.2.1
1085 + pathval: 2.0.1
1086 +
1087 + check-error@2.1.3: {}
1088 +
1089 + debug@4.4.3:
1090 + dependencies:
1091 + ms: 2.1.3
1092 +
1093 + deep-eql@5.0.2: {}
1094 +
1095 + es-module-lexer@1.7.0: {}
1096 +
1097 + esbuild@0.28.2:
1098 + optionalDependencies:
1099 + '@esbuild/aix-ppc64': 0.28.2
1100 + '@esbuild/android-arm': 0.28.2
1101 + '@esbuild/android-arm64': 0.28.2
1102 + '@esbuild/android-x64': 0.28.2
1103 + '@esbuild/darwin-arm64': 0.28.2
1104 + '@esbuild/darwin-x64': 0.28.2
1105 + '@esbuild/freebsd-arm64': 0.28.2
1106 + '@esbuild/freebsd-x64': 0.28.2
1107 + '@esbuild/linux-arm': 0.28.2
1108 + '@esbuild/linux-arm64': 0.28.2
1109 + '@esbuild/linux-ia32': 0.28.2
1110 + '@esbuild/linux-loong64': 0.28.2
1111 + '@esbuild/linux-mips64el': 0.28.2
1112 + '@esbuild/linux-ppc64': 0.28.2
1113 + '@esbuild/linux-riscv64': 0.28.2
1114 + '@esbuild/linux-s390x': 0.28.2
1115 + '@esbuild/linux-x64': 0.28.2
1116 + '@esbuild/netbsd-arm64': 0.28.2
1117 + '@esbuild/netbsd-x64': 0.28.2
1118 + '@esbuild/openbsd-arm64': 0.28.2
1119 + '@esbuild/openbsd-x64': 0.28.2
1120 + '@esbuild/openharmony-arm64': 0.28.2
1121 + '@esbuild/sunos-x64': 0.28.2
1122 + '@esbuild/win32-arm64': 0.28.2
1123 + '@esbuild/win32-ia32': 0.28.2
1124 + '@esbuild/win32-x64': 0.28.2
1125 +
1126 + estree-walker@3.0.3:
1127 + dependencies:
1128 + '@types/estree': 1.0.9
1129 +
1130 + expect-type@1.4.0: {}
1131 +
1132 + fast-sha256@1.3.0: {}
1133 +
1134 + fdir@6.5.0(picomatch@4.0.7):
1135 + optionalDependencies:
1136 + picomatch: 4.0.7
1137 +
1138 + fsevents@2.3.3:
1139 + optional: true
1140 +
1141 + js-tokens@9.0.1: {}
1142 +
1143 + json-schema-to-ts@3.1.1:
1144 + dependencies:
1145 + '@babel/runtime': 7.29.7
1146 + ts-algebra: 2.0.0
1147 +
1148 + loupe@3.2.1: {}
1149 +
1150 + magic-string@0.30.21:
1151 + dependencies:
1152 + '@jridgewell/sourcemap-codec': 1.6.0
1153 +
1154 + ms@2.1.3: {}
1155 +
1156 + nanoid@3.3.19: {}
1157 +
1158 + pathe@2.0.3: {}
1159 +
1160 + pathval@2.0.1: {}
1161 +
1162 + pg-cloudflare@1.4.0:
1163 + optional: true
1164 +
1165 + pg-connection-string@2.14.0: {}
1166 +
1167 + pg-int8@1.0.1: {}
1168 +
1169 + pg-pool@3.14.0(pg@8.23.0):
1170 + dependencies:
1171 + pg: 8.23.0
1172 +
1173 + pg-protocol@1.16.0: {}
1174 +
1175 + pg-types@2.2.0:
1176 + dependencies:
1177 + pg-int8: 1.0.1
1178 + postgres-array: 2.0.0
1179 + postgres-bytea: 1.0.1
1180 + postgres-date: 1.0.7
1181 + postgres-interval: 1.2.0
1182 +
1183 + pg@8.23.0:
1184 + dependencies:
1185 + pg-connection-string: 2.14.0
1186 + pg-pool: 3.14.0(pg@8.23.0)
1187 + pg-protocol: 1.16.0
1188 + pg-types: 2.2.0
1189 + pgpass: 1.0.5
1190 + optionalDependencies:
1191 + pg-cloudflare: 1.4.0
1192 +
1193 + pgpass@1.0.5:
1194 + dependencies:
1195 + split2: 4.2.0
1196 +
1197 + picocolors@1.1.1: {}
1198 +
1199 + picomatch@4.0.7: {}
1200 +
1201 + playwright-core@1.63.0: {}
1202 +
1203 + playwright@1.63.0:
1204 + dependencies:
1205 + playwright-core: 1.63.0
1206 +
1207 + postcss@8.5.28:
1208 + dependencies:
1209 + nanoid: 3.3.19
1210 + picocolors: 1.1.1
1211 + source-map-js: 1.2.1
1212 +
1213 + postgres-array@2.0.0: {}
1214 +
1215 + postgres-bytea@1.0.1: {}
1216 +
1217 + postgres-date@1.0.7: {}
1218 +
1219 + postgres-interval@1.2.0:
1220 + dependencies:
1221 + xtend: 4.0.2
1222 +
1223 + rollup@4.63.1:
1224 + dependencies:
1225 + '@types/estree': 1.0.9
1226 + optionalDependencies:
1227 + '@napi-rs/lzma-linux-x64-gnu': 1.5.1
1228 + '@rollup/rollup-android-arm-eabi': 4.63.1
1229 + '@rollup/rollup-android-arm64': 4.63.1
1230 + '@rollup/rollup-darwin-arm64': 4.63.1
1231 + '@rollup/rollup-darwin-x64': 4.63.1
1232 + '@rollup/rollup-freebsd-arm64': 4.63.1
1233 + '@rollup/rollup-freebsd-x64': 4.63.1
1234 + '@rollup/rollup-linux-arm-gnueabihf': 4.63.1
1235 + '@rollup/rollup-linux-arm-musleabihf': 4.63.1
1236 + '@rollup/rollup-linux-arm64-gnu': 4.63.1
1237 + '@rollup/rollup-linux-arm64-musl': 4.63.1
1238 + '@rollup/rollup-linux-loong64-gnu': 4.63.1
1239 + '@rollup/rollup-linux-loong64-musl': 4.63.1
1240 + '@rollup/rollup-linux-ppc64-gnu': 4.63.1
1241 + '@rollup/rollup-linux-ppc64-musl': 4.63.1
1242 + '@rollup/rollup-linux-riscv64-gnu': 4.63.1
1243 + '@rollup/rollup-linux-riscv64-musl': 4.63.1
1244 + '@rollup/rollup-linux-s390x-gnu': 4.63.1
1245 + '@rollup/rollup-linux-x64-gnu': 4.63.1
1246 + '@rollup/rollup-linux-x64-musl': 4.63.1
1247 + '@rollup/rollup-openbsd-x64': 4.63.1
1248 + '@rollup/rollup-openharmony-arm64': 4.63.1
1249 + '@rollup/rollup-win32-arm64-msvc': 4.63.1
1250 + '@rollup/rollup-win32-ia32-msvc': 4.63.1
1251 + '@rollup/rollup-win32-x64-gnu': 4.63.1
1252 + '@rollup/rollup-win32-x64-msvc': 4.63.1
1253 + fsevents: 2.3.3
1254 +
1255 + siginfo@2.0.0: {}
1256 +
1257 + source-map-js@1.2.1: {}
1258 +
1259 + split2@4.2.0: {}
1260 +
1261 + stackback@0.0.2: {}
1262 +
1263 + standardwebhooks@1.1.1:
1264 + dependencies:
1265 + '@stablelib/base64': 1.0.1
1266 + fast-sha256: 1.3.0
1267 +
1268 + std-env@3.10.0: {}
1269 +
1270 + strip-literal@3.1.0:
1271 + dependencies:
1272 + js-tokens: 9.0.1
1273 +
1274 + tinybench@2.9.0: {}
1275 +
1276 + tinyexec@0.3.2: {}
1277 +
1278 + tinyglobby@0.2.17:
1279 + dependencies:
1280 + fdir: 6.5.0(picomatch@4.0.7)
1281 + picomatch: 4.0.7
1282 +
1283 + tinypool@1.1.1: {}
1284 +
1285 + tinyrainbow@2.0.0: {}
1286 +
1287 + tinyspy@4.0.6: {}
1288 +
1289 + ts-algebra@2.0.0: {}
1290 +
1291 + tsx@4.23.13:
1292 + dependencies:
1293 + esbuild: 0.28.2
1294 + optionalDependencies:
1295 + fsevents: 2.3.3
1296 +
1297 + typescript@5.9.3: {}
1298 +
1299 + undici-types@7.18.2: {}
1300 +
1301 + vite-node@3.2.4(@types/node@24.13.4)(tsx@4.23.13):
1302 + dependencies:
1303 + cac: 6.7.14
1304 + debug: 4.4.3
1305 + es-module-lexer: 1.7.0
1306 + pathe: 2.0.3
1307 + vite: 7.3.6(@types/node@24.13.4)(tsx@4.23.13)
1308 + transitivePeerDependencies:
1309 + - '@types/node'
1310 + - jiti
1311 + - less
1312 + - lightningcss
1313 + - sass
1314 + - sass-embedded
1315 + - stylus
1316 + - sugarss
1317 + - supports-color
1318 + - terser
1319 + - tsx
1320 + - yaml
1321 +
1322 + vite@7.3.6(@types/node@24.13.4)(tsx@4.23.13):
1323 + dependencies:
1324 + esbuild: 0.28.2
1325 + fdir: 6.5.0(picomatch@4.0.7)
1326 + picomatch: 4.0.7
1327 + postcss: 8.5.28
1328 + rollup: 4.63.1
1329 + tinyglobby: 0.2.17
1330 + optionalDependencies:
1331 + '@types/node': 24.13.4
1332 + fsevents: 2.3.3
1333 + tsx: 4.23.13
1334 +
1335 + vitest@3.2.7(@types/node@24.13.4)(tsx@4.23.13):
1336 + dependencies:
1337 + '@types/chai': 5.2.3
1338 + '@vitest/expect': 3.2.7
1339 + '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@24.13.4)(tsx@4.23.13))
1340 + '@vitest/pretty-format': 3.2.7
1341 + '@vitest/runner': 3.2.7
1342 + '@vitest/snapshot': 3.2.7
1343 + '@vitest/spy': 3.2.7
1344 + '@vitest/utils': 3.2.7
1345 + chai: 5.3.3
1346 + debug: 4.4.3
1347 + expect-type: 1.4.0
1348 + magic-string: 0.30.21
1349 + pathe: 2.0.3
1350 + picomatch: 4.0.7
1351 + std-env: 3.10.0
1352 + tinybench: 2.9.0
1353 + tinyexec: 0.3.2
1354 + tinyglobby: 0.2.17
1355 + tinypool: 1.1.1
1356 + tinyrainbow: 2.0.0
1357 + vite: 7.3.6(@types/node@24.13.4)(tsx@4.23.13)
1358 + vite-node: 3.2.4(@types/node@24.13.4)(tsx@4.23.13)
1359 + why-is-node-running: 2.3.0
1360 + optionalDependencies:
1361 + '@types/node': 24.13.4
1362 + transitivePeerDependencies:
1363 + - jiti
1364 + - less
1365 + - lightningcss
1366 + - msw
1367 + - sass
1368 + - sass-embedded
1369 + - stylus
1370 + - sugarss
1371 + - supports-color
1372 + - terser
1373 + - tsx
1374 + - yaml
1375 +
1376 + why-is-node-running@2.3.0:
1377 + dependencies:
1378 + siginfo: 2.0.0
1379 + stackback: 0.0.2
1380 +
1381 + xtend@4.0.2: {}
added pnpm-workspace.yaml +5 −0
@@ -0,0 +1,5 @@
1 +packages:
2 + - "apps/*"
3 + - "packages/*"
4 +onlyBuiltDependencies:
5 + - esbuild
added tsconfig.json +34 −0
@@ -0,0 +1,34 @@
1 +{
2 + "compilerOptions": {
3 + "target": "ES2022",
4 + "module": "ESNext",
5 + "moduleResolution": "Bundler",
6 + "lib": ["ES2023", "DOM", "DOM.Iterable"],
7 + "strict": true,
8 + "noUncheckedIndexedAccess": true,
9 + "noImplicitOverride": true,
10 + "esModuleInterop": true,
11 + "skipLibCheck": true,
12 + "forceConsistentCasingInFileNames": true,
13 + "resolveJsonModule": true,
14 + "isolatedModules": true,
15 + "allowImportingTsExtensions": true,
16 + "noEmit": true,
17 + "types": ["node"],
18 + "baseUrl": ".",
19 + "paths": {
20 + "@src/shared": ["packages/shared/src/index.ts"],
21 + "@src/events": ["packages/events/src/index.ts"],
22 + "@src/browser": ["packages/browser/src/index.ts"],
23 + "@src/observers": ["packages/observers/src/index.ts"],
24 + "@src/entities": ["packages/entities/src/index.ts"],
25 + "@src/media": ["packages/media/src/index.ts"],
26 + "@src/agent": ["packages/agent/src/index.ts"],
27 + "@src/platform-model": ["packages/platform-model/src/index.ts"],
28 + "@src/storage": ["packages/storage/src/index.ts"],
29 + "@src/connectors": ["packages/connectors/src/index.ts"]
30 + }
31 + },
32 + "include": ["apps/**/*.ts", "packages/**/*.ts", "scripts/**/*.ts"],
33 + "exclude": ["node_modules", "**/node_modules", "data", "connectors"]
34 +}
added vitest.config.ts +27 −0
@@ -0,0 +1,27 @@
1 +import { defineConfig } from "vitest/config";
2 +import { fileURLToPath } from "node:url";
3 +import path from "node:path";
4 +
5 +const root = path.dirname(fileURLToPath(import.meta.url));
6 +const pkg = (name: string) => path.join(root, "packages", name, "src", "index.ts");
7 +
8 +export default defineConfig({
9 + test: {
10 + include: ["packages/**/*.test.ts", "apps/**/*.test.ts"],
11 + environment: "node",
12 + },
13 + resolve: {
14 + alias: {
15 + "@src/shared": pkg("shared"),
16 + "@src/events": pkg("events"),
17 + "@src/browser": pkg("browser"),
18 + "@src/observers": pkg("observers"),
19 + "@src/entities": pkg("entities"),
20 + "@src/media": pkg("media"),
21 + "@src/agent": pkg("agent"),
22 + "@src/platform-model": pkg("platform-model"),
23 + "@src/storage": pkg("storage"),
24 + "@src/connectors": pkg("connectors"),
25 + },
26 + },
27 +});
28