Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions server/bot-directory.test.ts
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([]);
});
});
150 changes: 150 additions & 0 deletions server/bot-directory.ts
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)));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

/** 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);
}
Comment thread
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);
}
42 changes: 42 additions & 0 deletions server/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -760,6 +760,48 @@ describe("harness HTTP API", () => {
}
});

it("the scout reads a folder, proposes an importable team, and creates nothing until the human imports", async () => {
const folder = mkdtempSync(join(tmpdir(), "omb-scout-"));
writeFileSync(join(folder, "README.md"), "# Demo Shop\n\nA storefront demo.\n");
writeFileSync(
join(folder, "package.json"),
JSON.stringify({ dependencies: { react: "^19" }, devDependencies: { vitest: "^3" } }),
);

const before = (await api("GET", "/api/bots")).body;

expect((await api("GET", "/api/teams/scout")).status).toBe(400);
expect((await api("GET", `/api/teams/scout?cwd=${encodeURIComponent(join(folder, "nope"))}`)).status).toBe(400);

const scouted = await api("GET", `/api/teams/scout?cwd=${encodeURIComponent(folder)}`);
expect(scouted.status).toBe(200);
expect(scouted.body.profile).toMatchObject({ name: "Demo Shop", summary: "A storefront demo." });
expect(scouted.body.profile.stacks).toContain("React");
expect(scouted.body.suggestion.roomName).toBe("Demo Shop");
const keys = scouted.body.suggestion.manifest.team.members.map((member: { key: string }) => member.key);
expect(keys).toEqual(["lead", "frontend", "testing"]);
expect(Object.keys(scouted.body.suggestion.reasons).sort()).toEqual(keys.slice().sort());

// scouting is read-only: no bot and no room exists until the import
const after = (await api("GET", "/api/bots")).body;
expect(after.bots).toHaveLength(before.bots.length);
expect(after.groups).toHaveLength(before.groups.length);

// and the suggestion goes through the real importer verbatim
const imported = await api(
"POST",
`/api/teams/import?mode=project&cwd=${encodeURIComponent(folder)}&room=${encodeURIComponent(scouted.body.suggestion.roomName)}`,
scouted.body.suggestion.manifest,
);
expect(imported.status).toBe(201);
expect(imported.body.group).toMatchObject({ name: "Demo Shop", cwd: folder });
expect(imported.body.bots).toHaveLength(3);

expect((await api("DELETE", `/api/groups/${imported.body.group.id}`)).status).toBe(200);
for (const bot of imported.body.bots) await api("DELETE", `/api/bots/${bot.id}`);
rmSync(folder, { recursive: true, force: true });
});

it("team import is additive-only: smuggled grants, claimed ids, and re-imports never touch existing records", async () => {
// an armed bot: every privilege a malicious manifest could try to
// capture is switched ON here, so any write-through shows up as a diff
Expand Down
34 changes: 34 additions & 0 deletions server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ import { LocalVmLease, LocalVmLeasePool } from "./local-vm-lease.ts";
import { RepeatDetector, callKey } from "./repeat-detector.ts";
import * as vps from "./vps-computer.ts";
import { RoutineManager, type RoutineRunOn, type RoutineRunTrigger } from "./routines.ts";
import { fetchBotDirectory, matchDirectoryBots, type MatchedDirectoryBot } from "./bot-directory.ts";
import { scoutProject, suggestTeam } from "./project-scout.ts";
import { fetchGithubTeam, fetchLibraryTeam, fetchTeamCatalog } from "./team-library.ts";
import { createTeamManifest, importedMemberProfile, parseTeamManifest } from "./team-manifest.ts";
import { readThreadEvents } from "./thread-events.ts";
Expand Down Expand Up @@ -2999,6 +3001,38 @@ const server = createServer(async (req, res) => {
return json(res, status, { error: error instanceof Error ? error.message : "The GitHub team could not be loaded" });
}
}
if (method === "GET" && path === "/api/teams/scout") {
// The scout reads a folder and answers with a suggestion — it creates
// nothing. Bots and the room come into being only when the human sends
// the suggested manifest through /api/teams/import, so "the agent
// proposes, the person imports" is enforced by the route split itself.
// The folder is whatever validateBotCwd accepts: the same local-user
// trust boundary as pointing any bot's working folder at a path.
// Deliberately offline — the community directory lives on its own
// route below, so a slow network can never delay the suggestion.
const validated = validateBotCwd(url.searchParams.get("cwd"));
if (!validated.ok) return json(res, 400, { error: validated.error });
if (!validated.cwd) return json(res, 400, { error: "scout needs a folder to read" });
const profile = scoutProject(validated.cwd);
return json(res, 200, { profile, suggestion: suggestTeam(profile) });
}
if (method === "GET" && path === "/api/teams/scout/directory") {
// Community bots that fit the scouted folder — a separate, lazy call
// so an unreachable directory degrades to "no extra candidates", never
// to a broken scout.
const validated = validateBotCwd(url.searchParams.get("cwd"));
if (!validated.ok) return json(res, 400, { error: validated.error });
if (!validated.cwd) return json(res, 400, { error: "scout needs a folder to read" });
let directory: MatchedDirectoryBot[] = [];
try {
directory = matchDirectoryBots(scoutProject(validated.cwd), await fetchBotDirectory());
} catch (error) {
// an unreachable directory is a fact of life, not an error — but an
// empty section should still be diagnosable from the server log
console.warn("bot directory lookup failed:", error instanceof Error ? error.message : String(error));
}
return json(res, 200, { directory });
}
if (method === "POST" && path === "/api/teams/import") {
// Import is additive-only. A manifest is untrusted input (catalog,
// GitHub, a shared file), so it must be structurally unable to reach
Expand Down
Loading
Loading