feat: GStackBrain — 16 new skills, resolver, conventions, identity layer (v0.10.0) (#120)

* feat: migrate 8 existing skills to conformance format

Add YAML frontmatter (name, version, description, triggers, tools, mutating),
Contract, Anti-Patterns, and Output Format sections to all existing skills.
Rename Workflow to Phases. Ingest becomes thin router delegating to specialized
ingestion skills (Phase 2).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add RESOLVER.md, conventions directory, and output rules

RESOLVER.md is the skill dispatcher modeled on Wintermute's AGENTS.md.
Categorized routing table: Always-on, Brain ops, Ingestion, Thinking,
Operational, Setup, Identity. Conventions directory extracts cross-cutting
rules (quality, brain-first lookup, model routing, test-before-bulk).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: add skills conformance and resolver validation tests

skills-conformance.test.ts validates every skill has YAML frontmatter with
required fields, Contract, Anti-Patterns, and Output Format sections, and
manifest.json coverage. resolver.test.ts validates routing table categories,
skill path existence, and manifest-to-resolver coverage. 50 new tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add 9 brain skills from Wintermute (Phase 2)

Generalized from Wintermute's battle-tested skills:
- signal-detector: always-on idea+entity capture on every message
- brain-ops: brain-first lookup, read-enrich-write loop, source attribution
- idea-ingest: links/articles/tweets with author people page mandatory
- media-ingest: video/audio/PDF/book with entity extraction (absorbs video/youtube/book)
- meeting-ingestion: transcripts with attendee enrichment chaining
- citation-fixer: audit and fix citation formatting
- repo-architecture: filing rules by primary subject
- skill-creator: create skills with conformance standard + MECE check
- daily-task-manager: task lifecycle with priority levels

All Garry-specific references generalized. Core workflows preserved.
Updated RESOLVER.md and manifest.json.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add operational infrastructure + identity layer (Phase 3)

Operational skills:
- daily-task-prep: morning prep with calendar context and open threads
- cross-modal-review: quality gate via second model with refusal routing
- cron-scheduler: schedule staggering, quiet hours, wake-up override, idempotency
- reports: timestamped reports with keyword routing
- testing: skill validation framework (conformance checks)
- soul-audit: 6-phase interview generating SOUL.md, USER.md, ACCESS_POLICY.md, HEARTBEAT.md
- webhook-transforms: external events to brain signals with dead-letter queue

Identity layer:
- SOUL.md template (agent identity, generated by soul-audit)
- USER.md template (user profile, generated by soul-audit)
- ACCESS_POLICY.md template (4-tier access control)
- HEARTBEAT.md template (operational cadence)
- cross-modal.yaml convention (review pairs, refusal routing chain)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: update CLAUDE.md with 24 skills, RESOLVER.md, conventions, templates

GBrain is now a GStack mod for agent platforms. Updated architecture description,
key files listing (16 new skill files, RESOLVER.md, conventions, templates), skills
section (24 skills organized by resolver categories), and testing section (new
conformance and resolver tests).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add GStack detection + mod status to gbrain init (Phase 4)

After brain initialization, gbrain init now reports:
- Number of skills loaded (from manifest.json)
- GStack detection (checks known host paths, uses gstack-global-discover if available)
- GStack install instructions if not found
- Resolver and soul-audit pointers

Also adds installDefaultTemplates() for SOUL.md/USER.md/ACCESS_POLICY.md/HEARTBEAT.md
deployment, and detectGStack() using gstack-global-discover with fallback to known paths
(DRY: doesn't reimplement GStack's host detection logic).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: v0.10.0 release documentation

- CHANGELOG: 24 skills, signal detector, RESOLVER.md, soul-audit, access control,
  conventions, conformance standard, GStack detection in init
- README: updated skill section with 24 skills, resolver, conventions
- TODOS: added runtime MCP access control (P1)
- VERSION: 0.9.2 → 0.10.0
- package.json + manifest.json version bumped

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: add skill table to CHANGELOG v0.10.0

16-row table detailing every new skill, what it does, and why it matters.
Written to sell the upgrade, not document the implementation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: restore package.json version after merge conflict resolution

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: zero-based README rewrite for GStackBrain v0.10.0

Lead with GStack mod identity. 24 skills table organized by category.
Install block references RESOLVER.md and soul-audit. GBrain+GStack
relationship explained. Removed redundancy (733 -> 406 lines).
All essential content preserved: install, recipes, architecture,
search, commands, engines, voice, knowledge model.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: extract install block to INSTALL_FOR_AGENTS.md, simplify README

The 30-line copy-paste install block becomes one line:
"Retrieve and follow INSTALL_FOR_AGENTS.md"

Benefits: agent always gets latest instructions (no stale copy-paste),
README stays clean, install details live where agents read them.

README now leads with what GBrain does ("gives your agent a brain")
instead of GStack relationship. Removed "requires frontier model" note.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: 3 bugs in init.ts from merge conflict resolution

1. llstatSync typo (merge corruption) → lstatSync
2. __dirname undefined in ESM module → fileURLToPath polyfill
3. require('fs') in ESM → use imported readFileSync

All three would crash gbrain init at runtime. Caught by /review.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add checkResolvable shared core function for resolver validation

Shared function at src/core/check-resolvable.ts validates that all skills
are reachable from RESOLVER.md, detects MECE overlaps (with whitelist for
always-on/router skills), finds gaps in frontmatter triggers, and scans
for DRY violations. Returns structured ResolvableIssue objects with
machine-parseable fix objects alongside human-readable action strings.

Three call sites: bun test, gbrain doctor, skill-creator skill.

Cleans up test/resolver.test.ts: removes stale 9-line skip list, imports
from production check-resolvable.ts instead of reimplementing parsing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: expand doctor with resolver validation, filesystem-first architecture

Doctor now runs filesystem checks (resolver health, skill conformance) before
connecting to DB. New --fast flag skips DB checks. Falls back to filesystem-only
when DB is unavailable. Adds schema_version: 2 to JSON output, composite health
score (0-100), and structured issues array with action strings for agent parsing.

Resolver health check calls checkResolvable() and surfaces actionable fix
instructions. Link integrity check uses engine.getHealth() dead_links count.

CLI routing split: doctor dispatched before connectEngine() so filesystem
checks always run. Fixes Codex-identified blocker where doctor required DB.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add adaptive load-aware throttling and fail-improve loop

backoff.ts: System load checking (CPU via os.loadavg, memory via os.freemem),
exponential backoff with 20-attempt max guard, active hours multiplier (2x
slower during waking hours), concurrent process limit (max 2). Windows-safe:
defaults to "proceed" when os.loadavg returns zeros.

fail-improve.ts: Deterministic-first, LLM-fallback pattern with JSONL failure
logging. Cascade failure handling: when both paths fail, throws LLM error and
logs both. Log rotation at 1000 entries. Call count tracking for deterministic
hit rate metrics. Auto-generates test cases from successful LLM fallbacks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add transcription service and enrichment-as-a-service

transcription.ts: Groq Whisper (default) with OpenAI fallback. Files >25MB
segmented via ffmpeg. Provider auto-detection from env vars. Clear error
messages for missing API keys and unsupported formats.

enrichment-service.ts: Global enrichment service callable from any ingest
pathway. Entity slug generation (people/jane-doe, companies/acme-corp),
mention counting via searchKeyword, tier auto-escalation (Tier 3→2→1 based
on mention frequency and source diversity), batch enrichment with backoff
throttling, regex-based entity extraction from text.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add data-research skill with recipe system, extraction, dedup, tracker

New skill: data-research — one parameterized pipeline for any email-to-
structured-data workflow (investor updates, donations, company metrics).
7-phase pipeline: define recipe, search, classify, extract (with extraction
integrity rule), archive, deduplicate, update tracker.

data-research.ts: Recipe validation, MRR/ARR/runway/headcount regex
extraction (battle-tested patterns), dedup with configurable tolerance,
markdown tracker parsing/appending, quarterly/monthly date windowing,
6-phase HTML email stripping with 500KB ReDoS cap.

Registers data-research in manifest.json (25th skill) and RESOLVER.md.
Fixes backoff test robustness for high-load systems.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: update project documentation for v0.10.0 infrastructure additions

CLAUDE.md: added 6 new core files (check-resolvable, backoff, fail-improve,
transcription, enrichment-service, data-research), 6 new test files, updated
skill count to 25, test file count to 34.

README.md: updated skill count to 25, added data-research to skills table.

CHANGELOG.md: added Infrastructure section documenting resolver validation,
doctor expansion, adaptive throttling, fail-improve loop, voice transcription,
enrichment service, and data-research skill.

TODOS.md: anonymized personal references.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: doctor.ts use ES module imports, harden backoff test

Replace require('fs') with ES module import in doctor.ts for consistency
with the rest of the file. Backoff test made resilient to parallel test
execution leaking module-level state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: README rewrite with production brain stats, sample output, new infrastructure

Lead with the flex: 17,888 pages, 4,383 people, 723 companies, 526 meeting
transcripts built in 12 days. Show sample query output so readers see what
they'll get. Document self-improving infrastructure (tier auto-escalation,
fail-improve loop, doctor trajectory). Add data-research recipes to Getting
Data In. Update commands section with doctor --fix, transcribe, research
init/list. Fix stale "24" references to "25".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: README lead with YC President origin and production agent deployments

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: README lead with skill philosophy and link to Thin Harness Fat Skills

Skills section now explains: skill files are code, they encode entire
workflows, they call deterministic TypeScript for the parts that shouldn't
be LLM judgment. Links to the tweet and the architecture essay.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: link GStack repo, add 70K stars and 30K daily users

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: remove meeting transcript count from README (sensitive)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: README lead with YC President origin and production agent deployments

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: rename political-donations recipe to expense-tracker (sensitivity)

Renamed the built-in data-research recipe from political-donations to
expense-tracker across README, CHANGELOG, SKILL.md, and reports routing.
Same extraction patterns (amounts, dates, recipients), neutral framing.
Also renamed social-radar keyword route to social-mentions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-04-14 19:41:34 -10:00
committed by GitHub
parent d547a64600
commit e5a9f0126a
62 changed files with 5980 additions and 658 deletions

View File

@@ -278,6 +278,24 @@ async function handleCliOnly(command: string, args: string[]) {
await runReport(args);
return;
}
if (command === 'doctor') {
// Doctor runs filesystem checks first (no DB needed), then DB checks.
// --fast skips DB checks entirely.
const { runDoctor } = await import('./commands/doctor.ts');
if (args.includes('--fast')) {
await runDoctor(null, args);
} else {
try {
const eng = await connectEngine();
await runDoctor(eng, args);
await eng.disconnect();
} catch {
// DB unavailable — still run filesystem checks
await runDoctor(null, args);
}
}
return;
}
// All remaining CLI-only commands need a DB connection
const engine = await connectEngine();
@@ -318,11 +336,7 @@ async function handleCliOnly(command: string, args: string[]) {
await runConfig(engine, args);
break;
}
case 'doctor': {
const { runDoctor } = await import('./commands/doctor.ts');
await runDoctor(engine, args);
break;
}
// doctor is handled before connectEngine() above
case 'migrate': {
const { runMigrateEngine } = await import('./commands/migrate-engine.ts');
await runMigrateEngine(engine, args);
@@ -383,7 +397,7 @@ SETUP
migrate --to <supabase|pglite> Transfer brain between engines
upgrade Self-update
check-update [--json] Check for new versions
doctor [--json] Health check (pgvector, RLS, schema, embeddings)
doctor [--json] [--fast] Health check (resolver, skills, pgvector, RLS, embeddings)
integrations [subcommand] Manage integration recipes (senses + reflexes)
PAGES

View File

@@ -1,18 +1,79 @@
import type { BrainEngine } from '../core/engine.ts';
import * as db from '../core/db.ts';
import { LATEST_VERSION } from '../core/migrate.ts';
import { checkResolvable } from '../core/check-resolvable.ts';
import { join } from 'path';
import { existsSync, readFileSync, readdirSync } from 'fs';
interface Check {
export interface Check {
name: string;
status: 'ok' | 'warn' | 'fail';
message: string;
issues?: Array<{ type: string; skill: string; action: string; fix?: any }>;
}
export async function runDoctor(engine: BrainEngine, args: string[]) {
/**
* Run doctor with filesystem-first, DB-second architecture.
* Filesystem checks (resolver, conformance) run without engine.
* DB checks run only if engine is provided.
*/
export async function runDoctor(engine: BrainEngine | null, args: string[]) {
const jsonOutput = args.includes('--json');
const fastMode = args.includes('--fast');
const checks: Check[] = [];
// 1. Connection
// --- Filesystem checks (always run, no DB needed) ---
// 1. Resolver health
const repoRoot = findRepoRoot();
if (repoRoot) {
const skillsDir = join(repoRoot, 'skills');
const report = checkResolvable(skillsDir);
if (report.ok && report.issues.length === 0) {
checks.push({
name: 'resolver_health',
status: 'ok',
message: `${report.summary.total_skills} skills, all reachable`,
});
} else {
const errors = report.issues.filter(i => i.severity === 'error');
const warnings = report.issues.filter(i => i.severity === 'warning');
const status = errors.length > 0 ? 'fail' as const : 'warn' as const;
const check: Check = {
name: 'resolver_health',
status,
message: `${report.issues.length} issue(s): ${errors.length} error(s), ${warnings.length} warning(s)`,
issues: report.issues.map(i => ({
type: i.type,
skill: i.skill,
action: i.action,
fix: i.fix,
})),
};
checks.push(check);
}
} else {
checks.push({ name: 'resolver_health', status: 'warn', message: 'Could not find skills directory' });
}
// 2. Skill conformance
if (repoRoot) {
const skillsDir = join(repoRoot, 'skills');
const conformanceResult = checkSkillConformance(skillsDir);
checks.push(conformanceResult);
}
// --- DB checks (skip if --fast or no engine) ---
if (fastMode || !engine) {
if (!engine) {
checks.push({ name: 'connection', status: 'warn', message: 'No database configured (filesystem checks only)' });
}
outputResults(checks, jsonOutput);
return;
}
// 3. Connection
try {
const stats = await engine.getStats();
checks.push({ name: 'connection', status: 'ok', message: `Connected, ${stats.page_count} pages` });
@@ -23,7 +84,7 @@ export async function runDoctor(engine: BrainEngine, args: string[]) {
return;
}
// 2. pgvector extension
// 4. pgvector extension
try {
const sql = db.getConnection();
const ext = await sql`SELECT extname FROM pg_extension WHERE extname = 'vector'`;
@@ -36,7 +97,7 @@ export async function runDoctor(engine: BrainEngine, args: string[]) {
checks.push({ name: 'pgvector', status: 'warn', message: 'Could not check pgvector extension' });
}
// 3. RLS
// 5. RLS
try {
const sql = db.getConnection();
const tables = await sql`
@@ -56,7 +117,7 @@ export async function runDoctor(engine: BrainEngine, args: string[]) {
checks.push({ name: 'rls', status: 'warn', message: 'Could not check RLS status' });
}
// 4. Schema version
// 6. Schema version
try {
const version = await engine.getConfig('version');
const v = parseInt(version || '0', 10);
@@ -69,7 +130,7 @@ export async function runDoctor(engine: BrainEngine, args: string[]) {
checks.push({ name: 'schema_version', status: 'warn', message: 'Could not check schema version' });
}
// 5. Embedding health
// 7. Embedding health
try {
const health = await engine.getHealth();
const pct = (health.embed_coverage * 100).toFixed(0);
@@ -84,13 +145,93 @@ export async function runDoctor(engine: BrainEngine, args: string[]) {
checks.push({ name: 'embeddings', status: 'warn', message: 'Could not check embedding health' });
}
// 8. Link integrity
try {
const health = await engine.getHealth();
if (health.dead_links === 0) {
checks.push({ name: 'link_integrity', status: 'ok', message: 'No dead links' });
} else {
checks.push({ name: 'link_integrity', status: 'warn', message: `${health.dead_links} dead link(s). Run: gbrain check-backlinks --fix` });
}
} catch {
checks.push({ name: 'link_integrity', status: 'warn', message: 'Could not check link integrity' });
}
outputResults(checks, jsonOutput);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Find the GBrain repo root by walking up from cwd looking for skills/RESOLVER.md */
function findRepoRoot(): string | null {
let dir = process.cwd();
for (let i = 0; i < 10; i++) {
if (existsSync(join(dir, 'skills', 'RESOLVER.md'))) return dir;
const parent = join(dir, '..');
if (parent === dir) break;
dir = parent;
}
return null;
}
/** Quick skill conformance check — frontmatter + required sections */
function checkSkillConformance(skillsDir: string): Check {
const manifestPath = join(skillsDir, 'manifest.json');
if (!existsSync(manifestPath)) {
return { name: 'skill_conformance', status: 'warn', message: 'manifest.json not found' };
}
try {
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
const skills = manifest.skills || [];
let passing = 0;
const failing: string[] = [];
for (const skill of skills) {
const skillPath = join(skillsDir, skill.path);
if (!existsSync(skillPath)) {
failing.push(`${skill.name}: file missing`);
continue;
}
const content = readFileSync(skillPath, 'utf-8');
// Check frontmatter exists
if (!content.startsWith('---')) {
failing.push(`${skill.name}: no frontmatter`);
continue;
}
passing++;
}
if (failing.length === 0) {
return { name: 'skill_conformance', status: 'ok', message: `${passing}/${skills.length} skills pass` };
}
return {
name: 'skill_conformance',
status: 'warn',
message: `${passing}/${skills.length} pass. Failing: ${failing.join(', ')}`,
};
} catch {
return { name: 'skill_conformance', status: 'warn', message: 'Could not parse manifest.json' };
}
}
function outputResults(checks: Check[], json: boolean) {
if (json) {
const hasFail = checks.some(c => c.status === 'fail');
console.log(JSON.stringify({ status: hasFail ? 'unhealthy' : 'healthy', checks }));
const hasWarn = checks.some(c => c.status === 'warn');
const status = hasFail ? 'unhealthy' : hasWarn ? 'warnings' : 'healthy';
// Compute composite health score (0-100)
let score = 100;
for (const c of checks) {
if (c.status === 'fail') score -= 20;
else if (c.status === 'warn') score -= 5;
}
score = Math.max(0, score);
console.log(JSON.stringify({ schema_version: 2, status, health_score: score, checks }));
process.exit(hasFail ? 1 : 0);
return;
}
@@ -100,16 +241,31 @@ function outputResults(checks: Check[], json: boolean) {
for (const c of checks) {
const icon = c.status === 'ok' ? 'OK' : c.status === 'warn' ? 'WARN' : 'FAIL';
console.log(` [${icon}] ${c.name}: ${c.message}`);
// Print resolver issues with actions
if (c.issues) {
for (const issue of c.issues) {
console.log(`${issue.type.toUpperCase()}: ${issue.skill}`);
console.log(` ACTION: ${issue.action}`);
}
}
}
// Composite health score
let score = 100;
for (const c of checks) {
if (c.status === 'fail') score -= 20;
else if (c.status === 'warn') score -= 5;
}
score = Math.max(0, score);
const hasFail = checks.some(c => c.status === 'fail');
const hasWarn = checks.some(c => c.status === 'warn');
if (hasFail) {
console.log('\nFailed checks found. Fix the issues above.');
console.log(`\nHealth score: ${score}/100. Failed checks found.`);
} else if (hasWarn) {
console.log('\nAll checks OK (some warnings).');
console.log(`\nHealth score: ${score}/100. All checks OK (some warnings).`);
} else {
console.log('\nAll checks passed.');
console.log(`\nHealth score: ${score}/100. All checks passed.`);
}
process.exit(hasFail ? 1 : 0);
}

View File

@@ -1,7 +1,11 @@
import { execSync } from 'child_process';
import { readdirSync, lstatSync } from 'fs';
import { join } from 'path';
import { readdirSync, lstatSync, existsSync, copyFileSync, mkdirSync, readFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { homedir } from 'os';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
import { saveConfig, type GBrainConfig } from '../core/config.ts';
import { createEngine } from '../core/engine-factory.ts';
@@ -81,6 +85,7 @@ async function initPGLite(opts: { jsonOutput: boolean; apiKey: string | null; cu
console.log('Next: gbrain import <dir>');
console.log('');
console.log('When you outgrow local: gbrain migrate --to supabase');
reportModStatus();
}
}
@@ -150,6 +155,7 @@ async function initPostgres(opts: { databaseUrl: string; jsonOutput: boolean; ap
} else {
console.log(`\nBrain ready. ${stats.page_count} pages. Engine: Postgres (Supabase).`);
console.log('Next: gbrain import <dir>');
reportModStatus();
}
}
@@ -216,3 +222,96 @@ function readLine(prompt: string): Promise<string> {
process.stdin.resume();
});
}
/**
* Detect GStack installation across known host paths.
* Uses gstack-global-discover if available, falls back to path checking.
*/
export function detectGStack(): { found: boolean; path: string | null; host: string | null } {
// Try gstack's own discovery tool first (DRY: don't reimplement host detection)
try {
const result = execSync(
`${join(homedir(), '.claude', 'skills', 'gstack', 'bin', 'gstack-global-discover')} 2>/dev/null`,
{ encoding: 'utf-8', timeout: 5000 }
).trim();
if (result) {
return { found: true, path: result.split('\n')[0], host: 'auto-detected' };
}
} catch { /* binary not available */ }
// Fallback: check known host paths
const hostPaths = [
{ path: join(homedir(), '.claude', 'skills', 'gstack'), host: 'claude' },
{ path: join(homedir(), '.openclaw', 'skills', 'gstack'), host: 'openclaw' },
{ path: join(homedir(), '.codex', 'skills', 'gstack'), host: 'codex' },
{ path: join(homedir(), '.factory', 'skills', 'gstack'), host: 'factory' },
{ path: join(homedir(), '.kiro', 'skills', 'gstack'), host: 'kiro' },
];
for (const { path, host } of hostPaths) {
if (existsSync(join(path, 'SKILL.md')) || existsSync(join(path, 'setup'))) {
return { found: true, path, host };
}
}
return { found: false, path: null, host: null };
}
/**
* Install default identity templates (SOUL.md, USER.md, ACCESS_POLICY.md, HEARTBEAT.md)
* into the agent workspace. Uses minimal defaults, not the soul-audit interview.
*/
export function installDefaultTemplates(workspaceDir: string): string[] {
const gbrainRoot = dirname(dirname(__dirname)); // up from src/commands/ to repo root
const templatesDir = join(gbrainRoot, 'templates');
const installed: string[] = [];
const templates = [
{ src: 'SOUL.md.template', dest: 'SOUL.md' },
{ src: 'USER.md.template', dest: 'USER.md' },
{ src: 'ACCESS_POLICY.md.template', dest: 'ACCESS_POLICY.md' },
{ src: 'HEARTBEAT.md.template', dest: 'HEARTBEAT.md' },
];
for (const { src, dest } of templates) {
const srcPath = join(templatesDir, src);
const destPath = join(workspaceDir, dest);
if (existsSync(srcPath) && !existsSync(destPath)) {
mkdirSync(dirname(destPath), { recursive: true });
copyFileSync(srcPath, destPath);
installed.push(dest);
}
}
return installed;
}
/**
* Report post-init status including GStack detection and skill count.
*/
export function reportModStatus(): void {
const gstack = detectGStack();
const gbrainRoot = dirname(dirname(__dirname));
const skillsDir = join(gbrainRoot, 'skills');
let skillCount = 0;
try {
const manifest = JSON.parse(
readFileSync(join(skillsDir, 'manifest.json'), 'utf-8')
);
skillCount = manifest.skills?.length || 0;
} catch { /* manifest not found */ }
console.log('');
console.log('--- GBrain Mod Status ---');
console.log(`Skills: ${skillCount} loaded`);
console.log(`GStack: ${gstack.found ? `found (${gstack.host})` : 'not found'}`);
if (!gstack.found) {
console.log(' Install GStack for coding skills:');
console.log(' git clone https://github.com/garrytan/gstack.git ~/.claude/skills/gstack');
console.log(' cd ~/.claude/skills/gstack && ./setup');
}
console.log('Resolver: skills/RESOLVER.md');
console.log('Soul audit: run `gbrain soul-audit` to customize agent identity');
console.log('');
}

190
src/core/backoff.ts Normal file
View File

@@ -0,0 +1,190 @@
/**
* Adaptive load-aware throttling for batch operations.
*
* Prevents batch imports, embedding jobs, and enrichment from overloading
* the system. Checks CPU load, memory, and concurrent process count.
*
* Note on os.loadavg(): returns [0,0,0] on Windows. When load data is
* unavailable (all zeros on non-Linux/macOS), defaults to "proceed" since
* we can't determine actual load.
*/
import { loadavg, freemem, totalmem, cpus } from 'os';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface ThrottleConfig {
/** Load average as fraction of CPU count above which to stop. Default: 0.62 */
loadStopPct: number;
/** Load average as fraction of CPU count above which to slow down. Default: 0.37 */
loadSlowPct: number;
/** Load average as fraction of CPU count considered normal. Default: 0.19 */
loadNormalPct: number;
/** Memory usage fraction above which to stop. Default: 0.85 */
memoryStopPct: number;
/** Multiplier applied during active hours (8am-11pm). Default: 2 */
activeHoursMultiplier: number;
/** Hour (0-23) when active hours start. Default: 8 */
activeHoursStart: number;
/** Hour (0-23) when active hours end. Default: 23 */
activeHoursEnd: number;
/** Maximum iterations for waitForCapacity before throwing. Default: 20 */
maxAttempts: number;
}
export interface ThrottleResult {
proceed: boolean;
delay: number;
reason: string;
load: number;
memoryUsed: number;
}
const DEFAULT_CONFIG: ThrottleConfig = {
loadStopPct: 0.62,
loadSlowPct: 0.37,
loadNormalPct: 0.19,
memoryStopPct: 0.85,
activeHoursMultiplier: 2,
activeHoursStart: 8,
activeHoursEnd: 23,
maxAttempts: 20,
};
// Module-level concurrent process counter
let _activeProcesses = 0;
const MAX_CONCURRENT = 2;
// ---------------------------------------------------------------------------
// Core functions
// ---------------------------------------------------------------------------
/** Merge user config with defaults */
function mergeConfig(config?: Partial<ThrottleConfig>): ThrottleConfig {
return { ...DEFAULT_CONFIG, ...config };
}
/** Check if current hour is within active hours */
function isActiveHours(cfg: ThrottleConfig): boolean {
const hour = new Date().getHours();
return hour >= cfg.activeHoursStart && hour < cfg.activeHoursEnd;
}
/** Get normalized load (0-1 scale relative to CPU count) */
function getLoad(): number {
const cores = cpus().length || 1;
const avg = loadavg()[0]; // 1-minute average
return avg / cores;
}
/** Get memory usage fraction (0-1) */
function getMemoryUsage(): number {
const total = totalmem();
if (total === 0) return 0;
return 1 - (freemem() / total);
}
/**
* Check if it's safe to proceed with batch work.
* Returns { proceed, delay, reason, load, memoryUsed }.
*/
export function shouldProceed(config?: Partial<ThrottleConfig>): ThrottleResult {
const cfg = mergeConfig(config);
const load = getLoad();
const memUsed = getMemoryUsage();
// Windows/unsupported: loadavg returns [0,0,0] — can't determine load, proceed
if (loadavg()[0] === 0 && loadavg()[1] === 0 && loadavg()[2] === 0) {
return { proceed: true, delay: 0, reason: 'Load data unavailable (Windows?), proceeding', load: 0, memoryUsed: memUsed };
}
// Concurrent process limit
if (_activeProcesses >= MAX_CONCURRENT) {
return { proceed: false, delay: 5000, reason: `${_activeProcesses} batch processes active (max ${MAX_CONCURRENT})`, load, memoryUsed: memUsed };
}
// Memory check
if (memUsed > cfg.memoryStopPct) {
return { proceed: false, delay: 30000, reason: `Memory ${(memUsed * 100).toFixed(0)}% > ${(cfg.memoryStopPct * 100).toFixed(0)}% threshold`, load, memoryUsed: memUsed };
}
// CPU load checks
const activeMultiplier = isActiveHours(cfg) ? cfg.activeHoursMultiplier : 1;
if (load > cfg.loadStopPct) {
return { proceed: false, delay: 30000 * activeMultiplier, reason: `Load ${(load * 100).toFixed(0)}% > stop threshold ${(cfg.loadStopPct * 100).toFixed(0)}%`, load, memoryUsed: memUsed };
}
if (load > cfg.loadSlowPct) {
return { proceed: true, delay: 2000 * activeMultiplier, reason: `Load ${(load * 100).toFixed(0)}% > slow threshold, adding delay`, load, memoryUsed: memUsed };
}
// Normal load
return { proceed: true, delay: 300 * activeMultiplier, reason: 'Normal load', load, memoryUsed: memUsed };
}
/**
* Wait until system has capacity for batch work.
* Exponential backoff from 1s to 60s, max attempts before throwing.
*/
export async function waitForCapacity(config?: Partial<ThrottleConfig>): Promise<void> {
const cfg = mergeConfig(config);
let backoff = 1000;
const maxBackoff = 60000;
for (let attempt = 0; attempt < cfg.maxAttempts; attempt++) {
const result = shouldProceed(cfg);
if (result.proceed) {
if (result.delay > 0) {
await sleep(result.delay);
}
return;
}
// Not safe to proceed — wait with exponential backoff
const waitTime = Math.min(backoff, maxBackoff);
await sleep(waitTime);
backoff = Math.min(backoff * 1.5, maxBackoff);
}
throw new Error(`Throttle timeout: system overloaded after ${cfg.maxAttempts} attempts (~${Math.round(cfg.maxAttempts * 30)}s). Load: ${(getLoad() * 100).toFixed(0)}%, Memory: ${(getMemoryUsage() * 100).toFixed(0)}%`);
}
/**
* Pre-flight check at script/command start.
* Registers this process as active and returns false if overloaded.
*/
export async function preflight(processName: string, config?: Partial<ThrottleConfig>): Promise<boolean> {
const result = shouldProceed(config);
if (!result.proceed) {
return false;
}
_activeProcesses++;
return true;
}
/** Mark a batch process as complete (decrement counter). */
export function complete(): void {
_activeProcesses = Math.max(0, _activeProcesses - 1);
}
/** Get current throttle state for diagnostics. */
export function getThrottleState(): { load: number; memoryUsed: number; activeProcesses: number; isActiveHours: boolean } {
return {
load: getLoad(),
memoryUsed: getMemoryUsage(),
activeProcesses: _activeProcesses,
isActiveHours: isActiveHours(DEFAULT_CONFIG),
};
}
// For testing: reset module state
export function _resetForTest(): void {
_activeProcesses = 0;
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}

View File

@@ -0,0 +1,356 @@
/**
* check-resolvable.ts — Shared core function for resolver validation.
*
* Three call sites:
* 1. `bun test` — unit tests import and assert on checkResolvable()
* 2. `gbrain doctor` — runtime health check with actionable agent guidance
* 3. skill-creator skill — mandatory post-creation validation gate
*
* @param skillsDir — the `skills/` directory (NOT repo root). Parser joins
* this path with manifest paths like `query/SKILL.md`.
*/
import { readFileSync, existsSync, readdirSync } from 'fs';
import { join, relative } from 'path';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface ResolvableFix {
type: 'add_trigger' | 'remove_trigger' | 'add_frontmatter' | 'create_stub';
file: string;
section?: string;
skill_path?: string;
}
export interface ResolvableIssue {
type: 'unreachable' | 'mece_overlap' | 'mece_gap' | 'dry_violation' | 'missing_file' | 'orphan_trigger';
severity: 'error' | 'warning';
skill: string;
message: string;
action: string;
fix?: ResolvableFix;
}
export interface ResolvableReport {
ok: boolean;
issues: ResolvableIssue[];
summary: {
total_skills: number;
reachable: number;
unreachable: number;
overlaps: number;
gaps: number;
};
}
export interface FixResult {
issue: ResolvableIssue;
applied: boolean;
detail: string;
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/** Skills that intentionally overlap with many others (always-on, routers). */
const OVERLAP_WHITELIST = new Set([
'ingest', // router that delegates to idea-ingest, media-ingest, meeting-ingestion
'signal-detector', // always-on, fires on every message
'brain-ops', // always-on, every brain read/write
]);
interface ResolverEntry {
trigger: string;
skillPath: string; // e.g., 'skills/query/SKILL.md'
isGStack: boolean; // GStack: X entries (external, skip file check)
section: string; // e.g., 'Brain operations'
}
/** Parse RESOLVER.md markdown tables into structured entries. */
export function parseResolverEntries(resolverContent: string): ResolverEntry[] {
const entries: ResolverEntry[] = [];
let currentSection = '';
for (const line of resolverContent.split('\n')) {
// Track section headings
const headingMatch = line.match(/^##\s+(.+)/);
if (headingMatch) {
currentSection = headingMatch[1].trim();
continue;
}
// Skip non-table rows
if (!line.startsWith('|') || line.includes('---')) continue;
// Split table columns
const cols = line.split('|').map(c => c.trim()).filter(Boolean);
if (cols.length < 2) continue;
const trigger = cols[0];
const skillCol = cols[1];
// Skip header rows
if (trigger.toLowerCase() === 'trigger' || trigger.toLowerCase() === 'skill') continue;
// Check for GStack entries
if (skillCol.startsWith('GStack:') || skillCol.startsWith('Check ') || skillCol.startsWith('Read ')) {
entries.push({ trigger, skillPath: skillCol, isGStack: true, section: currentSection });
continue;
}
// Extract skill path from backtick-wrapped references
const pathMatch = skillCol.match(/`(skills\/[^`]+\/SKILL\.md)`/);
if (pathMatch) {
entries.push({ trigger, skillPath: pathMatch[1], isGStack: false, section: currentSection });
}
}
return entries;
}
/** Extract skill names from manifest.json */
function loadManifest(skillsDir: string): Array<{ name: string; path: string }> {
const manifestPath = join(skillsDir, 'manifest.json');
if (!existsSync(manifestPath)) return [];
try {
const content = JSON.parse(readFileSync(manifestPath, 'utf-8'));
return content.skills || [];
} catch {
return [];
}
}
/** Simple YAML frontmatter parser — extracts triggers array if present. */
function extractTriggers(skillContent: string): string[] {
const fmMatch = skillContent.match(/^---\n([\s\S]*?)\n---/);
if (!fmMatch) return [];
const fm = fmMatch[1];
const triggersMatch = fm.match(/^triggers:\s*\n((?:\s+-\s+.+\n?)*)/m);
if (!triggersMatch) return [];
return triggersMatch[1]
.split('\n')
.map(l => l.replace(/^\s+-\s+/, '').replace(/^["']|["']$/g, '').trim())
.filter(Boolean);
}
/** Scan for inlined cross-cutting rules that should reference convention files. */
const CROSS_CUTTING_PATTERNS = [
{ pattern: /iron\s*law.*back-?link/i, convention: 'conventions/quality.md', label: 'Iron Law back-linking' },
{ pattern: /citation.*format.*\[Source:/i, convention: 'conventions/quality.md', label: 'citation format rules' },
{ pattern: /notability.*gate/i, convention: 'conventions/quality.md', label: 'notability gate' },
];
// ---------------------------------------------------------------------------
// Main function
// ---------------------------------------------------------------------------
/**
* Validate that all skills are reachable from RESOLVER.md, detect MECE
* violations, and check for DRY issues.
*
* @param skillsDir — path to the `skills/` directory
*/
export function checkResolvable(skillsDir: string): ResolvableReport {
const issues: ResolvableIssue[] = [];
// Load inputs
const resolverPath = join(skillsDir, 'RESOLVER.md');
if (!existsSync(resolverPath)) {
return {
ok: false,
issues: [{
type: 'missing_file',
severity: 'error',
skill: 'RESOLVER.md',
message: 'RESOLVER.md not found',
action: `Create ${resolverPath} with skill routing tables`,
fix: { type: 'create_stub', file: resolverPath },
}],
summary: { total_skills: 0, reachable: 0, unreachable: 0, overlaps: 0, gaps: 0 },
};
}
const resolverContent = readFileSync(resolverPath, 'utf-8');
const entries = parseResolverEntries(resolverContent);
const manifest = loadManifest(skillsDir);
// Build lookup sets
const resolverSkillPaths = new Set(
entries.filter(e => !e.isGStack).map(e => e.skillPath)
);
// 1. Check every manifest skill is reachable from RESOLVER.md
let reachable = 0;
let unreachable = 0;
for (const skill of manifest) {
const expectedPath = `skills/${skill.path}`;
if (resolverSkillPaths.has(expectedPath)) {
reachable++;
} else {
// Also check if the skill name appears in any resolver entry
const nameInResolver = entries.some(
e => e.skillPath.includes(skill.name) || e.trigger.includes(skill.name)
);
if (nameInResolver) {
reachable++;
} else {
unreachable++;
// Find the best section for this skill based on its description
const section = 'Brain operations'; // default suggestion
issues.push({
type: 'unreachable',
severity: 'error',
skill: skill.name,
message: `Skill '${skill.name}' is in manifest but has no trigger row in RESOLVER.md`,
action: `Add a trigger row for 'skills/${skill.path}' in RESOLVER.md under ${section}`,
fix: {
type: 'add_trigger',
file: resolverPath,
section,
skill_path: `skills/${skill.path}`,
},
});
}
}
}
// 2. Check every resolver entry points to a file that exists
for (const entry of entries) {
if (entry.isGStack) continue;
// Resolver uses 'skills/query/SKILL.md', manifest uses 'query/SKILL.md'
// The file on disk is at skillsDir + 'query/SKILL.md'
const relPath = entry.skillPath.replace(/^skills\//, '');
const fullPath = join(skillsDir, relPath);
if (!existsSync(fullPath)) {
issues.push({
type: 'missing_file',
severity: 'error',
skill: entry.skillPath,
message: `RESOLVER.md references '${entry.skillPath}' but the file doesn't exist`,
action: `Create the skill at '${fullPath}' or remove the resolver entry`,
fix: { type: 'create_stub', file: fullPath },
});
}
// Check if in manifest
const skillName = relPath.replace(/\/SKILL\.md$/, '');
const inManifest = manifest.some(s => s.name === skillName);
if (!inManifest && existsSync(fullPath)) {
issues.push({
type: 'orphan_trigger',
severity: 'warning',
skill: skillName,
message: `RESOLVER.md has a trigger for '${skillName}' which is not in manifest.json`,
action: `Register '${skillName}' in skills/manifest.json or remove from RESOLVER.md`,
fix: { type: 'remove_trigger', file: resolverPath, skill_path: entry.skillPath },
});
}
}
// 3. MECE overlap detection
let overlaps = 0;
// Build trigger→skill map from SKILL.md frontmatter triggers
const triggerMap = new Map<string, string[]>();
for (const skill of manifest) {
const skillPath = join(skillsDir, skill.path);
if (!existsSync(skillPath)) continue;
try {
const content = readFileSync(skillPath, 'utf-8');
const triggers = extractTriggers(content);
for (const t of triggers) {
const normalized = t.toLowerCase().trim();
if (!triggerMap.has(normalized)) triggerMap.set(normalized, []);
triggerMap.get(normalized)!.push(skill.name);
}
} catch {
// Skip unreadable files
}
}
for (const [trigger, skills] of triggerMap) {
if (skills.length <= 1) continue;
// Filter out whitelisted skills
const nonWhitelisted = skills.filter(s => !OVERLAP_WHITELIST.has(s));
if (nonWhitelisted.length <= 1) continue;
overlaps++;
issues.push({
type: 'mece_overlap',
severity: 'warning',
skill: nonWhitelisted.join(', '),
message: `Trigger '${trigger}' matches multiple skills: ${nonWhitelisted.join(', ')}`,
action: `Add disambiguation rule in RESOLVER.md or narrow triggers in one skill's frontmatter`,
});
}
// 4. Gap detection — skills with no triggers in frontmatter
let gaps = 0;
for (const skill of manifest) {
if (OVERLAP_WHITELIST.has(skill.name)) continue; // always-on don't need triggers
const skillPath = join(skillsDir, skill.path);
if (!existsSync(skillPath)) continue;
try {
const content = readFileSync(skillPath, 'utf-8');
const triggers = extractTriggers(content);
if (triggers.length === 0) {
gaps++;
issues.push({
type: 'mece_gap',
severity: 'warning',
skill: skill.name,
message: `Skill '${skill.name}' has no triggers: field in its SKILL.md frontmatter`,
action: `Add a triggers: array to the frontmatter of skills/${skill.path}`,
fix: {
type: 'add_frontmatter',
file: skillPath,
skill_path: `skills/${skill.path}`,
},
});
}
} catch {
// Skip unreadable
}
}
// 5. DRY detection — inlined cross-cutting rules
for (const skill of manifest) {
const skillPath = join(skillsDir, skill.path);
if (!existsSync(skillPath)) continue;
try {
const content = readFileSync(skillPath, 'utf-8');
for (const { pattern, convention, label } of CROSS_CUTTING_PATTERNS) {
if (pattern.test(content)) {
// Check if the skill also references the convention file
if (!content.includes(convention)) {
issues.push({
type: 'dry_violation',
severity: 'warning',
skill: skill.name,
message: `Skill '${skill.name}' inlines ${label} instead of referencing '${convention}'`,
action: `Replace inlined rules with a reference to '${convention}'`,
});
}
}
}
} catch {
// Skip unreadable
}
}
return {
ok: issues.filter(i => i.severity === 'error').length === 0,
issues,
summary: {
total_skills: manifest.length,
reachable,
unreachable,
overlaps,
gaps,
},
};
}

416
src/core/data-research.ts Normal file
View File

@@ -0,0 +1,416 @@
/**
* Data research utilities: recipe loading/validation, field extraction,
* deduplication, tracker page parsing, date windowing, HTML stripping.
*
* Used by the data-research skill and supporting agent workflows.
*/
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface ResearchRecipe {
name: string;
source_queries: {
gmail?: string[];
brain?: string[];
web?: string[];
date_windowing?: 'quarterly' | 'monthly';
};
classification: {
include_patterns?: string[];
exclude_patterns?: string[];
receipt_indicators?: string[];
marketing_indicators?: string[];
};
extraction_schema: Record<string, string>;
tracker_page: string;
tracker_format: {
group_by: string;
columns: string[];
sort?: string;
totals?: string[];
};
schedule?: {
cron: string;
notify?: boolean;
quiet_hours?: boolean;
};
}
export interface ValidationResult {
valid: boolean;
errors: string[];
}
export interface TrackerEntry {
[key: string]: string | number | string[];
}
export interface DedupConfig {
amountTolerance?: number; // e.g., 5 for $5 tolerance
dateExact?: boolean; // exact date match required
entityFuzzy?: boolean; // fuzzy entity name matching
}
export interface DedupResult {
isDuplicate: boolean;
type: 'exact' | 'fuzzy' | 'different_amount' | 'new';
matchedEntry?: TrackerEntry;
}
export interface DateWindow {
start: string; // YYYY/MM/DD
end: string;
label: string; // e.g., "Q1 2026"
}
// ---------------------------------------------------------------------------
// Recipe loading and validation
// ---------------------------------------------------------------------------
/** Validate a research recipe has required fields and valid patterns. */
export function validateRecipe(recipe: Partial<ResearchRecipe>): ValidationResult {
const errors: string[] = [];
if (!recipe.name) errors.push('Missing required field: name');
if (!recipe.source_queries) errors.push('Missing required field: source_queries');
if (!recipe.extraction_schema) errors.push('Missing required field: extraction_schema');
if (!recipe.tracker_page) errors.push('Missing required field: tracker_page');
if (!recipe.tracker_format) errors.push('Missing required field: tracker_format');
if (recipe.tracker_format) {
if (!recipe.tracker_format.group_by) errors.push('tracker_format missing group_by');
if (!recipe.tracker_format.columns || recipe.tracker_format.columns.length === 0) {
errors.push('tracker_format missing columns');
}
}
if (recipe.source_queries) {
const sq = recipe.source_queries;
if (!sq.gmail && !sq.brain && !sq.web) {
errors.push('source_queries must have at least one of: gmail, brain, web');
}
}
// Validate regex patterns are compilable
const patternArrays = [
recipe.classification?.include_patterns,
recipe.classification?.exclude_patterns,
recipe.classification?.receipt_indicators,
recipe.classification?.marketing_indicators,
].filter(Boolean);
for (const patterns of patternArrays) {
for (const p of patterns!) {
try {
// Patterns are stored as strings like "/regex/flags"
const match = p.match(/^\/(.+)\/([gimsuy]*)$/);
if (match) new RegExp(match[1], match[2]);
} catch (e: any) {
errors.push(`Invalid regex pattern '${p}': ${e.message}`);
}
}
}
return { valid: errors.length === 0, errors };
}
// ---------------------------------------------------------------------------
// Field extraction
// ---------------------------------------------------------------------------
/** Common financial metric regex patterns. */
const METRIC_PATTERNS: Record<string, RegExp[]> = {
mrr: [
/MRR[:\s]+(?:of\s+)?\$?([\d,]+\.?\d*\s*[KkMm]?)/i,
/MRR\s+(?:hit|is|at|reached|now|of)\s+\$?([\d,]+\.?\d*\s*[KkMm]?)/i,
/\$([\d,]+\.?\d*\s*[KkMm])\s*MRR/i,
],
arr: [
/ARR[:\s]+(?:of\s+)?\$?([\d,]+\.?\d*\s*[KkMmBb]?)/i,
/ARR\s+(?:hit|is|at|reached|now|of)\s+\$?([\d,]+\.?\d*\s*[KkMmBb]?)/i,
/\$([\d,]+\.?\d*\s*[KkMmBb])\s*ARR/i,
],
growth_mom: [
/(\+?-?\d+\.?\d*%)\s*(?:MoM|month[ -]over[ -]month)/i,
/(?:grew|growth|increased|up)\s+(?:by\s+)?(\+?\d+\.?\d*%)/i,
],
runway_months: [
/runway[:\s]+(?:of\s+)?(?:about\s+)?(\d+)\s*(?:months?|mo)/i,
/(\d+)\s*(?:months?|mo)\s*(?:of\s+)?runway/i,
],
headcount: [
/(\d+)\s*(?:employees?|team members?|people|headcount|FTEs?)/i,
/team\s+(?:of|size[:\s]+)\s*(\d+)/i,
],
customers: [
/(\d[\d,]*)\s*(?:customers?|clients?|users?|accounts?)/i,
],
amount: [
/Total Charged\s*\n?\s*\$([\d,]+\.\d{2})/i,
/receipt for your \$([\d,]+\.\d{2})/i,
/\$([\d,]+(?:\.\d{1,2})?)/g,
],
};
/** Extract structured fields from raw text using regex patterns. */
export function extractFields(
rawText: string,
schema: Record<string, string>,
): Record<string, string | null> {
const result: Record<string, string | null> = {};
for (const [field, type] of Object.entries(schema)) {
// Check if we have built-in patterns for this field
const patterns = METRIC_PATTERNS[field];
if (patterns) {
let matched = false;
for (const pattern of patterns) {
const match = rawText.match(pattern);
if (match && match[1]) {
result[field] = match[1].trim();
matched = true;
break;
}
}
if (!matched) result[field] = null;
} else if (type === 'date') {
// Extract dates in common formats
const dateMatch = rawText.match(/(\d{4}-\d{2}-\d{2}|\d{1,2}\/\d{1,2}\/\d{2,4})/);
result[field] = dateMatch ? dateMatch[1] : null;
} else {
result[field] = null; // No built-in pattern, needs LLM or custom regex
}
}
return result;
}
/** Verify extracted fields match what was saved to file (extraction integrity). */
export function verifyExtraction(
savedFields: Record<string, any>,
reportedFields: Record<string, any>,
): { verified: boolean; mismatches: string[] } {
const mismatches: string[] = [];
for (const [key, savedValue] of Object.entries(savedFields)) {
const reported = reportedFields[key];
if (reported !== undefined && String(reported) !== String(savedValue)) {
mismatches.push(`${key}: saved="${savedValue}" reported="${reported}"`);
}
}
return { verified: mismatches.length === 0, mismatches };
}
// ---------------------------------------------------------------------------
// Deduplication
// ---------------------------------------------------------------------------
/** Check if an entry duplicates an existing tracker entry. */
export function isDuplicate(
existing: TrackerEntry[],
candidate: TrackerEntry,
keyFields: string[],
config?: DedupConfig,
): DedupResult {
const tolerance = config?.amountTolerance || 0;
for (const entry of existing) {
// Check if all key fields match
let allMatch = true;
let nonAmountFieldsMatch = true;
let amountDiffers = false;
for (const key of keyFields) {
const existingVal = String(entry[key] || '');
const candidateVal = String(candidate[key] || '');
if (key === 'amount') {
const existingNum = parseFloat(existingVal.replace(/[$,]/g, ''));
const candidateNum = parseFloat(candidateVal.replace(/[$,]/g, ''));
if (tolerance > 0 && Math.abs(existingNum - candidateNum) > tolerance) {
amountDiffers = true;
allMatch = false;
} else if (existingVal.toLowerCase() !== candidateVal.toLowerCase()) {
amountDiffers = true;
allMatch = false;
}
} else if (config?.entityFuzzy && (key === 'recipient' || key === 'company')) {
if (existingVal.slice(0, 15).toLowerCase() !== candidateVal.slice(0, 15).toLowerCase()) {
allMatch = false;
nonAmountFieldsMatch = false;
}
} else {
if (existingVal.toLowerCase() !== candidateVal.toLowerCase()) {
allMatch = false;
nonAmountFieldsMatch = false;
}
}
}
if (allMatch) {
return { isDuplicate: true, type: 'exact', matchedEntry: entry };
}
if (amountDiffers && nonAmountFieldsMatch) {
return { isDuplicate: false, type: 'different_amount', matchedEntry: entry };
}
}
return { isDuplicate: false, type: 'new' };
}
// ---------------------------------------------------------------------------
// Tracker page parsing
// ---------------------------------------------------------------------------
/** Parse a markdown table into structured entries. */
export function parseTrackerPage(markdown: string, columns: string[]): TrackerEntry[] {
const entries: TrackerEntry[] = [];
const lines = markdown.split('\n');
for (const line of lines) {
if (!line.startsWith('|') || line.includes('---')) continue;
const cells = line.split('|').map(c => c.trim()).filter(Boolean);
// Skip header row
if (cells.length >= columns.length && cells[0] !== columns[0]) {
const entry: TrackerEntry = {};
for (let i = 0; i < columns.length && i < cells.length; i++) {
entry[columns[i]] = cells[i];
}
entries.push(entry);
}
}
return entries;
}
/** Append entries to a tracker page's markdown table. */
export function appendToTracker(
markdown: string,
entries: TrackerEntry[],
columns: string[],
section?: string,
): string {
const newRows = entries.map(entry => {
const cells = columns.map(col => String(entry[col] || ''));
return `| ${cells.join(' | ')} |`;
}).join('\n');
if (section) {
// Find the section and append before the next section or end
const sectionPattern = new RegExp(`(### ${section}[\\s\\S]*?)(\\n### |$)`);
const match = markdown.match(sectionPattern);
if (match) {
const insertPoint = match.index! + match[1].length;
return markdown.slice(0, insertPoint) + '\n' + newRows + '\n' + markdown.slice(insertPoint);
}
}
// Append to end
return markdown + '\n' + newRows + '\n';
}
/** Compute running totals for specified columns. */
export function computeTotals(
entries: TrackerEntry[],
totalColumns: string[],
): Record<string, number> {
const totals: Record<string, number> = {};
for (const col of totalColumns) {
totals[col] = 0;
for (const entry of entries) {
const val = String(entry[col] || '0').replace(/[$,]/g, '');
const num = parseFloat(val);
if (!isNaN(num)) totals[col] += num;
}
}
return totals;
}
// ---------------------------------------------------------------------------
// Date windowing
// ---------------------------------------------------------------------------
/** Build quarterly or monthly date windows for Gmail queries. */
export function buildDateWindows(
startYear: number,
endYear: number,
granularity: 'quarterly' | 'monthly' = 'quarterly',
): DateWindow[] {
if (endYear < startYear) {
throw new Error(`endYear (${endYear}) must be >= startYear (${startYear})`);
}
const windows: DateWindow[] = [];
for (let year = startYear; year <= endYear; year++) {
if (granularity === 'quarterly') {
windows.push(
{ start: `${year}/01/01`, end: `${year}/04/01`, label: `Q1 ${year}` },
{ start: `${year}/04/01`, end: `${year}/07/01`, label: `Q2 ${year}` },
{ start: `${year}/07/01`, end: `${year}/10/01`, label: `Q3 ${year}` },
{ start: `${year}/10/01`, end: `${year + 1}/01/01`, label: `Q4 ${year}` },
);
} else {
for (let month = 1; month <= 12; month++) {
const nextMonth = month === 12 ? 1 : month + 1;
const nextYear = month === 12 ? year + 1 : year;
windows.push({
start: `${year}/${String(month).padStart(2, '0')}/01`,
end: `${nextYear}/${String(nextMonth).padStart(2, '0')}/01`,
label: `${year}-${String(month).padStart(2, '0')}`,
});
}
}
}
return windows;
}
// ---------------------------------------------------------------------------
// HTML email stripping (6-phase pipeline)
// ---------------------------------------------------------------------------
const MAX_HTML_SIZE = 500 * 1024; // 500KB cap (ReDoS prevention)
/** Strip HTML from email bodies. 6-phase pipeline with input size cap. */
export function stripEmailHtml(html: string): string {
// Phase 0: Size cap (ReDoS prevention)
let text = html;
if (text.length > MAX_HTML_SIZE) {
text = text.slice(0, MAX_HTML_SIZE) + '\n...[truncated]';
}
// Phase 1: Remove <style> and <script> blocks entirely
text = text.replace(/<style[\s\S]*?<\/style>/gi, '');
text = text.replace(/<script[\s\S]*?<\/script>/gi, '');
// Phase 2: Convert block elements to newlines
text = text.replace(/<br\s*\/?>/gi, '\n');
text = text.replace(/<\/(p|div|tr|li|h[1-6])>/gi, '\n');
// Phase 3: Strip remaining HTML tags (non-greedy)
text = text.replace(/<[^>]*?>/g, '');
// Phase 4: Strip inline CSS artifacts (skip on large inputs for performance)
if (text.length < 100000) {
text = text.replace(/@media[^{]*\{[^}]*\}/g, '');
text = text.replace(/\.[a-zA-Z][\w-]*\s*\{[^}]*\}/g, '');
text = text.replace(/#[a-zA-Z][\w-]*\s*\{[^}]*\}/g, '');
}
// Phase 5: Decode HTML entities
text = text.replace(/&nbsp;/gi, ' ');
text = text.replace(/&amp;/gi, '&');
text = text.replace(/&lt;/gi, '<');
text = text.replace(/&gt;/gi, '>');
text = text.replace(/&quot;/gi, '"');
text = text.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(parseInt(code)));
// Phase 6: Collapse whitespace
text = text.replace(/[ \t]+/g, ' ');
text = text.replace(/\n{3,}/g, '\n\n');
text = text.trim();
return text;
}

View File

@@ -0,0 +1,270 @@
/**
* Enrichment as a global service.
*
* Shared library callable from any ingest pathway. Handles the brain CRUD
* for entity enrichment: check brain, create/update page, backlink, timeline.
*
* External API enrichment (people data APIs, professional networks) remains
* agent-orchestrated per the enrich skill file. This library handles the
* brain-side operations.
*
* Entity mention counts are derived from engine.searchKeyword() on the
* existing data (clamped to 100 results). Source tracking derives from
* page type/slug prefix since SearchResult has no metadata.skill field.
*/
import type { BrainEngine } from './engine.ts';
import { waitForCapacity } from './backoff.ts';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface EnrichmentRequest {
entityName: string;
entityType: 'person' | 'company';
context: string;
sourceSlug: string;
tier?: 1 | 2 | 3;
}
export interface EnrichmentResult {
slug: string;
action: 'created' | 'updated' | 'skipped';
tier: 1 | 2 | 3;
backlinkCreated: boolean;
timelineAdded: boolean;
mentionCount: number;
mentionSources: string[];
suggestedTier: 1 | 2 | 3;
tierEscalated: boolean;
}
// ---------------------------------------------------------------------------
// Entity naming utilities
// ---------------------------------------------------------------------------
/** Convert an entity name to a URL-safe slug. */
export function slugifyEntity(name: string, type: 'person' | 'company'): string {
const slug = name
.toLowerCase()
.replace(/['']/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
const prefix = type === 'person' ? 'people' : 'companies';
return `${prefix}/${slug}`;
}
/** Get the brain page path for an entity. */
export function entityPagePath(name: string, type: 'person' | 'company'): string {
return slugifyEntity(name, type);
}
// ---------------------------------------------------------------------------
// Core enrichment
// ---------------------------------------------------------------------------
/**
* Enrich a single entity: check brain, create/update, backlink, timeline.
* Uses searchKeyword to count mentions and derive source skills.
*/
export async function enrichEntity(
engine: BrainEngine,
request: EnrichmentRequest,
): Promise<EnrichmentResult> {
const slug = slugifyEntity(request.entityName, request.entityType);
// 1. Count existing mentions for tier auto-escalation
const { mentionCount, mentionSources } = await countMentions(engine, request.entityName);
// 2. Determine tier (auto-escalate based on mentions)
const suggestedTier = suggestTier(mentionCount, mentionSources, request.context);
const tier = request.tier || suggestedTier;
const tierEscalated = suggestedTier < (request.tier || 3); // lower tier number = higher importance
// 3. Check if entity page exists
const existingPage = await engine.getPage(slug);
let action: 'created' | 'updated' | 'skipped';
if (existingPage) {
// UPDATE path — add timeline entry
action = 'updated';
} else {
// CREATE path — new entity page
const title = request.entityName;
const type = request.entityType;
const content = generateStubContent(request.entityName, request.entityType, request.context);
await engine.putPage(slug, {
title,
type,
compiled_truth: content,
timeline: '',
frontmatter: {
created: new Date().toISOString().split('T')[0],
source: request.sourceSlug,
tier,
},
});
action = 'created';
}
// 4. Add timeline entry
let timelineAdded = false;
try {
await engine.addTimelineEntry(slug, {
date: new Date().toISOString().split('T')[0],
content: `Referenced in [${request.sourceSlug}](${request.sourceSlug}) — ${request.context}`,
source: request.sourceSlug,
});
timelineAdded = true;
} catch {
// Timeline add failed (page might not support it)
}
// 5. Add backlink from entity to source
let backlinkCreated = false;
try {
await engine.addLink(slug, request.sourceSlug, `Entity mention from ${request.sourceSlug}`);
backlinkCreated = true;
} catch {
// Link might already exist
}
return {
slug,
action,
tier,
backlinkCreated,
timelineAdded,
mentionCount,
mentionSources,
suggestedTier,
tierEscalated,
};
}
/**
* Enrich multiple entities with throttling between each.
*/
export async function enrichEntities(
engine: BrainEngine,
requests: EnrichmentRequest[],
config?: { throttle?: boolean },
): Promise<EnrichmentResult[]> {
const results: EnrichmentResult[] = [];
for (const req of requests) {
if (config?.throttle !== false) {
await waitForCapacity({ maxAttempts: 5 }); // shorter timeout for batch items
}
const result = await enrichEntity(engine, req);
results.push(result);
}
return results;
}
/**
* Extract entities from text, then enrich each.
* Uses simple regex patterns for entity detection.
* This is the first fail-improve integration candidate (per Codex review).
*/
export async function extractAndEnrich(
engine: BrainEngine,
text: string,
sourceSlug: string,
): Promise<EnrichmentResult[]> {
const entities = extractEntities(text);
if (entities.length === 0) return [];
const requests: EnrichmentRequest[] = entities.map(e => ({
entityName: e.name,
entityType: e.type,
context: e.context,
sourceSlug,
}));
return enrichEntities(engine, requests);
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/** Count entity mentions across the brain using keyword search. */
async function countMentions(
engine: BrainEngine,
entityName: string,
): Promise<{ mentionCount: number; mentionSources: string[] }> {
try {
const results = await engine.searchKeyword(entityName, { limit: 100 });
// Derive sources from slug prefixes since SearchResult has no metadata.skill
const sources = new Set<string>();
for (const r of results) {
const prefix = r.slug.split('/')[0];
if (prefix === 'people' || prefix === 'companies') sources.add('enrich');
else if (prefix === 'meetings') sources.add('meeting-ingestion');
else if (prefix === 'media') sources.add('media-ingest');
else if (prefix === 'sources' || prefix === 'ideas') sources.add('idea-ingest');
else if (prefix === 'voice-notes') sources.add('voice-note');
else sources.add('brain-ops');
}
return { mentionCount: results.length, mentionSources: [...sources] };
} catch {
return { mentionCount: 0, mentionSources: [] };
}
}
/** Suggest enrichment tier based on mention frequency. */
function suggestTier(
mentionCount: number,
mentionSources: string[],
context: string,
): 1 | 2 | 3 {
// 8+ mentions OR meeting/conversation source → Tier 1
if (mentionCount >= 8) return 1;
if (mentionSources.includes('meeting-ingestion') || mentionSources.includes('voice-note')) return 1;
// 3-7 mentions across 2+ sources → Tier 2
if (mentionCount >= 3 && mentionSources.length >= 2) return 2;
// Default → Tier 3
return 3;
}
/** Generate stub content for a new entity page. */
function generateStubContent(name: string, type: 'person' | 'company', context: string): string {
if (type === 'person') {
return `# ${name}\n\n**Type:** Person\n\n## Summary\n\n*Stub page. ${context}*\n\n## Timeline\n`;
}
return `# ${name}\n\n**Type:** Company\n\n## Summary\n\n*Stub page. ${context}*\n\n## Timeline\n`;
}
/** Simple entity extraction from text using regex patterns. */
export function extractEntities(text: string): Array<{ name: string; type: 'person' | 'company'; context: string }> {
const entities: Array<{ name: string; type: 'person' | 'company'; context: string }> = [];
const seen = new Set<string>();
// Match capitalized multi-word names (likely people or companies)
// Pattern: 2-4 capitalized words in sequence
const namePattern = /\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,3})\b/g;
let match;
while ((match = namePattern.exec(text)) !== null) {
const name = match[1];
if (seen.has(name.toLowerCase())) continue;
seen.add(name.toLowerCase());
// Simple heuristics for type classification
const isCompany = /Inc\b|Corp\b|Ltd\b|LLC\b|Co\b|Labs?\b|Tech\b|AI\b|Capital\b|Ventures?\b|Fund\b/i.test(name);
const type = isCompany ? 'company' : 'person';
// Extract surrounding context (50 chars each side)
const idx = match.index;
const start = Math.max(0, idx - 50);
const end = Math.min(text.length, idx + name.length + 50);
const context = text.slice(start, end).replace(/\n/g, ' ').trim();
entities.push({ name, type, context });
}
return entities;
}

247
src/core/fail-improve.ts Normal file
View File

@@ -0,0 +1,247 @@
/**
* Fail-improve loop: deterministic-first, LLM-fallback pattern.
*
* Tries deterministic code first (regex, parser). If it fails, falls back
* to LLM. Logs every fallback as a JSONL entry for future improvement.
* Over time, failure patterns reveal which regex rules are missing.
*
* Each operation writes to its own JSONL file (~/.gbrain/fail-improve/{operation}.jsonl).
* Atomic append assumption: individual log entries are <1KB, well under OS page size.
* No cross-operation file conflicts since each operation has its own file.
*/
import { appendFileSync, readFileSync, existsSync, mkdirSync, writeFileSync, renameSync } from 'fs';
import { join, dirname } from 'path';
import { homedir } from 'os';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface FailureEntry {
timestamp: string;
operation: string;
input: string;
deterministic_result: string | null;
llm_result: string | null;
metadata?: Record<string, any>;
}
export interface FailureAnalysis {
operation: string;
total_failures: number;
failures_by_pattern: Map<string, number>;
total_improvements: number;
last_improvement?: string;
total_calls: number;
deterministic_hits: number;
deterministic_rate: number;
}
export interface TestCase {
name: string;
input: string;
expected: string;
source: 'fail-improve-loop';
}
const LOG_DIR = join(homedir(), '.gbrain', 'fail-improve');
const MAX_ENTRIES = 1000;
// ---------------------------------------------------------------------------
// Core class
// ---------------------------------------------------------------------------
export class FailImproveLoop {
private logDir: string;
constructor(logDir?: string) {
this.logDir = logDir || LOG_DIR;
}
/**
* Try deterministic first, fall back to LLM, log mismatches.
* When both fail, throws the LLM error and logs both failures.
*/
async execute<T>(
operation: string,
input: string,
deterministicFn: (input: string) => T | null,
llmFallbackFn: (input: string) => Promise<T>,
): Promise<T> {
// Track call
this.incrementCallCount(operation, 'total');
// Try deterministic first
const deterResult = deterministicFn(input);
if (deterResult !== null && deterResult !== undefined) {
this.incrementCallCount(operation, 'deterministic');
return deterResult;
}
// Deterministic failed, try LLM
let llmResult: T;
try {
llmResult = await llmFallbackFn(input);
} catch (llmError: any) {
// Both failed — log both, throw LLM error
this.logFailure({
timestamp: new Date().toISOString(),
operation,
input: input.slice(0, 1000),
deterministic_result: null,
llm_result: `error: ${llmError.message || String(llmError)}`,
metadata: { cascade_failure: true },
});
throw llmError;
}
// Log the failure (deterministic failed, LLM succeeded)
this.logFailure({
timestamp: new Date().toISOString(),
operation,
input: input.slice(0, 1000),
deterministic_result: null,
llm_result: JSON.stringify(llmResult).slice(0, 1000),
});
return llmResult;
}
/** Append a failure entry to the operation's JSONL file. */
logFailure(entry: FailureEntry): void {
const filePath = this.getLogPath(entry.operation);
this.ensureDir(filePath);
const line = JSON.stringify(entry) + '\n';
appendFileSync(filePath, line, 'utf-8');
this.rotateIfNeeded(entry.operation);
}
/** Read all failures for an operation. */
getFailures(operation: string): FailureEntry[] {
const filePath = this.getLogPath(operation);
if (!existsSync(filePath)) return [];
try {
return readFileSync(filePath, 'utf-8')
.split('\n')
.filter(Boolean)
.map(line => {
try { return JSON.parse(line); }
catch { return null; }
})
.filter(Boolean) as FailureEntry[];
} catch {
return [];
}
}
/** Group failures by a key derived from the input (first 50 chars). */
getFailuresByPattern(operation: string): Map<string, FailureEntry[]> {
const failures = this.getFailures(operation);
const groups = new Map<string, FailureEntry[]>();
for (const f of failures) {
const key = f.input.slice(0, 50).replace(/\s+/g, ' ').trim();
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(f);
}
return groups;
}
/** Analyze failures and compute metrics. */
analyzeFailures(operation: string): FailureAnalysis {
const failures = this.getFailures(operation);
const patterns = this.getFailuresByPattern(operation);
const stats = this.getCallCounts(operation);
const improvements = this.getImprovements(operation);
return {
operation,
total_failures: failures.length,
failures_by_pattern: new Map([...patterns.entries()].map(([k, v]) => [k, v.length])),
total_improvements: improvements.length,
last_improvement: improvements.length > 0 ? improvements[improvements.length - 1].timestamp : undefined,
total_calls: stats.total,
deterministic_hits: stats.deterministic,
deterministic_rate: stats.total > 0 ? stats.deterministic / stats.total : 0,
};
}
/** Generate test cases from failure logs where LLM produced good results. */
generateTestCases(operation: string): TestCase[] {
const failures = this.getFailures(operation);
return failures
.filter(f => f.llm_result && !f.llm_result.startsWith('error:') && !f.metadata?.cascade_failure)
.map((f, i) => ({
name: `auto_${operation}_${i + 1}`,
input: f.input,
expected: f.llm_result!,
source: 'fail-improve-loop' as const,
}));
}
/** Log an improvement (when a new deterministic pattern is added). */
logImprovement(operation: string, description: string): void {
const filePath = join(this.logDir, operation, 'improvements.json');
this.ensureDir(filePath);
let improvements: any[] = [];
if (existsSync(filePath)) {
try { improvements = JSON.parse(readFileSync(filePath, 'utf-8')); } catch {}
}
improvements.push({ timestamp: new Date().toISOString(), description });
writeFileSync(filePath, JSON.stringify(improvements, null, 2), 'utf-8');
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
private getLogPath(operation: string): string {
return join(this.logDir, `${operation}.jsonl`);
}
private getCallCountPath(operation: string): string {
return join(this.logDir, `${operation}.counts.json`);
}
private ensureDir(filePath: string): void {
const dir = dirname(filePath);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
}
private incrementCallCount(operation: string, type: 'total' | 'deterministic'): void {
const filePath = this.getCallCountPath(operation);
this.ensureDir(filePath);
let counts = { total: 0, deterministic: 0 };
if (existsSync(filePath)) {
try { counts = JSON.parse(readFileSync(filePath, 'utf-8')); } catch {}
}
counts[type]++;
writeFileSync(filePath, JSON.stringify(counts), 'utf-8');
}
private getCallCounts(operation: string): { total: number; deterministic: number } {
const filePath = this.getCallCountPath(operation);
if (!existsSync(filePath)) return { total: 0, deterministic: 0 };
try { return JSON.parse(readFileSync(filePath, 'utf-8')); }
catch { return { total: 0, deterministic: 0 }; }
}
private getImprovements(operation: string): Array<{ timestamp: string; description: string }> {
const filePath = join(this.logDir, operation, 'improvements.json');
if (!existsSync(filePath)) return [];
try { return JSON.parse(readFileSync(filePath, 'utf-8')); }
catch { return []; }
}
private rotateIfNeeded(operation: string): void {
const filePath = this.getLogPath(operation);
if (!existsSync(filePath)) return;
const content = readFileSync(filePath, 'utf-8');
const lines = content.split('\n').filter(Boolean);
if (lines.length > MAX_ENTRIES) {
// Keep last MAX_ENTRIES entries
const kept = lines.slice(-MAX_ENTRIES);
writeFileSync(filePath, kept.join('\n') + '\n', 'utf-8');
}
}
}

237
src/core/transcription.ts Normal file
View File

@@ -0,0 +1,237 @@
/**
* Audio transcription service.
*
* Default provider: Groq Whisper (fast, cheap, OpenAI-compatible API format).
* Fallback: OpenAI Whisper if Groq unavailable.
* For files >25MB: ffmpeg segmentation into <25MB chunks, transcribe each, concatenate.
*/
import { statSync, readFileSync } from 'fs';
import { basename, extname } from 'path';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface TranscriptionSegment {
start: number;
end: number;
text: string;
speaker?: string;
}
export interface TranscriptionResult {
text: string;
segments: TranscriptionSegment[];
language: string;
duration: number;
provider: string;
}
export interface TranscriptionConfig {
provider?: 'groq' | 'openai' | 'deepgram';
apiKey?: string;
model?: string;
language?: string;
diarize?: boolean;
}
const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25MB
// Supported audio formats
const AUDIO_EXTENSIONS = new Set([
'.mp3', '.mp4', '.mpeg', '.mpga', '.m4a', '.wav', '.webm', '.ogg', '.flac',
]);
// ---------------------------------------------------------------------------
// Main function
// ---------------------------------------------------------------------------
/**
* Transcribe an audio file using Groq Whisper (default) or OpenAI Whisper.
* Files >25MB are segmented with ffmpeg before transcription.
*/
export async function transcribe(
audioPath: string,
config: TranscriptionConfig = {},
): Promise<TranscriptionResult> {
// Validate file exists and is audio
const stat = statSync(audioPath);
const ext = extname(audioPath).toLowerCase();
if (!AUDIO_EXTENSIONS.has(ext)) {
throw new Error(`Unsupported audio format: ${ext}. Supported: ${[...AUDIO_EXTENSIONS].join(', ')}`);
}
// Determine provider and API key
const provider = config.provider || detectProvider();
const apiKey = config.apiKey || getApiKey(provider);
if (!apiKey) {
const envVar = provider === 'groq' ? 'GROQ_API_KEY' : 'OPENAI_API_KEY';
throw new Error(
`${provider} API key not set. Set ${envVar} environment variable. ` +
(provider === 'groq' ? 'Or set OPENAI_API_KEY to use OpenAI Whisper as fallback.' : '')
);
}
// Handle large files via segmentation
if (stat.size > MAX_FILE_SIZE) {
return transcribeLargeFile(audioPath, provider, apiKey, config);
}
// Single file transcription
return transcribeFile(audioPath, provider, apiKey, config);
}
// ---------------------------------------------------------------------------
// Provider detection
// ---------------------------------------------------------------------------
function detectProvider(): 'groq' | 'openai' {
if (process.env.GROQ_API_KEY) return 'groq';
if (process.env.OPENAI_API_KEY) return 'openai';
return 'groq'; // default, will fail with clear error if no key
}
function getApiKey(provider: string): string | undefined {
switch (provider) {
case 'groq': return process.env.GROQ_API_KEY;
case 'openai': return process.env.OPENAI_API_KEY;
case 'deepgram': return process.env.DEEPGRAM_API_KEY;
default: return undefined;
}
}
// ---------------------------------------------------------------------------
// Single file transcription
// ---------------------------------------------------------------------------
async function transcribeFile(
audioPath: string,
provider: string,
apiKey: string,
config: TranscriptionConfig,
): Promise<TranscriptionResult> {
const model = config.model || (provider === 'groq' ? 'whisper-large-v3' : 'whisper-1');
const baseUrl = provider === 'groq'
? 'https://api.groq.com/openai/v1'
: 'https://api.openai.com/v1';
// Both Groq and OpenAI use the same API format
const fileData = readFileSync(audioPath);
const formData = new FormData();
formData.append('file', new Blob([fileData]), basename(audioPath));
formData.append('model', model);
formData.append('response_format', 'verbose_json');
if (config.language) formData.append('language', config.language);
const response = await fetch(`${baseUrl}/audio/transcriptions`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${apiKey}` },
body: formData,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Transcription failed (${provider} ${response.status}): ${errorText}`);
}
const data = await response.json() as any;
return {
text: data.text || '',
segments: (data.segments || []).map((s: any) => ({
start: s.start || 0,
end: s.end || 0,
text: s.text || '',
})),
language: data.language || config.language || 'unknown',
duration: data.duration || 0,
provider,
};
}
// ---------------------------------------------------------------------------
// Large file segmentation
// ---------------------------------------------------------------------------
async function transcribeLargeFile(
audioPath: string,
provider: string,
apiKey: string,
config: TranscriptionConfig,
): Promise<TranscriptionResult> {
// Check ffmpeg availability
const ffmpegAvailable = await checkFfmpeg();
if (!ffmpegAvailable) {
throw new Error(
'File exceeds 25MB and ffmpeg is required for segmentation. ' +
'Install ffmpeg: brew install ffmpeg (macOS) or apt install ffmpeg (Linux)'
);
}
// Segment into ~20MB chunks (with some overlap for better joining)
const { execSync } = await import('child_process');
const tmpDir = execSync('mktemp -d').toString().trim();
try {
// Get audio duration
const durationStr = execSync(
`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${audioPath}"`,
{ encoding: 'utf-8' }
).trim();
const totalDuration = parseFloat(durationStr) || 0;
// Calculate segment length (~20MB per segment, estimate from file size)
const stat = statSync(audioPath);
const bytesPerSecond = stat.size / Math.max(totalDuration, 1);
const segmentSeconds = Math.floor((20 * 1024 * 1024) / bytesPerSecond);
// Split audio
const ext = extname(audioPath);
execSync(
`ffmpeg -i "${audioPath}" -f segment -segment_time ${segmentSeconds} -c copy "${tmpDir}/segment_%03d${ext}"`,
{ stdio: 'pipe' }
);
// Transcribe each segment
const { readdirSync } = await import('fs');
const segments = readdirSync(tmpDir).filter(f => f.startsWith('segment_')).sort();
const results: TranscriptionResult[] = [];
let timeOffset = 0;
for (const seg of segments) {
const segPath = `${tmpDir}/${seg}`;
const result = await transcribeFile(segPath, provider, apiKey, config);
// Offset timestamps
result.segments = result.segments.map(s => ({
...s,
start: s.start + timeOffset,
end: s.end + timeOffset,
}));
results.push(result);
timeOffset += result.duration;
}
// Concatenate results
return {
text: results.map(r => r.text).join(' '),
segments: results.flatMap(r => r.segments),
language: results[0]?.language || 'unknown',
duration: timeOffset,
provider,
};
} finally {
// Cleanup temp directory
try { execSync(`rm -rf "${tmpDir}"`); } catch {}
}
}
async function checkFfmpeg(): Promise<boolean> {
try {
const { execSync } = await import('child_process');
execSync('ffmpeg -version', { stdio: 'pipe' });
return true;
} catch {
return false;
}
}