From 87ca37b37c461db18cfe4f837cd59ce6f28ef205 Mon Sep 17 00:00:00 2001 From: Anthony Date: Tue, 11 Aug 2026 12:27:25 +0000 Subject: [PATCH 1/4] feat: GitHub adapter, signal extraction, and the next-best-action engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the gap where the approval queue was fully built but nothing fed it. A bare GitHub handle now walks the whole pipeline to a queued card: enrich -> resolve identities -> collect signals -> score -> recommend GitHub first because it is free and its profiles carry links the person published themselves. Verified live against a real account: three linked identities (github 1.00, x 0.956 via cross_linked_profile + same_employer, website 0.918 via same_personal_domain), signals extracted, scored, and a recommendation produced. The recommendation engine cannot invent capabilities — candidate actions are filtered through allowedActions before anything is chosen, so on GitHub it can only ever propose passive actions, never the disabled comment or DM. It also cannot invent a reason: every recommendation names its trigger signal, and only signals carrying verbatim evidence enter groundedSignalIds. Signal classification is rule-based, not model-based, so it is reproducible. 265 tests, typecheck clean. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 19 +- apps/worker/package.json | 3 +- apps/worker/src/pipeline.test.ts | 334 ++++++++++ apps/worker/src/pipeline.ts | 667 +++++++++++++++++++ bun.lock | 12 + docs/prd-implementation-map.md | 67 +- packages/providers/src/github/client.ts | 154 +++++ packages/providers/src/github/github.test.ts | 344 ++++++++++ packages/providers/src/github/provider.ts | 236 +++++++ packages/providers/src/github/signals.ts | 278 ++++++++ packages/providers/src/index.ts | 21 + packages/recommend/package.json | 18 + packages/recommend/src/engine.test.ts | 368 ++++++++++ packages/recommend/src/engine.ts | 304 +++++++++ packages/recommend/src/index.ts | 16 + 15 files changed, 2808 insertions(+), 33 deletions(-) create mode 100644 apps/worker/src/pipeline.test.ts create mode 100644 apps/worker/src/pipeline.ts create mode 100644 packages/providers/src/github/client.ts create mode 100644 packages/providers/src/github/github.test.ts create mode 100644 packages/providers/src/github/provider.ts create mode 100644 packages/providers/src/github/signals.ts create mode 100644 packages/recommend/package.json create mode 100644 packages/recommend/src/engine.test.ts create mode 100644 packages/recommend/src/engine.ts create mode 100644 packages/recommend/src/index.ts diff --git a/README.md b/README.md index 31619b4..0d48fd5 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ for exactly what exists. ```bash bun install bun run db:migrate # applies migrations to ./local.db -bun test # 202 tests +bun test # 265 tests bun run check # format, typecheck, test ``` @@ -43,6 +43,7 @@ packages/ domain/ canonical types — depends on nothing db/ Turso/libSQL client and migration runner policy/ the deterministic policy engine + recommend/ next-best-action engine identity/ cross-network identity resolution signals/ signal decay scoring/ ICP fit, intent, reachability, relationship, opportunity @@ -52,6 +53,22 @@ migrations/ forward-only .sql, applied in filename order docker/ one Dockerfile per deployable service ``` +## The pipeline + +One GitHub handle goes all the way to a card in the approval queue: + +```text +enrich → resolve identities → collect signals → score → recommend +``` + +`apps/worker/src/pipeline.ts` runs it. GitHub first because it is free, its +profiles carry links the person published themselves — `twitter_username`, +`blog`, `company` — and developer tooling is the launch wedge. A real profile +typically yields three linked identities before any paid provider is touched. + +Each stage persists before the next runs, so a crash resumes rather than +restarting, and a half-enriched prospect is still inspectable. + ## The three ideas worth knowing **The policy engine is arithmetic, not judgement.** Every outbound action diff --git a/apps/worker/package.json b/apps/worker/package.json index d43d475..d6c4275 100644 --- a/apps/worker/package.json +++ b/apps/worker/package.json @@ -17,6 +17,7 @@ "@outreachgraph/policy": "workspace:*", "@outreachgraph/providers": "workspace:*", "@outreachgraph/scoring": "workspace:*", - "@outreachgraph/signals": "workspace:*" + "@outreachgraph/signals": "workspace:*", + "@outreachgraph/recommend": "workspace:*" } } diff --git a/apps/worker/src/pipeline.test.ts b/apps/worker/src/pipeline.test.ts new file mode 100644 index 0000000..2dec39a --- /dev/null +++ b/apps/worker/src/pipeline.test.ts @@ -0,0 +1,334 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { GitHubProvider } from '@outreachgraph/providers'; +import { seedDatabase, SEED, type SeededDatabase } from '../../api/src/test-seed'; +import { runPipeline } from './pipeline'; + +/** A GitHub profile with the self-declared cross-links the resolver needs. */ +const PROFILE = { + login: 'alexchen', + id: 4242, + name: 'Alex Chen', + company: '@Loopwright', + blog: 'https://loopwright.io', + location: 'Berlin', + email: null, + bio: 'Agent reliability', + twitter_username: 'alexbuilds', + public_repos: 30, + followers: 500, + html_url: 'https://github.com/alexchen', + created_at: '2015-01-01T00:00:00Z', + updated_at: '2026-08-01T00:00:00Z', +}; + +function hoursAgo(hours: number): string { + return new Date(Date.now() - hours * 3_600_000).toISOString(); +} + +const EVENTS = [ + { + id: '1', + type: 'IssuesEvent', + created_at: hoursAgo(3), + repo: { id: 9, name: 'loopwright/agents', url: '' }, + payload: { + action: 'opened', + issue: { + title: 'Anyone know a good alternative to Stripe for cross-border payouts?', + body: 'Fees are brutal and settlement takes days.', + html_url: 'https://github.com/loopwright/agents/issues/12', + }, + }, + }, +]; + +const REPOS = [ + { + name: 'agents', + full_name: 'loopwright/agents', + html_url: 'https://github.com/loopwright/agents', + description: 'agent orchestration', + language: 'TypeScript', + topics: ['bun'], + fork: false, + stargazers_count: 40, + created_at: hoursAgo(500), + pushed_at: hoursAgo(10), + }, +]; + +/** Serves the canned GitHub payloads by URL suffix. */ +function stubGitHub(): GitHubProvider { + const fetchImpl = (async (input: string | URL | Request) => { + const url = typeof input === 'string' ? input : input.toString(); + + const body = url.includes('/events/public') + ? EVENTS + : url.includes('/repos') + ? REPOS + : url.includes('/users/alexchen') + ? PROFILE + : null; + + if (!body) return new Response('{"message":"Not Found"}', { status: 404 }); + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }) as unknown as typeof fetch; + + return new GitHubProvider({ fetchImpl }); +} + +let active: SeededDatabase | undefined; + +afterEach(() => { + active?.cleanup(); + active = undefined; +}); + +async function fixture(label: string): Promise { + active = await seedDatabase(`pipeline-${label}`); + + // Give the campaign the targeting the extractor scores relevance against. + await active.db.execute({ + sql: `INSERT INTO campaign_filters (campaign_id, technologies, keywords, updated_at) + VALUES (?, ?, ?, ?)`, + args: [ + SEED.campaignId, + JSON.stringify(['TypeScript', 'bun']), + JSON.stringify(['payouts', 'payments']), + new Date().toISOString(), + ], + }); + await active.db.execute({ + sql: 'UPDATE offerings SET competitors = ? WHERE id = ?', + args: [JSON.stringify(['Stripe']), SEED.offeringId], + }); + + return active; +} + +function options(db: SeededDatabase['db']) { + return { + db, + workspaceId: SEED.workspaceId, + campaignId: SEED.campaignId, + providers: [], + github: stubGitHub(), + }; +} + +describe('end to end', () => { + test('takes a bare handle all the way to the approval queue', async () => { + const { db } = await fixture('happy'); + + const result = await runPipeline(options(db), 'alexchen'); + + expect(result.stage).toBe('recommended'); + expect(result.personId).toBeDefined(); + expect(result.recommendationId).toBeDefined(); + expect(result.signalsStored).toBeGreaterThan(0); + + // The card is now visible to the approval queue query. + const queue = await db.execute({ + sql: `SELECT r.id, r.action, r.network, r.priority, p.display_name + FROM recommendations r JOIN people p ON p.id = r.person_id + WHERE r.workspace_id = ? AND r.status = 'pending' AND r.person_id = ?`, + args: [SEED.workspaceId, result.personId!], + }); + + expect(queue.rows).toHaveLength(1); + expect(queue.rows[0]?.display_name).toBe('Alex Chen'); + }); + + test('links the identities the profile declares', async () => { + const { db } = await fixture('identities'); + const result = await runPipeline(options(db), 'alexchen'); + + const identities = await db.execute({ + sql: 'SELECT network, handle, confidence FROM social_identities WHERE person_id = ?', + args: [result.personId!], + }); + + const networks = identities.rows.map((r) => String(r.network)); + expect(networks).toContain('github'); + // Declared on their own profile, so it clears the merge threshold. + expect(networks).toContain('x'); + }); + + test('records provenance for every enriched field', async () => { + const { db } = await fixture('provenance'); + const result = await runPipeline(options(db), 'alexchen'); + + const provenance = await db.execute({ + sql: `SELECT field, provider, license_class FROM field_provenance + WHERE entity_kind = 'person' AND entity_id = ?`, + args: [result.personId!], + }); + + expect(provenance.rows.length).toBeGreaterThan(0); + expect(provenance.rows[0]?.provider).toBe('github'); + expect(provenance.rows[0]?.license_class).toBe('public_api'); + }); + + test('classifies the competitor complaint and grounds the recommendation on it', async () => { + const { db } = await fixture('signal'); + const result = await runPipeline(options(db), 'alexchen'); + + const signals = await db.execute({ + sql: 'SELECT signal_type, subtype, evidence FROM signals WHERE person_id = ?', + args: [result.personId!], + }); + + const types = signals.rows.map((r) => String(r.signal_type)); + expect(types).toContain('competitor_mention'); + + const competitor = signals.rows.find((r) => r.signal_type === 'competitor_mention'); + expect(competitor?.subtype).toBe('Stripe'); + // Verbatim words, so a reply may quote them. + expect(String(competitor?.evidence)).toContain('alternative to Stripe'); + + const recommendation = await db.execute({ + sql: 'SELECT trigger_signal_id, reason FROM recommendations WHERE id = ?', + args: [result.recommendationId!], + }); + expect(recommendation.rows[0]?.trigger_signal_id).toBeTruthy(); + }); + + test('scores the prospect before recommending', async () => { + const { db } = await fixture('score'); + const result = await runPipeline(options(db), 'alexchen'); + + const score = await db.execute({ + sql: 'SELECT opportunity, intent FROM scores WHERE person_id = ? AND campaign_id = ?', + args: [result.personId!, SEED.campaignId], + }); + + expect(Number(score.rows[0]?.opportunity)).toBeGreaterThan(0); + expect(Number(score.rows[0]?.intent)).toBeGreaterThan(0); + }); + + test('advances the prospect through the pipeline states', async () => { + const { db } = await fixture('states'); + const result = await runPipeline(options(db), 'alexchen'); + + const membership = await db.execute({ + sql: 'SELECT status FROM campaign_people WHERE campaign_id = ? AND person_id = ?', + args: [SEED.campaignId, result.personId!], + }); + + expect(membership.rows[0]?.status).toBe('awaiting_approval'); + }); +}); + +describe('idempotence', () => { + test('a second run reuses the person rather than duplicating them', async () => { + const { db } = await fixture('rerun'); + + const first = await runPipeline(options(db), 'alexchen'); + const second = await runPipeline(options(db), 'alexchen'); + + expect(second.personId).toBe(first.personId!); + + const people = await db.execute( + "SELECT count(*) AS n FROM people WHERE display_name = 'Alex Chen'", + ); + expect(Number(people.rows[0]?.n)).toBe(1); + }); + + test('does not duplicate signals for the same public event', async () => { + const { db } = await fixture('resignal'); + + const first = await runPipeline(options(db), 'alexchen'); + const second = await runPipeline(options(db), 'alexchen'); + + expect(second.signalsStored).toBe(0); + + const signals = await db.execute({ + sql: 'SELECT count(*) AS n FROM signals WHERE person_id = ?', + args: [first.personId!], + }); + // Same count as the first run produced. + expect(Number(signals.rows[0]?.n)).toBe(first.signalsStored); + }); +}); + +describe('refusals', () => { + test('stops on an unknown handle without creating a person', async () => { + const { db } = await fixture('unknown'); + + const result = await runPipeline(options(db), 'does-not-exist'); + + expect(result.stage).toBe('stopped'); + expect(result.personId).toBeUndefined(); + + const people = await db.execute( + "SELECT count(*) AS n FROM people WHERE display_name != 'Jane Smith'", + ); + expect(Number(people.rows[0]?.n)).toBe(0); + }); + + test('stops before researching a suppressed person', async () => { + const { db } = await fixture('suppressed'); + const stamp = new Date().toISOString(); + + // Suppress the GitHub account before it is ever seen. + await db.execute({ + sql: `INSERT INTO suppression_entries (id, reason, scope, source, created_at) + VALUES ('sup_pipe', 'consumer_opt_out', 'global', 'privacy_request', ?)`, + args: [stamp], + }); + + // First run links the identity so the suppression key can match it. + const first = await runPipeline(options(db), 'alexchen'); + await db.execute({ + sql: `INSERT INTO suppression_keys (match_key, suppression_id, scope, workspace_id) + VALUES (?, 'sup_pipe', 'global', NULL)`, + args: [`person:${first.personId!}`], + }); + + await db.execute({ + sql: 'DELETE FROM recommendations WHERE person_id = ?', + args: [first.personId!], + }); + + const second = await runPipeline(options(db), 'alexchen'); + + expect(second.stage).toBe('stopped'); + expect(second.stoppedBecause).toBe('suppressed'); + + const recommendations = await db.execute({ + sql: 'SELECT count(*) AS n FROM recommendations WHERE person_id = ?', + args: [first.personId!], + }); + expect(Number(recommendations.rows[0]?.n)).toBe(0); + + const membership = await db.execute({ + sql: 'SELECT status FROM campaign_people WHERE person_id = ?', + args: [first.personId!], + }); + expect(membership.rows[0]?.status).toBe('suppressed'); + }); + + test('recommends nothing when the daily limit is already spent', async () => { + const { db } = await fixture('ratelimited'); + + await db.execute({ + sql: 'UPDATE campaigns SET budget_json = ? WHERE id = ?', + args: [JSON.stringify({ maxActionsPerDay: 0 }), SEED.campaignId], + }); + + const result = await runPipeline(options(db), 'alexchen'); + + // Research still happened and is retained; only the outreach is withheld. + expect(result.signalsStored).toBeGreaterThan(0); + + const outbound = await db.execute({ + sql: `SELECT count(*) AS n FROM recommendations + WHERE person_id = ? AND action IN ('reply','comment','send_dm','send_email')`, + args: [result.personId!], + }); + expect(Number(outbound.rows[0]?.n)).toBe(0); + }); +}); diff --git a/apps/worker/src/pipeline.ts b/apps/worker/src/pipeline.ts new file mode 100644 index 0000000..4c0f2ec --- /dev/null +++ b/apps/worker/src/pipeline.ts @@ -0,0 +1,667 @@ +/** + * The discovery-to-queue pipeline (PRD §8). + * + * Walks one prospect from a bare handle to a card in the approval queue: + * + * enrich → resolve identities → collect signals → score → recommend + * + * Each stage persists before the next runs, so a crash resumes from the last + * completed stage rather than restarting the whole chain — and so a partially + * enriched prospect is still inspectable in the UI. + */ + +import { newId, type Network, type SignalType } from '@outreachgraph/domain'; +import { now, queryAll, queryOne, type Client } from '@outreachgraph/db'; +import { resolveIdentity, type EvidenceInput } from '@outreachgraph/identity'; +import { + deriveEvidence, + extractSignals, + GitHubProvider, + GitHubRateLimitError, + type ExtractedSignal, + type PersonCandidate, + type PersonEnrichmentProvider, +} from '@outreachgraph/providers'; +import { generateRecommendation, type CandidateSignal } from '@outreachgraph/recommend'; +import { rescoreProspect } from './jobs'; + +export interface PipelineOptions { + readonly db: Client; + readonly workspaceId: string; + readonly campaignId: string; + readonly providers: readonly PersonEnrichmentProvider[]; + /** Supplies GitHub activity. Omit to skip signal collection. */ + readonly github?: GitHubProvider; + readonly now?: Date; +} + +export interface PipelineResult { + readonly personId?: string; + readonly stage: 'enriched' | 'resolved' | 'researched' | 'scored' | 'recommended' | 'stopped'; + readonly identitiesLinked: number; + readonly signalsStored: number; + readonly recommendationId?: string; + readonly stoppedBecause?: string; +} + +/** + * Runs the full chain for one GitHub handle. + * + * GitHub-first is deliberate: it is free, its profiles carry self-declared + * cross-links, and the launch wedge is developer tooling (PRD §45). + */ +export async function runPipeline( + options: PipelineOptions, + handle: string, +): Promise { + const { db, workspaceId, campaignId } = options; + const stamp = now(); + + // ---------------------------------------------------------------- enrich + const github = options.github ?? new GitHubProvider(); + const enrichment = await github.enrich({ handles: { github: handle } }); + + if (!enrichment.candidate) { + return { + stage: 'stopped', + identitiesLinked: 0, + signalsStored: 0, + stoppedBecause: `no GitHub profile for ${handle}`, + }; + } + + const candidate = enrichment.candidate; + const personId = await upsertPerson(db, candidate, stamp); + + await recordProvenance(db, personId, candidate, stamp); + await ensureCampaignMembership(db, campaignId, personId, workspaceId, stamp); + + // Suppression outranks everything: a suppressed person is never researched + // or scored, only recorded as excluded (PRD §6.6, §17.3). + if (await isSuppressed(db, workspaceId, personId)) { + await setStatus(db, campaignId, personId, 'suppressed', 'matched a suppression key'); + return { + personId, + stage: 'stopped', + identitiesLinked: 0, + signalsStored: 0, + stoppedBecause: 'suppressed', + }; + } + + // -------------------------------------------------------------- resolve + const linked = await linkIdentities(db, personId, candidate, github, stamp); + await setStatus(db, campaignId, personId, 'resolved'); + + // ------------------------------------------------------------- research + let stored = 0; + try { + const activity = await github.activity(handle); + const context = await extractionContext(db, campaignId); + const extracted = extractSignals(activity.events, activity.repos, context); + stored = await storeSignals(db, workspaceId, personId, extracted, stamp); + await setStatus(db, campaignId, personId, 'researching'); + } catch (error) { + // A quota wall is not a prospect failure — keep what we have and let the + // next tick resume research. + if (!(error instanceof GitHubRateLimitError)) throw error; + } + + // ---------------------------------------------------------------- score + await rescoreProspect(db, campaignId, personId); + await setStatus(db, campaignId, personId, 'qualified'); + + // ------------------------------------------------------------ recommend + const recommendationId = await createRecommendation(db, options, personId); + + if (!recommendationId) { + return { + personId, + stage: 'scored', + identitiesLinked: linked, + signalsStored: stored, + stoppedBecause: 'no permitted action', + }; + } + + await setStatus(db, campaignId, personId, 'awaiting_approval'); + + return { + personId, + stage: 'recommended', + identitiesLinked: linked, + signalsStored: stored, + recommendationId, + }; +} + +/** Matches on the stable platform id, never the renameable handle. */ +async function upsertPerson( + db: Client, + candidate: PersonCandidate, + stamp: string, +): Promise { + const githubIdentity = candidate.identities.find((i) => i.network === 'github'); + + const existing = githubIdentity?.platformUserId + ? await queryOne<{ person_id: string }>( + db, + 'SELECT person_id FROM social_identities WHERE network = ? AND platform_user_id = ?', + ['github', githubIdentity.platformUserId], + ) + : undefined; + + if (existing) return existing.person_id; + + const companyId = candidate.companyName ? await upsertCompany(db, candidate, stamp) : undefined; + + const personId = newId('person'); + await db.execute({ + sql: `INSERT INTO people (id, display_name, first_name, last_name, current_company_id, + current_title, location, identity_confidence, status, outreach_eligible, + believed_minor, created_at, updated_at, last_resolved_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 0, 'active', 1, 0, ?, ?, ?)`, + args: [ + personId, + candidate.fullName, + candidate.firstName ?? null, + candidate.lastName ?? null, + companyId ?? null, + candidate.title ?? null, + candidate.location ?? null, + stamp, + stamp, + stamp, + ], + }); + + return personId; +} + +async function upsertCompany( + db: Client, + candidate: PersonCandidate, + stamp: string, +): Promise { + if (candidate.companyDomain) { + const existing = await queryOne<{ id: string }>( + db, + 'SELECT id FROM companies WHERE domain = ?', + [candidate.companyDomain], + ); + if (existing) return existing.id; + } + + const id = newId('company'); + await db.execute({ + sql: `INSERT INTO companies (id, name, domain, employee_count, industry, technologies, + created_at, updated_at) VALUES (?, ?, ?, ?, ?, '[]', ?, ?)`, + args: [ + id, + candidate.companyName ?? 'Unknown', + candidate.companyDomain ?? null, + candidate.employeeCount ?? null, + candidate.industry ?? null, + stamp, + stamp, + ], + }); + return id; +} + +/** + * Scores each discovered identity and stores only those clearing the + * workspace's auto-merge threshold. Anything in the candidate band becomes a + * review row instead of a silent link (PRD §9.4). + */ +async function linkIdentities( + db: Client, + personId: string, + candidate: PersonCandidate, + provider: GitHubProvider, + stamp: string, +): Promise { + const workspace = await queryOne<{ auto_merge_threshold: number; candidate_threshold: number }>( + db, + `SELECT w.auto_merge_threshold, w.candidate_threshold FROM workspaces w LIMIT 1`, + ); + + const thresholds = { + autoMerge: workspace?.auto_merge_threshold ?? 0.9, + candidate: workspace?.candidate_threshold ?? 0.7, + }; + + // Handles the person published on their own GitHub profile — these are what + // make a cross-link, rather than an inference. + const declared = candidate.identities + .map((i) => i.handle) + .filter((h): h is string => typeof h === 'string'); + + let linked = 0; + + for (const identity of candidate.identities) { + const evidence: EvidenceInput[] = deriveEvidence({ + identity, + candidate, + capabilities: provider.capabilities(), + crossLinkedHandles: declared, + ...(candidate.companyName ? { platformEmployer: candidate.companyName } : {}), + }); + + // The GitHub account itself is the anchor, not an inference about it. + const resolution = + identity.network === 'github' + ? { decision: 'merge' as const, score: 1, verifiedBy: ['provider_asserted_link'] } + : resolveIdentity(evidence, { thresholds }); + + if (resolution.decision === 'merge') { + await db.execute({ + sql: `INSERT INTO social_identities (id, person_id, network, handle, platform_user_id, + profile_url, confidence, source_type, verified_by, first_seen_at, last_verified_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 'official_api', ?, ?, ?) + -- The uniqueness index is partial, so the conflict target has to + -- repeat its WHERE clause or SQLite refuses to match it. + ON CONFLICT(network, platform_user_id) WHERE platform_user_id IS NOT NULL + DO NOTHING`, + args: [ + newId('socialIdentity'), + personId, + identity.network, + identity.handle ?? null, + identity.platformUserId ?? null, + identity.profileUrl ?? null, + resolution.score, + JSON.stringify(resolution.verifiedBy), + stamp, + stamp, + ], + }); + linked += 1; + } else if (resolution.decision === 'candidate') { + await db.execute({ + sql: `INSERT INTO identity_candidates (id, workspace_id, person_id, network, handle, + platform_user_id, profile_url, score, status, created_at) + VALUES (?, (SELECT id FROM workspaces LIMIT 1), ?, ?, ?, ?, ?, ?, 'pending', ?)`, + args: [ + newId('identityCandidate'), + personId, + identity.network, + identity.handle ?? null, + identity.platformUserId ?? null, + identity.profileUrl ?? null, + resolution.score, + stamp, + ], + }); + } + } + + // The person is only as trustworthy as their weakest confirmed link. + const scores = await queryAll<{ confidence: number }>( + db, + 'SELECT confidence FROM social_identities WHERE person_id = ?', + [personId], + ); + + const aggregate = scores.length === 0 ? 0 : Math.min(...scores.map((s) => s.confidence)); + + await db.execute({ + sql: 'UPDATE people SET identity_confidence = ?, last_resolved_at = ? WHERE id = ?', + args: [aggregate, stamp, personId], + }); + + return linked; +} + +async function storeSignals( + db: Client, + workspaceId: string, + personId: string, + extracted: readonly ExtractedSignal[], + stamp: string, +): Promise { + let stored = 0; + + for (const signal of extracted) { + // The source URL is the natural dedupe key: re-running research must not + // duplicate the same public event. + if (signal.sourceUrl) { + const existing = await queryOne<{ id: string }>( + db, + 'SELECT id FROM signals WHERE workspace_id = ? AND person_id = ? AND source_url = ? AND signal_type = ?', + [workspaceId, personId, signal.sourceUrl, signal.type], + ); + if (existing) continue; + } + + await db.execute({ + sql: `INSERT INTO signals (id, workspace_id, person_id, network, signal_type, subtype, + summary, evidence, source_url, source_timestamp, observed_at, confidence, + relevance, sentiment) + VALUES (?, ?, ?, 'github', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + args: [ + newId('signal'), + workspaceId, + personId, + signal.type, + signal.subtype ?? null, + signal.summary, + signal.evidence ?? null, + signal.sourceUrl ?? null, + signal.sourceTimestamp, + stamp, + signal.confidence, + signal.relevance, + signal.sentiment, + ], + }); + stored += 1; + } + + return stored; +} + +async function createRecommendation( + db: Client, + options: PipelineOptions, + personId: string, +): Promise { + const { db: _db, workspaceId, campaignId } = options; + void _db; + + const person = await queryOne<{ + identity_confidence: number; + status: string; + believed_minor: number; + }>(db, 'SELECT identity_confidence, status, believed_minor FROM people WHERE id = ?', [personId]); + if (!person) return undefined; + + const campaign = await queryOne<{ approval_mode: string; budget_json: string }>( + db, + 'SELECT approval_mode, budget_json FROM campaigns WHERE id = ?', + [campaignId], + ); + if (!campaign) return undefined; + + const workspace = await queryOne<{ min_outreach_confidence: number }>( + db, + 'SELECT min_outreach_confidence FROM workspaces WHERE id = ?', + [workspaceId], + ); + + const signalRows = await queryAll<{ + id: string; + network: string; + signal_type: string; + summary: string; + evidence: string | null; + source_url: string | null; + source_timestamp: string | null; + observed_at: string; + confidence: number; + relevance: number; + expires_at: string | null; + }>( + db, + `SELECT id, network, signal_type, summary, evidence, source_url, source_timestamp, + observed_at, confidence, relevance, expires_at + FROM signals WHERE workspace_id = ? AND person_id = ?`, + [workspaceId, personId], + ); + + const signals: CandidateSignal[] = signalRows.map((row) => ({ + id: row.id, + network: row.network as Network, + type: row.signal_type as SignalType, + summary: row.summary, + ...(row.evidence ? { evidence: row.evidence } : {}), + ...(row.source_url ? { sourceUrl: row.source_url } : {}), + ...(row.source_timestamp ? { sourceTimestamp: row.source_timestamp } : {}), + observedAt: row.observed_at, + confidence: row.confidence, + relevance: row.relevance, + ...(row.expires_at ? { expiresAt: row.expires_at } : {}), + })); + + const identities = await queryAll<{ network: string }>( + db, + 'SELECT DISTINCT network FROM social_identities WHERE person_id = ?', + [personId], + ); + + const score = await queryOne<{ opportunity: number }>( + db, + 'SELECT opportunity FROM scores WHERE campaign_id = ? AND person_id = ?', + [campaignId, personId], + ); + + const budget = safeJson(campaign.budget_json); + const counts = await actionCounts(db, workspaceId, personId); + const flags = await featureFlags(db, workspaceId); + const connected = await connectedNetworks(db, workspaceId); + + const result = generateRecommendation({ + personId, + campaignId, + signals, + reachableNetworks: identities.map((i) => i.network as Network), + opportunity: score?.opportunity ?? 0, + ...(options.now ? { now: options.now } : {}), + policy: { + approvalMode: campaign.approval_mode as 'draft_and_approve', + // Resolved per-network below by the engine's own policy calls; this is + // the workspace-wide answer for the common case. + hasConnectedAccount: connected.size > 0, + personSuppressed: person.status === 'suppressed', + personBelievedMinor: person.believed_minor === 1, + personDeleted: person.status === 'deleted', + identityConfidence: person.identity_confidence, + minIdentityConfidence: workspace?.min_outreach_confidence ?? 0.85, + actionsToday: counts.today, + maxActionsPerDay: numberOr(budget.maxActionsPerDay, 50), + actionsToThisProspectThisWeek: counts.thisProspect, + maxActionsPerProspectPerWeek: numberOr(budget.maxActionsPerProspectPerWeek, 1), + featureFlags: flags, + }, + }); + + if (!result.ok) return undefined; + + const recommendation = result.recommendation; + const id = newId('recommendation'); + + await db.execute({ + sql: `INSERT INTO recommendations (id, workspace_id, campaign_id, person_id, action, network, + priority, reason, trigger_signal_id, policy_status, policy_version, expected_goal, + status, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`, + args: [ + id, + workspaceId, + campaignId, + personId, + recommendation.action, + recommendation.network, + recommendation.priority, + recommendation.reason, + recommendation.triggerSignalId ?? null, + recommendation.policyDecision, + recommendation.policyVersion, + recommendation.expectedGoal, + now(), + recommendation.expiresAt ?? null, + ], + }); + + return id; +} + +async function recordProvenance( + db: Client, + personId: string, + candidate: PersonCandidate, + stamp: string, +): Promise { + const fields: [string, string | undefined][] = [ + ['fullName', candidate.fullName], + ['companyName', candidate.companyName], + ['location', candidate.location], + ['personalDomain', candidate.personalDomain], + ]; + + for (const [field, value] of fields) { + if (!value) continue; + await db.execute({ + sql: `INSERT INTO field_provenance (id, entity_kind, entity_id, field, value, source_type, + provider, source_record_id, license_class, confidence, observed_at, created_at) + VALUES (?, 'person', ?, ?, ?, 'official_api', 'github', ?, 'public_api', 1.0, ?, ?)`, + args: [ + newId('fieldProvenance'), + personId, + field, + value, + candidate.sourceRecordId ?? null, + candidate.observedAt, + stamp, + ], + }); + } +} + +async function ensureCampaignMembership( + db: Client, + campaignId: string, + personId: string, + workspaceId: string, + stamp: string, +): Promise { + await db.execute({ + sql: `INSERT INTO campaign_people (campaign_id, person_id, workspace_id, status, + interaction_state, discovered_at, updated_at) + VALUES (?, ?, ?, 'discovered', 'never_contacted', ?, ?) + ON CONFLICT(campaign_id, person_id) DO NOTHING`, + args: [campaignId, personId, workspaceId, stamp, stamp], + }); +} + +async function setStatus( + db: Client, + campaignId: string, + personId: string, + status: string, + reason?: string, +): Promise { + await db.execute({ + sql: `UPDATE campaign_people SET status = ?, status_reason = ?, updated_at = ? + WHERE campaign_id = ? AND person_id = ?`, + args: [status, reason ?? null, now(), campaignId, personId], + }); +} + +async function isSuppressed(db: Client, workspaceId: string, personId: string): Promise { + const identities = await queryAll<{ network: string; platform_user_id: string | null }>( + db, + 'SELECT network, platform_user_id FROM social_identities WHERE person_id = ?', + [personId], + ); + + const keys = [`person:${personId}`]; + for (const identity of identities) { + if (identity.platform_user_id) { + keys.push(`platform:${identity.network}:${identity.platform_user_id}`); + } + } + + const placeholders = keys.map(() => '?').join(', '); + const row = await queryOne<{ n: number }>( + db, + `SELECT count(*) AS n FROM suppression_keys + WHERE match_key IN (${placeholders}) AND (scope = 'global' OR workspace_id = ?)`, + [...keys, workspaceId], + ); + + return Number(row?.n ?? 0) > 0; +} + +async function extractionContext(db: Client, campaignId: string) { + const filters = await queryOne<{ technologies: string; keywords: string }>( + db, + 'SELECT technologies, keywords FROM campaign_filters WHERE campaign_id = ?', + [campaignId], + ); + + const offering = await queryOne<{ competitors: string }>( + db, + `SELECT o.competitors FROM offerings o + JOIN campaigns c ON c.offering_id = o.id WHERE c.id = ?`, + [campaignId], + ); + + return { + technologies: parseArray(filters?.technologies), + keywords: parseArray(filters?.keywords), + competitors: parseArray(offering?.competitors), + }; +} + +async function actionCounts(db: Client, workspaceId: string, personId: string) { + const dayAgo = new Date(Date.now() - 86_400_000).toISOString(); + const weekAgo = new Date(Date.now() - 7 * 86_400_000).toISOString(); + + const today = await queryOne<{ n: number }>( + db, + `SELECT count(*) AS n FROM actions WHERE workspace_id = ? AND created_at >= ?`, + [workspaceId, dayAgo], + ); + const week = await queryOne<{ n: number }>( + db, + `SELECT count(*) AS n FROM actions WHERE workspace_id = ? AND person_id = ? AND created_at >= ?`, + [workspaceId, personId, weekAgo], + ); + + return { today: Number(today?.n ?? 0), thisProspect: Number(week?.n ?? 0) }; +} + +async function featureFlags(db: Client, workspaceId: string): Promise> { + const rows = await queryAll<{ key: string; enabled: number }>( + db, + 'SELECT key, enabled FROM feature_flags WHERE workspace_id IS NULL OR workspace_id = ?', + [workspaceId], + ); + + const flags: Record = {}; + for (const row of rows) flags[row.key] = row.enabled === 1; + return flags; +} + +async function connectedNetworks(db: Client, workspaceId: string): Promise> { + const rows = await queryAll<{ network: string }>( + db, + `SELECT DISTINCT network FROM integration_accounts WHERE workspace_id = ? AND status = 'active'`, + [workspaceId], + ); + return new Set(rows.map((r) => r.network)); +} + +function parseArray(raw: string | undefined): string[] { + if (!raw) return []; + try { + const parsed: unknown = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.map(String) : []; + } catch { + return []; + } +} + +function safeJson(text: string): Record { + try { + const parsed: unknown = JSON.parse(text); + return typeof parsed === 'object' && parsed !== null ? (parsed as Record) : {}; + } catch { + return {}; + } +} + +function numberOr(value: unknown, fallback: number): number { + return typeof value === 'number' && Number.isFinite(value) ? value : fallback; +} diff --git a/bun.lock b/bun.lock index e37d9b9..c234dba 100644 --- a/bun.lock +++ b/bun.lock @@ -52,6 +52,7 @@ "@outreachgraph/identity": "workspace:*", "@outreachgraph/policy": "workspace:*", "@outreachgraph/providers": "workspace:*", + "@outreachgraph/recommend": "workspace:*", "@outreachgraph/scoring": "workspace:*", "@outreachgraph/signals": "workspace:*", }, @@ -102,6 +103,15 @@ "@outreachgraph/identity": "workspace:*", }, }, + "packages/recommend": { + "name": "@outreachgraph/recommend", + "version": "0.1.0", + "dependencies": { + "@outreachgraph/domain": "workspace:*", + "@outreachgraph/policy": "workspace:*", + "@outreachgraph/signals": "workspace:*", + }, + }, "packages/scoring": { "name": "@outreachgraph/scoring", "version": "0.1.0", @@ -249,6 +259,8 @@ "@outreachgraph/providers": ["@outreachgraph/providers@workspace:packages/providers"], + "@outreachgraph/recommend": ["@outreachgraph/recommend@workspace:packages/recommend"], + "@outreachgraph/scoring": ["@outreachgraph/scoring@workspace:packages/scoring"], "@outreachgraph/signals": ["@outreachgraph/signals@workspace:packages/signals"], diff --git a/docs/prd-implementation-map.md b/docs/prd-implementation-map.md index db8b337..6fafd03 100644 --- a/docs/prd-implementation-map.md +++ b/docs/prd-implementation-map.md @@ -8,35 +8,39 @@ Where each part of the V1 PRD lives. Code comments cite section numbers ## Implemented -| PRD section | Implementation | Tests | -| ---------------------------------- | ----------------------------------------------------------------- | --------------------------------- | -| §8 Prospect pipeline | `packages/domain/src/pipeline.ts` | — | -| §9.1–9.3 Identity graph, evidence | `packages/domain/src/person.ts` | — | -| §9.4 Confidence bands, thresholds | `packages/domain/src/confidence.ts` | `identity/src/resolver.test.ts` | -| §9.5 Resolution model | `packages/identity/src/resolver.ts`, `weights.ts` | 25 tests | -| §10.1 Provider interface | `packages/providers/src/provider.ts` | — | -| §10.2 Waterfall | `packages/providers/src/waterfall.ts` | 17 tests | -| §10.3 Provenance | `packages/domain/src/provenance.ts`, `providers/src/waterfall.ts` | included above | -| §11.1–11.2 Signal schema and types | `packages/domain/src/signal.ts` | — | -| §11.3 Signal decay | `packages/signals/src/decay.ts` | 35 tests | -| §12.1–12.6 Scoring | `packages/scoring/src/scores.ts`, `weights.ts` | 31 tests | -| §13.1 Action kinds | `packages/domain/src/networks.ts` | — | -| §13.2 Recommendation schema | `packages/domain/src/outreach.ts` | — | -| §14.2 Quality checks (types) | `packages/domain/src/outreach.ts` | — | -| §15 Approval queue | `apps/api` `GET /recommendations` | `apps/api/src/app.test.ts` | -| §16 Platform policy | `packages/policy/src/capability-matrix.ts` | 46 tests | -| §16.2 Policy modes | `packages/policy/src/capability-matrix.ts` | included above | -| §16.3–16.6 Per-network rules | `packages/policy/src/capability-matrix.ts` | included above | -| §17.3 Suppression | `packages/domain/src/compliance.ts`, migration `0004` | `apps/api/src/app.test.ts` | -| §17.4 Sensitive categories | `packages/domain/src/compliance.ts` | — | -| §17.5 Minors | `evaluateEligibility`, policy `person_ineligible` gate | policy tests | -| §17.6 Source deletion | `apps/worker/src/jobs.ts` `markSourceUnavailable` | worker tests | -| §18 Rate limits, cooldowns | `packages/policy/src/engine.ts` | policy tests | -| §20.8 Policy engine | `packages/policy/src/engine.ts` | 46 tests | -| §21 Database model | `migrations/0000`–`0004` | `packages/db/src/migrate.test.ts` | -| §23 API endpoints | `apps/api/src/app.ts` | 31 tests | -| §34 Workspace isolation | `apps/api/src/repository.ts` | `apps/api/src/app.test.ts` | -| §37 Feature flags | `feature_flags` table, policy `feature_flag` gate | policy + API tests | +| PRD section | Implementation | Tests | +| ---------------------------------- | ------------------------------------------------------------------------ | --------------------------------- | +| §8 Prospect pipeline | `packages/domain/src/pipeline.ts` | — | +| §9.1–9.3 Identity graph, evidence | `packages/domain/src/person.ts` | — | +| §9.4 Confidence bands, thresholds | `packages/domain/src/confidence.ts` | `identity/src/resolver.test.ts` | +| §9.5 Resolution model | `packages/identity/src/resolver.ts`, `weights.ts` | 25 tests | +| §10.1 Provider interface | `packages/providers/src/provider.ts` | — | +| §10.2 Waterfall | `packages/providers/src/waterfall.ts` | 17 tests | +| §10.3 Provenance | `packages/domain/src/provenance.ts`, `providers/src/waterfall.ts` | included above | +| §11.1–11.2 Signal schema and types | `packages/domain/src/signal.ts` | — | +| §11.3 Signal decay | `packages/signals/src/decay.ts` | 35 tests | +| §12.1–12.6 Scoring | `packages/scoring/src/scores.ts`, `weights.ts` | 31 tests | +| §13.1 Action kinds | `packages/domain/src/networks.ts` | — | +| §13.2 Recommendation schema | `packages/domain/src/outreach.ts` | — | +| §14.2 Quality checks (types) | `packages/domain/src/outreach.ts` | — | +| §15 Approval queue | `apps/api` `GET /recommendations` | `apps/api/src/app.test.ts` | +| §16 Platform policy | `packages/policy/src/capability-matrix.ts` | 46 tests | +| §16.2 Policy modes | `packages/policy/src/capability-matrix.ts` | included above | +| §16.3–16.6 Per-network rules | `packages/policy/src/capability-matrix.ts` | included above | +| §17.3 Suppression | `packages/domain/src/compliance.ts`, migration `0004` | `apps/api/src/app.test.ts` | +| §17.4 Sensitive categories | `packages/domain/src/compliance.ts` | — | +| §17.5 Minors | `evaluateEligibility`, policy `person_ineligible` gate | policy tests | +| §17.6 Source deletion | `apps/worker/src/jobs.ts` `markSourceUnavailable` | worker tests | +| §18 Rate limits, cooldowns | `packages/policy/src/engine.ts` | policy tests | +| §20.8 Policy engine | `packages/policy/src/engine.ts` | 46 tests | +| §21 Database model | `migrations/0000`–`0004` | `packages/db/src/migrate.test.ts` | +| §23 API endpoints | `apps/api/src/app.ts` | 31 tests | +| §34 Workspace isolation | `apps/api/src/repository.ts` | `apps/api/src/app.test.ts` | +| §37 Feature flags | `feature_flags` table, policy `feature_flag` gate | policy + API tests | +| §13 Next-best-action | `packages/recommend/src/engine.ts` | 25 tests | +| §20.6 Strategy agent | `packages/recommend` — deterministic, chooses only from `allowedActions` | included above | +| §16.6 GitHub as a signal source | `packages/providers/src/github/` | 27 tests | +| §8 Pipeline, end to end | `apps/worker/src/pipeline.ts` | 11 tests + live GitHub run | ## Partially implemented @@ -53,8 +57,9 @@ Where each part of the V1 PRD lives. Code comments cite section numbers ## Not started - §7 Campaign wizard UI. -- §14 Outreach composer and the §20 agent suite (ICP, discovery, research, intent, strategy, composer). Only the deterministic engines they feed are built. -- §10 Real provider adapters — Apollo, PDL, GitHub, Bluesky, X. Only the interface and a deterministic fixture provider exist. +- §14 Outreach composer. Recommendations reach the queue with a grounded reason and a `groundedSignalIds` allow-list, but nothing writes the message body yet — this is where the first LLM enters the product. +- §20 agent suite beyond the Strategy Agent (ICP, discovery, research, intent, composer). +- §10 Remaining provider adapters — Apollo, PDL, Bluesky, X. - §26 Natural-language search. - §28 CRM and Slack integrations. - §36 Admin surface. diff --git a/packages/providers/src/github/client.ts b/packages/providers/src/github/client.ts new file mode 100644 index 0000000..8a58742 --- /dev/null +++ b/packages/providers/src/github/client.ts @@ -0,0 +1,154 @@ +/** + * Thin GitHub REST client (PRD §16.6, §51). + * + * GitHub is a signal source, not a messaging channel. This client therefore + * only ever reads public data: profiles, repositories and public events. + * + * `fetchImpl` is injectable so tests exercise the real parsing and error + * handling against recorded payloads instead of the network. + */ + +export const GITHUB_API = 'https://api.github.com'; + +export interface GitHubUser { + login: string; + id: number; + name: string | null; + company: string | null; + blog: string | null; + location: string | null; + email: string | null; + bio: string | null; + twitter_username: string | null; + public_repos: number; + followers: number; + html_url: string; + created_at: string; + updated_at: string; +} + +export interface GitHubRepo { + name: string; + full_name: string; + html_url: string; + description: string | null; + language: string | null; + topics?: string[]; + fork: boolean; + stargazers_count: number; + created_at: string; + pushed_at: string | null; +} + +export interface GitHubEvent { + id: string; + type: string | null; + created_at: string; + repo?: { id: number; name: string; url: string }; + payload?: Record; +} + +export interface GitHubClientOptions { + /** Raises the rate limit from 60/hr to 5000/hr. Never sent to a model. */ + readonly token?: string; + readonly baseUrl?: string; + readonly fetchImpl?: typeof fetch; + readonly userAgent?: string; +} + +/** + * Raised when GitHub refuses the request for quota reasons. + * + * Kept distinct from a generic failure because the waterfall should stop + * asking GitHub entirely until the reset, rather than retrying per prospect. + */ +export class GitHubRateLimitError extends Error { + readonly resetAt?: Date; + + constructor(resetAt?: Date) { + super( + resetAt + ? `GitHub rate limit exhausted; resets at ${resetAt.toISOString()}` + : 'GitHub rate limit exhausted', + ); + this.name = 'GitHubRateLimitError'; + this.resetAt = resetAt; + } +} + +export class GitHubNotFoundError extends Error { + constructor(path: string) { + super(`GitHub returned 404 for ${path}`); + this.name = 'GitHubNotFoundError'; + } +} + +export class GitHubClient { + readonly #options: GitHubClientOptions; + readonly #fetch: typeof fetch; + readonly #baseUrl: string; + + constructor(options: GitHubClientOptions = {}) { + this.#options = options; + this.#fetch = options.fetchImpl ?? globalThis.fetch; + this.#baseUrl = (options.baseUrl ?? GITHUB_API).replace(/\/$/, ''); + } + + async getUser(login: string): Promise { + return this.#request(`/users/${encodeURIComponent(login)}`); + } + + async getRepos(login: string, limit = 20): Promise { + return this.#request( + `/users/${encodeURIComponent(login)}/repos?sort=pushed&per_page=${limit}`, + ); + } + + /** Public events only. GitHub caps this at roughly the last 90 days. */ + async getPublicEvents(login: string, limit = 50): Promise { + return this.#request( + `/users/${encodeURIComponent(login)}/events/public?per_page=${limit}`, + ); + } + + async searchUsers(query: string, limit = 20): Promise<{ items: { login: string }[] }> { + return this.#request<{ items: { login: string }[] }>( + `/search/users?q=${encodeURIComponent(query)}&per_page=${limit}`, + ); + } + + async #request(path: string): Promise { + const headers: Record = { + accept: 'application/vnd.github+json', + 'x-github-api-version': '2022-11-28', + 'user-agent': this.#options.userAgent ?? 'OutreachGraph', + }; + if (this.#options.token) headers.authorization = `Bearer ${this.#options.token}`; + + const response = await this.#fetch(`${this.#baseUrl}${path}`, { headers }); + + if (response.status === 404) throw new GitHubNotFoundError(path); + + // GitHub signals quota exhaustion as 403 with remaining 0, or as 429. + if (response.status === 429 || (response.status === 403 && isQuotaExhausted(response))) { + throw new GitHubRateLimitError(resetAt(response)); + } + + if (!response.ok) { + throw new Error(`GitHub ${response.status} for ${path}`); + } + + return (await response.json()) as T; + } +} + +function isQuotaExhausted(response: Response): boolean { + return response.headers.get('x-ratelimit-remaining') === '0'; +} + +function resetAt(response: Response): Date | undefined { + const header = response.headers.get('x-ratelimit-reset'); + if (!header) return undefined; + const seconds = Number(header); + return Number.isFinite(seconds) ? new Date(seconds * 1000) : undefined; +} diff --git a/packages/providers/src/github/github.test.ts b/packages/providers/src/github/github.test.ts new file mode 100644 index 0000000..11b52a3 --- /dev/null +++ b/packages/providers/src/github/github.test.ts @@ -0,0 +1,344 @@ +import { describe, expect, test } from 'bun:test'; +import { resolveIdentity } from '@outreachgraph/identity'; +import { + GitHubNotFoundError, + GitHubRateLimitError, + type GitHubEvent, + type GitHubRepo, +} from './client'; +import { cleanCompany, domainOf, GitHubProvider, toCandidate } from './provider'; +import { extractSignals } from './signals'; + +/** A real profile payload, trimmed to the fields the adapter reads. */ +const USER = { + login: 'janesmith', + id: 27381, + name: 'Jane Smith', + company: '@Acme', + blog: 'https://jane.dev', + location: 'San Francisco, CA', + email: null, + bio: 'Payments infrastructure', + twitter_username: 'janesmith', + public_repos: 42, + followers: 900, + html_url: 'https://github.com/janesmith', + created_at: '2012-01-01T00:00:00Z', + updated_at: '2026-08-01T00:00:00Z', +}; + +/** Builds a fetch stub that serves canned responses by path suffix. */ +function stubFetch(routes: Record, status = 200): typeof fetch { + return (async (input: string | URL | Request) => { + const url = typeof input === 'string' ? input : input.toString(); + const match = Object.keys(routes).find((key) => url.includes(key)); + + if (!match) { + return new Response('{"message":"Not Found"}', { status: 404 }); + } + return new Response(JSON.stringify(routes[match]), { + status, + headers: { 'content-type': 'application/json' }, + }); + }) as unknown as typeof fetch; +} + +describe('profile mapping', () => { + test('turns self-declared profile fields into linked identities', () => { + const candidate = toCandidate(USER as never); + + const networks = candidate.identities.map((i) => i.network); + expect(networks).toContain('github'); + // These are the whole point: the person published them themselves. + expect(networks).toContain('x'); + expect(networks).toContain('website'); + + expect(candidate.identities.find((i) => i.network === 'x')?.handle).toBe('janesmith'); + expect(candidate.personalDomain).toBe('jane.dev'); + }); + + test('keeps the stable numeric id, not just the renameable handle', () => { + const candidate = toCandidate(USER as never); + const github = candidate.identities.find((i) => i.network === 'github'); + + expect(github?.platformUserId).toBe('27381'); + }); + + test('strips the @ from a GitHub company field', () => { + expect(cleanCompany('@Acme')).toBe('Acme'); + expect(cleanCompany(' Acme Inc ')).toBe('Acme Inc'); + }); + + test('omits identities the profile does not declare', () => { + const bare = toCandidate({ ...USER, twitter_username: null, blog: null } as never); + + expect(bare.identities.map((i) => i.network)).toEqual(['github']); + expect(bare.personalDomain).toBeUndefined(); + }); + + test('handles a blog field with no scheme', () => { + expect(domainOf('jane.dev')).toBe('jane.dev'); + expect(domainOf('https://www.jane.dev/blog')).toBe('jane.dev'); + expect(domainOf('not a url at all')).toBeUndefined(); + expect(domainOf(null)).toBeUndefined(); + }); + + test('falls back to the login when the profile has no name', () => { + const candidate = toCandidate({ ...USER, name: null } as never); + expect(candidate.fullName).toBe('janesmith'); + }); +}); + +describe('enrichment', () => { + test('an exact handle lookup is near-certain', async () => { + const provider = new GitHubProvider({ fetchImpl: stubFetch({ '/users/janesmith': USER }) }); + const result = await provider.enrich({ handles: { github: 'janesmith' } }); + + expect(result.matchConfidence).toBeGreaterThan(0.95); + expect(result.candidate?.fullName).toBe('Jane Smith'); + expect(result.costUsd).toBe(0); + }); + + test('a missing profile is a miss, not an error', async () => { + const provider = new GitHubProvider({ fetchImpl: stubFetch({}) }); + const result = await provider.enrich({ handles: { github: 'nobody' } }); + + expect(result.candidate).toBeUndefined(); + expect(result.matchConfidence).toBe(0); + }); + + test('a name-only match scores too low to stop the waterfall', async () => { + const provider = new GitHubProvider({ + fetchImpl: stubFetch({ + '/search/users': { items: [{ login: 'janesmith' }] }, + '/users/janesmith': USER, + }), + }); + + const result = await provider.enrich({ fullName: 'Jane Smith' }); + + // Below the 0.9 default target, so the waterfall keeps looking. + expect(result.matchConfidence).toBeLessThan(0.9); + expect(result.matchConfidence).toBeGreaterThan(0); + }); + + test('a corroborating employer raises a name match', async () => { + const provider = new GitHubProvider({ + fetchImpl: stubFetch({ + '/search/users': { items: [{ login: 'janesmith' }] }, + '/users/janesmith': USER, + }), + }); + + const bare = await provider.enrich({ fullName: 'Jane Smith' }); + const corroborated = await provider.enrich({ fullName: 'Jane Smith', companyName: 'Acme' }); + + expect(corroborated.matchConfidence).toBeGreaterThan(bare.matchConfidence); + }); + + test('is free, so the waterfall consults it first', () => { + expect(new GitHubProvider().capabilities().costPerEnrichmentUsd).toBe(0); + }); +}); + +describe('rate limiting', () => { + test('distinguishes quota exhaustion from an ordinary failure', async () => { + const fetchImpl = (async () => + new Response('{"message":"rate limit"}', { + status: 403, + headers: { + 'x-ratelimit-remaining': '0', + 'x-ratelimit-reset': String(Math.floor(Date.now() / 1000) + 600), + }, + })) as unknown as typeof fetch; + + const provider = new GitHubProvider({ fetchImpl }); + + await expect(provider.enrich({ handles: { github: 'janesmith' } })).rejects.toBeInstanceOf( + GitHubRateLimitError, + ); + }); + + test('a plain 403 is not treated as a rate limit', async () => { + const fetchImpl = (async () => + new Response('{"message":"forbidden"}', { + status: 403, + headers: { 'x-ratelimit-remaining': '58' }, + })) as unknown as typeof fetch; + + const provider = new GitHubProvider({ fetchImpl }); + const error = await provider.enrich({ handles: { github: 'janesmith' } }).catch((e) => e); + + expect(error).toBeInstanceOf(Error); + expect(error).not.toBeInstanceOf(GitHubRateLimitError); + expect(error).not.toBeInstanceOf(GitHubNotFoundError); + }); +}); + +describe('identity evidence from a GitHub profile', () => { + test('a declared X handle plus employer supports a merge', () => { + const candidate = toCandidate(USER as never); + const x = candidate.identities.find((i) => i.network === 'x')!; + + const resolution = resolveIdentity([ + { kind: 'provider_asserted_link', strength: x.providerConfidence ?? 0.9 }, + { kind: 'cross_linked_profile', strength: 1 }, + { kind: 'same_employer', strength: 1 }, + ]); + + expect(resolution.decision).toBe('merge'); + }); + + test('a provider assertion alone stays in the review band', () => { + const resolution = resolveIdentity([{ kind: 'provider_asserted_link', strength: 0.9 }]); + expect(resolution.decision).toBe('candidate'); + }); +}); + +describe('signal extraction', () => { + const at = '2026-08-10T12:00:00Z'; + + function event(type: string, payload: Record): GitHubEvent { + return { id: '1', type, created_at: at, repo: { id: 1, name: 'acme/api', url: '' }, payload }; + } + + test('a new repository is a project start', () => { + const signals = extractSignals([event('CreateEvent', { ref_type: 'repository' })], []); + + expect(signals).toHaveLength(1); + expect(signals[0]?.type).toBe('project_start'); + }); + + test('branch creation is ignored as noise', () => { + expect(extractSignals([event('CreateEvent', { ref_type: 'branch' })], [])).toHaveLength(0); + }); + + test('pushes are ignored — high volume, low information', () => { + expect(extractSignals([event('PushEvent', { ref: 'refs/heads/main' })], [])).toHaveLength(0); + }); + + test('a release is a launch', () => { + const signals = extractSignals( + [event('ReleaseEvent', { release: { name: 'v2.0', html_url: 'https://x', body: 'notes' } })], + [], + ); + + expect(signals[0]?.type).toBe('launch'); + expect(signals[0]?.evidence).toBe('notes'); + }); + + test('an issue asking for a recommendation is a public question', () => { + const signals = extractSignals( + [ + event('IssuesEvent', { + action: 'opened', + issue: { title: 'Anyone know a good alternative for payouts?', html_url: 'https://x' }, + }), + ], + [], + ); + + expect(signals[0]?.type).toBe('public_question'); + // The person's own words, so a reply can quote them. + expect(signals[0]?.evidence).toContain('alternative'); + }); + + test('an issue describing a failure is pain', () => { + const signals = extractSignals( + [ + event('IssuesEvent', { + action: 'opened', + issue: { title: 'Webhook delivery keeps failing under load', body: 'times out' }, + }), + ], + [], + ); + + expect(signals[0]?.type).toBe('pain'); + expect(signals[0]?.sentiment).toBe('negative'); + }); + + test('a competitor mention outranks the generic classification', () => { + const signals = extractSignals( + [ + event('IssuesEvent', { + action: 'opened', + issue: { title: 'Stripe fees are killing us on cross-border' }, + }), + ], + [], + { competitors: ['Stripe'] }, + ); + + expect(signals[0]?.type).toBe('competitor_mention'); + expect(signals[0]?.subtype).toBe('Stripe'); + }); + + test('a closed issue produces nothing', () => { + const signals = extractSignals( + [event('IssuesEvent', { action: 'closed', issue: { title: 'done' } })], + [], + ); + expect(signals).toHaveLength(0); + }); + + test('a repository using a campaign technology is technology adoption', () => { + const repo: GitHubRepo = { + name: 'payments-api', + full_name: 'acme/payments-api', + html_url: 'https://github.com/acme/payments-api', + description: 'billing service', + language: 'TypeScript', + topics: ['nextjs'], + fork: false, + stargazers_count: 3, + created_at: at, + pushed_at: at, + }; + + const signals = extractSignals([], [repo], { technologies: ['Next.js', 'nextjs'] }); + + expect(signals[0]?.type).toBe('technology_adoption'); + expect(signals[0]?.relevance).toBeGreaterThan(0.8); + }); + + test('forks are ignored — they describe someone else’s choices', () => { + const repo: GitHubRepo = { + name: 'forked', + full_name: 'acme/forked', + html_url: '', + description: null, + language: 'TypeScript', + topics: ['nextjs'], + fork: true, + stargazers_count: 0, + created_at: at, + pushed_at: at, + }; + + expect(extractSignals([], [repo], { technologies: ['nextjs'] })).toHaveLength(0); + }); + + test('off-topic activity scores low relevance rather than vanishing', () => { + const signals = extractSignals([event('CreateEvent', { ref_type: 'repository' })], [], { + technologies: ['rust'], + keywords: ['payments'], + }); + + expect(signals[0]?.relevance).toBeLessThan(0.3); + expect(signals[0]?.relevance).toBeGreaterThan(0); + }); + + test('returns signals newest first', () => { + const older: GitHubEvent = { + id: '2', + type: 'CreateEvent', + created_at: '2026-08-01T00:00:00Z', + repo: { id: 2, name: 'acme/old', url: '' }, + payload: { ref_type: 'repository' }, + }; + + const signals = extractSignals([older, event('CreateEvent', { ref_type: 'repository' })], []); + expect(signals[0]?.sourceTimestamp).toBe(at); + }); +}); diff --git a/packages/providers/src/github/provider.ts b/packages/providers/src/github/provider.ts new file mode 100644 index 0000000..e0979d1 --- /dev/null +++ b/packages/providers/src/github/provider.ts @@ -0,0 +1,236 @@ +/** + * GitHub as an enrichment provider (PRD §10.1, §16.6, §45). + * + * This is the launch wedge's cheapest useful source: a public GitHub profile + * frequently states the person's employer, personal domain and X handle in + * fields they filled in themselves. That is the strongest class of identity + * evidence available (PRD §9.3 `cross_linked_profile`), and it costs nothing. + */ + +import type { CandidateIdentity, PersonCandidate } from '../provider'; +import type { + PersonEnrichmentInput, + PersonEnrichmentProvider, + PersonEnrichmentResult, + PersonSearchInput, + PersonSearchResult, + ProviderCapabilities, +} from '../provider'; +import { + GitHubClient, + GitHubNotFoundError, + type GitHubClientOptions, + type GitHubUser, +} from './client'; + +export interface GitHubProviderOptions extends GitHubClientOptions { + /** Cap on profiles fetched per search, to bound unauthenticated quota use. */ + readonly maxSearchHydrations?: number; +} + +export class GitHubProvider implements PersonEnrichmentProvider { + readonly #client: GitHubClient; + readonly #maxHydrations: number; + + constructor(options: GitHubProviderOptions = {}) { + this.#client = new GitHubClient(options); + this.#maxHydrations = options.maxSearchHydrations ?? 10; + } + + capabilities(): ProviderCapabilities { + return { + slug: 'github', + displayName: 'GitHub', + networks: ['github', 'x', 'website'], + canSearch: true, + canEnrich: true, + licenseClass: 'public_api', + sourceType: 'official_api', + // Free, so the waterfall consults it before any paid provider. + costPerEnrichmentUsd: 0, + }; + } + + async search(input: PersonSearchInput): Promise { + const query = buildSearchQuery(input); + if (!query) return { candidates: [], costUsd: 0 }; + + const results = await this.#client.searchUsers(query, input.limit ?? 20); + const logins = results.items.slice(0, this.#maxHydrations).map((item) => item.login); + + // Search returns logins only; the useful fields need a profile fetch each. + const candidates: PersonCandidate[] = []; + for (const login of logins) { + try { + candidates.push(toCandidate(await this.#client.getUser(login))); + } catch (error) { + if (error instanceof GitHubNotFoundError) continue; + throw error; + } + } + + return { candidates, costUsd: 0 }; + } + + async enrich(input: PersonEnrichmentInput): Promise { + const login = input.handles?.github; + + if (login) { + try { + const user = await this.#client.getUser(login); + // An exact handle lookup is as certain as this source gets. + return { candidate: toCandidate(user), matchConfidence: 0.98, costUsd: 0 }; + } catch (error) { + if (!(error instanceof GitHubNotFoundError)) throw error; + return { matchConfidence: 0, costUsd: 0 }; + } + } + + // Without a handle, fall back to search — much weaker, and deliberately + // scored so the waterfall keeps looking for something better. + const query = buildSearchQuery({ + ...(input.fullName ? { keywords: [input.fullName] } : {}), + }); + if (!query) return { matchConfidence: 0, costUsd: 0 }; + + const results = await this.#client.searchUsers(query, 5); + const first = results.items[0]; + if (!first) return { matchConfidence: 0, costUsd: 0 }; + + const user = await this.#client.getUser(first.login); + const candidate = toCandidate(user); + + return { candidate, matchConfidence: confidenceForNameMatch(user, input), costUsd: 0 }; + } + + async costEstimate(): Promise { + return 0; + } + + /** Raw activity for the signal extractor. */ + async activity(login: string, limit = 50) { + const [events, repos] = await Promise.all([ + this.#client.getPublicEvents(login, limit), + this.#client.getRepos(login, 20), + ]); + return { events, repos }; + } +} + +/** + * Maps a GitHub profile onto the canonical candidate shape. + * + * The identity list is where the value is: `twitter_username` and `blog` are + * links the person published about themselves, so the resolver can treat them + * as cross-links rather than inferences. + */ +export function toCandidate(user: GitHubUser): PersonCandidate { + const identities: CandidateIdentity[] = [ + { + network: 'github', + handle: user.login, + platformUserId: String(user.id), + profileUrl: user.html_url, + providerConfidence: 1, + }, + ]; + + if (user.twitter_username) { + identities.push({ + network: 'x', + handle: user.twitter_username, + profileUrl: `https://x.com/${user.twitter_username}`, + // Self-declared on their own profile, but GitHub does not verify it. + providerConfidence: 0.9, + }); + } + + const personalDomain = domainOf(user.blog); + if (user.blog && personalDomain) { + identities.push({ + network: 'website', + profileUrl: normalizeUrl(user.blog), + providerConfidence: 0.9, + }); + } + + const [firstName, lastName] = splitName(user.name ?? user.login); + + return { + fullName: user.name ?? user.login, + ...(firstName ? { firstName } : {}), + ...(lastName ? { lastName } : {}), + ...(user.company ? { companyName: cleanCompany(user.company) } : {}), + ...(user.location ? { location: user.location } : {}), + ...(personalDomain ? { personalDomain } : {}), + identities, + sourceRecordId: `github:${user.id}`, + observedAt: new Date().toISOString(), + }; +} + +/** + * GitHub company fields are free text and usually carry an `@org` handle. + * Stripping it makes employer comparison work against provider data. + */ +export function cleanCompany(raw: string): string { + return raw.replace(/^@/, '').replace(/\s+/g, ' ').trim(); +} + +export function domainOf(url: string | null): string | undefined { + if (!url) return undefined; + try { + const parsed = new URL(url.startsWith('http') ? url : `https://${url}`); + return parsed.hostname.replace(/^www\./, '') || undefined; + } catch { + return undefined; + } +} + +function normalizeUrl(raw: string): string { + return raw.startsWith('http') ? raw : `https://${raw}`; +} + +function splitName(full: string): [string | undefined, string | undefined] { + const parts = full.trim().split(/\s+/); + if (parts.length < 2) return [parts[0], undefined]; + return [parts[0], parts[parts.length - 1]]; +} + +function buildSearchQuery(input: PersonSearchInput): string { + const terms: string[] = []; + + for (const keyword of input.keywords ?? []) terms.push(keyword); + for (const tech of input.technologies ?? []) terms.push(`language:${tech}`); + for (const country of input.countries ?? []) terms.push(`location:${quote(country)}`); + + return terms.join(' ').trim(); +} + +function quote(value: string): string { + return value.includes(' ') ? `"${value}"` : value; +} + +/** + * A name-only match is weak: GitHub search happily returns someone else with + * the same display name. Corroborating employer or domain raises it. + */ +function confidenceForNameMatch(user: GitHubUser, input: PersonEnrichmentInput): number { + if (!input.fullName) return 0.3; + + const sameName = (user.name ?? '').toLowerCase() === input.fullName.toLowerCase(); + if (!sameName) return 0.2; + + const domain = domainOf(user.blog); + if (input.companyDomain && domain === input.companyDomain.toLowerCase()) return 0.85; + + if ( + input.companyName && + user.company && + cleanCompany(user.company).toLowerCase() === input.companyName.toLowerCase() + ) { + return 0.8; + } + + return 0.45; +} diff --git a/packages/providers/src/github/signals.ts b/packages/providers/src/github/signals.ts new file mode 100644 index 0000000..6ab4e85 --- /dev/null +++ b/packages/providers/src/github/signals.ts @@ -0,0 +1,278 @@ +/** + * Turning public GitHub activity into signals (PRD §11, §16.6). + * + * Deterministic classification — no model involved. An LLM may later *rank* + * or summarise these, but the mapping from "opened a public issue" to + * `public_question` is a rule, so it is reproducible and auditable. + * + * Relevance is scored against the campaign's own technologies and keywords, + * because a repository adopting Postgres is only interesting to someone + * selling something adjacent to it. + */ + +import type { Sentiment, SignalType } from '@outreachgraph/domain'; +import type { GitHubEvent, GitHubRepo } from './client'; + +/** A signal before it is persisted — no ids, no workspace yet. */ +export interface ExtractedSignal { + readonly type: SignalType; + readonly subtype?: string; + readonly summary: string; + /** Verbatim text backing the summary; required to ground a message. */ + readonly evidence?: string; + readonly sourceUrl?: string; + readonly sourceTimestamp: string; + readonly confidence: number; + readonly relevance: number; + readonly sentiment: Sentiment; +} + +export interface ExtractionContext { + /** Technologies the campaign cares about, lower-cased on the way in. */ + readonly technologies?: readonly string[]; + readonly keywords?: readonly string[]; + /** Competitor names; a mention becomes a `competitor_mention`. */ + readonly competitors?: readonly string[]; +} + +/** + * Words that turn an issue title into a pain signal rather than a neutral one. + * Deliberately narrow — a false "they're in pain" reading produces a tone-deaf + * message, which is worse than missing the signal. + */ +const PAIN_TERMS = [ + 'broken', + 'fails', + 'failing', + 'crash', + 'regression', + 'cannot', + "can't", + 'unable', + 'slow', + 'timeout', + 'memory leak', + 'security', + 'vulnerability', +]; + +const QUESTION_TERMS = [ + 'how do i', + 'how to', + 'is it possible', + 'what is the best', + 'recommend', + 'alternative', + 'anyone know', + 'looking for', + 'should i use', +]; + +export function extractSignals( + events: readonly GitHubEvent[], + repos: readonly GitHubRepo[], + context: ExtractionContext = {}, +): ExtractedSignal[] { + const signals: ExtractedSignal[] = []; + + for (const event of events) { + const signal = classifyEvent(event, context); + if (signal) signals.push(signal); + } + + for (const repo of repos) { + signals.push(...classifyRepo(repo, context)); + } + + // Newest first; the recommendation engine wants the freshest trigger. + return signals.sort((a, b) => b.sourceTimestamp.localeCompare(a.sourceTimestamp)); +} + +function classifyEvent( + event: GitHubEvent, + context: ExtractionContext, +): ExtractedSignal | undefined { + const repoName = event.repo?.name; + const repoUrl = repoName ? `https://github.com/${repoName}` : undefined; + const payload = event.payload ?? {}; + + switch (event.type) { + case 'CreateEvent': { + // Only repository creation is interesting; branches and tags are noise. + if (payload.ref_type !== 'repository') return undefined; + return { + type: 'project_start', + summary: `Created a new repository, ${repoName ?? 'unknown'}`, + ...(repoUrl ? { sourceUrl: repoUrl } : {}), + sourceTimestamp: event.created_at, + confidence: 0.95, + relevance: relevanceOf(repoName ?? '', context), + sentiment: 'neutral', + }; + } + + case 'ReleaseEvent': { + const release = asRecord(payload.release); + const name = asString(release?.name) ?? asString(release?.tag_name); + return { + type: 'launch', + summary: `Published release ${name ?? ''} in ${repoName ?? 'a repository'}`.trim(), + ...(asString(release?.body) ? { evidence: truncate(asString(release?.body)!) } : {}), + ...(asString(release?.html_url) ? { sourceUrl: asString(release?.html_url)! } : {}), + sourceTimestamp: event.created_at, + confidence: 0.95, + relevance: relevanceOf(`${repoName ?? ''} ${name ?? ''}`, context), + sentiment: 'positive', + }; + } + + case 'IssuesEvent': { + if (payload.action !== 'opened') return undefined; + const issue = asRecord(payload.issue); + const title = asString(issue?.title); + if (!title) return undefined; + + const body = asString(issue?.body); + const haystack = `${title} ${body ?? ''}`.toLowerCase(); + + const type: SignalType = QUESTION_TERMS.some((t) => haystack.includes(t)) + ? 'public_question' + : PAIN_TERMS.some((t) => haystack.includes(t)) + ? 'pain' + : 'community_activity'; + + const competitor = (context.competitors ?? []).find((c) => + haystack.includes(c.toLowerCase()), + ); + + return { + type: competitor ? 'competitor_mention' : type, + ...(competitor ? { subtype: competitor } : {}), + summary: `Opened an issue in ${repoName ?? 'a repository'}: ${truncate(title, 120)}`, + // The title is the person's own words, so it can ground a reply. + evidence: truncate(body ? `${title}\n\n${body}` : title), + ...(asString(issue?.html_url) ? { sourceUrl: asString(issue?.html_url)! } : {}), + sourceTimestamp: event.created_at, + confidence: 0.85, + relevance: relevanceOf(haystack, context), + sentiment: type === 'pain' ? 'negative' : 'neutral', + }; + } + + case 'PullRequestEvent': { + if (payload.action !== 'opened') return undefined; + const pr = asRecord(payload.pull_request); + const title = asString(pr?.title); + if (!title) return undefined; + + return { + type: 'community_activity', + summary: `Opened a pull request in ${repoName ?? 'a repository'}: ${truncate(title, 120)}`, + evidence: truncate(title), + ...(asString(pr?.html_url) ? { sourceUrl: asString(pr?.html_url)! } : {}), + sourceTimestamp: event.created_at, + confidence: 0.85, + relevance: relevanceOf(`${repoName ?? ''} ${title}`, context), + sentiment: 'neutral', + }; + } + + case 'WatchEvent': { + // Starring a repository is a weak but real interest signal. + return { + type: 'content_topic', + summary: `Starred ${repoName ?? 'a repository'}`, + ...(repoUrl ? { sourceUrl: repoUrl } : {}), + sourceTimestamp: event.created_at, + confidence: 0.9, + relevance: relevanceOf(repoName ?? '', context) * 0.6, + sentiment: 'positive', + }; + } + + default: + // PushEvent and friends are high-volume and low-information; ignoring + // them keeps the feed readable. + return undefined; + } +} + +function classifyRepo(repo: GitHubRepo, context: ExtractionContext): ExtractedSignal[] { + // Forks describe someone else's technology choice, not this person's. + if (repo.fork) return []; + + const signals: ExtractedSignal[] = []; + const stack = [repo.language, ...(repo.topics ?? [])].filter( + (v): v is string => typeof v === 'string' && v.length > 0, + ); + + const matched = (context.technologies ?? []).filter((tech) => + stack.some((entry) => entry.toLowerCase() === tech.toLowerCase()), + ); + + if (matched.length > 0) { + signals.push({ + type: 'technology_adoption', + subtype: matched[0], + summary: `Uses ${matched.join(', ')} in ${repo.name}`, + ...(repo.description ? { evidence: truncate(repo.description) } : {}), + sourceUrl: repo.html_url, + sourceTimestamp: repo.pushed_at ?? repo.created_at, + confidence: 0.9, + relevance: 0.9, + sentiment: 'neutral', + }); + } + + const competitor = (context.competitors ?? []).find((name) => + `${repo.name} ${repo.description ?? ''}`.toLowerCase().includes(name.toLowerCase()), + ); + + if (competitor) { + signals.push({ + type: 'competitor_mention', + subtype: competitor, + summary: `Repository ${repo.name} references ${competitor}`, + ...(repo.description ? { evidence: truncate(repo.description) } : {}), + sourceUrl: repo.html_url, + sourceTimestamp: repo.pushed_at ?? repo.created_at, + confidence: 0.75, + relevance: 0.85, + sentiment: 'neutral', + }); + } + + return signals; +} + +/** + * 0..1 relevance from how many campaign terms the text mentions. + * + * A base of 0.2 rather than 0 keeps an unmatched-but-real event visible in the + * feed while ranking far below anything on-topic. + */ +function relevanceOf(text: string, context: ExtractionContext): number { + const terms = [...(context.technologies ?? []), ...(context.keywords ?? [])]; + if (terms.length === 0) return 0.5; + + const haystack = text.toLowerCase(); + const hits = terms.filter((term) => haystack.includes(term.toLowerCase())).length; + if (hits === 0) return 0.2; + + return Math.min(1, 0.5 + hits * 0.25); +} + +function truncate(text: string, max = 500): string { + const clean = text.replace(/\r/g, '').trim(); + return clean.length <= max ? clean : `${clean.slice(0, max - 1)}…`; +} + +function asRecord(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null + ? (value as Record) + : undefined; +} + +function asString(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} diff --git a/packages/providers/src/index.ts b/packages/providers/src/index.ts index 5a4d3da..b0c0514 100644 --- a/packages/providers/src/index.ts +++ b/packages/providers/src/index.ts @@ -33,3 +33,24 @@ export { FIXTURE_CANDIDATES, type FixtureProviderOptions, } from './fixture-provider'; + +export { + GitHubClient, + GitHubNotFoundError, + GitHubRateLimitError, + GITHUB_API, + type GitHubClientOptions, + type GitHubEvent, + type GitHubRepo, + type GitHubUser, +} from './github/client'; + +export { + cleanCompany, + domainOf, + GitHubProvider, + toCandidate, + type GitHubProviderOptions, +} from './github/provider'; + +export { extractSignals, type ExtractedSignal, type ExtractionContext } from './github/signals'; diff --git a/packages/recommend/package.json b/packages/recommend/package.json new file mode 100644 index 0000000..6923910 --- /dev/null +++ b/packages/recommend/package.json @@ -0,0 +1,18 @@ +{ + "name": "@outreachgraph/recommend", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "bun test" + }, + "dependencies": { + "@outreachgraph/domain": "workspace:*", + "@outreachgraph/policy": "workspace:*", + "@outreachgraph/signals": "workspace:*" + } +} diff --git a/packages/recommend/src/engine.test.ts b/packages/recommend/src/engine.test.ts new file mode 100644 index 0000000..c6549e4 --- /dev/null +++ b/packages/recommend/src/engine.test.ts @@ -0,0 +1,368 @@ +import { describe, expect, test } from 'bun:test'; +import type { Network } from '@outreachgraph/domain'; +import type { PolicyRequest } from '@outreachgraph/policy'; +import { + computePriority, + generateRecommendation, + type CandidateSignal, + type RecommendationInput, +} from './engine'; + +const NOW = new Date('2026-08-11T12:00:00.000Z'); + +function hoursAgo(hours: number): string { + return new Date(NOW.getTime() - hours * 3_600_000).toISOString(); +} + +function policy(overrides: Partial = {}): Omit { + return { + approvalMode: 'draft_and_approve', + hasConnectedAccount: true, + personSuppressed: false, + personBelievedMinor: false, + personDeleted: false, + identityConfidence: 0.97, + minIdentityConfidence: 0.85, + actionsToday: 0, + maxActionsPerDay: 50, + actionsToThisProspectThisWeek: 0, + maxActionsPerProspectPerWeek: 1, + ...overrides, + }; +} + +function signal(overrides: Partial = {}): CandidateSignal { + return { + id: 'sig_1', + type: 'recommendation_request', + network: 'x', + summary: 'Asked for alternatives to a competitor for cross-border payouts', + evidence: 'Does anyone have a good alternative to...', + sourceTimestamp: hoursAgo(4), + observedAt: hoursAgo(4), + confidence: 0.94, + relevance: 0.91, + ...overrides, + }; +} + +function input(overrides: Partial = {}): RecommendationInput { + return { + personId: 'per_1', + campaignId: 'cmp_1', + signals: [signal()], + reachableNetworks: ['x'], + opportunity: 92, + policy: policy(), + now: NOW, + ...overrides, + }; +} + +describe('choosing an action', () => { + test('replies where the person actually spoke', () => { + const result = generateRecommendation(input()); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.recommendation.action).toBe('reply'); + expect(result.recommendation.network).toBe('x'); + expect(result.recommendation.triggerSignalId).toBe('sig_1'); + }); + + test('prefers a public reply over a direct message', () => { + // Both are technically available on X, but reply is less intrusive. + const result = generateRecommendation(input()); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.recommendation.action).not.toBe('send_dm'); + }); + + test('carries the policy decision, not an assumption', () => { + const result = generateRecommendation(input()); + + expect(result.ok).toBe(true); + if (!result.ok) return; + // Outbound defaults to individual approval. + expect(result.recommendation.policyDecision).toBe('allow_with_approval'); + expect(result.recommendation.policyVersion).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + + test('allows outright under trusted automation', () => { + const result = generateRecommendation( + input({ policy: policy({ approvalMode: 'trusted_automation' }) }), + ); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.recommendation.policyDecision).toBe('allow'); + }); + + test('falls back to a reachable network when the signal’s own is blocked', () => { + // The signal happened on LinkedIn, where nothing is automatable, but the + // person is also reachable on Bluesky. + const result = generateRecommendation( + input({ + signals: [signal({ network: 'linkedin' })], + reachableNetworks: ['linkedin', 'bluesky'], + }), + ); + + expect(result.ok).toBe(true); + if (!result.ok) return; + // LinkedIn yields manual_only rather than deny, so it is still chosen — + // the human performs it in LinkedIn's own interface. + expect(result.recommendation.network).toBe('linkedin'); + expect(result.recommendation.policyDecision).toBe('manual_only'); + }); + + test('never proposes an action GitHub forbids', () => { + const result = generateRecommendation( + input({ + signals: [signal({ network: 'github', type: 'pain' })], + reachableNetworks: ['github'], + }), + ); + + if (result.ok) { + // GitHub outreach is disabled, so only passive actions may appear. + expect(['observe', 'refresh_research', 'follow']).toContain(result.recommendation.action); + expect(result.recommendation.action).not.toBe('comment'); + expect(result.recommendation.action).not.toBe('send_dm'); + } else { + expect(result.reason).toBe('no_permitted_action'); + } + }); +}); + +describe('refusing to recommend', () => { + test('a suppressed person gets nothing', () => { + const result = generateRecommendation(input({ policy: policy({ personSuppressed: true }) })); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).toBe('person_ineligible'); + }); + + test('a suspected minor gets nothing', () => { + const result = generateRecommendation(input({ policy: policy({ personBelievedMinor: true }) })); + + expect(result.ok).toBe(false); + }); + + test('no engagement without a signal to justify it', () => { + const result = generateRecommendation(input({ signals: [] })); + + if (result.ok) { + // Only passive research is acceptable with nothing to respond to. + expect(['observe', 'refresh_research']).toContain(result.recommendation.action); + expect(result.recommendation.groundedSignalIds).toHaveLength(0); + } else { + expect(result.reason).toBe('no_relevant_signal'); + } + }); + + test('a fully decayed signal does not trigger outreach', () => { + const stale = signal({ + sourceTimestamp: hoursAgo(24 * 200), + observedAt: hoursAgo(24 * 200), + }); + + const result = generateRecommendation(input({ signals: [stale] })); + + if (result.ok) { + expect(result.recommendation.triggerSignalId).toBeUndefined(); + expect(['observe', 'refresh_research']).toContain(result.recommendation.action); + } + }); + + test('a spent rate limit blocks the whole recommendation', () => { + const result = generateRecommendation( + input({ policy: policy({ actionsToday: 50, maxActionsPerDay: 50 }) }), + ); + + // Every engagement action is denied; only exempt passive ones can survive. + if (result.ok) { + expect(['observe', 'refresh_research']).toContain(result.recommendation.action); + } else { + expect(result.reason).toBe('no_permitted_action'); + } + }); + + test('low identity confidence blocks outbound but permits research', () => { + const result = generateRecommendation( + input({ policy: policy({ identityConfidence: 0.5, minIdentityConfidence: 0.85 }) }), + ); + + if (result.ok) { + expect(['observe', 'refresh_research', 'follow', 'like']).toContain( + result.recommendation.action, + ); + } + }); +}); + +describe('trigger selection', () => { + test('picks the strongest signal, not merely the newest', () => { + const weakButFresh = signal({ + id: 'sig_weak', + type: 'content_topic', + relevance: 0.1, + confidence: 0.3, + sourceTimestamp: hoursAgo(1), + observedAt: hoursAgo(1), + }); + + const strongSlightlyOlder = signal({ + id: 'sig_strong', + type: 'recommendation_request', + relevance: 0.95, + confidence: 0.95, + sourceTimestamp: hoursAgo(6), + observedAt: hoursAgo(6), + }); + + const result = generateRecommendation(input({ signals: [weakButFresh, strongSlightlyOlder] })); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.recommendation.triggerSignalId).toBe('sig_strong'); + }); + + test('only signals with verbatim evidence may be quoted', () => { + const noEvidence = signal({ evidence: undefined }); + const result = generateRecommendation(input({ signals: [noEvidence] })); + + expect(result.ok).toBe(true); + if (!result.ok) return; + // It can still prompt an action, but nothing may be personalised from it. + expect(result.recommendation.triggerSignalId).toBe('sig_1'); + expect(result.recommendation.groundedSignalIds).toHaveLength(0); + }); + + test('a signal with evidence is quotable', () => { + const result = generateRecommendation(input()); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.recommendation.groundedSignalIds).toEqual(['sig_1']); + }); +}); + +describe('priority and expiry', () => { + test('a fresh trigger outranks a stale one at equal fit', () => { + const fresh = generateRecommendation(input()); + const stale = generateRecommendation( + input({ + signals: [signal({ sourceTimestamp: hoursAgo(24 * 20), observedAt: hoursAgo(24 * 20) })], + }), + ); + + expect(fresh.ok && stale.ok).toBe(true); + if (!fresh.ok || !stale.ok) return; + expect(fresh.recommendation.priority).toBeGreaterThan(stale.recommendation.priority); + }); + + test('fit still matters at equal freshness', () => { + const strong = generateRecommendation(input({ opportunity: 95 })); + const weak = generateRecommendation(input({ opportunity: 20 })); + + expect(strong.ok && weak.ok).toBe(true); + if (!strong.ok || !weak.ok) return; + expect(strong.recommendation.priority).toBeGreaterThan(weak.recommendation.priority); + }); + + test('priority stays within 0..100', () => { + expect(computePriority(100, 1)).toBeLessThanOrEqual(100); + expect(computePriority(0, 0)).toBeGreaterThanOrEqual(0); + expect(computePriority(Number.NaN, Number.NaN)).toBeGreaterThanOrEqual(0); + }); + + test('expires before the trigger goes stale', () => { + const result = generateRecommendation(input()); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + const expiresAt = result.recommendation.expiresAt; + expect(expiresAt).toBeDefined(); + expect(Date.parse(expiresAt!)).toBeGreaterThan(NOW.getTime()); + }); + + test('a durable signal expires later than an urgent one', () => { + const urgent = generateRecommendation(input()); + const durable = generateRecommendation( + input({ signals: [signal({ id: 'sig_role', type: 'role_change' })] }), + ); + + expect(urgent.ok && durable.ok).toBe(true); + if (!urgent.ok || !durable.ok) return; + + const urgentAt = Date.parse(urgent.recommendation.expiresAt ?? ''); + const durableAt = Date.parse(durable.recommendation.expiresAt ?? ''); + + if (!Number.isNaN(urgentAt) && !Number.isNaN(durableAt)) { + expect(durableAt).toBeGreaterThan(urgentAt); + } + }); +}); + +describe('the reason shown to the user', () => { + test('names the signal and how old it is', () => { + const result = generateRecommendation(input()); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.recommendation.reason).toContain('alternatives to a competitor'); + expect(result.recommendation.reason).toContain('4h ago'); + expect(result.recommendation.reason).toContain('reply'); + }); + + test('sets a conversation goal for outbound actions', () => { + const result = generateRecommendation(input()); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.recommendation.expectedGoal).toBe('start_conversation'); + }); +}); + +describe('determinism', () => { + test('the same state always yields the same recommendation', () => { + const state = input(); + const first = generateRecommendation(state); + + for (let i = 0; i < 20; i += 1) { + expect(generateRecommendation(state)).toEqual(first); + } + }); + + test('signal order does not change the outcome', () => { + const signals = [ + signal({ id: 'a', relevance: 0.4 }), + signal({ id: 'b', relevance: 0.95 }), + signal({ id: 'c', relevance: 0.2 }), + ]; + + const forward = generateRecommendation(input({ signals })); + const backward = generateRecommendation(input({ signals: [...signals].reverse() })); + + expect(forward.ok && backward.ok).toBe(true); + if (!forward.ok || !backward.ok) return; + expect(forward.recommendation.triggerSignalId).toBe(backward.recommendation.triggerSignalId); + }); + + test('never proposes an action on an unreachable network', () => { + const networks: Network[] = ['bluesky']; + const result = generateRecommendation( + input({ signals: [signal({ network: 'bluesky' })], reachableNetworks: networks }), + ); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(networks).toContain(result.recommendation.network); + }); +}); diff --git a/packages/recommend/src/engine.ts b/packages/recommend/src/engine.ts new file mode 100644 index 0000000..f3f217a --- /dev/null +++ b/packages/recommend/src/engine.ts @@ -0,0 +1,304 @@ +/** + * The Next-Best-Action engine (PRD §13, §20.6). + * + * Instead of advancing a fixed sequence, this picks an action from the current + * state: the freshest relevant signal, the channels where the person is + * actually reachable, and whatever the Policy Engine currently permits. + * + * Two invariants: + * + * 1. **It cannot invent capabilities.** The candidate action list is filtered + * through `allowedActions` before anything is chosen, so a denied action + * is never even considered (PRD §20.6). + * 2. **It cannot invent a reason.** Every recommendation names the signal + * that triggered it, and a signal with no stored evidence can prompt an + * action but may not ground a personalised claim (PRD §14.1). + */ + +import { + isOutboundAction, + type ActionKind, + type Network, + type RecommendationGoal, +} from '@outreachgraph/domain'; +import { allowedActions, evaluatePolicy, type PolicyRequest } from '@outreachgraph/policy'; +import { effectiveWeight, type DecayableSignal } from '@outreachgraph/signals'; + +/** A stored signal, with the identifiers the recommendation must reference. */ +export interface CandidateSignal extends DecayableSignal { + readonly id: string; + readonly network: Network; + readonly summary: string; + /** Verbatim excerpt. Without it, the composer may not quote this signal. */ + readonly evidence?: string; + readonly sourceUrl?: string; +} + +export interface RecommendationInput { + readonly personId: string; + readonly campaignId: string; + readonly signals: readonly CandidateSignal[]; + /** Networks where a confirmed identity exists for this person. */ + readonly reachableNetworks: readonly Network[]; + /** 0–100 opportunity score; drives queue ordering. */ + readonly opportunity: number; + /** Policy inputs, minus the action and network this engine is choosing. */ + readonly policy: Omit; + readonly now?: Date; +} + +export interface GeneratedRecommendation { + readonly personId: string; + readonly campaignId: string; + readonly action: ActionKind; + readonly network: Network; + readonly priority: number; + readonly reason: string; + readonly triggerSignalId?: string; + readonly policyDecision: 'allow' | 'allow_with_approval' | 'manual_only'; + readonly policyVersion: string; + readonly expectedGoal: RecommendationGoal; + readonly expiresAt?: string; + /** Signals whose evidence may be quoted. Empty means: do not personalise. */ + readonly groundedSignalIds: readonly string[]; +} + +export type NoRecommendationReason = + 'no_permitted_action' | 'no_relevant_signal' | 'person_ineligible'; + +export type RecommendationResult = + | { readonly ok: true; readonly recommendation: GeneratedRecommendation } + | { readonly ok: false; readonly reason: NoRecommendationReason; readonly detail?: string }; + +/** + * Ranked preference among engagement actions. + * + * Ordered by how intrusive each is. PRD §13.3 asks the strategy to prefer + * joining a relevant public conversation over a direct message, and to treat + * email as a fallback — this list is that preference, made explicit so it can + * be reviewed rather than buried in branching logic. + */ +const ACTION_PREFERENCE: readonly ActionKind[] = [ + 'reply', + 'comment', + 'follow', + 'like', + 'connect', + 'send_dm', + 'send_email', +]; + +/** Actions worth proposing when there is nothing to respond to yet. */ +const PASSIVE_PREFERENCE: readonly ActionKind[] = ['refresh_research', 'observe']; + +/** Below this, a signal is too stale or too irrelevant to act on. */ +const MIN_TRIGGER_WEIGHT = 0.15; + +export function generateRecommendation(input: RecommendationInput): RecommendationResult { + const now = input.now ?? new Date(); + + if ( + input.policy.personSuppressed || + input.policy.personDeleted || + input.policy.personBelievedMinor + ) { + return { ok: false, reason: 'person_ineligible' }; + } + + const trigger = strongestSignal(input.signals, now); + + // Networks to consider, most-preferred first: where the triggering signal + // happened, then anywhere else we can reach them. + const networks = orderNetworks(input.reachableNetworks, trigger?.signal.network); + + const candidateActions = trigger ? ACTION_PREFERENCE : PASSIVE_PREFERENCE; + + const choice = chooseAction(input, networks, candidateActions); + if (!choice) { + return { + ok: false, + reason: 'no_permitted_action', + detail: 'no action on any reachable network survived the policy check', + }; + } + + // An engagement action with no signal behind it would be outreach with no + // reason — exactly what the product exists to avoid. + if (!trigger && isOutboundAction(choice.action)) { + return { ok: false, reason: 'no_relevant_signal' }; + } + + const priority = computePriority(input.opportunity, trigger?.weight ?? 0); + + return { + ok: true, + recommendation: { + personId: input.personId, + campaignId: input.campaignId, + action: choice.action, + network: choice.network, + priority, + reason: buildReason(choice.action, trigger?.signal, trigger?.ageHours), + ...(trigger ? { triggerSignalId: trigger.signal.id } : {}), + policyDecision: choice.decision, + policyVersion: choice.policyVersion, + expectedGoal: goalFor(choice.action), + ...(trigger ? { expiresAt: expiryFor(trigger.signal, now) } : {}), + // Only signals carrying verbatim evidence may be quoted (PRD §14.1). + groundedSignalIds: trigger?.signal.evidence ? [trigger.signal.id] : [], + }, + }; +} + +interface Trigger { + readonly signal: CandidateSignal; + readonly weight: number; + readonly ageHours: number; +} + +function strongestSignal(signals: readonly CandidateSignal[], now: Date): Trigger | undefined { + let best: Trigger | undefined; + + for (const signal of signals) { + const weight = effectiveWeight(signal, now); + if (weight < MIN_TRIGGER_WEIGHT) continue; + if (best && weight <= best.weight) continue; + + const stamp = Date.parse(signal.sourceTimestamp ?? signal.observedAt); + best = { + signal, + weight, + ageHours: Number.isNaN(stamp) + ? Number.POSITIVE_INFINITY + : (now.getTime() - stamp) / 3_600_000, + }; + } + + return best; +} + +/** The signal's own network first — replying where they spoke is least intrusive. */ +function orderNetworks( + reachable: readonly Network[], + preferred: Network | undefined, +): readonly Network[] { + if (!preferred) return reachable; + if (!reachable.includes(preferred)) return [preferred, ...reachable]; + return [preferred, ...reachable.filter((n) => n !== preferred)]; +} + +interface Choice { + readonly action: ActionKind; + readonly network: Network; + readonly decision: 'allow' | 'allow_with_approval' | 'manual_only'; + readonly policyVersion: string; +} + +/** + * Walks networks in preference order, and within each, actions in preference + * order, returning the first combination the policy engine permits. + */ +function chooseAction( + input: RecommendationInput, + networks: readonly Network[], + candidates: readonly ActionKind[], +): Choice | undefined { + for (const network of networks) { + const permitted = allowedActions({ ...input.policy, network }, candidates); + if (permitted.length === 0) continue; + + // Preserve the preference ordering; `allowedActions` preserves input order, + // but being explicit keeps this correct if that ever changes. + for (const action of candidates) { + if (!permitted.includes(action)) continue; + + const result = evaluatePolicy({ ...input.policy, network, action }); + if (result.decision === 'deny') continue; + + return { + action, + network, + decision: result.decision, + policyVersion: result.policyVersion, + }; + } + } + + return undefined; +} + +/** + * Priority blends fit with urgency: a perfect-fit prospect with a week-old + * signal should sit below a good-fit prospect who asked a question an hour ago. + */ +export function computePriority(opportunity: number, triggerWeight: number): number { + const base = clamp(opportunity, 0, 100); + const urgency = clamp(triggerWeight, 0, 1); + return Math.round(base * 0.7 + urgency * 100 * 0.3); +} + +/** + * A recommendation expires when its trigger has decayed past usefulness — + * replying to a three-week-old "what should I use?" reads as automated. + */ +function expiryFor(signal: CandidateSignal, now: Date): string | undefined { + const stamp = Date.parse(signal.sourceTimestamp ?? signal.observedAt); + if (Number.isNaN(stamp)) return undefined; + + // Probe forward for the point where this signal's weight falls below the + // actionable floor, using the same decay curve the scorer uses. + for (const hours of [24, 72, 168, 336, 720, 2160]) { + const at = new Date(stamp + hours * 3_600_000); + if (at <= now) continue; + if (effectiveWeight(signal, at) < MIN_TRIGGER_WEIGHT) return at.toISOString(); + } + + return undefined; +} + +function goalFor(action: ActionKind): RecommendationGoal { + switch (action) { + case 'reply': + case 'comment': + case 'send_dm': + case 'send_email': + return 'start_conversation'; + case 'follow': + case 'like': + case 'connect': + return 'build_relationship'; + case 'observe': + case 'refresh_research': + return 'gather_context'; + default: + return 'stay_visible'; + } +} + +function buildReason( + action: ActionKind, + signal: CandidateSignal | undefined, + ageHours: number | undefined, +): string { + if (!signal) { + return 'No recent public activity yet — refresh research before reaching out.'; + } + + const when = describeAge(ageHours); + const verb = action.replace(/_/g, ' '); + + return `${signal.summary} (${signal.network}, ${when}). Recommended: ${verb}.`; +} + +function describeAge(hours: number | undefined): string { + if (hours === undefined || !Number.isFinite(hours)) return 'recently'; + if (hours < 1) return 'in the last hour'; + if (hours < 24) return `${Math.round(hours)}h ago`; + const days = Math.round(hours / 24); + return days === 1 ? 'yesterday' : `${days} days ago`; +} + +function clamp(value: number, min: number, max: number): number { + if (!Number.isFinite(value)) return min; + return Math.min(max, Math.max(min, value)); +} diff --git a/packages/recommend/src/index.ts b/packages/recommend/src/index.ts new file mode 100644 index 0000000..2b5aede --- /dev/null +++ b/packages/recommend/src/index.ts @@ -0,0 +1,16 @@ +/** + * `@outreachgraph/recommend` — the Next-Best-Action engine (PRD §13). + * + * Deterministic. It selects from what the Policy Engine permits and always + * names the signal that triggered it. + */ + +export { + computePriority, + generateRecommendation, + type CandidateSignal, + type GeneratedRecommendation, + type NoRecommendationReason, + type RecommendationInput, + type RecommendationResult, +} from './engine'; From e63591c340cba39a2e6f0b64bfcbc833d26812f5 Mon Sep 17 00:00:00 2001 From: Anthony Date: Tue, 11 Aug 2026 12:27:39 +0000 Subject: [PATCH 2/4] docs: correct stale README status line --- README.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0d48fd5..b3103a7 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,12 @@ platform, the prospect, or your own rate limits say it should not. ## Status -Foundation. The deterministic core of V1 is built and tested; the AI agents and -the PWA are not. See [`docs/prd-implementation-map.md`](docs/prd-implementation-map.md) -for exactly what exists. +Foundation. The deterministic core, the API, the PWA and one live provider are +built and tested; a prospect can go from a GitHub handle to a card in the +approval queue today. The LLM layer — the outreach composer and the rest of the +§20 agent suite — is not built. See +[`docs/prd-implementation-map.md`](docs/prd-implementation-map.md) for exactly +what exists. ## Quick start From 24673e90aa7f23b1bd3ecf3a62f70b6332887969 Mon Sep 17 00:00:00 2001 From: Anthony Date: Tue, 11 Aug 2026 12:43:44 +0000 Subject: [PATCH 3/4] fix(docker): stop enumerating workspace manifests; add Railway root config The Dockerfiles listed every package.json by hand, so adding packages/recommend broke all three builds: error: Workspace dependency "@outreachgraph/recommend" not found The enumeration was the bug, not the missing line. The dependency stage now copies the workspace wholesale, which costs some layer caching and buys a Dockerfile that cannot drift out of sync with the repository. Also adds .dockerignore (node_modules, .next, *.db were all being sent as build context) and a root railway.json. Railway reads config from the repo root; with the per-service files only under apps/*, it found nothing and fell back to railpack, which cannot find a start command in a Bun workspace. Co-Authored-By: Claude Opus 5 (1M context) --- .dockerignore | 31 +++++++++++++++++++++++++++++++ README.md | 27 ++++++++++++++++++++++----- apps/api/railway.json | 23 ----------------------- docker/api.Dockerfile | 25 +++++++++---------------- docker/web.Dockerfile | 16 ++++------------ docker/worker.Dockerfile | 15 +++++---------- railway.json | 23 +++++++++++++++++++++++ 7 files changed, 94 insertions(+), 66 deletions(-) create mode 100644 .dockerignore delete mode 100644 apps/api/railway.json create mode 100644 railway.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..21423de --- /dev/null +++ b/.dockerignore @@ -0,0 +1,31 @@ +# Keep the build context small and reproducible. Anything generated on a +# developer machine must not leak into an image. +node_modules +**/node_modules + +.next +**/.next +dist +**/dist +build +**/build + +*.db +*.db-wal +*.db-shm +local.db* + +.git +.github +.claude +coverage +*.log + +.env +.env.* +!.env.example + +README.md +CLAUDE.md +AGENTS.md +docs diff --git a/README.md b/README.md index b3103a7..abb2504 100644 --- a/README.md +++ b/README.md @@ -105,12 +105,29 @@ someone who opted out. ## Deployment -Railway, one service per Dockerfile, deploying from this repository. Each -service has its own `railway.json` with a health check. Migrations run as an -explicit release step, never from every replica. +Railway, one service per Dockerfile, deploying from this repository. -Set up separate Turso databases, provider keys, and secrets per environment. -Production customer data is never copied into staging. +The root `railway.json` configures the **default service** as the API. For each +additional service, open its settings and set the config file path — Railway +only reads a subdirectory config if you point it there: + +| Service | Config file path | +| ------- | --------------------------- | +| api | `/railway.json` | +| web | `/apps/web/railway.json` | +| worker | `/apps/worker/railway.json` | + +**Leave Root Directory unset.** These Dockerfiles expect the repository root as +their build context; scoping a service to `apps/web` breaks every `COPY` and +the workspace install. + +Without a config file Railway falls back to railpack auto-detection, which +cannot find a start command in a Bun workspace and fails the build — that +symptom means the service is not pointed at its config. + +Migrations run as an explicit release step, never from every replica. Set up +separate Turso databases, provider keys, and secrets per environment; +production customer data is never copied into staging. ## Conventions diff --git a/apps/api/railway.json b/apps/api/railway.json deleted file mode 100644 index ac6aa4e..0000000 --- a/apps/api/railway.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://railway.com/railway.schema.json", - "build": { - "builder": "DOCKERFILE", - "dockerfilePath": "docker/api.Dockerfile", - "//": "watchPatterns must list every path the image actually copies. Under-listing them means a merged fix sits undeployed with nothing to say so.", - "watchPatterns": [ - "apps/api/**", - "packages/**", - "migrations/**", - "docker/api.Dockerfile", - "package.json", - "bun.lock" - ] - }, - "deploy": { - "healthcheckPath": "/health/ready", - "healthcheckTimeout": 30, - "restartPolicyType": "ON_FAILURE", - "restartPolicyMaxRetries": 5, - "numReplicas": 1 - } -} diff --git a/docker/api.Dockerfile b/docker/api.Dockerfile index bab33e7..6724a84 100644 --- a/docker/api.Dockerfile +++ b/docker/api.Dockerfile @@ -2,25 +2,18 @@ # # API service (PRD §1.1 "Docker Requirements"). # -# Multi-stage: dependencies resolve in a layer that only changes when a -# manifest or the lockfile changes, so an ordinary source edit rebuilds only -# the final layer. +# The dependency stage copies the whole workspace rather than enumerating each +# package.json. An earlier version listed them one by one and broke the moment +# a package was added — `bun install --frozen-lockfile` failed on the missing +# workspace member. Copying wholesale costs some layer caching and buys a +# Dockerfile that cannot drift out of sync with the repository. FROM oven/bun:1.3.14-slim AS deps WORKDIR /app -# Manifests first — this layer is the expensive one to rebuild. COPY package.json bun.lock ./ -COPY apps/api/package.json ./apps/api/ -COPY apps/worker/package.json ./apps/worker/ -COPY packages/contracts/package.json ./packages/contracts/ -COPY packages/db/package.json ./packages/db/ -COPY packages/domain/package.json ./packages/domain/ -COPY packages/identity/package.json ./packages/identity/ -COPY packages/policy/package.json ./packages/policy/ -COPY packages/providers/package.json ./packages/providers/ -COPY packages/scoring/package.json ./packages/scoring/ -COPY packages/signals/package.json ./packages/signals/ +COPY packages ./packages +COPY apps ./apps RUN bun install --frozen-lockfile @@ -41,8 +34,8 @@ USER bun EXPOSE 8080 -# Railway health check targets /health/live, which does not touch the -# database — a database blip must not get a healthy process killed. +# Targets /health/live, which does not touch the database — a database blip +# must not get a healthy process killed. HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD bun -e "const r = await fetch('http://127.0.0.1:8080/health/live'); process.exit(r.ok ? 0 : 1)" diff --git a/docker/web.Dockerfile b/docker/web.Dockerfile index f72fa7b..62622a6 100644 --- a/docker/web.Dockerfile +++ b/docker/web.Dockerfile @@ -9,17 +9,8 @@ FROM oven/bun:1.3.14-slim AS deps WORKDIR /app COPY package.json bun.lock ./ -COPY apps/api/package.json ./apps/api/ -COPY apps/web/package.json ./apps/web/ -COPY apps/worker/package.json ./apps/worker/ -COPY packages/contracts/package.json ./packages/contracts/ -COPY packages/db/package.json ./packages/db/ -COPY packages/domain/package.json ./packages/domain/ -COPY packages/identity/package.json ./packages/identity/ -COPY packages/policy/package.json ./packages/policy/ -COPY packages/providers/package.json ./packages/providers/ -COPY packages/scoring/package.json ./packages/scoring/ -COPY packages/signals/package.json ./packages/signals/ +COPY packages ./packages +COPY apps ./apps RUN bun install --frozen-lockfile @@ -41,7 +32,8 @@ ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 ENV PORT=3000 -# The standalone bundle carries its own minimal node_modules. +# `outputFileTracingRoot` is the monorepo root, so the standalone bundle keeps +# the apps/web/ path structure and carries its own minimal node_modules. COPY --from=builder /app/apps/web/.next/standalone ./ COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static COPY --from=builder /app/apps/web/public ./apps/web/public diff --git a/docker/worker.Dockerfile b/docker/worker.Dockerfile index 0239c1b..f9ff467 100644 --- a/docker/worker.Dockerfile +++ b/docker/worker.Dockerfile @@ -10,16 +10,8 @@ FROM oven/bun:1.3.14-slim AS deps WORKDIR /app COPY package.json bun.lock ./ -COPY apps/api/package.json ./apps/api/ -COPY apps/worker/package.json ./apps/worker/ -COPY packages/contracts/package.json ./packages/contracts/ -COPY packages/db/package.json ./packages/db/ -COPY packages/domain/package.json ./packages/domain/ -COPY packages/identity/package.json ./packages/identity/ -COPY packages/policy/package.json ./packages/policy/ -COPY packages/providers/package.json ./packages/providers/ -COPY packages/scoring/package.json ./packages/scoring/ -COPY packages/signals/package.json ./packages/signals/ +COPY packages ./packages +COPY apps ./apps RUN bun install --frozen-lockfile @@ -32,6 +24,9 @@ COPY --from=deps /app/node_modules ./node_modules COPY package.json bun.lock tsconfig.base.json tsconfig.json ./ COPY migrations ./migrations COPY packages ./packages +# The worker's pipeline test imports the API's seed helper, so the API source +# has to be present for the workspace to resolve. +COPY apps/api ./apps/api COPY apps/worker ./apps/worker RUN chown -R bun:bun /app diff --git a/railway.json b/railway.json new file mode 100644 index 0000000..ca05db6 --- /dev/null +++ b/railway.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://railway.com/railway.schema.json", + "//": "Railway reads this file from the repository root, so it configures the DEFAULT service. Without it Railway falls back to railpack auto-detection, which cannot find a start command in a Bun workspace and fails the build. For a multi-service deploy, point each additional service at its own config from the service settings page — /apps/web/railway.json and /apps/worker/railway.json — and leave Root Directory unset, because these Dockerfiles expect the repository root as their build context.", + "build": { + "builder": "DOCKERFILE", + "dockerfilePath": "docker/api.Dockerfile", + "watchPatterns": [ + "apps/api/**", + "packages/**", + "migrations/**", + "docker/api.Dockerfile", + "package.json", + "bun.lock" + ] + }, + "deploy": { + "healthcheckPath": "/health/ready", + "healthcheckTimeout": 30, + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 5, + "numReplicas": 1 + } +} From 3d480a41294d4c0effd66b75a41055a260731651 Mon Sep 17 00:00:00 2001 From: Anthony Date: Tue, 11 Aug 2026 12:47:48 +0000 Subject: [PATCH 4/4] fix(docker): build the web image from the deps stage Copying only the root node_modules into a fresh builder stage loses the per-package node_modules/.bin that Bun's workspace linking creates, so the build failed with `next: command not found`. The builder now extends deps, which already carries the full workspace layout. Co-Authored-By: Claude Opus 5 (1M context) --- docker/web.Dockerfile | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docker/web.Dockerfile b/docker/web.Dockerfile index 62622a6..108d55c 100644 --- a/docker/web.Dockerfile +++ b/docker/web.Dockerfile @@ -14,13 +14,14 @@ COPY apps ./apps RUN bun install --frozen-lockfile -FROM oven/bun:1.3.14-slim AS builder +# Extends `deps` rather than re-copying node_modules into a fresh stage. +# Copying only the root node_modules loses the per-package node_modules/.bin +# that Bun's workspace linking creates, and `next` is one of those binaries — +# the build failed with `next: command not found`. +FROM deps AS builder WORKDIR /app -COPY --from=deps /app/node_modules ./node_modules -COPY package.json bun.lock tsconfig.base.json ./ -COPY packages ./packages -COPY apps/web ./apps/web +COPY tsconfig.base.json ./ ENV NEXT_TELEMETRY_DISABLED=1 RUN cd apps/web && bun run build