#!/usr/bin/env bun /** * gstack-memory-ingest — V1 memory ingest helper. * * Walks coding-agent transcript sources + ~/.gstack/ curated artifacts and writes * each one to gbrain as a typed page. Per plan §"Storage tiering": curated memory * rides the existing gbrain Postgres + git pipeline; code/transcripts go to the * Supabase tier when configured (or local PGLite otherwise) — never double-store. * * Usage: * gstack-memory-ingest --probe # count what would ingest, no writes * gstack-memory-ingest --incremental [--quiet] # default; mtime fast-path; cheap * gstack-memory-ingest --bulk [--all-history] # first-run; full walk * gstack-memory-ingest --bulk --benchmark # time the bulk pass + report * gstack-memory-ingest --include-unattributed # also ingest sessions with no git remote * * Sources walked: * ~/.claude/projects//.jsonl — Claude Code sessions * ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl — Codex CLI sessions * ~/Library/Application Support/Cursor/User/*.vscdb — Cursor (V1.0.1 follow-up) * ~/.gstack/projects//learnings.jsonl — typed: learning * ~/.gstack/projects//timeline.jsonl — typed: timeline * ~/.gstack/projects//ceo-plans/*.md — typed: ceo-plan * ~/.gstack/projects//*-design-*.md — typed: design-doc * ~/.gstack/analytics/eureka.jsonl — typed: eureka * ~/.gstack/builder-profile.jsonl — typed: builder-profile-entry * * State: ~/.gstack/.transcript-ingest-state.json (LOCAL per ED1, never synced). * Secret scanning: opt-in gitleaks over each rendered page via * lib/gstack-memory-helpers#secretScanText (D19). * Concurrent-write handling: partial-flag + re-ingest on next pass (D10). * * V1.0 NOTE: Cursor SQLite extraction is a V1.0.1 follow-up. The plan promoted it to * V1 scope, but full SQLite parsing requires a sqlite3 binary or library; deferred to * 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 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 { existsSync, readdirSync, readFileSync, writeFileSync, statSync, mkdirSync, mkdtempSync, appendFileSync, renameSync, openSync, readSync, closeSync, rmSync, realpathSync, } from "fs"; import { join, basename, dirname, delimiter, relative } from "path"; import { execFileSync, spawnSync, spawn, type ChildProcess } from "child_process"; import { homedir } from "os"; import { createHash } from "crypto"; import { canonicalizeRemote, secretScanFile, secretScanText, detectEngineTier, withErrorContext, } from "../lib/gstack-memory-helpers"; import { execGbrainText, gbrainConfigDir, spawnGbrain, spawnGbrainAsync } from "../lib/gbrain-exec"; import { constrainSourceId, parseSourcesList } from "../lib/gbrain-sources"; import { BISECT_AFTER_REFUSALS, DEFAULT_GET_CAP, DEFAULT_RECONCILE_LIMIT, LEGACY_SOURCE_ID, acquireStateLock, backupStateFile, checkLanding, classifyImport, formatReconcileSummary, gbrainLookups, isRetryable, loadStateFile, parseImportReport, reconcile, saveStateFile, setEntry, type BatchVerdict, type ImportReport, type IngestState, type LandingLookups, type StateEntry, } from "../lib/memory-ingest-landing"; import { writeReceipt } from "../lib/egress-receipt"; import { checkOwnedStagingDir, STAGING_MARKER } from "../lib/staging-guard"; import { hasRepoPolicyStore, repoPolicyTierBatch } from "../lib/gbrain-repo-policy-client"; import { resolveStateRoot, mergedStateRoots, type StateRootEnv } from "../lib/state-root"; import { cutoffExclusion, purgeStagedTranscripts, readTranscriptConsent, repoExclusion, type TranscriptConsent, type TranscriptPolicy, } from "../lib/transcript-consent"; // ── Types ────────────────────────────────────────────────────────────────── type Mode = "probe" | "incremental" | "bulk" | "reconcile"; interface CliArgs { mode: Mode; quiet: boolean; benchmark: boolean; includeUnattributed: boolean; allHistory: boolean; sources: Set; limit: number | null; noWrite: boolean; /** --reconcile --dry-run: report without changing state. */ dryRun: boolean; /** --request-reconcile: record that a reconcile pass is pending (no gbrain calls). */ requestReconcile: boolean; /** * Opt-in gitleaks scan of each rendered page during the prepare phase; * pages with findings, or that could not be scanned, are skipped. Off by * default — the cross-machine boundary (gstack-brain-sync, git push) * has its own scanner. Setting this adds ~4-8 min to cold runs. */ scanSecrets: boolean; } type MemoryType = | "transcript" | "eureka" | "learning" | "timeline" | "ceo-plan" | "design-doc" | "retro" | "builder-profile-entry"; interface PageRecord { slug: string; title: string; type: MemoryType; agent?: "claude-code" | "codex" | "cursor"; body: string; tags: string[]; source_path: string; session_id?: string; cwd?: string; git_remote?: string; start_time?: string; end_time?: string; partial?: boolean; size_bytes: number; content_sha256: string; } interface ProbeReport { total_files: number; total_bytes: number; by_type: Record; new_count: number; updated_count: number; unchanged_count: number; skipped_unattributed: number; /** * #2392 parity: transcripts whose remote's trust tier is `deny` / * `read-only`. Probe applies the SAME per-remote policy filter --bulk * applies, so its ingestible counts match what --bulk would write. */ skipped_policy_deny: number; skipped_policy_readonly: number; /** Sessions a scoped consent (new@ cutoff, transcript_repos) kept out. */ scope: TranscriptScopeCounts; estimate_minutes: number; } interface BulkResult { written: number; skipped_secret: number; skipped_dedup: number; skipped_unattributed: number; /** * #2392: transcripts skipped because their git remote's trust tier in * ~/.gstack/gbrain-repo-policy.json is `read-only` (search allowed, page * writes never — and transcript ingest writes pages). */ skipped_policy_readonly: number; /** #2392: transcripts skipped because their remote's trust tier is `deny`. */ skipped_policy_deny: number; failed: number; duration_ms: number; partial_pages: number; /** A1: pages gbrain refused or that were not found after import (retried next run). */ refused?: number; /** A1: pages imported but not yet confirmed in the brain (checked on later runs). */ unverified?: number; /** A1: pages isolated as poisoning their batch. */ quarantined?: number; /** A4: transcript pages kept local (source not registered, or a remote brain for unattributed pages). */ kept_local?: number; /** The pass returned before changing anything; the state file is not rewritten. */ state_untouched?: boolean; /** Sessions a scoped consent kept out, and transcript pages selected. */ scope?: TranscriptScopeCounts; /** * D6: when set, indicates a process-level failure (gbrain CLI missing * or `gbrain import` crashed). Per-file errors (FILE_TOO_LARGE etc.) * land in `failed` but do NOT set this flag — the orchestrator should * still treat the run as OK with summary mentioning the failure count. * Only when this is set does the verdict become ERR. */ system_error?: string; } // ── Constants ────────────────────────────────────────────────────────────── const HOME = homedir(); const GSTACK_HOME = resolveStateRoot(); const STATE_PATH = join(GSTACK_HOME, ".transcript-ingest-state.json"); const LOCK_PATH = `${STATE_PATH}.lock`; /** Machine-local, never-federated source for --include-unattributed transcripts (A4). */ const UNATTRIBUTED_SOURCE_ID = "gstack-transcripts-unattributed"; const DEFAULT_INCREMENTAL_BUDGET_MS = 50; const ALL_TYPES: MemoryType[] = [ "transcript", "eureka", "learning", "timeline", "ceo-plan", "design-doc", "retro", "builder-profile-entry", ]; // ── Transcript consent ───────────────────────────────────────────────────── export type { TranscriptConsent } from "../lib/transcript-consent"; let cachedConsent: TranscriptPolicy | null = null; /** * Read transcript consent once per process across every merged state root * (lib/transcript-consent.ts). `recent`, `all` and `new@