#!/usr/bin/env bun /** * gstack-brain-context-load — V1 retrieval surface (Lane C). * * Called from the gstack preamble at every skill start. Reads the active skill's * `gbrain.context_queries:` frontmatter (Layer 2) or falls back to a generic * salience block (Layer 1). Dispatches each query by kind: * * kind: vector → gbrain query * kind: list → gbrain list_pages --filter ... * kind: filesystem → local glob * * Each MCP/CLI call has a 500ms hard timeout per Section 1C. On timeout or * "gbrain not in PATH" / "MCP not registered", the helper renders * `(unavailable)` for that section and continues — skill startup never blocks * > 2s on gbrain issues. * * Layer 1 fallback per F7 (Codex outside-voice): every default query carries * an explicit `repo: {repo_slug}` filter so cross-repo contamination is the * non-default path. * * Datamark envelope per Section 1D: each rendered page body is wrapped in * `...` * once at the page level (not per-message). Layer 1 prompt-injection defense. * * V1.5 P0: salience smarts promote to gbrain server-side MCP tools * (`get_recent_salience`, `find_anomalies`). Helper signature stays the same; * internals switch from 4-call composition to a single MCP call. * * Usage: * gstack-brain-context-load --skill office-hours --repo garrytan-gstack * gstack-brain-context-load --skill-file ./SKILL.md --repo X --user Y * gstack-brain-context-load --window 14d --explain * gstack-brain-context-load --quiet */ import { existsSync, readFileSync, statSync, readdirSync } from "fs"; import { join, dirname, basename, resolve } from "path"; import { execFileSync, spawnSync } from "child_process"; import { homedir } from "os"; import { parseSkillManifest, type GbrainManifest, type GbrainManifestQuery, withErrorContext } from "../lib/gstack-memory-helpers"; // ── Types ────────────────────────────────────────────────────────────────── interface CliArgs { skill?: string; skillFile?: string; repo?: string; user?: string; branch?: string; window: string; // e.g. "14d" limit: number; explain: boolean; quiet: boolean; } interface QueryResult { query: GbrainManifestQuery; ok: boolean; rendered: string; bytes: number; duration_ms: number; reason?: string; } // ── Constants ────────────────────────────────────────────────────────────── const HOME = homedir(); const GSTACK_HOME = process.env.GSTACK_HOME || join(HOME, ".gstack"); const MCP_TIMEOUT_MS = 500; const PAGE_SIZE_CAP = 10 * 1024; // 10KB per query result before truncation // ── CLI ──────────────────────────────────────────────────────────────────── function printUsage(): void { console.error(`Usage: gstack-brain-context-load [options] Options: --skill Active skill name (looks up SKILL.md path) --skill-file Direct path to SKILL.md (overrides --skill) --repo Repo slug for {repo_slug} template var --user User slug for {user_slug} template var --branch Branch name for {branch} template var --window Layer 1 window (default: 14d) --limit Max results per query (default: from manifest, else 10) --explain Print byte counts + which queries ran (to stderr) --quiet Suppress everything except the rendered block --help This text. Output: rendered ## sections to stdout, ready for the preamble to inject. `); } function parseArgs(): CliArgs { const args = process.argv.slice(2); let skill: string | undefined; let skillFile: string | undefined; let repo: string | undefined; let user: string | undefined; let branch: string | undefined; let window = "14d"; let limit = 10; let explain = false; let quiet = false; for (let i = 0; i < args.length; i++) { const a = args[i]; switch (a) { case "--skill": skill = args[++i]; break; case "--skill-file": skillFile = args[++i]; break; case "--repo": repo = args[++i]; break; case "--user": user = args[++i]; break; case "--branch": branch = args[++i]; break; case "--window": window = args[++i] || "14d"; break; case "--limit": limit = parseInt(args[++i] || "10", 10); if (!Number.isFinite(limit) || limit <= 0) { console.error("--limit requires a positive integer"); process.exit(1); } break; case "--explain": explain = true; break; case "--quiet": quiet = true; break; case "--help": case "-h": printUsage(); process.exit(0); default: console.error(`Unknown argument: ${a}`); printUsage(); process.exit(1); } } return { skill, skillFile, repo, user, branch, window, limit, explain, quiet }; } // ── Template var substitution ────────────────────────────────────────────── function substituteTemplateVars(s: string, args: CliArgs): { resolved: string; unresolved: string[] } { const unresolved: string[] = []; const resolved = s.replace(/\{(\w+)\}/g, (full, name) => { switch (name) { case "repo_slug": if (args.repo) return args.repo; unresolved.push(name); return full; case "user_slug": if (args.user) return args.user; unresolved.push(name); return full; case "branch": if (args.branch) return args.branch; unresolved.push(name); return full; case "skill_name": if (args.skill) return args.skill; unresolved.push(name); return full; case "window": return args.window; default: unresolved.push(name); return full; } }); return { resolved, unresolved }; } // ── Skill manifest resolution ────────────────────────────────────────────── function resolveSkillFile(args: CliArgs): string | null { if (args.skillFile) { return resolve(args.skillFile); } if (!args.skill) return null; // Look in common gstack skill locations const candidates = [ join(HOME, ".claude", "skills", args.skill, "SKILL.md"), join(HOME, ".claude", "skills", "gstack", args.skill, "SKILL.md"), join(process.cwd(), ".claude", "skills", args.skill, "SKILL.md"), join(process.cwd(), args.skill, "SKILL.md"), ]; for (const c of candidates) { if (existsSync(c)) return c; } return null; } // ── Dispatchers ──────────────────────────────────────────────────────────── function gbrainAvailable(): boolean { try { execFileSync("command", ["-v", "gbrain"], { stdio: "ignore" }); return true; } catch { return false; } } function dispatchVector(q: GbrainManifestQuery, args: CliArgs): QueryResult { const t0 = Date.now(); const { resolved: query, unresolved } = substituteTemplateVars(q.query || "", args); if (unresolved.length > 0) { return { query: q, ok: false, rendered: "", bytes: 0, duration_ms: Date.now() - t0, reason: `template vars unresolved: ${unresolved.join(",")}`, }; } if (!gbrainAvailable()) { return { query: q, ok: false, rendered: "", bytes: 0, duration_ms: Date.now() - t0, reason: "gbrain CLI missing" }; } const limit = q.limit ?? args.limit; const result = spawnSync("gbrain", ["query", query, "--limit", String(limit), "--format", "compact"], { encoding: "utf-8", timeout: MCP_TIMEOUT_MS, }); if (result.status !== 0 || !result.stdout) { return { query: q, ok: false, rendered: "", bytes: 0, duration_ms: Date.now() - t0, reason: result.error?.message || `gbrain query exited ${result.status}`, }; } const rendered = wrapDatamarked(q.render_as, capBody(result.stdout)); return { query: q, ok: true, rendered, bytes: rendered.length, duration_ms: Date.now() - t0 }; } function dispatchList(q: GbrainManifestQuery, args: CliArgs): QueryResult { const t0 = Date.now(); if (!gbrainAvailable()) { return { query: q, ok: false, rendered: "", bytes: 0, duration_ms: Date.now() - t0, reason: "gbrain CLI missing" }; } const limit = q.limit ?? args.limit; const cliArgs: string[] = ["list_pages", "--limit", String(limit)]; if (q.sort) cliArgs.push("--sort", q.sort); if (q.filter) { for (const [k, v] of Object.entries(q.filter)) { const { resolved: rv } = substituteTemplateVars(String(v), args); cliArgs.push("--filter", `${k}=${rv}`); } } const result = spawnSync("gbrain", cliArgs, { encoding: "utf-8", timeout: MCP_TIMEOUT_MS }); if (result.status !== 0 || !result.stdout) { return { query: q, ok: false, rendered: "", bytes: 0, duration_ms: Date.now() - t0, reason: result.error?.message || `gbrain list_pages exited ${result.status}`, }; } const rendered = wrapDatamarked(q.render_as, capBody(result.stdout)); return { query: q, ok: true, rendered, bytes: rendered.length, duration_ms: Date.now() - t0 }; } function dispatchFilesystem(q: GbrainManifestQuery, args: CliArgs): QueryResult { const t0 = Date.now(); if (!q.glob) { return { query: q, ok: false, rendered: "", bytes: 0, duration_ms: Date.now() - t0, reason: "filesystem kind missing glob" }; } const { resolved: glob, unresolved } = substituteTemplateVars(q.glob, args); if (unresolved.length > 0) { return { query: q, ok: false, rendered: "", bytes: 0, duration_ms: Date.now() - t0, reason: `template vars unresolved: ${unresolved.join(",")}`, }; } // Expand ~ to home dir const expanded = glob.replace(/^~/, HOME); // Simple glob: match against filesystem const matches = simpleGlob(expanded); if (matches.length === 0) { return { query: q, ok: false, rendered: "", bytes: 0, duration_ms: Date.now() - t0, reason: "no matches" }; } // Sort + limit let sorted = matches; if (q.sort === "mtime_desc") { sorted = matches .map((p) => ({ p, mtime: tryStatMtime(p) })) .sort((a, b) => b.mtime - a.mtime) .map((x) => x.p); } const limit = q.limit ?? args.limit; const limited = q.tail !== undefined ? sorted.slice(-q.tail) : sorted.slice(0, limit); const lines = limited.map((p) => { const mt = new Date(tryStatMtime(p)).toISOString().slice(0, 10); return `- ${mt} — ${basename(p)}`; }); const rendered = wrapDatamarked(q.render_as, capBody(lines.join("\n"))); return { query: q, ok: true, rendered, bytes: rendered.length, duration_ms: Date.now() - t0 }; } // ── Helpers ──────────────────────────────────────────────────────────────── function simpleGlob(pattern: string): string[] { // Handle simple patterns: /** or /file or if (!pattern.includes("*") && !pattern.includes("?")) { return existsSync(pattern) ? [pattern] : []; } // Split on the last '/' before any glob char const idx = pattern.search(/[*?]/); const dirEnd = pattern.lastIndexOf("/", idx); if (dirEnd === -1) return []; const dir = pattern.slice(0, dirEnd); const fileGlob = pattern.slice(dirEnd + 1); if (!existsSync(dir)) return []; let entries: string[]; try { entries = readdirSync(dir); } catch { return []; } const re = new RegExp("^" + fileGlob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") + "$"); return entries.filter((e) => re.test(e)).map((e) => join(dir, e)); } function tryStatMtime(p: string): number { try { return statSync(p).mtimeMs; } catch { return 0; } } function capBody(s: string): string { if (s.length <= PAGE_SIZE_CAP) return s; return s.slice(0, PAGE_SIZE_CAP) + `\n\n_(truncated; ${s.length - PAGE_SIZE_CAP} more bytes — query gbrain directly for full results)_\n`; } function wrapDatamarked(renderAs: string, body: string): string { // Layer 1 prompt-injection defense (Section 1D, D12). Single envelope around // the whole rendered body, not per-message. return [ renderAs, "", "", body, "", "", ].join("\n"); } // ── Layer 1 fallback (no manifest) ───────────────────────────────────────── function defaultManifest(args: CliArgs): GbrainManifest { // Per plan §"Three-section default" (D13). Each query carries explicit // `repo: {repo_slug}` filter (F7 cleanup) so cross-repo contamination is // the non-default path. return { schema: 1, context_queries: [ { id: "recent-transcripts", kind: "list", filter: { type: "transcript", "tags_contains": "repo:{repo_slug}" }, sort: "updated_at_desc", limit: 5, render_as: "## Recent transcripts in this repo", }, { id: "recent-curated", kind: "list", filter: { "tags_contains": "repo:{repo_slug}", updated_after: "now-7d" }, sort: "updated_at_desc", limit: 10, render_as: "## Recent curated memory", }, { id: "skill-name-events", kind: "list", filter: { type: "timeline", content_contains: "{skill_name}" }, limit: 5, render_as: "## Recent {skill_name} events", }, ], }; } // ── Main pipeline ────────────────────────────────────────────────────────── async function loadContext(args: CliArgs): Promise<{ rendered: string; results: QueryResult[]; mode: "manifest" | "default" }> { const skillFile = resolveSkillFile(args); let manifest: GbrainManifest | null = null; let mode: "manifest" | "default" = "default"; if (skillFile) { manifest = parseSkillManifest(skillFile); if (manifest && manifest.context_queries.length > 0) { mode = "manifest"; } } if (!manifest) { manifest = defaultManifest(args); } const results: QueryResult[] = []; for (const q of manifest.context_queries) { const r = await withErrorContext(`context-load:${q.id}`, () => { switch (q.kind) { case "vector": return dispatchVector(q, args); case "list": return dispatchList(q, args); case "filesystem": return dispatchFilesystem(q, args); } }, "gstack-brain-context-load"); results.push(r); } // Substitute render_as template vars (e.g. "{skill_name}") const rendered = results .filter((r) => r.ok && r.rendered.length > 0) .map((r) => { const { resolved } = substituteTemplateVars(r.rendered, args); return resolved; }) .join("\n"); return { rendered, results, mode }; } // ── Entry point ──────────────────────────────────────────────────────────── async function main(): Promise { const args = parseArgs(); const { rendered, results, mode } = await loadContext(args); if (!args.quiet && rendered.length > 0) { console.log(rendered); } if (args.explain) { console.error(`[brain-context-load] mode=${mode} queries=${results.length}`); for (const r of results) { const status = r.ok ? "OK" : "SKIP"; console.error(` ${status.padEnd(5)} ${r.query.id.padEnd(28)} kind=${r.query.kind.padEnd(10)} bytes=${r.bytes.toString().padStart(6)} dur=${r.duration_ms}ms${r.reason ? ` (${r.reason})` : ""}`); } const totalBytes = results.reduce((s, r) => s + r.bytes, 0); const totalDur = results.reduce((s, r) => s + r.duration_ms, 0); console.error(`[brain-context-load] total bytes=${totalBytes} dur=${totalDur}ms`); } } main().catch((err) => { console.error(`gstack-brain-context-load fatal: ${err instanceof Error ? err.message : String(err)}`); process.exit(1); });