Repository navigation
feat: scout a project folder into a suggested team #339
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
| import { describe, expect, it, vi } from "vitest"; | ||
|
|
||
| import { | ||
| BOT_DIRECTORY_API_URL, | ||
| fetchBotDirectory, | ||
| matchDirectoryBots, | ||
| parseBotDirectory, | ||
| type DirectoryBot, | ||
| } from "./bot-directory.ts"; | ||
| import type { ProjectProfile } from "./project-scout.ts"; | ||
|
|
||
| const entry = (over: Partial<Record<string, unknown>> = {}) => ({ | ||
| slug: "release-scribe", | ||
| name: "Release Scribe", | ||
| category: "Docs", | ||
| integrations: ["GitHub"], | ||
| prompt: "Set up a bot that drafts release notes.", | ||
| detailUrl: "https://botdirectory.ai/bots/release-scribe/", | ||
| ...over, | ||
| }); | ||
|
|
||
| describe("parseBotDirectory", () => { | ||
| it("accepts the published shape and keeps only the fields we use", () => { | ||
| const bots = parseBotDirectory({ version: 1, bots: [entry({ contributor: "someone", addedAt: "2026" })] }); | ||
| expect(bots).toEqual([ | ||
| { | ||
| slug: "release-scribe", | ||
| name: "Release Scribe", | ||
| category: "Docs", | ||
| integrations: ["GitHub"], | ||
| prompt: "Set up a bot that drafts release notes.", | ||
| detailUrl: "https://botdirectory.ai/bots/release-scribe/", | ||
| }, | ||
| ]); | ||
| }); | ||
|
|
||
| it("drops malformed entries instead of failing the whole directory", () => { | ||
| const bots = parseBotDirectory({ | ||
| version: 1, | ||
| bots: [ | ||
| entry(), | ||
| entry({ slug: "UPPER CASE" }), | ||
| entry({ slug: "no-prompt", prompt: "" }), | ||
| entry({ slug: "elsewhere", detailUrl: "https://evil.example/bots/x/" }), | ||
| entry(), // duplicate slug | ||
| "not an object", | ||
| ], | ||
| }); | ||
| expect(bots.map((bot) => bot.slug)).toEqual(["release-scribe"]); | ||
| }); | ||
|
|
||
| it("rejects a response that is not the directory", () => { | ||
| expect(() => parseBotDirectory({ version: 2, bots: [] })).toThrow("not supported"); | ||
| expect(() => parseBotDirectory([])).toThrow("not supported"); | ||
| }); | ||
| }); | ||
|
|
||
| describe("fetchBotDirectory", () => { | ||
| it("fetches, validates, and passes errors through", async () => { | ||
| const ok = vi.fn(async () => new Response(JSON.stringify({ version: 1, bots: [entry()] }))); | ||
| await expect(fetchBotDirectory(ok as unknown as typeof fetch)).resolves.toHaveLength(1); | ||
| expect(ok).toHaveBeenCalledWith(BOT_DIRECTORY_API_URL, expect.objectContaining({ redirect: "error" })); | ||
|
|
||
| const down = vi.fn(async () => new Response("nope", { status: 503 })); | ||
| await expect(fetchBotDirectory(down as unknown as typeof fetch)).rejects.toThrow("HTTP 503"); | ||
| }); | ||
|
|
||
| it("rejects an oversized response while reading, not after buffering it whole", async () => { | ||
| // no content-length header on purpose: the announced-size shortcut must | ||
| // not be the only guard. The stream never ends on its own — the fetch | ||
| // has to bail the moment the cap is crossed, or this test times out. | ||
| const chunk = new TextEncoder().encode("x".repeat(64 * 1024)); | ||
| let sent = 0; | ||
| let cancelled = false; | ||
| const endless = new ReadableStream<Uint8Array>({ | ||
| pull(controller) { | ||
| sent += chunk.byteLength; | ||
| controller.enqueue(chunk); | ||
| }, | ||
| cancel() { | ||
| cancelled = true; | ||
| }, | ||
| }); | ||
| const big = vi.fn(async () => new Response(endless)); | ||
| await expect(fetchBotDirectory(big as unknown as typeof fetch)).rejects.toThrow("too large"); | ||
| expect(cancelled).toBe(true); | ||
| // barely past the 1 MB cap — nowhere near what an unbounded read would take | ||
| expect(sent).toBeLessThan(2_000_000); | ||
|
|
||
| const announced = vi.fn(async () => | ||
| new Response("{}", { headers: { "content-length": String(50_000_000) } }), | ||
| ); | ||
| await expect(fetchBotDirectory(announced as unknown as typeof fetch)).rejects.toThrow("too large"); | ||
| }); | ||
| }); | ||
|
|
||
| describe("matchDirectoryBots", () => { | ||
| const profile: ProjectProfile = { | ||
| name: "Shop", | ||
| summary: "A storefront with payments.", | ||
| stacks: ["TypeScript", "React"], | ||
| signals: [{ role: "frontend", evidence: ["react"] }], | ||
| }; | ||
| const bots: DirectoryBot[] = [ | ||
| { ...entry(), slug: "react-reviewer", name: "React Reviewer", category: "Engineering", integrations: ["GitHub"] } as DirectoryBot, | ||
| { ...entry(), slug: "payments-auditor", name: "Payments Auditor", category: "Finance", integrations: ["Stripe"] } as DirectoryBot, | ||
| { ...entry(), slug: "gig-closer", name: "Gig Closer", category: "Ops", integrations: ["QuickBooks"] } as DirectoryBot, | ||
| ]; | ||
|
|
||
| it("returns only bots that overlap the profile, best match first, with the matched terms", () => { | ||
| const matched = matchDirectoryBots(profile, bots); | ||
| expect(matched.map((bot) => bot.slug)).toEqual(["react-reviewer", "payments-auditor"]); | ||
| expect(matched[0]!.matched).toContain("react"); | ||
| expect(matched[1]!.matched).toContain("payments"); | ||
| }); | ||
|
|
||
| it("honors the limit", () => { | ||
| expect(matchDirectoryBots(profile, bots, 1)).toHaveLength(1); | ||
| }); | ||
|
|
||
| it("ignores stack names too short to mean anything as substrings", () => { | ||
| const goProfile: ProjectProfile = { name: "svc", summary: "", stacks: ["Go"], signals: [] }; | ||
| const google = { ...entry(), slug: "google-helper", name: "Google Helper", category: "Ops", integrations: [] } as DirectoryBot; | ||
| expect(matchDirectoryBots(goProfile, [google])).toEqual([]); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,150 @@ | ||
| import { parseJson } from "./schema.ts"; | ||
| import type { ProjectProfile } from "./project-scout.ts"; | ||
|
|
||
| export const BOT_DIRECTORY_URL = "https://botdirectory.ai"; | ||
| export const BOT_DIRECTORY_API_URL = "https://api.botdirectory.ai/api/bots"; | ||
|
|
||
| const MAX_DIRECTORY_BYTES = 1_000_000; | ||
| const MAX_DIRECTORY_BOTS = 200; | ||
|
|
||
| export interface DirectoryBot { | ||
| slug: string; | ||
| name: string; | ||
| category: string; | ||
| integrations: string[]; | ||
| /** the community-written setup prompt — shown to the human, and only ever | ||
| * imported as a bot description through the same persona-only boundary as | ||
| * any shared team file */ | ||
| prompt: string; | ||
| detailUrl: string; | ||
| } | ||
|
|
||
| export interface MatchedDirectoryBot extends DirectoryBot { | ||
| /** the profile terms this bot matched on, for the human reviewing it */ | ||
| matched: string[]; | ||
| } | ||
|
|
||
| type Fetcher = typeof fetch; | ||
|
|
||
| const isRecord = (value: unknown): value is Record<string, unknown> => | ||
| Boolean(value) && typeof value === "object" && !Array.isArray(value); | ||
|
|
||
| function text(value: unknown, max: number): string | null { | ||
| if (typeof value !== "string" || !value.trim()) return null; | ||
| const normalized = value.trim(); | ||
| return normalized.length > max ? null : normalized; | ||
| } | ||
|
|
||
| /** Validate the community-maintained index before any of it reaches the | ||
| * renderer. Entries that do not parse are dropped, not fatal — one bad | ||
| * community submission must not blank the whole directory. */ | ||
| export function parseBotDirectory(value: unknown): DirectoryBot[] { | ||
| if (!isRecord(value) || value.version !== 1 || !Array.isArray(value.bots)) { | ||
| throw new Error("The bot directory response is not supported"); | ||
| } | ||
| const bots: DirectoryBot[] = []; | ||
| const seen = new Set<string>(); | ||
| for (const raw of value.bots.slice(0, MAX_DIRECTORY_BOTS)) { | ||
| if (!isRecord(raw)) continue; | ||
| const slug = text(raw.slug, 100); | ||
| if (!slug || !/^[a-z0-9][a-z0-9-]*$/.test(slug) || seen.has(slug)) continue; | ||
| const name = text(raw.name, 100); | ||
| const prompt = text(raw.prompt, 4_000); | ||
| if (!name || !prompt) continue; | ||
| const detailUrl = text(raw.detailUrl, 300); | ||
| if (!detailUrl || !detailUrl.startsWith(`${BOT_DIRECTORY_URL}/`)) continue; | ||
| seen.add(slug); | ||
| bots.push({ | ||
| slug, | ||
| name, | ||
| category: text(raw.category, 80) ?? "", | ||
| integrations: Array.isArray(raw.integrations) | ||
| ? raw.integrations.flatMap((item) => text(item, 80) ?? []).slice(0, 20) | ||
| : [], | ||
| prompt, | ||
| detailUrl, | ||
| }); | ||
| } | ||
| return bots; | ||
| } | ||
|
|
||
| /** Read the body in bounded chunks: an oversized or endless response is | ||
| * rejected the moment it crosses the cap, never buffered whole first. */ | ||
| async function readBounded(response: Response, maxBytes: number): Promise<string> { | ||
| const oversized = () => new Error("The bot directory response is too large"); | ||
| const announced = Number(response.headers.get("content-length") ?? 0); | ||
| if (announced > maxBytes) throw oversized(); | ||
| if (!response.body) { | ||
| const raw = await response.text(); | ||
| if (Buffer.byteLength(raw) > maxBytes) throw oversized(); | ||
| return raw; | ||
| } | ||
| const reader = response.body.getReader(); | ||
| const chunks: Uint8Array[] = []; | ||
| let total = 0; | ||
| for (;;) { | ||
| const { done, value } = await reader.read(); | ||
| if (done) break; | ||
| total += value.byteLength; | ||
| if (total > maxBytes) { | ||
| await reader.cancel().catch(() => {}); | ||
| throw oversized(); | ||
| } | ||
| chunks.push(value); | ||
| } | ||
| return Buffer.concat(chunks).toString("utf8"); | ||
| } | ||
|
|
||
| export async function fetchBotDirectory(fetcher: Fetcher = fetch): Promise<DirectoryBot[]> { | ||
| const response = await fetcher(BOT_DIRECTORY_API_URL, { | ||
| headers: { accept: "application/json" }, | ||
| redirect: "error", | ||
| signal: AbortSignal.timeout(10_000), | ||
| }); | ||
| if (!response.ok) throw new Error(`The bot directory returned HTTP ${response.status}`); | ||
| return parseBotDirectory(parseJson(await readBounded(response, MAX_DIRECTORY_BYTES))); | ||
| } | ||
|
|
||
| /** Rank directory bots against a scouted project: overlap between the | ||
| * project's stacks/summary words and a bot's name, category and | ||
| * integrations. Purely lexical on purpose — deterministic, explainable, | ||
| * and honest about being a hint rather than a verdict. */ | ||
| // Words that appear in nearly every project blurb and would match nearly | ||
| // every directory entry. A match on one of these is noise, not affinity — | ||
| // measured live: "Your own team of AI bots" matched a home-value tracker | ||
| // purely on "your". | ||
| const STOPWORDS = new Set([ | ||
| "your", "with", "that", "this", "from", "have", "what", "when", "where", "will", | ||
| "them", "then", "than", "only", "also", "into", "over", "about", "after", "before", | ||
| "every", "some", "most", "much", "many", "very", "just", "like", "each", "other", | ||
| "their", "there", "these", "those", "using", "based", "open", "free", "fast", | ||
| "simple", "easy", "apps", "tool", "tools", "project", "team", "chat", "bots", | ||
| ]); | ||
|
|
||
| export function matchDirectoryBots( | ||
| profile: ProjectProfile, | ||
| bots: DirectoryBot[], | ||
| limit = 5, | ||
| ): MatchedDirectoryBot[] { | ||
| const terms = new Set<string>(); | ||
| for (const stack of profile.stacks) { | ||
| const term = stack.toLowerCase(); | ||
| // a two- or three-letter stack ("go", "php") substring-matches half the | ||
| // directory — "google", "django", "logo" are not Go affinity | ||
| if (term.length >= 4) terms.add(term); | ||
| } | ||
| for (const signal of profile.signals) terms.add(signal.role); | ||
| // "." and "#" stay word-internal for the likes of next.js and C#, but a | ||
| // sentence-final "payments." must still match "Payments" | ||
| for (const raw of `${profile.name} ${profile.summary}`.toLowerCase().split(/[^a-z0-9+.#-]+/)) { | ||
| const word = raw.replace(/^[.#+-]+|[.#+-]+$/g, ""); | ||
| if (word.length >= 4 && !STOPWORDS.has(word)) terms.add(word); | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
|
|
||
| const scored = bots.flatMap((bot) => { | ||
| const haystackParts = [bot.name, bot.category, ...bot.integrations].map((part) => part.toLowerCase()); | ||
| const matched = [...terms].filter((term) => haystackParts.some((part) => part.includes(term))); | ||
| return matched.length > 0 ? [{ ...bot, matched }] : []; | ||
| }); | ||
| return scored.sort((a, b) => b.matched.length - a.matched.length).slice(0, limit); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.