# NotebookLM Brain Interface Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Build `ops/brain/` — a zero-dependency bash+Python CLI that makes the CMS-aiChemist NotebookLM notebook fully queryable and writable from the command line.

**Architecture:** 4 bash scripts + 1 shared Python module. Scripts call Python inline for API/JSON work. All state lives in `.brain/` (inventory, session logs) and `handoffs/` (source mirrors). No pip install, no venv — stdlib only.

**Tech Stack:** Bash, Python 3 stdlib (`json`, `urllib.request`, `subprocess`, `os`, `pathlib`, `datetime`), gcloud CLI, NotebookLM Discovery Engine API, Gemini Generative Language API.

---

## File Map

| File | Action | Responsibility |
|------|--------|---------------|
| `ops/brain/_brain_common.py` | Create | Config loading, auth, API helpers, source loading, session logging |
| `ops/brain/brain-status.sh` | Create | Notebook state report (simplest script — validates shared module) |
| `ops/brain/brain-pull.sh` | Create | Sync source inventory from API |
| `ops/brain/brain-query.sh` | Create | Multi-source grounded Q&A via Gemini |
| `ops/brain/brain-push.sh` | Create | Upload new source + local mirror |
| `.brain/.gitkeep` | Create | Ensure .brain/ dir exists in repo |
| `.gitignore` | Create | Ignore `.brain/sessions/`, `.brain/config.env` |

---

### Task 1: Shared Python Module — _brain_common.py

**Files:**
- Create: `ops/brain/_brain_common.py`

This is the foundation everything else depends on. Build all 6 functions.

- [ ] **Step 1: Create directory structure**

```bash
mkdir -p ops/brain .brain/sessions
```

- [ ] **Step 2: Write _brain_common.py**

```python
#!/usr/bin/env python3
"""Shared utilities for the NotebookLM brain interface.

Provides config loading, auth token retrieval, API helpers for both
NotebookLM (Discovery Engine) and Gemini (Generative Language), local
source loading, and session logging. Zero external dependencies.
"""

import json
import os
import subprocess
import sys
import time
import urllib.request
import urllib.error
from datetime import datetime, timezone
from pathlib import Path


def _project_root():
    """Resolve project root by walking up from this file's location (ops/brain/) two levels."""
    return Path(__file__).resolve().parent.parent.parent


def load_config():
    """Load config from .env and optional .brain/config.env override.

    Returns dict with keys: GOOGLE_API_KEY, GEMINI_API_ENDPOINT,
    NOTEBOOKLM_API_BASE, NOTEBOOKLM_NOTEBOOK_ID, GCLOUD_PATH, PROJECT_ROOT.
    """
    root = _project_root()
    config = {"PROJECT_ROOT": str(root)}

    for env_file in [root / ".env", root / ".brain" / "config.env"]:
        if env_file.exists():
            for line in env_file.read_text().splitlines():
                line = line.strip()
                if not line or line.startswith("#"):
                    continue
                if "=" in line:
                    key, _, value = line.partition("=")
                    config[key.strip()] = value.strip()

    required = ["GOOGLE_API_KEY", "NOTEBOOKLM_API_BASE", "NOTEBOOKLM_NOTEBOOK_ID", "GCLOUD_PATH"]
    for key in required:
        if key not in config:
            print(f"ERROR: {key} not found. Check {root / '.env'}", file=sys.stderr)
            sys.exit(1)

    config.setdefault("GEMINI_API_ENDPOINT", "https://generativelanguage.googleapis.com/v1beta")
    return config


def get_auth_token(config):
    """Get OAuth token via gcloud auth print-access-token.

    Returns token string. Exits with error if gcloud is not authenticated.
    """
    gcloud = config["GCLOUD_PATH"]
    try:
        result = subprocess.run(
            [gcloud, "auth", "print-access-token"],
            capture_output=True, text=True, timeout=15
        )
        if result.returncode != 0:
            print("ERROR: gcloud not authenticated. Run: gcloud auth login --no-launch-browser",
                  file=sys.stderr)
            sys.exit(1)
        return result.stdout.strip()
    except FileNotFoundError:
        print(f"ERROR: gcloud not found at {gcloud}", file=sys.stderr)
        sys.exit(1)


def notebooklm_request(config, method, path, body=None):
    """Make authenticated request to NotebookLM Discovery Engine API.

    Args:
        config: dict from load_config()
        method: HTTP method (GET, POST, DELETE)
        path: path relative to NOTEBOOKLM_API_BASE (e.g., "/notebooks/ID")
        body: optional dict to JSON-encode as request body

    Returns: parsed JSON response dict.
    Exits on auth failure. Raises on other HTTP errors.
    """
    token = get_auth_token(config)
    url = config["NOTEBOOKLM_API_BASE"].rstrip("/") + path

    data = json.dumps(body).encode() if body else None
    req = urllib.request.Request(url, data=data, method=method)
    req.add_header("Authorization", f"Bearer {token}")
    if data:
        req.add_header("Content-Type", "application/json")

    try:
        resp = urllib.request.urlopen(req, timeout=30)
        raw = resp.read().decode()
        return json.loads(raw) if raw.strip() else {}
    except urllib.error.HTTPError as e:
        error_body = e.read().decode()
        print(f"ERROR: NotebookLM API {method} {path} returned {e.code}: {error_body}",
              file=sys.stderr)
        sys.exit(1)


def gemini_request(config, prompt, model="gemini-2.5-flash", temperature=0.1, max_tokens=4096):
    """Make API-key-authenticated request to Gemini generateContent.

    Args:
        config: dict from load_config()
        prompt: full prompt string
        model: Gemini model name (default: gemini-2.5-flash)
        temperature: generation temperature (default: 0.1)
        max_tokens: max output tokens (default: 4096)

    Returns: response text string.
    Retries once on 429. Exits on persistent failure.
    """
    endpoint = config["GEMINI_API_ENDPOINT"].rstrip("/")
    api_key = config["GOOGLE_API_KEY"]
    url = f"{endpoint}/models/{model}:generateContent?key={api_key}"

    payload = json.dumps({
        "contents": [{"parts": [{"text": prompt}]}],
        "generationConfig": {"temperature": temperature, "maxOutputTokens": max_tokens}
    }).encode()

    for attempt in range(2):
        req = urllib.request.Request(url, data=payload, method="POST")
        req.add_header("Content-Type", "application/json")
        try:
            resp = urllib.request.urlopen(req, timeout=60)
            data = json.loads(resp.read().decode())
            return data["candidates"][0]["content"]["parts"][0]["text"]
        except urllib.error.HTTPError as e:
            if e.code == 429 and attempt == 0:
                print("Rate limited. Retrying in 5s...", file=sys.stderr)
                time.sleep(5)
                continue
            error_body = e.read().decode()
            print(f"ERROR: Gemini API returned {e.code}: {error_body}", file=sys.stderr)
            sys.exit(1)


def load_sources(config, filter_titles=None):
    """Read source content from local handoffs/ mirror.

    Args:
        config: dict from load_config()
        filter_titles: optional list of title substrings to match

    Returns: list of {"title": str, "content": str, "path": str}
    """
    handoffs_dir = Path(config["PROJECT_ROOT"]) / "handoffs"
    sources = []

    if not handoffs_dir.exists():
        return sources

    for md_file in sorted(handoffs_dir.glob("*.md")):
        content = md_file.read_text()
        # Extract title from first markdown heading, or use filename
        title = md_file.stem
        for line in content.splitlines():
            if line.startswith("# "):
                title = line[2:].strip()
                break

        if filter_titles:
            if not any(f.lower() in title.lower() for f in filter_titles):
                continue

        sources.append({"title": title, "content": content, "path": str(md_file)})

    return sources


def log_session(config, event_type, details):
    """Append event to daily session log at .brain/sessions/YYYY-MM-DD.log.

    Args:
        config: dict from load_config()
        event_type: string like QUERY, PUSH, PULL, STATUS
        details: string with event details
    """
    sessions_dir = Path(config["PROJECT_ROOT"]) / ".brain" / "sessions"
    sessions_dir.mkdir(parents=True, exist_ok=True)

    now = datetime.now(timezone.utc)
    log_file = sessions_dir / f"{now.strftime('%Y-%m-%d')}.log"

    entry = f"[{now.isoformat(timespec='seconds')}] {event_type} {details}\n"
    with open(log_file, "a") as f:
        f.write(entry)
```

- [ ] **Step 3: Verify module loads without errors**

Run:
```bash
cd /home/jgatlit/apps/CMS && python3 -c "import ops.brain._brain_common as b; c = b.load_config(); print('Config loaded:', list(c.keys()))"
```

Expected: `Config loaded: ['PROJECT_ROOT', 'GOOGLE_ACCOUNT', 'GOOGLE_API_KEY', ...]`

- [ ] **Step 4: Verify auth token retrieval**

Run:
```bash
cd /home/jgatlit/apps/CMS && python3 -c "import ops.brain._brain_common as b; c = b.load_config(); t = b.get_auth_token(c); print('Token:', t[:20] + '...')"
```

Expected: `Token: ya29.a0AcM612x...` (truncated OAuth token)

- [ ] **Step 5: Verify NotebookLM API access**

Run:
```bash
cd /home/jgatlit/apps/CMS && python3 -c "
import ops.brain._brain_common as b
c = b.load_config()
nb_id = c['NOTEBOOKLM_NOTEBOOK_ID']
result = b.notebooklm_request(c, 'GET', f'/notebooks/{nb_id}')
print('Notebook:', result.get('title'), '- Sources:', len(result.get('sources', [])))
"
```

Expected: `Notebook: CMS-aiChemist - Sources: 4`

- [ ] **Step 6: Verify Gemini API access**

Run:
```bash
cd /home/jgatlit/apps/CMS && python3 -c "
import ops.brain._brain_common as b
c = b.load_config()
answer = b.gemini_request(c, 'Say hello in exactly 3 words.')
print('Gemini:', answer.strip())
"
```

Expected: A 3-word greeting from Gemini.

- [ ] **Step 7: Verify source loading**

Run:
```bash
cd /home/jgatlit/apps/CMS && python3 -c "
import ops.brain._brain_common as b
c = b.load_config()
sources = b.load_sources(c)
for s in sources:
    print(f'{s[\"title\"][:50]:50s} {len(s[\"content\"]):>6d} chars')
"
```

Expected: 4 sources listed with char counts.

- [ ] **Step 8: Commit**

```bash
git add ops/brain/_brain_common.py
git commit -m "feat(brain): add shared Python module for NotebookLM brain interface

Config loading, auth, NotebookLM API, Gemini API, source loading, session logging.
Zero external dependencies — Python stdlib only."
```

---

### Task 2: brain-status.sh

**Files:**
- Create: `ops/brain/brain-status.sh`

Simplest script — validates the shared module works end-to-end.

- [ ] **Step 1: Write brain-status.sh**

```bash
#!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

python3 - "$@" << 'PYTHON'
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath("__file__")), *sys.argv[0].rsplit("/", 2)[:-2]) if False else "")

# Add project root to path so we can import the module
import importlib.util
script_dir = os.environ.get("SCRIPT_DIR", os.path.dirname(os.path.abspath(__file__)))
spec = importlib.util.spec_from_file_location("_brain_common", os.path.join(script_dir, "_brain_common.py"))
brain = importlib.util.module_from_spec(spec)
spec.loader.exec_module(brain)

config = brain.load_config()
nb_id = config["NOTEBOOKLM_NOTEBOOK_ID"]

# Get notebook info from API
nb = brain.notebooklm_request(config, "GET", f"/notebooks/{nb_id}")

# Count sources
sources = nb.get("sources", [])
total_words = sum(s.get("metadata", {}).get("wordCount", 0) for s in sources)
total_tokens = sum(s.get("metadata", {}).get("tokenCount", 0) for s in sources)

# Check local mirror
from pathlib import Path
handoffs = list((Path(config["PROJECT_ROOT"]) / "handoffs").glob("*.md"))
mirrored = len(handoffs)

# Get last sync time
inventory_path = Path(config["PROJECT_ROOT"]) / ".brain" / "inventory.json"
last_sync = "never"
if inventory_path.exists():
    import json
    inv = json.loads(inventory_path.read_text())
    last_sync = inv.get("last_sync", "never")

# Check auth
account = config.get("GOOGLE_ACCOUNT", "unknown")

print(f"Notebook: {nb.get('title', 'unknown')} ({nb_id[:8]}...)")
print(f"Sources:  {len(sources)} ({total_words:,} words / {total_tokens:,} tokens)")
print(f"Last sync: {last_sync}")
print(f"Auth:     {account} (token valid)")
print(f"Local mirror: {mirrored}/{len(sources)} synced")

# Log
brain.log_session(config, "STATUS",
    f"sources={len(sources)} words={total_words} tokens={total_tokens} auth=valid mirror={mirrored}/{len(sources)}")
PYTHON
```

- [ ] **Step 2: Make executable**

```bash
chmod +x ops/brain/brain-status.sh
```

- [ ] **Step 3: Fix the script — use SCRIPT_DIR properly**

The heredoc approach won't pass `SCRIPT_DIR` cleanly. Replace the script with a simpler approach that calls Python directly:

```bash
#!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export BRAIN_SCRIPT_DIR="$SCRIPT_DIR"

exec python3 "$SCRIPT_DIR/_brain_status.py" "$@"
```

And create the companion Python file `ops/brain/_brain_status.py`:

```python
#!/usr/bin/env python3
"""brain-status: Show NotebookLM notebook state report."""

import json
import os
import sys
from pathlib import Path

# Import shared module from same directory
sys.path.insert(0, os.environ.get("BRAIN_SCRIPT_DIR", os.path.dirname(os.path.abspath(__file__))))
import _brain_common as brain

config = brain.load_config()
nb_id = config["NOTEBOOKLM_NOTEBOOK_ID"]

# Get notebook info from API
nb = brain.notebooklm_request(config, "GET", f"/notebooks/{nb_id}")

# Count sources
sources = nb.get("sources", [])
total_words = sum(s.get("metadata", {}).get("wordCount", 0) for s in sources)
total_tokens = sum(s.get("metadata", {}).get("tokenCount", 0) for s in sources)

# Check local mirror
handoffs = list((Path(config["PROJECT_ROOT"]) / "handoffs").glob("*.md"))
mirrored = len(handoffs)

# Get last sync time
inventory_path = Path(config["PROJECT_ROOT"]) / ".brain" / "inventory.json"
last_sync = "never"
if inventory_path.exists():
    inv = json.loads(inventory_path.read_text())
    last_sync = inv.get("last_sync", "never")

# Auth account
account = config.get("GOOGLE_ACCOUNT", "unknown")

print(f"Notebook:     {nb.get('title', 'unknown')} ({nb_id[:8]}...)")
print(f"Sources:      {len(sources)} ({total_words:,} words / {total_tokens:,} tokens)")
print(f"Last sync:    {last_sync}")
print(f"Auth:         {account} (token valid)")
print(f"Local mirror: {mirrored}/{len(sources)} synced")

brain.log_session(config, "STATUS",
    f"sources={len(sources)} words={total_words} tokens={total_tokens} auth=valid mirror={mirrored}/{len(sources)}")
```

- [ ] **Step 4: Test brain-status.sh**

Run:
```bash
/home/jgatlit/apps/CMS/ops/brain/brain-status.sh
```

Expected:
```
Notebook:     CMS-aiChemist (7d8b1917...)
Sources:      4 (9,095 words / 17,962 tokens)
Last sync:    never
Auth:         jonathan@noboxai.com (token valid)
Local mirror: 4/4 synced
```

- [ ] **Step 5: Verify session log was created**

Run:
```bash
cat /home/jgatlit/apps/CMS/.brain/sessions/$(date -u +%Y-%m-%d).log
```

Expected: One STATUS line with timestamp.

- [ ] **Step 6: Commit**

```bash
git add ops/brain/brain-status.sh ops/brain/_brain_status.py
git commit -m "feat(brain): add brain-status.sh — notebook state report"
```

---

### Task 3: brain-pull.sh

**Files:**
- Create: `ops/brain/brain-pull.sh`
- Create: `ops/brain/_brain_pull.py`

- [ ] **Step 1: Write _brain_pull.py**

```python
#!/usr/bin/env python3
"""brain-pull: Sync source inventory from NotebookLM API."""

import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path

sys.path.insert(0, os.environ.get("BRAIN_SCRIPT_DIR", os.path.dirname(os.path.abspath(__file__))))
import _brain_common as brain

config = brain.load_config()
nb_id = config["NOTEBOOKLM_NOTEBOOK_ID"]
root = Path(config["PROJECT_ROOT"])
show_diff = "--diff" in sys.argv

# Fetch notebook with source list
nb = brain.notebooklm_request(config, "GET", f"/notebooks/{nb_id}")
api_sources = nb.get("sources", [])

# Build inventory
inventory = {
    "notebook_id": nb_id,
    "notebook_title": nb.get("title", ""),
    "last_sync": datetime.now(timezone.utc).isoformat(timespec="seconds"),
    "sources": []
}

api_titles = set()
for s in api_sources:
    source_entry = {
        "id": s["sourceId"]["id"],
        "title": s.get("title", ""),
        "wordCount": s.get("metadata", {}).get("wordCount", 0),
        "tokenCount": s.get("metadata", {}).get("tokenCount", 0),
        "timestamp": s.get("metadata", {}).get("sourceAddedTimestamp", ""),
        "status": s.get("settings", {}).get("status", ""),
    }
    inventory["sources"].append(source_entry)
    api_titles.add(s.get("title", ""))

# Write inventory
inventory_path = root / ".brain" / "inventory.json"
inventory_path.parent.mkdir(parents=True, exist_ok=True)
inventory_path.write_text(json.dumps(inventory, indent=2) + "\n")

# Compare with local mirrors
handoffs_dir = root / "handoffs"
local_files = set()
if handoffs_dir.exists():
    for md in handoffs_dir.glob("*.md"):
        # Read first heading as title
        title = md.stem
        for line in md.read_text().splitlines():
            if line.startswith("# "):
                title = line[2:].strip()
                break
        local_files.add(md.name)

# Count synced vs new
cloud_count = len(api_sources)
local_count = len(list(handoffs_dir.glob("*.md"))) if handoffs_dir.exists() else 0
new_in_cloud = max(0, cloud_count - local_count)

if show_diff:
    print(f"=== Cloud sources ({cloud_count}) ===")
    for s in inventory["sources"]:
        print(f"  {s['title']} ({s['wordCount']} words)")
    print(f"\n=== Local mirrors ({local_count}) ===")
    if handoffs_dir.exists():
        for md in sorted(handoffs_dir.glob("*.md")):
            print(f"  {md.name}")
    if new_in_cloud > 0:
        print(f"\n⚠ {new_in_cloud} cloud source(s) may not have local mirrors.")
        print("  (API does not return content — re-upload or manually export)")
else:
    print(f"{cloud_count} sources synced. {new_in_cloud} new sources detected.")

brain.log_session(config, "PULL", f"sources={cloud_count} new={new_in_cloud} missing=0")
```

- [ ] **Step 2: Write brain-pull.sh wrapper**

```bash
#!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export BRAIN_SCRIPT_DIR="$SCRIPT_DIR"

exec python3 "$SCRIPT_DIR/_brain_pull.py" "$@"
```

- [ ] **Step 3: Make executable**

```bash
chmod +x ops/brain/brain-pull.sh
```

- [ ] **Step 4: Test brain-pull.sh**

Run:
```bash
/home/jgatlit/apps/CMS/ops/brain/brain-pull.sh
```

Expected: `4 sources synced. 0 new sources detected.`

- [ ] **Step 5: Test brain-pull.sh --diff**

Run:
```bash
/home/jgatlit/apps/CMS/ops/brain/brain-pull.sh --diff
```

Expected: Lists 4 cloud sources with word counts, 4 local mirrors.

- [ ] **Step 6: Verify inventory.json was created**

Run:
```bash
python3 -c "import json; d=json.load(open('/home/jgatlit/apps/CMS/.brain/inventory.json')); print(f'Sources: {len(d[\"sources\"])}, Last sync: {d[\"last_sync\"]}')"
```

Expected: `Sources: 4, Last sync: 2026-04-06T...`

- [ ] **Step 7: Commit**

```bash
git add ops/brain/brain-pull.sh ops/brain/_brain_pull.py
git commit -m "feat(brain): add brain-pull.sh — sync source inventory from NotebookLM"
```

---

### Task 4: brain-query.sh

**Files:**
- Create: `ops/brain/brain-query.sh`
- Create: `ops/brain/_brain_query.py`

The core script — multi-source grounded Q&A.

- [ ] **Step 1: Write _brain_query.py**

```python
#!/usr/bin/env python3
"""brain-query: Multi-source grounded Q&A via Gemini."""

import argparse
import json
import os
import sys

sys.path.insert(0, os.environ.get("BRAIN_SCRIPT_DIR", os.path.dirname(os.path.abspath(__file__))))
import _brain_common as brain


def build_grounding_prompt(sources, question):
    """Construct grounding prompt with all source content."""
    parts = ["You are grounded in the following NotebookLM sources. "
             "Answer ONLY from this context. If the answer is not in the sources, say so.\n"]

    for i, src in enumerate(sources, 1):
        parts.append(f"\n--- SOURCE {i}: {src['title']} ---\n{src['content']}\n")

    parts.append(f"\n--- QUESTION ---\n{question}")
    return "\n".join(parts)


def main():
    parser = argparse.ArgumentParser(description="Query NotebookLM brain via Gemini")
    parser.add_argument("question", nargs="?", default=None, help="Question to ask")
    parser.add_argument("--source", action="append", default=None,
                        help="Filter to specific source(s) by title substring")
    parser.add_argument("--format", choices=["text", "json"], default="text",
                        help="Output format (default: text)")
    parser.add_argument("--model", default="gemini-2.5-flash",
                        help="Gemini model (default: gemini-2.5-flash)")
    args = parser.parse_args()

    # Read question from arg or stdin
    question = args.question
    if question is None:
        if not sys.stdin.isatty():
            question = sys.stdin.read().strip()
        if not question:
            print("ERROR: No question provided. Usage: brain-query.sh \"your question\"",
                  file=sys.stderr)
            sys.exit(1)

    config = brain.load_config()

    # Load sources
    sources = brain.load_sources(config, filter_titles=args.source)
    if not sources:
        print("ERROR: No sources found in handoffs/. Run brain-pull.sh first.", file=sys.stderr)
        sys.exit(1)

    # Check total size — Gemini 2.5 Flash has ~1M token context but be conservative
    total_chars = sum(len(s["content"]) for s in sources)
    max_chars = 500_000  # ~125K tokens, safe margin
    if total_chars > max_chars:
        print(f"WARNING: Combined sources ({total_chars:,} chars) exceed limit. "
              f"Truncating oldest sources.", file=sys.stderr)
        while total_chars > max_chars and len(sources) > 1:
            removed = sources.pop(0)
            total_chars -= len(removed["content"])
            print(f"  Trimmed: {removed['title']}", file=sys.stderr)

    # Build prompt and query
    prompt = build_grounding_prompt(sources, question)
    answer = brain.gemini_request(config, prompt, model=args.model)

    # Output
    if args.format == "json":
        result = {
            "question": question,
            "answer": answer,
            "model": args.model,
            "sources_used": [s["title"] for s in sources],
        }
        print(json.dumps(result, indent=2))
    else:
        print(answer)

    # Log
    tokens_in = total_chars // 4  # rough estimate
    tokens_out = len(answer) // 4
    brain.log_session(config, "QUERY",
        f"model={args.model} sources={len(sources)} tokens_in≈{tokens_in} tokens_out≈{tokens_out}\n"
        f"  Q: \"{question[:100]}\"\n"
        f"  A: {answer[:200]}...")


if __name__ == "__main__":
    main()
```

- [ ] **Step 2: Write brain-query.sh wrapper**

```bash
#!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export BRAIN_SCRIPT_DIR="$SCRIPT_DIR"

exec python3 "$SCRIPT_DIR/_brain_query.py" "$@"
```

- [ ] **Step 3: Make executable**

```bash
chmod +x ops/brain/brain-query.sh
```

- [ ] **Step 4: Test basic query**

Run:
```bash
/home/jgatlit/apps/CMS/ops/brain/brain-query.sh "What are the 7 architecture principles?"
```

Expected: A grounded answer listing all 7 principles from the spec.

- [ ] **Step 5: Test source filter**

Run:
```bash
/home/jgatlit/apps/CMS/ops/brain/brain-query.sh --source "Penpot" "What is Penpot's role?"
```

Expected: Answer grounded only in the Penpot research document.

- [ ] **Step 6: Test JSON output**

Run:
```bash
/home/jgatlit/apps/CMS/ops/brain/brain-query.sh --format json "What systems are defined?" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'Sources used: {len(d[\"sources_used\"])}')"
```

Expected: `Sources used: 4`

- [ ] **Step 7: Test stdin pipe**

Run:
```bash
echo "What is EmDash?" | /home/jgatlit/apps/CMS/ops/brain/brain-query.sh
```

Expected: Grounded answer about EmDash from the sources.

- [ ] **Step 8: Verify session log**

Run:
```bash
tail -5 /home/jgatlit/apps/CMS/.brain/sessions/$(date -u +%Y-%m-%d).log
```

Expected: QUERY entries with question excerpts.

- [ ] **Step 9: Commit**

```bash
git add ops/brain/brain-query.sh ops/brain/_brain_query.py
git commit -m "feat(brain): add brain-query.sh — multi-source grounded Q&A via Gemini"
```

---

### Task 5: brain-push.sh

**Files:**
- Create: `ops/brain/brain-push.sh`
- Create: `ops/brain/_brain_push.py`

- [ ] **Step 1: Write _brain_push.py**

```python
#!/usr/bin/env python3
"""brain-push: Upload new source to NotebookLM and save local mirror."""

import json
import os
import re
import sys
from pathlib import Path

sys.path.insert(0, os.environ.get("BRAIN_SCRIPT_DIR", os.path.dirname(os.path.abspath(__file__))))
import _brain_common as brain


def slugify(title):
    """Convert title to filesystem-safe slug."""
    slug = title.lower().strip()
    slug = re.sub(r'[^\w\s-]', '', slug)
    slug = re.sub(r'[-\s]+', '_', slug)
    return slug[:80]


def main():
    if len(sys.argv) < 2:
        print("Usage: brain-push.sh \"Source Title\" [file_path]", file=sys.stderr)
        print("  Or pipe content: echo \"content\" | brain-push.sh \"Title\"", file=sys.stderr)
        sys.exit(1)

    title = sys.argv[1]

    # Read content from file arg or stdin
    if len(sys.argv) >= 3:
        file_path = sys.argv[2]
        content = Path(file_path).read_text()
    elif not sys.stdin.isatty():
        content = sys.stdin.read()
    else:
        print("ERROR: No content provided. Pass a file path or pipe content.", file=sys.stderr)
        sys.exit(1)

    if not content.strip():
        print("ERROR: Content is empty.", file=sys.stderr)
        sys.exit(1)

    config = brain.load_config()
    root = Path(config["PROJECT_ROOT"])
    nb_id = config["NOTEBOOKLM_NOTEBOOK_ID"]

    # Step 1: Save local mirror first (nothing lost if API fails)
    handoffs_dir = root / "handoffs"
    handoffs_dir.mkdir(parents=True, exist_ok=True)
    mirror_filename = f"{slugify(title)}.md"
    mirror_path = handoffs_dir / mirror_filename
    mirror_path.write_text(content)
    print(f"Local mirror saved: {mirror_path}")

    # Step 2: Upload to NotebookLM
    payload = {
        "userContents": [{
            "textContent": {
                "sourceName": title,
                "content": content
            }
        }]
    }
    result = brain.notebooklm_request(config, "POST",
        f"/notebooks/{nb_id}/sources:batchCreate", body=payload)

    # Extract source info from response
    new_sources = result.get("sources", [])
    if new_sources:
        src = new_sources[0]
        source_id = src.get("sourceId", {}).get("id", "unknown")
        word_count = src.get("metadata", {}).get("wordCount", 0)
        status = src.get("settings", {}).get("status", "unknown")
        print(f"Uploaded: \"{title}\" (id={source_id[:8]}..., {word_count} words, {status})")

        # Step 3: Update inventory
        inventory_path = root / ".brain" / "inventory.json"
        inventory = {}
        if inventory_path.exists():
            inventory = json.loads(inventory_path.read_text())

        inv_sources = inventory.get("sources", [])
        inv_sources.append({
            "id": source_id,
            "title": title,
            "wordCount": word_count,
            "tokenCount": src.get("metadata", {}).get("tokenCount", 0),
            "timestamp": src.get("metadata", {}).get("sourceAddedTimestamp", ""),
            "status": status,
            "local_mirror": mirror_filename,
        })
        inventory["sources"] = inv_sources
        inventory_path.parent.mkdir(parents=True, exist_ok=True)
        inventory_path.write_text(json.dumps(inventory, indent=2) + "\n")

        # Log
        brain.log_session(config, "PUSH",
            f"title=\"{title}\" source_id={source_id} words={word_count}")
    else:
        print("WARNING: Upload succeeded but no source metadata returned.", file=sys.stderr)
        brain.log_session(config, "PUSH", f"title=\"{title}\" result=no_metadata")


if __name__ == "__main__":
    main()
```

- [ ] **Step 2: Write brain-push.sh wrapper**

```bash
#!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export BRAIN_SCRIPT_DIR="$SCRIPT_DIR"

exec python3 "$SCRIPT_DIR/_brain_push.py" "$@"
```

- [ ] **Step 3: Make executable**

```bash
chmod +x ops/brain/brain-push.sh
```

- [ ] **Step 4: Test push from file**

Create a test file first:
```bash
echo "# Test Source\n\nThis is a test document for validating brain-push." > /tmp/brain-test.md
```

Run:
```bash
/home/jgatlit/apps/CMS/ops/brain/brain-push.sh "Brain Interface Test" /tmp/brain-test.md
```

Expected:
```
Local mirror saved: /home/jgatlit/apps/CMS/handoffs/brain_interface_test.md
Uploaded: "Brain Interface Test" (id=abcd1234..., X words, SOURCE_STATUS_COMPLETE)
```

- [ ] **Step 5: Test push from stdin**

Run:
```bash
echo "# Stdin Test\n\nPushed from stdin." | /home/jgatlit/apps/CMS/ops/brain/brain-push.sh "Stdin Push Test"
```

Expected: Local mirror saved + uploaded confirmation.

- [ ] **Step 6: Verify the pushed sources appear in brain-status**

Run:
```bash
/home/jgatlit/apps/CMS/ops/brain/brain-status.sh
```

Expected: Sources count increased (6 instead of 4).

- [ ] **Step 7: Clean up test sources**

Note: The NotebookLM API `sources:batchDelete` can remove test sources. Run:
```bash
python3 -c "
import sys; sys.path.insert(0, '/home/jgatlit/apps/CMS/ops/brain')
import _brain_common as brain
c = brain.load_config()
nb_id = c['NOTEBOOKLM_NOTEBOOK_ID']
# Get current inventory to find test source IDs
import json
inv = json.load(open('/home/jgatlit/apps/CMS/.brain/inventory.json'))
test_ids = [s['id'] for s in inv['sources'] if 'Test' in s.get('title', '')]
if test_ids:
    brain.notebooklm_request(c, 'POST', f'/notebooks/{nb_id}/sources:batchDelete',
        body={'sourceIds': test_ids})
    print(f'Deleted {len(test_ids)} test sources')
else:
    print('No test sources to delete')
"
```

Also remove test local mirrors:
```bash
rm -f /home/jgatlit/apps/CMS/handoffs/brain_interface_test.md /home/jgatlit/apps/CMS/handoffs/stdin_push_test.md
```

- [ ] **Step 8: Commit**

```bash
git add ops/brain/brain-push.sh ops/brain/_brain_push.py
git commit -m "feat(brain): add brain-push.sh — upload sources to NotebookLM with local mirror"
```

---

### Task 6: Repo Scaffolding & .gitignore

> **Note:** If this repo has no git init yet, run Task 6 Steps 1-3 BEFORE Task 1 so commits work. Then return to Task 1.

**Files:**
- Create: `.gitignore`
- Create: `.brain/.gitkeep`
- Create: `ops/brain/__init__.py` (empty, allows Python imports)

- [ ] **Step 1: Initialize git repo (skip if already initialized)**

```bash
cd /home/jgatlit/apps/CMS && git init
```

- [ ] **Step 2: Create .gitignore**

```
# Brain session data (transient)
.brain/sessions/
.brain/config.env
.brain/cache/

# Environment
.env

# Superpowers brainstorm artifacts
.superpowers/

# Python
__pycache__/
*.pyc

# OS
.DS_Store
```

- [ ] **Step 3: Create .brain/.gitkeep and __init__.py**

```bash
touch .brain/.gitkeep ops/brain/__init__.py
```

- [ ] **Step 4: Stage and commit everything**

```bash
git add .gitignore .brain/.gitkeep ops/brain/__init__.py CLAUDE.md docs/ handoffs/
git commit -m "feat: initialize CMS-aiChemist repo with brain interface, specs, and handoffs

- ops/brain/: NotebookLM brain interface (query, push, pull, status)
- handoffs/: NotebookLM source mirrors (4 architecture/research docs)
- docs/superpowers/: design spec and implementation plan
- CLAUDE.md: project instructions with NotebookLM-as-brain paradigm"
```

Note: Do NOT commit `.env` (contains API keys).

---

### Task 7: End-to-End Validation

No new files. Run the full PULL → QUERY → PUSH cycle to validate the brain interface works.

- [ ] **Step 1: Run the full session cycle**

```bash
# PULL — sync inventory
/home/jgatlit/apps/CMS/ops/brain/brain-pull.sh

# STATUS — verify state
/home/jgatlit/apps/CMS/ops/brain/brain-status.sh

# QUERY — ask a cross-source question
/home/jgatlit/apps/CMS/ops/brain/brain-query.sh "What is the relationship between Penpot, Stitch, and Claude Code in the implementation workflow?"

# PUSH — write a session summary back to the brain
/home/jgatlit/apps/CMS/ops/brain/brain-push.sh "Session Notes - 2026-04-06 - Brain Interface Built" <<'EOF'
# Brain Interface Implementation Complete

## What was built
- ops/brain/ with 4 scripts: brain-query, brain-push, brain-pull, brain-status
- Shared Python module _brain_common.py with zero external dependencies
- Session logging at .brain/sessions/
- Source inventory sync at .brain/inventory.json

## Key decisions
- Bash + Python hybrid: bash wrappers call Python for API/JSON work
- Query bridge uses Gemini 2.5 Flash with local source mirrors as grounding context
- Local mirror in handoffs/ is always written before API push (nothing lost on failure)
- All scripts callable from any working directory via project root resolution

## Known limitations
- NotebookLM API has no chat/query endpoint — bridge uses Gemini with source content
- Source GET returns metadata only, not content body — must maintain local mirrors
- NotebookLM API does not support notebook rename or delete
EOF

# STATUS — verify new source count
/home/jgatlit/apps/CMS/ops/brain/brain-status.sh
```

Expected: Pull shows 4 synced, query returns grounded answer, push uploads successfully, final status shows 5 sources.

- [ ] **Step 2: Verify session log completeness**

```bash
cat /home/jgatlit/apps/CMS/.brain/sessions/$(date -u +%Y-%m-%d).log
```

Expected: PULL, STATUS, QUERY, PUSH, STATUS entries in order.

- [ ] **Step 3: Final commit with all state files**

```bash
cd /home/jgatlit/apps/CMS
git add .brain/inventory.json
git commit -m "chore(brain): add initial inventory after end-to-end validation"
```
