diff --git a/.github/workflows/ob1-gate.yml b/.github/workflows/ob1-gate-v2.yml similarity index 99% rename from .github/workflows/ob1-gate.yml rename to .github/workflows/ob1-gate-v2.yml index 86571c0ac..c69f519de 100644 --- a/.github/workflows/ob1-gate.yml +++ b/.github/workflows/ob1-gate-v2.yml @@ -1,5 +1,9 @@ name: OB1 PR Gate +# This workflow used to live at `.github/workflows/ob1-gate.yml`. +# It was renamed to force GitHub Actions to register a fresh workflow after +# the old workflow id began creating runs with zero jobs. +# # Branch protection recommendation: # After this action is working, enable branch protection on main: # - Require the "OB1 PR Gate" workflow to pass its checks diff --git a/.github/workflows/ob1-pr-followups.yml b/.github/workflows/ob1-pr-followups.yml index af3a225d2..e22180dc2 100644 --- a/.github/workflows/ob1-pr-followups.yml +++ b/.github/workflows/ob1-pr-followups.yml @@ -4,6 +4,7 @@ on: workflow_run: workflows: - "OB1 PR Gate" + - ".github/workflows/ob1-gate-v2.yml" - ".github/workflows/ob1-gate.yml" types: [completed] diff --git a/.github/workflows/update-readme-contributions.yml b/.github/workflows/update-readme-contributions.yml new file mode 100644 index 000000000..636b80126 --- /dev/null +++ b/.github/workflows/update-readme-contributions.yml @@ -0,0 +1,38 @@ +name: Update README Contributions + +on: + workflow_dispatch: + schedule: + - cron: "17 9 * * *" + +permissions: + contents: write + pull-requests: write + +jobs: + update: + runs-on: ubuntu-latest + steps: + - name: Checkout main + uses: actions/checkout@v4 + with: + ref: main + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Update README recent contributions + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node scripts/update-readme-contributions.mjs + + - name: Open README update PR + uses: peter-evans/create-pull-request@v6 + with: + commit-message: "[docs] Refresh README recent contributions" + title: "[docs] Refresh README recent contributions" + body: "Automated refresh of the generated Recent Contributions section in README.md." + branch: automation/readme-recent-contributions + delete-branch: true diff --git a/.gitignore b/.gitignore index d85ab2ae7..3ba197c80 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,15 @@ node_modules/ .planning/ supabase/ +# Maintainer-local overlays (not part of upstream contribution contract) +.local/ +.agent/ +.claude.json + +# Python caches +__pycache__/ +*.pyc + # Credentials & runtime state .env .env.local @@ -14,3 +23,7 @@ sync-log.json # Build artifacts deno.lock +docs/walkthroughs/*/public/ +docs/assets/agent-memory/promotional/generated-backgrounds/**/result.json +docs/assets/agent-memory/screenshots/**/.chrome-*/ +integrations/openclaw-agent-memory/dist/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..4b1f6851a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,61 @@ +# OB1 Agent Instructions + +## Parallel Agent Worktrees + +When multiple AI agents or assistant chats work on this repo, do not put them in the same checkout. + +### Setup pattern + +- Treat the main repo checkout as the canonical repo for pulling, inspection, and creating worktrees. +- Create one Git worktree per active agent, task, or PR-sized workstream. +- Give each worktree a descriptive folder name and a matching branch name. +- Start every agent task by naming the exact absolute worktree path it owns. +- The assigned worktree path is the boundary. The chat is not the boundary. + +### Agent assignment template + +Start each parallel-agent task with: + +```text +Repository worktree: +/ABSOLUTE/PATH/TO/PROJECT-WORKTREE + +Branch: +codex/SHORT-TASK-NAME + +Task: +DESCRIBE THE EXACT WORK. +``` + +### Rules + +- Do not switch branches in the canonical repo while another agent may be working. +- Do not edit sibling worktrees unless explicitly asked. +- Before staging or committing, run `git status --short` and stage only files that belong to the current task. +- If `main` or another branch changed underneath the worktree, pause before merging or rebasing unless the task explicitly says to finish the PR end to end. +- After a branch is merged and the worktree is clean, remove the finished worktree with `git worktree remove /ABSOLUTE/PATH/TO/PROJECT-WORKTREE`. + +### Quick checks + +- If another chat suddenly changed branches, both chats were probably in the same working directory. +- If `git worktree add` says a branch is already checked out, create a new branch name or remove the old clean worktree. +- If cleanup fails, inspect `git status --short` and preserve uncommitted work. + +## Required Step: Update Linear + +- For feature work tied to a Linear issue, update Linear at the start of the work, at meaningful checkpoints, and before handing back to the user. +- Use the parent issue as the living implementation log and keep child issues aligned with the files and behavior being changed. +- For the OB1 Agent Memory / OpenClaw launch work, the parent issue is `NAT-833`. Record architecture notes, implementation milestones, blockers, and verification results there. +- Do not wait until the end to document decisions. If a decision changes schema, API contract, trust policy, user-facing workflow, or publishing path, capture it in Linear while it is still fresh. + +## Agent Memory Product Guardrails + +- Keep `OB1 Agent Memory` runtime-neutral. OpenClaw is the flagship launch runtime, not the product boundary. +- Treat inferred or generated memory as evidence by default. Instruction-grade memory requires human confirmation or trusted import. +- Avoid raw transcript, model reasoning trace, secret, and large-code-block storage by default. +- Avoid profanity in all content. Keep docs, examples, seed data, UI copy, prompts, walkthroughs, and generated assets clean and professional. +- Prefer diagram-first documentation for this work: diagram, short explanation, copy-paste setup, then deeper reference. +- Carry Nate B. Jones / OB1 provenance through product surfaces, docs, diagrams, screenshots, and starter seed data. Keep it subtle and useful: micro-branding, source labels, logo marks, and provenance language instead of loud marketing copy. +- Treat public OB1 assets as helpful-first audience growth for Nate Jones. Every public guide, recipe, tutorial, package page, release note, and walkthrough should point back to Nate's Substack and site in a natural way: https://substack.com/@natesnewsletter and https://natebjones.com. +- Make the case by being genuinely useful. The CTA should feel earned: "Nate gives away practical systems like this" rather than generic marketing copy. +- For ClawHub/OpenClaw publishing, do not fall back to Jonathan's personal handle or any non-Nate namespace. If `@natebjones` / Nate OB1 ownership is not available, stop and record the blocker. diff --git a/CLAUDE.md b/CLAUDE.md index 53941a657..a86a68d6b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,12 +24,54 @@ resources/ — Official companion files and packaged exports. Every contribution lives in its own subfolder under the right category and must include `README.md` + `metadata.json`. +## Parallel Agent Worktrees + +When multiple AI agents or assistant chats work on this repo, do not put them in the same checkout. + +### Setup pattern + +- Treat the main repo checkout as the canonical repo for pulling, inspection, and creating worktrees. +- Create one Git worktree per active agent, task, or PR-sized workstream. +- Give each worktree a descriptive folder name and a matching branch name. +- Start every agent task by naming the exact absolute worktree path it owns. +- The assigned worktree path is the boundary. The chat is not the boundary. + +### Agent assignment template + +Start each parallel-agent task with: + +```text +Repository worktree: +/ABSOLUTE/PATH/TO/PROJECT-WORKTREE + +Branch: +codex/SHORT-TASK-NAME + +Task: +DESCRIBE THE EXACT WORK. +``` + +### Rules + +- Do not switch branches in the canonical repo while another agent may be working. +- Do not edit sibling worktrees unless explicitly asked. +- Before staging or committing, run `git status --short` and stage only files that belong to the current task. +- If `main` or another branch changed underneath the worktree, pause before merging or rebasing unless the task explicitly says to finish the PR end to end. +- After a branch is merged and the worktree is clean, remove the finished worktree with `git worktree remove /ABSOLUTE/PATH/TO/PROJECT-WORKTREE`. + +### Quick checks + +- If another chat suddenly changed branches, both chats were probably in the same working directory. +- If `git worktree add` says a branch is already checked out, create a new branch name or remove the old clean worktree. +- If cleanup fails, inspect `git status --short` and preserve uncommitted work. + ## Guard Rails - **Never modify the core `thoughts` table structure.** Adding columns is fine; altering or dropping existing ones is not. - **No credentials, API keys, or secrets in any file.** Use environment variables. - **No binary blobs** over 1MB. No `.exe`, `.dmg`, `.zip`, `.tar.gz`. - **No `DROP TABLE`, `DROP DATABASE`, `TRUNCATE`, or unqualified `DELETE FROM`** in SQL files. +- **Avoid profanity in all content.** Keep docs, examples, seed data, UI copy, prompts, walkthroughs, and generated assets clean and professional. - **MCP servers must be remote (Supabase Edge Functions), not local.** Never use `claude_desktop_config.json`, `StdioServerTransport`, or local Node.js servers. All extensions deploy as Edge Functions and connect via Claude Desktop's custom connectors UI (Settings → Connectors → Add custom connector → paste URL). See `docs/01-getting-started.md` Step 7 for the pattern. ## PR Standards @@ -37,13 +79,23 @@ Every contribution lives in its own subfolder under the right category and must - **Title format:** `[category] Short description` (e.g., `[recipes] Email history import via Gmail API`, `[skills] Panning for Gold standalone skill pack`) - **Branch convention:** `contrib//` - **Commit prefixes:** `[category]` matching the contribution type -- Every PR must pass the automated review checks in `.github/workflows/ob1-review.yml` before human review +- Every PR must pass the automated review checks in `.github/workflows/ob1-gate-v2.yml` before human review - See `CONTRIBUTING.md` for the full review process, metadata.json template, and README requirements ## Key Files - `CONTRIBUTING.md` — Source of truth for contribution rules, metadata format, and the review process -- `.github/workflows/ob1-review.yml` — Automated PR review +- `.github/workflows/ob1-gate-v2.yml` — Automated PR gate +- `.github/workflows/claude-review.yml` — Maintainer-triggered Claude PR review - `.github/metadata.schema.json` — JSON schema for metadata.json validation - `.github/PULL_REQUEST_TEMPLATE.md` — PR description template - `LICENSE.md` — FSL-1.1-MIT terms + +## Local GSD Execution Layer + +This repo also has a maintainer-local GSD layer in `.planning/`. + +- If `.planning/` exists, use it for local brownfield planning and phased execution. +- Start with `.planning/STATE.md`, then read `.planning/PROJECT.md`, `.planning/ROADMAP.md`, and the relevant `.planning/codebase/*.md` documents. +- Keep `.planning/` local. It is gitignored intentionally and is not part of the public contribution contract or upstream PR scope. +- Public contributor rules still come from `CLAUDE.md`, `AGENTS.md`, `CONTRIBUTING.md`, and the committed repo files. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 780f837f9..c73e8d1d5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -128,6 +128,7 @@ grant select, insert, update, delete on table public.your_table to service_role; - **"Cross-Extension Integration"** prominently documenting connections to other extensions - **"Next Steps"** linking to the next extension - **Tool audit link** — Any extension or integration that exposes MCP tools must link to the [MCP Tool Audit & Optimization Guide](docs/05-tool-audit.md) in its "Next Steps" or closing section. This helps users manage their tool surface area as they add extensions. The link is checked by the automated review. +- **MCP tool annotations** — Any extension or integration that exposes MCP tools must mark read-only tools with `annotations: { readOnlyHint: true }` and write tools with `annotations: { readOnlyHint: false, openWorldHint: false, destructiveHint: false }` unless the tool really can touch arbitrary external resources or destroy data. ChatGPT uses this metadata to distinguish read tools from write actions. - **Remote MCP setup** — MCP servers must be deployed as Supabase Edge Functions and connected via custom connectors (URL-based). Do NOT use local Node.js servers or `claude_desktop_config.json`. See the [extension template](extensions/_template/) for the correct pattern. **Primitives** additionally require: @@ -298,3 +299,4 @@ Every PR is checked against these rules. All must pass before human review. 13. **Internal links** — All relative links in READMEs resolve to existing files 14. **Remote MCP pattern** — Extensions and integrations must use remote MCP via Supabase Edge Functions. No `claude_desktop_config.json`, no local Node.js stdio servers. See the [Getting Started guide](docs/01-getting-started.md) for the correct pattern 15. **Tool audit link** — Extensions and integrations must link to the [MCP Tool Audit & Optimization Guide](docs/05-tool-audit.md) in their README. This ensures users are aware of tool surface area management as they add capabilities +16. **MCP tool annotations** — Read-only tools include `readOnlyHint: true`; write tools include `readOnlyHint: false`, `openWorldHint`, and `destructiveHint` diff --git a/README.md b/README.md index f2daea9e2..f88efcea4 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,8 @@ This isn't a notes app. It's a database with vector search and an open protocol > Open Brain was created by [Nate B. Jones](https://natesnewsletter.substack.com/). Follow the [Substack](https://natesnewsletter.substack.com/) for updates, discussion, and the companion prompt pack. Join the [Discord](https://discord.gg/Cgh9WJEkeG) for real-time help and community. -https://github.com/user-attachments/assets/80a79b09-f323-42c6-b11b-de10bb6fa36f - ## Getting Started -https://github.com/user-attachments/assets/85208d73-112b-4204-82fd-d03b6c397a8b - Never built an Open Brain? Start here: 1. **[Setup Guide](docs/01-getting-started.md)** — Build the full system (database, AI gateway, Slack capture, MCP server) in about 45 minutes. No coding experience needed. Or watch the [video walkthrough](https://vimeo.com/1174979042/f883f6489a) (~27 min). @@ -25,9 +21,38 @@ Never built an Open Brain? Start here: **If you hit a wall:** We built a [FAQ](docs/03-faq.md) that covers the most common questions and gotchas. And if you need real-time help, we created dedicated AI assistants that know this system inside and out: a [Claude Skill](https://www.notion.so/product-templates/Open-Brain-Companion-Claude-Skill-31a5a2ccb526802797caeb37df3ba3cb?source=copy_link), a [ChatGPT Custom GPT](https://chatgpt.com/g/g-69a892b6a7708191b00e48ff655d5597-nate-jones-open-brain-assistant), and a [Gemini GEM](https://gemini.google.com/gem/1fDsAENjhdku-3RufY7ystbS1Md8MtDCg?usp=sharing). Use whichever one matches the AI tool you already use. -## Extensions — The Learning Path +## Recent Contributions + +The 20 most recent merged PRs. This list is generated from GitHub and refreshes daily. Last updated: 2026-05-22. + + + +| Contribution | What changed | Creator | +| ------------ | ------------ | ------- | +| [Provenance chains — derivation tracking](recipes/provenance-chains/) | Provenance chains — derivation tracking. | [@alanshurafa](https://github.com/alanshurafa) | +| [Open Brain Dashboard Pro — Next.js 16 + iron-session](dashboards/open-brain-dashboard-pro/) | Open Brain Dashboard Pro — Next.js 16 + iron-session. | [@alanshurafa](https://github.com/alanshurafa) | +| [Atomizer — generic + Gmail re-atomization toolkit](recipes/atomizer/) | Atomizer — generic + Gmail re-atomization toolkit. | [@alanshurafa](https://github.com/alanshurafa) | +| [Brain smoke test — install verification harness](recipes/brain-smoke-test/) | Brain smoke test — install verification harness. | [@alanshurafa](https://github.com/alanshurafa) | +| [CRM improvements: crm_ prefix, FTS search, meeting prep, stale detection](extensions/professional-crm/) | CRM improvements: crm_ prefix, FTS search, meeting prep, stale detection. | [@pintomatic](https://github.com/pintomatic) | +| [Edge function cost optimization — 73% invocation reduction](recipes/edge-function-cost-optimization/) | Edge function cost optimization — 73% invocation reduction. | [@JustinTSmith](https://github.com/JustinTSmith) | +| [Obsidian-vault-import: --source-label to override metadata.source](recipes/obsidian-vault-import/) | Obsidian-vault-import: --source-label to override metadata.source. | [@dhanjit](https://github.com/dhanjit) | +| [Preserve full frontmatter in obsidian-vault-import metadata](recipes/obsidian-vault-import/) | Preserves full frontmatter in obsidian-vault-import metadata. | [@dhanjit](https://github.com/dhanjit) | +| [Load .env into wiki-compiler child processes](recipes/wiki-compiler/) | Loads .env into wiki-compiler child processes. | [@mlava](https://github.com/mlava) | +| [Enable standalone output for Docker builds](dashboards/open-brain-dashboard-next/) | Enables standalone output for Docker builds. | [@Mavrick-F](https://github.com/Mavrick-F) | +| [Fix outdated primitives section in README](README.md) | Fixes outdated primitives section in README. | [@jjshanks](https://github.com/jjshanks) | +| [Document Edge Function redeploy step in OpenRouter rotation FAQ](docs/03-faq.md) | Documents Edge Function redeploy step in OpenRouter rotation FAQ. | [@Silverhawk-bit](https://github.com/Silverhawk-bit) | +| [Return JSON-RPC error envelopes on auth failure](server/index.ts) | Returns JSON-RPC error envelopes on auth failure. | [@txcfi-scott](https://github.com/txcfi-scott) | +| [Markdownlint sweep for existing recipe/schema docs](schemas/workflow-status/) | Markdownlint sweep for existing recipe/schema docs. | [@alanshurafa](https://github.com/alanshurafa) | +| [Improve ChatGPT MCP compatibility](primitives/remote-mcp/) | Improves ChatGPT MCP compatibility. | [@justfinethanku](https://github.com/justfinethanku) | +| [Add wiki compiler orchestration recipe](recipes/wiki-compiler/) | Adds wiki compiler orchestration recipe. | [@justfinethanku](https://github.com/justfinethanku) | +| [Wiki synthesis + autobiography pipeline](recipes/wiki-synthesis/) | Wiki synthesis + autobiography pipeline. | [@alanshurafa](https://github.com/alanshurafa) | +| [Entity wiki pages from knowledge graph](recipes/entity-wiki/) | Entity wiki pages from knowledge graph. | [@alanshurafa](https://github.com/alanshurafa) | +| [Typed reasoning edges + Opus/Haiku classifier](schemas/typed-reasoning-edges/) | Typed reasoning edges + Opus/Haiku classifier. | [@alanshurafa](https://github.com/alanshurafa) | +| [Entity extraction worker](integrations/entity-extraction-worker/) | Entity extraction worker. | [@alanshurafa](https://github.com/alanshurafa) | + + -https://github.com/user-attachments/assets/cc477f00-bb6b-4f96-9f7d-a6bcd0cf8b60 +## Extensions — The Learning Path Build these in order. Each one teaches new concepts through something you'll actually use. By the end, your agent manages your household, your schedule, your meals, your professional network, and your career — all interconnected. @@ -44,19 +69,18 @@ Extensions compound. Your CRM knows about thoughts you've captured. Your meal pl ## Primitives: Concepts That Compound -https://github.com/user-attachments/assets/f488e495-fe2a-4ccc-a834-fc6ab5a0ed41 - Some concepts show up in multiple extensions. Learn them once, apply them everywhere. | Primitive | What It Teaches | Used By | | --------- | --------------- | ------- | +| [Deploy an Edge Function](primitives/deploy-edge-function/) | Deploying any extension as a Supabase Edge Function | All extensions | +| [Remote MCP Connection](primitives/remote-mcp/) | Connecting to Claude Desktop, ChatGPT, Claude Code, Cursor, and other clients | All extensions | +| [Common Troubleshooting](primitives/troubleshooting/) | Solutions for connection, deployment, and database issues | All extensions | | [Row Level Security](primitives/rls/) | PostgreSQL policies for multi-user data isolation | Extensions 4, 5, 6 | | [Shared MCP Server](primitives/shared-mcp/) | Giving others scoped access to parts of your brain | Extension 4 | ## Community Contributions -https://github.com/user-attachments/assets/9454662f-2648-4928-8723-f7d52e94e9b8 - Beyond the curated learning path, the community builds and shares real tools that real people use. Every contribution below was reviewed, approved, and merged by the maintainer team. Look for the **Community Contribution** badge in each README. ### [`/recipes`](recipes/) — Import Your Data @@ -94,6 +118,9 @@ Standalone capabilities that make your Open Brain smarter. | [Work Operating Model Activation](recipes/work-operating-model-activation/) | Conversation-first workflow that turns tacit work patterns into structured Open Brain records and agent-ready operating files | [@jonathanedwards](https://github.com/jonathanedwards) | | [World Model Diagnostic Activation](recipes/world-model-diagnostic-activation/) | Ship-now activation path for a 20-minute world-model readiness diagnostic that compounds through core Open Brain capture | [@jonathanedwards](https://github.com/jonathanedwards) | | [Research-to-Decision Workflow](recipes/research-to-decision-workflow/) | Composition recipe that chains canonical skills into operator and investor research, synthesis, meeting, and memo workflows | [@NateBJones](https://github.com/NateBJones) | +| [OpenClaw Agent Memory for OB1](recipes/openclaw-agent-memory/) | Canonical recipe for using OB1 Agent Memory as the governed continuity layer for OpenClaw workflows | OB1 Team | +| [OpenClaw Code Review Memory](recipes/openclaw-code-review-memory/) | Flagship workflow for compounding repo-specific review lessons, maintainer corrections, and false positives | OB1 Team | +| [OpenClaw TaskFlow Work Log](recipes/openclaw-taskflow-work-log/) | Durable handoff recipe for long-running OpenClaw TaskFlows across agents, models, and channels | OB1 Team | ### [`/skills`](skills/) — Agent Skills @@ -111,6 +138,7 @@ Plain-text skill packs you can drop into Claude Code, Codex, or other AI clients | [Aiception Skill Pack (formerly Claudeception)](skills/claudeception/) | Extracts reusable lessons from work sessions into new skills | [@jaredirish](https://github.com/jaredirish) | | [Work Operating Model Skill Pack](skills/work-operating-model/) | Runs a five-layer elicitation interview and saves the approved operating model into Open Brain | [@jonathanedwards](https://github.com/jonathanedwards) | | [World Model Readiness Diagnostic](skills/world-model-diagnostic/) | Runs a 20-minute world-model diagnostic that maps paradigm fit, audits the boundary layer, and labels findings by confidence | [@jonathanedwards](https://github.com/jonathanedwards) | +| [OpenClaw Agent Memory Skill Pack](skills/openclaw-agent-memory/) | Teaches OpenClaw agents to recall, write back, report usage, and respect OB1 provenance/use-policy rules | OB1 Team | ### [`/dashboards`](dashboards/) — Frontend Templates @@ -128,14 +156,18 @@ MCP server extensions, alternative deployment targets, and capture sources beyon | Integration | What It Does | Contributor | | ----------- | ------------ | ----------- | | [Kubernetes Deployment](integrations/kubernetes-deployment/) | Fully self-hosted K8s deployment with PostgreSQL + pgvector — no Supabase required | [@velo](https://github.com/velo) | +| [Agent Memory API](integrations/agent-memory-api/) | Runtime-neutral recall, write-back, review, inspector, and recall-trace API for OB1 Agent Memory | OB1 Team | +| [OpenClaw Agent Memory](integrations/openclaw-agent-memory/) | OpenClaw plugin and publishing package for using OB1 Agent Memory from OpenClaw workflows | OB1 Team | | [Slack Capture](integrations/slack-capture/) | Quick-capture thoughts via Slack messages with auto-embedding and classification | Core | | [Discord Capture](integrations/discord-capture/) | Discord bot that captures messages into Open Brain, mirroring the Slack pattern | Core | -### [`/primitives`](primitives/) — Reusable Patterns +### [`/schemas`](schemas/) — Database Extensions -| Primitive | What It Does | Contributor | -| --------- | ------------ | ----------- | -| [Content Fingerprint Dedup](primitives/content-fingerprint-dedup/) | SHA-256 deduplication for thought ingestion — prevents duplicates across all import recipes | [@alanshurafa](https://github.com/alanshurafa) | +Tables and sidecars that extend the base `thoughts` model without replacing it. + +| Schema | What It Does | Contributor | +| ------ | ------------ | ----------- | +| [Agent Memory](schemas/agent-memory/) | Provenance, review, use-policy, source-reference, relation, recall-trace, and audit sidecars for agent workflow memory | OB1 Team | ## Using a Contribution @@ -162,7 +194,9 @@ Read [CONTRIBUTING.md](CONTRIBUTING.md) for the full details. The short version: ## Who Maintains This -Built by Nate B. Jones's team. Matt Hallett is the first community admin and repo manager. PRs are reviewed by the automated agent + human admins. +Created by [Nate B. Jones](https://github.com/NateBJones). + +The OB1 repo team: [Jonathan Edwards](https://github.com/justfinethanku), Repo Manager; [Matt Hallett](https://github.com/matthallett1), Community Admin; [Alan Shurafa](https://github.com/alanshurafa), Community Reviewer. PRs are reviewed by the automated agent + human admins. ## License diff --git a/dashboards/ob1-canonical-landing/404.html b/dashboards/ob1-canonical-landing/404.html new file mode 100644 index 000000000..3d605d34e --- /dev/null +++ b/dashboards/ob1-canonical-landing/404.html @@ -0,0 +1,68 @@ + + + + + + Page not found — Open Brain + + + + + + + + +
+ +
+
404
+

Page not found

+

This page doesn’t exist. The brain has no memory of it.

+ Return home → + + diff --git a/dashboards/ob1-canonical-landing/CNAME b/dashboards/ob1-canonical-landing/CNAME new file mode 100644 index 000000000..a72a42c87 --- /dev/null +++ b/dashboards/ob1-canonical-landing/CNAME @@ -0,0 +1 @@ +openbrain.fyi \ No newline at end of file diff --git a/dashboards/ob1-canonical-landing/MAINTENANCE.md b/dashboards/ob1-canonical-landing/MAINTENANCE.md new file mode 100644 index 000000000..231e98f6d --- /dev/null +++ b/dashboards/ob1-canonical-landing/MAINTENANCE.md @@ -0,0 +1,222 @@ +# Maintaining openbrain.fyi + +This guide is for the OB1 maintainer and any future contributors who need to update the canonical landing page after it goes live. The page is intentionally a single static `index.html` with no build step — every change is a normal git edit, and every deploy is a single push. + +## Edit-and-deploy loop + +1. Edit any file under `dashboards/ob1-canonical-landing/` +2. Open a PR (or push to `main` if you have direct write access) +3. The `Deploy landing page` workflow fires on the path filter and pushes the artifact to Pages +4. The `https://openbrain.fyi/` cache refreshes within 1–2 minutes of the deploy job finishing + +Watch progress at **Actions → Deploy landing page**. The job's environment URL links to the live site once it's published. + +## Common edits + +### Update copy, headlines, or links + +All visible text lives directly in `index.html`. The major sections are marked with HTML `id`s for navigation: + +| `id` | Section | +|------|---------| +| `main-content` | Skip-link target, wraps everything below the nav | +| (hero, no id) | The first `
` — h1, byline, CTA buttons, demo video | +| `how-it-works` | Three-pillar overview | +| `get-started` | Numbered steps + AI-assistant cards + companion prompts | +| `extensions` | Extensions table | +| `community` | Recipes + tools + skills tables, dashboards/integrations pillars | + +After any copy change, update `dateModified` in three places so search engines and citations stay in sync: + +| File | Field | +|------|-------| +| `index.html` | `` | +| `index.html` | JSON-LD `TechArticle` → `"dateModified"` | +| `index.html` | Byline `` | +| `sitemap.xml` | `` | +| `metadata.json` | `"updated"` | + +All five should be the same ISO date (`YYYY-MM-DD`). + +### Add or swap a video + +Videos use GitHub user-attachment URLs (the same URLs that render in the project README). To add a new video: + +1. Drag the `.mp4` into a GitHub issue or PR comment to upload it; copy the `https://github.com/user-attachments/assets/...` URL +2. Embed using the existing pattern: + +```html + +``` + +Always set a meaningful `aria-label` — screen readers announce it. Use `preload="metadata"` only for the hero video; keep all below-fold videos at `preload="none"` to protect first-paint performance. + +If a GitHub user-attachment URL ever goes 404, replace with a re-uploaded asset; do not host video binaries in the repo (gate rule blocks files >1MB). + +### Replace or update the logo + +The page ships three brand-image variants generated from a single source: + +| File | Use | Source | +|------|-----|--------| +| `imgs/ob1-logo.png` (512×512) | Nav + manifest icon | Square master | +| `imgs/ob1-logo-wide.png` (1200×360) | Hero banner | Wide master | +| `imgs/og.png` (1200×630) | Social share | Wide on `#0f1b33` background | +| `imgs/apple-touch-icon.png` (180×180) | iOS home screen | Square master | +| `imgs/favicon-32.png` (32×32) | Browser tab | Square master | + +To regenerate from new master images, drop the new sources at `imgs/_master-square.png` and `imgs/_master-wide.png`, then run: + +```bash +cd dashboards/ob1-canonical-landing/imgs +magick _master-square.png -resize 512x512 -strip ob1-logo.png +magick _master-square.png -resize 180x180 -strip apple-touch-icon.png +magick _master-square.png -resize 32x32 -strip favicon-32.png +magick _master-wide.png -resize 1200x -strip ob1-logo-wide.png +magick _master-wide.png -resize 1000x -background "#0f1b33" -gravity center -extent 1200x630 og.png +rm _master-square.png _master-wide.png +``` + +Requires ImageMagick 7+ (`brew install imagemagick`). The `-strip` flag removes EXIF/color-profile metadata that bloats files and leaks build-environment info. + +If the logo's color palette changes, update the four CSS custom properties in the `:root` block of `index.html`: + +```css +--accent: #e05a20; /* primary accent (OB1 orange) */ +--brand-blue-deep: #0f1b33; /* used in hero gradient + OG bg */ +--brand-blue-mid: #1a3a6e; +--brand-blue-bright: #2563b8; +``` + +### Add a new content section + +Use the existing pattern so styling, spacing, and a11y stay consistent: + +```html +
+
+

Section title

+

Lead paragraph.

+ +
+
+``` + +Alternate `
` and `
` for visual rhythm. Add a nav link in the sticky `
+
+

NBJ / OB1

+

+ Personal continuity layer +

+
- Open Brain + +
+ + Open Brain + + Nate B. Jones +
{/* Backdrop */} diff --git a/dashboards/open-brain-dashboard-next/lib/agent-memory.ts b/dashboards/open-brain-dashboard-next/lib/agent-memory.ts new file mode 100644 index 000000000..8dded8a2d --- /dev/null +++ b/dashboards/open-brain-dashboard-next/lib/agent-memory.ts @@ -0,0 +1,130 @@ +import "server-only"; + +import type { + AgentMemory, + AgentMemoryListResponse, + AgentMemoryRecord, + AgentMemoryReviewAction, + AgentMemoryTraceResponse, +} from "./types"; + +const AGENT_MEMORY_API_URL = + process.env.AGENT_MEMORY_API_URL || + process.env.NEXT_PUBLIC_AGENT_MEMORY_API_URL || + deriveAgentMemoryUrl(process.env.NEXT_PUBLIC_API_URL); + +function deriveAgentMemoryUrl(restUrl?: string) { + if (!restUrl) return undefined; + return restUrl.replace(/\/open-brain-rest\/?$/, "/agent-memory-api"); +} + +function headers(apiKey: string): HeadersInit { + return { + "x-brain-key": apiKey, + "Content-Type": "application/json", + }; +} + +async function agentMemoryFetch( + apiKey: string, + path: string, + init?: RequestInit +): Promise { + if (!AGENT_MEMORY_API_URL) { + throw new Error( + "AGENT_MEMORY_API_URL or NEXT_PUBLIC_AGENT_MEMORY_API_URL is required for Agent Memory dashboard pages." + ); + } + const res = await fetch(`${AGENT_MEMORY_API_URL}${path}`, { + ...init, + headers: { ...headers(apiKey), ...(init?.headers || {}) }, + cache: "no-store", + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`Agent Memory API ${res.status}: ${text || res.statusText}`); + } + return res.json(); +} + +export function agentMemoryDefaults() { + return { + workspaceId: process.env.AGENT_MEMORY_WORKSPACE_ID || "ob1-staging", + projectId: process.env.AGENT_MEMORY_PROJECT_ID || "", + }; +} + +export async function fetchAgentMemories( + apiKey: string, + params: { + workspace_id: string; + project_id?: string; + review_status?: string; + lifecycle_status?: string; + runtime_name?: string; + memory_type?: string; + task_id_prefix?: string; + limit?: number; + } +): Promise { + const sp = new URLSearchParams({ workspace_id: params.workspace_id }); + for (const [key, value] of Object.entries(params)) { + if (key === "workspace_id" || value === undefined || value === "") continue; + sp.set(key, String(value)); + } + return agentMemoryFetch(apiKey, `/memories?${sp.toString()}`); +} + +export async function fetchReviewQueue( + apiKey: string, + workspaceId: string, + projectId?: string +): Promise { + const sp = new URLSearchParams({ workspace_id: workspaceId }); + if (projectId) sp.set("project_id", projectId); + const data = await agentMemoryFetch<{ memories: AgentMemory[] }>( + apiKey, + `/memories/review?${sp.toString()}` + ); + return data.memories; +} + +export async function fetchAgentMemory( + apiKey: string, + memoryId: string +): Promise { + const data = await agentMemoryFetch<{ memory: AgentMemoryRecord }>( + apiKey, + `/memories/${memoryId}` + ); + return data.memory; +} + +export async function reviewAgentMemory( + apiKey: string, + memoryId: string, + action: AgentMemoryReviewAction, + options?: { + actor_label?: string; + notes?: string; + content?: string; + summary?: string; + visibility?: string; + related_memory_id?: string; + } +): Promise<{ memory: AgentMemoryRecord }> { + return agentMemoryFetch(apiKey, `/memories/${memoryId}/review`, { + method: "PATCH", + body: JSON.stringify({ action, ...options }), + }); +} + +export async function fetchRecallTrace( + apiKey: string, + requestId: string +): Promise { + return agentMemoryFetch( + apiKey, + `/recall-traces/${requestId}` + ); +} diff --git a/dashboards/open-brain-dashboard-next/lib/api.ts b/dashboards/open-brain-dashboard-next/lib/api.ts index 846a1200e..3bc7c1dc7 100644 --- a/dashboards/open-brain-dashboard-next/lib/api.ts +++ b/dashboards/open-brain-dashboard-next/lib/api.ts @@ -73,7 +73,7 @@ export async function fetchThoughts( export async function fetchThought( apiKey: string, - id: number, + id: string, excludeRestricted: boolean = true ): Promise { const qs = excludeRestricted ? "" : "?exclude_restricted=false"; @@ -82,10 +82,10 @@ export async function fetchThought( export async function updateThought( apiKey: string, - id: number, + id: string, data: { content?: string; type?: string; importance?: number; status?: string | null } -): Promise<{ id: number; action: string; message: string }> { - return apiFetch<{ id: number; action: string; message: string }>( +): Promise<{ id: string; action: string; message: string }> { + return apiFetch<{ id: string; action: string; message: string }>( apiKey, `/thought/${id}`, { @@ -136,7 +136,7 @@ export async function fetchDuplicates( export async function deleteThought( apiKey: string, - id: number + id: string ): Promise { const url = `${API_URL}/thought/${id}`; const res = await fetch(url, { @@ -186,7 +186,7 @@ export async function fetchStats( } export interface CaptureResult { - thought_id: number; + thought_id: string; action: string; type: string; sensitivity_tier: string; @@ -206,7 +206,7 @@ export async function captureThought( export async function fetchReflections( apiKey: string, - thoughtId: number + thoughtId: string ): Promise { const data = await apiFetch<{ reflections: Reflection[] }>( apiKey, diff --git a/dashboards/open-brain-dashboard-next/lib/auth.ts b/dashboards/open-brain-dashboard-next/lib/auth.ts index 84ef7d60f..0428c2208 100644 --- a/dashboards/open-brain-dashboard-next/lib/auth.ts +++ b/dashboards/open-brain-dashboard-next/lib/auth.ts @@ -15,6 +15,14 @@ export class AuthError extends Error { } } +function shouldUseSecureCookie() { + if (process.env.AUTH_COOKIE_SECURE) { + return process.env.AUTH_COOKIE_SECURE === "true"; + } + const appUrl = process.env.NEXT_PUBLIC_APP_URL || process.env.APP_URL || ""; + return appUrl.startsWith("https://") || process.env.VERCEL === "1"; +} + // Fail fast if SESSION_SECRET is missing or too short const SESSION_SECRET = process.env.SESSION_SECRET; if (!SESSION_SECRET || SESSION_SECRET.length < 32) { @@ -29,12 +37,19 @@ export const sessionOptions: SessionOptions = { ttl: 60 * 60 * 24, // 24 hours cookieOptions: { httpOnly: true, - secure: process.env.NODE_ENV === "production", + secure: shouldUseSecureCookie(), sameSite: "lax" as const, path: "/", }, }; +function demoAuthBypass() { + if (process.env.OB1_DEMO_AUTH_BYPASS !== "true") return null; + return { + apiKey: process.env.OB1_DASHBOARD_DEMO_KEY || "local-screenshot-key", + }; +} + export async function getSession() { const cookieStore = await cookies(); return getIronSession(cookieStore, sessionOptions); @@ -45,6 +60,9 @@ export async function getSession() { * Call BEFORE parsing request body so unauthed requests get 401, not 400. */ export async function requireSession(): Promise<{ apiKey: string }> { + const demoSession = demoAuthBypass(); + if (demoSession) return demoSession; + const session = await getSession(); if (!session.loggedIn || !session.apiKey) { throw new AuthError(); @@ -58,6 +76,9 @@ export async function requireSession(): Promise<{ apiKey: string }> { export async function requireSessionOrRedirect(): Promise<{ apiKey: string; }> { + const demoSession = demoAuthBypass(); + if (demoSession) return demoSession; + const session = await getSession(); if (!session.loggedIn || !session.apiKey) { redirect("/login"); diff --git a/dashboards/open-brain-dashboard-next/lib/types.ts b/dashboards/open-brain-dashboard-next/lib/types.ts index f58a1cbe2..b5b9fb63f 100644 --- a/dashboards/open-brain-dashboard-next/lib/types.ts +++ b/dashboards/open-brain-dashboard-next/lib/types.ts @@ -1,5 +1,5 @@ export interface Thought { - id: number; + id: string; uuid?: string; content: string; type: string; @@ -64,8 +64,8 @@ export function getPriorityLevel(importance: number) { } export interface Reflection { - id: number; - thought_id: number; + id: string | number; + thought_id: string; trigger_context: string; options: unknown[]; factors: unknown[]; @@ -104,8 +104,8 @@ export interface StatsResponse { } export interface DuplicatePair { - thought_id_a: number; - thought_id_b: number; + thought_id_a: string; + thought_id_b: string; similarity: number; content_a: string; content_b: string; @@ -142,7 +142,7 @@ export interface ReflectionInput { } export interface IngestionItem { - id: number; + id: number | string; job_id: number; content: string; type: string; @@ -163,10 +163,156 @@ export type AddToBrainMode = "auto" | "single" | "extract"; export interface AddToBrainResult { path: "single" | "extract"; - thought_id?: number; + thought_id?: string; job_id?: number; type?: string; status?: string; extracted_count?: number | null; message: string; } + +export type AgentMemoryReviewAction = + | "confirm" + | "edit" + | "evidence_only" + | "restrict_scope" + | "mark_stale" + | "merge" + | "reject" + | "dispute" + | "supersede"; + +export interface AgentMemory { + memory_id: string; + summary: string; + content: string; + source: { + kind: string; + uri: string | null; + title: string | null; + timestamp: string | null; + }; + provenance: { + status: string; + confidence: number; + created_by: string; + model: string | null; + runtime: string | null; + }; + scope: { + workspace_id: string; + project_id: string | null; + channel_id: string | null; + visibility: string; + }; + use_policy: { + can_use_as_instruction: boolean; + can_use_as_evidence: boolean; + requires_user_confirmation: boolean; + }; + freshness: { + created_at: string; + last_confirmed_at: string | null; + stale_after: string | null; + }; + related_artifacts: Array<{ kind: string; uri: string }>; +} + +export interface AgentMemoryListResponse { + memories: AgentMemory[]; + count: number; +} + +export interface AgentMemorySourceRef { + id: string; + memory_id: string; + source_kind: string; + uri: string | null; + title: string | null; + source_timestamp: string | null; + metadata: Record; + created_at: string; +} + +export interface AgentMemoryArtifact { + id: string; + memory_id: string; + artifact_kind: string; + uri: string; + description: string | null; + metadata: Record; + created_at: string; +} + +export interface AgentMemoryRecord { + id: string; + thought_id: string | null; + workspace_id: string; + project_id: string | null; + channel_kind: string | null; + channel_id: string | null; + channel_thread_id: string | null; + visibility: string; + memory_type: string; + summary: string; + content: string; + lifecycle_status: string; + provenance_status: string; + confidence: number; + created_by: string; + runtime_name: string | null; + runtime_version: string | null; + provider: string | null; + model: string | null; + task_id: string | null; + flow_id: string | null; + can_use_as_instruction: boolean; + can_use_as_evidence: boolean; + requires_user_confirmation: boolean; + review_status: string; + last_confirmed_at: string | null; + stale_after: string | null; + idempotency_key: string | null; + content_hash: string | null; + metadata: Record; + created_at: string; + updated_at: string; + agent_memory_source_refs?: AgentMemorySourceRef[]; + agent_memory_artifacts?: AgentMemoryArtifact[]; +} + +export interface AgentMemoryTraceItem { + id: string; + trace_id: string; + memory_id: string; + rank: number; + similarity: number | null; + ranking_score: number | null; + returned: boolean; + used: boolean | null; + ignored_reason: string | null; + use_policy_snapshot: Record; + created_at: string; + agent_memories?: AgentMemoryRecord; +} + +export interface AgentMemoryTraceResponse { + trace: { + id: string; + request_id: string; + workspace_id: string; + project_id: string | null; + runtime_name: string | null; + runtime_version: string | null; + task_id: string | null; + flow_id: string | null; + channel_kind: string | null; + channel_id: string | null; + query: string; + schema_version: string; + request_payload: Record; + response_policy: Record; + created_at: string; + }; + items: AgentMemoryTraceItem[]; +} diff --git a/dashboards/open-brain-dashboard-next/middleware.ts b/dashboards/open-brain-dashboard-next/middleware.ts index 6ad1714bb..f4f996366 100644 --- a/dashboards/open-brain-dashboard-next/middleware.ts +++ b/dashboards/open-brain-dashboard-next/middleware.ts @@ -3,11 +3,16 @@ import { NextRequest, NextResponse } from "next/server"; export function middleware(request: NextRequest) { const { pathname } = request.nextUrl; + if (process.env.OB1_DEMO_AUTH_BYPASS === "true") { + return NextResponse.next(); + } + // Allow login page, API routes, and static assets if ( pathname === "/login" || pathname.startsWith("/api") || pathname.startsWith("/_next") || + pathname.startsWith("/brand") || pathname.startsWith("/favicon") ) { return NextResponse.next(); diff --git a/dashboards/open-brain-dashboard-next/next.config.ts b/dashboards/open-brain-dashboard-next/next.config.ts index 62e982ad6..270993ffb 100644 --- a/dashboards/open-brain-dashboard-next/next.config.ts +++ b/dashboards/open-brain-dashboard-next/next.config.ts @@ -2,6 +2,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { allowedDevOrigins: ["192.168.0.140"], + output: "standalone", }; export default nextConfig; diff --git a/dashboards/open-brain-dashboard-next/package-lock.json b/dashboards/open-brain-dashboard-next/package-lock.json index 2608fa689..995a4b0cf 100644 --- a/dashboards/open-brain-dashboard-next/package-lock.json +++ b/dashboards/open-brain-dashboard-next/package-lock.json @@ -12,7 +12,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "iron-session": "^8.0.4", - "next": "16.2.1", + "next": "16.2.4", "react": "19.2.4", "react-dom": "19.2.4", "server-only": "^0.0.1" @@ -23,7 +23,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", - "eslint-config-next": "16.2.1", + "eslint-config-next": "16.2.4", "tailwindcss": "^4", "typescript": "^5" } @@ -1093,15 +1093,15 @@ } }, "node_modules/@next/env": { - "version": "16.2.1", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.1.tgz", - "integrity": "sha512-n8P/HCkIWW+gVal2Z8XqXJ6aB3J0tuM29OcHpCsobWlChH/SITBs1DFBk/HajgrwDkqqBXPbuUuzgDvUekREPg==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.4.tgz", + "integrity": "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "16.2.1", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.1.tgz", - "integrity": "sha512-r0epZGo24eT4g08jJlg2OEryBphXqO8aL18oajoTKLzHJ6jVr6P6FI58DLMug04MwD3j8Fj0YK0slyzneKVyzA==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.4.tgz", + "integrity": "sha512-tOX826JJ96gYK/go18sPUgMq9FK1tqxBFfUCEufJb5XIkWFFmpgU7mahJANKGkHs7F41ir3tReJ3Lv5La0RvhA==", "dev": true, "license": "MIT", "dependencies": { @@ -1109,9 +1109,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.2.1", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.1.tgz", - "integrity": "sha512-BwZ8w8YTaSEr2HIuXLMLxIdElNMPvY9fLqb20LX9A9OMGtJilhHLbCL3ggyd0TwjmMcTxi0XXt+ur1vWUoxj2Q==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.4.tgz", + "integrity": "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A==", "cpu": [ "arm64" ], @@ -1125,9 +1125,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.2.1", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.1.tgz", - "integrity": "sha512-/vrcE6iQSJq3uL3VGVHiXeaKbn8Es10DGTGRJnRZlkNQQk3kaNtAJg8Y6xuAlrx/6INKVjkfi5rY0iEXorZ6uA==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.4.tgz", + "integrity": "sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ==", "cpu": [ "x64" ], @@ -1141,12 +1141,15 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.2.1", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.1.tgz", - "integrity": "sha512-uLn+0BK+C31LTVbQ/QU+UaVrV0rRSJQ8RfniQAHPghDdgE+SlroYqcmFnO5iNjNfVWCyKZHYrs3Nl0mUzWxbBw==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.4.tgz", + "integrity": "sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1157,12 +1160,15 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.2.1", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.1.tgz", - "integrity": "sha512-ssKq6iMRnHdnycGp9hCuGnXJZ0YPr4/wNwrfE5DbmvEcgl9+yv97/Kq3TPVDfYome1SW5geciLB9aiEqKXQjlQ==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.4.tgz", + "integrity": "sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1173,12 +1179,15 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.2.1", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.1.tgz", - "integrity": "sha512-HQm7SrHRELJ30T1TSmT706IWovFFSRGxfgUkyWJZF/RKBMdbdRWJuFrcpDdE5vy9UXjFOx6L3mRdqH04Mmx0hg==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.4.tgz", + "integrity": "sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1189,12 +1198,15 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.2.1", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.1.tgz", - "integrity": "sha512-aV2iUaC/5HGEpbBkE+4B8aHIudoOy5DYekAKOMSHoIYQ66y/wIVeaRx8MS2ZMdxe/HIXlMho4ubdZs/J8441Tg==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.4.tgz", + "integrity": "sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1205,9 +1217,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.2.1", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.1.tgz", - "integrity": "sha512-IXdNgiDHaSk0ZUJ+xp0OQTdTgnpx1RCfRTalhn3cjOP+IddTMINwA7DXZrwTmGDO8SUr5q2hdP/du4DcrB1GxA==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.4.tgz", + "integrity": "sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==", "cpu": [ "arm64" ], @@ -1221,9 +1233,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.2.1", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.1.tgz", - "integrity": "sha512-qvU+3a39Hay+ieIztkGSbF7+mccbbg1Tk25hc4JDylf8IHjYmY/Zm64Qq1602yPyQqvie+vf5T/uPwNxDNIoeg==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz", + "integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==", "cpu": [ "x64" ], @@ -3178,13 +3190,13 @@ } }, "node_modules/eslint-config-next": { - "version": "16.2.1", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.1.tgz", - "integrity": "sha512-qhabwjQZ1Mk53XzXvmogf8KQ0tG0CQXF0CZ56+2/lVhmObgmaqj7x5A1DSrWdZd3kwI7GTPGUjFne+krRxYmFg==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.4.tgz", + "integrity": "sha512-A6ekXYFj/YQxBPMl45g3e+U8zJo+X2+ZQwcz34pPKjpc/3S4roBA2Rd9xWB4FKuSxhofo1/95WjzmUY+wHrOhg==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "16.2.1", + "@next/eslint-plugin-next": "16.2.4", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", @@ -5076,12 +5088,12 @@ "license": "MIT" }, "node_modules/next": { - "version": "16.2.1", - "resolved": "https://registry.npmjs.org/next/-/next-16.2.1.tgz", - "integrity": "sha512-VaChzNL7o9rbfdt60HUj8tev4m6d7iC1igAy157526+cJlXOQu5LzsBXNT+xaJnTP/k+utSX5vMv7m0G+zKH+Q==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.4.tgz", + "integrity": "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==", "license": "MIT", "dependencies": { - "@next/env": "16.2.1", + "@next/env": "16.2.4", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -5095,14 +5107,14 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.2.1", - "@next/swc-darwin-x64": "16.2.1", - "@next/swc-linux-arm64-gnu": "16.2.1", - "@next/swc-linux-arm64-musl": "16.2.1", - "@next/swc-linux-x64-gnu": "16.2.1", - "@next/swc-linux-x64-musl": "16.2.1", - "@next/swc-win32-arm64-msvc": "16.2.1", - "@next/swc-win32-x64-msvc": "16.2.1", + "@next/swc-darwin-arm64": "16.2.4", + "@next/swc-darwin-x64": "16.2.4", + "@next/swc-linux-arm64-gnu": "16.2.4", + "@next/swc-linux-arm64-musl": "16.2.4", + "@next/swc-linux-x64-gnu": "16.2.4", + "@next/swc-linux-x64-musl": "16.2.4", + "@next/swc-win32-arm64-msvc": "16.2.4", + "@next/swc-win32-x64-msvc": "16.2.4", "sharp": "^0.34.5" }, "peerDependencies": { @@ -5420,9 +5432,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { diff --git a/dashboards/open-brain-dashboard-next/package.json b/dashboards/open-brain-dashboard-next/package.json index b369d301b..1f3d4ea82 100644 --- a/dashboards/open-brain-dashboard-next/package.json +++ b/dashboards/open-brain-dashboard-next/package.json @@ -13,7 +13,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "iron-session": "^8.0.4", - "next": "16.2.1", + "next": "16.2.4", "react": "19.2.4", "react-dom": "19.2.4", "server-only": "^0.0.1" @@ -24,7 +24,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", - "eslint-config-next": "16.2.1", + "eslint-config-next": "16.2.4", "tailwindcss": "^4", "typescript": "^5" } diff --git a/dashboards/open-brain-dashboard-next/public/brand/ob1-beanie-mark-128.png b/dashboards/open-brain-dashboard-next/public/brand/ob1-beanie-mark-128.png new file mode 100644 index 000000000..4662c5c48 Binary files /dev/null and b/dashboards/open-brain-dashboard-next/public/brand/ob1-beanie-mark-128.png differ diff --git a/dashboards/open-brain-dashboard-next/public/brand/ob1-beanie-mark-cream.png b/dashboards/open-brain-dashboard-next/public/brand/ob1-beanie-mark-cream.png new file mode 100644 index 000000000..2ceeccac4 Binary files /dev/null and b/dashboards/open-brain-dashboard-next/public/brand/ob1-beanie-mark-cream.png differ diff --git a/dashboards/open-brain-dashboard-next/public/brand/ob1-beanie-mark-green.png b/dashboards/open-brain-dashboard-next/public/brand/ob1-beanie-mark-green.png new file mode 100644 index 000000000..4d408ef1e Binary files /dev/null and b/dashboards/open-brain-dashboard-next/public/brand/ob1-beanie-mark-green.png differ diff --git a/dashboards/open-brain-dashboard-next/public/brand/ob1-beanie-mark-white.png b/dashboards/open-brain-dashboard-next/public/brand/ob1-beanie-mark-white.png new file mode 100644 index 000000000..02e6b6c5f Binary files /dev/null and b/dashboards/open-brain-dashboard-next/public/brand/ob1-beanie-mark-white.png differ diff --git a/dashboards/open-brain-dashboard-next/public/brand/ob1-logo-wide.png b/dashboards/open-brain-dashboard-next/public/brand/ob1-logo-wide.png new file mode 100644 index 000000000..60ff3eb1e Binary files /dev/null and b/dashboards/open-brain-dashboard-next/public/brand/ob1-logo-wide.png differ diff --git a/dashboards/open-brain-dashboard-next/public/brand/ob1-logo.png b/dashboards/open-brain-dashboard-next/public/brand/ob1-logo.png new file mode 100644 index 000000000..6fb240f83 Binary files /dev/null and b/dashboards/open-brain-dashboard-next/public/brand/ob1-logo.png differ diff --git a/dashboards/open-brain-dashboard-pro/.env.example b/dashboards/open-brain-dashboard-pro/.env.example new file mode 100644 index 000000000..f168d331e --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/.env.example @@ -0,0 +1,14 @@ +# Required: Base URL of your Open Brain REST API gateway +# Typically: https://YOUR-PROJECT-REF.supabase.co/functions/v1/open-brain-rest +NEXT_PUBLIC_API_URL=https://YOUR-PROJECT-REF.supabase.co/functions/v1/open-brain-rest + +# Required: 32+ character secret for iron-session cookie encryption. +# The app will refuse to start if this is missing or too short. +# Generate with: openssl rand -hex 32 +SESSION_SECRET= + +# Optional: SHA-256 hash of a passphrase to unlock restricted/sensitive content. +# Only needed if you've applied the sensitivity-tiers primitive (sensitivity_tier +# column on the thoughts table). Leave unset to hide the lock/unlock toggle. +# Generate with: echo -n "your-passphrase" | shasum -a 256 +# RESTRICTED_PASSPHRASE_HASH= diff --git a/dashboards/open-brain-dashboard-pro/.gitignore b/dashboards/open-brain-dashboard-pro/.gitignore new file mode 100644 index 000000000..c53691451 --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/.gitignore @@ -0,0 +1,43 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (never commit actual secrets) +.env +.env.local +.env.*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/dashboards/open-brain-dashboard-pro/README.md b/dashboards/open-brain-dashboard-pro/README.md new file mode 100644 index 000000000..f1473cccf --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/README.md @@ -0,0 +1,144 @@ +# Open Brain Dashboard Pro + +> A Next.js 16 + Tailwind + iron-session dashboard for browsing, searching, auditing, and ingesting content in your Open Brain. A third flavor alongside the SvelteKit `open-brain-dashboard` and the Next.js `open-brain-dashboard-next`. + +## What It Does + +Seven server-rendered pages backed by iron-session auth and the Open Brain REST API gateway: + +| Page | What you get | +|------|--------------| +| **Dashboard** (`/`) | Stats widget (total thoughts, type distribution, top topics), inline "Add to Brain" capture, and the five most recent thoughts. | +| **Browse** (`/thoughts`) | Paginated thought table with filters for type, source, and minimum importance. | +| **Detail** (`/thoughts/:id`) | Full thought view with metadata panel, inline edit (content/type/importance), delete, and a connections panel when topics/people metadata is present. | +| **Search** (`/search`) | Client-side form for semantic (vector) and full-text search with pagination and similarity scores. | +| **Audit** (`/audit`) | Quality audit of thoughts with `quality_score < 30`, sorted ascending, with two-step bulk delete. | +| **Duplicates** (`/duplicates`) | Semantic near-duplicate pairs with threshold control, side-by-side comparison, and batch resolution (keep A / keep B / keep both). | +| **Ingest** (`/ingest`) | Smart-ingest UI with dry-run preview, extracted-item cards, execute button, and job history. | +| **Settings** (`/settings`) | Connection status, thought type breakdown, top topics, and masked API key prefix. | + +## Screenshots + +Screenshots go in `docs/screenshots/` and should be referenced from this README once you add them. + +## Prerequisites + +- A working Open Brain setup ([guide](../../docs/01-getting-started.md)) +- The **REST API gateway** (`open-brain-rest` Edge Function from PR #201) deployed and reachable +- **Node.js 20+** +- A host for the dashboard: Vercel or Netlify free tier works; self-hosting on a Node.js 20+ runtime is also fine + +## Configuration + +All configuration is through environment variables. **The app refuses to start if required variables are missing.** + +| Variable | Required | Description | +|----------|----------|-------------| +| `NEXT_PUBLIC_API_URL` | Yes | Base URL of your Open Brain REST API, typically `https://YOUR-PROJECT-REF.supabase.co/functions/v1/open-brain-rest`. | +| `SESSION_SECRET` | Yes | 32+ character secret used by `iron-session` to encrypt the session cookie. Generate with `openssl rand -hex 32`. | +| `RESTRICTED_PASSPHRASE_HASH` | No | SHA-256 hash of a passphrase that unlocks restricted/sensitive content. Only meaningful if your brain has a `sensitivity_tier` column on `public.thoughts`. There is no official sensitivity-tiers primitive upstream yet — either add your own migration (see PR #192 for pattern) or wait for the primitive to land. On stock OB1, this dashboard's restricted-content toggle is hidden at startup. Generate with `echo -n "your-passphrase" \| shasum -a 256`. | + +Copy `.env.example` to `.env.local` (gitignored) and fill it in. + +## Installation + +```bash +cd dashboards/open-brain-dashboard-pro +npm install + +# Local dev +cp .env.example .env.local # then edit and fill in values +npm run dev # http://localhost:3000 + +# Production build +npm run build +npm start +``` + +For Vercel or Netlify, connect this folder and set the same environment variables in the hosting provider's dashboard. + +## Authentication + +The dashboard uses [`iron-session`](https://github.com/vvo/iron-session) v8 for encrypted HTTP-only session cookies. No API key is ever exposed to the browser. + +1. User enters their Open Brain API key at `/login`. +2. The server hits `GET {NEXT_PUBLIC_API_URL}/health` with `x-brain-key: ` — if the REST gateway responds `200 OK`, the key is accepted. +3. The key is written into an encrypted session cookie named `open_brain_session` (24 h TTL, `httpOnly`, `secure` in production, `sameSite: lax`). +4. Every server component and API route reads the key from the session and injects it into Open Brain REST calls. +5. `/api/logout` destroys the session and redirects back to `/login`. + +If `SESSION_SECRET` is missing or shorter than 32 characters, the app throws at startup so you can't accidentally run with an empty cookie password. + +## Expected REST Endpoints + +The dashboard calls these endpoints on your Open Brain REST gateway (all authenticated via `x-brain-key`): + +| Endpoint | Method | Used by | Required? | +|----------|--------|---------|-----------| +| `/health` | GET | Login validation, Settings status | **Yes** | +| `/count` | GET | Settings status (total + per-type counts) | **Yes** | +| `/stats` | GET | Dashboard stats widget | **Yes** | +| `/thoughts` | GET | Browse, Dashboard recent, Audit (filtered) | **Yes** | +| `/thought/:id` | GET, PUT, DELETE | Detail view, inline edit, delete | **Yes** | +| `/search` | POST | Search page (semantic + full-text) | **Yes** | +| `/capture` | POST | Single-thought "Add to Brain" path | **Yes** | +| `/thought/:id/connections` | GET | Detail page connections panel | Optional — panel hides if it errors | +| `/duplicates`, `/duplicates/resolve` | GET / POST | Duplicates page | Optional — page shows an error otherwise | +| `/ingest`, `/ingestion-jobs`, `/ingestion-jobs/:id`, `/ingestion-jobs/:id/execute` | POST / GET | Ingest page | Optional — page still loads without jobs | + +> **On `/reflections/*`:** The ExoCortex upstream dashboard staged a reflections feature. This fork does not yet ship a reflections UI surface, but the architecture is ready: if you add a reflection panel later and your gateway doesn't serve `/reflections/*`, expect a 404 that the UI should swallow. The existing optional endpoints already degrade this way — the Connections panel, Duplicates page, and Ingest history all swallow fetch errors and render an empty/neutral state instead of crashing. + +## Adapting + +- **Point at a different REST API** — change `NEXT_PUBLIC_API_URL`. Everything else follows. +- **Remove Audit** — delete `app/audit/`, `app/api/audit/`, and the `AuditIcon` nav entry in `components/Sidebar.tsx`. +- **Remove Duplicates** — delete `app/duplicates/`, `app/api/duplicates/`, and the `DuplicatesIcon` nav entry in `components/Sidebar.tsx`. +- **Remove Ingest** — delete `app/ingest/`, `app/api/ingest/`, and the `AddIcon` nav entry. The `AddToBrain` component will no longer be reachable; remove its usage from `app/page.tsx` (the Dashboard). +- **Rebrand** — the wordmark lives in `app/layout.tsx` (`metadata`), `components/Sidebar.tsx` (header), `app/login/page.tsx` (hero), and a few in-page strings (`app/page.tsx`, `app/ingest/page.tsx`). The session cookie name is `open_brain_session` (see `lib/auth.ts` and `proxy.ts`). +- **Change the color palette** — edit `app/globals.css`. The CSS variables under `@theme inline` drive every surface color. +- **Add a new page** — drop a `page.tsx` under `app/` following the existing patterns. For protected pages, call `await requireSessionOrRedirect()` at the top and do REST work from the server. + +## Deployment + +> **A note on `proxy.ts` vs `middleware.ts` (Cloudflare caveat).** This dashboard uses the Next.js 16 `proxy.ts` convention (the older `middleware.ts` is deprecated). There is a known issue ([vercel/next.js#86122](https://github.com/vercel/next.js/issues/86122)) where `proxy.ts` does not execute in production behind Cloudflare Proxy, while `middleware.ts` does. Auth in this dashboard is enforced server-side on every server component and API route, so `proxy.ts` is defense-in-depth only and the app remains secure if it never runs. If your deploy target is Cloudflare and you want the extra redirect layer active, rename `proxy.ts` back to `middleware.ts` (and rename the exported `proxy` function to `middleware`). You'll get a Next.js deprecation warning at build time, but the redirect logic will fire. Vercel, Netlify, and standalone Node.js hosting work correctly with `proxy.ts` as-shipped. + +### Vercel + +1. Import the `dashboards/open-brain-dashboard-pro/` folder as a new project (or use `vercel link` from inside it). +2. Set `NEXT_PUBLIC_API_URL` and `SESSION_SECRET` (and optionally `RESTRICTED_PASSPHRASE_HASH`) in Project Settings → Environment Variables. +3. Deploy. Vercel's free tier is sufficient — the dashboard does only lightweight server-side proxy work. + +### Netlify + +1. Point a new site at the folder. Netlify will detect Next.js automatically. +2. Set the same environment variables. +3. Deploy. + +### Self-hosted (Node.js 20+) + +```bash +npm ci +npm run build +NODE_ENV=production \ + NEXT_PUBLIC_API_URL=... \ + SESSION_SECRET=... \ + npm start +``` + +The app listens on port 3000 by default; use `PORT=4000 npm start` to override. + +## Tech Stack + +- **Next.js 16** (App Router, server components) +- **React 19** + TypeScript +- **Tailwind CSS 4** (dark theme, custom palette) +- **iron-session 8** (encrypted cookies) + +## Troubleshooting + +1. **"SESSION_SECRET env var is required and must be at least 32 characters"** — generate one with `openssl rand -hex 32` and set it. This is intentional; the app refuses to start without it. +2. **Login says "Could not reach API"** — verify `NEXT_PUBLIC_API_URL` is correct and the REST gateway is live. Test with `curl -H "x-brain-key: YOUR_KEY" $NEXT_PUBLIC_API_URL/health`. +3. **Login says "Invalid API key or service unavailable"** — the REST gateway reached but rejected the key. Check `MCP_ACCESS_KEY` (or whatever secret backs `x-brain-key`) in your Edge Function secrets. +4. **Search returns nothing** — semantic search needs embeddings. Verify `OPENROUTER_API_KEY` (or your embedding provider) is set in Supabase secrets and that the `embedding` column is populated. +5. **Ingest page never finishes extracting** — confirm the `smart-ingest` Edge Function is deployed alongside the REST gateway. +6. **Connections panel empty on Detail page** — the panel requires `topics` or `people` in `metadata`. Thoughts enriched through classification have these; raw captures do not. diff --git a/dashboards/open-brain-dashboard-pro/app/api/audit/delete/route.ts b/dashboards/open-brain-dashboard-pro/app/api/audit/delete/route.ts new file mode 100644 index 000000000..36d32f172 --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/api/audit/delete/route.ts @@ -0,0 +1,96 @@ +import { NextRequest, NextResponse } from "next/server"; +import { deleteThought, fetchThought, ApiError } from "@/lib/api"; +import { requireSession, AuthError, getSession } from "@/lib/auth"; + +// BL-03: Cap bulk-delete to prevent accidental or malicious wipes +const MAX_DELETE_IDS = 50; +// BL-03: Audit page explicitly targets quality_score < 30; enforce server-side +const AUDIT_QUALITY_THRESHOLD = 30; + +export async function POST(request: NextRequest) { + // Auth BEFORE body parse — unauthed requests get 401, not 400 + let apiKey: string; + try { + ({ apiKey } = await requireSession()); + } catch (err) { + if (err instanceof AuthError) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + throw err; + } + + // REVIEW-CODEX-2-P1: honor the session's restricted lock state — never + // hard-code `excludeRestricted=false`, or a locked session can bulk-delete + // restricted thoughts just by knowing their IDs. + const session = await getSession(); + const excludeRestricted = !session.restrictedUnlocked; + + try { + const { ids } = (await request.json()) as { ids: unknown }; + + // BL-03: Strict input validation — array of positive integers only + if (!Array.isArray(ids) || ids.length === 0) { + return NextResponse.json({ error: "No IDs provided" }, { status: 400 }); + } + if (ids.length > MAX_DELETE_IDS) { + return NextResponse.json( + { error: `Cannot delete more than ${MAX_DELETE_IDS} thoughts per request` }, + { status: 400 } + ); + } + const sanitized: number[] = []; + for (const raw of ids) { + if (!Number.isInteger(raw) || (raw as number) <= 0) { + return NextResponse.json( + { error: "All IDs must be positive integers" }, + { status: 400 } + ); + } + sanitized.push(raw as number); + } + + // BL-03: Re-verify each thought actually has quality_score < 30 before deleting + // Prevents a user from passing arbitrary IDs (e.g. importance-6 thoughts) to this route. + // REVIEW-CODEX-2-P1: excludeRestricted is derived from session — a locked + // session will see 403/404 on restricted thoughts here, which correctly + // drops them out of the delete set via the rejected branch below. + const verifyResults = await Promise.allSettled( + sanitized.map((id) => fetchThought(apiKey, id, excludeRestricted)) + ); + + const verifiedIds: number[] = []; + let rejected = 0; + for (let i = 0; i < verifyResults.length; i++) { + const r = verifyResults[i]; + if (r.status === "fulfilled" && typeof r.value.quality_score === "number" && r.value.quality_score < AUDIT_QUALITY_THRESHOLD) { + verifiedIds.push(sanitized[i]); + } else { + rejected += 1; + } + } + + if (verifiedIds.length === 0) { + return NextResponse.json( + { error: "No IDs matched audit criteria (quality_score < 30)" }, + { status: 403 } + ); + } + + const results = await Promise.allSettled( + verifiedIds.map((id) => deleteThought(apiKey, id)) + ); + const failed = results.filter((r) => r.status === "rejected").length; + + return NextResponse.json({ + deleted: verifiedIds.length - failed, + failed: failed + rejected, + rejected_non_audit: rejected, + }); + } catch (err) { + // WR-05: Log detail server-side, return generic to client + console.error("[audit/delete]", err); + if (err instanceof ApiError) { + return NextResponse.json({ error: "Upstream error" }, { status: 502 }); + } + return NextResponse.json({ error: "Delete failed" }, { status: 500 }); + } +} diff --git a/dashboards/open-brain-dashboard-pro/app/api/audit/route.ts b/dashboards/open-brain-dashboard-pro/app/api/audit/route.ts new file mode 100644 index 000000000..5e9250551 --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/api/audit/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from "next/server"; +import { fetchThoughts } from "@/lib/api"; +import { requireSession, AuthError, getSession } from "@/lib/auth"; + +export async function GET(request: NextRequest) { + let apiKey: string; + try { + ({ apiKey } = await requireSession()); + } catch (err) { + if (err instanceof AuthError) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + throw err; + } + + const session = await getSession(); + const excludeRestricted = !session.restrictedUnlocked; + + const page = parseInt( + request.nextUrl.searchParams.get("page") || "1", + 10 + ); + + try { + // Server-side filter: quality_score_max=29, sorted by quality ascending + const data = await fetchThoughts(apiKey, { + page, + per_page: 50, + quality_score_max: 29, + sort: "quality_score", + order: "asc", + exclude_restricted: excludeRestricted, + }); + return NextResponse.json(data); + } catch (err) { + // WR-05: Log detail server-side, return generic to client + console.error("[audit]", err); + return NextResponse.json({ error: "Failed" }, { status: 500 }); + } +} diff --git a/dashboards/open-brain-dashboard-pro/app/api/duplicates/resolve/route.ts b/dashboards/open-brain-dashboard-pro/app/api/duplicates/resolve/route.ts new file mode 100644 index 000000000..a1a8add3f --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/api/duplicates/resolve/route.ts @@ -0,0 +1,121 @@ +import { NextRequest, NextResponse } from "next/server"; +import { resolveDuplicate, fetchDuplicates, ApiError } from "@/lib/api"; +import { requireSession, AuthError } from "@/lib/auth"; + +// BL-03: Lowest threshold the dashboard UI ever surfaces is 0.80 — use it as the +// server-side re-verification floor. A pair only counts as a "duplicate" if the +// backend still agrees at this threshold. +const MIN_DUPLICATE_THRESHOLD = 0.8; +// REVIEW-CODEX-2-P2: paginate through candidate pairs until we either find the +// requested pair or hit the safety cap. The previous single-page 500-pair +// window made pairs on later UI pages impossible to resolve. +const VERIFY_PAGE_SIZE = 500; +const VERIFY_MAX_SCAN = 5000; + +export async function POST(request: NextRequest) { + let apiKey: string; + try { + ({ apiKey } = await requireSession()); + } catch (err) { + if (err instanceof AuthError) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + throw err; + } + + try { + const { action, thought_id_a, thought_id_b } = (await request.json()) as { + action: "keep_a" | "keep_b" | "keep_both"; + thought_id_a: unknown; + thought_id_b: unknown; + }; + + // BL-03: Validate IDs are positive integers, not truthy-but-wrong values + if ( + !Number.isInteger(thought_id_a) || + (thought_id_a as number) <= 0 || + !Number.isInteger(thought_id_b) || + (thought_id_b as number) <= 0 + ) { + return NextResponse.json( + { error: "Both thought_id_a and thought_id_b must be positive integers" }, + { status: 400 } + ); + } + const idA = thought_id_a as number; + const idB = thought_id_b as number; + + if (idA === idB) { + return NextResponse.json( + { error: "thought_id_a and thought_id_b must differ" }, + { status: 400 } + ); + } + + if (!["keep_a", "keep_b", "keep_both"].includes(action)) { + return NextResponse.json( + { error: "Invalid action" }, + { status: 400 } + ); + } + + // BL-03: Re-verify the pair is an actual near-duplicate at MIN_DUPLICATE_THRESHOLD. + // This prevents a user from passing arbitrary IDs with action:"keep_a" + // to delete an arbitrary thought B that is NOT actually a duplicate. + // REVIEW-CODEX-2-P2: walk pages until we find the pair or exceed the cap. + // Previously we only scanned the first 500-pair page, so pairs surfaced + // on later UI pages could not be resolved. + let pairMatches = false; + let scanned = 0; + let offset = 0; + while (scanned < VERIFY_MAX_SCAN) { + const dups = await fetchDuplicates(apiKey, { + threshold: MIN_DUPLICATE_THRESHOLD, + limit: VERIFY_PAGE_SIZE, + offset, + }); + const pageLen = dups.pairs.length; + if ( + dups.pairs.some( + (p) => + (p.thought_id_a === idA && p.thought_id_b === idB) || + (p.thought_id_a === idB && p.thought_id_b === idA) + ) + ) { + pairMatches = true; + break; + } + scanned += pageLen; + // Backend returned fewer than a full page → no more pairs to scan. + if (pageLen < VERIFY_PAGE_SIZE) break; + offset += VERIFY_PAGE_SIZE; + } + if (!pairMatches) { + // If we hit the cap without finding the pair, tell the caller so they + // can narrow threshold / retry rather than silently 403-ing forever. + const hitCap = scanned >= VERIFY_MAX_SCAN; + return NextResponse.json( + { + error: hitCap + ? `Pair not found after scanning ${VERIFY_MAX_SCAN} candidates — raise the UI threshold and retry` + : "Pair is not a recognized duplicate", + }, + { status: hitCap ? 404 : 403 } + ); + } + + const result = await resolveDuplicate(apiKey, { + thought_id_a: idA, + thought_id_b: idB, + action, + }); + + return NextResponse.json(result); + } catch (err) { + // WR-05: Log detail server-side, return generic to client + console.error("[duplicates/resolve]", err); + if (err instanceof ApiError) { + return NextResponse.json({ error: "Upstream error" }, { status: 502 }); + } + return NextResponse.json({ error: "Resolve failed" }, { status: 500 }); + } +} diff --git a/dashboards/open-brain-dashboard-pro/app/api/duplicates/route.ts b/dashboards/open-brain-dashboard-pro/app/api/duplicates/route.ts new file mode 100644 index 000000000..56dd90af2 --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/api/duplicates/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from "next/server"; +import { fetchDuplicates } from "@/lib/api"; +import { requireSession, AuthError } from "@/lib/auth"; + +export async function GET(request: NextRequest) { + let apiKey: string; + try { + ({ apiKey } = await requireSession()); + } catch (err) { + if (err instanceof AuthError) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + throw err; + } + + const threshold = parseFloat( + request.nextUrl.searchParams.get("threshold") || "0.85" + ); + const limit = parseInt( + request.nextUrl.searchParams.get("limit") || "50", + 10 + ); + const offset = parseInt( + request.nextUrl.searchParams.get("offset") || "0", + 10 + ); + + try { + const data = await fetchDuplicates(apiKey, { threshold, limit, offset }); + return NextResponse.json(data); + } catch (err) { + // WR-05: Log detail server-side, return generic to client + console.error("[duplicates]", err); + return NextResponse.json({ error: "Failed" }, { status: 500 }); + } +} diff --git a/dashboards/open-brain-dashboard-pro/app/api/ingest/[id]/execute/route.ts b/dashboards/open-brain-dashboard-pro/app/api/ingest/[id]/execute/route.ts new file mode 100644 index 000000000..dda2ca3ea --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/api/ingest/[id]/execute/route.ts @@ -0,0 +1,88 @@ +import { NextResponse } from "next/server"; +import { requireSession, AuthError } from "@/lib/auth"; + +export async function POST( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + let apiKey: string; + try { + ({ apiKey } = await requireSession()); + } catch (err) { + if (err instanceof AuthError) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + throw err; + } + + const { id } = await params; + + // WR-04 / BL-03: Validate id is a positive integer before forwarding + const idNum = Number(id); + if (!Number.isInteger(idNum) || idNum <= 0) { + return NextResponse.json({ error: "Invalid id" }, { status: 400 }); + } + + const API_URL = process.env.NEXT_PUBLIC_API_URL; + if (!API_URL) { + return NextResponse.json( + { error: "NEXT_PUBLIC_API_URL not configured" }, + { status: 500 } + ); + } + + try { + // BL-03: Re-verify the session owns this job by fetching it first. + // The REST gateway filters by the session's x-brain-key, so a 404/403 here + // indicates the job is not visible to the caller. + const verifyRes = await fetch(`${API_URL}/ingestion-jobs/${idNum}`, { + headers: { "x-brain-key": apiKey, "Content-Type": "application/json" }, + }); + if (!verifyRes.ok) { + // REVIEW-CODEX-3 P2#2: Differentiate transient upstream 5xx from + // authorization 4xx so the client can show a retry affordance instead + // of a misleading "denied" message. + if (verifyRes.status >= 500) { + console.error( + `[ingest/[id]/execute] preflight upstream 5xx for job ${idNum}:`, + verifyRes.status + ); + return NextResponse.json( + { error: "Upstream API temporarily unavailable", retryable: true }, + { status: 503 } + ); + } + console.error( + "[ingest/[id]/execute] ownership check failed", + verifyRes.status + ); + // 4xx (403/404) — treat as "not yours" without leaking upstream detail + return NextResponse.json( + { error: "Job not found or not accessible" }, + { status: verifyRes.status === 404 ? 404 : 403 } + ); + } + + const res = await fetch(`${API_URL}/ingestion-jobs/${idNum}/execute`, { + method: "POST", + headers: { "x-brain-key": apiKey, "Content-Type": "application/json" }, + }); + const data = await res.json(); + if (!res.ok) { + // WR-05: Log detail server-side, return generic to client + console.error("[ingest/[id]/execute] upstream error", res.status, data); + return NextResponse.json( + { error: "Upstream error" }, + { status: res.status } + ); + } + return NextResponse.json(data); + } catch (err) { + // REVIEW-CODEX-3 P2#2: Network/fetch throw is transient — signal retryable + // rather than a blanket 500 "Failed". + console.error("[ingest/[id]/execute]", err); + return NextResponse.json( + { error: "Upstream API temporarily unavailable", retryable: true }, + { status: 503 } + ); + } +} diff --git a/dashboards/open-brain-dashboard-pro/app/api/ingest/[id]/route.ts b/dashboards/open-brain-dashboard-pro/app/api/ingest/[id]/route.ts new file mode 100644 index 000000000..8a89998bf --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/api/ingest/[id]/route.ts @@ -0,0 +1,83 @@ +import { NextResponse } from "next/server"; +import { requireSession, AuthError } from "@/lib/auth"; +import type { IngestionItem, IngestionItemMeta } from "@/lib/types"; + +/** Normalize a raw DB ingestion_item row into the web API contract. */ +function normalizeItem(raw: Record): IngestionItem { + const meta = (raw.metadata ?? {}) as Record; + const parsedMeta: IngestionItemMeta = { + type: typeof meta.type === "string" ? meta.type : undefined, + importance: typeof meta.importance === "number" ? meta.importance : undefined, + tags: Array.isArray(meta.tags) ? meta.tags.filter((t): t is string => typeof t === "string") : undefined, + source_snippet: typeof meta.source_snippet === "string" ? meta.source_snippet : undefined, + }; + + return { + id: raw.id as number, + job_id: raw.job_id as number, + content: (raw.extracted_content ?? raw.content ?? "") as string, + action: (raw.action ?? "skip") as string, + reason: (raw.reason as string) ?? null, + status: (raw.status ?? "pending") as string, + matched_thought_id: (raw.matched_thought_id as number) ?? null, + similarity_score: raw.similarity_score != null ? Number(raw.similarity_score) : null, + result_thought_id: (raw.result_thought_id as number) ?? null, + meta: parsedMeta, + }; +} + +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + let apiKey: string; + try { + ({ apiKey } = await requireSession()); + } catch (err) { + if (err instanceof AuthError) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + throw err; + } + + const { id } = await params; + + // WR-04: Validate id is a positive integer before forwarding + const idNum = Number(id); + if (!Number.isInteger(idNum) || idNum <= 0) { + return NextResponse.json({ error: "Invalid id" }, { status: 400 }); + } + + const API_URL = process.env.NEXT_PUBLIC_API_URL; + if (!API_URL) { + return NextResponse.json( + { error: "NEXT_PUBLIC_API_URL not configured" }, + { status: 500 } + ); + } + + try { + const res = await fetch(`${API_URL}/ingestion-jobs/${idNum}`, { + headers: { "x-brain-key": apiKey, "Content-Type": "application/json" }, + }); + const data = await res.json(); + if (!res.ok) { + // WR-05: Log detail server-side, return generic to client + console.error("[ingest/[id]] upstream error", res.status, data); + return NextResponse.json( + { error: "Upstream error" }, + { status: res.status } + ); + } + + // Normalize items from raw DB shape to web contract + const items = Array.isArray(data.items) + ? data.items.map((raw: Record) => normalizeItem(raw)) + : []; + + return NextResponse.json({ job: data.job, items }); + } catch (err) { + // WR-05: Log detail server-side, return generic to client + console.error("[ingest/[id]]", err); + return NextResponse.json({ error: "Failed" }, { status: 500 }); + } +} diff --git a/dashboards/open-brain-dashboard-pro/app/api/ingest/route.ts b/dashboards/open-brain-dashboard-pro/app/api/ingest/route.ts new file mode 100644 index 000000000..d866a7d84 --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/api/ingest/route.ts @@ -0,0 +1,132 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + fetchIngestionJobs, + triggerIngest, + captureThought, +} from "@/lib/api"; +import { requireSession, AuthError } from "@/lib/auth"; + +// ── Auto-routing heuristic ────────────────────────────────────────────────── + +/** + * Determines whether input text should be routed to smart-ingest extraction + * (multiple thoughts) rather than single-thought capture. + * + * Heuristic rules (any match → extract): + * 1. Long text (> 500 chars) + * 2. Multiple paragraphs (2+) + * 3. Bullet or numbered lists (2+ items) + * 4. Transcript/speaker markers (2+ lines like "Name: ...") + * 5. Timestamp patterns (e.g. "10:32 AM") + * 6. Email-style headers (From:, Subject:, Date:, To:) + */ +function shouldExtract(text: string): boolean { + if (text.length > 500) return true; + + const paragraphs = text.split(/\n\s*\n/).filter((p) => p.trim()); + if (paragraphs.length >= 2) return true; + + const lines = text.split("\n"); + const bulletLines = lines.filter((l) => + /^\s*[-*\u2022]\s|^\s*\d+[.)]\s/.test(l) + ); + if (bulletLines.length >= 2) return true; + + const speakerLines = lines.filter((l) => /^\s*\w+:\s/.test(l)); + if (speakerLines.length >= 2) return true; + + if (/\d{1,2}:\d{2}\s*(AM|PM)?/i.test(text)) return true; + + if (/^(From|Subject|Date|To):\s/m.test(text)) return true; + + return false; +} + +// ── GET — list ingestion jobs ─────────────────────────────────────────────── + +export async function GET() { + let apiKey: string; + try { + ({ apiKey } = await requireSession()); + } catch (err) { + if (err instanceof AuthError) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + throw err; + } + + try { + const jobs = await fetchIngestionJobs(apiKey); + return NextResponse.json(jobs); + } catch (err) { + // WR-05: Log detail server-side, return generic to client + console.error("[ingest]", err); + return NextResponse.json({ error: "Failed" }, { status: 500 }); + } +} + +// ── POST — unified Add to Brain ───────────────────────────────────────────── + +export async function POST(request: NextRequest) { + let apiKey: string; + try { + ({ apiKey } = await requireSession()); + } catch (err) { + if (err instanceof AuthError) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + throw err; + } + + try { + const body = await request.json(); + const { text, mode = "auto", dry_run, skip_classification } = body as { + text: string; + mode?: "auto" | "single" | "extract"; + dry_run?: boolean; + skip_classification?: boolean; + }; + + if (!text?.trim()) { + return NextResponse.json( + { error: "Text is required" }, + { status: 400 } + ); + } + + const trimmed = text.trim(); + const resolvedMode = + mode === "auto" + ? shouldExtract(trimmed) + ? "extract" + : "single" + : mode; + + if (resolvedMode === "single") { + const result = await captureThought(apiKey, trimmed); + return NextResponse.json({ + path: "single" as const, + thought_id: result.thought_id, + type: result.type, + message: "Saved as 1 thought", + }); + } + + // extract path → smart-ingest + const result = await triggerIngest(apiKey, trimmed, { dry_run, skip_classification }); + const extracted = + (result as Record).extracted_count ?? null; + const isDryRun = result.status === "dry_run_complete"; + return NextResponse.json({ + path: "extract" as const, + job_id: result.job_id, + status: result.status, + extracted_count: extracted, + message: isDryRun + ? `Extracted ${extracted ?? "?"} candidate thoughts (dry run)` + : `Extracted thoughts from your input`, + }); + } catch (err) { + // WR-05: Log detail server-side, return generic to client + console.error("[ingest]", err); + return NextResponse.json({ error: "Failed" }, { status: 500 }); + } +} diff --git a/dashboards/open-brain-dashboard-pro/app/api/logout/route.ts b/dashboards/open-brain-dashboard-pro/app/api/logout/route.ts new file mode 100644 index 000000000..94ddba8ed --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/api/logout/route.ts @@ -0,0 +1,8 @@ +import { getSession } from "@/lib/auth"; +import { redirect } from "next/navigation"; + +export async function POST() { + const session = await getSession(); + session.destroy(); + redirect("/login"); +} diff --git a/dashboards/open-brain-dashboard-pro/app/api/restricted/route.ts b/dashboards/open-brain-dashboard-pro/app/api/restricted/route.ts new file mode 100644 index 000000000..7ae19b628 --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/api/restricted/route.ts @@ -0,0 +1,86 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getSession, requireSession, AuthError } from "@/lib/auth"; + +const RESTRICTED_PASSPHRASE_HASH = process.env.RESTRICTED_PASSPHRASE_HASH ?? ""; + +async function hashPassphrase(passphrase: string): Promise { + const encoded = new TextEncoder().encode(passphrase); + const hash = await crypto.subtle.digest("SHA-256", encoded); + return Array.from(new Uint8Array(hash)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +/** POST — verify passphrase and unlock restricted content */ +export async function POST(request: NextRequest) { + try { + await requireSession(); + } catch (err) { + if (err instanceof AuthError) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + throw err; + } + + if (!RESTRICTED_PASSPHRASE_HASH) { + return NextResponse.json( + { error: "Restricted passphrase not configured on server" }, + { status: 503 } + ); + } + + const body = await request.json(); + const passphrase = typeof body.passphrase === "string" ? body.passphrase : ""; + + if (!passphrase) { + return NextResponse.json( + { error: "Passphrase is required" }, + { status: 400 } + ); + } + + const inputHash = await hashPassphrase(passphrase); + + if (inputHash !== RESTRICTED_PASSPHRASE_HASH) { + return NextResponse.json( + { error: "Incorrect passphrase" }, + { status: 401 } + ); + } + + const session = await getSession(); + session.restrictedUnlocked = true; + await session.save(); + + return NextResponse.json({ unlocked: true }); +} + +/** DELETE — re-lock restricted content */ +export async function DELETE() { + try { + await requireSession(); + } catch (err) { + if (err instanceof AuthError) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + throw err; + } + + const session = await getSession(); + session.restrictedUnlocked = false; + await session.save(); + + return NextResponse.json({ unlocked: false }); +} + +/** GET — check current restricted unlock status */ +export async function GET() { + try { + await requireSession(); + } catch (err) { + if (err instanceof AuthError) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + throw err; + } + + const session = await getSession(); + return NextResponse.json({ unlocked: session.restrictedUnlocked === true }); +} diff --git a/dashboards/open-brain-dashboard-pro/app/api/search/route.ts b/dashboards/open-brain-dashboard-pro/app/api/search/route.ts new file mode 100644 index 000000000..588f85a9b --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/api/search/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from "next/server"; +import { searchThoughts } from "@/lib/api"; +import { requireSession, getSession, AuthError } from "@/lib/auth"; + +export async function GET(request: NextRequest) { + let apiKey: string; + try { + ({ apiKey } = await requireSession()); + } catch (err) { + if (err instanceof AuthError) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + throw err; + } + + const session = await getSession(); + const excludeRestricted = session.restrictedUnlocked !== true; + + const q = request.nextUrl.searchParams.get("q"); + const mode = (request.nextUrl.searchParams.get("mode") || "semantic") as + | "semantic" + | "text"; + const page = parseInt(request.nextUrl.searchParams.get("page") || "1", 10); + + if (!q) { + return NextResponse.json({ error: "Query required" }, { status: 400 }); + } + + try { + const data = await searchThoughts(apiKey, q, mode, 100, page, excludeRestricted); + return NextResponse.json(data); + } catch (err) { + // WR-05: Log detail server-side, return generic to client + console.error("[search]", err); + return NextResponse.json({ error: "Search failed" }, { status: 500 }); + } +} diff --git a/dashboards/open-brain-dashboard-pro/app/api/settings/status/route.ts b/dashboards/open-brain-dashboard-pro/app/api/settings/status/route.ts new file mode 100644 index 000000000..0675f0f04 --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/api/settings/status/route.ts @@ -0,0 +1,100 @@ +import { NextResponse } from "next/server"; +import { requireSession, AuthError } from "@/lib/auth"; + +export async function GET() { + let apiKey: string; + try { + ({ apiKey } = await requireSession()); + } catch (err) { + if (err instanceof AuthError) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + throw err; + } + + const API_URL = process.env.NEXT_PUBLIC_API_URL; + if (!API_URL) { + return NextResponse.json( + { error: "NEXT_PUBLIC_API_URL not configured" }, + { status: 500 } + ); + } + const headers = { "x-brain-key": apiKey, "Content-Type": "application/json" }; + + try { + // Use /count endpoint (simple, reliable) with /health as a secondary liveness check + const [countRes, healthRes, statsRes] = await Promise.allSettled([ + fetch(`${API_URL}/count`, { headers }), + fetch(`${API_URL}/health`, { headers }), + fetch(`${API_URL}/stats`, { headers }), + ]); + + // Parse count + let totalThoughts = 0; + if (countRes.status === "fulfilled" && countRes.value.ok) { + const data = await countRes.value.json(); + totalThoughts = data.count ?? data.total ?? 0; + } + + // Parse health + let healthy = false; + if (healthRes.status === "fulfilled" && healthRes.value.ok) { + const data = await healthRes.value.json(); + healthy = data.status === "ok"; + } + // If health endpoint fails but count worked, we're still connected + if (!healthy && totalThoughts > 0) healthy = true; + + // CR-01 / WR-01 / WR-02: pull real types + top_topics from /stats + // instead of the previous hardcoded type list and empty placeholders. + // If /stats doesn't return types we fall through to an empty map and + // the UI renders "type distribution not available". + const types: Record = {}; + const topTopics: Array<{ topic: string; count: number }> = []; + if (statsRes.status === "fulfilled" && statsRes.value.ok) { + try { + const statsData = await statsRes.value.json(); + if (statsData && typeof statsData.types === "object" && statsData.types) { + for (const [k, v] of Object.entries(statsData.types)) { + if (typeof v === "number" && v > 0) types[k] = v; + } + } + if (Array.isArray(statsData?.top_topics)) { + for (const entry of statsData.top_topics) { + if ( + entry && + typeof entry.topic === "string" && + typeof entry.count === "number" + ) { + topTopics.push({ topic: entry.topic, count: entry.count }); + } + } + } + } catch (err) { + console.error("[settings/status] failed to parse /stats", err); + } + } + + return NextResponse.json({ + healthy, + totalThoughts, + // CR-01: embeddingCoverage removed — the previous "99.2%" was fabricated. + // TODO: wire a real /embeddings/coverage endpoint on the REST gateway, + // then restore this field with the computed value. + types, + topTopics, + // REVIEW-CODEX-2-P3: `sources` previously shipped as an empty object + // while the README promised a source breakdown. No backend endpoint + // currently supplies this, so the field is removed entirely and the + // README no longer claims to render it. Re-add here (and in the UI + // consumer) when /stats or a sibling endpoint returns source counts. + apiKeyPrefix: apiKey.substring(0, 8), + }); + } catch (err) { + // WR-05: Log detail server-side, return generic to client + console.error("[settings/status]", err); + return NextResponse.json( + { error: "Failed to load status" }, + { status: 500 } + ); + } +} diff --git a/dashboards/open-brain-dashboard-pro/app/api/thoughts/[id]/connections/route.ts b/dashboards/open-brain-dashboard-pro/app/api/thoughts/[id]/connections/route.ts new file mode 100644 index 000000000..6895c799d --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/api/thoughts/[id]/connections/route.ts @@ -0,0 +1,65 @@ +import { NextResponse } from "next/server"; +import { requireSession, getSession, AuthError } from "@/lib/auth"; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + let apiKey: string; + try { + ({ apiKey } = await requireSession()); + } catch (err) { + if (err instanceof AuthError) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + throw err; + } + + const { id } = await params; + + // WR-04: Validate id is a positive integer before forwarding + const idNum = Number(id); + if (!Number.isInteger(idNum) || idNum <= 0) { + return NextResponse.json({ error: "Invalid id" }, { status: 400 }); + } + + const API_URL = process.env.NEXT_PUBLIC_API_URL; + if (!API_URL) { + return NextResponse.json( + { error: "NEXT_PUBLIC_API_URL not configured" }, + { status: 500 } + ); + } + + // BL-02: Derive excludeRestricted from server session, NEVER client query string + const session = await getSession(); + const excludeRestricted = session.restrictedUnlocked !== true; + + try { + const res = await fetch( + `${API_URL}/thought/${idNum}/connections?exclude_restricted=${excludeRestricted}&limit=20`, + { + headers: { + "x-brain-key": apiKey, + "Content-Type": "application/json", + }, + } + ); + const data = await res.json(); + if (!res.ok) { + // WR-05: Do not leak backend error details to client + console.error("[thoughts/[id]/connections] upstream error", res.status, data); + return NextResponse.json( + { error: "Upstream error" }, + { status: res.status } + ); + } + return NextResponse.json(data); + } catch (err) { + // WR-05: Log detail server-side, return generic to client + console.error("[thoughts/[id]/connections]", err); + return NextResponse.json( + { error: "Failed to fetch connections" }, + { status: 500 } + ); + } +} diff --git a/dashboards/open-brain-dashboard-pro/app/audit/page.tsx b/dashboards/open-brain-dashboard-pro/app/audit/page.tsx new file mode 100644 index 000000000..29ced59c6 --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/audit/page.tsx @@ -0,0 +1,232 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import Link from "next/link"; +import { TypeBadge } from "@/components/ThoughtCard"; +import { DeleteModal } from "@/components/DeleteModal"; +import type { Thought, BrowseResponse } from "@/lib/types"; + +export default function AuditPage() { + const [data, setData] = useState(null); + const [selected, setSelected] = useState>(new Set()); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(true); + const [showDelete, setShowDelete] = useState(false); + const [showFinalConfirm, setShowFinalConfirm] = useState(false); + const [error, setError] = useState(null); + const [reloadTick, setReloadTick] = useState(0); + + // Codex-P1-3: Resolve react-hooks/set-state-in-effect by keeping the effect + // side-effect-only on completion — setLoading(true) runs from event + // handlers (page change, reload), not from inside the effect body. + useEffect(() => { + const controller = new AbortController(); + let cancelled = false; + + (async () => { + try { + const res = await fetch(`/api/audit?page=${page}`, { + signal: controller.signal, + }); + if (!res.ok) throw new Error("Failed to load"); + const d = await res.json(); + if (!cancelled) setData(d); + } catch (err) { + if (cancelled || controller.signal.aborted) return; + setError(err instanceof Error ? err.message : "Load failed"); + } finally { + if (!cancelled) setLoading(false); + } + })(); + + return () => { + cancelled = true; + controller.abort(); + }; + }, [page, reloadTick]); + + // Stable handle for event handlers to trigger a refetch. + const reload = useRef(() => { + setLoading(true); + setReloadTick((t) => t + 1); + }).current; + + const changePage = (next: number) => { + setLoading(true); + setPage(next); + }; + + const toggleSelect = (id: number) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const toggleAll = () => { + if (!data) return; + if (selected.size === data.data.length) { + setSelected(new Set()); + } else { + setSelected(new Set(data.data.map((t) => t.id))); + } + }; + + const handleBulkDelete = async () => { + try { + const res = await fetch("/api/audit/delete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ids: Array.from(selected) }), + }); + if (!res.ok) throw new Error("Delete failed"); + setSelected(new Set()); + setShowFinalConfirm(false); + setShowDelete(false); + reload(); + } catch (err) { + setError(err instanceof Error ? err.message : "Delete failed"); + } + }; + + if (loading && !data) { + return ( +
+

Audit

+
+
+ Loading low-quality thoughts... +
+
+ ); + } + + const totalPages = data ? Math.ceil(data.total / data.per_page) : 0; + + return ( +
+
+
+

Audit

+

+ Review low quality thoughts (score < 30) + {data && ` | ${data.total.toLocaleString()} total`} +

+
+ {selected.size > 0 && ( + + )} +
+ + {error &&

{error}

} + + {data && ( +
+ + + + + + + + + + + {data.data.map((t: Thought) => ( + + + + + + + ))} + +
+ 0 && + selected.size === data.data.length + } + onChange={toggleAll} + className="accent-violet" + /> + ContentTypeScore
+ toggleSelect(t.id)} + className="accent-violet" + /> + + + {t.content.length > 100 + ? t.content.slice(0, 100) + "..." + : t.content} + + + + + {t.quality_score} +
+
+ )} + + {/* Pagination */} + {totalPages > 1 && ( +
+

+ Page {page} of {totalPages} +

+
+ + +
+
+ )} + + {/* Two-step delete: first confirm count */} + {showDelete && !showFinalConfirm && ( + { + setShowDelete(false); + setShowFinalConfirm(true); + }} + onCancel={() => setShowDelete(false)} + /> + )} + {showFinalConfirm && ( + setShowFinalConfirm(false)} + /> + )} +
+ ); +} diff --git a/dashboards/open-brain-dashboard-pro/app/duplicates/page.tsx b/dashboards/open-brain-dashboard-pro/app/duplicates/page.tsx new file mode 100644 index 000000000..2bc65de8a --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/duplicates/page.tsx @@ -0,0 +1,526 @@ +"use client"; + +import { useState, useEffect } from "react"; +import Link from "next/link"; +import { TypeBadge } from "@/components/ThoughtCard"; +import { DeleteModal } from "@/components/DeleteModal"; +import { formatDate } from "@/lib/format"; +import type { DuplicatePair } from "@/lib/types"; + +const PER_PAGE = 30; + +// Tracks resolution for a pair — "keep_a" = delete B, "keep_b" = delete A, "keep_both" = dismiss +type Selection = "keep_a" | "keep_b" | "keep_both"; + +export default function DuplicatesPage() { + const [pairs, setPairs] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [threshold, setThreshold] = useState(0.85); + const [offset, setOffset] = useState(0); + const [resolving, setResolving] = useState(null); + const [confirmDelete, setConfirmDelete] = useState<{ + action: "keep_a" | "keep_b"; + pair: DuplicatePair; + } | null>(null); + + // Batch selection state: pairKey -> { pair data, which side to keep }. + // REVIEW-CODEX-3 P2#1: Store full pair DATA (not just IDs) so selections + // survive page navigation. Without this, selecting pairs on page 1 then + // navigating to page 2 silently drops them because processBatch can only + // resolve pairs present in the current page's `pairs` array. + const [selections, setSelections] = useState< + Map + >(new Map()); + const [batchProcessing, setBatchProcessing] = useState(false); + const [confirmBatch, setConfirmBatch] = useState(false); + + const toggleSelection = (pair: DuplicatePair, action: Selection) => { + const key = pairKey(pair); + setSelections((prev) => { + const next = new Map(prev); + const existing = next.get(key); + if (existing && existing.action === action) { + // Deselect if clicking the same side + next.delete(key); + } else { + next.set(key, { pair, action }); + } + return next; + }); + }; + + const clearSelections = () => setSelections(new Map()); + + const selectedCount = selections.size; + + const processBatch = async () => { + setBatchProcessing(true); + setError(null); + const entries = Array.from(selections.entries()); + const removedKeys: string[] = []; + const failedKeys: string[] = []; + + for (const [key, { pair, action }] of entries) { + try { + const res = await fetch("/api/duplicates/resolve", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action, + thought_id_a: pair.thought_id_a, + thought_id_b: pair.thought_id_b, + }), + }); + if (!res.ok) throw new Error(`Failed for pair ${key}`); + removedKeys.push(key); + } catch { + // WR-03: surface per-pair failures instead of silently swallowing + failedKeys.push(key); + } + } + + // Remove resolved pairs from current-page state and from selections + setPairs((prev) => prev.filter((p) => !removedKeys.includes(pairKey(p)))); + setSelections((prev) => { + const next = new Map(prev); + for (const k of removedKeys) next.delete(k); + return next; + }); + setBatchProcessing(false); + setConfirmBatch(false); + // WR-03: Report partial-failure counts to the user. + if (failedKeys.length > 0) { + setError( + `Resolved ${removedKeys.length}/${entries.length} — ${failedKeys.length} failed. Try again or resolve them individually.` + ); + } + }; + + // Codex-P1-3: Resolve react-hooks/set-state-in-effect by keeping setLoading(true) + // OUT of useEffect — event handlers (threshold/offset changes) drive it. + useEffect(() => { + const controller = new AbortController(); + let cancelled = false; + + (async () => { + try { + const res = await fetch( + `/api/duplicates?threshold=${threshold}&limit=${PER_PAGE}&offset=${offset}`, + { signal: controller.signal } + ); + if (!res.ok) throw new Error("Failed to load"); + const data = await res.json(); + if (!cancelled) setPairs(data.pairs ?? []); + } catch (err) { + if (cancelled || controller.signal.aborted) return; + setError(err instanceof Error ? err.message : "Load failed"); + } finally { + if (!cancelled) setLoading(false); + } + })(); + + return () => { + cancelled = true; + controller.abort(); + }; + }, [threshold, offset]); + + const resolve = async ( + action: "keep_a" | "keep_b" | "keep_both", + pair: DuplicatePair + ) => { + const key = `${pair.thought_id_a}-${pair.thought_id_b}`; + setResolving(key); + try { + const res = await fetch("/api/duplicates/resolve", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action, + thought_id_a: pair.thought_id_a, + thought_id_b: pair.thought_id_b, + }), + }); + if (!res.ok) throw new Error("Resolve failed"); + setPairs((prev) => + prev.filter( + (p) => + !( + p.thought_id_a === pair.thought_id_a && + p.thought_id_b === pair.thought_id_b + ) + ) + ); + // REVIEW-CODEX-6 P2: prune selections so the toolbar count and + // later batch replay don't hit an already-resolved pair. + setSelections((prev) => { + const next = new Map(prev); + next.delete(key); + return next; + }); + } catch (err) { + setError(err instanceof Error ? err.message : "Resolve failed"); + } finally { + setResolving(null); + setConfirmDelete(null); + } + }; + + const pairKey = (p: DuplicatePair) => + `${p.thought_id_a}-${p.thought_id_b}`; + + if (loading && pairs.length === 0) { + return ( +
+

Duplicates

+
+
+ Searching for near-duplicates... +
+
+ ); + } + + return ( +
+
+
+

Duplicates

+

+ Semantic near-duplicates (similarity > {(threshold * 100).toFixed(0)}%) + {!loading && ` | ${pairs.length} pairs found`} +

+
+
+ + +
+
+ + {/* Batch action toolbar */} + {selectedCount > 0 && (() => { + const actions = Array.from(selections.values()).map((s) => s.action); + const deleteCount = actions.filter((a) => a === "keep_a" || a === "keep_b").length; + const keepBothCount = actions.filter((a) => a === "keep_both").length; + // REVIEW-CODEX-3 P2#1: How many of the currently-selected pairs are + // NOT on this page — surfaces cross-page selections to the user. + const currentPageKeys = new Set(pairs.map(pairKey)); + const offPageCount = Array.from(selections.keys()).filter( + (k) => !currentPageKeys.has(k) + ).length; + return ( +
+
+ + {selectedCount} pair{selectedCount > 1 ? "s" : ""} selected + {deleteCount > 0 && keepBothCount > 0 && ( + + {" "}({deleteCount} to delete, {keepBothCount} to dismiss) + + )} + + + +
+ {offPageCount > 0 && ( +

+ {offPageCount} selection{offPageCount > 1 ? "s" : ""} from other page{offPageCount > 1 ? "s" : ""} — selections persist across pagination. +

+ )} +
+ ); + })()} + + {error &&

{error}

} + + {pairs.length === 0 && !loading && offset === 0 && ( +
+ No near-duplicates found at this threshold. +
+ )} + + {pairs.length === 0 && !loading && offset > 0 && ( +
+ No duplicates on this page. Go back to see earlier results. +
+ )} + +
+ {pairs.map((pair) => { + const key = pairKey(pair); + const isResolving = resolving === key; + const sim = (pair.similarity * 100).toFixed(1); + + return ( +
+ {/* Header with similarity badge */} +
+ + {sim}% similar + +
+ +
+
+ + {/* Side-by-side content */} +
+ {/* Left: Thought A */} +
toggleSelection(pair, "keep_a")} + > +
+
+ toggleSelection(pair, "keep_a")} + onClick={(e) => e.stopPropagation()} + className="accent-emerald-500" + title="Keep this, delete the other" + /> + e.stopPropagation()} + > + #{pair.thought_id_a} + + +
+ + Q:{pair.quality_a ?? "—"} + +
+

+ {pair.content_a.length > 200 + ? pair.content_a.slice(0, 200) + "..." + : pair.content_a} +

+
+ +
+ {selections.get(key)?.action === "keep_a" && ( + Keep + )} + {selections.get(key)?.action === "keep_b" && ( + Delete + )} + +
+
+
+ + {/* Right: Thought B */} +
toggleSelection(pair, "keep_b")} + > +
+
+ toggleSelection(pair, "keep_b")} + onClick={(e) => e.stopPropagation()} + className="accent-emerald-500" + title="Keep this, delete the other" + /> + e.stopPropagation()} + > + #{pair.thought_id_b} + + +
+ + Q:{pair.quality_b ?? "—"} + +
+

+ {pair.content_b.length > 200 + ? pair.content_b.slice(0, 200) + "..." + : pair.content_b} +

+
+ +
+ {selections.get(key)?.action === "keep_b" && ( + Keep + )} + {selections.get(key)?.action === "keep_a" && ( + Delete + )} + +
+
+
+
+
+ ); + })} +
+ + {/* Pagination */} + {/* REVIEW-CODEX-6 P1: also render when past page 1 with 0 results, so + Previous stays available when the duplicate count is an exact + multiple of PER_PAGE and the next fetch returned 0 pairs. */} + {(offset > 0 || pairs.length > 0) && ( +
+

+ {pairs.length > 0 + ? `Showing ${offset + 1}–${offset + pairs.length}` + : `No results on this page`} +

+
+ + +
+
+ )} + + {/* Confirm single delete modal */} + {confirmDelete && ( + + resolve(confirmDelete.action, confirmDelete.pair) + } + onCancel={() => setConfirmDelete(null)} + /> + )} + + {/* Confirm batch resolve modal */} + {confirmBatch && (() => { + const actions = Array.from(selections.values()).map((s) => s.action); + const deleteCount = actions.filter((a) => a === "keep_a" || a === "keep_b").length; + const keepBothCount = actions.filter((a) => a === "keep_both").length; + const parts: string[] = []; + if (deleteCount > 0) parts.push(`delete ${deleteCount} duplicate${deleteCount > 1 ? "s" : ""}`); + if (keepBothCount > 0) parts.push(`dismiss ${keepBothCount} pair${keepBothCount > 1 ? "s" : ""}`); + return ( + setConfirmBatch(false)} + /> + ); + })()} +
+ ); +} diff --git a/dashboards/open-brain-dashboard-pro/app/globals.css b/dashboards/open-brain-dashboard-pro/app/globals.css new file mode 100644 index 000000000..90a1d8f93 --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/globals.css @@ -0,0 +1,51 @@ +@import "tailwindcss"; + +@theme inline { + --color-bg-primary: #0a0a0f; + --color-bg-surface: #111118; + --color-bg-elevated: #1a1a24; + --color-bg-hover: #222230; + --color-border: #2a2a3a; + --color-border-subtle: #1e1e2e; + --color-text-primary: #e8e8ef; + --color-text-secondary: #9090a8; + --color-text-muted: #606078; + --color-violet: #8b5cf6; + --color-violet-dim: #7c3aed; + --color-violet-glow: rgba(139, 92, 246, 0.15); + --color-violet-surface: rgba(139, 92, 246, 0.08); + --color-success: #34d399; + --color-warning: #fbbf24; + --color-danger: #f87171; + --color-info: #60a5fa; + --font-sans: var(--font-geist-sans), system-ui, sans-serif; + --font-mono: var(--font-geist-mono), monospace; +} + +body { + background: var(--color-bg-primary); + color: var(--color-text-primary); + font-family: var(--font-sans); +} + +/* Scrollbar */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} +::-webkit-scrollbar-track { + background: var(--color-bg-primary); +} +::-webkit-scrollbar-thumb { + background: var(--color-border); + border-radius: 3px; +} +::-webkit-scrollbar-thumb:hover { + background: var(--color-text-muted); +} + +/* Selection */ +::selection { + background: var(--color-violet-glow); + color: var(--color-text-primary); +} diff --git a/dashboards/open-brain-dashboard-pro/app/ingest/page.tsx b/dashboards/open-brain-dashboard-pro/app/ingest/page.tsx new file mode 100644 index 000000000..8b7fae208 --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/ingest/page.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import { AddToBrain } from "@/components/AddToBrain"; +import type { IngestionJob } from "@/lib/types"; +import { formatDate } from "@/lib/format"; + +const statusColor: Record = { + complete: "text-success", + dry_run_complete: "text-violet", + executing: "text-violet", + extracting: "text-warning", + pending: "text-warning", + failed: "text-danger", +}; + +export default function AddToBrainPage() { + const [jobs, setJobs] = useState([]); + const [loading, setLoading] = useState(true); + const [reloadTick, setReloadTick] = useState(0); + + // Codex-P1-3: Keep setLoading(true) OUT of useEffect. The effect only + // transitions loading to false on completion; event handlers (reload) + // set it to true synchronously. + // IN-06: fetchIngestionJobs signature drift — tolerate both shapes here + // (array vs { jobs: [...] }) so the client doesn't crash if the server + // contract changes. + useEffect(() => { + const controller = new AbortController(); + let cancelled = false; + + (async () => { + try { + const res = await fetch("/api/ingest", { signal: controller.signal }); + if (!res.ok) throw new Error("Failed to load jobs"); + const data = await res.json(); + const normalized: IngestionJob[] = Array.isArray(data) + ? data + : Array.isArray(data?.jobs) + ? data.jobs + : []; + if (!cancelled) setJobs(normalized); + } catch { + // silently ignore — job list is secondary + } finally { + if (!cancelled) setLoading(false); + } + })(); + + return () => { + cancelled = true; + controller.abort(); + }; + }, [reloadTick]); + + const loadJobs = useRef(() => { + setLoading(true); + setReloadTick((t) => t + 1); + }).current; + + return ( +
+
+

Add to Brain

+

+ Paste a thought, notes, or source text. Open Brain will decide + whether to save one thought or extract several. +

+
+ + {/* Unified input */} +
+ loadJobs()} + /> +
+ + {/* Job history */} +
+

Recent Activity

+ {loading ? ( +
+
+ Loading... +
+ ) : jobs.length === 0 ? ( +

+ No ingestion jobs yet. Add longer content to see extraction results + here. +

+ ) : ( +
+ {jobs.map((job) => ( +
+
+
+ + {job.source_label || `Job #${job.id}`} + + + {job.status.replace(/_/g, " ")} + +
+ + {formatDate(job.created_at)} + +
+
+ + Extracted:{" "} + + {job.extracted_count} + + + + Added:{" "} + {job.added_count} + + + Skipped:{" "} + + {job.skipped_count} + + + {job.revised_count > 0 && ( + + Revised:{" "} + {job.revised_count} + + )} +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/dashboards/open-brain-dashboard-pro/app/layout.tsx b/dashboards/open-brain-dashboard-pro/app/layout.tsx new file mode 100644 index 000000000..1bd594f1e --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/layout.tsx @@ -0,0 +1,49 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import "./globals.css"; +import { Sidebar } from "@/components/Sidebar"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "Open Brain", + description: "Open Brain second-brain dashboard", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + // Codex-P2-4: server-only check — only expose the restricted toggle to the + // client when the passphrase hash is configured. Passing as a boolean avoids + // leaking the hash itself into the client bundle. + const restrictedConfigured = Boolean( + process.env.RESTRICTED_PASSPHRASE_HASH && + process.env.RESTRICTED_PASSPHRASE_HASH.length > 0 + ); + + return ( + + + +
+
+ {children} +
+
+ + + ); +} diff --git a/dashboards/open-brain-dashboard-pro/app/login/LoginForm.tsx b/dashboards/open-brain-dashboard-pro/app/login/LoginForm.tsx new file mode 100644 index 000000000..2cbf68e41 --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/login/LoginForm.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useActionState } from "react"; + +export function LoginForm({ + action, +}: { + action: (formData: FormData) => Promise<{ error: string } | undefined>; +}) { + const [state, formAction, pending] = useActionState( + async (_prev: { error: string } | undefined, formData: FormData) => { + return await action(formData); + }, + undefined + ); + + return ( + +
+ + +
+ + {state?.error && ( +

{state.error}

+ )} + + + + ); +} diff --git a/dashboards/open-brain-dashboard-pro/app/login/layout.tsx b/dashboards/open-brain-dashboard-pro/app/login/layout.tsx new file mode 100644 index 000000000..13df71a85 --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/login/layout.tsx @@ -0,0 +1,8 @@ +export default function LoginLayout({ + children, +}: { + children: React.ReactNode; +}) { + // Login page renders without the sidebar (Sidebar checks pathname) + return <>{children}; +} diff --git a/dashboards/open-brain-dashboard-pro/app/login/page.tsx b/dashboards/open-brain-dashboard-pro/app/login/page.tsx new file mode 100644 index 000000000..79fe74f7a --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/login/page.tsx @@ -0,0 +1,60 @@ +import { redirect } from "next/navigation"; +import { getSession } from "@/lib/auth"; +import { checkHealth, ApiError } from "@/lib/api"; +import { LoginForm } from "./LoginForm"; + +async function loginAction(formData: FormData) { + "use server"; + + const apiKey = formData.get("apiKey") as string; + if (!apiKey?.trim()) { + return { error: "API key is required" }; + } + + // CR-02: Route through lib/api.ts::checkHealth — this both enforces the + // NEXT_PUBLIC_API_URL module-load guard (throws at import if missing) and + // centralizes the health-check shape so the login path cannot drift from + // the rest of the app. + try { + await checkHealth(apiKey.trim()); + } catch (err) { + if (err instanceof ApiError) { + return { error: "Invalid API key or service unavailable" }; + } + return { error: "Could not reach API. Check your connection." }; + } + + const session = await getSession(); + session.apiKey = apiKey.trim(); + session.loggedIn = true; + await session.save(); + + redirect("/"); +} + +export default async function LoginPage() { + const session = await getSession(); + if (session.loggedIn && session.apiKey) { + redirect("/"); + } + + return ( +
+
+
+
+ O +
+

+ Open Brain +

+

+ Enter your API key to continue +

+
+ + +
+
+ ); +} diff --git a/dashboards/open-brain-dashboard-pro/app/page.tsx b/dashboards/open-brain-dashboard-pro/app/page.tsx new file mode 100644 index 000000000..1d8cd031f --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/page.tsx @@ -0,0 +1,80 @@ +import { fetchStats, fetchThoughts, ApiError } from "@/lib/api"; +import { requireSessionOrRedirect, getSession } from "@/lib/auth"; +import { StatsWidget } from "@/components/StatsWidget"; +import { ThoughtCard } from "@/components/ThoughtCard"; +import { AddToBrain } from "@/components/AddToBrain"; + +export const dynamic = "force-dynamic"; + +export default async function DashboardPage() { + const { apiKey } = await requireSessionOrRedirect(); + const session = await getSession(); + const excludeRestricted = !session.restrictedUnlocked; + + let stats, recent; + try { + [stats, recent] = await Promise.all([ + fetchStats(apiKey, undefined, excludeRestricted), + fetchThoughts(apiKey, { page: 1, per_page: 5, exclude_restricted: excludeRestricted }), + ]); + } catch (err) { + // REVIEW-CODEX-2-P2: log upstream detail server-side, render only a + // generic user-safe message. ApiError.upstreamBody can contain SQL / + // schema / stack trace content and must never reach HTML. + if (err instanceof ApiError) { + console.error("[dashboard] upstream", err.status, err.upstreamBody); + } else { + console.error("[dashboard]", err); + } + const safeMessage = + err instanceof ApiError + ? err.message + : "Unknown error"; + return ( +
+

Dashboard

+
+ Failed to load dashboard data. Check API connection. +
+ {safeMessage} +
+
+ ); + } + + return ( +
+
+

Dashboard

+

+ Overview of your Open Brain +

+
+ + + + {/* Add to Brain */} +
+

Add to Brain

+

+ Paste a thought, notes, or source text. Open Brain decides whether to + save one thought or extract several. +

+ +
+ + {/* Recent activity */} +
+

Recent Activity

+
+ {recent.data.map((thought) => ( + + ))} + {recent.data.length === 0 && ( +

No thoughts yet.

+ )} +
+
+
+ ); +} diff --git a/dashboards/open-brain-dashboard-pro/app/search/page.tsx b/dashboards/open-brain-dashboard-pro/app/search/page.tsx new file mode 100644 index 000000000..f6cf22d69 --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/search/page.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { SearchBar } from "@/components/SearchBar"; +import { TypeBadge } from "@/components/ThoughtCard"; +import Link from "next/link"; +import type { Thought } from "@/lib/types"; +import { formatDate } from "@/lib/format"; + +type SearchResult = Thought & { similarity?: number; rank?: number }; + +interface SearchState { + results: SearchResult[]; + total: number; + page: number; + totalPages: number; + mode: "semantic" | "text"; +} + +export default function SearchPage() { + const [state, setState] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [lastQuery, setLastQuery] = useState(""); + const [lastMode, setLastMode] = useState<"semantic" | "text">("semantic"); + + const doSearch = useCallback( + async (query: string, mode: "semantic" | "text", page: number = 1) => { + setLoading(true); + setError(null); + setLastQuery(query); + setLastMode(mode); + try { + const res = await fetch( + `/api/search?q=${encodeURIComponent(query)}&mode=${mode}&page=${page}` + ); + if (!res.ok) throw new Error("Search failed"); + const data = await res.json(); + setState({ + results: data.results || [], + total: data.total || 0, + page: data.page || 1, + totalPages: data.total_pages || 1, + mode, + }); + } catch (err) { + setError(err instanceof Error ? err.message : "Search failed"); + setState(null); + } finally { + setLoading(false); + } + }, + [] + ); + + const handleSearch = useCallback( + (query: string, mode: "semantic" | "text") => { + doSearch(query, mode, 1); + }, + [doSearch] + ); + + const goToPage = useCallback( + (page: number) => { + if (lastQuery) doSearch(lastQuery, lastMode, page); + }, + [doSearch, lastQuery, lastMode] + ); + + return ( +
+
+

Search

+

+ Search across your Open Brain +

+
+ + + + {loading && ( +
+
+ Searching... +
+ )} + + {error &&

{error}

} + + {state !== null && !loading && ( +
+

+ {state.total} result{state.total !== 1 ? "s" : ""} + {state.totalPages > 1 && ( + + {" "} + · Page {state.page} of {state.totalPages} + + )} +

+
+ {state.results.map((r) => ( + +
+ + {state.mode === "semantic" && r.similarity != null && ( + + {(r.similarity * 100).toFixed(1)}% match + + )} + + {formatDate(r.created_at)} + +
+

+ {r.content.length > 300 + ? r.content.slice(0, 300) + "..." + : r.content} +

+ + ))} + {state.results.length === 0 && ( +

No results found.

+ )} +
+ + {/* Pagination */} + {state.totalPages > 1 && ( +
+

+ Page {state.page} of {state.totalPages} ({state.total} results) +

+
+ {state.page > 1 && ( + + )} + {state.page < state.totalPages && ( + + )} +
+
+ )} +
+ )} +
+ ); +} diff --git a/dashboards/open-brain-dashboard-pro/app/settings/page.tsx b/dashboards/open-brain-dashboard-pro/app/settings/page.tsx new file mode 100644 index 000000000..e22a2d6ec --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/settings/page.tsx @@ -0,0 +1,197 @@ +"use client"; + +import { useState, useEffect } from "react"; + +interface BrainStatus { + healthy: boolean; + totalThoughts: number; + // CR-01: embeddingCoverage removed from API response; card no longer shown. + types: Record; + topTopics: Array<{ topic: string; count: number }>; + // REVIEW-CODEX-2-P3: `sources` removed from the API response — no backend + // endpoint provides a real source breakdown yet, and shipping an empty + // placeholder was both a spec drift and a UX lie. Re-add when available. + apiKeyPrefix: string; +} + +export default function SettingsPage() { + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + (async () => { + try { + const res = await fetch("/api/settings/status"); + if (!res.ok) throw new Error("Failed to load brain status"); + const data: BrainStatus = await res.json(); + setStatus(data); + } catch (err) { + setError(err instanceof Error ? err.message : "Load failed"); + } finally { + setLoading(false); + } + })(); + }, []); + + if (loading) { + return ( +
+

Settings

+
+
+ Loading brain status... +
+
+ ); + } + + if (error) { + return ( +
+

Settings

+
+ {error} +
+
+ ); + } + + if (!status) return null; + + const typeEntries = Object.entries(status.types).sort((a, b) => b[1] - a[1]); + + return ( +
+
+

Settings

+

+ Brain status and connection details +

+
+ + {/* Connection status */} +
+

+ Connection +

+
+
+ Status +
+
+ + {status.healthy ? "Connected" : "Disconnected"} + +
+
+
+ API Key +

+ {status.apiKeyPrefix}... +

+
+
+
+ + {/* Brain overview */} +
+

+ Your Brain +

+ + {status.totalThoughts === 0 ? ( +
+

+ Your brain is empty. Add your first thought to get started. +

+ + Add your first thought + +
+ ) : ( +
+ + +
+ )} +
+ + {/* Type breakdown */} + {typeEntries.length === 0 && status.totalThoughts > 0 && ( +
+

+ Thought Types +

+

+ Type distribution not available. +

+
+ )} + {typeEntries.length > 0 && ( +
+

+ Thought Types +

+
+ {typeEntries.map(([type, count]) => { + const pct = Math.round((count / status.totalThoughts) * 100); + return ( +
+ + {type} + +
+
+
+ + {count.toLocaleString()} ({pct}%) + +
+ ); + })} +
+
+ )} + + {/* REVIEW-CODEX-2-P3: Source breakdown section removed — no backend + endpoint currently supplies these counts. Restore this block when + /stats (or a sibling endpoint) returns a real source map. */} + + {/* Top topics */} + {status.topTopics.length > 0 && ( +
+

+ Top Topics +

+
+ {status.topTopics.map(({ topic, count }) => ( + + {topic} + {count} + + ))} +
+
+ )} +
+ ); +} + +function StatCard({ label, value }: { label: string; value: string }) { + return ( +
+ {label} +

{value}

+
+ ); +} diff --git a/dashboards/open-brain-dashboard-pro/app/thoughts/[id]/page.tsx b/dashboards/open-brain-dashboard-pro/app/thoughts/[id]/page.tsx new file mode 100644 index 000000000..66ab6d1f8 --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/thoughts/[id]/page.tsx @@ -0,0 +1,171 @@ +import { notFound } from "next/navigation"; +import { + fetchThought, + updateThought, + deleteThought, + ApiError, +} from "@/lib/api"; +import { requireSessionOrRedirect, getSession } from "@/lib/auth"; +import { TypeBadge } from "@/components/ThoughtCard"; +import { ThoughtEditor } from "@/components/ThoughtEditor"; +import { ThoughtDeleteButton } from "@/components/ThoughtDeleteButton"; +import { ConnectionsPanel } from "@/components/ConnectionsPanel"; +import { FormattedDate } from "@/components/FormattedDate"; +import Link from "next/link"; + +export const dynamic = "force-dynamic"; + +export default async function ThoughtDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { apiKey } = await requireSessionOrRedirect(); + const session = await getSession(); + const excludeRestricted = !session.restrictedUnlocked; + const { id } = await params; + const thoughtId = parseInt(id, 10); + if (isNaN(thoughtId)) notFound(); + + let thought; + try { + thought = await fetchThought(apiKey, thoughtId, excludeRestricted); + } catch (err) { + if (err instanceof ApiError && err.status === 403) { + return ( +
+
🔒
+

Restricted Content

+

+ This thought is classified as restricted. Unlock restricted content using the lock icon in the sidebar to view it. +

+ + Back to Thoughts + +
+ ); + } + notFound(); + } + + const meta = thought.metadata || {}; + const topics = (meta.topics as string[]) || []; + const tags = (meta.tags as string[]) || []; + + async function editAction(formData: FormData) { + "use server"; + const { apiKey } = await requireSessionOrRedirect(); + const content = formData.get("content") as string; + const type = formData.get("type") as string; + const importance = parseInt(formData.get("importance") as string, 10); + await updateThought(apiKey, thoughtId, { content, type, importance }); + } + + async function deleteAction() { + "use server"; + const { apiKey } = await requireSessionOrRedirect(); + await deleteThought(apiKey, thoughtId); + } + + return ( +
+ {/* Header */} +
+
+
+ + + ID: {thought.id} + + {thought.uuid && ( + + UUID: {thought.uuid} + + )} + + Importance: {thought.importance} + + {thought.quality_score > 0 && ( + + Quality: {thought.quality_score} + + )} +
+

+ Created + {thought.source_type && ` | Source: ${thought.source_type}`} + {thought.sensitivity_tier && + thought.sensitivity_tier !== "standard" && + ` | Sensitivity: ${thought.sensitivity_tier}`} +

+
+ +
+ + {/* Content + Edit */} + + + {/* Metadata panel */} + {(topics.length > 0 || + tags.length > 0 || + Object.keys(meta).length > 0) && ( +
+

+ Metadata +

+ {topics.length > 0 && ( +
+ Topics: +
+ {topics.map((t) => ( + + {t} + + ))} +
+
+ )} + {tags.length > 0 && ( +
+ Tags: +
+ {tags.map((t) => ( + + {t} + + ))} +
+
+ )} + {typeof meta.summary === "string" && ( +
+ Summary: + + {meta.summary} + +
+ )} +
+ )} + + {/* Connections */} + 0 || + ((meta.people as string[]) || []).length > 0 + } + /> + +
+ ); +} diff --git a/dashboards/open-brain-dashboard-pro/app/thoughts/page.tsx b/dashboards/open-brain-dashboard-pro/app/thoughts/page.tsx new file mode 100644 index 000000000..374b2d2e4 --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/thoughts/page.tsx @@ -0,0 +1,162 @@ +import Link from "next/link"; +import { fetchThoughts, ApiError } from "@/lib/api"; +import { requireSessionOrRedirect, getSession } from "@/lib/auth"; +import { TypeBadge } from "@/components/ThoughtCard"; +import { ThoughtsFilter } from "@/components/ThoughtsFilter"; +import { FormattedDate } from "@/components/FormattedDate"; + +export const dynamic = "force-dynamic"; + +const TYPES = [ + "idea", + "task", + "person_note", + "reference", + "decision", + "lesson", + "meeting", + "journal", +]; + +export default async function ThoughtsPage({ + searchParams, +}: { + searchParams: Promise>; +}) { + const { apiKey } = await requireSessionOrRedirect(); + const session = await getSession(); + const excludeRestricted = session.restrictedUnlocked !== true; + const params = await searchParams; + const page = parseInt(params.page || "1", 10); + const type = params.type || ""; + const source_type = params.source_type || ""; + const importance_min = params.importance_min + ? parseInt(params.importance_min, 10) + : undefined; + + let data; + try { + data = await fetchThoughts(apiKey, { + page, + per_page: 25, + type: type || undefined, + source_type: source_type || undefined, + importance_min, + exclude_restricted: excludeRestricted, + }); + } catch (err) { + // REVIEW-CODEX-2-P2: log upstream detail server-side, render only a + // generic user-safe message. Never print ApiError.upstreamBody to HTML. + if (err instanceof ApiError) { + console.error("[thoughts] upstream", err.status, err.upstreamBody); + } else { + console.error("[thoughts]", err); + } + const safeMessage = err instanceof ApiError ? err.message : ""; + return ( +
+

Thoughts

+

+ Failed to load thoughts. {safeMessage} +

+
+ ); + } + + const totalPages = Math.ceil(data.total / data.per_page); + + function pageUrl(p: number) { + const sp = new URLSearchParams(); + sp.set("page", String(p)); + if (type) sp.set("type", type); + if (source_type) sp.set("source_type", source_type); + if (importance_min) sp.set("importance_min", String(importance_min)); + return `/thoughts?${sp.toString()}`; + } + + return ( +
+
+

Thoughts

+

+ {data.total.toLocaleString()} total thoughts +

+
+ + {/* Filters */} + + + {/* Table */} +
+ + + + + + + + + + + {data.data.map((t) => ( + + + + + + + ))} + +
ContentTypeImp.Date
+ + {t.content.length > 120 + ? t.content.slice(0, 120) + "..." + : t.content} + + + + {t.importance} + +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+

+ Page {page} of {totalPages} +

+
+ {page > 1 && ( + + Previous + + )} + {page < totalPages && ( + + Next + + )} +
+
+ )} +
+ ); +} diff --git a/dashboards/open-brain-dashboard-pro/components/AddToBrain.tsx b/dashboards/open-brain-dashboard-pro/components/AddToBrain.tsx new file mode 100644 index 000000000..ddfcf4a02 --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/components/AddToBrain.tsx @@ -0,0 +1,465 @@ +"use client"; + +import { useState, useCallback } from "react"; +import type { + AddToBrainMode, + AddToBrainResult, + IngestionItem, + IngestionJobDetail, +} from "@/lib/types"; + +// ── Display maps ─────────────────────────────────────────────────────────── + +interface AddToBrainProps { + /** Textarea row count (default 4) */ + rows?: number; + /** Show mode selector (default false — auto only) */ + showModeControl?: boolean; + /** Show inline job detail + execute (default false) */ + showJobDetail?: boolean; + /** Callback after successful add */ + onSuccess?: (result: AddToBrainResult) => void; +} + +const MODES: { value: AddToBrainMode; label: string; description: string }[] = [ + { + value: "auto", + label: "Auto", + description: "Open Brain decides the best path", + }, + { + value: "single", + label: "Save as one thought", + description: "Save the entire input as a single thought", + }, + { + value: "extract", + label: "Extract multiple thoughts", + description: "Run smart-ingest to extract atomic thoughts", + }, +]; + +const ACTION_COLORS: Record = { + add: "text-success", + skip: "text-text-muted", + create_revision: "text-info", + append_evidence: "text-violet", +}; + +const ACTION_LABELS: Record = { + add: "Add", + skip: "Skip", + create_revision: "Revise", + append_evidence: "Append", +}; + +const REASON_LABELS: Record = { + quality_gate_low_importance: "Filtered: low importance", + quality_gate_short_content: "Filtered: too short", + fingerprint_match: "Already exists", + semantic_duplicate: "Near-duplicate", + duplicate_within_job: "Duplicate in batch", + no_semantic_match: "New thought", + existing_is_richer: "Existing thought is richer", + new_has_more_info: "Has more detail than existing", +}; + +const IMPORTANCE_COLORS: Record = { + 0: "bg-bg-surface text-text-muted/60 border-border", + 1: "bg-bg-surface text-text-muted border-border", + 2: "bg-bg-surface text-text-muted border-border", + 3: "bg-bg-surface text-text-secondary border-border", + 4: "bg-amber-500/10 text-amber-400 border-amber-500/20", + 5: "bg-rose-500/10 text-rose-400 border-rose-500/20", + 6: "bg-red-500/15 text-red-400 border-red-500/30", +}; + +/** Renders a single ingestion item card with type, action, importance, tags, snippet, and thought link. */ +function ItemCard({ item, jobStatus }: { item: IngestionItem; jobStatus: string }) { + const [snippetOpen, setSnippetOpen] = useState(false); + const type = item.meta.type ?? "unknown"; + const importance = item.meta.importance; + const tags = item.meta.tags ?? []; + const snippet = item.meta.source_snippet; + const friendlyReason = item.reason ? (REASON_LABELS[item.reason] ?? item.reason) : null; + + return ( +
+ {/* Row 1: type badge + importance + action + reason */} +
+ + {type} + + {importance != null && ( + + {importance} + + )} + + {ACTION_LABELS[item.action] || item.action} + + {friendlyReason && ( + + — {friendlyReason} + + )} + {/* Thought link after execution */} + {item.result_thought_id && jobStatus === "complete" && ( + + View thought → + + )} +
+ + {/* Row 2: content (up to 280 chars) */} +

+ {item.content.length > 280 + ? item.content.slice(0, 277) + "..." + : item.content} +

+ + {/* Row 3: tags */} + {tags.length > 0 && ( +
+ {tags.map((tag) => ( + + {tag} + + ))} +
+ )} + + {/* Row 4: source snippet (collapsible) */} + {snippet && ( +
+ + {snippetOpen && ( +
+ {snippet} +
+ )} +
+ )} +
+ ); +} + +export function AddToBrain({ + rows = 4, + showModeControl = false, + showJobDetail = false, + onSuccess, +}: AddToBrainProps) { + const [text, setText] = useState(""); + const [mode, setMode] = useState("auto"); + const [submitting, setSubmitting] = useState(false); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const [showAdvanced, setShowAdvanced] = useState(false); + const [dryRun, setDryRun] = useState(false); + + // Job detail state (only used when showJobDetail is true) + const [jobDetail, setJobDetail] = useState(null); + const [loadingDetail, setLoadingDetail] = useState(false); + const [executing, setExecuting] = useState(false); + const [executeError, setExecuteError] = useState(null); + + const fetchJobDetail = useCallback(async (jobId: number) => { + setLoadingDetail(true); + try { + const res = await fetch(`/api/ingest/${jobId}`); + if (!res.ok) throw new Error("Failed to load job detail"); + const data: IngestionJobDetail = await res.json(); + setJobDetail(data); + } catch { + // Non-critical — the job link still works + } finally { + setLoadingDetail(false); + } + }, []); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!text.trim() || submitting) return; + + setSubmitting(true); + setError(null); + setResult(null); + setJobDetail(null); + setExecuteError(null); + + try { + const body: Record = { text: text.trim(), mode }; + if (showJobDetail && dryRun) { + body.dry_run = true; + } + + const res = await fetch("/api/ingest", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error( + (data as Record).error || "Failed to add" + ); + } + const data: AddToBrainResult = await res.json(); + setResult(data); + setText(""); + setMode("auto"); + onSuccess?.(data); + + // Auto-fetch job detail if enabled and we got a job_id + if (showJobDetail && data.job_id) { + fetchJobDetail(data.job_id); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Something went wrong"); + } finally { + setSubmitting(false); + } + }; + + const handleExecute = async () => { + if (!jobDetail || executing) return; + setExecuting(true); + setExecuteError(null); + + try { + const res = await fetch(`/api/ingest/${jobDetail.job.id}/execute`, { + method: "POST", + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error( + (data as Record).error || "Execution failed" + ); + } + // Refresh job detail to show updated status + await fetchJobDetail(jobDetail.job.id); + // Update the result message + setResult((prev) => + prev + ? { ...prev, status: "complete", message: "Thoughts committed to brain" } + : prev + ); + } catch (err) { + setExecuteError( + err instanceof Error ? err.message : "Execution failed" + ); + } finally { + setExecuting(false); + } + }; + + return ( +
+
+