mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 08:53:22 +00:00
Adversarial review: survived a hostile reviewer plus two independent refuters, each told to assume the PR was broken and to default to refuting when uncertain. 19 of 62 PRs cleared that bar. The docs and recipe pinned `X_BEARER_TOKEN` while the resolver only ever read `X_API_BEARER_TOKEN` — so no single name worked and the integration could not be configured by following its own documentation. Renamed the dead documented side; reverting fails exactly 2 of the 3 new tests. Verified before merge: the PR's own tests fail when the production change is reverted; typecheck clean; MERGEABLE/CLEAN on the current base after batches 1 and 2 landed, not a stale one. Known gap, recorded rather than hidden: no live X API call was made.
306 lines
10 KiB
TypeScript
306 lines
10 KiB
TypeScript
/**
|
|
* gbrain features — Scan brain usage and recommend unused features.
|
|
*
|
|
* Usage:
|
|
* gbrain features [--json] [--auto-fix] [--help]
|
|
*/
|
|
|
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
import { join } from 'path';
|
|
import type { BrainEngine } from '../core/engine.ts';
|
|
import { VERSION } from '../version.ts';
|
|
|
|
// --- Types ---
|
|
|
|
type FeaturePriority = 1 | 2;
|
|
|
|
interface FeatureRecommendation {
|
|
id: string;
|
|
priority: FeaturePriority;
|
|
title: string;
|
|
pitch: string;
|
|
command: string;
|
|
auto_fixable: boolean;
|
|
}
|
|
|
|
interface FeatureOffersFile {
|
|
lastVersion: string;
|
|
lastScan: string;
|
|
declined: Record<string, { at: string; version: string }>;
|
|
accepted: Record<string, { at: string; version: string }>;
|
|
}
|
|
|
|
interface FeatureScanResult {
|
|
version: string;
|
|
scan_ts: string;
|
|
brain_score: number;
|
|
recommendations: FeatureRecommendation[];
|
|
}
|
|
|
|
// --- Embedded recipe metadata (binary-safe, no disk reads) ---
|
|
|
|
const RECIPE_META = [
|
|
{ id: 'email-to-brain', name: 'Email to Brain', secrets: ['GMAIL_APP_PASSWORD'] },
|
|
{ id: 'calendar-to-brain', name: 'Calendar Sync', secrets: ['GOOGLE_CALENDAR_API_KEY'] },
|
|
{ id: 'x-to-brain', name: 'X/Twitter to Brain', secrets: ['X_API_BEARER_TOKEN'] },
|
|
{ id: 'twilio-voice-brain', name: 'Voice to Brain', secrets: ['TWILIO_AUTH_TOKEN'] },
|
|
{ id: 'meeting-sync', name: 'Meeting Sync', secrets: ['CIRCLEBACK_API_KEY'] },
|
|
{ id: 'credential-gateway', name: 'Credential Gateway', secrets: ['OAUTH_CLIENT_SECRET'] },
|
|
{ id: 'ngrok-tunnel', name: 'Ngrok Tunnel', secrets: ['NGROK_AUTHTOKEN'] },
|
|
] as const;
|
|
|
|
// --- Persistence ---
|
|
|
|
function offersPath(): string {
|
|
return join(process.env.HOME || '', '.gbrain', 'feature-offers.json');
|
|
}
|
|
|
|
function loadOffers(): FeatureOffersFile {
|
|
try {
|
|
const raw = readFileSync(offersPath(), 'utf-8');
|
|
return JSON.parse(raw);
|
|
} catch {
|
|
return { lastVersion: '', lastScan: '', declined: {}, accepted: {} };
|
|
}
|
|
}
|
|
|
|
function saveOffers(offers: FeatureOffersFile) {
|
|
try {
|
|
const dir = join(process.env.HOME || '', '.gbrain');
|
|
mkdirSync(dir, { recursive: true });
|
|
writeFileSync(offersPath(), JSON.stringify(offers, null, 2));
|
|
} catch { /* best-effort */ }
|
|
}
|
|
|
|
function shouldPitch(rec: FeatureRecommendation, offers: FeatureOffersFile, currentVersion: string): boolean {
|
|
if (rec.priority === 1) return true; // always pitch data quality
|
|
const majorMinor = currentVersion.split('.').slice(0, 2).join('.');
|
|
const declined = offers.declined[rec.id];
|
|
if (declined && declined.version.startsWith(majorMinor)) return false;
|
|
return true;
|
|
}
|
|
|
|
// --- Scanners ---
|
|
|
|
async function scanFeatures(engine: BrainEngine): Promise<FeatureScanResult> {
|
|
const stats = await engine.getStats();
|
|
const health = await engine.getHealth();
|
|
const recommendations: FeatureRecommendation[] = [];
|
|
|
|
// P1: Missing embeddings
|
|
if (health.missing_embeddings > 0) {
|
|
recommendations.push({
|
|
id: 'missing-embeddings', priority: 1,
|
|
title: 'Fix Missing Embeddings',
|
|
pitch: `${health.missing_embeddings} chunks invisible to semantic search. One command fixes it.`,
|
|
command: 'gbrain embed --stale',
|
|
auto_fixable: true,
|
|
});
|
|
}
|
|
|
|
// P1: Dead links
|
|
if (health.dead_links > 0) {
|
|
recommendations.push({
|
|
id: 'dead-links', priority: 1,
|
|
title: 'Fix Dead Links',
|
|
pitch: `${health.dead_links} links pointing to non-existent pages.`,
|
|
command: 'gbrain check-backlinks fix',
|
|
auto_fixable: false,
|
|
});
|
|
}
|
|
|
|
// P2: skip if brain too new
|
|
if (stats.page_count >= 3) {
|
|
// Zero links
|
|
if (stats.link_count === 0 && stats.page_count > 5) {
|
|
recommendations.push({
|
|
id: 'zero-links', priority: 2,
|
|
title: 'Build Link Graph',
|
|
pitch: `${stats.page_count} pages but 0 links. Your brain is a flat file cabinet, not a knowledge graph.`,
|
|
command: 'gbrain extract links',
|
|
auto_fixable: true,
|
|
});
|
|
}
|
|
|
|
// Zero timeline
|
|
if (stats.timeline_entry_count === 0 && stats.page_count > 5) {
|
|
recommendations.push({
|
|
id: 'zero-timeline', priority: 2,
|
|
title: 'Extract Timeline',
|
|
pitch: `No structured timeline entries. Your brain can't answer "when did X happen?"`,
|
|
command: 'gbrain extract timeline',
|
|
auto_fixable: true,
|
|
});
|
|
}
|
|
|
|
// Low embed coverage
|
|
if (health.embed_coverage < 0.9 && health.embed_coverage > 0) {
|
|
const pct = (health.embed_coverage * 100).toFixed(0);
|
|
recommendations.push({
|
|
id: 'low-coverage', priority: 2,
|
|
title: 'Improve Embedding Coverage',
|
|
pitch: `${pct}% embed coverage. ${health.missing_embeddings} chunks invisible to semantic search.`,
|
|
command: 'gbrain embed --stale',
|
|
auto_fixable: true,
|
|
});
|
|
}
|
|
|
|
// Unconfigured integrations
|
|
const unconfigured = RECIPE_META.filter(r =>
|
|
!r.secrets.every(s => process.env[s])
|
|
);
|
|
if (unconfigured.length > 0) {
|
|
recommendations.push({
|
|
id: 'no-integrations', priority: 2,
|
|
title: 'Set Up Integrations',
|
|
pitch: `${unconfigured.length} integration recipes available but not configured: ${unconfigured.map(r => r.name).join(', ')}.`,
|
|
command: `gbrain integrations list`,
|
|
auto_fixable: false,
|
|
});
|
|
}
|
|
|
|
// No sync configured
|
|
try {
|
|
const syncRepo = await engine.getConfig('sync.repo_path');
|
|
if (!syncRepo) {
|
|
recommendations.push({
|
|
id: 'no-sync', priority: 2,
|
|
title: 'Configure Sync',
|
|
pitch: `Brain not syncing from git. Changes in your repo don't reach your brain.`,
|
|
command: 'gbrain sync --repo <path>',
|
|
auto_fixable: false,
|
|
});
|
|
}
|
|
} catch { /* skip */ }
|
|
}
|
|
|
|
return {
|
|
version: VERSION,
|
|
scan_ts: new Date().toISOString(),
|
|
brain_score: (health as any).brain_score ?? 0,
|
|
recommendations,
|
|
};
|
|
}
|
|
|
|
// --- Auto-fix ---
|
|
|
|
async function executeAutoFix(rec: FeatureRecommendation, engine: BrainEngine): Promise<{ success: boolean; output: string }> {
|
|
try {
|
|
switch (rec.id) {
|
|
case 'missing-embeddings':
|
|
case 'low-coverage': {
|
|
const { runEmbed } = await import('./embed.ts');
|
|
await runEmbed(engine, ['--stale']);
|
|
return { success: true, output: 'Stale embeddings refreshed' };
|
|
}
|
|
case 'zero-links': {
|
|
const { runExtract } = await import('./extract.ts');
|
|
await runExtract(engine, ['links']);
|
|
return { success: true, output: 'Links extracted' };
|
|
}
|
|
case 'zero-timeline': {
|
|
const { runExtract } = await import('./extract.ts');
|
|
await runExtract(engine, ['timeline']);
|
|
return { success: true, output: 'Timeline entries extracted' };
|
|
}
|
|
default:
|
|
return { success: false, output: 'No auto-fix available' };
|
|
}
|
|
} catch (e) {
|
|
return { success: false, output: e instanceof Error ? e.message : String(e) };
|
|
}
|
|
}
|
|
|
|
// --- Main command ---
|
|
|
|
export async function runFeatures(engine: BrainEngine, args: string[]) {
|
|
if (args.includes('--help') || args.includes('-h')) {
|
|
console.log('Usage: gbrain features [--json] [--auto-fix]\n\nScan brain usage and recommend unused features.\n\n --json Output as JSON (for agents)\n --auto-fix Automatically fix all auto-fixable issues');
|
|
return;
|
|
}
|
|
|
|
const jsonMode = args.includes('--json');
|
|
const autoFix = args.includes('--auto-fix');
|
|
|
|
const scan = await scanFeatures(engine);
|
|
const offers = loadOffers();
|
|
const pitchable = scan.recommendations.filter(r => shouldPitch(r, offers, scan.version));
|
|
|
|
if (pitchable.length === 0) {
|
|
if (jsonMode) {
|
|
console.log(JSON.stringify({ ...scan, recommendations: [] }, null, 2));
|
|
} else {
|
|
console.log(`\nBrain score: ${scan.brain_score}/100. All features adopted. Nothing to recommend.`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (jsonMode) {
|
|
const fixResults: Record<string, { success: boolean; output: string }> = {};
|
|
if (autoFix) {
|
|
for (const rec of pitchable.filter(r => r.auto_fixable)) {
|
|
fixResults[rec.id] = await executeAutoFix(rec, engine);
|
|
offers.accepted[rec.id] = { at: new Date().toISOString().slice(0, 10), version: scan.version };
|
|
}
|
|
}
|
|
console.log(JSON.stringify({ ...scan, recommendations: pitchable, auto_fix_results: autoFix ? fixResults : undefined }, null, 2));
|
|
offers.lastVersion = scan.version;
|
|
offers.lastScan = scan.scan_ts;
|
|
saveOffers(offers);
|
|
return;
|
|
}
|
|
|
|
// Human-readable output
|
|
console.log(`\nBrain score: ${scan.brain_score}/100\n`);
|
|
|
|
const p1 = pitchable.filter(r => r.priority === 1);
|
|
const p2 = pitchable.filter(r => r.priority === 2);
|
|
|
|
if (p1.length > 0) {
|
|
console.log('DATA QUALITY (fix these first):');
|
|
for (const rec of p1) {
|
|
console.log(` ${rec.title}: ${rec.pitch}`);
|
|
console.log(` Fix: ${rec.command}`);
|
|
}
|
|
console.log('');
|
|
}
|
|
|
|
if (p2.length > 0) {
|
|
console.log('UNUSED FEATURES:');
|
|
for (const rec of p2) {
|
|
console.log(` ${rec.title}: ${rec.pitch}`);
|
|
console.log(` Try: ${rec.command}`);
|
|
}
|
|
console.log('');
|
|
}
|
|
|
|
if (autoFix) {
|
|
console.log('Running auto-fix...');
|
|
for (const rec of pitchable.filter(r => r.auto_fixable)) {
|
|
const result = await executeAutoFix(rec, engine);
|
|
console.log(` ${result.success ? 'OK' : 'FAIL'}: ${rec.title} — ${result.output}`);
|
|
offers.accepted[rec.id] = { at: new Date().toISOString().slice(0, 10), version: scan.version };
|
|
}
|
|
} else if (process.stdin.isTTY) {
|
|
console.log(`Run 'gbrain features --auto-fix' to fix all auto-fixable issues.`);
|
|
}
|
|
|
|
offers.lastVersion = scan.version;
|
|
offers.lastScan = scan.scan_ts;
|
|
saveOffers(offers);
|
|
}
|
|
|
|
/** Lightweight features teaser for doctor output */
|
|
export async function featuresTeaserForDoctor(engine: BrainEngine): Promise<string | null> {
|
|
try {
|
|
const health = await engine.getHealth();
|
|
const parts: string[] = [];
|
|
if (health.missing_embeddings > 0) parts.push(`${health.missing_embeddings} missing embeddings`);
|
|
if (health.dead_links > 0) parts.push(`${health.dead_links} dead links`);
|
|
if (parts.length === 0) return null;
|
|
return `Tip: ${parts.join(', ')}. Run 'gbrain features' to fix.`;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|