Skip to content

Commit 31e669c

Browse files
EtanHeyclaude
andauthored
feat: Codex session transcript ingestion into BrainLayer (#83)
- New adapter: src/brainlayer/ingest/codex.py — parse, classify, chunk, embed, store - CLI command: brainlayer ingest-codex <file|dir> - Smart mapping: function_call_output → file_read/stack_trace/git_diff - Batch ingest with dedup (skips already-indexed sessions) - Fixed source field propagation in index_new.py - 39 tests, validated on real data: 4,129 chunks from 20 sessions Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 322265a commit 31e669c

6 files changed

Lines changed: 1042 additions & 10 deletions

File tree

scripts/cloud_backfill.py

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -624,17 +624,26 @@ def poll_gemini_batch(batch_name: str, timeout_hours: float = 25) -> Dict[str, A
624624

625625
print(f"\nPolling job: {batch_name}")
626626
while time.time() < deadline:
627-
batch_job = client.batches.get(name=batch_name)
627+
try:
628+
batch_job = client.batches.get(name=batch_name)
629+
except Exception as exc:
630+
err_str = str(exc)
631+
if "404" in err_str or "NOT_FOUND" in err_str:
632+
print(f" Job not found (404) — may have expired: {batch_name}")
633+
return {"state": "failed", "error": "NOT_FOUND (expired or invalid)"}
634+
raise
635+
# state may be "JOB_STATE_SUCCEEDED" or "JobState.JOB_STATE_SUCCEEDED"
628636
state = str(batch_job.state)
637+
state_normalized = state.split(".")[-1] # strip "JobState." prefix if present
629638

630-
if state == "JOB_STATE_SUCCEEDED":
639+
if state_normalized == "JOB_STATE_SUCCEEDED":
631640
print(" Job SUCCEEDED!")
632641
return {"state": "succeeded", "job": batch_job}
633-
elif state == "JOB_STATE_FAILED":
642+
elif state_normalized == "JOB_STATE_FAILED":
634643
error = getattr(batch_job, "error", "unknown error")
635644
print(f" Job FAILED: {error}")
636645
return {"state": "failed", "error": str(error), "job": batch_job}
637-
elif state == "JOB_STATE_CANCELLED":
646+
elif state_normalized == "JOB_STATE_CANCELLED":
638647
print(" Job CANCELLED")
639648
return {"state": "failed", "error": "cancelled"}
640649

@@ -674,8 +683,8 @@ def download_gemini_results(batch_job) -> List[Dict[str, Any]]:
674683
print(f" Downloading results: {file_name}")
675684

676685
try:
677-
# Download the result file
678-
content = client.files.download(name=file_name)
686+
# Download the result file (SDK uses file= not name=)
687+
content = client.files.download(file=file_name)
679688
if isinstance(content, bytes):
680689
content = content.decode("utf-8")
681690

@@ -963,13 +972,24 @@ def resume_backfill(db_path: Path) -> None:
963972
return
964973

965974
print(f"Found {len(pending)} pending jobs to resume")
966-
for job in pending:
967-
print(f"\nResuming: {job['batch_id']}")
968-
result = poll_gemini_batch(job["batch_id"])
975+
imported_count = 0
976+
failed_count = 0
977+
for i, job in enumerate(pending):
978+
print(f"\nResuming [{i+1}/{len(pending)}]: {job['batch_id']}")
979+
try:
980+
result = poll_gemini_batch(job["batch_id"])
981+
except Exception as exc:
982+
print(f" ERROR polling job: {exc}")
983+
save_checkpoint(store, batch_id=job["batch_id"], status="failed",
984+
error=str(exc)[:500],
985+
completed_at=datetime.now(timezone.utc).isoformat())
986+
failed_count += 1
987+
continue
969988

970989
if result["state"] == "succeeded":
971990
batch_results = download_gemini_results(result["job"])
972991
import_results(store, batch_results, job["batch_id"])
992+
imported_count += 1
973993

974994
# Log usage to Supabase (best-effort)
975995
batch_job = result.get("job")
@@ -986,9 +1006,11 @@ def resume_backfill(db_path: Path) -> None:
9861006
save_checkpoint(store, batch_id=job["batch_id"], status="failed",
9871007
error=result.get("error", "unknown"),
9881008
completed_at=datetime.now(timezone.utc).isoformat())
1009+
failed_count += 1
9891010

9901011
final_stats = store.get_enrichment_stats()
991-
print(f"\nEnrichment: {final_stats['enriched']}/{final_stats['total_chunks']} ({final_stats['percent']}%)")
1012+
print(f"\nImported {imported_count} jobs, {failed_count} failed")
1013+
print(f"Enrichment: {final_stats['enriched']}/{final_stats['total_chunks']} ({final_stats['percent']}%)")
9921014

9931015
finally:
9941016
store.close()

src/brainlayer/cli/__init__.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import sys
66
import time
77
from pathlib import Path
8+
from typing import Optional
89

910
import typer
1011
from rich import print as rprint
@@ -1543,6 +1544,69 @@ def progress_callback(embedded_count, total_embed):
15431544
raise typer.Exit(1)
15441545

15451546

1547+
@app.command("ingest-codex")
1548+
def ingest_codex(
1549+
path: Optional[Path] = typer.Argument(
1550+
None,
1551+
help="Codex session JSONL file or sessions directory (default: ~/.codex/sessions)",
1552+
),
1553+
project: str = typer.Option(None, "--project", "-p", help="Override project name"),
1554+
since_days: int = typer.Option(None, "--since-days", "-d", help="Only process last N days"),
1555+
dry_run: bool = typer.Option(False, "--dry-run", help="Parse but do not write to DB"),
1556+
verbose: bool = typer.Option(False, "--verbose", "-v", help="Print each classified entry"),
1557+
) -> None:
1558+
"""Ingest Codex (GPT-5.4) session transcripts into BrainLayer.
1559+
1560+
Normalizes ~/.codex/sessions/YYYY/MM/DD/*.jsonl into searchable chunks
1561+
with source='codex_cli'. Already-indexed sessions are skipped automatically.
1562+
"""
1563+
from ..ingest.codex import ingest_codex_dir, ingest_codex_session
1564+
from ..paths import DEFAULT_DB_PATH
1565+
1566+
db_path = DEFAULT_DB_PATH
1567+
1568+
try:
1569+
if path and path.is_file():
1570+
rprint(f"[bold blue]זיכרון[/] — Ingesting Codex session: [bold]{path.name}[/]")
1571+
with console.status("Indexing..."):
1572+
n = ingest_codex_session(
1573+
path,
1574+
db_path=db_path,
1575+
project_override=project,
1576+
dry_run=dry_run,
1577+
verbose=verbose,
1578+
)
1579+
rprint(f"[bold green]✓[/] Indexed [bold]{n}[/] chunks from {path.name}")
1580+
else:
1581+
sessions_root = path if path else None
1582+
label = str(sessions_root) if sessions_root else "~/.codex/sessions"
1583+
rprint(f"[bold blue]זיכרון[/] — Ingesting Codex sessions from: [bold]{label}[/]")
1584+
with Progress(
1585+
SpinnerColumn(),
1586+
TextColumn("[progress.description]{task.description}"),
1587+
TimeElapsedColumn(),
1588+
console=console,
1589+
) as progress:
1590+
task = progress.add_task("Scanning sessions...", total=None)
1591+
files, chunks = ingest_codex_dir(
1592+
sessions_dir=sessions_root,
1593+
db_path=db_path,
1594+
project_override=project,
1595+
since_days=since_days,
1596+
dry_run=dry_run,
1597+
verbose=verbose,
1598+
)
1599+
progress.update(task, description=f"Done — {files} files, {chunks:,} chunks")
1600+
tag = " [dim](dry run)[/]" if dry_run else ""
1601+
rprint(f"[bold green]✓[/] Processed [bold]{files}[/] session files, [bold]{chunks:,}[/] chunks{tag}")
1602+
except FileNotFoundError as e:
1603+
rprint(f"[bold red]Error:[/] {e}")
1604+
raise typer.Exit(1)
1605+
except Exception as e:
1606+
rprint(f"[bold red]Error:[/] {e}")
1607+
raise typer.Exit(1)
1608+
1609+
15461610
@app.command("analyze-semantic")
15471611
def analyze_semantic(
15481612
whatsapp_limit: int = typer.Option(5000, "--whatsapp-limit", "-w", help="Number of WhatsApp messages to analyze"),

src/brainlayer/index_new.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ def index_chunks_to_sqlite(
7979
"conversation_id": conversation_id,
8080
"position": i,
8181
"sender": chunk.metadata.get("sender"),
82+
"source": chunk.metadata.get("source", "claude_code"),
8283
}
8384
)
8485

src/brainlayer/ingest/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Ingest adapters for non-Claude AI session transcripts."""

0 commit comments

Comments
 (0)