diff --git a/bin/gstack-memory-ingest.ts b/bin/gstack-memory-ingest.ts index 8ba03eb1f..12d079576 100644 --- a/bin/gstack-memory-ingest.ts +++ b/bin/gstack-memory-ingest.ts @@ -34,8 +34,9 @@ * keep V1 ship-tight. See TODOS.md. * * V1.5 NOTE: When `gbrain put_file` ships in the gbrain CLI (cross-repo P0 TODO), - * transcripts will route to Supabase Storage instead of put_page. Until then, all - * content rides put_page; gbrain's native dedup keys on session_id. + * transcripts will route to Supabase Storage instead of the page-write path. + * Until then, all content rides `gbrain put ` (stdin, YAML frontmatter for + * title/type/tags); gbrain's native dedup keys on session_id. */ import { @@ -745,14 +746,23 @@ function buildArtifactPage(path: string, type: MemoryType): PageRecord { }; } -// ── Writer (calls gbrain put_page) ───────────────────────────────────────── +// ── Writer (calls `gbrain put`) ──────────────────────────────────────────── let _gbrainAvailability: boolean | null = null; function gbrainAvailable(): boolean { if (_gbrainAvailability !== null) return _gbrainAvailability; try { execSync("command -v gbrain", { stdio: "ignore" }); - _gbrainAvailability = true; + // gbrain v0.27 retired the legacy `put_page` flag-form for `put ` + // (content via stdin, metadata as YAML frontmatter). Probe `--help` for + // the `put` subcommand so we surface a single clean error here rather + // than failing every page with "Unknown command: put_page". + const help = execFileSync("gbrain", ["--help"], { + encoding: "utf-8", + timeout: 5000, + stdio: ["ignore", "pipe", "pipe"], + }); + _gbrainAvailability = /(^|\s)put(\s|$)/m.test(help); } catch { _gbrainAvailability = false; } @@ -761,18 +771,24 @@ function gbrainAvailable(): boolean { function gbrainPutPage(page: PageRecord): { ok: boolean; error?: string } { if (!gbrainAvailable()) { - return { ok: false, error: "gbrain CLI not in PATH" }; + return { ok: false, error: "gbrain CLI not in PATH or missing `put` subcommand" }; } try { - const args = [ - "put_page", - "--slug", page.slug, - "--title", page.title, - "--type", page.type, - "--tags", page.tags.join(","), + // gbrain v0.27+ uses `put ` (positional, content via stdin) instead + // of the legacy `put_page` flag-based form. Inject frontmatter into the + // body so title/type/tags survive when the caller did not prepend it. + const fmLines = [ + "---", + `title: ${JSON.stringify(page.title)}`, + `type: ${page.type}`, + `tags: [${page.tags.map((t) => JSON.stringify(t)).join(", ")}]`, + "---", + "", ]; - execFileSync("gbrain", args, { - input: page.body, + const hasFm = page.body.startsWith("---\n"); + const bodyOut = hasFm ? page.body : fmLines.join("\n") + page.body; + execFileSync("gbrain", ["put", page.slug], { + input: bodyOut, encoding: "utf-8", timeout: 30000, stdio: ["pipe", "pipe", "pipe"], diff --git a/test/gstack-memory-ingest.test.ts b/test/gstack-memory-ingest.test.ts index e9c45f738..93fb8cb74 100644 --- a/test/gstack-memory-ingest.test.ts +++ b/test/gstack-memory-ingest.test.ts @@ -15,7 +15,7 @@ */ import { describe, it, expect, beforeEach, afterEach } from "bun:test"; -import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync, mkdirSync, statSync } from "fs"; +import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync, mkdirSync, statSync, chmodSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; import { spawnSync } from "child_process"; @@ -265,3 +265,136 @@ describe("gstack-memory-ingest --limit", () => { expect(r.stderr).toContain("--limit requires a positive integer"); }); }); + +// ── Writer regression: gbrain v0.27+ uses `put`, not `put_page` ─────────── + +/** + * Stand up a fake `gbrain` shim on PATH that: + * - advertises `put` in `--help` output (so gbrainAvailable() passes) + * - records `put ` invocations + their stdin to a log + * - rejects `put_page` with a non-zero exit, mimicking real gbrain v0.27+ + * + * If the writer ever regresses to the legacy flag-form, the bulk pass will + * report 0 writes and the assertion on `Wrote: 1` will fail loudly. + */ +function installFakeGbrain(home: string): { binDir: string; logFile: string; stdinFile: string } { + const binDir = join(home, "fake-bin"); + mkdirSync(binDir, { recursive: true }); + const logFile = join(home, "gbrain-calls.log"); + const stdinFile = join(home, "gbrain-stdin.log"); + const script = `#!/usr/bin/env bash +set -euo pipefail +LOG="${logFile}" +STDIN_LOG="${stdinFile}" +case "\${1:-}" in + --help|-h) + cat < [options] + +Commands: + put Write a page (content via stdin, YAML frontmatter for metadata) + search Keyword search across pages + ask Hybrid semantic + keyword query +EOF + exit 0 + ;; + put) + if [ "\${2:-}" = "--help" ]; then + echo "Usage: gbrain put " + exit 0 + fi + echo "put \${2:-}" >> "\$LOG" + { + echo "--- slug=\${2:-} ---" + cat + echo + } >> "\$STDIN_LOG" + exit 0 + ;; + put_page|put-page) + echo "Unknown command: \$1" >&2 + exit 2 + ;; + *) + echo "Unknown command: \${1:-}" >&2 + exit 2 + ;; +esac +`; + const binPath = join(binDir, "gbrain"); + writeFileSync(binPath, script, "utf-8"); + chmodSync(binPath, 0o755); + return { binDir, logFile, stdinFile }; +} + +describe("gstack-memory-ingest writer (gbrain v0.27+ `put` interface)", () => { + it("invokes `gbrain put ` with stdin body, not legacy `put_page`", () => { + const home = makeTestHome(); + const gstackHome = join(home, ".gstack"); + mkdirSync(gstackHome, { recursive: true }); + const { binDir, logFile, stdinFile } = installFakeGbrain(home); + + // Single Claude Code session fixture. --include-unattributed lets it write + // even though there's no resolvable git remote in /tmp. + const session = + `{"type":"user","message":{"role":"user","content":"hi"},"timestamp":"2026-05-01T00:00:00Z","cwd":"/tmp/foo"}\n` + + `{"type":"assistant","message":{"role":"assistant","content":"hello"},"timestamp":"2026-05-01T00:00:01Z"}\n`; + writeClaudeCodeSession(home, "tmp-foo", "abc123", session); + + const r = runScript(["--bulk", "--include-unattributed", "--quiet"], { + HOME: home, + GSTACK_HOME: gstackHome, + PATH: `${binDir}:${process.env.PATH || ""}`, + }); + + expect(r.exitCode).toBe(0); + expect(existsSync(logFile)).toBe(true); + + const calls = readFileSync(logFile, "utf-8"); + expect(calls).toContain("put "); + expect(calls).not.toContain("put_page"); + + // Body should ride stdin and carry frontmatter (transcript builder + // prepends its own; raw artifacts get the writer-side injection). + const stdin = readFileSync(stdinFile, "utf-8"); + expect(stdin).toContain("---"); + expect(stdin).toMatch(/agent:\s+claude-code/); + + rmSync(home, { recursive: true, force: true }); + }); + + it("fails fast when gbrain CLI is missing the `put` subcommand", () => { + const home = makeTestHome(); + const gstackHome = join(home, ".gstack"); + mkdirSync(gstackHome, { recursive: true }); + + // Fake gbrain that ONLY advertises legacy `put_page` (no `put`). + const binDir = join(home, "legacy-bin"); + mkdirSync(binDir, { recursive: true }); + const script = `#!/usr/bin/env bash +case "\${1:-}" in + --help|-h) echo "Commands:"; echo " put_page Write a page (legacy)"; exit 0 ;; + *) echo "Unknown command: \$1" >&2; exit 2 ;; +esac +`; + const binPath = join(binDir, "gbrain"); + writeFileSync(binPath, script, "utf-8"); + chmodSync(binPath, 0o755); + + const session = + `{"type":"user","message":{"role":"user","content":"hi"},"timestamp":"2026-05-01T00:00:00Z","cwd":"/tmp/bar"}\n`; + writeClaudeCodeSession(home, "tmp-bar", "def456", session); + + const r = runScript(["--bulk", "--include-unattributed"], { + HOME: home, + GSTACK_HOME: gstackHome, + PATH: `${binDir}:${process.env.PATH || ""}`, + }); + + // Bulk completes (the script is per-page tolerant), but every page + // surfaces the missing-`put` error rather than the old "Unknown command". + expect(r.stderr + r.stdout).toMatch(/missing `put` subcommand|gbrain CLI not in PATH/); + + rmSync(home, { recursive: true, force: true }); + }); +});