Skip to content
Open
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
97 changes: 92 additions & 5 deletions bin/gstack-memory-ingest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -520,13 +520,65 @@ function* walkGstackArtifacts(ctx: WalkContext): Generator<{ path: string; type:
}

function* walkAllSources(ctx: WalkContext): Generator<{ path: string; type: MemoryType }> {
if (ctx.args.sources.has("transcript")) {
if (ctx.args.sources.has("transcript") && transcriptIngestMode() !== "off") {
yield* walkClaudeCodeProjects(ctx);
yield* walkCodexSessions(ctx);
}
yield* walkGstackArtifacts(ctx);
}

function transcriptIngestMode(): string {
try {
const raw = readFileSync(join(GSTACK_HOME, "config.yaml"), "utf-8");
const match = raw.match(/^transcript_ingest_mode:\s*([^#\s]+)\s*(?:#.*)?$/m);
return match?.[1]?.toLowerCase() || "";
} catch {
return "";
}
}

let cachedCurrentRepoRemote: string | undefined;
function currentRepoRemote(): string {
if (cachedCurrentRepoRemote !== undefined) return cachedCurrentRepoRemote;
try {
cachedCurrentRepoRemote = canonicalizeRemote(
execFileSync("git", ["-C", process.cwd(), "remote", "get-url", "origin"], {
encoding: "utf-8",
timeout: 5_000,
stdio: ["ignore", "pipe", "ignore"],
}).trim(),
);
} catch {
cachedCurrentRepoRemote = "";
}
return cachedCurrentRepoRemote;
}

let cachedRepoPolicy: Record<string, unknown> | undefined;
function repoTrustTier(remote: string): string {
if (!remote) return "unset";
try {
cachedRepoPolicy ??= JSON.parse(
readFileSync(join(GSTACK_HOME, "gbrain-repo-policy.json"), "utf-8"),
) as Record<string, unknown>;
const tier = cachedRepoPolicy[remote];
return typeof tier === "string" ? tier : "unset";
} catch {
cachedRepoPolicy = {};
return "unset";
}
}

function transcriptSourceId(): string {
const remote = currentRepoRemote();
const readable = repoSlug(remote)
.replace(/[^a-z0-9]+/gi, "-")
.replace(/^-+|-+$/g, "")
.toLowerCase() || "current-repo";
const hash = createHash("sha256").update(remote || process.cwd()).digest("hex").slice(0, 8);
return `transcripts-${readable.slice(0, 11)}-${hash}`;
}

// ── Renderers ──────────────────────────────────────────────────────────────

interface ParsedSession {
Expand Down Expand Up @@ -1171,6 +1223,16 @@ function preparePages(
skippedUnattributed++;
continue;
}
const currentRemote = currentRepoRemote();
const tier = repoTrustTier(page.git_remote || "");
const explicitlyUnattributed = (!page.git_remote || page.git_remote === "_unattributed") && args.includeUnattributed;
if (
!explicitlyUnattributed &&
(!currentRemote || page.git_remote !== currentRemote || tier === "deny" || tier === "read-only")
) {
skippedUnattributed++;
continue;
}
if (page.partial) partialPages++;
} else {
page = buildArtifactPage(path, type);
Expand Down Expand Up @@ -1409,14 +1471,17 @@ export function resolveImportTimeoutMs(
function runGbrainImport(
stagingDir: string,
timeoutMs: number,
sourceId?: string,
): Promise<{ status: number | null; stdout: string; stderr: string; timedOut: boolean }> {
installSignalForwarder();
return new Promise((resolve) => {
// Seed DATABASE_URL from gbrain's own config so this stage works
// inside Next.js / Prisma / Rails projects with their own
// .env.local (codex review #7 — defense in depth on top of the
// parent gstack-gbrain-sync seeding the bun grandchild's env).
const child = spawnGbrainAsync(["import", stagingDir, "--no-embed", "--json"]);
const importArgs = ["import", stagingDir, "--no-embed", "--json"];
if (sourceId) importArgs.push("--source-id", sourceId);
const child = spawnGbrainAsync(importArgs);
_activeImportChild = child;
let stdout = "";
let stderr = "";
Expand Down Expand Up @@ -1690,7 +1755,10 @@ async function ingestPass(args: CliArgs): Promise<BulkResult> {
// spawn, parent termination orphans the gbrain process (observed
// during 2026-05-10 cold-run testing — gbrain kept running 15 min
// after the orchestrator timed out).
const importResult = await runGbrainImport(stagingDir, resolveImportTimeoutMs());
const sourceId = args.sources.size === 1 && args.sources.has("transcript")
? transcriptSourceId()
: undefined;
const importResult = await runGbrainImport(stagingDir, resolveImportTimeoutMs(), sourceId);

const stdout = importResult.stdout || "";
const stderr = importResult.stderr || "";
Expand Down Expand Up @@ -1893,6 +1961,25 @@ function printBulkResult(r: BulkResult, args: CliArgs): void {
}
}

async function runScopedIngest(args: CliArgs): Promise<BulkResult> {
const hasTranscripts = args.sources.has("transcript");
const artifactTypes = new Set([...args.sources].filter((type) => type !== "transcript"));
if (!hasTranscripts || artifactTypes.size === 0) return ingestPass(args);

const transcriptResult = await ingestPass({ ...args, sources: new Set<MemoryType>(["transcript"]) });
const artifactResult = await ingestPass({ ...args, sources: artifactTypes });
return {
written: transcriptResult.written + artifactResult.written,
skipped_secret: transcriptResult.skipped_secret + artifactResult.skipped_secret,
skipped_dedup: transcriptResult.skipped_dedup + artifactResult.skipped_dedup,
skipped_unattributed: transcriptResult.skipped_unattributed + artifactResult.skipped_unattributed,
failed: transcriptResult.failed + artifactResult.failed,
duration_ms: transcriptResult.duration_ms + artifactResult.duration_ms,
partial_pages: transcriptResult.partial_pages + artifactResult.partial_pages,
system_error: transcriptResult.system_error || artifactResult.system_error,
};
}

// ── Entry point ────────────────────────────────────────────────────────────

async function main(): Promise<void> {
Expand All @@ -1913,7 +2000,7 @@ async function main(): Promise<void> {
if (args.mode === "incremental" && args.quiet) {
// Steady-state fast path: log nothing unless changes happen.
const t0 = Date.now();
const result = await ingestPass(args);
const result = await runScopedIngest(args);
const dt = Date.now() - t0;
if (result.written > 0 || result.failed > 0) {
console.error(`[memory-ingest] ${result.written} written, ${result.failed} failed in ${dt}ms`);
Expand All @@ -1924,7 +2011,7 @@ async function main(): Promise<void> {
return;
}

const result = await ingestPass(args);
const result = await runScopedIngest(args);
printBulkResult(result, args);
if (result.system_error) process.exit(1);
}
Expand Down
69 changes: 69 additions & 0 deletions test/gstack-memory-ingest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,7 @@ EOF
DIR="\${2:-}"
NO_EMBED=0
JSON=0
echo "argv=\$*" >> "\$ARGS_LOG"
shift 2 || true
for arg in "\$@"; do
case "\$arg" in
Expand Down Expand Up @@ -766,3 +767,71 @@ exit 0
rmSync(home, { recursive: true, force: true });
});
});

describe("gstack-memory-ingest transcript boundaries (#2140)", () => {
it("honors off mode before invoking gbrain", () => {
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
writeFileSync(join(gstackHome, "config.yaml"), "transcript_ingest_mode: off\n");
writeClaudeCodeSession(
home,
"current",
"off-session",
`${JSON.stringify({ type: "user", sessionId: "off-session", cwd: process.cwd(), timestamp: new Date().toISOString(), message: { role: "user", content: "private" } })}\n`,
);
const fake = installFakeGbrain(home);
const r = runScript(["--bulk", "--quiet", "--sources", "transcript"], {
HOME: home,
GSTACK_HOME: gstackHome,
PATH: `${fake.binDir}:${process.env.PATH}`,
});
expect(r.exitCode).toBe(0);
expect(existsSync(fake.argsFile)).toBe(false);
rmSync(home, { recursive: true, force: true });
});

it("imports only the trusted current repo into a dedicated transcript source", () => {
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
const currentRemote = spawnSync("git", ["remote", "get-url", "origin"], { encoding: "utf-8" }).stdout.trim();
const currentKey = currentRemote.replace(/^[^:]+:\/\//, "").replace(/^[^@]+@/, "").replace(":", "/").replace(/\.git$/, "").toLowerCase();
const repos = [
["denied", "https://github.com/probe/denied.git"],
["readonly", "https://github.com/probe/readonly.git"],
["other", "https://github.com/probe/other.git"],
];
for (const [name, remote] of repos) {
const cwd = join(home, name);
mkdirSync(cwd, { recursive: true });
spawnSync("git", ["init", "-q"], { cwd });
spawnSync("git", ["remote", "add", "origin", remote], { cwd });
writeClaudeCodeSession(home, name, `${name}-session`, `${JSON.stringify({ type: "user", sessionId: `${name}-session`, cwd, timestamp: new Date().toISOString(), message: { role: "user", content: name } })}\n`);
}
writeClaudeCodeSession(home, "current", "current-session", `${JSON.stringify({ type: "user", sessionId: "current-session", cwd: process.cwd(), timestamp: new Date().toISOString(), message: { role: "user", content: "current" } })}\n`);
writeFileSync(join(gstackHome, "gbrain-repo-policy.json"), JSON.stringify({
_schema_version: 2,
[currentKey]: "read-write",
"github.com/probe/denied": "deny",
"github.com/probe/readonly": "read-only",
"github.com/probe/other": "read-write",
}));
const fake = installFakeGbrain(home);
const r = runScript(["--bulk", "--quiet", "--sources", "transcript"], {
HOME: home,
GSTACK_HOME: gstackHome,
PATH: `${fake.binDir}:${process.env.PATH}`,
});
expect(r.exitCode).toBe(0);
const stagedPages = readFileSync(fake.stagingListFile, "utf-8").trim().split("\n").filter((line) => line.endsWith(".md"));
expect(stagedPages).toHaveLength(1);
const args = readFileSync(fake.argsFile, "utf-8");
expect(args).toContain("--source-id transcripts-");
expect(args).not.toContain("--source-id default");
const sourceId = args.match(/--source-id\s+(\S+)/)?.[1] || "";
expect(sourceId).toMatch(/^[a-z0-9]+(?:-[a-z0-9]+)*$/);
expect(sourceId.length).toBeLessThanOrEqual(32);
rmSync(home, { recursive: true, force: true });
});
});