Skip to content
Closed
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
42 changes: 29 additions & 13 deletions bin/gstack-memory-ingest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <slug>` (stdin, YAML frontmatter for
* title/type/tags); gbrain's native dedup keys on session_id.
*/

import {
Expand Down Expand Up @@ -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 <slug>`
// (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;
}
Expand All @@ -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 <slug>` (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"],
Expand Down
135 changes: 134 additions & 1 deletion test/gstack-memory-ingest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 <slug>` 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 <<EOF
Usage: gbrain <command> [options]

Commands:
put <slug> Write a page (content via stdin, YAML frontmatter for metadata)
search <query> Keyword search across pages
ask <question> Hybrid semantic + keyword query
EOF
exit 0
;;
put)
if [ "\${2:-}" = "--help" ]; then
echo "Usage: gbrain put <slug>"
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:-<empty>}" >&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 <slug>` 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 });
});
});