#!/usr/bin/env bun /** * gstack-brain-cache — three-tier cache for brain-aware planning skills. * * Subcommands: * get [--project ] — return digest content; refresh if stale * refresh [--full] [--entity X] [--project ] — force refresh one or all * invalidate [--project ] — mark stale; next get triggers cold * digest — compress a brain page slug to digest * meta [--project ] — print _meta.json * * (Later commits add: bootstrap [T2b], list [T18], purge [T18], retention sweep [T18].) * * Cache layout: * ~/.gstack/brain-cache/ ← cross-project (user-profile only) * ~/.gstack/projects//brain-cache/ ← per-project (everything else) * * Atomic writes via .tmp + rename. Stale-but-usable fallback when brain * unreachable. Concurrent-refresh dedup is a follow-up commit (T15). */ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, statSync, unlinkSync, readdirSync } from 'fs'; import { join, dirname } from 'path'; import { homedir } from 'os'; import { execGbrainJson, spawnGbrain } from '../lib/gbrain-exec'; import { BRAIN_CACHE_ENTITIES, GSTACK_SCHEMA_PACK_NAME, GSTACK_SCHEMA_PACK_VERSION, type BrainCacheEntity, } from '../scripts/brain-cache-spec'; // ────────────────────────────────────────────────────────────────────────── // Paths + meta // ────────────────────────────────────────────────────────────────────────── const GSTACK_HOME = process.env.GSTACK_HOME || join(homedir(), '.gstack'); interface CacheMeta { /** Version of the schema pack the cache was built against. Mismatch → full rebuild. */ schema_version: string; /** SHA8 hash of the brain MCP endpoint URL (or 'local' for on-disk engines). */ endpoint_hash: string; /** Per-entity last-refresh epoch ms. Absent → never refreshed. */ last_refresh: Record; /** Per-entity last-attempt epoch ms (even if attempt failed). For stale-but-usable diagnostics. */ last_attempt?: Record; } /** Returns the directory holding a given entity's cache file. */ export function entityDir(entity: BrainCacheEntity, projectSlug: string | null): string { if (entity.scope === 'cross-project') { return join(GSTACK_HOME, 'brain-cache'); } if (!projectSlug) { throw new Error(`Per-project entity needs a project slug: ${entity.file}`); } return join(GSTACK_HOME, 'projects', projectSlug, 'brain-cache'); } /** Returns the path to the cache file for a given entity. */ export function entityPath(entityName: string, projectSlug: string | null): string { const entity = BRAIN_CACHE_ENTITIES[entityName]; if (!entity) throw new Error(`Unknown brain cache entity: ${entityName}`); return join(entityDir(entity, projectSlug), entity.file); } /** Returns the path to the _meta.json for a given scope. */ export function metaPath(scope: 'cross-project' | 'per-project', projectSlug: string | null): string { if (scope === 'cross-project') { return join(GSTACK_HOME, 'brain-cache', '_meta.json'); } if (!projectSlug) throw new Error('Per-project meta needs a project slug'); return join(GSTACK_HOME, 'projects', projectSlug, 'brain-cache', '_meta.json'); } function loadMeta(scope: 'cross-project' | 'per-project', projectSlug: string | null): CacheMeta { const path = metaPath(scope, projectSlug); if (!existsSync(path)) { return { schema_version: GSTACK_SCHEMA_PACK_VERSION, endpoint_hash: detectEndpointHash(), last_refresh: {}, last_attempt: {} }; } try { return JSON.parse(readFileSync(path, 'utf-8')) as CacheMeta; } catch { // Corrupt _meta — start fresh (entries will refresh on next access). return { schema_version: GSTACK_SCHEMA_PACK_VERSION, endpoint_hash: detectEndpointHash(), last_refresh: {}, last_attempt: {} }; } } function saveMeta(scope: 'cross-project' | 'per-project', projectSlug: string | null, meta: CacheMeta): void { const path = metaPath(scope, projectSlug); mkdirSync(dirname(path), { recursive: true }); atomicWrite(path, JSON.stringify(meta, null, 2)); } // ────────────────────────────────────────────────────────────────────────── // Endpoint hash detection // ────────────────────────────────────────────────────────────────────────── import { createHash } from 'crypto'; function sha8(input: string): string { return createHash('sha256').update(input).digest('hex').slice(0, 8); } /** * Detects the active brain endpoint (MCP URL or 'local') and returns its * stable identity hash. Used to detect when the user switches brains * (different endpoint → different cache). */ export function detectEndpointHash(): string { const claudeJsonPath = join(homedir(), '.claude.json'); if (existsSync(claudeJsonPath)) { try { const cfg = JSON.parse(readFileSync(claudeJsonPath, 'utf-8')); const gbrainServer = cfg?.mcpServers?.gbrain; const url = gbrainServer?.url || gbrainServer?.transport?.url; if (typeof url === 'string' && url.length > 0) { return sha8(url); } } catch { /* fall through to local */ } } // Local engine — no endpoint URL; use a stable literal hash. return 'local'; } // ────────────────────────────────────────────────────────────────────────── // Atomic write (tmp + rename) // ────────────────────────────────────────────────────────────────────────── function atomicWrite(path: string, content: string): void { mkdirSync(dirname(path), { recursive: true }); const tmp = `${path}.tmp.${process.pid}.${Date.now()}`; writeFileSync(tmp, content, 'utf-8'); renameSync(tmp, path); } // ────────────────────────────────────────────────────────────────────────── // Staleness + refresh logic // ────────────────────────────────────────────────────────────────────────── /** Returns true if the cached digest is past its TTL. */ function isStale(entityName: string, meta: CacheMeta): boolean { const entity = BRAIN_CACHE_ENTITIES[entityName]; if (!entity) return true; const last = meta.last_refresh[entityName]; if (!last) return true; return Date.now() - last > entity.ttl_ms; } /** Returns true if the cache file exists on disk. */ function hasFile(entityName: string, projectSlug: string | null): boolean { return existsSync(entityPath(entityName, projectSlug)); } /** Returns true if schema version recorded in meta differs from current pack version. */ function schemaVersionMismatch(meta: CacheMeta): boolean { return meta.schema_version !== GSTACK_SCHEMA_PACK_VERSION; } /** Returns true if endpoint hash recorded in meta differs from current detected endpoint. */ function endpointSwitched(meta: CacheMeta): boolean { return meta.endpoint_hash !== detectEndpointHash(); } // ────────────────────────────────────────────────────────────────────────── // Subcommand: get // ────────────────────────────────────────────────────────────────────────── interface GetResult { /** Path to the digest file. */ path: string; /** Cache state: 'warm' (fresh + valid), 'cold-refreshed' (was stale, refreshed inline), 'stale-fallback' (used stale because refresh failed), 'missing' (no cache and no refresh). */ state: 'warm' | 'cold-refreshed' | 'stale-fallback' | 'missing'; /** Optional message for diagnostics. */ message?: string; } export function cmdGet(entityName: string, projectSlug: string | null): GetResult { const entity = BRAIN_CACHE_ENTITIES[entityName]; if (!entity) throw new Error(`Unknown entity: ${entityName}`); const scope = entity.scope; const meta = loadMeta(scope, projectSlug); // Schema-version mismatch → full rebuild (D4 A4). if (schemaVersionMismatch(meta) || endpointSwitched(meta)) { rebuildAllForScope(scope, projectSlug); // After rebuild, meta is fresh; fall through to warm path. const newMeta = loadMeta(scope, projectSlug); if (hasFile(entityName, projectSlug) && !isStale(entityName, newMeta)) { return { path: entityPath(entityName, projectSlug), state: 'warm' }; } // Rebuild may have failed for this entity specifically. return { path: entityPath(entityName, projectSlug), state: 'missing', message: 'rebuild after schema/endpoint change' }; } if (hasFile(entityName, projectSlug) && !isStale(entityName, meta)) { return { path: entityPath(entityName, projectSlug), state: 'warm' }; } // Stale or missing — try cold refresh. const refreshed = refreshEntity(entityName, projectSlug); if (refreshed) { return { path: entityPath(entityName, projectSlug), state: 'cold-refreshed' }; } // Refresh failed. Use stale-but-usable if file exists. if (hasFile(entityName, projectSlug)) { return { path: entityPath(entityName, projectSlug), state: 'stale-fallback', message: 'brain unreachable; using stale cache' }; } // No cache and no refresh = missing. return { path: entityPath(entityName, projectSlug), state: 'missing', message: 'brain unreachable; no cache available' }; } // ────────────────────────────────────────────────────────────────────────── // Subcommand: refresh // ────────────────────────────────────────────────────────────────────────── /** Refreshes one entity from the brain. Returns true on success. */ export function refreshEntity(entityName: string, projectSlug: string | null): boolean { const entity = BRAIN_CACHE_ENTITIES[entityName]; if (!entity) return false; // Mark attempt const meta = loadMeta(entity.scope, projectSlug); meta.last_attempt = meta.last_attempt || {}; meta.last_attempt[entityName] = Date.now(); // Fetch from brain. The actual fetch logic varies per entity — derived digests // (recent-decisions, salience) need different queries from direct page reads. // For T2a we implement the direct-page path; derived digests get filled in by // the resolver / write-back paths in later commits. const digestContent = fetchAndCompressEntity(entityName, projectSlug); if (digestContent === null) { saveMeta(entity.scope, projectSlug, meta); return false; } // Enforce per-entity budget by truncating from end (oldest items live there // by convention in our compressor). The per-skill budget is separately // enforced at preflight injection time. let final = digestContent; if (Buffer.byteLength(final, 'utf-8') > entity.budget_bytes) { final = truncateToBudget(final, entity.budget_bytes); } atomicWrite(entityPath(entityName, projectSlug), final); meta.last_refresh[entityName] = Date.now(); // Keep schema/endpoint identity fresh. meta.schema_version = GSTACK_SCHEMA_PACK_VERSION; meta.endpoint_hash = detectEndpointHash(); saveMeta(entity.scope, projectSlug, meta); return true; } /** * Refresh all entities for a scope (per-project or cross-project). * Used by --full and by schema/endpoint-change rebuilds. */ export function refreshAll(projectSlug: string | null): { success: number; failed: number } { let success = 0; let failed = 0; for (const [name, entity] of Object.entries(BRAIN_CACHE_ENTITIES)) { // Cross-project entities only refresh when explicitly targeted via no-slug calls if (entity.scope === 'cross-project' && projectSlug) continue; if (entity.scope === 'per-project' && !projectSlug) continue; if (refreshEntity(name, projectSlug)) success++; else failed++; } return { success, failed }; } /** Rebuild on schema-version mismatch or endpoint switch. Wipes affected scope first. */ function rebuildAllForScope(scope: 'cross-project' | 'per-project', projectSlug: string | null): void { // Wipe files but preserve dir; meta gets fully rewritten by refreshes below. for (const [name, entity] of Object.entries(BRAIN_CACHE_ENTITIES)) { if (entity.scope !== scope) continue; const p = entityPath(name, projectSlug); if (existsSync(p)) { try { unlinkSync(p); } catch { /* best effort */ } } } // Fresh meta starts here const fresh: CacheMeta = { schema_version: GSTACK_SCHEMA_PACK_VERSION, endpoint_hash: detectEndpointHash(), last_refresh: {}, last_attempt: {}, }; saveMeta(scope, projectSlug, fresh); // Refresh all entities in this scope for (const [name, entity] of Object.entries(BRAIN_CACHE_ENTITIES)) { if (entity.scope !== scope) continue; refreshEntity(name, projectSlug); } } // ────────────────────────────────────────────────────────────────────────── // Subcommand: invalidate // ────────────────────────────────────────────────────────────────────────── export function cmdInvalidate(entityName: string, projectSlug: string | null): void { const entity = BRAIN_CACHE_ENTITIES[entityName]; if (!entity) throw new Error(`Unknown entity: ${entityName}`); const meta = loadMeta(entity.scope, projectSlug); delete meta.last_refresh[entityName]; saveMeta(entity.scope, projectSlug, meta); } // ────────────────────────────────────────────────────────────────────────── // Fetch + compress per-entity // ────────────────────────────────────────────────────────────────────────── /** * Returns the digest markdown content for an entity, or null if the brain is * unreachable / the source page doesn't exist. * * For T2a we implement the entity → page-slug mapping for the simple cases. * Derived digests (recent-decisions, salience) get specialized paths. */ function fetchAndCompressEntity(entityName: string, projectSlug: string | null): string | null { switch (entityName) { case 'user-profile': return fetchUserProfile(); case 'product': return fetchProduct(projectSlug); case 'goals': return fetchGoals(projectSlug); case 'developer-persona': return fetchSimplePage(`gstack/developer-persona/${projectSlug}`); case 'brand': return fetchSimplePage(`gstack/brand/${projectSlug}`); case 'competitive-intel': return fetchSimplePage(`gstack/competitive-intel/${projectSlug}`); case 'recent-decisions': return fetchRecentDecisions(projectSlug); case 'salience': // D9 salience allowlist applied in T17 commit; T2a returns raw output for now. return fetchSalience(projectSlug); default: return null; } } /** Generic single-page fetch via `gbrain get`. Returns null on miss/unreachable. */ function fetchSimplePage(slug: string): string | null { const result = spawnGbrain(['get', slug, '--json'], { timeout: 10_000 }); if (result.status !== 0) return null; try { const page = JSON.parse(result.stdout) as { body?: string; title?: string }; if (!page?.body) return null; return compressPage(slug, page.title || slug, page.body); } catch { return null; } } function fetchUserProfile(): string | null { // The user-slug discovery is implemented in T16 (D4 A3). For T2a we accept // env GSTACK_USER_SLUG as override, fallback to $USER for direct calls. const slug = process.env.GSTACK_USER_SLUG || process.env.USER || 'unknown'; return fetchSimplePage(`gstack/user-profile/${slug}`); } function fetchProduct(projectSlug: string | null): string | null { if (!projectSlug) return null; return fetchSimplePage(`gstack/product/${projectSlug}`); } /** * Goals are LIST queries: all gstack/goal//* pages. * Compress the top N by recency. */ function fetchGoals(projectSlug: string | null): string | null { if (!projectSlug) return null; const result = execGbrainJson<{ pages?: Array<{ slug: string; title?: string; body?: string }> }>([ 'list-pages', '--type', 'gstack/goal', '--limit', '10', '--json', ]); if (!result?.pages) return null; const goals = result.pages.filter((p) => p.slug?.startsWith(`gstack/goal/${projectSlug}/`)); if (goals.length === 0) { // Empty digest is valid (just header + 'no active goals' line) return `# Active goals (project: ${projectSlug})\n\n_No active goals recorded yet._\n`; } const lines = goals.map((g) => `- [[${g.slug}]] — ${g.title || '(untitled)'}`); return `# Active goals (project: ${projectSlug})\n\n${lines.join('\n')}\n`; } /** * recent-decisions: last 5 gstack/skill-run pages for this project, compressed * to one-line summaries. */ function fetchRecentDecisions(projectSlug: string | null): string | null { if (!projectSlug) return null; const result = execGbrainJson<{ pages?: Array<{ slug: string; title?: string }> }>([ 'list-pages', '--type', 'gstack/skill-run', '--limit', '5', '--sort', 'updated_desc', '--json', ]); if (!result?.pages) { return `# Recent decisions (project: ${projectSlug})\n\n_No prior skill runs recorded._\n`; } const lines = result.pages.map((p) => `- ${p.title || p.slug}`); return `# Recent decisions (project: ${projectSlug})\n\n${lines.join('\n')}\n`; } function fetchSalience(projectSlug: string | null): string | null { // get-recent-salience is a gbrain CLI sub-shape; we use the MCP-shape JSON const result = execGbrainJson<{ pages?: Array<{ slug: string; title?: string; emotional_weight?: number }> }>([ 'get-recent-salience', '--days', '14', '--limit', '10', '--json', ]); if (!result?.pages) return `# Recent salience\n\n_No salient pages in last 14d._\n`; // T17 will add allowlist filtering here. For T2a we return raw output. const lines = result.pages.map((p) => `- [[${p.slug}]] — ${p.title || ''} (weight: ${p.emotional_weight?.toFixed(2) ?? 'n/a'})`); return `# Recent salience (last 14d)\n\n${lines.join('\n')}\n`; } /** * Compress a brain page body into a digest. The compressor keeps frontmatter * out, trims body to the first H2/H3 sections, and prepends a slug header. * Per-entity budget enforcement happens at the caller (refreshEntity). */ function compressPage(slug: string, title: string, body: string): string { const trimmed = body .replace(/^---[\s\S]*?---\s*\n/m, '') // strip frontmatter .trim(); return `# ${title}\nslug: ${slug}\n\n${trimmed}\n`; } /** * Truncate a digest to a byte budget. Tries to cut at the last newline before * the budget so the digest stays readable. */ function truncateToBudget(content: string, budgetBytes: number): string { const buf = Buffer.from(content, 'utf-8'); if (buf.byteLength <= budgetBytes) return content; const truncated = buf.slice(0, budgetBytes).toString('utf-8'); const lastNewline = truncated.lastIndexOf('\n'); const cleanCut = lastNewline > budgetBytes * 0.8 ? truncated.slice(0, lastNewline) : truncated; return `${cleanCut}\n\n_(digest truncated to ${budgetBytes}-byte budget)_\n`; } // ────────────────────────────────────────────────────────────────────────── // Subcommand: digest // ────────────────────────────────────────────────────────────────────────── /** * Public: compress a brain page slug to digest format. Used by callers that * want to know what the digest WOULD look like without writing to cache. */ export function cmdDigest(slug: string): string | null { return fetchSimplePage(slug); } // ────────────────────────────────────────────────────────────────────────── // Subcommand: meta // ────────────────────────────────────────────────────────────────────────── export function cmdMeta(projectSlug: string | null): CacheMeta { if (projectSlug) return loadMeta('per-project', projectSlug); return loadMeta('cross-project', null); } // ────────────────────────────────────────────────────────────────────────── // CLI dispatch // ────────────────────────────────────────────────────────────────────────── function parseArgs(argv: string[]): { cmd: string; positional: string[]; flags: Record } { const cmd = argv[2] || ''; const rest = argv.slice(3); const positional: string[] = []; const flags: Record = {}; for (let i = 0; i < rest.length; i++) { const arg = rest[i]; if (arg.startsWith('--')) { const key = arg.slice(2); const next = rest[i + 1]; if (next && !next.startsWith('--')) { flags[key] = next; i++; } else { flags[key] = true; } } else { positional.push(arg); } } return { cmd, positional, flags }; } function projectSlugFromFlag(flags: Record): string | null { const v = flags.project; return typeof v === 'string' ? v : null; } function printUsage(): void { process.stderr.write(`Usage: gstack-brain-cache Subcommands: get [--project ] refresh [--full] [--entity X] [--project ] invalidate [--project ] digest meta [--project ] `); } async function main(): Promise { const { cmd, positional, flags } = parseArgs(process.argv); const projectSlug = projectSlugFromFlag(flags); try { switch (cmd) { case 'get': { const entityName = positional[0]; if (!entityName) { printUsage(); return 1; } const result = cmdGet(entityName, projectSlug); if (result.state === 'missing') { process.stderr.write(`(${result.state}: ${result.message ?? 'no cache'})\n`); return 2; } if (result.state !== 'warm') { process.stderr.write(`(${result.state}${result.message ? ': ' + result.message : ''})\n`); } process.stdout.write(readFileSync(result.path, 'utf-8')); return 0; } case 'refresh': { if (flags.entity) { const ok = refreshEntity(String(flags.entity), projectSlug); process.stdout.write(ok ? `refreshed ${flags.entity}\n` : `failed to refresh ${flags.entity}\n`); return ok ? 0 : 1; } const { success, failed } = refreshAll(projectSlug); process.stdout.write(`refreshed=${success} failed=${failed}\n`); return failed > 0 ? 1 : 0; } case 'invalidate': { const entityName = positional[0]; if (!entityName) { printUsage(); return 1; } cmdInvalidate(entityName, projectSlug); process.stdout.write(`invalidated ${entityName}\n`); return 0; } case 'digest': { const slug = positional[0]; if (!slug) { printUsage(); return 1; } const content = cmdDigest(slug); if (content === null) { process.stderr.write('brain unreachable or page not found\n'); return 2; } process.stdout.write(content); return 0; } case 'meta': { const meta = cmdMeta(projectSlug); process.stdout.write(JSON.stringify(meta, null, 2) + '\n'); return 0; } case '': case 'help': case '--help': case '-h': printUsage(); return 0; default: process.stderr.write(`unknown subcommand: ${cmd}\n`); printUsage(); return 1; } } catch (err) { process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}\n`); return 1; } } // Only run main when invoked as a script (not when imported by tests) if (import.meta.main) { main().then((code) => process.exit(code)); }