feat: code-intelligence contract with real GBrain/Graphify/Sourcebot adapters

contract.ts: repo-oriented interface (four required ops, three optional),
typed CodeProviderError, egress + capability consent guards.

Three real, runtime-drivable adapters:
- GbrainProvider: gbrain CLI (reuses lib/gbrain-exec + lib/gbrain-sources),
  all seven capabilities.
- GraphifyProvider: graphify CLI — `graphify <dir>` builds the local graph,
  `graphify query` searches it, export reads graphify-out/graph.json. Fully
  local; never auto-installed.
- SourcebotProvider: self-hosted server over HTTP — register writes a local
  git connection to config.json, search is POST /api/search, status is a
  liveness probe. Loopback base URL = local (no egress); remote = consent.

selection.ts persists the chosen provider + per-repo indexing consent under
$GSTACK_HOME. picker.ts recommends GBrain first, resolves the selected
provider or null (provider-OFF), and probes live availability. Every adapter
degrades to PROVIDER_UNAVAILABLE when its tool/server is absent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sinabina
2026-07-21 17:16:54 -07:00
co-authored by Claude Opus 4.8
parent b4421a4e69
commit 3eb4a1b23f
5 changed files with 508 additions and 27 deletions
+156
View File
@@ -0,0 +1,156 @@
/**
* Graphify adapter — real CLI integration (github.com/Graphify-Labs/graphify).
*
* Graphify is a LOCAL tree-sitter knowledge graph: `graphify <dir>` builds a
* `graphify-out/` (graph.json + report) in that dir, `graphify query "<q>"`
* queries it, and nothing leaves the machine (no embeddings, no network). So it
* needs no egress consent and no MCP — the runtime just shells out to the CLI,
* the same shape as the GBrain adapter.
*
* Never auto-installed: install is `pip install graphifyy && graphify install`,
* a user action the picker gates on. When the CLI is absent every op throws
* PROVIDER_UNAVAILABLE and callers degrade to file-only.
*
* Path-based, not id-based: for Graphify a source "id" IS the absolute repo path
* (that is where `graphify-out/` lives), unlike GBrain's short source ids.
*/
import { spawnSync } from "child_process";
import { existsSync, readFileSync } from "fs";
import { join } from "path";
import {
assertCapability,
assertRequiredCapabilities,
CodeProviderError,
type CodeProvider,
type CodeProviderCapability,
type CodeSearchHit,
type OpOptions,
type RepoRef,
type SearchOptions,
type SourceRef,
type SourceStatus,
} from "./contract";
const CAPABILITIES: CodeProviderCapability[] = ["register_source", "refresh", "search", "status", "export"];
const OUT_DIR = "graphify-out";
const GRAPH_JSON = "graph.json";
const DEFAULT_TIMEOUT_MS = 120_000; // indexing a repo can take a while
const NEEDS_SHELL_ON_WINDOWS = process.platform === "win32"; // graphify is a shim on Windows
export interface GraphifyOptions {
/** Directory whose `graphify-out/` search/status/export read. Defaults to cwd. */
root?: string;
env?: NodeJS.ProcessEnv;
}
export class GraphifyProvider implements CodeProvider {
readonly id = "graphify" as const;
readonly label = "Graphify";
readonly capabilities = new Set<CodeProviderCapability>(CAPABILITIES);
/** Fully local — no repo content leaves the machine. */
readonly local = true;
readonly #root: string;
readonly #env?: NodeJS.ProcessEnv;
constructor(opts: GraphifyOptions = {}) {
this.#root = opts.root ?? process.cwd();
this.#env = opts.env;
assertRequiredCapabilities(this.id, this.capabilities);
}
has(capability: CodeProviderCapability): boolean {
return this.capabilities.has(capability);
}
#run(args: string[], cwd: string, timeout: number) {
return spawnSync("graphify", args, {
cwd,
encoding: "utf-8",
timeout,
stdio: ["ignore", "pipe", "pipe"],
env: this.#env,
shell: NEEDS_SHELL_ON_WINDOWS,
});
}
#assertOk(r: { status: number | null; stderr?: string; error?: Error & { code?: string }; signal?: NodeJS.Signals | null }): void {
if (r.status === 0) return;
const stderr = (r.stderr || "").trim();
if (r.error?.code === "ENOENT" || /command not found/.test(stderr)) {
throw new CodeProviderError("PROVIDER_UNAVAILABLE", "graphify CLI not on PATH (install: pip install graphifyy && graphify install)", this.id);
}
if (r.error?.code === "ETIMEDOUT" || r.signal === "SIGTERM") {
throw new CodeProviderError("PROVIDER_TIMEOUT", "graphify timed out", this.id);
}
throw new CodeProviderError("PROVIDER_ERROR", stderr || `graphify exited ${r.status}`, this.id);
}
/** Build the graph over repo.path (local; no egress consent needed). */
async registerSource(repo: RepoRef, opts: OpOptions = {}): Promise<SourceStatus> {
this.#assertOk(this.#run(["."], repo.path, opts.timeout ?? DEFAULT_TIMEOUT_MS));
return this.status({ id: repo.path }, opts);
}
/** Re-parse and merge changes into the existing graph. */
async refresh(source: SourceRef, opts: OpOptions = {}): Promise<SourceStatus> {
this.#assertOk(this.#run([".", "--update"], source.id, opts.timeout ?? DEFAULT_TIMEOUT_MS));
return this.status(source, opts);
}
/**
* `graphify query "<q>"` returns a traced answer over the graph. Its stdout is
* an answer, not a fixed hit schema (the CLI reference documents the command
* but not a machine format), so we map non-empty output lines to hits
* tolerantly: a leading path-like token becomes the ref, the line the snippet.
* Reconcile against a live graphify if a stricter schema is needed.
*/
async search(query: string, opts: SearchOptions = {}): Promise<CodeSearchHit[]> {
if (!query.trim()) return [];
const cwd = opts.source ?? this.#root;
const r = this.#run(["query", query], cwd, opts.timeout ?? DEFAULT_TIMEOUT_MS);
this.#assertOk(r);
return parseGraphifyQuery(r.stdout || "", opts.limit ?? 10);
}
async status(source?: SourceRef, _opts: OpOptions = {}): Promise<SourceStatus> {
const dir = source?.id ?? this.#root;
const graphPath = join(dir, OUT_DIR, GRAPH_JSON);
if (!existsSync(graphPath)) return { id: dir, state: "absent" };
let itemCount: number | undefined;
try {
const graph = JSON.parse(readFileSync(graphPath, "utf-8")) as { nodes?: unknown[] };
if (Array.isArray(graph.nodes)) itemCount = graph.nodes.length;
} catch {
// graph.json present but unparseable — still ready, just no count.
}
return { id: dir, state: "ready", itemCount, detail: graphPath };
}
async export(source: SourceRef, _opts: OpOptions = {}): Promise<string> {
assertCapability(this, "export");
const graphPath = join(source.id, OUT_DIR, GRAPH_JSON);
if (!existsSync(graphPath)) {
throw new CodeProviderError("SOURCE_NOT_REGISTERED", `no graph at ${graphPath}; index it first`, this.id);
}
return readFileSync(graphPath, "utf-8");
}
}
/**
* Map `graphify query` stdout to hits. Tolerant: each non-empty line becomes a
* hit; a leading `path` or `path:line` token becomes the ref, else the whole
* line is the snippet. Exported for deterministic unit testing.
*/
export function parseGraphifyQuery(stdout: string, limit: number): CodeSearchHit[] {
const hits: CodeSearchHit[] = [];
for (const raw of stdout.split("\n")) {
const line = raw.trim();
if (!line) continue;
const token = line.split(/\s+/)[0];
const looksPath = /[/\\.]/.test(token) && !token.includes(" ");
hits.push({ ref: looksPath ? token : "graphify", snippet: line, kind: "graph-node" });
if (hits.length >= limit) break;
}
return hits;
}
+13 -3
View File
@@ -5,10 +5,20 @@
export * from "./contract";
export { GbrainProvider, parseGbrainSearch } from "./gbrain-adapter";
export { SourcebotProvider, GraphifyProvider } from "./mcp-adapters";
export { GraphifyProvider, parseGraphifyQuery, type GraphifyOptions } from "./graphify-adapter";
export { SourcebotProvider, parseSourcebotSearch, type SourcebotOptions } from "./sourcebot-adapter";
export {
readSelection,
setProvider,
setConsent,
hasConsent,
type Selection,
} from "./selection";
export {
recommendCodeProvider,
resolveCodeProvider,
RECOMMENDED_ORDER,
providerById,
resolveSelectedProvider,
detectAvailable,
type PickerOptions,
type Availability,
} from "./picker";
+74 -24
View File
@@ -1,43 +1,93 @@
/**
* Picker — recommends a code-intelligence provider, GBrain first.
* Picker — constructs the code-intelligence provider the user selected, and
* offers the recommendation order (GBrain first) for the selection UX.
*
* RECOMMENDED_ORDER is the static "GBrain first" fact the options UX and phase-2
* resolution filter against. In phase 1 only GBrain is drivable from the runtime
* (it has a CLI the runtime can spawn); Sourcebot and Graphify are MCP tools in
* the host session and become resolvable in phase 2 once a host transport is
* wired. So recommendCodeProvider returns GBrain-or-nothing today, and
* resolveCodeProvider degrades to null — the provider-OFF path — when GBrain is
* unavailable. Callers then use grep / the file-only decision store.
* `resolveSelectedProvider()` reads the persisted selection and constructs that
* provider, or returns null when nothing is selected — the provider-OFF path,
* where callers degrade to grep / the file-only decision store. Availability is
* proven at call time: a selected provider whose tool/server is absent throws
* PROVIDER_UNAVAILABLE from its ops, which callers catch and degrade on. The
* `detectAvailable()` probe drives the `options`/`status` display.
*
* GBrain availability uses the real detector, localEngineStatus() ("ok"/"timeout"
* are usable). Graphify is NEVER auto-installed or auto-offered.
* GBrain is recommended first. Graphify is NEVER auto-installed — it appears in
* the options only once its CLI is present (a user install).
*/
import { localEngineStatus, type LocalEngineStatus } from "../gbrain-local-status";
import { localEngineStatus } from "../gbrain-local-status";
import { GbrainProvider } from "./gbrain-adapter";
import { GraphifyProvider, type GraphifyOptions } from "./graphify-adapter";
import { SourcebotProvider, type SourcebotOptions } from "./sourcebot-adapter";
import { readSelection } from "./selection";
import type { CodeProvider, CodeProviderId } from "./contract";
/** Recommendation order — GBrain first. Sourcebot/Graphify join in phase 2. */
/** Recommendation order — GBrain first. */
export const RECOMMENDED_ORDER: readonly CodeProviderId[] = ["gbrain", "sourcebot", "graphify"];
export interface PickerOptions {
env?: NodeJS.ProcessEnv;
/** Inject GBrain status for tests; otherwise probed via localEngineStatus(). */
gbrainStatus?: LocalEngineStatus;
graphify?: GraphifyOptions;
sourcebot?: SourcebotOptions;
}
const GBRAIN_USABLE: ReadonlySet<LocalEngineStatus> = new Set(["ok", "timeout"]);
/** Drivable providers, in recommendation order (GBrain first). */
export function recommendCodeProvider(opts: PickerOptions = {}): CodeProvider[] {
const status = opts.gbrainStatus ?? localEngineStatus({ env: opts.env });
return GBRAIN_USABLE.has(status) ? [new GbrainProvider()] : [];
/** Construct a provider by id (no availability check — ops degrade at call time). */
export function providerById(id: CodeProviderId, opts: PickerOptions = {}): CodeProvider {
switch (id) {
case "gbrain":
return new GbrainProvider();
case "graphify":
return new GraphifyProvider({ env: opts.env, ...opts.graphify });
case "sourcebot":
return new SourcebotProvider({ env: opts.env, ...opts.sourcebot });
}
}
/**
* The single recommended provider, or null when none is drivable. Null is the
* provider-OFF path: callers MUST degrade to grep / file-only, never fail.
* The provider the user selected, constructed, or null when none is selected.
* Null is the provider-OFF path: callers MUST degrade to grep / file-only.
*/
export function resolveCodeProvider(opts: PickerOptions = {}): CodeProvider | null {
return recommendCodeProvider(opts)[0] ?? null;
export function resolveSelectedProvider(opts: PickerOptions = {}): CodeProvider | null {
const { provider } = readSelection(opts.env);
return provider ? providerById(provider, opts) : null;
}
export interface Availability {
id: CodeProviderId;
available: boolean;
detail: string;
}
/**
* Probe which providers are usable right now, in recommendation order. Used by
* the `options`/`status` display. GBrain via the real localEngineStatus();
* Graphify via its CLI status; Sourcebot via an HTTP liveness probe.
*/
export async function detectAvailable(opts: PickerOptions = {}): Promise<Availability[]> {
const gbrainStatus = localEngineStatus({ env: opts.env });
const gbrainOk = gbrainStatus === "ok" || gbrainStatus === "timeout";
let graphifyOk = false;
let graphifyDetail = "graphify CLI not installed";
try {
const s = await new GraphifyProvider({ env: opts.env, ...opts.graphify }).status();
graphifyOk = s.state === "ready";
graphifyDetail = graphifyOk ? "graph indexed in this repo" : "installed; no graph in this repo yet";
} catch {
graphifyOk = false;
}
let sourcebotOk = false;
let sourcebotDetail = "server unreachable";
try {
const s = await new SourcebotProvider({ env: opts.env, ...opts.sourcebot }).status();
sourcebotOk = s.state === "ready";
sourcebotDetail = s.detail ?? "";
} catch {
sourcebotOk = false;
}
return [
{ id: "gbrain", available: gbrainOk, detail: `gbrain engine: ${gbrainStatus}` },
{ id: "sourcebot", available: sourcebotOk, detail: sourcebotDetail },
{ id: "graphify", available: graphifyOk, detail: graphifyDetail },
];
}
+68
View File
@@ -0,0 +1,68 @@
/**
* selection — persists the user's chosen code-intelligence provider and their
* per-repo indexing consent. Stored at `$GSTACK_HOME/code-intelligence.json`
* (default `~/.gstack/`), the same home the rest of gstack uses.
*
* Consent is per-repo (keyed by absolute repo path), because indexing consent
* is "may THIS repo's content be indexed by the selected provider" — a decision
* a user makes per project, not once for the machine. No selection at all is the
* provider-OFF default: callers degrade to grep / the file-only decision store.
*/
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
import { homedir } from "os";
import { dirname, join, resolve } from "path";
import type { CodeProviderId } from "./contract";
export interface Selection {
provider: CodeProviderId | null;
/** Absolute repo path → consented. */
consents: Record<string, boolean>;
}
const EMPTY: Selection = { provider: null, consents: {} };
function storePath(env: NodeJS.ProcessEnv = process.env): string {
const home = env.GSTACK_HOME || join(env.HOME || homedir(), ".gstack");
return join(home, "code-intelligence.json");
}
export function readSelection(env: NodeJS.ProcessEnv = process.env): Selection {
const p = storePath(env);
if (!existsSync(p)) return { ...EMPTY };
try {
const raw = JSON.parse(readFileSync(p, "utf-8")) as Partial<Selection>;
return {
provider: raw.provider ?? null,
consents: raw.consents && typeof raw.consents === "object" ? raw.consents : {},
};
} catch {
return { ...EMPTY };
}
}
function write(selection: Selection, env: NodeJS.ProcessEnv = process.env): void {
const p = storePath(env);
mkdirSync(dirname(p), { recursive: true });
const tmp = `${p}.tmp.${process.pid}`;
writeFileSync(tmp, JSON.stringify(selection, null, 2), "utf-8");
renameSync(tmp, p);
}
export function setProvider(provider: CodeProviderId | null, env: NodeJS.ProcessEnv = process.env): Selection {
const next = { ...readSelection(env), provider };
write(next, env);
return next;
}
/** Record per-repo indexing consent (repo path resolved to absolute). */
export function setConsent(repoPath: string, consented: boolean, env: NodeJS.ProcessEnv = process.env): Selection {
const current = readSelection(env);
const next: Selection = { ...current, consents: { ...current.consents, [resolve(repoPath)]: consented } };
write(next, env);
return next;
}
export function hasConsent(repoPath: string, env: NodeJS.ProcessEnv = process.env): boolean {
return readSelection(env).consents[resolve(repoPath)] === true;
}
+197
View File
@@ -0,0 +1,197 @@
/**
* Sourcebot adapter — real HTTP + config integration
* (github.com/sourcebot-dev/sourcebot, YC F2025).
*
* Sourcebot is a self-hosted server that indexes repos declared in its
* config.json and serves regex code search over `POST /api/search` (zoekt). So
* the runtime drives it with plain HTTP + a config-file edit — no MCP:
* - register_source: add `{ "type": "git", "url": "file:///abs/path" }` to the
* server's config.json (it re-indexes automatically on config change).
* - refresh: Sourcebot re-indexes on config change and on reindexIntervalMs;
* there is no per-source trigger endpoint, so refresh reports current status.
* - search: POST /api/search with a regex query, map files[] to hits.
* - status: liveness GET against the base URL.
* Declines the document ops (add/delete/export) — it is a whole-repo search index.
*
* Egress: a loopback base URL means the index runs on this machine, so no repo
* content leaves it (local=true). A non-loopback base URL means content reaches
* another host, so egress consent is required (local=false).
*/
import { existsSync, readFileSync, writeFileSync, renameSync } from "fs";
import {
assertCapability,
assertEgressConsent,
assertRequiredCapabilities,
CodeProviderError,
type CodeProvider,
type CodeProviderCapability,
type CodeSearchHit,
type OpOptions,
type RepoRef,
type SearchOptions,
type SourceRef,
type SourceStatus,
} from "./contract";
const CAPABILITIES: CodeProviderCapability[] = ["register_source", "refresh", "search", "status"];
const DEFAULT_URL = "http://localhost:3000";
const DEFAULT_TIMEOUT_MS = 30_000;
type FetchLike = typeof globalThis.fetch;
export interface SourcebotOptions {
/** Base URL of the Sourcebot server. Defaults to SOURCEBOT_URL or http://localhost:3000. */
baseUrl?: string;
/** Path to the server's config.json (for register_source). Defaults to SOURCEBOT_CONFIG. */
configPath?: string;
/** Injectable fetch for tests. */
fetch?: FetchLike;
env?: NodeJS.ProcessEnv;
}
function isLoopback(url: string): boolean {
try {
const h = new URL(url).hostname.toLowerCase();
return h === "localhost" || h === "127.0.0.1" || h === "::1" || h.endsWith(".localhost");
} catch {
return false;
}
}
export class SourcebotProvider implements CodeProvider {
readonly id = "sourcebot" as const;
readonly label = "Sourcebot";
readonly capabilities = new Set<CodeProviderCapability>(CAPABILITIES);
readonly local: boolean;
readonly #baseUrl: string;
readonly #configPath?: string;
readonly #fetch: FetchLike;
constructor(opts: SourcebotOptions = {}) {
const env = opts.env ?? process.env;
this.#baseUrl = (opts.baseUrl ?? env.SOURCEBOT_URL ?? DEFAULT_URL).replace(/\/$/, "");
this.#configPath = opts.configPath ?? env.SOURCEBOT_CONFIG;
this.#fetch = opts.fetch ?? globalThis.fetch;
this.local = isLoopback(this.#baseUrl);
assertRequiredCapabilities(this.id, this.capabilities);
}
has(capability: CodeProviderCapability): boolean {
return this.capabilities.has(capability);
}
/** Add the repo as a local `git` connection in Sourcebot's config.json. */
async registerSource(repo: RepoRef, opts: OpOptions = {}): Promise<SourceStatus> {
assertEgressConsent(this, opts); // no-op when the server is loopback (local)
if (!this.#configPath) {
throw new CodeProviderError(
"PROVIDER_UNAVAILABLE",
"set SOURCEBOT_CONFIG to the server's config.json path to register sources",
this.id,
);
}
let config: { connections?: Record<string, unknown> };
try {
config = existsSync(this.#configPath)
? (JSON.parse(readFileSync(this.#configPath, "utf-8")) as typeof config)
: {};
} catch (err) {
throw new CodeProviderError("PROVIDER_ERROR", `unreadable Sourcebot config: ${(err as Error).message}`, this.id);
}
config.connections = config.connections ?? {};
config.connections[repo.id] = { type: "git", url: `file://${repo.path}` };
// Atomic write so a running Sourcebot never reads a half-written config.
const tmp = `${this.#configPath}.tmp.${process.pid}`;
writeFileSync(tmp, JSON.stringify(config, null, 2), "utf-8");
renameSync(tmp, this.#configPath);
return { id: repo.id, state: "registered", detail: "Sourcebot re-indexes on config change" };
}
/** Sourcebot re-indexes automatically; report current liveness. */
async refresh(source: SourceRef, opts: OpOptions = {}): Promise<SourceStatus> {
const live = await this.status(source, opts);
return { ...live, detail: "Sourcebot re-indexes automatically (config change + reindexIntervalMs)" };
}
async search(query: string, opts: SearchOptions = {}): Promise<CodeSearchHit[]> {
if (!query.trim()) return [];
const body = {
query: opts.source ? `repo:${opts.source} ${query}` : query,
matches: opts.limit ?? 20,
isRegexEnabled: true,
isCaseSensitivityEnabled: false,
};
const payload = await this.#post("/api/search", body, opts.timeout ?? DEFAULT_TIMEOUT_MS);
return parseSourcebotSearch(payload, opts.limit ?? 20);
}
async status(_source?: SourceRef, opts: OpOptions = {}): Promise<SourceStatus> {
try {
const res = await this.#fetchWithTimeout(this.#baseUrl, { method: "GET" }, opts.timeout ?? DEFAULT_TIMEOUT_MS);
return { id: "*", state: res.ok ? "ready" : "unknown", partial: true, detail: `HTTP ${res.status}` };
} catch {
return { id: "*", state: "unknown", partial: true, detail: `unreachable at ${this.#baseUrl}` };
}
}
async #post(path: string, body: unknown, timeout: number): Promise<unknown> {
let res: Response;
try {
res = await this.#fetchWithTimeout(`${this.#baseUrl}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(body),
}, timeout);
} catch (err) {
throw new CodeProviderError("PROVIDER_UNAVAILABLE", `Sourcebot unreachable at ${this.#baseUrl}: ${(err as Error).message}`, this.id);
}
if (!res.ok) throw new CodeProviderError("PROVIDER_ERROR", `Sourcebot ${path} returned HTTP ${res.status}`, this.id);
try {
return await res.json();
} catch (err) {
throw new CodeProviderError("PROVIDER_ERROR", `Sourcebot returned non-JSON: ${(err as Error).message}`, this.id);
}
}
async #fetchWithTimeout(url: string, init: RequestInit, timeout: number): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
return await this.#fetch(url, { ...init, signal: controller.signal });
} catch (err) {
if ((err as Error)?.name === "AbortError") throw new CodeProviderError("PROVIDER_TIMEOUT", "Sourcebot request timed out", this.id);
throw err;
} finally {
clearTimeout(timer);
}
}
}
interface SourcebotMatchRange { start?: { lineNumber?: number } }
interface SourcebotChunk { content?: string; matchRanges?: SourcebotMatchRange[] }
interface SourcebotFile { fileName?: { text?: string }; repository?: string; chunks?: SourcebotChunk[] }
/**
* Map a `POST /api/search` response `{ files: [...] }` to hits: one hit per file,
* ref = file path, snippet = first matching chunk, kind = "file". Tolerant of a
* missing/garbage payload (returns []). Exported for deterministic testing.
*/
export function parseSourcebotSearch(payload: unknown, limit: number): CodeSearchHit[] {
const files = (payload as { files?: unknown })?.files;
if (!Array.isArray(files)) return [];
const hits: CodeSearchHit[] = [];
for (const f of files as SourcebotFile[]) {
const ref = f?.fileName?.text;
if (typeof ref !== "string") continue;
const chunk = f.chunks?.[0];
const line = chunk?.matchRanges?.[0]?.start?.lineNumber;
hits.push({
ref: typeof line === "number" ? `${ref}:${line}` : ref,
snippet: typeof chunk?.content === "string" ? chunk.content.trim() : undefined,
kind: "file",
});
if (hits.length >= limit) break;
}
return hits;
}