Initial commit: RA-H Open Source Edition

Local-first knowledge management system with BYO API keys.

Features:
- 3-panel UI (Nodes | Focus | Helpers)
- SQLite + sqlite-vec for vector search
- Agent system (Easy/Hard mode orchestrators)
- Content extraction (YouTube, PDF, web)
- Integrate workflow for connection discovery
- Dimension system with auto-assignment

Tech stack:
- Next.js 15 + TypeScript + Tailwind CSS
- Anthropic (Claude) + OpenAI (GPT) via Vercel AI SDK

Setup:
  npm install && npm rebuild better-sqlite3
  scripts/dev/bootstrap-local.sh
  npm run dev

MIT License
This commit is contained in:
“BeeRad”
2025-12-15 16:14:28 +11:00
commit 733d1c3407
226 changed files with 46231 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# Database Configuration - SQLite (production ready)
# Paths are auto-detected if not specified
SQLITE_DB_PATH=/Users/<username>/Library/Application Support/RA-H/db/rah.sqlite
SQLITE_VEC_EXTENSION_PATH=./vendor/sqlite-extensions/vec0.dylib
# AI API Keys (main orchestrator + mini agents)
ANTHROPIC_API_KEY=your-anthropic-api-key-here
OPENAI_API_KEY=your-openai-api-key-here
# Optional overrides specifically for ra-h orchestration/delegation
RAH_ORCHESTRATOR_ANTHROPIC_API_KEY=
RAH_DELEGATE_OPENAI_API_KEY=
RAH_WISE_RAH_OPENAI_API_KEY= # Optional: GPT-5 for wise ra-h (falls back to OPENAI_API_KEY)
# Voice TTS defaults
RAH_TTS_MODEL=gpt-4o-mini-tts
RAH_TTS_VOICE=ash
RAH_TTS_COST_PER_1K_CHAR_USD=0.015
# Application Configuration
NODE_ENV=development
PORT=3000
NEXT_PUBLIC_APP_URL=http://localhost:3000
NEXT_PUBLIC_DEPLOYMENT_MODE=local
+6
View File
@@ -0,0 +1,6 @@
{
"extends": [
"next/core-web-vitals",
"next/typescript"
]
}
+53
View File
@@ -0,0 +1,53 @@
---
name: Bug report
about: Create a report to help us improve
title: '[BUG] '
labels: bug
assignees: ''
---
## Bug Description
A clear and concise description of what the bug is.
## Steps to Reproduce
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
## Expected Behavior
A clear and concise description of what you expected to happen.
## Actual Behavior
A clear and concise description of what actually happened.
## Screenshots
If applicable, add screenshots to help explain your problem.
## Environment
- OS: [e.g., macOS 13.5, Ubuntu 22.04, Windows 11]
- Node.js Version: [e.g., 18.17.0]
- Browser: [e.g., Chrome 115, Firefox 116, Safari 16.6]
- RA-H Version: [e.g., 0.1.0]
## Database Information
- PostgreSQL Version: [if known]
- Docker Version: [if using Docker]
- Any error messages from database logs
## Additional Context
- Error messages (include full stack traces if available)
- Console output
- Network tab information (if API-related)
- Any other context about the problem
## Possible Solution
If you have ideas on how to fix the issue, please describe them here.
## Checklist
- [ ] I have searched existing issues to ensure this is not a duplicate
- [ ] I have provided all the requested information
- [ ] I can reproduce this issue consistently
- [ ] I have included error messages and logs where applicable
+52
View File
@@ -0,0 +1,52 @@
---
name: Feature request
about: Suggest an idea for this project
title: '[FEATURE] '
labels: enhancement
assignees: ''
---
## Problem Statement
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
## Proposed Solution
A clear and concise description of what you want to happen.
## Alternative Solutions
A clear and concise description of any alternative solutions or features you've considered.
## Use Cases
Describe specific use cases where this feature would be helpful:
1. Use case 1: [description]
2. Use case 2: [description]
## Implementation Ideas
If you have ideas on how this could be implemented, please describe them:
- Frontend changes needed
- Backend/API changes needed
- Database schema changes
- New dependencies required
## Priority
How important is this feature to you?
- [ ] Nice to have
- [ ] Important for my workflow
- [ ] Critical for adoption
## Mockups/Examples
If applicable, add mockups, wireframes, or examples to help explain your request.
## Additional Context
Add any other context, links to similar features in other apps, or screenshots about the feature request here.
## Acceptance Criteria
What would need to be true for this feature to be considered complete?
- [ ] Criteria 1
- [ ] Criteria 2
- [ ] Criteria 3
## Related Issues
Are there any existing issues that relate to this request?
- Fixes #
- Related to #
+125
View File
@@ -0,0 +1,125 @@
# Pull Request
## Description
Brief description of what this PR does and why.
## Type of Change
Please delete options that are not relevant.
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] Documentation update
- [ ] Code refactoring (no functional changes)
- [ ] Performance improvement
- [ ] Test coverage improvement
## Related Issues
Closes #[issue number]
Related to #[issue number]
## Changes Made
- [ ] Change 1: Description
- [ ] Change 2: Description
- [ ] Change 3: Description
## How Has This Been Tested?
Please describe the tests that you ran to verify your changes:
- [ ] Unit tests
- [ ] Integration tests
- [ ] Manual testing
- [ ] API endpoint testing (if applicable)
- [ ] Database operations verified
- [ ] UI/UX testing (if applicable)
### Test Configuration
- Node.js version: [e.g., 18.17.0]
- Operating System: [e.g., macOS 13.5]
- Database: [e.g., PostgreSQL 15 with pgvector]
## Screenshots/Videos
If applicable, add screenshots or videos to demonstrate the changes.
### Before
[Screenshot/description of before state]
### After
[Screenshot/description of after state]
## Database Changes
- [ ] No database changes
- [ ] Schema changes (describe below)
- [ ] Migration required
- [ ] Seeds/fixtures updated
If database changes are made, describe them:
```sql
-- Example of schema changes
```
## API Changes
- [ ] No API changes
- [ ] New endpoints added
- [ ] Existing endpoints modified
- [ ] Breaking changes to API
If API changes are made, describe them:
```typescript
// Example of API changes
```
## Performance Impact
- [ ] No performance impact expected
- [ ] Performance improvement
- [ ] Potential performance impact (explain below)
## Security Considerations
- [ ] No security implications
- [ ] Security improvement
- [ ] Potential security impact (explain below)
## Documentation
- [ ] Documentation updated
- [ ] No documentation changes needed
- [ ] Documentation to be updated in separate PR
## Checklist
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings or errors
- [ ] I have added tests that prove my fix is effective or that my feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published
## Deployment Notes
Special instructions for deploying this change:
- [ ] No special deployment requirements
- [ ] Environment variables need to be updated
- [ ] Database migration required
- [ ] External service configuration needed
## Breaking Changes
If this introduces breaking changes, describe the migration path:
## Additional Notes
Any additional information that reviewers should know:
---
## For Reviewers
### Review Focus Areas
Please pay special attention to:
- [ ] Code quality and style
- [ ] Test coverage
- [ ] Performance implications
- [ ] Security considerations
- [ ] Documentation accuracy
### Testing Instructions
Specific steps for reviewers to test this change:
1. Step 1
2. Step 2
3. Step 3
+86
View File
@@ -0,0 +1,86 @@
# Dependencies
node_modules/
.pnp
.pnp.js
.yarn/install-state.gz
# Testing
coverage/
.nyc_output/
# Next.js
.next/
out/
build/
dist/
# Misc
.DS_Store
*.pem
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Vercel
.vercel
# Typescript
*.tsbuildinfo
next-env.d.ts
# Database
*.db
*.sqlite
*.sqlite3
rah_trial.db
pgdata/
/pgdata
# Migration temporary files
migration-report-*.json
*.sql
# Logs
logs/
*.log
# OS files
Thumbs.db
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Backup files
backups/
# Temporary files
tmp/
apps/mac/src-tauri/target/
dist/local-app/
dist/runtime/
dist/.staging/
+60
View File
@@ -0,0 +1,60 @@
# RA-H Open Source - Knowledge Management System
## What This Is
LLM-powered knowledge management system built for emergence and flexibility. This is the **open source, self-hosted version** with BYO (bring your own) API keys.
## Tech Stack
- Next.js 15 + TypeScript + Tailwind CSS
- SQLite + sqlite-vec (vector search)
- Anthropic (Claude) + OpenAI (GPT) models via Vercel AI SDK
- 3-panel UI: Nodes | Focus | Helpers
## Quick Start
```bash
git clone https://github.com/bradwmorris/ra-h_os.git
cd ra-h_os
npm install
npm rebuild better-sqlite3
scripts/dev/bootstrap-local.sh
npm run dev
```
Open http://localhost:3000 and enter your API keys (OpenAI + Anthropic).
## Agent System
**Orchestrator (Easy Mode):** GPT-5 Mini - DEFAULT - fast, cheap orchestration
**Orchestrator (Hard Mode):** Claude Sonnet 4.5 - deep reasoning (toggle via UI)
**Oracle (Wise ra-h):** GPT-5 - complex workflows, multi-step planning
**Delegates:** GPT-4o mini - spawned for write operations, extraction, batch tasks
Tools available: queryNodes, queryEdge, searchContentEmbeddings, webSearch, think, executeWorkflow, createNode, updateNode, createEdge, updateEdge, youtubeExtract, websiteExtract, paperExtract
## Workflows System
- **Code-first registry:** Defined in `src/services/workflows/registry.ts`
- **Integrate workflow:** Database-wide connection discovery for focused nodes
- 5-step process: plan → ground → search → contextualize → append
- Finds 3-8 strong connections across your database
## Database
- SQLite at `~/Library/Application Support/RA-H/db/rah.sqlite`
- Schema defined in `docs/2_schema.md`
- Health check: `GET /api/health/db`
## Key Files
- `src/services/agents/` - Agent executors and delegation
- `src/tools/` - All available tools
- `src/config/prompts/` - Agent system prompts
- `src/services/workflows/` - Workflow definitions
- `src/components/` - React components
## Documentation
- `docs/0_overview.md` - System overview
- `docs/1_architecture.md` - Architecture details
- `docs/2_schema.md` - Database schema
- `docs/4_tools-and-workflows.md` - Tools reference
## Contributing
See `CONTRIBUTING.md` for guidelines. Issues and PRs welcome!
## License
MIT - see LICENSE file
+173
View File
@@ -0,0 +1,173 @@
# Contributing to RA-H
Thank you for your interest in contributing to RA-H! This guide explains how to work inside the private repo that powers the packaged Mac app.
> **Licensing note:** By contributing, you agree that your contributions are provided under the [PolyForm Noncommercial License 1.0.0](LICENSE). If you need a commercial exception, contact hello@ra-h.app before submitting changes.
## 🎯 Ways to Contribute
- **🐛 Bug Reports**: Found a bug? Let us know!
- **💡 Feature Requests**: Have ideas for new features?
- **📝 Documentation**: Help improve our docs
- **🔧 Code Contributions**: Fix bugs or implement features
- **🧪 Testing**: Help us test new features and find edge cases
## 🚀 Getting Started
### Development Setup
Begin with `docs/development/process/0_kickstart.md` for internal context. When touching the desktop build, read `docs/development/process/6_macpack.md` so you follow the packaging checklist. `docs/9_open-source.md` simply tracks the future BYO-key repo idea; there is no public OSS workflow today.
### Development Workflow
**Important**: We use Claude Code for all development. Follow the 7-step workflow documented in `docs/development/process/1_workflow.md`:
1. **Review** - Read handoff and workflow docs
2. **Branch** - Create feature branch (NEVER work on main)
3. **Plan** - Write PRD and get approval
4. **Implement** - Code with user testing
5. **Document** - Update handoff and CLAUDE.md
6. **Commit** - Save and merge to main
7. **Cleanup** - Delete branch, confirm clean state
### Quick Commands
```bash
# Start new feature
git checkout main && git pull && git checkout -b feature/your-name
# Basic development
npm run build && npm run type-check && npm run lint
# Clean generated artefacts before committing
npm run clean:local
```
## 📝 Code Standards
### TypeScript
- Use strict TypeScript - no `any` types unless absolutely necessary
- Provide proper type definitions for all functions and objects
- Use meaningful interface names
### React/Next.js
- Use functional components with hooks
- Follow Next.js App Router patterns
- Use proper error boundaries
### Database
- All database operations must use the service layer (`/src/services/database/`)
- No direct SQL in components - use service methods
- Include proper error handling
### Styling
- Use Tailwind CSS utilities
- Follow the existing color scheme (dark theme)
- Ensure responsive design
## 🏗️ Project Architecture
See `docs/overview.md` for complete system architecture.
### Key Patterns
- Use service layer for all database operations
- Components organized by feature area
- Helpers are JSON-configured AI assistants
- All development follows 7-step Claude Code workflow
## 🧪 Testing
Manual testing is primary - use `npm run build && npm run type-check && npm run lint` to verify changes.
## 📚 Documentation
### What to Document
- New features and their usage
- API endpoint changes
- Database schema modifications
- Breaking changes
### Documentation Style
- Use clear, concise language
- Include code examples
- Add screenshots for UI changes
- Keep README.md updated
## 🚨 Issue Reporting
### Bug Reports
Include:
- Steps to reproduce
- Expected vs actual behavior
- Environment details (OS, Node version, etc.)
- Screenshots if applicable
- Error messages/logs
### Feature Requests
Include:
- Clear problem description
- Proposed solution
- Use cases
- Alternatives considered
## 🔍 Pull Request Process
### Before Submitting
- [ ] Tests pass locally
- [ ] Code follows our style guide
- [ ] Documentation updated if needed
- [ ] Branch is up to date with main
### PR Template
We'll provide a template, but include:
- **Description**: What does this PR do?
- **Type**: Bug fix, feature, docs, etc.
- **Testing**: How was this tested?
- **Screenshots**: For UI changes
### Review Process
1. Automated checks must pass
2. At least one maintainer review required
3. Address feedback promptly
4. Squash commits before merge
## 🏷️ Labels and Tagging
We use these labels:
- `bug` - Something isn't working
- `enhancement` - New feature or request
- `documentation` - Improvements to docs
- `good first issue` - Good for newcomers
- `help wanted` - Extra attention needed
- `priority: high/medium/low` - Priority levels
## 💬 Communication
### Channels
- **Issues**: Bug reports and feature requests
- **Discussions**: General questions and ideas
- **Pull Requests**: Code review discussions
### Code of Conduct
- Be respectful and inclusive
- Focus on constructive feedback
- Help others learn and grow
- Follow our [Code of Conduct](CODE_OF_CONDUCT.md)
## 🎉 Recognition
Contributors will be:
- Listed in our README acknowledgments
- Mentioned in release notes
- Invited to join our contributors team
## 📞 Getting Help
Stuck? Need help?
- Check existing issues and discussions
- Create a new discussion for questions
- Tag maintainers in issues if urgent
- Join our community discussions
---
**Happy Contributing!** 🚀
Your contributions help make RA-H better for everyone. Thank you for being part of our open-source community!
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Bradley Morris
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+64
View File
@@ -0,0 +1,64 @@
# RA-H Open Source
A local-first research workspace with the complete RA-H three-panel interface, vector search, content ingestion, workflows, and conversation agents. This edition removes the Mac packaging, hosted authentication, and subscription backend so you can run everything locally with your own API keys.
## Features
- **3-Panel interface** Explore nodes, focus, and chat with the orchestrator in one view.
- **Bring-your-own keys** Works with your Anthropic/OpenAI keys only; nothing is sent to RA-H.
- **Local SQLite + sqlite-vec** Semantic search, workflows, and memories run on your machine.
- **Content extraction** YouTube, PDF, and web extraction pipelines included.
- **Extensible workflows** Integrate workflow + tool registry ship intact for further hacking.
## Getting Started
### Prerequisites
- Node.js 20+
- npm 10+
- SQLite with `sqlite-vec` extension (prebuilt macOS binary is under `vendor/sqlite-extensions/vec0.dylib`; see `docs/2_schema.md` for build instructions on Linux/Windows)
### Install & Bootstrap
```bash
git clone https://github.com/bradwmorris/ra-h_os.git
cd ra-h_os
npm install
scripts/dev/bootstrap-local.sh # seeds SQLite schema + local env template
npm run dev # http://localhost:3000
```
When the UI loads, open **Settings → API Keys** and paste your OpenAI/Anthropic keys. They are stored locally via `src/services/storage/apiKeys.ts`.
### Environment
- `.env.example` documents every supported variable and defaults to `NEXT_PUBLIC_DEPLOYMENT_MODE=local`.
- Custom database paths: set `SQLITE_DB_PATH` and `SQLITE_VEC_EXTENSION_PATH`.
- No `.env.local` ships with the repo—run the bootstrap script to create yours.
## Project Layout
```
app/ Next.js App Router entrypoints
components/ UI building blocks (auth/tauri removed)
docs/ Architecture + schema docs (updated for local mode)
scripts/ Local dev helpers (bootstrap, sqlite backup/restore, audits)
src/services/ Agents, embeddings, ingestion, storage, workflows
vendor/sqlite-extensions/vec0.dylib macOS sqlite-vec build
```
## Development Scripts
- `npm run dev` Local Next.js dev server (local mode forced)
- `npm run build` / `npm start` Production build/start in local-only mode
- `npm run lint`, `npm run type-check` Quality gates
- `npm run sqlite:backup` / `npm run sqlite:restore` Database snapshots
## Documentation
- `docs/0_overview.md` Product background
- `docs/1_architecture.md` Agents, tools, and workflow internals
- `docs/2_schema.md` SQLite schema + sqlite-vec setup
- `docs/4_tools-and-workflows.md` Tool registry + workflow guide
- `docs/9_open-source.md` Local BYO-key process tracking
Private runbooks, Supabase CRM docs, and Mac packaging instructions were removed from this tree. See `docs/os_docs/2025-02-09-open-source-porting-notes.md` for details on what was changed from the private repo.
## Contributing
Issues and PRs are welcome! Please open a draft PR with context on the feature/fix, list any new environment requirements, and include manual test notes. See `CONTRIBUTING.md` for the lightweight guidelines.
## License
Released under the [MIT License](LICENSE). By contributing you agree that your code is provided under the same license.
+63
View File
@@ -0,0 +1,63 @@
import { NextRequest, NextResponse } from 'next/server';
import { CostAnalytics } from '@/services/analytics/costs';
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const period = searchParams.get('period') || 'week';
const agent = searchParams.get('agent');
const traceId = searchParams.get('trace_id');
const startDate = searchParams.get('start_date');
const endDate = searchParams.get('end_date');
if (traceId) {
const traceCosts = CostAnalytics.getCostsByTrace(traceId);
return NextResponse.json(traceCosts);
}
if (agent) {
const agentCosts = CostAnalytics.getCostsByAgent(
agent,
startDate || undefined,
endDate || undefined
);
return NextResponse.json(agentCosts);
}
const days = period === 'day' ? 1 : period === 'week' ? 7 : period === 'month' ? 30 : 7;
if (startDate && endDate) {
const dateRangeCosts = CostAnalytics.getCostsByDateRange(startDate, endDate);
return NextResponse.json(dateRangeCosts);
}
const now = new Date();
const start = new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
const periodCosts = CostAnalytics.getCostsByDateRange(
start.toISOString(),
now.toISOString()
);
const cacheStats = CostAnalytics.getCacheEffectiveness(
start.toISOString(),
now.toISOString()
);
const dailyBreakdown = CostAnalytics.getDailyBreakdown(days);
return NextResponse.json({
period: period,
summary: periodCosts,
cache: cacheStats,
daily: dailyBreakdown,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('❌ [Analytics] Error:', errorMessage);
return NextResponse.json(
{ error: errorMessage },
{ status: 500 }
);
}
}
+105
View File
@@ -0,0 +1,105 @@
import { NextRequest, NextResponse } from 'next/server';
import { isSubscriptionBackendEnabled } from '@/config/runtime';
const BACKEND_BASE_URL = (process.env.BACKEND_SERVICE_URL || process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:3001').replace(/\/$/, '');
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
async function proxyRequest(
request: NextRequest,
context: { params: Promise<{ endpoint?: string[] }> }
) {
if (!isSubscriptionBackendEnabled()) {
return NextResponse.json({ error: 'Subscription backend disabled' }, { status: 404 });
}
if (!BACKEND_BASE_URL) {
return NextResponse.json({ error: 'Backend URL is not configured' }, { status: 500 });
}
const { endpoint: pathSegments = [] } = await context.params;
const targetPath = pathSegments.join('/');
const search = request.nextUrl.search || '';
const targetUrl = `${BACKEND_BASE_URL}${targetPath ? `/${targetPath}` : ''}${search}`;
const outgoingHeaders = new Headers();
const authHeader = request.headers.get('authorization');
const contentType = request.headers.get('content-type');
const clientInfo = request.headers.get('x-client-info');
if (authHeader) {
outgoingHeaders.set('authorization', authHeader);
}
if (contentType) {
outgoingHeaders.set('content-type', contentType);
}
if (clientInfo) {
outgoingHeaders.set('x-client-info', clientInfo);
}
outgoingHeaders.set('accept', request.headers.get('accept') || 'application/json');
const bodyAllowed = !['GET', 'HEAD', 'OPTIONS'].includes(request.method.toUpperCase());
const body = bodyAllowed ? await request.text() : undefined;
try {
const backendResponse = await fetch(targetUrl, {
method: request.method,
headers: outgoingHeaders,
body,
});
const responseHeaders = new Headers();
backendResponse.headers.forEach((value, key) => {
const lowerKey = key.toLowerCase();
if (['content-length', 'transfer-encoding', 'connection'].includes(lowerKey)) {
return;
}
responseHeaders.set(key, value);
});
responseHeaders.set('cache-control', 'no-store');
if (request.method.toUpperCase() === 'HEAD') {
return new NextResponse(null, {
status: backendResponse.status,
headers: responseHeaders,
});
}
const text = await backendResponse.text();
return new NextResponse(text, {
status: backendResponse.status,
headers: responseHeaders,
});
} catch (error) {
console.error('[backend-proxy] Failed to reach backend:', error);
return NextResponse.json(
{ error: 'Failed to contact backend service' },
{ status: 502 }
);
}
}
export function GET(request: NextRequest, context: { params: Promise<{ endpoint?: string[] }> }) {
return proxyRequest(request, context);
}
export function POST(request: NextRequest, context: { params: Promise<{ endpoint?: string[] }> }) {
return proxyRequest(request, context);
}
export function PUT(request: NextRequest, context: { params: Promise<{ endpoint?: string[] }> }) {
return proxyRequest(request, context);
}
export function PATCH(request: NextRequest, context: { params: Promise<{ endpoint?: string[] }> }) {
return proxyRequest(request, context);
}
export function DELETE(request: NextRequest, context: { params: Promise<{ endpoint?: string[] }> }) {
return proxyRequest(request, context);
}
export function OPTIONS(request: NextRequest, context: { params: Promise<{ endpoint?: string[] }> }) {
return proxyRequest(request, context);
}
+67
View File
@@ -0,0 +1,67 @@
import { NextResponse } from 'next/server';
import type { CacheStats } from '@/types/prompts';
declare global {
// eslint-disable-next-line no-var
var lastCacheStats: CacheStats | undefined;
}
export async function GET() {
try {
const stats = global.lastCacheStats;
if (!stats) {
return NextResponse.json({
error: 'No cache statistics available yet',
message: 'Send a message to ra-h to generate cache stats'
}, { status: 404 });
}
const hitRate = stats.cacheReadInputTokens > 0 ? 'HIT' : 'MISS';
const totalInputTokens = stats.inputTokens + stats.cacheCreationInputTokens + stats.cacheReadInputTokens;
// Cost calculation (Sonnet 4.5 pricing)
const baseCost = 3.0; // $3 per million input tokens
const writeCost = baseCost * 1.25; // $3.75 per million
const readCost = baseCost * 0.1; // $0.30 per million
const actualCost = (
(stats.inputTokens * baseCost) +
(stats.cacheCreationInputTokens * writeCost) +
(stats.cacheReadInputTokens * readCost)
) / 1_000_000;
const noCacheCost = (totalInputTokens * baseCost) / 1_000_000;
const costSavingsPercentage = noCacheCost > 0
? Math.round(((noCacheCost - actualCost) / noCacheCost) * 100)
: 0;
return NextResponse.json({
status: 'success',
timestamp: new Date().toISOString(),
lastRequest: {
hitRate,
tokens: {
cacheWrite: stats.cacheCreationInputTokens,
cacheRead: stats.cacheReadInputTokens,
regular: stats.inputTokens,
totalInput: totalInputTokens,
output: stats.outputTokens
},
savings: {
tokenPercentage: stats.savingsPercentage,
costPercentage: costSavingsPercentage,
actualCostUSD: actualCost.toFixed(6),
noCacheCostUSD: noCacheCost.toFixed(6),
savedUSD: (noCacheCost - actualCost).toFixed(6)
}
}
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return NextResponse.json(
{ error: 'Failed to fetch cache stats', details: errorMessage },
{ status: 500 }
);
}
}
+109
View File
@@ -0,0 +1,109 @@
import { NextRequest, NextResponse } from 'next/server';
import { getSQLiteClient } from '@/services/database/sqlite-client';
import { eventBroadcaster } from '@/services/events';
export const runtime = 'nodejs';
export async function GET() {
try {
return getPopularDimensionsSQLite();
} catch (error) {
console.error('Error fetching popular dimensions:', error);
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch popular dimensions'
}, { status: 500 });
}
}
// PostgreSQL path removed in SQLite-only consolidation
async function getPopularDimensionsSQLite() {
const sqlite = getSQLiteClient();
const result = sqlite.query(`
WITH dimension_counts AS (
SELECT nd.dimension, COUNT(*) AS count
FROM node_dimensions nd
GROUP BY nd.dimension
),
all_dimensions AS (
SELECT DISTINCT dimension AS name FROM node_dimensions
UNION
SELECT name FROM dimensions
)
SELECT ad.name AS dimension,
COALESCE(dc.count, 0) AS count,
COALESCE(dim.is_priority, 0) AS is_priority,
dim.description
FROM all_dimensions ad
LEFT JOIN dimension_counts dc ON dc.dimension = ad.name
LEFT JOIN dimensions dim ON dim.name = ad.name
WHERE ad.name IS NOT NULL
ORDER BY is_priority DESC, LOWER(ad.name) ASC
`);
return NextResponse.json({
success: true,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: result.rows.map((row: any) => ({
dimension: row.dimension,
count: Number(row.count),
isPriority: Boolean(row.is_priority),
description: row.description || null
}))
});
}
export async function POST(request: NextRequest) {
try {
const { dimension } = await request.json();
if (!dimension || typeof dimension !== 'string') {
return NextResponse.json({
success: false,
error: 'Dimension name is required'
}, { status: 400 });
}
return togglePrioritySQLite(dimension);
} catch (error) {
console.error('Error toggling dimension priority:', error);
return NextResponse.json({
success: false,
error: 'Internal server error'
}, { status: 500 });
}
}
// PostgreSQL path removed in SQLite-only consolidation
async function togglePrioritySQLite(dimension: string) {
const sqlite = getSQLiteClient();
const result = sqlite.query(`
INSERT INTO dimensions(name, is_priority, updated_at)
VALUES (?, 1, CURRENT_TIMESTAMP)
ON CONFLICT(name) DO UPDATE SET
is_priority = CASE WHEN is_priority=1 THEN 0 ELSE 1 END,
updated_at = CURRENT_TIMESTAMP
RETURNING is_priority
`, [dimension]);
const isPriority = Boolean(result.rows[0].is_priority);
// Broadcast dimension update event
eventBroadcaster.broadcast({
type: 'DIMENSION_UPDATED',
data: { dimension, isPriority }
});
return NextResponse.json({
success: true,
data: {
dimension,
is_priority: isPriority
}
});
}
+326
View File
@@ -0,0 +1,326 @@
import { NextRequest, NextResponse } from 'next/server';
import { getSQLiteClient } from '@/services/database/sqlite-client';
import { eventBroadcaster } from '@/services/events';
import { DimensionService } from '@/services/database/dimensionService';
export const runtime = 'nodejs';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const rawName = typeof body?.name === 'string' ? body.name.trim() : '';
const description = typeof body?.description === 'string' ? body.description.trim() : null;
const isPriority = typeof body?.isPriority === 'boolean' ? body.isPriority : false;
if (!rawName) {
return NextResponse.json({
success: false,
error: 'Dimension name is required'
}, { status: 400 });
}
if (description && description.length > 500) {
return NextResponse.json({
success: false,
error: 'Description must be 500 characters or less'
}, { status: 400 });
}
const sqlite = getSQLiteClient();
const result = sqlite.query(`
INSERT INTO dimensions(name, description, is_priority, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(name) DO UPDATE SET
description = COALESCE(?, description),
is_priority = COALESCE(?, is_priority),
updated_at = CURRENT_TIMESTAMP
RETURNING name, description, is_priority
`, [rawName, description, isPriority ? 1 : 0, description, isPriority ? 1 : 0]);
if (result.rows.length === 0) {
throw new Error('Failed to create dimension');
}
const row = result.rows[0];
const dimension = row.name as string;
const isPriorityValue = Boolean(row.is_priority);
const descriptionValue = row.description as string | null;
eventBroadcaster.broadcast({
type: 'DIMENSION_UPDATED',
data: { dimension, isPriority: isPriorityValue, description: descriptionValue, count: 0 }
});
return NextResponse.json({
success: true,
data: {
dimension,
description: descriptionValue,
isPriority: isPriorityValue
}
});
} catch (error) {
console.error('Error creating dimension:', error);
return NextResponse.json({
success: false,
error: 'Failed to create dimension'
}, { status: 500 });
}
}
export async function PUT(request: NextRequest) {
try {
const body = await request.json();
const currentName = typeof body?.currentName === 'string' ? body.currentName.trim() : '';
const newName = typeof body?.newName === 'string' ? body.newName.trim() : '';
const name = typeof body?.name === 'string' ? body.name.trim() : '';
const description = typeof body?.description === 'string' ? body.description.trim() : '';
const isPriority = typeof body?.isPriority === 'boolean' ? body.isPriority : undefined;
// Handle isPriority update (lock/unlock) - simple case
if (isPriority !== undefined && name && !currentName && !newName) {
const sqlite = getSQLiteClient();
const updateResult = sqlite.prepare(`
UPDATE dimensions
SET is_priority = ?, updated_at = CURRENT_TIMESTAMP
WHERE name = ?
`).run(isPriority ? 1 : 0, name);
if (updateResult.changes === 0) {
return NextResponse.json({
success: false,
error: 'Dimension not found'
}, { status: 404 });
}
eventBroadcaster.broadcast({
type: 'DIMENSION_UPDATED',
data: { dimension: name, isPriority }
});
return NextResponse.json({
success: true,
data: { dimension: name, isPriority }
});
}
// Handle dimension name change
if (currentName && newName && currentName !== newName) {
if (!newName) {
return NextResponse.json({
success: false,
error: 'New dimension name is required'
}, { status: 400 });
}
const sqlite = getSQLiteClient();
// Check if new name already exists
const existingCheck = sqlite.query(`
SELECT name FROM dimensions WHERE name = ?
`, [newName]);
if (existingCheck.rows.length > 0) {
return NextResponse.json({
success: false,
error: 'A dimension with this name already exists'
}, { status: 400 });
}
// Update dimension name in transaction (also handle description and isPriority if provided)
const updateResult = sqlite.transaction(() => {
// Build update query with optional fields
const updates: string[] = ['name = ?', 'updated_at = CURRENT_TIMESTAMP'];
const values: any[] = [newName];
if (description !== '') {
updates.push('description = ?');
values.push(description || null);
}
if (isPriority !== undefined) {
updates.push('is_priority = ?');
values.push(isPriority ? 1 : 0);
}
values.push(currentName);
const dimUpdate = sqlite.prepare(`
UPDATE dimensions
SET ${updates.join(', ')}
WHERE name = ?
`).run(...values);
// Update node_dimensions table
const nodeDimUpdate = sqlite.prepare(`
UPDATE node_dimensions
SET dimension = ?
WHERE dimension = ?
`).run(newName, currentName);
return {
dimensionUpdated: dimUpdate.changes > 0,
nodeLinksUpdated: nodeDimUpdate.changes
};
});
if (!updateResult.dimensionUpdated) {
return NextResponse.json({
success: false,
error: 'Dimension not found'
}, { status: 404 });
}
eventBroadcaster.broadcast({
type: 'DIMENSION_UPDATED',
data: {
dimension: newName,
previousName: currentName,
description: description || undefined,
isPriority: isPriority !== undefined ? isPriority : undefined,
renamed: true
}
});
return NextResponse.json({
success: true,
data: {
dimension: newName,
previousName: currentName,
description: description || undefined,
isPriority: isPriority !== undefined ? isPriority : undefined,
nodeLinksUpdated: updateResult.nodeLinksUpdated
}
});
}
// Handle description and/or isPriority update (existing functionality)
const targetName = name || currentName;
if (!targetName) {
return NextResponse.json({
success: false,
error: 'Dimension name is required'
}, { status: 400 });
}
if (description && description.length > 500) {
return NextResponse.json({
success: false,
error: 'Description must be 500 characters or less'
}, { status: 400 });
}
const sqlite = getSQLiteClient();
// Build update query
if (description !== '' || isPriority !== undefined) {
const updates: string[] = ['updated_at = CURRENT_TIMESTAMP'];
const values: any[] = [];
if (description !== '') {
updates.push('description = ?');
values.push(description || null);
}
if (isPriority !== undefined) {
updates.push('is_priority = ?');
values.push(isPriority ? 1 : 0);
}
values.push(targetName);
const updateResult = sqlite.prepare(`
UPDATE dimensions
SET ${updates.join(', ')}
WHERE name = ?
`).run(...values);
if (updateResult.changes === 0) {
return NextResponse.json({
success: false,
error: 'Dimension not found'
}, { status: 404 });
}
} else {
// No updates provided
return NextResponse.json({
success: false,
error: 'At least one update field (description or isPriority) must be provided'
}, { status: 400 });
}
eventBroadcaster.broadcast({
type: 'DIMENSION_UPDATED',
data: {
dimension: targetName,
description: description !== '' ? description : undefined,
isPriority: isPriority !== undefined ? isPriority : undefined
}
});
return NextResponse.json({
success: true,
data: {
dimension: targetName,
description: description !== '' ? description : undefined,
isPriority: isPriority !== undefined ? isPriority : undefined
}
});
} catch (error) {
console.error('Error updating dimension:', error);
return NextResponse.json({
success: false,
error: 'Failed to update dimension'
}, { status: 500 });
}
}
export async function DELETE(request: NextRequest) {
try {
const dimension = (request.nextUrl.searchParams.get('name') || '').trim();
if (!dimension) {
return NextResponse.json({
success: false,
error: 'Dimension name is required'
}, { status: 400 });
}
const sqlite = getSQLiteClient();
const removal = sqlite.transaction(() => {
const nodeDimStmt = sqlite.prepare('DELETE FROM node_dimensions WHERE dimension = ?');
const dimStmt = sqlite.prepare('DELETE FROM dimensions WHERE name = ?');
const removedLinks = nodeDimStmt.run(dimension).changes ?? 0;
const removedRow = dimStmt.run(dimension).changes ?? 0;
return {
removedLinks,
removedRow
};
});
if (!removal.removedLinks && !removal.removedRow) {
return NextResponse.json({
success: false,
error: 'Dimension not found'
}, { status: 404 });
}
eventBroadcaster.broadcast({
type: 'DIMENSION_UPDATED',
data: { dimension, deleted: true }
});
return NextResponse.json({
success: true,
data: {
dimension,
deleted: true
}
});
} catch (error) {
console.error('Error deleting dimension:', error);
return NextResponse.json({
success: false,
error: 'Failed to delete dimension'
}, { status: 500 });
}
}
+51
View File
@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from 'next/server';
import { getSQLiteClient } from '@/services/database/sqlite-client';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const query = searchParams.get('q') || '';
if (!query.trim()) {
return NextResponse.json({
success: false,
error: 'Search query is required'
}, { status: 400 });
}
return searchDimensionsSQLite(query);
} catch (error) {
console.error('Error searching dimensions:', error);
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Failed to search dimensions'
}, { status: 500 });
}
}
// PostgreSQL path removed in SQLite-only consolidation
async function searchDimensionsSQLite(query: string) {
const sqlite = getSQLiteClient();
const result = sqlite.query(`
SELECT nd.dimension, COUNT(*) AS count
FROM node_dimensions nd
WHERE LOWER(nd.dimension) LIKE LOWER(?)
GROUP BY nd.dimension
ORDER BY count DESC, nd.dimension ASC
LIMIT 20
`, [`%${query}%`]);
return NextResponse.json({
success: true,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: result.rows.map((row: any) => ({
dimension: row.dimension,
count: Number(row.count)
}))
});
}
+115
View File
@@ -0,0 +1,115 @@
import { NextRequest, NextResponse } from 'next/server';
import { edgeService } from '@/services/database';
export const runtime = 'nodejs';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const edgeId = parseInt(id, 10);
if (isNaN(edgeId)) {
return NextResponse.json({
success: false,
error: 'Invalid edge ID'
}, { status: 400 });
}
const edge = await edgeService.getEdgeById(edgeId);
if (!edge) {
return NextResponse.json({
success: false,
error: 'Edge not found'
}, { status: 404 });
}
return NextResponse.json({
success: true,
data: edge
});
} catch (error) {
console.error('Error fetching edge:', error);
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch edge'
}, { status: 500 });
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const edgeId = parseInt(id, 10);
if (isNaN(edgeId)) {
return NextResponse.json({
success: false,
error: 'Invalid edge ID'
}, { status: 400 });
}
const body = await request.json();
// Validate source value if provided
if (body.source && !['user', 'ai_similarity', 'helper_name'].includes(body.source)) {
return NextResponse.json({
success: false,
error: 'Invalid source: must be user, ai_similarity, or helper_name'
}, { status: 400 });
}
const edge = await edgeService.updateEdge(edgeId, body);
return NextResponse.json({
success: true,
data: edge,
message: `Edge updated successfully`
});
} catch (error) {
console.error('Error updating edge:', error);
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Failed to update edge'
}, { status: 500 });
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const edgeId = parseInt(id, 10);
if (isNaN(edgeId)) {
return NextResponse.json({
success: false,
error: 'Invalid edge ID'
}, { status: 400 });
}
await edgeService.deleteEdge(edgeId);
return NextResponse.json({
success: true,
message: `Edge ${edgeId} deleted successfully`
});
} catch (error) {
console.error('Error deleting edge:', error);
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Failed to delete edge'
}, { status: 500 });
}
}
+96
View File
@@ -0,0 +1,96 @@
import { NextRequest, NextResponse } from 'next/server';
import { edgeService } from '@/services/database';
export const runtime = 'nodejs';
export async function GET() {
try {
const edges = await edgeService.getEdges();
return NextResponse.json({
success: true,
data: edges,
count: edges.length
});
} catch (error) {
console.error('Error fetching edges:', error);
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch edges'
}, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// Validate required fields
if (!body.from_node_id || !body.to_node_id) {
return NextResponse.json({
success: false,
error: 'Missing required fields: from_node_id and to_node_id are required'
}, { status: 400 });
}
// Validate node IDs are numbers
if (isNaN(parseInt(body.from_node_id)) || isNaN(parseInt(body.to_node_id))) {
return NextResponse.json({
success: false,
error: 'Invalid node IDs: must be valid numbers'
}, { status: 400 });
}
// Set default source if not provided
if (!body.source) {
body.source = 'user';
}
// Validate source value
if (!['user', 'ai_similarity', 'helper_name'].includes(body.source)) {
return NextResponse.json({
success: false,
error: 'Invalid source: must be user, ai_similarity, or helper_name'
}, { status: 400 });
}
const fromId = parseInt(body.from_node_id);
const toId = parseInt(body.to_node_id);
// Idempotency: prevent duplicate edges between same pair
try {
const exists = await edgeService.edgeExists(fromId, toId);
if (exists) {
return NextResponse.json({
success: true,
data: { from_node_id: fromId, to_node_id: toId },
message: `Edge already exists between nodes ${fromId} and ${toId}`
}, { status: 200 });
}
} catch (e) {
// Non-fatal: continue with creation if existence check fails
console.warn('edgeExists check failed; proceeding to create:', e);
}
const edge = await edgeService.createEdge({
from_node_id: fromId,
to_node_id: toId,
context: body.context || {},
source: body.source
});
return NextResponse.json({
success: true,
data: edge,
message: `Edge created successfully between nodes ${edge.from_node_id} and ${edge.to_node_id}`
}, { status: 201 });
} catch (error) {
console.error('Error creating edge:', error);
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Failed to create edge'
}, { status: 500 });
}
}
+53
View File
@@ -0,0 +1,53 @@
/**
* Server-Sent Events (SSE) API Route
* Streams real-time database change events to connected clients
*/
import { eventBroadcaster } from '@/services/events';
export async function GET() {
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
// Add this connection to the broadcaster
console.log('🔌 New SSE connection established');
eventBroadcaster.addConnection(controller);
console.log('📊 Total SSE connections:', eventBroadcaster.getConnectionCount());
// Send initial connection confirmation
const initialMessage = `data: ${JSON.stringify({
type: 'CONNECTION_ESTABLISHED',
data: { timestamp: Date.now() }
})}\n\n`;
controller.enqueue(encoder.encode(initialMessage));
// Store controller reference for cleanup
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(controller as any)._cleanup = () => {
console.log('🔌 SSE connection cleanup');
eventBroadcaster.removeConnection(controller);
};
},
cancel(controller) {
// Clean up when client disconnects
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((controller as any)._cleanup) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(controller as any)._cleanup();
}
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Cache-Control',
},
});
}
+20
View File
@@ -0,0 +1,20 @@
import { NextResponse } from 'next/server';
import { checkDatabaseHealth } from '@/services/database';
export const runtime = 'nodejs';
export async function GET() {
try {
const status = await checkDatabaseHealth();
return NextResponse.json({ success: true, ...status });
} catch (error) {
return NextResponse.json({
success: false,
connected: false,
vectorExtension: false,
tablesExist: false,
error: error instanceof Error ? error.message : 'Unknown error'
}, { status: 500 });
}
}
+13
View File
@@ -0,0 +1,13 @@
/**
* Simple ping endpoint for sidecar health checks
* Returns a basic OK response to verify the server is responding
*/
import { NextResponse } from 'next/server';
export async function GET() {
return NextResponse.json({
status: 'ok',
timestamp: new Date().toISOString()
});
}
+120
View File
@@ -0,0 +1,120 @@
import { NextResponse } from 'next/server';
import { getSQLiteClient } from '@/services/database/sqlite-client';
import { chunkService } from '@/services/database/chunks';
export async function GET() {
try {
const sqlite = getSQLiteClient();
// Test basic database connection
const connectionTest = await sqlite.testConnection();
if (!connectionTest) {
return NextResponse.json({
status: 'error',
message: 'Database connection failed',
details: null
});
}
// Check if vector extension is loaded
const vectorExtensionTest = await sqlite.checkVectorExtension();
let vectorStats = null;
let chunkStats = null;
let vectorHealth = 'unknown';
try {
// Get chunk counts
const totalChunks = await chunkService.getChunkCount();
const chunksWithoutEmbeddings = await chunkService.getChunksWithoutEmbeddings();
const vectorizedCount = totalChunks - chunksWithoutEmbeddings.length;
chunkStats = {
total_chunks: totalChunks,
vectorized_chunks: vectorizedCount,
missing_embeddings: chunksWithoutEmbeddings.length,
coverage_percentage: totalChunks > 0 ? Math.round((vectorizedCount / totalChunks) * 100) : 0
};
// Test vector table health by attempting a simple query
if (vectorExtensionTest) {
try {
const result = sqlite.query('SELECT COUNT(*) as count FROM vec_chunks');
const vecCount = Number(result.rows[0].count);
vectorStats = {
vec_chunks_count: vecCount,
matches_chunk_embeddings: vecCount === vectorizedCount
};
vectorHealth = vecCount === vectorizedCount ? 'healthy' : 'inconsistent';
} catch (vecError: any) {
vectorHealth = 'corrupted';
vectorStats = {
error: vecError.message,
suggestion: 'Vector table may be corrupted and need recreation'
};
}
} else {
vectorHealth = 'extension_unavailable';
}
} catch (error: any) {
return NextResponse.json({
status: 'error',
message: 'Failed to collect vector statistics',
details: error.message
});
}
return NextResponse.json({
status: 'success',
data: {
database_connected: connectionTest,
vector_extension_loaded: vectorExtensionTest,
vector_health: vectorHealth,
chunk_stats: chunkStats,
vector_stats: vectorStats,
recommendations: generateRecommendations(vectorHealth, chunkStats, vectorStats)
}
});
} catch (error: any) {
console.error('Vector health check failed:', error);
return NextResponse.json({
status: 'error',
message: 'Health check failed',
details: error.message
});
}
}
function generateRecommendations(
vectorHealth: string,
chunkStats: any,
vectorStats: any
): string[] {
const recommendations: string[] = [];
if (vectorHealth === 'corrupted') {
recommendations.push('Vector tables are corrupted - restart the application to trigger automatic healing');
}
if (vectorHealth === 'extension_unavailable') {
recommendations.push('Vector extension not loaded - check sqlite-vec installation');
}
if (chunkStats && chunkStats.coverage_percentage < 95) {
recommendations.push(`${chunkStats.missing_embeddings} chunks missing embeddings - consider running embedding generation`);
}
if (vectorStats && !vectorStats.matches_chunk_embeddings) {
recommendations.push('Vector count does not match chunk embeddings - database inconsistency detected');
}
if (recommendations.length === 0) {
recommendations.push('Vector search system is healthy');
}
return recommendations;
}
+50
View File
@@ -0,0 +1,50 @@
import { NextRequest, NextResponse } from 'next/server';
import { nodeService } from '@/services/database';
import { eventBroadcaster } from '@/services/events';
import { embedNodeContent } from '@/services/embedding/ingestion';
export const runtime = 'nodejs';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// Validate required fields
if (!body.nodeId) {
return NextResponse.json({
success: false,
error: 'Missing required field: nodeId'
}, { status: 400 });
}
// Get the node to validate it exists
const node = await nodeService.getNodeById(body.nodeId);
if (!node) {
return NextResponse.json({
success: false,
error: 'Node not found'
}, { status: 404 });
}
const results = await embedNodeContent(body.nodeId);
if (results.success) {
console.log('📡 Broadcasting NODE_UPDATED for embedding completion');
eventBroadcaster.broadcast({
type: 'NODE_UPDATED',
data: { nodeId: body.nodeId }
});
}
const statusCode = results.success ? 200 : 500;
return NextResponse.json(results, { status: statusCode });
} catch (error) {
console.error('Error in ingestion endpoint:', error);
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Failed to process embedding request'
}, { status: 500 });
}
}
+47
View File
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
try {
const { apiKey } = await request.json();
if (typeof apiKey !== 'string' || apiKey.trim().length === 0) {
return NextResponse.json(
{ ok: false, error: 'API key is required.' },
{ status: 400 }
);
}
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'x-api-key': apiKey.trim(),
'content-type': 'application/json',
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: 'claude-3-haiku-20240307',
max_tokens: 1,
messages: [{ role: 'user', content: 'test' }],
}),
});
if (!response.ok) {
const errorBody = await response.text();
return NextResponse.json(
{
ok: false,
status: response.status,
error: errorBody || 'Anthropic returned an error.',
},
{ status: 200 }
);
}
return NextResponse.json({ ok: true });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
return NextResponse.json(
{ ok: false, error: message },
{ status: 500 }
);
}
}
+35
View File
@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from 'next/server';
import { chunkService } from '@/services/database';
export const runtime = 'nodejs';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const nodeId = parseInt(id, 10);
if (isNaN(nodeId)) {
return NextResponse.json({
success: false,
error: 'Invalid node ID'
}, { status: 400 });
}
const chunks = await chunkService.getChunksByNodeId(nodeId);
return NextResponse.json({
success: true,
chunks: chunks
});
} catch (error) {
console.error('Error fetching chunks:', error);
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch chunks'
}, { status: 500 });
}
}
+37
View File
@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server';
import { edgeService } from '@/services/database';
export const runtime = 'nodejs';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const nodeId = parseInt(id, 10);
if (isNaN(nodeId)) {
return NextResponse.json({
success: false,
error: 'Invalid node ID'
}, { status: 400 });
}
const connections = await edgeService.getNodeConnections(nodeId);
console.log('[api/nodes/[id]/edges] node', nodeId, 'returned connections:', connections.length);
return NextResponse.json({
success: true,
data: connections,
count: connections.length
});
} catch (error) {
console.error('Error fetching node edges:', error);
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch node edges'
}, { status: 500 });
}
}
+150
View File
@@ -0,0 +1,150 @@
import { NextRequest, NextResponse } from 'next/server';
import { nodeService } from '@/services/database';
import { autoEmbedQueue } from '@/services/embedding/autoEmbedQueue';
import { hasSufficientContent } from '@/services/embedding/constants';
export const runtime = 'nodejs';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const nodeId = parseInt(id, 10);
if (isNaN(nodeId)) {
return NextResponse.json({
success: false,
error: 'Invalid node ID'
}, { status: 400 });
}
const node = await nodeService.getNodeById(nodeId);
if (!node) {
return NextResponse.json({
success: false,
error: 'Node not found'
}, { status: 404 });
}
return NextResponse.json({
success: true,
node: node
});
} catch (error) {
console.error('Error fetching node:', error);
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch node'
}, { status: 500 });
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const nodeId = parseInt(id, 10);
if (isNaN(nodeId)) {
return NextResponse.json({
success: false,
error: 'Invalid node ID'
}, { status: 400 });
}
const body = await request.json();
const existingNode = await nodeService.getNodeById(nodeId);
if (!existingNode) {
return NextResponse.json({
success: false,
error: 'Node not found'
}, { status: 404 });
}
if (body && Object.prototype.hasOwnProperty.call(body, 'is_pinned')) {
console.warn(`[nodes/${nodeId}] Ignoring legacy is_pinned payload`);
delete body.is_pinned;
}
const updates: Record<string, unknown> = { ...body };
let shouldQueueEmbed = false;
const incomingChunk = typeof body.chunk === 'string' ? body.chunk : undefined;
const incomingContent = typeof body.content === 'string' ? body.content : undefined;
const existingChunk = existingNode.chunk ?? '';
if (incomingChunk !== undefined) {
const trimmedIncoming = incomingChunk.trim();
const trimmedExisting = existingChunk.trim();
if (!trimmedIncoming) {
updates.chunk_status = null;
} else if (trimmedIncoming !== trimmedExisting) {
updates.chunk_status = 'not_chunked';
shouldQueueEmbed = hasSufficientContent(trimmedIncoming);
} else {
delete updates.chunk_status;
}
} else if (!existingChunk.trim() && hasSufficientContent(incomingContent)) {
updates.chunk = incomingContent;
updates.chunk_status = 'not_chunked';
shouldQueueEmbed = true;
}
const node = await nodeService.updateNode(nodeId, updates);
if (shouldQueueEmbed) {
autoEmbedQueue.enqueue(nodeId, { reason: 'node_updated' });
}
return NextResponse.json({
success: true,
node: node,
message: `Node updated successfully`
});
} catch (error) {
console.error('Error updating node:', error);
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Failed to update node'
}, { status: 500 });
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const nodeId = parseInt(id, 10);
if (isNaN(nodeId)) {
return NextResponse.json({
success: false,
error: 'Invalid node ID'
}, { status: 400 });
}
await nodeService.deleteNode(nodeId);
return NextResponse.json({
success: true,
message: `Node ${nodeId} deleted successfully`
});
} catch (error) {
console.error('Error deleting node:', error);
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Failed to delete node'
}, { status: 500 });
}
}
+117
View File
@@ -0,0 +1,117 @@
import { NextRequest, NextResponse } from 'next/server';
import { nodeService } from '@/services/database';
import { Node, NodeFilters } from '@/types/database';
import { autoEmbedQueue } from '@/services/embedding/autoEmbedQueue';
import { hasSufficientContent } from '@/services/embedding/constants';
import { DimensionService } from '@/services/database/dimensionService';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const filters: NodeFilters = {
search: searchParams.get('search') || undefined,
limit: searchParams.get('limit') ? parseInt(searchParams.get('limit')!) : 100,
offset: searchParams.get('offset') ? parseInt(searchParams.get('offset')!) : 0
};
// Handle dimensions parameter (comma-separated)
const dimensionsParam = searchParams.get('dimensions');
if (dimensionsParam) {
filters.dimensions = dimensionsParam.split(',').map(dim => dim.trim()).filter(Boolean);
}
// Handle sortBy parameter
const sortByParam = searchParams.get('sortBy');
if (sortByParam === 'edges' || sortByParam === 'updated') {
filters.sortBy = sortByParam;
}
const nodes = await nodeService.getNodes(filters);
return NextResponse.json({
success: true,
data: nodes,
count: nodes.length
});
} catch (error) {
console.error('Error fetching nodes:', error);
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch nodes'
}, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// Validate required fields
if (!body.title) {
return NextResponse.json({
success: false,
error: 'Missing required field: title is required'
}, { status: 400 });
}
const rawContent = typeof body.content === 'string' ? body.content : null;
const providedDimensions = Array.isArray(body.dimensions) ? body.dimensions : [];
const trimmedProvidedDimensions = providedDimensions
.map((dim: unknown) => typeof dim === 'string' ? dim.trim() : '')
.filter(Boolean)
.slice(0, 5);
// Auto-assign locked dimensions for all new nodes
const autoAssignedDimensions = await DimensionService.assignLockedDimensions({
title: body.title,
content: rawContent || undefined,
link: body.link
});
// Combine provided and auto-assigned dimensions, remove duplicates
const finalDimensions = [...new Set([...trimmedProvidedDimensions, ...autoAssignedDimensions])]
.slice(0, 5); // Ensure we don't exceed 5 total dimensions
const rawChunk = typeof body.chunk === 'string' ? body.chunk : null;
let chunkToStore = rawChunk;
let chunkStatus: Node['chunk_status'];
if (chunkToStore && chunkToStore.trim().length > 0) {
chunkStatus = 'not_chunked';
} else if (!chunkToStore && hasSufficientContent(rawContent)) {
chunkToStore = rawContent;
chunkStatus = 'not_chunked';
}
const node = await nodeService.createNode({
title: body.title,
content: rawContent ?? undefined,
link: body.link,
dimensions: finalDimensions,
chunk: chunkToStore ?? undefined,
chunk_status: chunkStatus,
metadata: body.metadata || {}
});
if (chunkStatus === 'not_chunked' && node.id) {
autoEmbedQueue.enqueue(node.id, { reason: 'node_created' });
}
return NextResponse.json({
success: true,
data: node,
message: `Node created successfully with dimensions: ${finalDimensions.join(', ')}`
}, { status: 201 });
} catch (error) {
console.error('Error creating node:', error);
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Failed to create node'
}, { status: 500 });
}
}
+49
View File
@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server';
import { nodeService } from '@/services/database';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const query = searchParams.get('q');
const limit = parseInt(searchParams.get('limit') || '10');
if (!query || query.trim() === '') {
return NextResponse.json({
success: false,
error: 'Missing required parameter: q (search query)'
}, { status: 400 });
}
if (query.length < 2) {
return NextResponse.json({
success: false,
error: 'Search query must be at least 2 characters long'
}, { status: 400 });
}
const nodes = await nodeService.searchNodes(query.trim(), Math.min(limit, 50));
// Return minimal data for edge creation UI
const results = nodes.map(node => ({
id: node.id,
title: node.title,
dimensions: node.dimensions
}));
return NextResponse.json({
success: true,
data: results,
count: results.length,
query: query.trim()
});
} catch (error) {
console.error('Error searching nodes:', error);
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Failed to search nodes'
}, { status: 500 });
}
}
+33
View File
@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server';
import { enqueueQuickAdd, QuickAddMode } from '@/services/agents/quickAdd';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { input, mode } = body as { input?: unknown; mode?: unknown };
if (typeof input !== 'string' || input.trim().length === 0) {
return NextResponse.json(
{ success: false, error: 'Input is required' },
{ status: 400 }
);
}
const normalizedMode: QuickAddMode | undefined =
mode === 'link' || mode === 'note' || mode === 'chat' ? mode : undefined;
const delegation = await enqueueQuickAdd({
rawInput: input.trim(),
mode: normalizedMode,
});
return NextResponse.json({ success: true, delegation });
} catch (error) {
console.error('[Quick Add API] Error:', error);
const message = error instanceof Error ? error.message : 'Unknown error';
return NextResponse.json(
{ success: false, error: message },
{ status: 500 }
);
}
}
+465
View File
@@ -0,0 +1,465 @@
import { NextRequest } from 'next/server';
import { streamText, convertToCoreMessages } from 'ai';
import { createAnthropic } from '@ai-sdk/anthropic';
import { createOpenAI } from '@ai-sdk/openai';
import { getHelperTools, getDefaultToolNamesForRole } from '@/tools/infrastructure/registry';
import { buildSystemPromptBlocks } from '@/services/helpers/contextBuilder';
import { helperLogger } from '@/services/helpers/logger';
import { withChatLogging } from '@/services/chat/middleware';
import { AgentRegistry } from '@/services/agents/registry';
import { calculateCost } from '@/services/analytics/pricing';
import { UsageData } from '@/types/analytics';
import type { CacheStats } from '@/types/prompts';
import { randomUUID } from 'crypto';
import { RequestContext } from '@/services/context/requestContext';
import { isLocalMode } from '@/config/runtime';
export const maxDuration = 900; // 15 minutes (for workflows)
if (isLocalMode()) {
// TODO: add any special local-mode setup if needed later
}
const ANTHROPIC_MODEL_MAP: Record<string, string> = {
'claude-sonnet-4.5': 'claude-sonnet-4-5-20250929',
'claude-3-5-sonnet': 'claude-3-5-sonnet-20241022',
};
type ApiKeyOverrides = {
openai?: string;
anthropic?: string;
};
function resolveModel(modelId: string, apiKeys?: ApiKeyOverrides) {
if (modelId.startsWith('anthropic/')) {
const rawName = modelId.split('/')[1];
const mapped = ANTHROPIC_MODEL_MAP[rawName] || rawName;
const orchestratorKey =
apiKeys?.anthropic ||
process.env.RAH_ORCHESTRATOR_ANTHROPIC_API_KEY ||
process.env.ANTHROPIC_API_KEY;
if (!orchestratorKey) {
throw new Error('RAH_ORCHESTRATOR_ANTHROPIC_API_KEY (or ANTHROPIC_API_KEY) is not set.');
}
const provider = createAnthropic({
apiKey: orchestratorKey,
headers: {
'anthropic-beta': 'prompt-caching-2024-07-31',
},
});
return provider(mapped);
}
if (modelId.startsWith('openai/')) {
const name = modelId.split('/')[1];
const delegateKey =
apiKeys?.openai ||
process.env.RAH_DELEGATE_OPENAI_API_KEY ||
process.env.OPENAI_API_KEY;
if (!delegateKey) {
throw new Error('RAH_DELEGATE_OPENAI_API_KEY (or OPENAI_API_KEY) is not set.');
}
const provider = createOpenAI({ apiKey: delegateKey });
return provider(name);
}
throw new Error(`Unsupported model id: ${modelId}`);
}
// Global cache stats storage for monitoring
declare global {
// eslint-disable-next-line no-var
var lastCacheStats: CacheStats | undefined;
}
type AnthropicUsageLike = {
inputTokens?: number;
outputTokens?: number;
promptTokens?: number;
completionTokens?: number;
cacheCreationInputTokens?: number;
cachedInputTokens?: number;
totalTokens?: number;
cache_write_input_tokens?: number;
cache_read_input_tokens?: number;
cacheWriteInputTokens?: number;
cacheReadInputTokens?: number;
};
function normaliseUsage(entry: AnthropicUsageLike | undefined | null) {
if (!entry) {
return { input: 0, output: 0, cacheWrite: 0, cacheRead: 0 };
}
const toNumber = (value: unknown): number => {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && value.trim().length > 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
return 0;
};
const input = toNumber(entry.inputTokens ?? entry.promptTokens ?? (entry as any).input_tokens ?? 0);
const output = toNumber(entry.outputTokens ?? entry.completionTokens ?? (entry as any).output_tokens ?? 0);
const cacheWrite = toNumber(
entry.cacheCreationInputTokens ??
(entry as any).cache_creation_input_tokens ??
entry.cacheWriteInputTokens ??
entry.cache_write_input_tokens ??
0
);
const cacheRead = toNumber(
entry.cachedInputTokens ??
entry.cacheReadInputTokens ??
(entry as any).cache_read_input_tokens ??
0
);
return { input, output, cacheWrite, cacheRead };
}
function aggregateAnthropicUsage(usage: AnthropicUsageLike | undefined, providerMetadata: any) {
const totals = { inputTokens: 0, outputTokens: 0, cacheWriteTokens: 0, cacheReadTokens: 0 };
let dataSources = 0;
const add = (entry: AnthropicUsageLike | undefined | null) => {
if (!entry) return;
const { input, output, cacheWrite, cacheRead } = normaliseUsage(entry);
if (input || output || cacheWrite || cacheRead) {
totals.inputTokens += input;
totals.outputTokens += output;
totals.cacheWriteTokens += cacheWrite;
totals.cacheReadTokens += cacheRead;
dataSources += 1;
}
};
const anthropicMeta = providerMetadata?.anthropic;
if (anthropicMeta) {
if (Array.isArray(anthropicMeta.requests)) {
anthropicMeta.requests.forEach((req: any) => {
add(req?.usage ?? req);
});
}
if (anthropicMeta.response?.usage) {
add(anthropicMeta.response.usage);
}
if (anthropicMeta.usage && dataSources === 0) {
add(anthropicMeta.usage);
}
}
if (dataSources === 0) {
add(usage);
}
// Fallback to usage totals if metadata did not provide cache info
if (totals.cacheReadTokens === 0 && usage?.cachedInputTokens) {
totals.cacheReadTokens = normaliseUsage(usage).cacheRead;
}
if (totals.cacheWriteTokens === 0 && usage?.cacheCreationInputTokens) {
totals.cacheWriteTokens = normaliseUsage(usage).cacheWrite;
}
return totals;
}
export async function POST(request: NextRequest) {
let helperKey = 'ra-h';
try {
const {
messages = [],
openTabs = [],
activeTabId = null,
currentView = 'nodes',
sessionId,
traceId,
mode: requestedMode = 'easy',
apiKeys: rawApiKeys
} = await request.json();
const apiKeys: ApiKeyOverrides | undefined = rawApiKeys
? {
openai: typeof rawApiKeys.openai === 'string' && rawApiKeys.openai.trim().length > 0
? rawApiKeys.openai.trim()
: undefined,
anthropic: typeof rawApiKeys.anthropic === 'string' && rawApiKeys.anthropic.trim().length > 0
? rawApiKeys.anthropic.trim()
: undefined,
}
: undefined;
const mode: 'easy' | 'hard' = requestedMode === 'hard' ? 'hard' : 'easy';
helperKey = mode === 'hard' ? 'ra-h' : 'ra-h-easy';
const conversationTraceId = traceId || randomUUID();
RequestContext.set({
traceId: conversationTraceId,
openTabs,
activeTabId,
mode,
apiKeys,
});
// Filter messages to only valid roles
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sanitizedMessages = messages.filter((msg: any) =>
msg && ['user', 'assistant', 'system'].includes(msg.role)
);
const helperConfig = await AgentRegistry.orchestratorForMode(mode);
if (!helperConfig) {
throw new Error(`No orchestrator definition found for mode '${mode}'`);
}
helperKey = helperConfig.key || helperKey;
helperLogger.logUserMessage(helperKey, messages, openTabs, activeTabId);
const { blocks: systemBlocks, cacheHit } = await buildSystemPromptBlocks(
{ nodes: openTabs, activeNodeId: activeTabId },
helperKey
);
const systemPromptPreview = systemBlocks.map(b => b.text).join('\n').substring(0, 200) + '...';
helperLogger.logSystemPrompt(helperKey, systemPromptPreview, cacheHit);
console.log('🔧 [Prompt Caching] System blocks structure:', {
helperKey,
totalBlocks: systemBlocks.length,
cachedBlocks: systemBlocks.filter(b => b.cache_control).length,
blockLengths: systemBlocks.map((b, i) => ({
index: i,
length: b.text.length,
cached: !!b.cache_control
}))
});
const toolNames = helperConfig.availableTools?.length
? helperConfig.availableTools
: getDefaultToolNamesForRole(helperConfig.role);
const tools = getHelperTools(toolNames);
const modelId = helperConfig.model || 'anthropic/claude-sonnet-4.5';
const model = resolveModel(modelId, apiKeys);
const rawModelId = modelId.split('/')[1] || modelId;
const fullModelId = ANTHROPIC_MODEL_MAP[rawModelId] || rawModelId;
const isAnthropicModel = modelId.startsWith('anthropic/');
const isOpenAIModel = modelId.startsWith('openai/');
const toolsUsedInSession: string[] = [];
// Convert system blocks to messages with providerOptions for caching
const systemMessages = systemBlocks.map((block) => ({
role: 'system' as const,
content: block.text,
...(isAnthropicModel && block.cache_control ? {
providerOptions: {
anthropic: { cacheControl: block.cache_control }
}
} : {})
}));
const coreMessages = convertToCoreMessages(sanitizedMessages);
const allMessages = [...systemMessages, ...coreMessages];
// Debug logging (can be removed in production)
if (process.env.DEBUG_CACHE === 'true') {
console.log('🔍 [Debug] System messages with cache control:');
systemMessages.forEach((msg, i) => {
console.log(` Block ${i}:`, {
hasContent: !!msg.content,
contentLength: msg.content?.length || 0,
hasProviderOptions: !!msg.providerOptions,
cacheControl: msg.providerOptions?.anthropic?.cacheControl
});
});
}
const streamConfig = {
model,
messages: allMessages,
tools,
stopWhen: [],
maxSteps: 10,
...(isOpenAIModel ? { reasoning: { effort: 'light' as const } } : {}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onToolCall: ({ toolCall }: any) => {
helperLogger.logToolCall(helperKey, toolCall.toolName, toolCall.args);
if (!toolsUsedInSession.includes(toolCall.toolName)) {
toolsUsedInSession.push(toolCall.toolName);
}
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onToolResult: ({ toolCall, result }: any) => {
helperLogger.logToolResult(helperKey, toolCall.toolName, result);
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onFinish: (result: any) => {
helperLogger.logAssistantResponse(helperKey, result.text);
// Debug logging (can be removed in production)
if (process.env.DEBUG_CACHE === 'true') {
console.log('🔍 [Debug] Full onFinish result keys:', Object.keys(result));
console.log('🔍 [Debug] Raw usage object:', JSON.stringify(result.usage, null, 2));
console.log('🔍 [Debug] Provider metadata:', JSON.stringify(result.providerMetadata, null, 2));
console.log('🔍 [Debug] Steps array length:', result.steps?.length || 0);
if (result.steps && result.steps.length > 0) {
result.steps.forEach((step: any, i: number) => {
console.log(`🔍 [Debug] Step ${i} usage:`, JSON.stringify(step.usage, null, 2));
console.log(`🔍 [Debug] Step ${i} providerMetadata:`, JSON.stringify(step.providerMetadata, null, 2));
});
}
}
const aggregatedUsage = isAnthropicModel
? (() => {
// Aggregate across ALL steps, not just final result
const totals = { inputTokens: 0, outputTokens: 0, cacheWriteTokens: 0, cacheReadTokens: 0 };
if (result.steps && Array.isArray(result.steps)) {
result.steps.forEach((step: any) => {
const stepUsage = aggregateAnthropicUsage(step.usage, step.providerMetadata);
totals.inputTokens += stepUsage.inputTokens;
totals.outputTokens += stepUsage.outputTokens;
// Cache write only happens once (in first step), so take MAX not SUM
totals.cacheWriteTokens = Math.max(totals.cacheWriteTokens, stepUsage.cacheWriteTokens);
totals.cacheReadTokens += stepUsage.cacheReadTokens;
});
} else {
// Fallback to result-level usage if no steps
const resultUsage = aggregateAnthropicUsage(result.usage, result.providerMetadata);
totals.inputTokens = resultUsage.inputTokens;
totals.outputTokens = resultUsage.outputTokens;
totals.cacheWriteTokens = resultUsage.cacheWriteTokens;
totals.cacheReadTokens = resultUsage.cacheReadTokens;
}
return totals;
})()
: {
inputTokens: Number(result.usage?.inputTokens ?? result.usage?.promptTokens ?? 0),
outputTokens: Number(result.usage?.outputTokens ?? result.usage?.completionTokens ?? 0),
cacheWriteTokens: 0,
cacheReadTokens: 0,
};
const regular = aggregatedUsage.inputTokens || 0;
const cacheWrite = aggregatedUsage.cacheWriteTokens || 0;
const cacheRead = aggregatedUsage.cacheReadTokens || 0;
const outputTokens = aggregatedUsage.outputTokens || 0;
const total = regular + cacheWrite + cacheRead;
if (regular || cacheWrite || cacheRead || outputTokens) {
const savingsPercentage = total > 0 && cacheRead > 0 ? Math.round((cacheRead / total) * 100) : 0;
if (isAnthropicModel) {
const cacheStats: CacheStats = {
cacheCreationInputTokens: cacheWrite,
cacheReadInputTokens: cacheRead,
inputTokens: regular,
outputTokens,
savingsPercentage
};
console.log('\n📦 ═══════════════════════════════════════');
console.log('📦 ANTHROPIC PROMPT CACHE STATISTICS');
console.log('📦 ═══════════════════════════════════════');
console.log(`📦 Cache Write: ${cacheWrite.toLocaleString()} tokens ${cacheWrite > 0 ? '(NEW CACHE CREATED ✨)' : ''}`);
console.log(`📦 Cache Read: ${cacheRead.toLocaleString()} tokens ${cacheRead > 0 ? '(CACHE HIT 🎯)' : '(CACHE MISS ❌)'}`);
console.log(`📦 Regular: ${regular.toLocaleString()} tokens`);
console.log(`📦 Total Input: ${total.toLocaleString()} tokens`);
console.log(`📦 Output: ${cacheStats.outputTokens.toLocaleString()} tokens`);
if (cacheRead > 0) {
const costSavings = Math.round(((cacheWrite * 1.25 + regular * 1.0 + cacheRead * 0.1) / (total * 1.0)) * 100);
console.log(`📦 💰 Savings: ${savingsPercentage}% tokens, ~${100 - costSavings}% cost`);
}
console.log('📦 ═══════════════════════════════════════\n');
global.lastCacheStats = cacheStats;
}
const costResult = calculateCost({
inputTokens: regular,
outputTokens,
cacheWriteTokens: cacheWrite,
cacheReadTokens: cacheRead,
modelId: fullModelId,
});
usageData = {
inputTokens: regular,
outputTokens,
totalTokens: costResult.totalTokens,
cacheWriteTokens: cacheWrite,
cacheReadTokens: cacheRead,
cacheHit: cacheRead > 0,
cacheSavingsPct: savingsPercentage,
estimatedCostUsd: costResult.totalCostUsd,
modelUsed: fullModelId,
provider: isAnthropicModel ? 'anthropic' : 'openai',
toolsUsed: toolsUsedInSession.length > 0 ? toolsUsedInSession : undefined,
toolCallsCount: toolsUsedInSession.length > 0 ? toolsUsedInSession.length : undefined,
traceId: conversationTraceId,
workflowKey: currentContext.workflowKey,
workflowNodeId: currentContext.workflowNodeId,
mode,
};
}
}
};
let usageData: UsageData | undefined;
const systemMessageText = systemBlocks.map(b => b.text).join('\n\n');
const currentContext = RequestContext.get();
const chatMetadata = {
helperName: helperKey,
openTabs,
activeTabId,
currentView,
sessionId: sessionId || `session_${Date.now()}`,
agentType: 'orchestrator' as const,
traceId: conversationTraceId,
mode,
modelUsed: fullModelId,
systemMessage: systemMessageText,
workflowKey: currentContext.workflowKey,
workflowNodeId: currentContext.workflowNodeId,
get usageData() {
return usageData;
},
};
const result = await streamText(withChatLogging(streamConfig, chatMetadata, messages));
return result.toUIMessageStreamResponse();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const errorStack = error instanceof Error ? error.stack : undefined;
console.error(`❌ [${helperKey}] Route error:`, {
error: errorMessage,
stack: errorStack,
timestamp: new Date().toISOString()
});
helperLogger.logError(helperKey, error instanceof Error ? error : String(error));
return new Response(
JSON.stringify({
error: errorMessage,
details: 'Check server logs for full error details'
}),
{
status: 500,
headers: { 'Content-Type': 'application/json' }
}
);
}
}
@@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from 'next/server';
import { AgentDelegationService } from '@/services/agents/delegation';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ sessionId: string }> }
) {
try {
const { sessionId } = await params;
const delegation = AgentDelegationService.getBySessionId(sessionId);
if (!delegation) {
return NextResponse.json({ error: 'Delegation not found' }, { status: 404 });
}
return NextResponse.json({ delegation });
} catch (error) {
console.error('Failed to fetch delegation:', error);
return NextResponse.json({ error: 'Failed to fetch delegation' }, { status: 500 });
}
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ sessionId: string }> }
) {
try {
const { sessionId } = await params;
const body = await request.json();
const summary: string | undefined = body?.summary;
const status: string | undefined = body?.status;
if (!summary && !status) {
return NextResponse.json({ error: 'Nothing to update' }, { status: 400 });
}
const normalizedStatus = status && ['queued', 'in_progress', 'completed', 'failed'].includes(status)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
? (status as any)
: undefined;
const delegation = summary
? AgentDelegationService.completeDelegation(sessionId, summary, normalizedStatus ?? 'completed')
: AgentDelegationService.markInProgress(sessionId);
if (!delegation) {
return NextResponse.json({ error: 'Delegation not found' }, { status: 404 });
}
return NextResponse.json({ delegation });
} catch (error) {
console.error('Failed to update delegation:', error);
return NextResponse.json({ error: 'Failed to update delegation' }, { status: 500 });
}
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ sessionId: string }> }
) {
try {
const { sessionId } = await params;
const deleted = AgentDelegationService.deleteDelegation(sessionId);
if (!deleted) {
return NextResponse.json({ error: 'Delegation not found' }, { status: 404 });
}
return NextResponse.json({ success: true });
} catch (error) {
console.error('Failed to delete delegation:', error);
return NextResponse.json({ error: 'Failed to delete delegation' }, { status: 500 });
}
}
@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server';
import { AgentDelegationService } from '@/services/agents/delegation';
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ sessionId: string }> }
) {
try {
const { sessionId } = await params;
const body = await request.json();
const summary: string | undefined = body?.summary;
const status: string | undefined = body?.status;
if (!summary) {
return NextResponse.json({ error: 'Summary is required' }, { status: 400 });
}
const normalizedStatus = status && ['queued', 'in_progress', 'completed', 'failed'].includes(status)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
? (status as any)
: 'completed';
const delegation = AgentDelegationService.completeDelegation(sessionId, summary, normalizedStatus);
if (!delegation) {
return NextResponse.json({ error: 'Delegation not found' }, { status: 404 });
}
return NextResponse.json({ delegation });
} catch (error) {
console.error('Failed to store delegation summary:', error);
return NextResponse.json({ error: 'Failed to store delegation summary' }, { status: 500 });
}
}
+20
View File
@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from 'next/server';
import { AgentDelegationService } from '@/services/agents/delegation';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const statusFilter = searchParams.get('status');
const includeCompleted = searchParams.get('includeCompleted') === 'true'
|| statusFilter !== 'active';
const delegations = statusFilter === 'active'
? AgentDelegationService.listActive({ includeCompleted })
: AgentDelegationService.listRecent();
return NextResponse.json({ delegations });
} catch (error) {
console.error('Failed to list delegations:', error);
return NextResponse.json({ error: 'Failed to load delegations' }, { status: 500 });
}
}
+179
View File
@@ -0,0 +1,179 @@
import { NextRequest } from 'next/server';
export const runtime = 'nodejs';
export const maxDuration = 900;
class DelegationStreamBroadcaster {
private connections = new Map<string, Set<ReadableStreamDefaultController>>();
private pendingMessages = new Map<string, any[]>();
private encoder = new TextEncoder();
private encode(message: any) {
return `data: ${JSON.stringify({ ...message, timestamp: Date.now() })}\n\n`;
}
private send(controller: ReadableStreamDefaultController, encoded: string) {
try {
controller.enqueue(this.encoder.encode(encoded));
return true;
} catch (error) {
console.log('[DelegationStream] Removing dead connection', error);
return false;
}
}
addConnection(sessionId: string, controller: ReadableStreamDefaultController) {
if (!this.connections.has(sessionId)) {
this.connections.set(sessionId, new Set());
}
this.connections.get(sessionId)!.add(controller);
console.log(`[DelegationStream] Connection added for ${sessionId}, total: ${this.connections.get(sessionId)!.size}`);
const backlog = this.pendingMessages.get(sessionId);
if (backlog && backlog.length > 0) {
console.log(`[DelegationStream] Flushing ${backlog.length} queued events for ${sessionId}`);
for (const message of backlog) {
const encoded = this.encode(message);
const delivered = this.send(controller, encoded);
if (!delivered) {
this.removeConnection(sessionId, controller);
break;
}
}
if ((this.connections.get(sessionId)?.size || 0) > 0) {
this.pendingMessages.delete(sessionId);
}
}
}
removeConnection(sessionId: string, controller: ReadableStreamDefaultController) {
const sessionConns = this.connections.get(sessionId);
if (sessionConns) {
sessionConns.delete(controller);
console.log(`[DelegationStream] Connection removed from ${sessionId}, remaining: ${sessionConns.size}`);
if (sessionConns.size === 0) {
this.connections.delete(sessionId);
}
}
}
broadcast(sessionId: string, message: any) {
const sessionConns = this.connections.get(sessionId);
if (!sessionConns || sessionConns.size === 0) {
const queue = this.pendingMessages.get(sessionId) ?? [];
queue.push(message);
// Prevent unbounded growth by keeping the latest 200 events
if (queue.length > 200) {
queue.splice(0, queue.length - 200);
}
this.pendingMessages.set(sessionId, queue);
console.log(`[DelegationStream] Queued event for ${sessionId}, pending=${queue.length}`);
return;
}
const encoded = this.encode(message);
let successCount = 0;
const staleControllers: ReadableStreamDefaultController[] = [];
for (const controller of sessionConns) {
if (this.send(controller, encoded)) {
successCount++;
} else {
staleControllers.push(controller);
}
}
if (staleControllers.length > 0) {
staleControllers.forEach((controller) => sessionConns.delete(controller));
}
console.log(`[DelegationStream] Broadcasted to ${successCount}/${sessionConns.size} connections for ${sessionId}`);
}
sendKeepAlive(sessionId: string) {
const sessionConns = this.connections.get(sessionId);
if (!sessionConns) return;
const ping = this.encoder.encode(`: keep-alive\n\n`);
for (const controller of sessionConns) {
try {
controller.enqueue(ping);
} catch {
sessionConns.delete(controller);
}
}
}
}
declare global {
// eslint-disable-next-line no-var
var delegationStreamBroadcaster: DelegationStreamBroadcaster | undefined;
}
export const delegationStreamBroadcaster =
globalThis.delegationStreamBroadcaster ?? new DelegationStreamBroadcaster();
if (typeof window === 'undefined') {
globalThis.delegationStreamBroadcaster = delegationStreamBroadcaster;
}
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const sessionId = searchParams.get('sessionId');
if (!sessionId) {
return new Response('Missing sessionId', { status: 400 });
}
const stream = new ReadableStream({
start(controller) {
const state = this as unknown as { cleanup?: () => void; abortHandler?: () => void };
delegationStreamBroadcaster.addConnection(sessionId, controller);
const encoder = new TextEncoder();
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'CONNECTION_ESTABLISHED', timestamp: Date.now() })}\n\n`));
const keepAliveInterval = setInterval(() => {
delegationStreamBroadcaster.sendKeepAlive(sessionId);
}, 30000);
const cleanup = () => {
clearInterval(keepAliveInterval);
delegationStreamBroadcaster.removeConnection(sessionId, controller);
state.cleanup = undefined;
};
const abortHandler = () => {
cleanup();
request.signal.removeEventListener('abort', abortHandler);
state.abortHandler = undefined;
};
request.signal.addEventListener('abort', abortHandler);
state.cleanup = cleanup;
state.abortHandler = abortHandler;
},
cancel() {
console.log(`[DelegationStream] Stream cancelled for ${sessionId}`);
const state = this as unknown as { cleanup?: () => void; abortHandler?: () => void };
if (state.abortHandler) {
request.signal.removeEventListener('abort', state.abortHandler);
state.abortHandler = undefined;
}
if (state.cleanup) {
state.cleanup();
}
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no',
},
});
}
+56
View File
@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from 'next/server';
const REALTIME_MODEL = process.env.OPENAI_REALTIME_MODEL || 'gpt-4o-realtime-preview-2024-12-17';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export async function POST(request: NextRequest) {
try {
const authHeader = request.headers.get('authorization');
if (!authHeader) {
return NextResponse.json({ error: 'Missing authorization header' }, { status: 401 });
}
const apiKey =
process.env.RAH_REALTIME_OPENAI_API_KEY ||
process.env.RAH_DELEGATE_OPENAI_API_KEY ||
process.env.OPENAI_API_KEY;
if (!apiKey) {
return NextResponse.json({ error: 'Realtime OpenAI API key is not configured' }, { status: 500 });
}
const response = await fetch('https://api.openai.com/v1/realtime/sessions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: REALTIME_MODEL,
modalities: ['text'],
instructions: 'Provide high-accuracy streaming transcription only. Never speak responses.',
}),
});
if (!response.ok) {
const errorPayload = await response.json().catch(() => null);
const message = errorPayload?.error?.message || response.statusText || 'Failed to create realtime session';
return NextResponse.json({ error: message }, { status: response.status });
}
const data = await response.json();
return NextResponse.json({
client_secret: data.client_secret,
expires_at: data.expires_at,
model: data.model ?? REALTIME_MODEL,
voice: data.voice,
id: data.id,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error('[realtime] Failed to mint ephemeral token:', message);
return NextResponse.json({ error: message }, { status: 500 });
}
}
+41
View File
@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server';
import {
getAutoContextSettings,
setAutoContextEnabled,
} from '@/services/settings/autoContextSettings';
export const runtime = 'nodejs';
export async function GET() {
try {
const settings = getAutoContextSettings();
return NextResponse.json({ success: true, data: settings });
} catch (error) {
console.error('Failed to read auto-context settings:', error);
return NextResponse.json(
{ success: false, error: 'Unable to read auto-context settings' },
{ status: 500 }
);
}
}
export async function PUT(request: NextRequest) {
try {
const body = await request.json();
if (!body || typeof body.autoContextEnabled !== 'boolean') {
return NextResponse.json(
{ success: false, error: 'autoContextEnabled boolean is required' },
{ status: 400 }
);
}
const updated = setAutoContextEnabled(body.autoContextEnabled);
return NextResponse.json({ success: true, data: updated });
} catch (error) {
console.error('Failed to update auto-context settings:', error);
return NextResponse.json(
{ success: false, error: 'Unable to update auto-context settings' },
{ status: 500 }
);
}
}
+46
View File
@@ -0,0 +1,46 @@
import { NextResponse } from 'next/server';
import fs from 'fs';
import os from 'os';
import path from 'path';
export const runtime = 'nodejs';
const STATUS_PATH = path.join(
os.homedir(),
'Library',
'Application Support',
'RA-H',
'config',
'mcp-status.json'
);
export async function GET() {
try {
if (!fs.existsSync(STATUS_PATH)) {
return NextResponse.json({
enabled: false,
port: null,
url: null,
last_updated: null,
target_base_url: null
});
}
const raw = fs.readFileSync(STATUS_PATH, 'utf-8');
const parsed = JSON.parse(raw);
return NextResponse.json(parsed);
} catch (error) {
console.error('Failed to read MCP status file:', error);
return NextResponse.json(
{
enabled: false,
port: null,
url: null,
last_updated: null,
target_base_url: null,
error: 'Unable to read MCP status'
},
{ status: 500 }
);
}
}
+28
View File
@@ -0,0 +1,28 @@
import { NextRequest } from 'next/server';
import { resultCache } from '@/services/tools/resultCache';
export async function GET(
request: NextRequest,
ctx: { params: Promise<{ id: string }> }
) {
try {
const { id } = await ctx.params; // Next.js 15: params is a Promise
const data = resultCache.get(id);
if (!data) {
return new Response(JSON.stringify({ success: false, error: 'Not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' }
});
}
return new Response(JSON.stringify({ success: true, data }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) {
return new Response(JSON.stringify({ success: false, error: e?.message || 'Failed' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}
+38
View File
@@ -0,0 +1,38 @@
import { NextResponse } from 'next/server';
import { TOOL_GROUP_ASSIGNMENTS, TOOL_GROUPS } from '@/tools/infrastructure/groups';
import { getHelperTools } from '@/tools/infrastructure/registry';
export async function GET() {
try {
const grouped: Record<string, { name: string; description: string }[]> = {
core: [],
orchestration: [],
execution: [],
};
Object.entries(TOOL_GROUP_ASSIGNMENTS).forEach(([toolName, groupId]) => {
const tools = getHelperTools([toolName]);
const tool = tools[toolName];
if (tool) {
grouped[groupId]?.push({
name: toolName,
description: tool.description || 'No description available',
});
}
});
return NextResponse.json({
success: true,
data: {
groups: TOOL_GROUPS,
tools: grouped,
},
});
} catch (error) {
console.error('Error fetching tools:', error);
return NextResponse.json(
{ success: false, error: 'Failed to fetch tools' },
{ status: 500 }
);
}
}
+101
View File
@@ -0,0 +1,101 @@
import { NextRequest, NextResponse } from 'next/server';
import { randomUUID } from 'crypto';
import { recordVoiceUsage } from '@/services/voice/usageLogger';
const OPENAI_TTS_MODEL = process.env.RAH_TTS_MODEL || 'gpt-4o-mini-tts';
const DEFAULT_TTS_VOICE = process.env.RAH_TTS_VOICE || 'ash';
const DEFAULT_TTS_COST_PER_1K_CHAR_USD = 0.015;
const TTS_COST_PER_1K_CHAR_USD = (() => {
const raw = process.env.RAH_TTS_COST_PER_1K_CHAR_USD;
if (!raw) return DEFAULT_TTS_COST_PER_1K_CHAR_USD;
const parsed = Number(raw);
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_TTS_COST_PER_1K_CHAR_USD;
})();
function estimateTtsCost(charCount: number) {
const cost = (charCount / 1000) * TTS_COST_PER_1K_CHAR_USD;
return Number.isFinite(cost) ? parseFloat(cost.toFixed(6)) : 0;
}
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export async function POST(request: NextRequest) {
try {
const apiKey = process.env.RAH_VOICE_OPENAI_API_KEY || process.env.RAH_DELEGATE_OPENAI_API_KEY || process.env.OPENAI_API_KEY;
if (!apiKey) {
return NextResponse.json({ error: 'OpenAI API key is not configured' }, { status: 500 });
}
const body = await request.json().catch(() => null);
const text = typeof body?.text === 'string' ? body.text.trim() : '';
const voice = typeof body?.voice === 'string' && body.voice.trim().length > 0 ? body.voice.trim() : DEFAULT_TTS_VOICE;
const helperName = typeof body?.helper === 'string' && body.helper.trim().length > 0 ? body.helper.trim() : null;
const sessionId = typeof body?.sessionId === 'string' && body.sessionId.trim().length > 0 ? body.sessionId.trim() : null;
const messageId = typeof body?.messageId === 'string' && body.messageId.trim().length > 0 ? body.messageId.trim() : null;
const providedRequestId = typeof body?.requestId === 'string' && body.requestId.trim().length > 0 ? body.requestId.trim() : null;
const voiceRequestId = providedRequestId || randomUUID();
if (!text) {
return NextResponse.json({ error: 'Text is required for TTS' }, { status: 400 });
}
const requestStartedAt = Date.now();
const response = await fetch('https://api.openai.com/v1/audio/speech', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: OPENAI_TTS_MODEL,
voice,
input: text,
format: 'mp3',
}),
});
if (!response.ok || !response.body) {
const errorPayload = await response.json().catch(() => null);
const message = errorPayload?.error?.message || response.statusText || 'Failed to synthesize audio';
return NextResponse.json({ error: message }, { status: response.status || 500 });
}
const durationMs = Date.now() - requestStartedAt;
const charCount = [...text].length;
const estimatedCostUsd = estimateTtsCost(charCount);
const textPreview =
text.length > 240 ? `${text.slice(0, 237)}...` : text;
try {
recordVoiceUsage({
sessionId,
helperName,
requestId: voiceRequestId,
messageId,
voice,
model: OPENAI_TTS_MODEL,
charCount,
costUsd: estimatedCostUsd,
durationMs,
textPreview,
});
} catch (loggingError) {
console.error('[voice/tts] failed to record usage', loggingError);
}
const headers = new Headers();
headers.set('Content-Type', response.headers.get('Content-Type') || 'audio/mpeg');
headers.set('Cache-Control', 'no-cache');
headers.set('X-Voice-Request-Id', voiceRequestId);
return new Response(response.body, {
status: 200,
headers,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error('[voice/tts] failed to synthesize:', message);
return NextResponse.json({ error: message }, { status: 500 });
}
}
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from 'next/server';
import { WorkflowRegistry } from '@/services/workflows/registry';
export async function GET() {
try {
const workflows = await WorkflowRegistry.getAllWorkflows();
return NextResponse.json({
success: true,
data: workflows,
});
} catch (error) {
console.error('Error fetching workflows:', error);
return NextResponse.json(
{ success: false, error: 'Failed to fetch workflows' },
{ status: 500 }
);
}
}
+132
View File
@@ -0,0 +1,132 @@
/* Import clean, modern fonts */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600&display=swap');
/* Geist Sans - Modern font optimized for reading/writing applications */
@font-face {
font-family: 'Geist';
src: url('https://cdn.jsdelivr.net/npm/geist@1.3.0/dist/fonts/geist-sans/Geist-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist';
src: url('https://cdn.jsdelivr.net/npm/geist@1.3.0/dist/fonts/geist-sans/Geist-Medium.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist';
src: url('https://cdn.jsdelivr.net/npm/geist@1.3.0/dist/fonts/geist-sans/Geist-SemiBold.woff2') format('woff2');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist';
src: url('https://cdn.jsdelivr.net/npm/geist@1.3.0/dist/fonts/geist-sans/Geist-Bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Modern Clean Design System - RA-H */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
overflow: hidden;
background-color: #0a0a0a;
color: #e5e5e5;
/* Geist Sans for optimal reading/writing experience */
font-family: 'Geist', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 14px;
line-height: 1.5;
letter-spacing: -0.011em; /* Optimized letter spacing for Geist */
font-weight: 400;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Monospace font class for technical elements */
.font-mono {
font-family: 'JetBrains Mono', 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Courier New', monospace;
font-feature-settings: 'liga' 1, 'calt' 1;
}
/* Scrollbar styling for terminal theme */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #0a0a0a;
}
::-webkit-scrollbar-thumb {
background: #353535;
border-radius: 2px;
}
::-webkit-scrollbar-thumb:hover {
background: #4a4a4a;
}
/* Utility classes */
.border-divider {
border-color: #1a1a1a;
}
.text-muted {
color: #6b6b6b;
}
.text-primary {
color: #e5e5e5;
}
.bg-hover:hover {
background-color: #151515;
}
/* Terminal animations */
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.6;
transform: scale(1.2);
}
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes fadeIn {
to { opacity: 1; }
}
@keyframes loadingPulse {
0%, 80%, 100% {
transform: scale(0.8);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
+18
View File
@@ -0,0 +1,18 @@
import './globals.css';
export const metadata = {
title: 'RA-H Open Source',
description: 'Local-first research workspace with a BYO-key AI orchestrator',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
+10
View File
@@ -0,0 +1,10 @@
import ThreePanelLayout from '@/components/layout/ThreePanelLayout';
import { LocalKeyGate } from '@/components/auth/LocalKeyGate';
export default function Home() {
return (
<LocalKeyGate>
<ThreePanelLayout />
</LocalKeyGate>
);
}
+436
View File
@@ -0,0 +1,436 @@
/**
* RA-H MCP Server
*
* Exposes a minimal HTTP-based Model Context Protocol endpoint that lets external
* assistants read/write the local RA-H SQLite graph by calling our existing API routes.
* Designed to run locally (packaged with the desktop app) and never exposes data
* beyond 127.0.0.1.
*/
const http = require('node:http');
const os = require('node:os');
const path = require('node:path');
const fs = require('node:fs');
const { URL } = require('node:url');
const { z } = require('zod');
const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
const { StreamableHTTPServerTransport } = require('@modelcontextprotocol/sdk/server/streamableHttp.js');
const { McpError, ErrorCode } = require('@modelcontextprotocol/sdk/types.js');
const getRawBody = require('raw-body');
const packageJson = require('../../package.json');
const DEFAULT_PORT = Number(process.env.RAH_MCP_PORT || 44145);
const DEFAULT_HOST = '127.0.0.1';
const STATUS_DIR = path.join(
os.homedir(),
'Library',
'Application Support',
'RA-H',
'config'
);
const STATUS_FILE = path.join(STATUS_DIR, 'mcp-status.json');
let baseUrlResolver =
typeof process.env.RAH_MCP_TARGET_URL === 'string'
? () => process.env.RAH_MCP_TARGET_URL
: () => process.env.NEXT_PUBLIC_BASE_URL || 'http://127.0.0.1:3000';
let httpServer = null;
let httpPort = null;
let lastErrorMessage = null;
let logger = (message) => console.log(`[mcp] ${message}`);
const instructions = [
'Use rah.add_node to summarize conversations or files into nodes with dimensions.',
'Use rah.search_nodes to recall prior notes before you suggest creating new ones.',
'All operations happen locally on this device; data never leaves 127.0.0.1.'
].join(' ');
const serverInfo = {
name: 'ra-h-local-mcp',
version: packageJson.version || '0.0.0'
};
const createServer = () =>
new McpServer(serverInfo, {
instructions,
capabilities: {
tools: {}
}
});
const mcpServer = createServer();
const sanitizeDimensions = (raw) => {
if (!Array.isArray(raw)) return [];
const result = [];
const seen = new Set();
for (const value of raw) {
if (typeof value !== 'string') continue;
const trimmed = value.trim();
if (!trimmed) continue;
const lowered = trimmed.toLowerCase();
if (seen.has(lowered)) continue;
seen.add(lowered);
result.push(trimmed);
if (result.length >= 5) break;
}
return result;
};
const addNodeInputSchema = {
title: z.string().min(1).max(160),
content: z.string().max(20000).optional(),
link: z.string().url().optional(),
description: z.string().max(2000).optional(),
dimensions: z.array(z.string()).min(1).max(5),
metadata: z.record(z.any()).optional(),
chunk: z.string().max(50000).optional()
};
const addNodeOutputSchema = {
nodeId: z.number(),
title: z.string(),
dimensions: z.array(z.string()),
message: z.string()
};
const searchNodesInputSchema = {
query: z.string().min(1).max(400),
limit: z.number().min(1).max(25).optional(),
dimensions: z.array(z.string()).max(5).optional()
};
const searchNodesOutputSchema = {
count: z.number(),
nodes: z.array(
z.object({
id: z.number(),
title: z.string(),
content: z.string().nullable(),
description: z.string().nullable(),
link: z.string().nullable(),
dimensions: z.array(z.string()),
updated_at: z.string()
})
)
};
async function resolveBaseUrl() {
try {
const value = await baseUrlResolver();
if (typeof value === 'string' && value.trim().length > 0) {
return value.replace(/\/+$/, '');
}
} catch (error) {
lastErrorMessage = error instanceof Error ? error.message : String(error);
}
return (process.env.NEXT_PUBLIC_BASE_URL || 'http://127.0.0.1:3000').replace(/\/+$/, '');
}
async function callRaHApi(pathname, options = {}) {
const baseUrl = await resolveBaseUrl();
const targetUrl = `${baseUrl}${pathname}`;
try {
const response = await fetch(targetUrl, {
...options,
headers: {
'Content-Type': 'application/json',
...(options.headers || {})
}
});
const body = await response.json().catch(() => null);
if (!response.ok || !body || body.success === false) {
const errorMessage = body?.error || `RA-H API request failed at ${pathname}`;
lastErrorMessage = errorMessage;
throw new McpError(ErrorCode.InternalError, errorMessage);
}
lastErrorMessage = null;
return body;
} catch (error) {
const message =
error instanceof McpError
? error.message
: `Unable to reach local RA-H API at ${targetUrl}`;
lastErrorMessage = message;
if (error instanceof McpError) {
throw error;
}
throw new McpError(ErrorCode.InternalError, message);
}
}
mcpServer.registerTool(
'rah.add_node',
{
title: 'Add RA-H node',
description: 'Create a new node in the local RA-H knowledge base.',
inputSchema: addNodeInputSchema,
outputSchema: addNodeOutputSchema
},
async ({ title, content, link, description, dimensions, metadata, chunk }) => {
const normalizedDimensions = sanitizeDimensions(dimensions);
if (normalizedDimensions.length === 0) {
throw new McpError(
ErrorCode.InvalidParams,
'At least one dimension/tag is required when creating a node.'
);
}
const payload = {
title: title.trim(),
content: content?.trim() || undefined,
link: link?.trim() || undefined,
description: description?.trim() || undefined,
dimensions: normalizedDimensions,
metadata: metadata || {},
chunk: chunk?.trim() || undefined
};
const result = await callRaHApi('/api/nodes', {
method: 'POST',
body: JSON.stringify(payload)
});
const node = result.data;
const summary = `Created node #${node.id}: ${node.title} [${(node.dimensions || normalizedDimensions).join(', ')}]`;
return {
content: [{ type: 'text', text: summary }],
structuredContent: {
nodeId: node.id,
title: node.title,
dimensions: node.dimensions || normalizedDimensions,
message: result.message || summary
}
};
}
);
mcpServer.registerTool(
'rah.search_nodes',
{
title: 'Search RA-H nodes',
description: 'Find existing RA-H entries that mention a topic before adding new ones.',
inputSchema: searchNodesInputSchema,
outputSchema: searchNodesOutputSchema
},
async ({ query, limit = 10, dimensions }) => {
const params = new URLSearchParams();
params.set('search', query.trim());
params.set('limit', String(Math.min(Math.max(limit, 1), 25)));
const dimensionList = sanitizeDimensions(dimensions || []);
if (dimensionList.length > 0) {
params.set('dimensions', dimensionList.join(','));
}
const result = await callRaHApi(`/api/nodes?${params.toString()}`, {
method: 'GET'
});
const nodes = Array.isArray(result.data) ? result.data : [];
const summary = nodes.length === 0
? 'No existing RA-H nodes mention that topic yet.'
: `Found ${nodes.length} node(s) mentioning that topic.`;
return {
content: [{ type: 'text', text: summary }],
structuredContent: {
count: nodes.length,
nodes: nodes.map((node) => ({
id: node.id,
title: node.title,
content: node.content ?? null,
description: node.description ?? null,
link: node.link ?? null,
dimensions: node.dimensions || [],
updated_at: node.updated_at
}))
}
};
}
);
async function readRequestBody(req) {
if (req.method !== 'POST') return undefined;
try {
const raw = await getRawBody(req, {
limit: '4mb',
encoding: 'utf-8'
});
return raw ? JSON.parse(raw) : undefined;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new McpError(ErrorCode.ParseError, `Invalid JSON body: ${message}`);
}
}
async function handleMcpRequest(req, res) {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true
});
res.on('close', () => {
transport.close().catch(() => undefined);
});
try {
const parsedBody = await readRequestBody(req);
await mcpServer.connect(transport);
await transport.handleRequest(req, res, parsedBody);
} catch (error) {
const message = error instanceof McpError ? error.message : 'MCP transport failure';
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
jsonrpc: '2.0',
error: { code: ErrorCode.InternalError, message }
}));
logger(`MCP request error: ${message}`);
}
}
function ensureStatusDir() {
fs.mkdirSync(STATUS_DIR, { recursive: true });
}
async function getStatusSnapshot() {
const baseUrl = await resolveBaseUrl();
return {
enabled: !!httpServer,
port: httpPort,
url: httpPort ? `http://${DEFAULT_HOST}:${httpPort}/mcp` : null,
target_base_url: baseUrl,
last_updated: new Date().toISOString(),
last_error: lastErrorMessage
};
}
async function persistStatus() {
try {
if (!httpServer) {
ensureStatusDir();
fs.writeFileSync(
STATUS_FILE,
JSON.stringify({
enabled: false,
port: null,
url: null,
last_updated: new Date().toISOString()
}, null, 2)
);
return;
}
const snapshot = await getStatusSnapshot();
ensureStatusDir();
fs.writeFileSync(STATUS_FILE, JSON.stringify(snapshot, null, 2));
} catch (error) {
logger(`Failed to persist MCP status: ${error instanceof Error ? error.message : String(error)}`);
}
}
async function ensureMcpServer(options = {}) {
if (typeof options.logger === 'function') {
logger = options.logger;
}
if (typeof options.resolveBaseUrl === 'function') {
baseUrlResolver = options.resolveBaseUrl;
}
if (httpServer) {
await persistStatus();
return { port: httpPort };
}
const port = Number(options.port || DEFAULT_PORT);
const host = options.host || DEFAULT_HOST;
httpServer = http.createServer(async (req, res) => {
const parsedUrl = new URL(req.url || '/', `http://${req.headers.host || `${host}:${port}`}`);
if (req.method === 'OPTIONS') {
res.writeHead(204, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type'
});
res.end();
return;
}
if (parsedUrl.pathname === '/status') {
const snapshot = await getStatusSnapshot();
res.writeHead(200, {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
});
res.end(JSON.stringify(snapshot));
return;
}
if (parsedUrl.pathname !== '/mcp') {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Route not found' }));
return;
}
if (req.method !== 'POST') {
res.writeHead(405, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Use POST for MCP requests' }));
return;
}
await handleMcpRequest(req, res);
});
await new Promise((resolve, reject) => {
httpServer.once('error', reject);
httpServer.listen(port, host, () => {
httpPort = port;
logger(`MCP server listening on http://${host}:${port}/mcp`);
resolve();
});
});
await persistStatus();
return { port };
}
function updateBaseUrlResolver(resolver) {
if (typeof resolver === 'function') {
baseUrlResolver = resolver;
persistStatus().catch(() => undefined);
}
}
async function stopMcpServer() {
if (!httpServer) return;
await new Promise((resolve) => {
httpServer.close(() => resolve());
});
httpServer = null;
httpPort = null;
await persistStatus();
}
module.exports = {
ensureMcpServer,
updateBaseUrlResolver,
getStatusSnapshot,
stopMcpServer,
STATUS_FILE
};
if (require.main === module) {
ensureMcpServer({
port: DEFAULT_PORT,
resolveBaseUrl: baseUrlResolver
}).catch((error) => {
console.error('Failed to start RA-H MCP server:', error);
process.exit(1);
});
}
+239
View File
@@ -0,0 +1,239 @@
#!/usr/bin/env node
'use strict';
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { z } = require('zod');
const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
const packageJson = require('../../package.json');
const instructions = [
'Use rah.add_node to summarize conversations or files into nodes with dimensions.',
'Use rah.search_nodes to recall prior notes before you suggest creating new ones.',
'All operations happen locally on this device; data never leaves 127.0.0.1.'
].join(' ');
const serverInfo = {
name: 'ra-h-local-stdio',
version: packageJson.version || '0.0.0'
};
const STATUS_PATH = path.join(
os.homedir(),
'Library',
'Application Support',
'RA-H',
'config',
'mcp-status.json'
);
const addNodeInputSchema = {
title: z.string().min(1).max(160),
content: z.string().max(20000).optional(),
link: z.string().url().optional(),
description: z.string().max(2000).optional(),
dimensions: z.array(z.string()).min(1).max(5),
metadata: z.record(z.any()).optional(),
chunk: z.string().max(50000).optional()
};
const addNodeOutputSchema = {
nodeId: z.number(),
title: z.string(),
dimensions: z.array(z.string()),
message: z.string()
};
const searchNodesInputSchema = {
query: z.string().min(1).max(400),
limit: z.number().min(1).max(25).optional(),
dimensions: z.array(z.string()).max(5).optional()
};
const searchNodesOutputSchema = {
count: z.number(),
nodes: z.array(
z.object({
id: z.number(),
title: z.string(),
content: z.string().nullable(),
description: z.string().nullable(),
link: z.string().nullable(),
dimensions: z.array(z.string()),
updated_at: z.string()
})
)
};
const server = new McpServer(serverInfo, { instructions });
function logError(...args) {
console.error('[ra-h-stdio]', ...args);
}
const sanitizeDimensions = (raw) => {
if (!Array.isArray(raw)) return [];
const result = [];
const seen = new Set();
for (const value of raw) {
if (typeof value !== 'string') continue;
const trimmed = value.trim();
if (!trimmed) continue;
const lowered = trimmed.toLowerCase();
if (seen.has(lowered)) continue;
seen.add(lowered);
result.push(trimmed);
if (result.length >= 5) break;
}
return result;
};
function readStatusFile() {
try {
if (!fs.existsSync(STATUS_PATH)) {
return null;
}
const raw = fs.readFileSync(STATUS_PATH, 'utf-8');
return JSON.parse(raw);
} catch {
return null;
}
}
async function resolveBaseUrl() {
const envTarget = process.env.RAH_MCP_TARGET_URL || process.env.NEXT_PUBLIC_BASE_URL;
if (envTarget && envTarget.trim().length > 0) {
return envTarget.replace(/\/+$/, '');
}
const status = readStatusFile();
if (status?.target_base_url) {
return String(status.target_base_url).replace(/\/+$/, '');
}
if (status?.port) {
return `http://127.0.0.1:${status.port}`.replace(/\/+$/, '');
}
return 'http://127.0.0.1:3000';
}
async function callRaHApi(pathname, options = {}) {
const baseUrl = (await resolveBaseUrl()).replace(/\/+$/, '');
const targetUrl = `${baseUrl}${pathname}`;
const response = await fetch(targetUrl, {
...options,
headers: {
'Content-Type': 'application/json',
...(options.headers || {})
}
});
const body = await response.json().catch(() => null);
if (!response.ok || !body || body.success === false) {
const errorMessage = body?.error || `RA-H API request failed at ${pathname}`;
throw new Error(errorMessage);
}
return body;
}
server.registerTool(
'rah_add_node',
{
title: 'Add RA-H node',
description: 'Create a new node in the local RA-H knowledge base.',
inputSchema: addNodeInputSchema,
outputSchema: addNodeOutputSchema
},
async ({ title, content, link, description, dimensions, metadata, chunk }) => {
const normalizedDimensions = sanitizeDimensions(dimensions);
if (normalizedDimensions.length === 0) {
throw new Error('At least one dimension/tag is required when creating a node.');
}
const payload = {
title: title.trim(),
content: content?.trim() || undefined,
link: link?.trim() || undefined,
description: description?.trim() || undefined,
dimensions: normalizedDimensions,
metadata: metadata || {},
chunk: chunk?.trim() || undefined
};
const result = await callRaHApi('/api/nodes', {
method: 'POST',
body: JSON.stringify(payload)
});
const node = result.data;
const summary = `Created node #${node.id}: ${node.title} [${(node.dimensions || normalizedDimensions).join(', ')}]`;
return {
content: [{ type: 'text', text: summary }],
structuredContent: {
nodeId: node.id,
title: node.title,
dimensions: node.dimensions || normalizedDimensions,
message: result.message || summary
}
};
}
);
server.registerTool(
'rah_search_nodes',
{
title: 'Search RA-H nodes',
description: 'Find existing RA-H entries that mention a topic before adding new ones.',
inputSchema: searchNodesInputSchema,
outputSchema: searchNodesOutputSchema
},
async ({ query, limit = 10, dimensions }) => {
const params = new URLSearchParams();
params.set('search', query.trim());
params.set('limit', String(Math.min(Math.max(limit, 1), 25)));
const dimensionList = sanitizeDimensions(dimensions || []);
if (dimensionList.length > 0) {
params.set('dimensions', dimensionList.join(','));
}
const result = await callRaHApi(`/api/nodes?${params.toString()}`, {
method: 'GET'
});
const nodes = Array.isArray(result.data) ? result.data : [];
const summary =
nodes.length === 0
? 'No existing RA-H nodes mention that topic yet.'
: `Found ${nodes.length} node(s) mentioning that topic.`;
return {
content: [{ type: 'text', text: summary }],
structuredContent: {
count: nodes.length,
nodes: nodes.map((node) => ({
id: node.id,
title: node.title,
content: node.content ?? null,
description: node.description ?? null,
link: node.link ?? null,
dimensions: node.dimensions || [],
updated_at: node.updated_at
}))
}
};
}
);
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
logError('STDIO MCP server ready');
}
main().catch((error) => {
logError('Fatal error:', error);
process.exit(1);
});
+28
View File
@@ -0,0 +1,28 @@
# RA-H Overview
## What is RA-H?
RA-H is a flexible knowledge management system designed for researchers. It learns how you think and helps connect ideas across your knowledge base.
For more information, visit [ra-h.app](https://ra-h.app)
## Design Philosophy
**Non-prescriptive & emergent** - The system doesn't force you into folders or predefined categories. Organization emerges naturally from your actual content. The structure adapts to how you think, not the other way around.
**Everything is connected** - Every piece of knowledge can potentially connect to any other. Connections aren't just links - they carry context, explanation, and meaning.
**Local-first** - Your knowledge network belongs to you, not a platform. Your thinking, research, and connections all belong to you in a portable format you control.
## Tech Stack
- **Frontend:** Next.js 15, TypeScript, Tailwind CSS
- **Database:** SQLite + sqlite-vec (vector search)
- **AI:** Anthropic Claude + OpenAI GPT via Vercel AI SDK
- **Deployment:** Currently beta web bundle, Mac app coming soon
## Current Status
- **Version:** Beta (private distribution)
- **Platform:** Web-based (Next.js server)
- **Roadmap:** Native Mac application with user data migration
+106
View File
@@ -0,0 +1,106 @@
# System Architecture
## Overview
RA-H uses a multi-agent architecture with three specialized AI agents that collaborate to manage your knowledge base. The system is built around **nodes** (knowledge items), **edges** (relationships), and **dimensions** (categories).
## Core Concepts
### Nodes
Knowledge items stored in the database (papers, ideas, people, projects, videos, tweets, etc). Each node has:
- **Title** and **content**
- **Dimensions** (multi-tag categorization)
- **Metadata** (structured JSON)
- **Embeddings** (for semantic search)
- **Links** (for external sources)
### Edges
Directed relationships between nodes. Edges capture how nodes connect ("relates to", "inspired by", etc).
### Dimensions
Multi-select categorization tags. Nodes can have multiple dimensions. Some dimensions can be marked as "priority" for focused context.
## Agent Architecture
### Orchestrator Agents (Easy/Hard Mode)
**ra-h-easy (Easy Mode - Default)**
- **Model:** GPT-5 Mini (`openai/gpt-5-mini`)
- **Purpose:** Fast, low-latency orchestration for everyday tasks
- **Caching:** OpenAI implicit caching
- **Reasoning:** `reasoning_effort: light` for speed
**ra-h (Hard Mode)**
- **Model:** Claude Sonnet 4.5 (`anthropic/claude-sonnet-4.5`)
- **Purpose:** Deep reasoning for complex tasks
- **Caching:** Anthropic explicit prompt caching
- **Reasoning:** Stronger analytical capabilities
**Tools Available:**
- `queryNodes`, `queryEdge`, `searchContentEmbeddings`
- `webSearch`, `think`
- `executeWorkflow` (delegates to wise-rah)
- `createNode`, `updateNode`, `createEdge`, `updateEdge`
- `youtubeExtract`, `websiteExtract`, `paperExtract`
**Mode Switching:**
Users toggle via UI (⚡ Easy / 🔥 Hard). Choice persists in localStorage. **Seamless mid-conversation switching** - context maintained across mode changes.
### Wise RA-H (Workflow Executor)
**wise-rah**
- **Model:** GPT-5 (`openai/gpt-5`)
- **Purpose:** Executes predefined workflows (integrate, deep analysis)
- **Direct write access:** Calls `updateNode` directly (no delegation)
- **Context isolation:** Returns summaries only to orchestrator
**Tools Available:**
- `queryNodes`, `getNodesById`, `queryEdge`, `searchContentEmbeddings`
- `webSearch`, `think`
- `updateNode` (append-only, enforced at tool level)
**Key Workflows:**
- **Integrate:** Database-wide connection discovery (5-step: plan → ground → search → contextualize → append)
### Mini RA-H (Delegate Workers)
**mini-rah**
- **Model:** GPT-4o Mini (`openai/gpt-4o-mini`)
- **Purpose:** Spawned for write operations, extraction, batch tasks
- **Execution:** Isolated context, returns summaries only
**Tools Available:**
- All read tools + `createNode`, `updateNode`, `createEdge`, `updateEdge`
- Extraction tools (`youtubeExtract`, `websiteExtract`, `paperExtract`)
## Prompt Caching
**Anthropic (Claude):**
- Explicit cache control blocks in system prompts
- Caches tool definitions, workflows, base context
**OpenAI (GPT-5/4o):**
- Implicit caching based on prefix matching
- Optimized prompts for cache reuse
- `reasoning_effort` parameter for speed/quality tradeoff
## Context Hygiene
**Orchestrator:**
- Maintains full conversation history
- Sees pinned nodes + focused node
- Delegates isolation ensures clean context
**Workers (wise-rah/mini-rah):**
- Execute in isolated sessions
- Return structured summaries only
- Do NOT pollute orchestrator context with tool execution details
## UI Integration
Users interact with a single interface that automatically routes requests to the appropriate agent based on:
- **Mode selection** (Easy/Hard)
- **Workflow triggers** (executeWorkflow → wise-rah)
- **Delegation needs** (mini-rah spawned in background)
All agents share the same **pinned context** (up to 10 nodes) plus the **focused node** for consistent knowledge access.
+254
View File
@@ -0,0 +1,254 @@
# Database Schema
## Why SQLite?
RA-H uses **SQLite** for local-first data ownership. Your knowledge stays on your machine - no cloud dependencies. SQLite provides:
- **Zero configuration** - single file database
- **sqlite-vec extension** - fast vector similarity search
- **Full-text search (FTS5)** - Google-like text search
- **Relational integrity** - foreign keys, triggers, transactions
- **Portability** - database file migrates with Mac app
**Database Location:** `~/Library/Application Support/RA-H/db/rah.sqlite`
## Two-Layer Embedding Architecture
RA-H uses **two types of embeddings** for different search needs:
### 1. Node-Level Embeddings
- **Storage:** `nodes.embedding` column (BLOB)
- **Purpose:** Semantic search for nodes (legacy memory pipeline used this too)
- **Model:** `text-embedding-3-small` (1536 dimensions)
- **Used by:** Search/agent tools (legacy memory pipeline has been removed)
### 2. Chunk-Level Embeddings
- **Storage:** `chunks` table (text) → `vec_chunks` virtual table (embeddings)
- **Purpose:** Detailed content search within long documents
- **Model:** `text-embedding-3-small` (1536 dimensions)
- **Used by:** `searchContentEmbeddings` tool
## Core Tables
### nodes
Primary knowledge storage. Each row is a discrete knowledge item.
**Columns:**
- `id` (INTEGER PK) - Unique identifier
- `title` (TEXT) - Node title
- `content` (TEXT) - Full content
- `type` (TEXT) - Node type (memory, paper, idea, person, etc)
- `link` (TEXT) - External source URL (only for source nodes, not derived ideas)
- `description` (TEXT) - Brief summary
- `metadata` (TEXT) - JSON metadata
- `chunk` (TEXT) - Source text for chunking
- `chunk_status` (TEXT) - Chunking status (not_chunked, chunked)
- `embedding` (BLOB) - Node-level embedding vector
- `embedding_text` (TEXT) - Text that was embedded
- `embedding_updated_at` (TEXT) - Embedding timestamp
- `is_pinned` (INTEGER) - Legacy pin flag (kept for migration; not surfaced in UI)
- `created_at`, `updated_at` (TEXT) - Timestamps
**Indexes:**
- `idx_nodes_type` - Fast type filtering
- `idx_nodes_pinned` - Legacy partial index (no longer recreated, safe to drop later)
**FTS:**
- `nodes_fts` - Full-text search on title + content
### edges
Directed relationships between nodes (knowledge graph).
**Columns:**
- `id` (INTEGER PK)
- `from_node_id` (INTEGER FK → nodes.id)
- `to_node_id` (INTEGER FK → nodes.id)
- `source` (TEXT) - How edge was created (e.g., "user", "agent")
- `context` (TEXT) - Relationship context/description
- `user_feedback` (INTEGER) - User rating
- `created_at` (TEXT)
**Indexes:**
- `idx_edges_from` - Fast "outgoing edges" queries
- `idx_edges_to` - Fast "incoming edges" queries
### chunks
Long-form content split into searchable pieces.
**Columns:**
- `id` (INTEGER PK)
- `node_id` (INTEGER FK → nodes.id)
- `chunk_idx` (INTEGER) - Sequence number
- `text` (TEXT) - Chunk content
- `embedding_type` (TEXT) - Model used
- `metadata` (TEXT) - JSON metadata
- `created_at` (TEXT)
**Indexes:**
- `idx_chunks_by_node` - Fast node→chunks lookup
- `idx_chunks_by_node_idx` - Ordered retrieval
**FTS:**
- `chunks_fts` - Full-text search within chunks
### dimensions
Master list of categorization tags.
**Columns:**
- `name` (TEXT PK) - Dimension name
- `is_priority` (INTEGER) - Priority dimension flag
- `updated_at` (TEXT)
### node_dimensions
Many-to-many junction table (nodes ↔ dimensions).
**Columns:**
- `node_id` (INTEGER FK → nodes.id)
- `dimension` (TEXT FK → dimensions.name)
- Primary key: `(node_id, dimension)`
**Indexes:**
- `idx_dim_by_dimension` - Fast "all nodes in dimension X"
- `idx_dim_by_node` - Fast "all dimensions for node X"
### chats
Conversation history with token/cost tracking.
**Columns:**
- `id` (INTEGER PK)
- `chat_type` (TEXT) - Conversation type
- `helper_name` (TEXT) - Agent key (ra-h, ra-h-easy, mini-rah, wise-rah)
- `agent_type` (TEXT) - Role category (orchestrator, executor, planner)
- `delegation_id` (INTEGER FK)
- `user_message` (TEXT)
- `assistant_message` (TEXT)
- `thread_id` (TEXT) - Conversation thread
- `focused_node_id` (INTEGER FK → nodes.id)
- `metadata` (TEXT) - JSON with token counts, costs, traces
- `created_at` (TEXT)
**Indexes:**
- `idx_chats_thread` - Fast thread retrieval
### agent_delegations
Task queue for mini-rah workers.
**Columns:**
- `id` (INTEGER PK)
- `session_id` (TEXT UNIQUE) - Delegation identifier
- `agent_type` (TEXT) - Delegate type (default: 'mini')
- `task` (TEXT) - Task description
- `context` (TEXT) - Execution context
- `expected_outcome` (TEXT)
- `status` (TEXT) - queued, in_progress, completed, failed
- `summary` (TEXT) - Result summary
- `created_at`, `updated_at` (TEXT)
### chat_memory_state
Checkpoint tracker for memory pipeline.
**Columns:**
- `thread_id` (TEXT PK)
- `helper_name` (TEXT) - Agent that owns the thread
- `last_processed_chat_id` (INTEGER) - Last chat processed
- `last_processed_at` (TEXT)
**Indexes:**
- `idx_chat_memory_thread` - Fast state lookup
### logs
Activity audit trail (auto-pruned to last 10k).
**Columns:**
- `id` (INTEGER PK)
- `ts` (TEXT) - Timestamp
- `table_name` (TEXT) - Affected table
- `action` (TEXT) - INSERT, UPDATE, DELETE
- `row_id` (INTEGER) - Affected row
- `summary` (TEXT) - Human-readable summary
- `snapshot_json` (TEXT) - Row snapshot
- `enriched_summary` (TEXT) - Enriched log entry
**Indexes:**
- `idx_logs_ts` - Chronological queries
- `idx_logs_table_ts` - Per-table chronological
- `idx_logs_table_row` - Per-row history
- `idx_logs_enriched` - Enriched-only filtering
## Vector Tables (Auto-Created)
### vec_nodes
Virtual table for node-level vector search (sqlite-vec).
```sql
VIRTUAL TABLE USING vec0(
node_id INTEGER PRIMARY KEY,
embedding FLOAT[1536]
)
```
**Supporting tables (auto-generated):**
- `vec_nodes_info`, `vec_nodes_chunks`, `vec_nodes_rowids`, `vec_nodes_vector_chunks00`
### vec_chunks
Virtual table for chunk-level vector search.
```sql
VIRTUAL TABLE USING vec0(
chunk_id INTEGER PRIMARY KEY,
embedding FLOAT[1536]
)
```
**Supporting tables (auto-generated):**
- `vec_chunks_info`, `vec_chunks_chunks`, `vec_chunks_rowids`, `vec_chunks_vector_chunks00`
**Note:** Vec tables are NOT pre-seeded in distribution. They auto-create on first app startup via `ensureVectorTables()` in `sqlite-client.ts:45`.
## Views
### nodes_v
Nodes with dimensions aggregated as JSON array.
```sql
SELECT
n.id, n.title, n.content, n.link, n.metadata, n.chunk,
n.created_at, n.updated_at,
COALESCE(JSON_GROUP_ARRAY(d.dimension), '[]') AS dimensions_json
FROM nodes n
LEFT JOIN node_dimensions d ON d.node_id = n.id
```
### logs_v
Enriched logs with related data (node titles, edge titles, chat previews).
## Triggers
**Logging triggers:**
- `trg_nodes_ai` / `trg_nodes_au` - Log node inserts/updates
- `trg_edges_ai` / `trg_edges_au` - Log edge inserts/updates
- `trg_chats_ai` - Log chat inserts (with token/cost/trace metadata)
**Maintenance triggers:**
- `trg_edges_update_nodes_on_insert` - Touch node timestamps on edge creation
- `trg_logs_prune` - Keep last 10,000 log rows
## Schema Version
**schema_version table:**
- Tracks database migrations
- Current: v1.0 (frozen for Mac app release)
```sql
CREATE TABLE schema_version (
version INTEGER PRIMARY KEY,
applied_at TEXT DEFAULT CURRENT_TIMESTAMP,
description TEXT
);
```
## Seed Database
**Location:** `/dist/resources/rah_seed.sqlite`
**Purpose:** Ships with Mac app for new users
**Contents:** Clean schema, no data, no vec tables (auto-created on first run)
**Size:** ~128KB
+148
View File
@@ -0,0 +1,148 @@
# Context & Memory
## Context Builder
The context builder assembles the information each agent sees during conversations. It creates **cacheable blocks** (for Anthropic) and structured context for all agents.
### Context Structure
Every agent receives:
**1. Base Context**
- How nodes, edges, and dimensions work
- Node reference format: `[NODE:id:"title"]`
- If auto-context is enabled, BACKGROUND CONTEXT explains that the 10 most-connected nodes will be listed later
- Pronouns like "this conversation/paper/video" refer to focused node
**2. Agent Instructions**
- Agent-specific system prompt (ra-h, ra-h-easy, wise-rah, mini-rah)
- Role-specific behavior guidelines
- Execution approach and response style
**3. Tool Definitions**
- Role-specific tools (orchestrator/executor/planner)
- Each tool's description and parameters
- Usage guidelines
**4. Workflow Definitions** (orchestrators only)
- Available workflows (integrate, etc.)
- Workflow descriptions and triggers
- Only shown to ra-h and ra-h-easy
**5. Background Context (auto-context hubs)** (orchestrators only)
- Optional block controlled by `~/Library/Application Support/RA-H/config/settings.json`
- Lists the 10 nodes with the highest edge counts (ID + title + edge count)
- Reminds agents to call `queryNodes`/`getNodesById` for full content
- Ordered by edge count, then most recently updated to break ties
**6. Focused Nodes**
- Primary focused node (active tab)
- Additional focused nodes (other open tabs)
- 25-word content previews
- Chunk status and embedding availability
- Link information
### Context Caching
**Anthropic (Claude):**
- Explicit cache control blocks (`cache_control: {type: 'ephemeral'}`)
- Caches base context, instructions, tools, workflows, background context
- Focused nodes NOT cached (changes frequently)
**OpenAI (GPT):**
- Implicit caching based on prefix matching
- Same block structure for consistency
- No explicit cache control markers needed
### Truncation Strategy
- **Background context:** IDs + titles only to keep the block lightweight
- **Focused nodes:** 25-word previews
- **Full content access:** Agents use `queryNodes`, `getNodesById`, `searchContentEmbeddings` for complete content
- **Chunk status indicator:** Shows if embeddings are available (avoid re-extraction)
## Memory System (Legacy)
The automatic memory pipeline has been removed. Existing memory nodes remain in the database for archival purposes, but no new ones are created and they are excluded from auto-context. The old files (`src/services/memory/**`) have been deleted along with the `ENABLE_CHAT_MEMORY_PIPELINE` toggle. Future improvements should store long-term knowledge as normal nodes with explicit dimensions rather than a background pipeline.
6. Update checkpoint in `chat_memory_state`
**Location:**
- Pipeline: `/src/services/memory/synthesis/chatMemoryPipeline.ts`
- Trigger: `/src/services/chat/middleware.ts:168-178`
- Extraction: `/src/services/memory/synthesis/llmSynthesis.ts`
### Memory Node Structure
```typescript
{
type: 'memory',
title: 'Insight on [subject]',
description: 'Multi-line list of facts',
content: 'Second-person statements combined',
metadata: {
category: 'identity' | 'interests' | 'models' | 'preferences' | 'relationships',
subject_type: 'person' | 'project' | 'concept' | 'resource' | 'organization' | 'workflow',
source_thread: string,
source_helper: string,
importance: 'low' | 'medium' | 'high',
focused_node_titles: string[],
canonical_key: string | null,
subject: string
}
}
```
### Memory Categories
- **identity** - Who you are, your roles, background
- **interests** - What you're curious about, learning, exploring
- **models** - How you think, mental frameworks, approaches
- **preferences** - What you like/dislike, priorities, values
- **relationships** - Connections to people, projects, concepts
### Subject Types
Memory extraction classifies subjects into these types:
- **person** - Individual people (yourself or collaborators)
- **project** - Projects, products, or initiatives
- **concept** - Ideas, theories, mental models, beliefs
- **resource** - Tools, articles, books, references
- **organization** - Companies, institutions, communities
- **workflow** - Processes, systems, methodologies
### Importance Levels
Each memory fact is classified by importance:
- **high** - Explicitly marked as priority, main project, strong belief ("is crucial", "must")
- **medium** - Normal statements and observations (default)
- **low** - Tentative, uncertain, or exploratory statements
## Auto-Context Toggle
Auto-context replaces manual pinning. When enabled, BACKGROUND CONTEXT includes the 10 nodes with the highest edge counts.
**How it works:**
1. Settings UI exposes a toggle inside the Context tab.
2. The toggle writes to `~/Library/Application Support/RA-H/config/settings.json`.
3. Context builder and workflows read that file on each request (missing file defaults to `false`).
4. When enabled, the following query runs once per request:
```sql
SELECT n.id,
n.title,
COUNT(DISTINCT e.id) AS edge_count,
n.updated_at
FROM nodes n
LEFT JOIN edges e ON (e.from_node_id = n.id OR e.to_node_id = n.id)
WHERE n.type IS NULL OR n.type != 'memory'
GROUP BY n.id
ORDER BY edge_count DESC, n.updated_at DESC
LIMIT 10;
```
**Notes:**
- Only orchestrators (ra-h / ra-h-easy) see the block.
- Titles + edge counts are shown; agents must call `queryNodes`/`getNodesById` for content.
- Users who previously pinned nodes are auto-migrated to `autoContextEnabled: true` the first time the toggle helper runs and sees legacy pins.
+174
View File
@@ -0,0 +1,174 @@
# Tools & Workflows
## Tool Architecture
Tools are organized into three categories with role-based access control.
### Core Tools (All Agents)
Read-only graph operations available to orchestrators, executors, and planners:
- **queryNodes** - Search nodes by title/content/dimensions
- **getNodesById** - Retrieve full node data by ID array
- **queryEdge** - Inspect existing edges between nodes
- **searchContentEmbeddings** - Semantic search across chunk embeddings
### Orchestration Tools (Orchestrators Only)
Workflow and delegation tools for ra-h and ra-h-easy:
- **webSearch** - External web search via Tavily
- **think** - Internal reasoning/planning (logged to metadata)
- **executeWorkflow** - Delegate to wise-rah for predefined workflows
- **delegateToMiniRAH** - Spawn mini-rah worker for tasks (deprecated in favor of direct execution)
- **delegateToWiseRAH** - Delegate to wise-rah (now replaced by executeWorkflow)
### Execution Tools (Workers + Orchestrators)
Write operations and extraction - available to mini-rah, wise-rah, and orchestrators:
- **createNode** - Create new knowledge nodes
- **updateNode** - Append content to existing nodes (append-only enforced at tool level)
- **createEdge** - Create relationships between nodes
- **updateEdge** - Modify edge metadata
- **youtubeExtract** - Extract transcripts from YouTube videos
- **websiteExtract** - Extract content from web pages
- **paperExtract** - Extract text from PDF papers
## Tool Access by Agent
### ra-h / ra-h-easy (Orchestrators)
**Tools:** Core + Orchestration + Execution (minus delegation helpers)
- Direct write access (createNode, updateNode, createEdge, updateEdge)
- Extraction tools (youtube, website, paper)
- Workflow execution (executeWorkflow)
- External search (webSearch)
### wise-rah (Planner)
**Tools:** Core + webSearch + think + updateNode
- **Direct write access** via updateNode (append-only)
- **NO delegation** - executes workflows autonomously
- Database-wide search capabilities
- Minimal tool set for focused workflow execution
### mini-rah (Executor)
**Tools:** Core + Execution + webSearch + think
- All read tools
- All write tools (createNode, updateNode, createEdge, updateEdge)
- All extraction tools
- **NO delegation** - leaf workers only
## Tool Registry
**Location:** `/src/tools/infrastructure/registry.ts`
**Structure:**
```typescript
TOOL_SETS = {
core: { queryNodes, getNodesById, queryEdge, searchContentEmbeddings },
orchestration: { webSearch, think, delegateToMiniRAH, executeWorkflow, ... },
execution: { createNode, updateNode, createEdge, updateEdge, youtubeExtract, websiteExtract, paperExtract }
}
```
**Role mappings:**
- `ORCHESTRATOR_TOOL_NAMES` - Core + webSearch + think + executeWorkflow + Execution
- `EXECUTOR_TOOL_NAMES` - Core + Execution + webSearch + think (no delegation)
- `PLANNER_TOOL_NAMES` - Core + webSearch + think + updateNode
## Workflows
**Location:** `/src/services/workflows/registry.ts`
Workflows are **code-first** - defined in registry, not database. Users cannot create custom workflows.
### Integrate Workflow
**Key:** `integrate`
**Executor:** wise-rah (planner role)
**Purpose:** Database-wide connection discovery for focused node
**Cost:** ~$0.18/execution (GPT-5, 18 tool calls max)
**Process (5 steps):**
1. **Plan** - Call `think` to outline approach
2. **Ground** - Identify node type, extract entities (names, projects, concepts), summarize core insight
3. **Search** - Database-wide search using extracted entities
- Obvious connections: queryNodes for specific names/projects/techniques
- Thematic connections: searchContentEmbeddings for shared concepts
- Finds 3-8 strong connections (not 20 weak ones)
4. **Contextualize** - Brief 1-2 sentence relevance to pinned context
5. **Append** - Call updateNode ONCE with Integration Analysis section
**Output format:**
```markdown
## Integration Analysis
[2-3 sentences: what this node is, why it matters, core insight]
**Database Connections:**
- [NODE:123:"Title"] — [why: authorship/shared concept/dependency/contradiction]
- [NODE:456:"Title"] — [why: ...]
- [continue for 3-8 connections]
**Relevance:** [1-2 sentences connecting to user's pinned context]
```
**Key features:**
- **Database-first search** - Ignores pinned context during search (step 3), uses it only for relevance explanation (step 4)
- **Entity extraction** - Grounding step identifies searchable entities before searching
- **Append-only** - updateNode enforced at tool level (cannot overwrite)
- **Single update** - Calls updateNode EXACTLY once per workflow
- **Works for any node type** - Adapts to person/project/paper/idea/video/tweet/technique
**Invocation:**
```typescript
// User: "run integrate workflow"
// Orchestrator calls: executeWorkflow({ workflow_key: 'integrate' })
// System delegates to wise-rah with workflow instructions
```
## Workflow Registry
**Definition:**
```typescript
{
id: 1,
key: 'integrate',
displayName: 'Integrate',
description: 'Deep analysis and connection-building for focused node',
instructions: INTEGRATE_WORKFLOW_INSTRUCTIONS,
enabled: true,
requiresFocusedNode: true,
primaryActor: 'oracle',
expectedOutcome: 'Focused node updated with insights; 3-5 high-value edges created'
}
```
**Adding new workflows:**
1. Create instructions file in `/src/config/workflows/[name].ts`
2. Add workflow definition to `WorkflowRegistry.WORKFLOWS`
3. Immediately available to orchestrators (no database changes needed)
## Tool Execution Flow
**Orchestrator conversation:**
1. User sends message → ra-h/ra-h-easy
2. Agent calls tools directly (createNode, queryNodes, etc.)
3. Agent synthesizes response from tool results
4. Response includes [NODE:id:"title"] references for UI rendering
**Workflow execution:**
1. User: "run integrate workflow"
2. ra-h/ra-h-easy calls `executeWorkflow({ workflow_key: 'integrate' })`
3. System spawns wise-rah session with workflow instructions
4. wise-rah executes 5-step process autonomously
5. wise-rah returns summary to orchestrator
6. Orchestrator shows summary to user
**Delegation (legacy, rarely used):**
1. Orchestrator calls `delegateToMiniRAH({ task, context, expected_outcome })`
2. System creates agent_delegations row (status: 'queued')
3. Mini-rah spawned in isolated session
4. Mini-rah executes task, returns structured summary
5. Summary shown in delegation tab (persists until manually closed)
+152
View File
@@ -0,0 +1,152 @@
# Logging & Evals
## Logging System
RA-H uses a **trigger-based logging system** that automatically captures all database activity in the `logs` table.
### What Gets Logged
**Automatically logged via triggers:**
- **Node operations** - Create, update (via `trg_nodes_ai`, `trg_nodes_au`)
- **Edge operations** - Create, update (via `trg_edges_ai`, `trg_edges_au`)
- **Chat operations** - All conversations with token/cost metadata (via `trg_chats_ai`)
**Log structure:**
```typescript
{
id: number,
ts: timestamp,
table_name: 'nodes' | 'edges' | 'chats',
action: 'INSERT' | 'UPDATE',
row_id: number,
summary: string, // Human-readable description
snapshot_json: string, // Full row data as JSON
enriched_summary: string | null // Enhanced log entry
}
```
### Chat Metadata
Every chat log includes detailed execution metadata:
```typescript
metadata: {
// Token tracking
prompt_tokens: number,
completion_tokens: number,
reasoning_tokens: number,
total_tokens: number,
// Cost tracking
cost: number, // USD cost for this chat
// Tool usage
tools_used: string[], // Array of tool names called
// Workflow tracking
is_workflow: boolean,
workflow_key?: string,
workflow_node_id?: number,
// Model parameters
reasoning_effort?: 'low' | 'medium' | 'high',
// Execution trace
trace?: {
session_id: string,
parent_session_id?: string,
execution_time_ms: number
}
}
```
### Auto-Pruning
**Trigger:** `trg_logs_prune`
**Behavior:** Keeps last 10,000 log entries
**Runs:** After every INSERT to logs table
This prevents infinite database growth while preserving recent activity history.
### Enriched Logs View
**View:** `logs_v`
**Purpose:** Joins log entries with related data for readable activity feed
**Enrichment:**
- Node logs → show node title
- Edge logs → show from/to node titles
- Chat logs → show agent name, user/assistant message previews
## Settings Panel Visibility
**Location:** Settings → Logs tab
**Features:**
- **Real-time activity feed** - Shows last 100 log entries
- **Table filtering** - Filter by nodes/edges/chats
- **Action filtering** - Filter by INSERT/UPDATE
- **Detailed view** - Click to see full snapshot_json
- **Token/cost visibility** - Chat logs show usage and costs
- **Tool usage** - See which tools were called per chat
**Query:**
```sql
SELECT * FROM logs_v
ORDER BY ts DESC
LIMIT 100
```
## Cost Tracking
**Automatic cost calculation:**
- Every chat records token counts from LLM response
- Cost computed using model-specific pricing
- Stored in `chats.metadata.cost` (USD)
- Aggregated in Settings → Analytics
**Model pricing (as of v1.0):**
- GPT-5 Mini: $0.10/1M input, $0.40/1M output
- GPT-5: $2.50/1M input, $10.00/1M output
- GPT-4o Mini: $0.15/1M input, $0.60/1M output
- Claude Sonnet 4.5: $3.00/1M input, $15.00/1M output
**Typical costs:**
- Easy mode chat: $0.01-0.03
- Hard mode chat: $0.03-0.10
- Integrate workflow: ~$0.18
- Deep analysis: ~$0.33
## Token Analytics
**Settings → Analytics panel shows:**
- Total tokens used (all time)
- Total cost (USD)
- Breakdown by agent (ra-h, ra-h-easy, mini-rah, wise-rah)
- Breakdown by conversation thread
- Average cost per chat
**Query:**
```sql
SELECT
helper_name,
COUNT(*) as chat_count,
SUM(JSON_EXTRACT(metadata, '$.total_tokens')) as total_tokens,
SUM(JSON_EXTRACT(metadata, '$.cost')) as total_cost
FROM chats
WHERE metadata IS NOT NULL
GROUP BY helper_name
```
## Evaluation (Future)
**Planned features:**
- Edge quality ratings (user feedback via `edges.user_feedback`)
- Memory node relevance scoring
- Workflow success metrics
- Connection discovery quality
**Current state:**
- Infrastructure exists (`edges.user_feedback` column)
- UI not yet implemented
- Manual evaluation via logs table queries
+169
View File
@@ -0,0 +1,169 @@
# User Interface
## 3-Panel Layout
RA-H uses a fixed 3-panel desktop layout optimized for knowledge work.
### Left Panel: Nodes
**Purpose:** Browse and manage your knowledge base
**Features:**
- **Search bar** - Cmd+K global search modal
- **Dimension filters** - Multi-select dimension tags
- **Node list** - Scrollable list of filtered nodes
- **Quick actions** - Pin/unpin, delete, open in focus
**Node display:**
- Title + description preview
- Dimension tags
- Last updated timestamp
- Pin indicator
### Middle Panel: Focus
**Purpose:** Active workspace for current node(s)
**Tabbed interface:**
- **Primary tab** - Main focused node
- **Additional tabs** - Related nodes opened from conversations
- **Tab controls** - Close, reorder, switch
**Node detail view:**
- Full title and content
- Metadata (created, updated, type, link)
- Dimension tags (editable)
- Edge list (incoming/outgoing connections)
- Quick Add bar (bottom) - Create related nodes
**Content rendering:**
- Markdown support
- `[NODE:id:"title"]` auto-links to clickable node references
- Syntax highlighting for code blocks
- YouTube embeds (if link present)
### Right Panel: Helpers
**Purpose:** AI conversation interface
**Tabbed interface:**
- **ra-h tab** - Main orchestrator conversation
- **Delegation tabs** - Background worker tasks (mini-rah)
- **Tab lifecycle** - Manual close only (persist until user closes)
**Conversation view:**
- Message history (user + assistant)
- Tool call visibility (collapsed by default)
- Token/cost tracking per message
- Node references auto-linked
**Input controls:**
- Text input with Shift+Enter for multiline
- Submit button
- Mode toggle (⚡ Easy / 🔥 Hard)
- Thread reset button
**Mode switching:**
- Easy mode: GPT-5 Mini (fast, cheap)
- Hard mode: Claude Sonnet 4.5 (deep reasoning)
- Seamless mid-conversation switching
- localStorage persists user choice
## Settings Panel
**Access:** Settings icon (top-right)
**Tabs:**
1. **General** - App info, version, data location
2. **Agents** - View agent configurations (ra-h, ra-h-easy, mini-rah, wise-rah)
3. **Workflows** - Available workflows (integrate)
4. **Logs** - Activity feed (last 100 entries)
5. **Analytics** - Token usage and cost breakdown
6. **API Keys** - Configure Anthropic/OpenAI/Tavily keys (beta ships with embedded keys)
**Logs view:**
- Table/action filtering
- Timestamp, table, action, summary
- Detailed JSON snapshot on click
- Real-time updates
**Analytics view:**
- Total tokens used
- Total cost (USD)
- Breakdown by agent
- Breakdown by thread
- Average cost per chat
## Search (Cmd+K)
**Trigger:** Cmd+K keyboard shortcut
**4-tier relevance ranking:**
1. **Exact title match** - Highest priority
2. **Title substring** - High priority
3. **FTS content match** - Medium priority
4. **Semantic embedding** - Fallback for conceptual matches
**Features:**
- Type-ahead search
- Instant results (no search button)
- Click to open node in Focus panel
- Recent searches preserved (session only)
**UI:**
- Modal overlay
- Search input
- Results list (grouped by relevance tier)
- Keyboard navigation (arrow keys, Enter to select)
## Quick Add
**Location:** Bottom of Focus panel
**Purpose:** Rapidly create nodes related to current focus
**Flow:**
1. User types title in Quick Add input
2. Presses Enter
3. System creates node via `createNode` tool
4. Auto-creates edge from focused node to new node
5. New node opens in adjacent tab
**Features:**
- Single-field input (title only)
- Inherit dimensions from focused node
- Automatic edge creation with source='quick_add'
- Real-time feedback (loading state, success confirmation)
## Node References
**Format:** `[NODE:id:"title"]`
**Rendering:**
- Clickable labels in chat messages
- Clickable labels in node content
- Hover tooltip with node preview
- Click → open node in Focus panel
**Usage:**
- Agents automatically use this format
- Middleware converts to clickable UI elements
- Enables knowledge graph navigation from conversations
## Delegation Tabs
**Purpose:** Show background worker task progress
**Lifecycle:**
- Created when mini-rah delegated
- Persist until manually closed (no auto-cleanup)
- Show task, status, summary, result
- Tool calls visible (collapsed)
**Status indicators:**
- queued - Task waiting
- in_progress - Worker executing
- completed - Success
- failed - Error with details
**Close behavior:**
- Manual close only (X button)
- DELETE request to `/api/rah/delegations/[sessionId]`
- Permanent deletion from database
+45
View File
@@ -0,0 +1,45 @@
# RA-H MCP Connector Setup
The desktop app now ships with a local Model Context Protocol (MCP) server so any MCPcompatible assistant (Claude, ChatGPT, Gemini, Codex, etc.) can read/write your RA-H graph. Everything runs on `127.0.0.1` and never leaves your Mac.
## Quick Start
1. Launch the RA-H desktop app (it boots the Next.js sidecar + MCP bridge automatically).
2. Open **Settings → External Agents** inside RA-H and copy the connector URL (example: `http://127.0.0.1:44145/mcp`).
3. In Claude, ChatGPT, or any other assistant:
- open the MCP/connectors panel,
- choose **Add connector → HTTP**,
- paste the copied URL and name it “RA-H”.
4. Talk naturally. Examples:
- “Summarize this chat and add it to RA-H under Strategy + Q1 Execution.”
- “Search RA-H for what I already wrote about Apollo launch delays.”
The assistant calls two tools behind the scenes:
| Tool | Description |
| --- | --- |
| `rah_add_node` | Adds a new entry (title/content/dimensions) to the local SQLite graph and triggers the auto-embed queue. |
| `rah_search_nodes` | Searches existing nodes (title/content/dimensions) before deciding whether to create something new. |
## Guardrails
- The MCP server only binds to `127.0.0.1` and is meant for **your** agents. Do not expose it beyond your machine.
- Anything the assistant writes is immediately persisted to `~/Library/Application Support/RA-H/db/rah.sqlite`. Review the RA-H activity panel if something looks off.
- Disable the connector by setting `RAH_ENABLE_MCP=false` before launching the app (UI toggle coming soon).
- The `/status` endpoint returns health info if you need diagnostics: `curl http://127.0.0.1:44145/status`.
### Claude Desktop (STDIO Connector)
Claudes configuration window expects STDIO-based servers. To let Claude start a connector directly, point it at:
```
node /Users/<you>/Desktop/dev/ra-h/apps/mcp-server/stdio-server.js
```
This script speaks MCP over stdin/stdout (no HTTP listener), so Claude can manage it through `claude_desktop_config.json` or the “Add MCP Server” CLI flow. Keep the main RA-H app running so the STDIO bridge can call `http://127.0.0.1:3000/api/nodes`.
## Development Notes
- Implementation lives in `apps/mcp-server/server.js` (HTTP transport + tool definitions). It proxies through the existing `/api/nodes/*` routes, so validation + auto-embed behavior stays consistent.
- The Mac sidecar (`apps/mac/scripts/sidecar-launcher.js`) bootstraps the MCP server and keeps `~/Library/Application Support/RA-H/config/mcp-status.json` updated for the Settings panel/API.
- To run the server standalone (for MCP Inspector, etc.): `node apps/mcp-server/server.js` (requires the Next.js sidecar to be running so the API endpoints respond).
+9
View File
@@ -0,0 +1,9 @@
# RA-H BYO-Key Plan (Future Work)
The production repo remains private and powers the packaged Mac app. A separate, minimal open-source project will be created later for users who want to run RA-H with their own API keys. That future repo will include:
1. Basic three-panel UI
2. Local SQLite storage
3. Simple Settings → API Keys experience (no Supabase/subscription backend)
No active work is happening in this repo toward that goal right now. Track progress in `docs/development/prd-private-repo-reset.md`.
+21
View File
@@ -0,0 +1,21 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
experimental: {
serverActions: {
allowedOrigins: ['localhost:3000'],
},
},
devIndicators: false,
typescript: {
ignoreBuildErrors: false,
},
eslint: {
// Temporarily ignore lint during builds for beta packaging
// TODO: Fix remaining ~150 lint errors in follow-up PR
ignoreDuringBuilds: true,
},
}
module.exports = nextConfig
+9792
View File
File diff suppressed because it is too large Load Diff
+67
View File
@@ -0,0 +1,67 @@
{
"name": "ra-h-open-source",
"version": "0.1.0",
"private": false,
"description": "RA-H Open Source local-first research workspace with BYO API keys",
"license": "MIT",
"author": "Bradley Morris",
"scripts": {
"dev": "NEXT_PUBLIC_DEPLOYMENT_MODE=local NEXT_PUBLIC_ENABLE_SUBSCRIPTION_BACKEND=false NODE_OPTIONS=\"--dns-result-order=ipv4first\" next dev",
"clean:local": "bash ./scripts/dev/clean-local-artifacts.sh",
"audit:repo": "bash ./scripts/dev/audit-repo.sh",
"build": "NEXT_PUBLIC_DEPLOYMENT_MODE=local NEXT_PUBLIC_ENABLE_SUBSCRIPTION_BACKEND=false NODE_OPTIONS=\"--dns-result-order=ipv4first\" next build",
"start": "NEXT_PUBLIC_DEPLOYMENT_MODE=local NEXT_PUBLIC_ENABLE_SUBSCRIPTION_BACKEND=false NODE_OPTIONS=\"--dns-result-order=ipv4first\" next start",
"lint": "next lint",
"type-check": "tsc --noEmit",
"setup": "npm install",
"sqlite:backup": "bash scripts/database/sqlite-backup.sh",
"sqlite:restore": "bash scripts/database/sqlite-restore.sh"
},
"dependencies": {
"@ai-sdk/anthropic": "^2.0.27",
"@ai-sdk/openai": "^2.0.22",
"@ai-sdk/react": "^2.0.26",
"@langchain/core": "^1.0.1",
"@langchain/textsplitters": "^0.1.0",
"@radix-ui/react-checkbox": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-slot": "^1.1.0",
"ai": "^5.0.68",
"better-sqlite3": "^12.2.0",
"cheerio": "^1.1.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-react": "^0.468.0",
"next": "latest",
"next-themes": "^0.4.3",
"openai": "^4.103.0",
"pdf-parse": "^1.1.1",
"pdfjs-dist": "^5.4.296",
"react": "19.0.0",
"react-dom": "19.0.0",
"tailwind-merge": "^2.5.2",
"uuid": "^11.1.0",
"youtube-captions-scraper": "^2.0.3",
"youtube-transcript": "^1.2.1",
"youtube-transcript-plus": "^1.1.1",
"ytdl-core": "^4.11.5",
"zod": "^3.25.76"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "22.10.2",
"@types/react": "^19.0.2",
"@types/react-dom": "19.0.2",
"@types/uuid": "^10.0.0",
"autoprefixer": "^10.4.20",
"esbuild": "^0.23.1",
"eslint": "^8.57.0",
"eslint-config-next": "latest",
"postcss": "^8.4.49",
"prettier": "^3.3.3",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.7.2"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+68
View File
@@ -0,0 +1,68 @@
# RA-H Scripts
Utility scripts for managing the RA-H knowledge management system.
## Directory Structure
```
scripts/
├── database/ # Database management
│ ├── backup.sh # Create database backups
│ └── restore.sh # Restore from backups
├── helpers/ # Helper management (CRITICAL - used by API)
│ ├── duplicate-helper.js # Create new helpers
│ └── delete-helper.js # Remove helpers
└── migrations/ # Completed one-time migrations
└── cleanup-dimensions.sql
```
## Database Scripts
### Create a Backup
```bash
npm run backup
```
- Creates timestamped backup in `/backups/` folder
- Example: `rah_backup_20250902_102846.sql`
- Includes all nodes, chunks, edges, dimensions
### Restore from Backup
```bash
npm run restore backups/rah_backup_20250902_102846.sql
```
- ⚠️ **WARNING**: Replaces entire database
- Requires confirmation before proceeding
- Shows verification after restore
### List Backups
```bash
ls -lt backups/
```
## Helper Scripts
⚠️ **CRITICAL**: These scripts are used by the API at runtime. DO NOT modify or delete.
### Create New Helper
Called automatically by the API when creating helpers through the UI.
```bash
node scripts/helpers/duplicate-helper.js "HelperName"
```
### Delete Helper
Called automatically by the API when deleting helpers through the UI.
```bash
node scripts/helpers/delete-helper.js "helper-id"
```
## What Gets Backed Up
- All nodes (42,000+ knowledge items)
- Content chunks and embeddings
- Node connections/edges
- Dimensions and metadata
- Database schema and indexes
## Notes
- Backups typically ~250MB for 40k+ nodes
- Store backups safely - they contain your entire knowledge base
- Helper scripts are integrated with the application - modify with caution
+286
View File
@@ -0,0 +1,286 @@
#!/usr/bin/env bash
set -euo pipefail
# Build universal native dependencies (better-sqlite3 + sqlite-vec) for the packaged bundle.
# Relies on the universal Node runtime prepared in dist/runtime/node-universal.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
export PATH="$REPO_ROOT/scripts:$PATH"
DIST_ROOT="${DIST_ROOT:-$REPO_ROOT/dist/local-app}"
APP_DIR="${APP_DIR:-$DIST_ROOT/app}"
NODE_RUNTIME_DIR="${NODE_RUNTIME_DIR:-$REPO_ROOT/dist/runtime/node-universal}"
NODE_BIN="$NODE_RUNTIME_DIR/bin/node"
NPM_CLI="$NODE_RUNTIME_DIR/lib/node_modules/npm/bin/npm-cli.js"
SQLITE_VEC_REPO="${SQLITE_VEC_REPO:-https://github.com/asg017/sqlite-vec.git}"
SQLITE_VEC_REF="${SQLITE_VEC_REF:-main}"
SQLITE_VEC_SOURCE_DIR="${SQLITE_VEC_SOURCE_DIR:-$REPO_ROOT/dist/.deps/sqlite-vec-src}"
SQLITE_VEC_OUTPUT="${SQLITE_VEC_OUTPUT:-$DIST_ROOT/vendor/sqlite-extensions/vec0.dylib}"
SQLITE_VEC_VENDOR_COPY="${SQLITE_VEC_VENDOR_COPY:-$REPO_ROOT/vendor/sqlite-extensions/vec0.dylib}"
TMP_DIR="$(mktemp -d -t rah-native-XXXX)"
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
ensure_prerequisites() {
if [ ! -x "$NODE_BIN" ]; then
echo "❌ Universal Node runtime missing at $NODE_BIN" >&2
echo " Run scripts/build-universal-node.sh first." >&2
exit 1
fi
if [ ! -d "$APP_DIR" ]; then
echo "❌ Next.js standalone output missing at $APP_DIR" >&2
echo " Ensure scripts/build-production.sh has copied the app bundle." >&2
exit 1
fi
if [ ! -d "$APP_DIR/node_modules/better-sqlite3" ]; then
echo "❌ better-sqlite3 node module missing from $APP_DIR/node_modules." >&2
echo " Did you run npm run build before invoking this script?" >&2
exit 1
fi
if ! command -v arch >/dev/null 2>&1; then
echo "❌ arch command not found. Install Xcode command line tools." >&2
exit 1
fi
if arch -x86_64 /usr/bin/true >/dev/null 2>&1; then
:
else
echo "❌ Rosetta is required to build x86_64 binaries. Install with 'softwareupdate --install-rosetta'." >&2
exit 1
fi
if ! command -v lipo >/dev/null 2>&1; then
echo "❌ lipo not found. Install Xcode command line tools (xcode-select --install)." >&2
exit 1
fi
if ! command -v git >/dev/null 2>&1; then
echo "❌ git not found. Install git to fetch sqlite-vec source." >&2
exit 1
fi
}
ensure_sqlite_vec_source() {
if [ -d "$SQLITE_VEC_SOURCE_DIR/.git" ]; then
return
fi
echo "⬇️ Cloning sqlite-vec sources into $SQLITE_VEC_SOURCE_DIR"
mkdir -p "$(dirname "$SQLITE_VEC_SOURCE_DIR")"
git clone --depth 1 --branch "$SQLITE_VEC_REF" "$SQLITE_VEC_REPO" "$SQLITE_VEC_SOURCE_DIR"
}
refresh_better_sqlite3_sources() {
local root_module="$REPO_ROOT/node_modules/better-sqlite3"
local target_module="$APP_DIR/node_modules/better-sqlite3"
if [ ! -d "$root_module" ]; then
echo "❌ Root better-sqlite3 module missing at $root_module" >&2
echo " Run npm install in the repo root before packaging." >&2
exit 1
fi
rm -rf "$target_module"
mkdir -p "$target_module"
rsync -a "$root_module/" "$target_module/"
}
arch_prefix() {
local arch="$1"
if [ "$arch" = "x86_64" ]; then
echo "arch -x86_64"
else
echo "arch -arm64"
fi
}
codesign_if_available() {
local target="$1"
if ! command -v codesign >/dev/null 2>&1; then
return
fi
local identity="${CODESIGN_IDENTITY:--}"
local extra_opts=()
if [ "${CODESIGN_HARDENED_RUNTIME:-1}" = "1" ]; then
extra_opts=(--options runtime)
fi
local codesign_cmd=(codesign --force --timestamp --sign "$identity" "${extra_opts[@]}" "$target")
"${codesign_cmd[@]}" >/dev/null 2>&1 || \
codesign --force --timestamp --sign - "${extra_opts[@]}" "$target" >/dev/null 2>&1 || true
}
rebuild_better_sqlite3_for_arch() {
local arch="$1"
local output="$TMP_DIR/better_sqlite3.${arch}.node"
echo "⚙️ Rebuilding better-sqlite3 for ${arch}"
rm -rf "$APP_DIR/node_modules/better-sqlite3/build"
local arch_cmd
arch_cmd=($(arch_prefix "$arch"))
(
cd "$APP_DIR"
PATH="$NODE_RUNTIME_DIR/bin:$PATH" \
npm_config_build_from_source=true \
npm_config_fallback_to_build=false \
npm_config_arch="$arch" \
npm_config_target_arch="$arch" \
CFLAGS="-arch $arch" \
CXXFLAGS="-arch $arch" \
LDFLAGS="-arch $arch" \
"${arch_cmd[@]}" "$NODE_BIN" "$NPM_CLI" rebuild better-sqlite3 --build-from-source
)
if [ ! -f "$APP_DIR/node_modules/better-sqlite3/build/Release/better_sqlite3.node" ]; then
echo "❌ Failed to build better-sqlite3 for ${arch}" >&2
exit 1
fi
cp "$APP_DIR/node_modules/better-sqlite3/build/Release/better_sqlite3.node" "$output"
}
combine_better_sqlite3() {
local arm_file="$TMP_DIR/better_sqlite3.arm64.node"
local x64_file="$TMP_DIR/better_sqlite3.x86_64.node"
local target="$APP_DIR/node_modules/better-sqlite3/build/Release/better_sqlite3.node"
lipo -create "$arm_file" "$x64_file" -output "$target"
codesign_if_available "$target"
rm -f "$APP_DIR/node_modules/better-sqlite3/build/Release/test_extension.node"
echo "✓ better-sqlite3 universal binary ready at $target"
}
build_sqlite_vec_for_arch() {
local arch="$1"
local output="$TMP_DIR/vec0.${arch}.dylib"
echo "⚙️ Building sqlite-vec (vec0) for ${arch}"
local include_dir="${SQLITE3_INCLUDE_DIR:-}"
local lib_dir="${SQLITE3_LIB_DIR:-}"
if { [ -z "$include_dir" ] || [ -z "$lib_dir" ]; } && command -v brew >/dev/null 2>&1; then
local brew_prefix
brew_prefix="$(brew --prefix sqlite 2>/dev/null || true)"
if [ -n "$brew_prefix" ]; then
if [ -z "$include_dir" ] && [ -d "$brew_prefix/include" ]; then
include_dir="$brew_prefix/include"
fi
if [ -z "$lib_dir" ] && [ -d "$brew_prefix/lib" ]; then
lib_dir="$brew_prefix/lib"
fi
fi
fi
local cflags="-O3 -fPIC -arch $arch -undefined dynamic_lookup"
if [ -n "$include_dir" ]; then
cflags="$cflags -I${include_dir}"
fi
if [ -n "$lib_dir" ]; then
cflags="$cflags -L${lib_dir}"
fi
local arch_cmd
arch_cmd=($(arch_prefix "$arch"))
local -a env_vars=(
"CC=clang"
"CFLAGS=$cflags"
)
if [ -n "$include_dir" ] || [ -n "$lib_dir" ]; then
env_vars+=("USE_BREW_SQLITE=1")
if [ -n "$include_dir" ]; then
env_vars+=("SQLITE_INCLUDE_PATH=-I${include_dir}")
fi
if [ -n "$lib_dir" ]; then
env_vars+=("SQLITE_LIB_PATH=-L${lib_dir}")
fi
fi
(
cd "$SQLITE_VEC_SOURCE_DIR"
make clean >/dev/null 2>&1 || true
"${arch_cmd[@]}" env "${env_vars[@]}" make loadable
)
local built_path="$SQLITE_VEC_SOURCE_DIR/dist/vec0.dylib"
if [ ! -f "$built_path" ]; then
echo "❌ sqlite-vec build failed for ${arch}. Inspect make output above." >&2
exit 1
fi
mv "$built_path" "$output"
}
combine_sqlite_vec() {
mkdir -p "$(dirname "$SQLITE_VEC_OUTPUT")"
mkdir -p "$(dirname "$SQLITE_VEC_VENDOR_COPY")"
lipo -create \
"$TMP_DIR/vec0.arm64.dylib" \
"$TMP_DIR/vec0.x86_64.dylib" \
-output "$SQLITE_VEC_OUTPUT"
cp "$SQLITE_VEC_OUTPUT" "$SQLITE_VEC_VENDOR_COPY"
codesign_if_available "$SQLITE_VEC_OUTPUT"
codesign_if_available "$SQLITE_VEC_VENDOR_COPY"
echo "✓ sqlite-vec universal dylib ready at $SQLITE_VEC_OUTPUT"
}
codesign_additional_binaries() {
local -a extras=(
"$DIST_ROOT/bin/yt-dlp"
"$APP_DIR/node_modules/@img/sharp-darwin-arm64/lib/sharp-darwin-arm64.node"
"$APP_DIR/node_modules/@img/sharp-darwin-x64/lib/sharp-darwin-x64.node"
)
for target in "${extras[@]}"; do
if [ -e "$target" ]; then
codesign_if_available "$target"
fi
done
local glob
for glob in \
"$APP_DIR/node_modules/@img/sharp-libvips-darwin-arm64/lib/"* \
"$APP_DIR/node_modules/@img/sharp-libvips-darwin-x64/lib/"*; do
if [ -e "$glob" ]; then
codesign_if_available "$glob"
fi
done
}
main() {
ensure_prerequisites
ensure_sqlite_vec_source
refresh_better_sqlite3_sources
rebuild_better_sqlite3_for_arch arm64
rebuild_better_sqlite3_for_arch x86_64
combine_better_sqlite3
build_sqlite_vec_for_arch arm64
build_sqlite_vec_for_arch x86_64
combine_sqlite_vec
codesign_additional_binaries
}
main "$@"
+580
View File
@@ -0,0 +1,580 @@
#!/usr/bin/env bash
set -euo pipefail
# Orchestrate the production build assets required by the Tauri bundle.
# 1. Builds the Next.js standalone output.
# 2. Prepares the universal Node runtime.
# 3. Copies resources into dist/local-app for the sidecar.
# 4. Rebuilds native modules as universal binaries.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
DIST_ROOT="$REPO_ROOT/dist/local-app"
RUNTIME_ROOT="$REPO_ROOT/dist/runtime/node-universal"
STAGING_ROOT="$REPO_ROOT/dist/.staging/production"
SEED_SOURCE="$REPO_ROOT/dist/resources/rah_seed.sqlite"
NEXT_BUILD_DIR="$REPO_ROOT/.next"
resolve_public_env_value() {
local var_name="$1"
local fallback="$2"
local value="${!var_name:-}"
if [ -z "$value" ] && [ -f "$REPO_ROOT/.env" ]; then
value="$(grep -E "^${var_name}=" "$REPO_ROOT/.env" | tail -n 1 | cut -d '=' -f2- || true)"
fi
if [ -z "$value" ]; then
value="$fallback"
fi
printf '%s' "$value"
}
ensure_prerequisites() {
if ! command -v npm >/dev/null 2>&1; then
echo "❌ npm not found. Install Node.js before running this script." >&2
exit 1
fi
if ! command -v rsync >/dev/null 2>&1; then
echo "❌ rsync is required (brew install rsync)." >&2
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
echo "️ jq not found; version.json will omit git metadata." >&2
fi
}
prepare_next_standalone() {
echo "▶️ Building Next.js standalone output"
if [ -d "$DIST_ROOT" ]; then
echo " • Clearing previous dist at $DIST_ROOT"
rm -rf "$DIST_ROOT"
fi
if [ -f "$REPO_ROOT/tsconfig.tsbuildinfo" ]; then
echo " • Removing stale tsconfig build info"
rm -f "$REPO_ROOT/tsconfig.tsbuildinfo"
fi
echo " • Using node: $(command -v node)"
echo " • Node version: $(node -v)"
echo " • Node module ABI: $(node -p 'process.versions.modules')"
local enable_subscriptions backend_url supabase_url supabase_anon app_url
enable_subscriptions="$(resolve_public_env_value "NEXT_PUBLIC_ENABLE_SUBSCRIPTION_BACKEND" "true")"
backend_url="$(resolve_public_env_value "NEXT_PUBLIC_BACKEND_URL" "https://api.ra-h.app")"
supabase_url="$(resolve_public_env_value "NEXT_PUBLIC_SUPABASE_URL" "https://wabhzavwgsizrkjpnryd.supabase.co")"
supabase_anon="$(resolve_public_env_value "NEXT_PUBLIC_SUPABASE_ANON_KEY" "")"
app_url="$(resolve_public_env_value "NEXT_PUBLIC_APP_URL" "https://ra-h.app")"
NEXT_PUBLIC_DEPLOYMENT_MODE=cloud \
NEXT_PUBLIC_ENABLE_SUBSCRIPTION_BACKEND="${enable_subscriptions}" \
NEXT_PUBLIC_BACKEND_URL="${backend_url}" \
NEXT_PUBLIC_SUPABASE_URL="${supabase_url}" \
NEXT_PUBLIC_SUPABASE_ANON_KEY="${supabase_anon}" \
NEXT_PUBLIC_APP_URL="${app_url}" \
NODE_ENV=production npm run build
if [ ! -d "$NEXT_BUILD_DIR/standalone" ]; then
echo "❌ Next.js standalone output missing at .next/standalone" >&2
exit 1
fi
}
prepare_directories() {
rm -rf "$DIST_ROOT"
rm -rf "$STAGING_ROOT"
mkdir -p "$DIST_ROOT/app"
mkdir -p "$DIST_ROOT/bin"
mkdir -p "$DIST_ROOT/lib"
mkdir -p "$DIST_ROOT/vendor/sqlite-extensions"
mkdir -p "$DIST_ROOT/resources"
mkdir -p "$DIST_ROOT/logs"
mkdir -p "$STAGING_ROOT"
}
write_splash_bootstrap() {
echo "🛰 Writing packaged splash bootstrap with instrumentation"
local build_id
build_id="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
cat <<'HTML' >"$DIST_ROOT/index.html"
<!DOCTYPE html>
<html lang="en" data-build="__BUILD_ID__">
<head>
<meta charset="utf-8" />
<title>RA-H</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
:root { color-scheme: dark; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #09090b;
color: #f8fafc;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.status { text-align: center; letter-spacing: 0.02em; max-width: 480px; }
.status h1 { font-size: 1.5rem; margin: 0 0 0.5rem; }
.status p { margin: 0 0 0.75rem; color: #94a3b8; line-height: 1.4; }
.status pre {
text-align: left;
background: rgba(148, 163, 184, 0.08);
border-radius: 0.75rem;
padding: 0.75rem;
font-family: "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 0.75rem;
max-height: 18rem;
overflow-y: auto;
margin: 0;
}
</style>
</head>
<body data-build="__BUILD_ID__">
<div class="status">
<h1 id="status-title">Starting RA-H…</h1>
<p id="status-message">Bootstrap script pending (<span id="status-build">__BUILD_ID__</span>).</p>
<pre id="status-log">[0 ms] Splash HTML loaded (build __BUILD_ID__)
</pre>
</div>
<script>
(function () {
const BUILD_ID = "__BUILD_ID__";
const start = performance.now();
const titleEl = document.getElementById('status-title');
const messageEl = document.getElementById('status-message');
const logEl = document.getElementById('status-log');
const state = {
polls: 0,
maxLogs: 300
};
const sinceStart = () => Math.round(performance.now() - start);
const appendLog = (label, detail = '') => {
const stamp = `[${sinceStart()} ms] ${label}`;
const suffix = detail ? ` :: ${detail}` : '';
if (logEl.textContent.split('\n').length > state.maxLogs) {
logEl.textContent = `${logEl.textContent.split('\n').slice(-state.maxLogs / 2).join('\n')}\n`;
}
logEl.textContent += `${stamp}${suffix}\n`;
logEl.scrollTop = logEl.scrollHeight;
try { console.log(`[splash:${BUILD_ID}] ${label}`, detail); } catch (_) {}
};
const updateStatus = (heading, text) => {
if (heading) titleEl.textContent = heading;
if (text) messageEl.textContent = text;
};
const navigateToPort = (port, source) => {
if (!port) {
appendLog('navigateToPort invoked with empty port', source);
return false;
}
const url = `http://127.0.0.1:${port}`;
updateStatus('Connecting…', `Requesting navigation to ${url}`);
appendLog('Attempting window.location.replace', `${url} via ${source}`);
try {
window.location.replace(url);
return true;
} catch (error) {
appendLog('window.location.replace threw', String(error ?? 'unknown error'));
}
try {
window.location.href = url;
appendLog('Fallback window.location.href dispatched', url);
return true;
} catch (error) {
appendLog('window.location.href threw', String(error ?? 'unknown error'));
}
return false;
};
const checkGlobalPort = (source) => {
const port = window.__RAH_SIDECAR_PORT;
if (port) {
appendLog('Detected global port', `${port} via ${source}`);
navigateToPort(port, source);
return true;
}
return false;
};
window.addEventListener('rah:sidecar-port', (event) => {
appendLog('Event: rah:sidecar-port', JSON.stringify(event?.detail));
navigateToPort(event?.detail, 'custom-event');
});
const tryAttachTauri = () => {
const api = window.__TAURI__;
if (!api || !api.tauri || !api.event) {
appendLog('Tauri APIs unavailable', 'Monitoring native dispatcher');
return;
}
appendLog('__TAURI__ detected', Object.keys(api).join(', '));
updateStatus('Tauri runtime detected', 'Listening for sidecar events.');
try {
api.event.listen('sidecar://port', (event) => {
appendLog('Event: sidecar://port', JSON.stringify(event?.payload));
navigateToPort(event?.payload, 'tauri-event');
}).catch((error) => appendLog('Failed to attach Tauri event listener', String(error ?? 'unknown error')));
} catch (error) {
appendLog('Error wiring Tauri listener', String(error ?? 'unknown error'));
}
try {
api.tauri.invoke('get_sidecar_port')
.then((existingPort) => {
appendLog('invoke(get_sidecar_port) resolved', JSON.stringify(existingPort));
if (existingPort) {
navigateToPort(existingPort, 'tauri-invoke');
} else {
updateStatus('Awaiting sidecar…', 'Will redirect once port event arrives.');
}
})
.catch((error) => {
appendLog('invoke(get_sidecar_port) rejected', String(error ?? 'unknown error'));
});
} catch (error) {
appendLog('Error invoking get_sidecar_port', String(error ?? 'unknown error'));
}
};
appendLog('Bootstrap script executing');
updateStatus('Initialising…', `Build ${BUILD_ID} starting up.`);
tryAttachTauri();
const pollForPort = () => {
if (checkGlobalPort(`poll-${state.polls}`)) {
return;
}
if (state.polls % 10 === 0) {
appendLog('Polling for native port', `attempt ${state.polls}`);
}
state.polls += 1;
setTimeout(pollForPort, 500);
};
if (!checkGlobalPort('initial')) {
pollForPort();
}
})();
</script>
</body>
</html>
HTML
python3 - <<PY
from pathlib import Path
path = Path(r"$DIST_ROOT/index.html")
content = path.read_text()
content = content.replace("__BUILD_ID__", "$build_id")
path.write_text(content)
PY
# Mirror splash bootstrap at dist/index.html so Tauri can locate web assets
mkdir -p "$REPO_ROOT/dist"
cp "$DIST_ROOT/index.html" "$REPO_ROOT/dist/index.html"
}
sync_next_output() {
echo "📦 Copying Next.js standalone output into dist/local-app"
rsync -a "$NEXT_BUILD_DIR/standalone/" "$DIST_ROOT/app/"
if [ -d "$NEXT_BUILD_DIR/static" ]; then
rsync -a "$NEXT_BUILD_DIR/static/" "$DIST_ROOT/app/.next/static/"
fi
# Next.js 15 places the shared vendor chunks outside of the standalone bundle.
if [ -d "$NEXT_BUILD_DIR/server/vendor-chunks" ]; then
echo " • Copying Next.js vendor chunks"
mkdir -p "$DIST_ROOT/app/.next/server/vendor-chunks"
rsync -a "$NEXT_BUILD_DIR/server/vendor-chunks/" "$DIST_ROOT/app/.next/server/vendor-chunks/"
fi
if [ -d "$REPO_ROOT/public" ]; then
rsync -a "$REPO_ROOT/public/" "$DIST_ROOT/app/public/"
fi
rm -f "$DIST_ROOT/app/src/helpers/"*.json 2>/dev/null || true
}
copy_seed_database() {
if [ ! -f "$SEED_SOURCE" ]; then
echo "❌ Seed database missing at $SEED_SOURCE" >&2
echo " Run scripts/database/generate-seed.sh to create it." >&2
exit 1
fi
cp "$SEED_SOURCE" "$DIST_ROOT/resources/rah_seed.sqlite"
}
prepare_node_runtime() {
echo "⚙️ Preparing universal Node runtime"
"$REPO_ROOT/scripts/build-universal-node.sh"
if [ ! -x "$RUNTIME_ROOT/bin/node" ]; then
echo "❌ Universal Node runtime missing at $RUNTIME_ROOT/bin/node" >&2
exit 1
fi
rsync -a "$RUNTIME_ROOT/bin/node" "$DIST_ROOT/bin/node"
chmod +x "$DIST_ROOT/bin/node"
# Ensure supporting libraries accompany the binary.
if [ -d "$RUNTIME_ROOT/lib" ]; then
rsync -a "$RUNTIME_ROOT/lib/" "$DIST_ROOT/lib/"
fi
}
sign_node_runtime() {
local node_bin="$DIST_ROOT/bin/node"
if [ ! -x "$node_bin" ]; then
echo "⚠️ Node runtime not found at $node_bin; skipping signing"
return
fi
if ! command -v codesign >/dev/null 2>&1; then
echo "⚠️ codesign not available; skipping Node runtime signing"
return
fi
local identity="Developer ID Application: Bradley Morris (HTMNQ7JM3H)"
local entitlements="$REPO_ROOT/apps/mac/src-tauri/entitlements-node.plist"
if ! security find-identity -v -p codesigning 2>/dev/null | grep -q "$identity"; then
echo "❌ Signing identity '$identity' not found; cannot produce a release build" >&2
exit 1
fi
echo "🔏 Signing Node runtime with $identity"
codesign --force --options runtime --timestamp --entitlements "$entitlements" --sign "$identity" "$node_bin"
}
stage_sidecar_launcher_script() {
local launcher_js="$REPO_ROOT/apps/mac/scripts/sidecar-launcher.js"
local destination="$DIST_ROOT/bin/sidecar-launcher.js"
if [ ! -f "$launcher_js" ]; then
echo "❌ sidecar-launcher.js missing at $launcher_js" >&2
exit 1
fi
mkdir -p "$(dirname "$destination")"
cp "$launcher_js" "$destination"
}
bundle_mcp_bridge() {
local server_entry="$REPO_ROOT/apps/mcp-server/server.js"
local stdio_entry="$REPO_ROOT/apps/mcp-server/stdio-server.js"
local out_dir="$DIST_ROOT/mcp-server"
if [ ! -f "$server_entry" ]; then
echo "❌ MCP server entry not found at $server_entry" >&2
exit 1
fi
mkdir -p "$out_dir"
echo "🧩 Bundling MCP HTTP bridge"
npx esbuild "$server_entry" \
--bundle \
--platform=node \
--target=node20 \
--format=cjs \
--outfile="$out_dir/server.js"
if [ -f "$stdio_entry" ]; then
echo "🧩 Bundling MCP STDIO bridge"
npx esbuild "$stdio_entry" \
--bundle \
--platform=node \
--target=node20 \
--format=cjs \
--banner:js="#!/usr/bin/env node" \
--outfile="$out_dir/stdio-server.js"
chmod +x "$out_dir/stdio-server.js"
fi
}
sign_native_modules() {
if ! command -v codesign >/dev/null 2>&1; then
echo "⚠️ codesign not available; skipping native module signing"
return
fi
local identity="Developer ID Application: Bradley Morris (HTMNQ7JM3H)"
local entitlements="$REPO_ROOT/apps/mac/src-tauri/entitlements-node.plist"
if ! security find-identity -v -p codesigning 2>/dev/null | grep -q "$identity"; then
echo "❌ Signing identity '$identity' not found; cannot produce a release build" >&2
exit 1
fi
local modules=(
"$DIST_ROOT/app/node_modules/better-sqlite3/build/Release/better_sqlite3.node"
"$DIST_ROOT/vendor/sqlite-extensions/vec0.dylib"
)
local vec_app_path="$DIST_ROOT/app/vendor/sqlite-extensions/vec0.dylib"
if [ -f "$vec_app_path" ]; then
modules+=("$vec_app_path")
fi
if [ -d "$DIST_ROOT/app/node_modules/@img" ]; then
while IFS= read -r module_path; do
modules+=("$module_path")
done < <(find "$DIST_ROOT/app/node_modules/@img" -type f \( -name "*.node" -o -name "*.dylib" \))
fi
for module_path in "${modules[@]}"; do
if [ -f "$module_path" ]; then
echo "🔏 Signing native module $(basename "$module_path")"
chmod +x "$module_path" 2>/dev/null || true
codesign --force --options runtime --timestamp --entitlements "$entitlements" --sign "$identity" "$module_path"
else
echo "⚠️ Native module not found at $module_path; skipping"
fi
done
}
prepare_vendor_assets() {
if [ -f "$REPO_ROOT/vendor/sqlite-extensions/vec0.dylib" ]; then
cp "$REPO_ROOT/vendor/sqlite-extensions/vec0.dylib" "$DIST_ROOT/vendor/sqlite-extensions/vec0.dylib"
fi
if [ -x "$REPO_ROOT/vendor/bin/yt-dlp" ]; then
mkdir -p "$DIST_ROOT/bin"
cp "$REPO_ROOT/vendor/bin/yt-dlp" "$DIST_ROOT/bin/yt-dlp"
chmod +x "$DIST_ROOT/bin/yt-dlp"
fi
}
sign_sidecar_launcher() {
local launcher="$REPO_ROOT/apps/mac/scripts/sidecar-launcher"
if [ ! -x "$launcher" ]; then
echo "⚠️ Sidecar launcher missing or not executable at $launcher"
return
fi
if ! command -v codesign >/dev/null 2>&1; then
echo "⚠️ codesign not available; skipping launcher signing"
return
fi
local identity="Developer ID Application: Bradley Morris (HTMNQ7JM3H)"
local entitlements="$REPO_ROOT/apps/mac/src-tauri/entitlements.plist"
if ! security find-identity -v -p codesigning 2>/dev/null | grep -q "$identity"; then
echo "❌ Signing identity '$identity' not found; cannot produce a release build" >&2
exit 1
fi
echo "🔏 Signing sidecar launcher with $identity"
codesign --force --options runtime --timestamp --entitlements "$entitlements" --sign "$identity" "$launcher"
}
sign_vendor_tools() {
local identity="Developer ID Application: Bradley Morris (HTMNQ7JM3H)"
if ! security find-identity -v -p codesigning 2>/dev/null | grep -q "$identity"; then
echo "❌ Signing identity '$identity' not found; cannot produce a release build" >&2
exit 1
fi
local yt_dlp="$DIST_ROOT/bin/yt-dlp"
if [ -x "$yt_dlp" ]; then
echo "🔏 Signing vendor tool $(basename "$yt_dlp")"
codesign --force --options runtime --timestamp --sign "$identity" "$yt_dlp"
fi
}
write_version_metadata() {
local version
if [ -n "${VERSION:-}" ]; then
version="$VERSION"
elif command -v jq >/dev/null 2>&1; then
version="$(jq -r '.version' < "$REPO_ROOT/package.json" 2>/dev/null || echo "0.0.0")"
else
version="$(node -p "require('./package.json').version" 2>/dev/null || echo "0.0.0")"
fi
local build_date
build_date="$(date +%Y%m%d)"
local git_sha
git_sha="$(git -C "$REPO_ROOT" rev-parse --short HEAD 2>/dev/null || echo unknown)"
local node_version
if [ -x "$RUNTIME_ROOT/bin/node" ]; then
node_version="$("$RUNTIME_ROOT/bin/node" -v 2>/dev/null || echo "vUnknown")"
else
node_version="v20.11.0"
fi
cat >"$DIST_ROOT/version.json" <<EOF
{
"version": "${version}",
"build_date": "${build_date}",
"node_version": "${node_version}",
"git_commit": "${git_sha}"
}
EOF
}
prepare_env_stub() {
local enable_subscriptions
enable_subscriptions="$(resolve_public_env_value "NEXT_PUBLIC_ENABLE_SUBSCRIPTION_BACKEND" "true")"
local deployment_mode
deployment_mode="cloud"
if [ "${NEXT_PUBLIC_DEPLOYMENT_MODE:-}" = "local" ]; then
echo "⚠️ Forcing NEXT_PUBLIC_DEPLOYMENT_MODE=cloud for production build" >&2
fi
local backend_url
backend_url="$(resolve_public_env_value "NEXT_PUBLIC_BACKEND_URL" "https://api.ra-h.app")"
local supabase_url
supabase_url="$(resolve_public_env_value "NEXT_PUBLIC_SUPABASE_URL" "https://wabhzavwgsizrkjpnryd.supabase.co")"
local supabase_anon
supabase_anon="$(resolve_public_env_value "NEXT_PUBLIC_SUPABASE_ANON_KEY" "")"
if [ -z "$supabase_anon" ]; then
echo "⚠️ NEXT_PUBLIC_SUPABASE_ANON_KEY not set in environment or .env; packaged app may not authenticate." >&2
fi
cat >"$DIST_ROOT/.env.production" <<EOF
# Production runtime configuration (public environment values only)
NODE_ENV=production
NEXT_PUBLIC_ENABLE_SUBSCRIPTION_BACKEND=${enable_subscriptions}
NEXT_PUBLIC_BACKEND_URL=${backend_url}
NEXT_PUBLIC_SUPABASE_URL=${supabase_url}
NEXT_PUBLIC_SUPABASE_ANON_KEY=${supabase_anon}
NEXT_PUBLIC_DEPLOYMENT_MODE=${deployment_mode}
EOF
}
build_native_modules() {
echo "⚙️ Building universal native modules"
"$REPO_ROOT/scripts/build-native-modules.sh"
}
main() {
ensure_prerequisites
prepare_next_standalone
prepare_directories
write_splash_bootstrap
sync_next_output
copy_seed_database
prepare_node_runtime
stage_sidecar_launcher_script
bundle_mcp_bridge
sign_node_runtime
prepare_vendor_assets
sign_sidecar_launcher
sign_vendor_tools
write_version_metadata
prepare_env_stub
build_native_modules
sign_native_modules
echo ""
echo "✅ Production payload staged in $DIST_ROOT"
}
main "$@"
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env bash
set -euo pipefail
# Build a universal (arm64 + x86_64) Node.js runtime staged inside dist/runtime/node-universal.
# The script downloads the official macOS tarballs for each architecture, combines the Mach-O
# binaries with lipo, and leaves the result ready to be copied into the packaged app.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
NODE_VERSION="${NODE_VERSION:-20.11.0}"
CACHE_ROOT="${NODE_CACHE_DIR:-$HOME/.cache/ra-h/node}"
OUTPUT_ROOT="${NODE_OUTPUT_DIR:-$REPO_ROOT/dist/runtime/node-universal}"
STAGING_ROOT="${NODE_STAGING_DIR:-$REPO_ROOT/dist/.staging/node-universal}"
ARM_ARCH="arm64"
X64_ARCH="x64"
ARM_TARBALL="node-v${NODE_VERSION}-darwin-${ARM_ARCH}"
X64_TARBALL="node-v${NODE_VERSION}-darwin-${X64_ARCH}"
ensure_prerequisites() {
if ! command -v curl >/dev/null 2>&1; then
echo "❌ curl not found. Install curl before running this script." >&2
exit 1
fi
if ! command -v tar >/dev/null 2>&1; then
echo "❌ tar not found. Install GNU tar / BSD tar before running this script." >&2
exit 1
fi
if ! command -v rsync >/dev/null 2>&1; then
echo "❌ rsync not found. Install rsync before running this script." >&2
exit 1
fi
if ! command -v lipo >/dev/null 2>&1; then
echo "❌ lipo not found. Install Xcode command line tools (xcode-select --install)." >&2
exit 1
fi
}
download_node() {
local tarball_name="$1"
local archive_dir="$CACHE_ROOT/${tarball_name}"
if [ -x "${archive_dir}/bin/node" ]; then
echo "✓ Node ${tarball_name} cached at ${archive_dir}"
return
fi
mkdir -p "$CACHE_ROOT"
local url="https://nodejs.org/dist/v${NODE_VERSION}/${tarball_name}.tar.gz"
echo "⬇️ Downloading ${url}"
tmp_archive="$(mktemp -d -t node-archive-XXXX)"
trap 'rm -rf "$tmp_archive"' EXIT
curl -fsSL "$url" | tar xz -C "$tmp_archive"
mv "${tmp_archive}/${tarball_name}" "$archive_dir"
rm -rf "$tmp_archive"
trap - EXIT
echo "✓ Node ${tarball_name} cached"
}
prepare_staging() {
rm -rf "$OUTPUT_ROOT"
rm -rf "$STAGING_ROOT"
mkdir -p "$STAGING_ROOT"
mkdir -p "$OUTPUT_ROOT"
}
copy_arch_tree() {
local src="$1"
local dest="$2"
mkdir -p "$(dirname "$dest")"
rsync -a --delete "$src/" "$dest/"
}
create_universal_binary() {
local relative_path="$1"
local arm_file="${STAGING_ROOT}/${ARM_TARBALL}/${relative_path}"
local x64_file="${STAGING_ROOT}/${X64_TARBALL}/${relative_path}"
local output_file="${OUTPUT_ROOT}/${relative_path}"
if [ ! -f "$arm_file" ] || [ ! -f "$x64_file" ]; then
return
fi
mkdir -p "$(dirname "$output_file")"
lipo -create "$arm_file" "$x64_file" -output "$output_file"
}
walk_and_merge_binaries() {
local pattern='*'
(
cd "${STAGING_ROOT}/${ARM_TARBALL}"
find . -type f | while read -r relative; do
if file "${relative}" 2>/dev/null | grep -q "Mach-O"; then
create_universal_binary "${relative#./}"
fi
done
)
}
finalize_assets() {
# Copy everything from arm64 tree first (text files, scripts, npm, etc.)
copy_arch_tree "${STAGING_ROOT}/${ARM_TARBALL}" "$OUTPUT_ROOT"
# Merge Mach-O binaries/dylibs with lipo (overwrites the copies above).
walk_and_merge_binaries
chmod +x "$OUTPUT_ROOT/bin/node"
echo "node" >"$OUTPUT_ROOT/.arch"
}
print_summary() {
echo ""
echo "✅ Universal Node runtime prepared:"
echo " - Version: v${NODE_VERSION}"
echo " - Output : ${OUTPUT_ROOT}"
echo " - Includes npm CLI at ${OUTPUT_ROOT}/lib/node_modules/npm/"
echo ""
lipo -info "$OUTPUT_ROOT/bin/node"
}
main() {
ensure_prerequisites
download_node "$ARM_TARBALL"
download_node "$X64_TARBALL"
prepare_staging
copy_arch_tree "$CACHE_ROOT/$ARM_TARBALL" "$STAGING_ROOT/$ARM_TARBALL"
copy_arch_tree "$CACHE_ROOT/$X64_TARBALL" "$STAGING_ROOT/$X64_TARBALL"
finalize_assets
print_summary
}
main "$@"
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env node
// Script to create Supabase database schema using the REST API
// This works around the issue with direct SQL execution via psql
const https = require('https');
const SUPABASE_URL = process.env.SUPABASE_URL;
const SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!SUPABASE_URL || !SERVICE_KEY) {
console.error('Missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY environment variables.');
process.exit(1);
}
// SQL statements to execute
const sqlStatements = [
// Create subscriptions table
`CREATE TABLE IF NOT EXISTS public.subscriptions (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID NOT NULL,
stripe_subscription_id TEXT UNIQUE,
stripe_customer_id TEXT,
tier TEXT NOT NULL CHECK (tier IN ('free', 'lite', 'pro', 'max')),
status TEXT NOT NULL CHECK (status IN ('active', 'canceled', 'past_due', 'trialing', 'incomplete')),
current_period_start TIMESTAMPTZ,
current_period_end TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL
);`,
// Create usage_events table
`CREATE TABLE IF NOT EXISTS public.usage_events (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID NOT NULL,
timestamp TIMESTAMPTZ DEFAULT NOW() NOT NULL,
model TEXT NOT NULL,
tokens_prompt INTEGER NOT NULL DEFAULT 0,
tokens_completion INTEGER NOT NULL DEFAULT 0,
cost_usd DECIMAL(10,6) NOT NULL DEFAULT 0,
endpoint TEXT NOT NULL,
provider TEXT NOT NULL CHECK (provider IN ('openai', 'anthropic', 'tavily')),
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL
);`,
// Create indexes
`CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON public.subscriptions(user_id);`,
`CREATE INDEX IF NOT EXISTS idx_usage_events_user_id ON public.usage_events(user_id);`
];
function executeSQL(sql) {
return new Promise((resolve, reject) => {
const postData = JSON.stringify({ query: sql });
const options = {
hostname: new URL(SUPABASE_URL).hostname,
port: 443,
path: '/rest/v1/rpc/query',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'apikey': SERVICE_KEY,
'Authorization': `Bearer ${SERVICE_KEY}`,
'Content-Length': Buffer.byteLength(postData)
}
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(data);
} else {
reject(new Error(`HTTP ${res.statusCode}: ${data}`));
}
});
});
req.on('error', (e) => {
reject(e);
});
req.write(postData);
req.end();
});
}
async function createSchema() {
console.log('Creating Supabase schema...');
for (let i = 0; i < sqlStatements.length; i++) {
const sql = sqlStatements[i];
try {
console.log(`Executing statement ${i + 1}/${sqlStatements.length}...`);
const result = await executeSQL(sql);
console.log('✓ Success');
} catch (error) {
console.error(`✗ Failed: ${error.message}`);
// Don't exit on error, continue with next statement
}
}
console.log('Schema creation complete!');
}
createSchema().catch(console.error);
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
OUTPUT_PATH="${1:-$REPO_ROOT/dist/resources/rah_seed.sqlite}"
SQLITE_SCRIPT="$REPO_ROOT/scripts/database/sqlite-ensure-app-schema.sh"
if [ ! -x "$SQLITE_SCRIPT" ]; then
echo "Error: $SQLITE_SCRIPT not found or not executable" >&2
exit 1
fi
mkdir -p "$(dirname "$OUTPUT_PATH")"
rm -f "$OUTPUT_PATH" "$OUTPUT_PATH-wal" "$OUTPUT_PATH-shm"
sqlite3 "$OUTPUT_PATH" "VACUUM;" >/dev/null 2>&1 || true
bash "$SQLITE_SCRIPT" "$OUTPUT_PATH"
echo "✅ Seed database ready at $OUTPUT_PATH"
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
set -euo pipefail
# RA-H SQLite backup using VACUUM INTO
# Safer behavior: auto-read .env.local for SQLITE_DB_PATH, verify integrity, print table counts.
# Resolve DB path in this order:
# 1) $SQLITE_DB_PATH (env)
# 2) ./.env.local → SQLITE_DB_PATH
# 3) Default dev path under ~/Library
DB_PATH=${SQLITE_DB_PATH:-}
if [ -z "${DB_PATH}" ] && [ -f ".env.local" ]; then
# Extract only the SQLITE_DB_PATH line (supports values with spaces)
DB_PATH=$(grep -E '^SQLITE_DB_PATH=' .env.local | sed 's/^SQLITE_DB_PATH=//' | sed 's/^"\(.*\)"$/\1/') || true
fi
if [ -z "${DB_PATH}" ]; then
DB_PATH="$HOME/Library/Application Support/RA-H/db/rah.sqlite"
fi
# Normalize and validate
if [ ! -f "$DB_PATH" ]; then
echo "❌ Error: Resolved DB not found: $DB_PATH" >&2
echo "Hint: Set SQLITE_DB_PATH in .env.local or export it inline: SQLITE_DB_PATH=\"/full/path/rah.sqlite\" npm run sqlite:backup" >&2
exit 1
fi
echo "Resolved DB path: $DB_PATH"
BACKUP_DIR="$(dirname "$0")/../backups"
mkdir -p "$BACKUP_DIR"
TS=$(date +"%Y%m%d_%H%M%S")
BASENAME="rah_backup_${TS}.sqlite"
DEST="$BACKUP_DIR/$BASENAME"
echo "Backing up → $DEST"
sqlite3 "$DB_PATH" <<SQL
PRAGMA optimize;
VACUUM INTO '$DEST';
SQL
echo "Verifying snapshot integrity..."
ICHECK=$(sqlite3 "$DEST" "PRAGMA integrity_check;")
echo " $ICHECK"
if [ "$ICHECK" != "ok" ]; then
echo "❌ Snapshot integrity check failed" >&2
exit 2
fi
echo "Snapshot counts:"
sqlite3 "$DEST" "SELECT 'nodes',COUNT(*) FROM nodes UNION ALL SELECT 'edges',COUNT(*) FROM edges UNION ALL SELECT 'chunks',COUNT(*) FROM chunks;" | sed 's/^/ /'
SIZE=$(du -h "$DEST" | awk '{print $1}')
echo "✅ Backup complete: $BASENAME ($SIZE)"
echo "Recent backups:"
ls -lht "$BACKUP_DIR"/*.sqlite 2>/dev/null | head -5 || echo " (none)"
+579
View File
@@ -0,0 +1,579 @@
#!/usr/bin/env bash
set -euo pipefail
DB_PATH=${1:-rah_trial.db}
if [ ! -f "$DB_PATH" ]; then
echo "Error: Database file not found: $DB_PATH" >&2
exit 1
fi
if command -v brew >/dev/null 2>&1; then
SQLITE_BIN="$(brew --prefix sqlite 2>/dev/null)/bin/sqlite3"
[ -x "$SQLITE_BIN" ] || SQLITE_BIN="sqlite3"
else
SQLITE_BIN="sqlite3"
fi
echo "Using sqlite: $($SQLITE_BIN --version)"
has_col() {
local table=$1 col=$2
"$SQLITE_BIN" "$DB_PATH" -json \
"PRAGMA table_info($table);" | \
grep -q "\"name\":\s*\"$col\""
}
has_table() {
local table=$1
"$SQLITE_BIN" "$DB_PATH" -json \
"SELECT name FROM sqlite_master WHERE type='table' AND name='$table';" | \
grep -q "$table"
}
has_view() {
local view=$1
"$SQLITE_BIN" "$DB_PATH" -json \
"SELECT name FROM sqlite_master WHERE type='view' AND name='$view';" | \
grep -q "$view"
}
has_trigger() {
local trg=$1
"$SQLITE_BIN" "$DB_PATH" -json \
"SELECT name FROM sqlite_master WHERE type='trigger' AND name='$trg';" | \
grep -q "$trg"
}
echo "Ensuring agents table exists and orchestrator is seeded..."
# Rename legacy helpers table if present
if has_table helpers && ! has_table agents; then
"$SQLITE_BIN" "$DB_PATH" "ALTER TABLE helpers RENAME TO agents;"
fi
if ! has_table agents; then
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
CREATE TABLE agents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT UNIQUE NOT NULL,
display_name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'executor',
system_prompt TEXT NOT NULL,
available_tools TEXT NOT NULL,
model TEXT NOT NULL,
description TEXT,
enabled INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
memory TEXT,
prompts TEXT
);
SQL
fi
if has_table agents && ! has_col agents role; then
"$SQLITE_BIN" "$DB_PATH" "ALTER TABLE agents ADD COLUMN role TEXT NOT NULL DEFAULT 'executor';"
fi
if has_table agents && ! has_col agents prompts; then
"$SQLITE_BIN" "$DB_PATH" "ALTER TABLE agents ADD COLUMN prompts TEXT DEFAULT '[]';"
fi
if has_table agents && ! has_col agents memory; then
"$SQLITE_BIN" "$DB_PATH" "ALTER TABLE agents ADD COLUMN memory TEXT;"
fi
COUNT_AGENTS=$("$SQLITE_BIN" -readonly "$DB_PATH" "SELECT COUNT(*) FROM agents;" 2>/dev/null || echo 0)
if [ "${COUNT_AGENTS:-0}" = "0" ]; then
echo " Seeding default orchestrator agent (ra-h)..."
NOW=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
TOOLS_JSON='["queryNodes","createNode","updateNode","createEdge","queryEdge","updateEdge","searchContentEmbeddings","webSearch","think","delegateToMiniRAH"]'
PROMPTS_JSON='[{"id":"p_seed_0","name":"Summary of Focus","content":"Summarize the primary focused node clearly. Include 35 key points and cite [NODE:id:\"title\"]."},{"id":"p_seed_1","name":"Next Steps","content":"Propose 3 concrete next actions based on the focused nodes with references to [NODE:id:\"title\"]."}]'
SYSTEM_PROMPT="You are ra-h, the main orchestrator for RA-H. Coordinate work, delegate to mini ra-hs when tasks can be isolated, and keep the conversation focused on the user's goals."
ESCAPED_SYSTEM_PROMPT=${SYSTEM_PROMPT//\'/''}
"$SQLITE_BIN" "$DB_PATH" <<SQL
INSERT INTO agents(key, display_name, role, system_prompt, available_tools, model, description, enabled, created_at, updated_at, prompts)
VALUES (
'ra-h',
'ra-h',
'orchestrator',
'$ESCAPED_SYSTEM_PROMPT',
'$TOOLS_JSON',
'anthropic/claude-sonnet-4.5',
'Opinionated orchestrator agent',
1,
'$NOW',
'$NOW',
'$PROMPTS_JSON'
);
SQL
fi
echo "Ensuring core tables exist (nodes, chunks, edges, chats, node_dimensions)..."
if ! has_table nodes; then
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
CREATE TABLE nodes (
id INTEGER PRIMARY KEY,
title TEXT,
description TEXT,
content TEXT,
link TEXT,
type TEXT,
created_at TEXT,
updated_at TEXT,
metadata TEXT,
chunk TEXT,
embedding BLOB,
embedding_updated_at TEXT,
embedding_text TEXT,
chunk_status TEXT DEFAULT 'not_chunked'
);
SQL
fi
if ! has_table chunks; then
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
CREATE TABLE chunks (
id INTEGER PRIMARY KEY,
node_id INTEGER NOT NULL,
chunk_idx INTEGER,
text TEXT,
created_at TEXT,
embedding_type TEXT DEFAULT 'text-embedding-3-small',
metadata TEXT,
FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE
);
CREATE INDEX idx_chunks_by_node ON chunks(node_id);
CREATE INDEX idx_chunks_by_node_idx ON chunks(node_id, chunk_idx);
SQL
fi
if ! has_table edges; then
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
CREATE TABLE edges (
id INTEGER PRIMARY KEY,
from_node_id INTEGER NOT NULL,
to_node_id INTEGER NOT NULL,
source TEXT,
created_at TEXT,
context TEXT,
user_feedback INTEGER,
FOREIGN KEY (from_node_id) REFERENCES nodes(id) ON DELETE CASCADE,
FOREIGN KEY (to_node_id) REFERENCES nodes(id) ON DELETE CASCADE
);
CREATE INDEX idx_edges_from ON edges(from_node_id);
CREATE INDEX idx_edges_to ON edges(to_node_id);
SQL
fi
echo "Dropping legacy episodic/semantic memory tables if they exist..."
if has_table chats; then
if "$SQLITE_BIN" "$DB_PATH" -json "PRAGMA table_info(chats);" | grep -q 'focused_memory_id'; then
echo " Removing legacy chats.focused_memory_id column"
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION;
ALTER TABLE chats RENAME TO chats_legacy_cleanup;
CREATE TABLE chats (
id INTEGER PRIMARY KEY,
chat_type TEXT,
helper_name TEXT,
agent_type TEXT DEFAULT 'orchestrator',
delegation_id INTEGER,
user_message TEXT,
assistant_message TEXT,
thread_id TEXT,
focused_node_id INTEGER,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP),
metadata TEXT,
FOREIGN KEY (focused_node_id) REFERENCES nodes(id) ON DELETE SET NULL
);
INSERT INTO chats (
id, chat_type, helper_name, agent_type, delegation_id,
user_message, assistant_message, thread_id, focused_node_id,
created_at, metadata
)
SELECT id, chat_type, helper_name, agent_type, delegation_id,
user_message, assistant_message, thread_id, focused_node_id,
created_at, metadata
FROM chats_legacy_cleanup;
DROP TABLE chats_legacy_cleanup;
CREATE INDEX IF NOT EXISTS idx_chats_thread ON chats(thread_id);
COMMIT;
PRAGMA foreign_keys=ON;
SQL
fi
fi
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
DROP TRIGGER IF EXISTS trg_episodic_prune;
DROP TABLE IF EXISTS episodic_memory;
DROP TABLE IF EXISTS episodic_pipeline_state;
DROP TABLE IF EXISTS semantic_memory;
DROP TABLE IF EXISTS semantic_pipeline_state;
DROP TABLE IF EXISTS memory_pipeline_state;
DROP TABLE IF EXISTS memory;
SQL
echo "Removing deprecated context_versions table (if present)..."
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
DROP TABLE IF EXISTS context_versions;
DROP INDEX IF EXISTS idx_context_created;
SQL
if ! has_table chats; then
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
CREATE TABLE chats (
id INTEGER PRIMARY KEY,
chat_type TEXT,
helper_name TEXT,
agent_type TEXT DEFAULT 'orchestrator',
delegation_id INTEGER,
user_message TEXT,
assistant_message TEXT,
thread_id TEXT,
focused_node_id INTEGER,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP),
metadata TEXT,
FOREIGN KEY (focused_node_id) REFERENCES nodes(id) ON DELETE SET NULL
);
CREATE INDEX idx_chats_thread ON chats(thread_id);
SQL
fi
if has_table chats && ! has_col chats agent_type; then
"$SQLITE_BIN" "$DB_PATH" "ALTER TABLE chats ADD COLUMN agent_type TEXT DEFAULT 'orchestrator';"
fi
if has_table chats && ! has_col chats delegation_id; then
"$SQLITE_BIN" "$DB_PATH" "ALTER TABLE chats ADD COLUMN delegation_id INTEGER;"
fi
if ! has_table chat_memory_state; then
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
CREATE TABLE chat_memory_state (
thread_id TEXT PRIMARY KEY,
helper_name TEXT,
last_processed_chat_id INTEGER DEFAULT 0,
last_processed_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_chat_memory_thread ON chat_memory_state(thread_id);
SQL
fi
echo "Ensuring agent_delegations table exists..."
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
CREATE TABLE IF NOT EXISTS agent_delegations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT UNIQUE NOT NULL,
task TEXT NOT NULL,
context TEXT,
expected_outcome TEXT,
status TEXT NOT NULL DEFAULT 'queued',
summary TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
SQL
if ! has_table node_dimensions; then
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
CREATE TABLE node_dimensions (
node_id INTEGER NOT NULL,
dimension TEXT NOT NULL,
PRIMARY KEY (node_id, dimension),
FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE
) WITHOUT ROWID;
CREATE INDEX idx_dim_by_dimension ON node_dimensions(dimension, node_id);
CREATE INDEX idx_dim_by_node ON node_dimensions(node_id, dimension);
SQL
fi
echo "Checking/adding missing columns..."
if has_table nodes; then
if ! has_col nodes type; then
echo "Adding nodes.type"
"$SQLITE_BIN" "$DB_PATH" "ALTER TABLE nodes ADD COLUMN type TEXT;"
fi
if ! has_col nodes description; then
echo "Adding nodes.description"
"$SQLITE_BIN" "$DB_PATH" "ALTER TABLE nodes ADD COLUMN description TEXT;"
fi
if ! has_col nodes metadata; then
echo "Adding nodes.metadata"
"$SQLITE_BIN" "$DB_PATH" "ALTER TABLE nodes ADD COLUMN metadata TEXT;"
fi
if ! has_col nodes chunk; then
echo "Adding nodes.chunk"
"$SQLITE_BIN" "$DB_PATH" "ALTER TABLE nodes ADD COLUMN chunk TEXT;"
fi
if ! has_col nodes is_pinned; then
echo "Adding nodes.is_pinned"
"$SQLITE_BIN" "$DB_PATH" "ALTER TABLE nodes ADD COLUMN is_pinned INTEGER DEFAULT 0;"
fi
fi
if has_table chunks; then
if ! has_col chunks embedding_type; then
echo "Adding chunks.embedding_type"
"$SQLITE_BIN" "$DB_PATH" "ALTER TABLE chunks ADD COLUMN embedding_type TEXT DEFAULT 'text-embedding-3-small';"
fi
if ! has_col chunks metadata; then
echo "Adding chunks.metadata"
"$SQLITE_BIN" "$DB_PATH" "ALTER TABLE chunks ADD COLUMN metadata TEXT;"
fi
fi
echo "Ensuring dimensions table exists..."
if ! has_table dimensions; then
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
CREATE TABLE dimensions (
name TEXT PRIMARY KEY,
is_priority INTEGER DEFAULT 0,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
SQL
fi
if has_table dimensions; then
echo "Seeding default locked dimensions..."
for dimension in research ideas projects memory preferences; do
"$SQLITE_BIN" "$DB_PATH" <<SQL
INSERT INTO dimensions (name, is_priority, updated_at)
VALUES ('$dimension', 1, datetime('now'))
ON CONFLICT(name) DO UPDATE SET is_priority = 1, updated_at = datetime('now');
SQL
done
fi
echo "Refreshing helper view nodes_v..."
"$SQLITE_BIN" "$DB_PATH" "DROP VIEW IF EXISTS nodes_v;"
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
CREATE VIEW nodes_v AS
SELECT n.id,
n.title,
n.description,
n.content,
n.link,
n.type,
n.metadata,
n.created_at,
n.updated_at,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d
WHERE d.node_id = n.id), '[]') AS dimensions_json
FROM nodes n;
SQL
echo "Ensuring logs table and triggers exist (migrating from memory if needed)..."
# migrate memory -> logs if needed
if ! has_table logs && has_table memory; then
echo "Dropping view memory_v (if exists) to unlock table rename..."
"$SQLITE_BIN" "$DB_PATH" "DROP VIEW IF EXISTS memory_v;"
echo "Renaming memory -> logs..."
"$SQLITE_BIN" "$DB_PATH" "ALTER TABLE memory RENAME TO logs;"
fi
# logs table
if ! has_table logs; then
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
CREATE TABLE logs (
id INTEGER PRIMARY KEY,
ts TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP),
table_name TEXT NOT NULL,
action TEXT NOT NULL,
row_id INTEGER NOT NULL,
summary TEXT,
enriched_summary TEXT,
snapshot_json TEXT
);
SQL
fi
# Add enriched_summary column if missing
if has_table logs && ! has_col logs enriched_summary; then
"$SQLITE_BIN" "$DB_PATH" "ALTER TABLE logs ADD COLUMN enriched_summary TEXT;"
fi
# indexes on logs (cleanup legacy names first)
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
DROP INDEX IF EXISTS idx_memory_ts;
DROP INDEX IF EXISTS idx_memory_table_ts;
DROP INDEX IF EXISTS idx_memory_table_row;
CREATE INDEX IF NOT EXISTS idx_logs_ts ON logs(ts);
CREATE INDEX IF NOT EXISTS idx_logs_table_ts ON logs(table_name, ts);
CREATE INDEX IF NOT EXISTS idx_logs_table_row ON logs(table_name, row_id);
SQL
# triggers for nodes (drop/recreate to ensure enriched payloads)
if has_table nodes; then
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
DROP TRIGGER IF EXISTS trg_nodes_ai;
DROP TRIGGER IF EXISTS trg_nodes_au;
CREATE TRIGGER trg_nodes_ai AFTER INSERT ON nodes BEGIN
INSERT INTO logs(table_name, action, row_id, summary, snapshot_json)
VALUES('nodes', 'insert', NEW.id,
printf('node created: %s', COALESCE(NEW.title,'')),
json_object(
'id', NEW.id,
'title', NEW.title,
'link', NEW.link
)
);
END;
CREATE TRIGGER trg_nodes_au AFTER UPDATE ON nodes BEGIN
INSERT INTO logs(table_name, action, row_id, summary, snapshot_json)
VALUES('nodes', 'update', NEW.id,
printf('node updated: %s', COALESCE(NEW.title,'')),
json_object(
'id', NEW.id,
'title', NEW.title,
'link', NEW.link
)
);
END;
SQL
fi
# triggers for edges (enriched with node titles, truncated)
if has_table edges; then
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
DROP TRIGGER IF EXISTS trg_edges_ai;
DROP TRIGGER IF EXISTS trg_edges_au;
CREATE TRIGGER trg_edges_ai AFTER INSERT ON edges BEGIN
INSERT INTO logs(table_name, action, row_id, summary, snapshot_json)
VALUES('edges', 'insert', NEW.id,
printf('edge %d→%d (%s)', NEW.from_node_id, NEW.to_node_id, COALESCE(NEW.source,'')),
json_object(
'id', NEW.id,
'from', NEW.from_node_id,
'to', NEW.to_node_id,
'source', NEW.source,
'from_title', substr((SELECT title FROM nodes WHERE id = NEW.from_node_id), 1, 120),
'to_title', substr((SELECT title FROM nodes WHERE id = NEW.to_node_id), 1, 120)
)
);
END;
CREATE TRIGGER trg_edges_au AFTER UPDATE ON edges BEGIN
INSERT INTO logs(table_name, action, row_id, summary, snapshot_json)
VALUES('edges', 'update', NEW.id,
printf('edge updated %d→%d', NEW.from_node_id, NEW.to_node_id),
json_object(
'id', NEW.id,
'from', NEW.from_node_id,
'to', NEW.to_node_id,
'source', NEW.source,
'from_title', substr((SELECT title FROM nodes WHERE id = NEW.from_node_id), 1, 120),
'to_title', substr((SELECT title FROM nodes WHERE id = NEW.to_node_id), 1, 120)
)
);
END;
-- Add trigger to auto-update node updated_at timestamps when edges are created
DROP TRIGGER IF EXISTS trg_edges_update_nodes_on_insert;
CREATE TRIGGER trg_edges_update_nodes_on_insert
AFTER INSERT ON edges
BEGIN
UPDATE nodes SET updated_at = strftime('%Y-%m-%dT%H:%M:%f', 'now') || 'Z' WHERE id = NEW.from_node_id;
UPDATE nodes SET updated_at = strftime('%Y-%m-%dT%H:%M:%f', 'now') || 'Z' WHERE id = NEW.to_node_id;
END;
SQL
fi
# trigger for chats (enriched with content previews, truncated)
if has_table chats; then
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
DROP TRIGGER IF EXISTS trg_chats_ai;
CREATE TRIGGER trg_chats_ai AFTER INSERT ON chats BEGIN
INSERT INTO logs(table_name, action, row_id, summary, snapshot_json)
VALUES('chats', 'insert', NEW.id,
printf('chat: %s (%s)', COALESCE(NEW.helper_name,''), COALESCE(NEW.thread_id,'')),
json_object(
'id', NEW.id,
'helper', NEW.helper_name,
'thread', NEW.thread_id,
'user_preview', substr(NEW.user_message, 1, 120),
'assistant_preview', substr(NEW.assistant_message, 1, 120)
)
);
END;
SQL
fi
# Add trigger to auto-prune logs to keep only most recent 10k entries
if has_table logs; then
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
DROP TRIGGER IF EXISTS trg_logs_prune;
CREATE TRIGGER trg_logs_prune AFTER INSERT ON logs BEGIN
DELETE FROM logs WHERE id < NEW.id - 10000;
END;
SQL
fi
echo "Ensuring logs_v view exists (removing legacy memory_v)..."
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
DROP VIEW IF EXISTS logs_v;
DROP VIEW IF EXISTS memory_v;
CREATE VIEW logs_v AS
SELECT
m.id,
m.ts,
m.table_name,
m.action,
m.row_id,
m.summary,
m.enriched_summary,
m.snapshot_json,
CASE WHEN m.table_name='nodes' THEN n.title END AS node_title,
CASE WHEN m.table_name='edges' THEN nf.title END AS edge_from_title,
CASE WHEN m.table_name='edges' THEN nt.title END AS edge_to_title,
CASE WHEN m.table_name='chats' THEN c.helper_name END AS chat_helper,
CASE WHEN m.table_name='chats' THEN substr(c.user_message,1,120) END AS chat_user_preview,
CASE WHEN m.table_name='chats' THEN substr(c.assistant_message,1,120) END AS chat_assistant_preview,
CASE WHEN m.table_name='chats' THEN c.user_message END AS chat_user_full,
CASE WHEN m.table_name='chats' THEN c.assistant_message END AS chat_assistant_full
FROM logs m
LEFT JOIN nodes n ON (m.table_name='nodes' AND m.row_id = n.id)
LEFT JOIN edges e ON (m.table_name='edges' AND m.row_id = e.id)
LEFT JOIN nodes nf ON e.from_node_id = nf.id
LEFT JOIN nodes nt ON e.to_node_id = nt.id
LEFT JOIN chats c ON (m.table_name='chats' AND m.row_id = c.id);
SQL
echo "Ensuring helpers.memory exists and is populated from fluid_context (if present)..."
if has_table helpers; then
if ! has_col helpers memory; then
"$SQLITE_BIN" "$DB_PATH" "ALTER TABLE helpers ADD COLUMN memory TEXT;"
if has_col helpers fluid_context; then
"$SQLITE_BIN" "$DB_PATH" "UPDATE helpers SET memory = fluid_context WHERE fluid_context IS NOT NULL;"
fi
fi
else
echo " helpers table not present; skipping helpers.memory migration"
fi
echo "Updating helper available_tools to use updateHelperMemory (renaming from updateHelperFluidContext)..."
if has_table helpers; then
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
UPDATE helpers
SET available_tools = REPLACE(available_tools, 'updateHelperFluidContext', 'updateHelperMemory')
WHERE available_tools LIKE '%updateHelperFluidContext%';
SQL
else
echo " helpers table not present; skipping available_tools rename"
fi
echo "Dropping helpers.fluid_context column if present (post-migration cleanup)..."
if has_table helpers && has_col helpers fluid_context; then
"$SQLITE_BIN" "$DB_PATH" "ALTER TABLE helpers DROP COLUMN fluid_context;" || true
fi
echo "Running VACUUM and ANALYZE..."
"$SQLITE_BIN" "$DB_PATH" "VACUUM; ANALYZE;"
echo "Done. Schema is compatible."
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
set -euo pipefail
# Restore RA-H SQLite database from a backup snapshot
if [ $# -lt 1 ]; then
echo "Usage: $0 <backup.sqlite>" >&2
exit 1
fi
SRC="$1"
if [ ! -f "$SRC" ]; then
echo "Backup file not found: $SRC" >&2
exit 1
fi
# Resolve target DB path in this order:
# 1) $SQLITE_DB_PATH (env)
# 2) ./.env.local → SQLITE_DB_PATH
# 3) Default dev path under ~/Library
DB_PATH=${SQLITE_DB_PATH:-}
if [ -z "${DB_PATH}" ] && [ -f ".env.local" ]; then
DB_PATH=$(grep -E '^SQLITE_DB_PATH=' .env.local | sed 's/^SQLITE_DB_PATH=//' | sed 's/^"\(.*\)"$/\1/') || true
fi
if [ -z "${DB_PATH}" ]; then
DB_PATH="$HOME/Library/Application Support/RA-H/db/rah.sqlite"
fi
DB_DIR="$(dirname "$DB_PATH")"
mkdir -p "$DB_DIR"
echo "Source backup: $SRC"
echo "Target DB: $DB_PATH"
echo "Verifying backup integrity before restore..."
ICHECK=$(sqlite3 "$SRC" "PRAGMA integrity_check;")
echo " $ICHECK"
if [ "$ICHECK" != "ok" ]; then
echo "❌ Backup integrity check failed — aborting restore" >&2
exit 2
fi
TS=$(date +"%Y%m%d_%H%M%S")
SAFE_COPY="$DB_DIR/rah_pre_restore_${TS}.sqlite"
echo "Creating safety copy: $SAFE_COPY"
cp -f "$DB_PATH" "$SAFE_COPY" 2>/dev/null || true
echo "Restoring backup..."
cp -f "$SRC" "$DB_PATH"
echo "Integrity check on restored DB..."
RCHECK=$(sqlite3 "$DB_PATH" "PRAGMA integrity_check;")
echo " $RCHECK"
if [ "$RCHECK" != "ok" ]; then
echo "❌ Restored DB integrity check failed" >&2
exit 3
fi
echo "Restored counts:"
sqlite3 "$DB_PATH" "SELECT 'nodes',COUNT(*) FROM nodes UNION ALL SELECT 'edges',COUNT(*) FROM edges UNION ALL SELECT 'chunks',COUNT(*) FROM chunks;" | sed 's/^/ /'
echo "✅ Restore complete."
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail
DB_PATH=${1:-rah_trial.db}
if [ ! -f "$DB_PATH" ]; then
echo "Error: Database file not found: $DB_PATH" >&2
exit 1
fi
if command -v brew >/dev/null 2>&1; then
SQLITE_BIN="$(brew --prefix sqlite 2>/dev/null)/bin/sqlite3"
if [ ! -x "$SQLITE_BIN" ]; then
SQLITE_BIN="sqlite3"
fi
else
SQLITE_BIN="sqlite3"
fi
echo "Using sqlite3 at: $SQLITE_BIN"
"$SQLITE_BIN" --version
"$SQLITE_BIN" "$DB_PATH" < "$(dirname "$0")/sqlite-verify.sql"
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
RED='\033[0;31m'
GREEN='\033[0;32m'
NC='\033[0m'
TMP_FILE="$(mktemp)"
printf "[audit] scanning for accidental API keys (sk-)...\n"
if rg --hidden --no-messages --files-with-matches "sk-" \
--glob '!.git/**' --glob '!node_modules/**' --glob '!dist/**' --glob '!apps/mac/src-tauri/target/**' \
> "$TMP_FILE"; then
printf "${RED}Found potential secrets:${NC}\n"
cat "$TMP_FILE"
rm -f "$TMP_FILE"
exit 1
else
status=$?
if [ "$status" -ne 1 ]; then
rm -f "$TMP_FILE"
exit "$status"
fi
printf "${GREEN}No sk- tokens found.${NC}\n"
fi
rm -f "$TMP_FILE"
printf "[audit] checking tracked files > 50MB...\n"
LARGE_FILES=$(git ls-tree -r HEAD --long | awk '$4 > 52428800 {printf "%s\t%s\n", $4, $5}')
if [ -n "$LARGE_FILES" ]; then
printf "${RED}Large tracked files detected (>50MB):${NC}\n%s\n" "$LARGE_FILES"
exit 1
else
printf "${GREEN}No tracked blobs exceed 50MB.${NC}\n"
fi
printf "[audit] done.\n"
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
ENV_TEMPLATE="${REPO_DIR}/.env.example.local"
TARGET_ENV="${REPO_DIR}/.env.local"
SQLITE_SEED_SCRIPT="${REPO_DIR}/scripts/database/sqlite-ensure-app-schema.sh"
log() {
echo "[bootstrap-local] $1"
}
if [ ! -f "$ENV_TEMPLATE" ]; then
echo "Error: ${ENV_TEMPLATE} not found. Make sure .env.example.local exists." >&2
exit 1
fi
if [ -f "$TARGET_ENV" ]; then
log ".env.local already exists; leaving it untouched."
else
log "Creating .env.local from template..."
cp "$ENV_TEMPLATE" "$TARGET_ENV"
fi
SQLITE_DB_PATH_LINE=$(grep -E '^SQLITE_DB_PATH=' "$TARGET_ENV" | tail -n 1 || true)
DEFAULT_DB_PATH="$HOME/Library/Application Support/RA-H/db/rah.sqlite"
if [ -z "$SQLITE_DB_PATH_LINE" ]; then
SQLITE_DB_PATH="$DEFAULT_DB_PATH"
else
SQLITE_DB_PATH="${SQLITE_DB_PATH_LINE#SQLITE_DB_PATH=}"
fi
# Expand variables like $HOME or ~
EXPANDED_DB_PATH=$(eval "echo \"$SQLITE_DB_PATH\"")
DB_DIR=$(dirname "$EXPANDED_DB_PATH")
log "Ensuring database directory exists: $DB_DIR"
mkdir -p "$DB_DIR"
if [ ! -f "$EXPANDED_DB_PATH" ]; then
log "Creating empty SQLite database at $EXPANDED_DB_PATH"
: > "$EXPANDED_DB_PATH"
else
log "SQLite database already exists at $EXPANDED_DB_PATH"
fi
if [ ! -x "$SQLITE_SEED_SCRIPT" ]; then
echo "Error: $SQLITE_SEED_SCRIPT not found or not executable" >&2
exit 1
fi
log "Seeding database schema via sqlite-ensure-app-schema.sh"
"$SQLITE_SEED_SCRIPT" "$EXPANDED_DB_PATH"
log "Bootstrap complete. Run 'npm run dev:local' to start the local-first app."
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
INFO() { printf "[clean-local] %s\n" "$1"; }
maybe_remove_dir() {
local target="$1"
if [ -d "$target" ]; then
rm -rf "$target"
INFO "Removed directory $target"
fi
}
maybe_remove_file() {
local target="$1"
if [ -f "$target" ]; then
rm -f "$target"
INFO "Removed file $target"
fi
}
INFO "Cleaning generated artifacts (safe-only)"
maybe_remove_dir "$REPO_ROOT/dist/local-app"
maybe_remove_dir "$REPO_ROOT/dist/runtime"
maybe_remove_dir "$REPO_ROOT/dist/.staging"
maybe_remove_dir "$REPO_ROOT/.next"
maybe_remove_dir "$REPO_ROOT/apps/mac/dist"
maybe_remove_file "$REPO_ROOT/dist/index.html"
maybe_remove_file "$REPO_ROOT/logs/helper-interactions.log"
maybe_remove_file "$REPO_ROOT/logs/next-dev.log"
maybe_remove_file "$REPO_ROOT/app/api/logs/requests.log"
INFO "Cleanup complete"
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env node
/*
Dev utility: Inspect memory claims ledger and write a Markdown report.
Usage: node scripts/dev/inspect-memory-ledger.js [--out docs/development/reports/memory-ledger.md] [--versions 5]
Optionally set RAH_DB_PATH to override the default DB path.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execFileSync } = require('child_process');
const args = process.argv.slice(2);
const outIdx = args.indexOf('--out');
const outPath = outIdx >= 0 ? args[outIdx + 1] : 'docs/development/reports/memory-ledger.md';
const verIdx = args.indexOf('--versions');
const maxVersions = verIdx >= 0 ? parseInt(args[verIdx + 1], 10) : 5;
const defaultDbPath = path.join(os.homedir(), 'Library', 'Application Support', 'RA-H', 'db', 'rah.sqlite');
const dbPath = process.env.RAH_DB_PATH || defaultDbPath;
function q(dbPath, sql, params = []) {
const paramList = params.map(p => typeof p === 'number' ? String(p) : `'${String(p).replace(/'/g, "''")}'`).join(',');
const wrapped = params.length ? sql.replace(/\?/g, () => params.shift()) : sql;
try {
const out = execFileSync('sqlite3', ['-json', dbPath, wrapped], { encoding: 'utf8' });
return JSON.parse(out || '[]');
} catch (e) {
console.error('sqlite3 query failed:', e.message);
process.exit(1);
}
}
function loadCategoryRows(dbPath, category, limit) {
const sql = `SELECT id, version, is_current, content, metadata, created_at
FROM memory
WHERE type='big_memory' AND entity_id='${category}'
ORDER BY version DESC
LIMIT ${limit}`;
return q(dbPath, sql);
}
function parseMeta(m) {
try { return m ? JSON.parse(m) : {}; } catch { return {}; }
}
function renderTable(rowsLatest, rowsPrev) {
// rowsLatest: {claim, count, first_seen, last_seen}
// Map previous counts for delta
const prevMap = new Map(rowsPrev.map(r => [r.claim, r.count]));
const header = `| claim | count | Δ | first_seen | last_seen |\n|---|---:|---:|---|---|`;
const lines = rowsLatest.map(r => {
const prev = prevMap.get(r.claim) || 0;
const delta = r.count - prev;
return `| ${r.claim} | ${r.count} | ${delta >= 0 ? '+'+delta : delta} | ${r.first_seen} | ${r.last_seen} |`;
});
return [header, ...lines].join('\n');
}
function main() {
const categories = ['world_model','interests','goals','styles','connections'];
let md = `# Memory Ledger Report\n\n- DB: \`${dbPath}\`\n- Generated: ${new Date().toISOString()}\n- Versions shown per category: ${maxVersions}\n\n`;
for (const cat of categories) {
const rows = loadCategoryRows(dbPath, cat, maxVersions);
if (!rows || rows.length === 0) {
md += `## ${cat}\n(No rows)\n\n`;
continue;
}
const current = rows[0];
const prev = rows[1] || null;
const curMeta = parseMeta(current.metadata);
const prevMeta = parseMeta(prev && prev.metadata);
const curClaims = curMeta.claims || {};
const prevClaims = prevMeta.claims || {};
const toArr = (obj) => Object.entries(obj).map(([k, v]) => ({ claim: k, count: Number(v.count || 0), first_seen: v.first_seen || '', last_seen: v.last_seen || '' }));
const curArr = toArr(curClaims).sort((a,b) => b.count - a.count || (a.last_seen > b.last_seen ? -1 : 1)).slice(0, 50);
const prevArr = toArr(prevClaims).sort((a,b) => b.count - a.count).slice(0, 50);
md += `## ${cat}\n\n`;
md += `- current version: ${current.version} (created ${current.created_at})\n`;
if (prev) md += `- previous version: ${prev.version} (created ${prev.created_at})\n`;
md += `- current paragraph (first 200 chars):\n\n> ${String(current.content || '').slice(0,200)}\n\n`;
md += `### Top Claims (current vs previous Δ)\n\n`;
md += renderTable(curArr, prevArr) + '\n\n';
}
// Ensure folder exists
const outAbs = path.resolve(outPath);
fs.mkdirSync(path.dirname(outAbs), { recursive: true });
fs.writeFileSync(outAbs, md, 'utf8');
console.log(`Wrote report: ${outAbs}`);
}
main();
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
REPO_DIR="$(cd "${ROOT_DIR}/.." && pwd)"
PORT="${PORT:-3000}"
HOST="127.0.0.1"
NEXT_LOG="${REPO_DIR}/logs/next-dev.log"
mkdir -p "${REPO_DIR}/logs"
cleanup() {
if [[ -n "${NEXT_PID:-}" ]]; then
echo "Shutting down Next.js dev server (pid ${NEXT_PID})"
kill "${NEXT_PID}" 2>/dev/null || true
wait "${NEXT_PID}" 2>/dev/null || true
fi
}
trap cleanup EXIT INT TERM
echo "Starting Next.js dev server on http://${HOST}:${PORT}"
HOST="${HOST}" PORT="${PORT}" npm run dev > "${NEXT_LOG}" 2>&1 &
NEXT_PID=$!
sleep 1
echo -n "Waiting for Next.js to become ready"
for _ in $(seq 1 90); do
if nc -z "${HOST}" "${PORT}" 2>/dev/null; then
echo " ✓"
break
fi
echo -n "."
sleep 1
done
if ! nc -z "${HOST}" "${PORT}" 2>/dev/null; then
echo " ✗ Next.js did not start within 90s. Last log lines:"
tail -n 40 "${NEXT_LOG}" || true
exit 1
fi
echo "Next.js ready. Launching Tauri shell."
RAH_ATTACH_DEV_SERVER=true \
RAH_DEV_SERVER_PORT="${PORT}" \
PORT="${PORT}" \
HOST="${HOST}" \
npm run tauri:dev --workspace @ra-h/mac-shell
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env bash
set -euo pipefail
# Generate a Tauri updater manifest for the universal DMG build.
# Usage:
# scripts/generate-update-manifest.sh \
# --file dist/bundle/RA-H.dmg \
# --url https://updates.ra-h.app/dmg/RA-H.dmg \
# --version 0.1.0 \
# --out dist/update/latest.json \
# [--notes release-notes.md] \
# [--signature <base64 signature>]
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
FILE_PATH=""
DOWNLOAD_URL=""
VERSION=""
OUTPUT_PATH=""
NOTES_CONTENT=""
SIGNATURE="${TAURI_UPDATER_SIGNATURE:-}"
usage() {
grep '^#' "$0" | cut -c 3-
}
while [[ $# -gt 0 ]]; do
case "$1" in
--file)
FILE_PATH="$2"
shift 2
;;
--url)
DOWNLOAD_URL="$2"
shift 2
;;
--version)
VERSION="$2"
shift 2
;;
--out)
OUTPUT_PATH="$2"
shift 2
;;
--notes)
NOTES_CONTENT="$(cat "$2")"
shift 2
;;
--signature)
SIGNATURE="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage
exit 1
;;
esac
done
if [ -z "$FILE_PATH" ] || [ -z "$DOWNLOAD_URL" ] || [ -z "$VERSION" ] || [ -z "$OUTPUT_PATH" ]; then
echo "❌ Missing required arguments." >&2
usage
exit 1
fi
if [ ! -f "$FILE_PATH" ]; then
echo "❌ File not found: $FILE_PATH" >&2
exit 1
fi
ensure_signature() {
if [ -n "$SIGNATURE" ]; then
return
fi
if [ -n "${TAURI_PRIVATE_KEY:-}" ]; then
if ! command -v tauri >/dev/null 2>&1; then
echo "❌ Tauri CLI not found; cannot derive signature automatically." >&2
exit 1
fi
echo "🔐 Generating signature via tauri signer"
SIGNATURE="$(tauri signer sign \
--private-key "$TAURI_PRIVATE_KEY" \
${TAURI_PRIVATE_KEY_PASSWORD:+--password "$TAURI_PRIVATE_KEY_PASSWORD"} \
"$FILE_PATH" | tail -n 1)"
fi
if [ -z "$SIGNATURE" ]; then
echo "❌ Provide --signature or set TAURI_PRIVATE_KEY/TAURI_UPDATER_SIGNATURE." >&2
exit 1
fi
}
ensure_signature
if ! command -v shasum >/dev/null 2>&1; then
echo "❌ shasum not found. Install coreutils." >&2
exit 1
fi
SHA512="$(shasum -a 512 "$FILE_PATH" | awk '{print $1}')"
PUB_DATE="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
if ! command -v jq >/dev/null 2>&1; then
echo "❌ jq is required to emit the manifest." >&2
exit 1
fi
jq -n \
--arg version "$VERSION" \
--arg notes "$NOTES_CONTENT" \
--arg pub_date "$PUB_DATE" \
--arg url "$DOWNLOAD_URL" \
--arg sha "$SHA512" \
--arg sig "$SIGNATURE" \
'{
version: $version,
notes: $notes,
pub_date: $pub_date,
platforms: {
"darwin-aarch64": { signature: $sig, url: $url, sha512: $sha },
"darwin-x86_64": { signature: $sig, url: $url, sha512: $sha }
}
}' >"$OUTPUT_PATH"
echo "✅ Update manifest written to $OUTPUT_PATH"
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.ra-h.sqlite-backup</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>-lc</string>
<string>cd /Users/bradleymorris/Desktop/dev/ra-h && bash scripts/database/sqlite-backup.sh</string>
</array>
<key>WorkingDirectory</key>
<string>/Users/bradleymorris/Desktop/dev/ra-h</string>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>2</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<key>RunAtLoad</key>
<true/>
<key>StandardOutPath</key>
<string>/Users/bradleymorris/Library/Logs/ra-h-sqlite-backup.log</string>
<key>StandardErrorPath</key>
<string>/Users/bradleymorris/Library/Logs/ra-h-sqlite-backup.log</string>
</dict>
</plist>
+57
View File
@@ -0,0 +1,57 @@
#!/bin/bash
# Export all PostgreSQL data to CSV files for SQLite migration
set -e # Exit on error
echo "Starting PostgreSQL data export..."
# Ensure tmp directory exists
mkdir -p tmp/migrate
# Database connection string
DB_URL="postgresql://rah_user:rah_password@localhost:5432/rah_db"
# Export nodes
echo "Exporting nodes..."
psql "$DB_URL" -c "\COPY (SELECT id, title, content, link, created_at, updated_at FROM nodes ORDER BY id) TO STDOUT WITH CSV HEADER" > tmp/migrate/nodes.csv
# Export dimensions (unnest array into rows)
echo "Exporting dimensions (array to rows)..."
psql "$DB_URL" -c "\COPY (SELECT n.id AS node_id, TRIM(unnest(n.dimensions)) AS dimension FROM nodes n WHERE n.dimensions IS NOT NULL AND array_length(n.dimensions, 1) > 0 ORDER BY n.id) TO STDOUT WITH CSV HEADER" > tmp/migrate/node_dimensions.csv
# Export chunks (without embeddings)
echo "Exporting chunks..."
psql "$DB_URL" -c "\COPY (SELECT id, node_id, chunk_idx, text, created_at FROM chunks ORDER BY id) TO STDOUT WITH CSV HEADER" > tmp/migrate/chunks.csv
# Export embeddings as CSV text (handle NULL embeddings)
echo "Exporting embeddings..."
psql "$DB_URL" -c "\COPY (SELECT id AS chunk_id, CASE WHEN embedding IS NOT NULL THEN trim(both '[]' from embedding::text) ELSE NULL END AS embedding_csv FROM chunks WHERE embedding IS NOT NULL ORDER BY id) TO STDOUT WITH CSV HEADER" > tmp/migrate/chunk_embeddings.csv
# Export edges
echo "Exporting edges..."
psql "$DB_URL" -c "\COPY (SELECT id, from_node_id, to_node_id, source, created_at FROM edges ORDER BY id) TO STDOUT WITH CSV HEADER" > tmp/migrate/edges.csv
# Export chats (for completeness)
echo "Exporting chats..."
psql "$DB_URL" -c "\COPY (SELECT id, chat_type, helper_name, user_message, assistant_message, thread_id, focused_node_id, created_at FROM chats ORDER BY id) TO STDOUT WITH CSV HEADER" > tmp/migrate/chats.csv
# Get counts for verification
echo ""
echo "Export complete! Counts:"
echo "------------------------"
echo -n "Nodes: "
psql "$DB_URL" -t -c "SELECT COUNT(*) FROM nodes"
echo -n "Dimensions: "
psql "$DB_URL" -t -c "SELECT COUNT(*) FROM (SELECT unnest(dimensions) FROM nodes WHERE dimensions IS NOT NULL) AS d"
echo -n "Chunks: "
psql "$DB_URL" -t -c "SELECT COUNT(*) FROM chunks"
echo -n "Chunks with embeddings: "
psql "$DB_URL" -t -c "SELECT COUNT(*) FROM chunks WHERE embedding IS NOT NULL"
echo -n "Edges: "
psql "$DB_URL" -t -c "SELECT COUNT(*) FROM edges"
echo -n "Chats: "
psql "$DB_URL" -t -c "SELECT COUNT(*) FROM chats"
echo ""
echo "Files exported to tmp/migrate/"
echo "Next step: Run ./scripts/migrate/import_to_sqlite.sh"
+148
View File
@@ -0,0 +1,148 @@
#!/bin/bash
# Simplified import without FTS complications
set -e
echo "Starting simplified SQLite import..."
# Remove old database
rm -f rah_trial.db
# Create basic schema first
sqlite3 rah_trial.db <<'SQL'
-- Basic settings
PRAGMA foreign_keys = ON;
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA cache_size = -200000;
PRAGMA temp_store = MEMORY;
PRAGMA mmap_size = 268435456;
-- Create tables without FTS first
CREATE TABLE nodes (
id INTEGER PRIMARY KEY,
title TEXT,
content TEXT,
link TEXT,
created_at TEXT,
updated_at TEXT
);
CREATE TABLE node_dimensions (
node_id INTEGER NOT NULL,
dimension TEXT NOT NULL,
PRIMARY KEY (node_id, dimension),
FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE
) WITHOUT ROWID;
CREATE TABLE chunks (
id INTEGER PRIMARY KEY,
node_id INTEGER NOT NULL,
chunk_idx INTEGER,
text TEXT,
created_at TEXT,
FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE
);
CREATE TABLE chunk_embeddings (
chunk_id INTEGER PRIMARY KEY,
embedding TEXT NOT NULL,
FOREIGN KEY (chunk_id) REFERENCES chunks(id) ON DELETE CASCADE
);
CREATE TABLE edges (
id INTEGER PRIMARY KEY,
from_node_id INTEGER NOT NULL,
to_node_id INTEGER NOT NULL,
source TEXT,
created_at TEXT,
FOREIGN KEY (from_node_id) REFERENCES nodes(id) ON DELETE CASCADE,
FOREIGN KEY (to_node_id) REFERENCES nodes(id) ON DELETE CASCADE
);
CREATE TABLE chats (
id INTEGER PRIMARY KEY,
chat_type TEXT,
helper_name TEXT,
user_message TEXT,
assistant_message TEXT,
thread_id TEXT,
focused_node_id INTEGER,
created_at TEXT,
FOREIGN KEY (focused_node_id) REFERENCES nodes(id) ON DELETE SET NULL
);
-- Create indexes
CREATE INDEX idx_dim_by_dimension ON node_dimensions(dimension, node_id);
CREATE INDEX idx_dim_by_node ON node_dimensions(node_id, dimension);
CREATE INDEX idx_chunks_by_node ON chunks(node_id);
CREATE INDEX idx_chunks_by_node_idx ON chunks(node_id, chunk_idx);
CREATE INDEX idx_edges_from ON edges(from_node_id);
CREATE INDEX idx_edges_to ON edges(to_node_id);
CREATE INDEX idx_chats_thread ON chats(thread_id);
SQL
echo "Schema created. Importing data..."
# Import CSVs with proper handling
sqlite3 rah_trial.db <<'SQL'
.mode csv
-- Import nodes
.import --skip 1 tmp/migrate/nodes.csv nodes
-- Import dimensions
.import --skip 1 tmp/migrate/node_dimensions.csv node_dimensions
-- Import chunks
.import --skip 1 tmp/migrate/chunks.csv chunks
-- Import embeddings
.import --skip 1 tmp/migrate/chunk_embeddings.csv chunk_embeddings
-- Import edges (skip header)
.import --skip 1 tmp/migrate/edges.csv edges
-- Import chats
.import --skip 1 tmp/migrate/chats.csv chats
-- Add FTS after data is loaded
CREATE VIRTUAL TABLE chunks_fts USING fts5(
text,
content=chunks,
content_rowid=id,
tokenize='porter ascii'
);
CREATE VIRTUAL TABLE nodes_fts USING fts5(
title,
content,
content=nodes,
content_rowid=id,
tokenize='porter ascii'
);
-- Populate FTS
INSERT INTO chunks_fts(text) SELECT text FROM chunks;
INSERT INTO nodes_fts(title, content) SELECT title, content FROM nodes;
-- Analyze for optimization
ANALYZE;
SQL
# Verify counts
echo ""
echo "Verifying import..."
sqlite3 rah_trial.db <<'SQL'
.mode list
SELECT 'Nodes: ' || COUNT(*) FROM nodes;
SELECT 'Chunks: ' || COUNT(*) FROM chunks;
SELECT 'Embeddings: ' || COUNT(*) FROM chunk_embeddings;
SELECT 'Dimensions: ' || COUNT(*) FROM node_dimensions;
SELECT 'Edges: ' || COUNT(*) FROM edges;
SELECT 'Chats: ' || COUNT(*) FROM chats;
SELECT 'Database size: ' || ROUND(page_count * page_size / 1024.0 / 1024.0, 2) || ' MB' FROM pragma_page_count(), pragma_page_size();
SQL
echo ""
echo "✅ Import complete!"
+115
View File
@@ -0,0 +1,115 @@
#!/bin/bash
# Import CSV data into SQLite database
set -e # Exit on error
echo "Starting SQLite database import..."
echo "================================"
# Remove old database if exists
if [ -f "rah_trial.db" ]; then
echo "Removing existing rah_trial.db..."
rm -f rah_trial.db
fi
# Create database with schema
echo "Creating database with optimized schema..."
sqlite3 rah_trial.db < scripts/migrate/sqlite_schema.sql
# Import CSV files
echo ""
echo "Importing data from CSV files..."
echo "--------------------------------"
sqlite3 rah_trial.db <<'EOF'
.mode csv
.headers on
-- Import nodes (handle embedded quotes/newlines)
.print "Importing nodes..."
.separator ","
.import tmp/migrate/nodes.csv nodes_temp
INSERT INTO nodes SELECT * FROM nodes_temp WHERE id != 'id';
DROP TABLE nodes_temp;
-- Import node dimensions
.print "Importing dimensions..."
.import tmp/migrate/node_dimensions.csv node_dimensions
-- Import chunks
.print "Importing chunks..."
.separator ","
.import tmp/migrate/chunks.csv chunks_temp
INSERT INTO chunks SELECT * FROM chunks_temp WHERE id != 'id';
DROP TABLE chunks_temp;
-- Import embeddings
.print "Importing embeddings (this may take a moment)..."
.import tmp/migrate/chunk_embeddings.csv chunk_embeddings
-- Import edges
.print "Importing edges..."
.import tmp/migrate/edges.csv edges
-- Import chats
.print "Importing chats..."
.separator ","
.import tmp/migrate/chats.csv chats_temp
INSERT INTO chats SELECT * FROM chats_temp WHERE id != 'id';
DROP TABLE chats_temp;
-- Populate FTS indexes
.print ""
.print "Building full-text search indexes..."
INSERT INTO chunks_fts(rowid, text) SELECT id, text FROM chunks;
INSERT INTO nodes_fts(rowid, title, content) SELECT id, title, content FROM nodes;
-- Run ANALYZE for query optimization
.print "Analyzing tables for query optimization..."
ANALYZE;
-- Verify imports with counts
.print ""
.print "Import complete! Verifying counts:"
.print "===================================="
SELECT printf('Nodes: %d', COUNT(*)) FROM nodes;
SELECT printf('Node dimensions: %d', COUNT(*)) FROM node_dimensions;
SELECT printf('Unique dimensions: %d', COUNT(DISTINCT dimension)) FROM node_dimensions;
SELECT printf('Chunks: %d', COUNT(*)) FROM chunks;
SELECT printf('Chunks with embeddings: %d', COUNT(*)) FROM chunk_embeddings;
SELECT printf('Edges: %d', COUNT(*)) FROM edges;
SELECT printf('Chats: %d', COUNT(*)) FROM chats;
SELECT printf('FTS chunks indexed: %d', COUNT(*)) FROM chunks_fts;
SELECT printf('FTS nodes indexed: %d', COUNT(*)) FROM nodes_fts;
-- Database statistics
.print ""
.print "Database Statistics:"
.print "===================="
SELECT printf('Database size: %.2f MB', page_count * page_size / 1024.0 / 1024.0) FROM pragma_page_count(), pragma_page_size();
SELECT printf('Cache size: %.0f MB', cache_size * -1 / 1000.0) FROM pragma_cache_size();
SELECT printf('Journal mode: %s', journal_mode) FROM pragma_journal_mode();
-- Sample data verification
.print ""
.print "Sample data (first 3 nodes):"
.print "============================="
.mode column
.width 10 40
SELECT id, substr(title, 1, 40) as title FROM nodes LIMIT 3;
.print ""
.print "Sample dimensions for node 5:"
.print "=============================="
SELECT dimension FROM node_dimensions WHERE node_id = 5 LIMIT 5;
EOF
echo ""
echo "✅ SQLite database created successfully: rah_trial.db"
echo ""
echo "Database is ready for testing!"
echo "Next steps:"
echo " 1. Install sqlite-vec extension for vector search (optional)"
echo " 2. Run performance benchmarks"
echo " 3. Compare with PostgreSQL baseline"
+306
View File
@@ -0,0 +1,306 @@
#!/usr/bin/env node
/**
* Full pipeline test: Extract facts, match, and persist to memory table
*/
const Database = require('better-sqlite3');
const OpenAI = require('openai');
const path = require('path');
const os = require('os');
const crypto = require('crypto');
const dbPath = path.join(os.homedir(), 'Library/Application Support/RA-H/db/rah.sqlite');
const db = new Database(dbPath);
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const MODEL = process.env.MEMORY_MODEL || 'gpt-4o-mini';
async function chatJSON(model, system, user, maxTokens = 1500) {
const payload = {
model,
messages: [
{ role: 'system', content: system },
{ role: 'user', content: user }
]
};
if (model.includes('gpt-5')) {
payload.max_completion_tokens = maxTokens;
payload.response_format = { type: 'json_object' };
} else {
payload.temperature = 0.3;
payload.max_tokens = maxTokens;
}
const completion = await openai.chat.completions.create(payload);
const text = completion.choices[0]?.message?.content || '{}';
try {
return JSON.parse(text);
} catch {
const m = text.match(/\{[\s\S]*\}/);
if (m) {
try { return JSON.parse(m[0]); } catch {}
}
throw new Error('LLM did not return valid JSON');
}
}
function getExistingFacts() {
const rows = db.prepare(`
SELECT id, entity_id, content, metadata
FROM memory
WHERE type='big_memory' AND entity_id LIKE 'fact:%'
`).all();
return rows.map(r => {
const meta = safeParse(r.metadata);
return {
id: r.id,
entity_id: r.entity_id,
text: (r.content || '').trim(),
meta: meta
};
});
}
async function extractFacts(batchJson) {
const system = `You are given a JSON array of the last 10100 activity logs. Each entry has id and fields: for chats {id,type:'chat',ts,helper,user,assistant}, for nodes {id,type:'node',ts,title}, for edges {id,type:'edge',ts,from_title,to_title}.
Extract ANY potentially important, durable facts the user explicitly stated or clearly implied — facts that help refine their research/thinking/learning process over time.
Keep each fact atomic and canonical (<=160 chars). Do not invent or generalize beyond evidence.
Acceptable, generic fact types include: identity/role, relationships, goals/projects, interests/domains, learning styles, preferences, beliefs/world model, workflows/tools, constraints/availability, and assets/channels (e.g., podcast/newsletter titles).
Return STRICT JSON only:
{ "facts": [ { "text": string, "explicit": boolean, "sources": [log_id, ...] } ] }
Rules:
- text is a single atomic fact (<=160 chars), canonical phrasing.
- explicit = true only for clear first-person/possessive or labeled statements (e.g., "my…", "I…", "partner named…").
- sources: include 13 representative log ids (from the provided id fields) for each fact.`;
const result = await chatJSON(MODEL, system, batchJson, 1500);
return Array.isArray(result?.facts) ? result.facts : [];
}
async function matchFacts(candidates, existing) {
const system = `Given CANDIDATE facts and EXISTING facts, decide for each candidate whether to:
- REINFORCE one or more existing facts (near-duplicates or same meaning), and/or
- create NEW canonical fact texts when it's genuinely new.
Definitions:
- Similar (reinforce): same meaning with minor wording differences; same named entity with different surface form; specific vs broader phrasing where the core claim aligns.
- New: a distinct fact not covered by any existing entry.
Normalization guidance:
- Prefer simple, canonical phrasing (no quotes unless part of a title).
- Keep <=160 chars; avoid trailing punctuation unless it's part of a title.
- Maintain names/titles/capitalization as they appear; no speculation.
Return STRICT JSON only:
{
"actions": [
{ "candidate": number, "reinforce": [existing_id, ...], "new": [canonical_text, ...] }
]
}
Rules:
- A candidate may reinforce multiple existing items (1→many) if they're all clearly near-duplicates.
- A candidate may also create NEW text(s) if there's a distinct fact not covered by existing items.
- If neither applies (ambiguous/noisy), leave both arrays empty.`;
const payload = {
candidates,
existing_facts: existing.map(e => ({
id: e.entity_id,
text: e.text,
reinforcement_count: e.meta?.reinforcement_count || 0,
reinforcement_score: e.meta?.reinforcement_score || 0
}))
};
try {
const res = await chatJSON(MODEL, system, JSON.stringify(payload), 1500);
if (res && Array.isArray(res.actions)) return { actions: res.actions };
} catch (e) {
console.warn('Match LLM failed, using fallback:', e.message);
}
const actions = candidates.map((c, i) => ({ candidate: i, reinforce: [], new: [c.text] }));
return { actions };
}
function persistMatches(candidates, existing, matchActions) {
const nowIso = new Date().toISOString();
const existingByEntity = new Map(existing.map(e => [e.entity_id, e]));
let reinforced = 0;
let created = 0;
db.transaction(() => {
for (const act of (matchActions.actions || [])) {
const cand = candidates[act.candidate];
if (!cand || !cand.text) continue;
// REINFORCE existing facts
for (const target of (act.reinforce || [])) {
const ex = existingByEntity.get(String(target));
if (!ex) continue;
const meta = ex.meta || {};
const scoreInc = cand.explicit ? 5 : 1;
meta.reinforcement_count = (meta.reinforcement_count || 0) + 1;
meta.reinforcement_score = (meta.reinforcement_score || 0) + scoreInc;
meta.last_seen = nowIso;
meta.first_seen = meta.first_seen || nowIso;
const srcs = new Set(Array.isArray(meta.sources) ? meta.sources : []);
(cand.sources || []).forEach(s => srcs.add(s));
meta.sources = Array.from(srcs).slice(0, 20);
meta.explicit = !!(meta.explicit || cand.explicit);
meta.user_score = meta.user_score || 0;
db.prepare(`UPDATE memory SET metadata = ? WHERE id = ?`)
.run(JSON.stringify(meta), ex.id);
reinforced++;
console.log(` ↑ REINFORCED fact:${ex.entity_id.substring(5, 13)}... (count: ${meta.reinforcement_count}, score: ${meta.reinforcement_score})`);
}
// CREATE new facts
for (const newText of (act.new || [])) {
const txt = String(newText || cand.text).trim().slice(0, 160);
if (!txt) continue;
const meta = {
reinforcement_count: 1,
reinforcement_score: cand.explicit ? 5 : 1,
explicit: !!cand.explicit,
cross_source: false,
sources: (cand.sources || []).slice(0, 20),
first_seen: nowIso,
last_seen: nowIso,
user_score: 0
};
const entity = `fact:${crypto.randomUUID()}`;
db.prepare(`
INSERT INTO memory(type, entity_id, version, content, metadata, is_current)
VALUES('big_memory', ?, 1, ?, ?, 1)
`).run(entity, txt, JSON.stringify(meta));
created++;
console.log(` + NEW fact: "${txt.substring(0, 60)}${txt.length > 60 ? '...' : ''}"`);
}
}
})();
return { reinforced, created };
}
function safeParse(s) {
try { return s ? JSON.parse(s) : {}; } catch { return {}; }
}
async function runFullPipeline() {
console.log('═══════════════════════════════════════════════════════');
console.log('🧪 FULL MEMORY PIPELINE TEST (Last 100 Logs)');
console.log('═══════════════════════════════════════════════════════\n');
// 1. Fetch logs
const batch = db.prepare(`
SELECT id, ts, table_name, action, summary, snapshot_json, chat_helper,
chat_user_full, chat_assistant_full, node_title, edge_from_title, edge_to_title
FROM logs_v ORDER BY id DESC LIMIT 100
`).all().reverse();
console.log(`📊 Fetched ${batch.length} logs (ID ${batch[0].id}${batch[batch.length-1].id})\n`);
// 2. Build batch JSON
const inputJson = [];
for (const r of batch) {
if (r.table_name === 'chats') {
inputJson.push({
id: r.id, type: 'chat', ts: r.ts,
helper: r.chat_helper || null,
user: r.chat_user_full || '',
assistant: r.chat_assistant_full || ''
});
} else if (r.table_name === 'nodes') {
inputJson.push({
id: r.id, type: 'node', ts: r.ts,
title: r.node_title || r.summary || ''
});
} else if (r.table_name === 'edges') {
inputJson.push({
id: r.id, type: 'edge', ts: r.ts,
from_title: r.edge_from_title || '',
to_title: r.edge_to_title || ''
});
}
}
// 3. Get existing facts (before)
const existingBefore = getExistingFacts();
console.log(`📋 Existing facts in DB: ${existingBefore.length}\n`);
// 4. Extract candidates
console.log(`🤖 Extracting facts using ${MODEL}...\n`);
const candidates = await extractFacts(JSON.stringify(inputJson, null, 2));
console.log(`✅ Extracted ${candidates.length} candidate facts:\n`);
candidates.forEach((c, i) => {
console.log(`${i+1}. "${c.text.substring(0, 80)}${c.text.length > 80 ? '...' : ''}"`);
console.log(` Explicit: ${c.explicit}, Sources: [${c.sources.slice(0, 3).join(', ')}]`);
});
console.log();
// 5. Match against existing
console.log('🔍 Matching against existing facts...\n');
const matchActions = await matchFacts(candidates, existingBefore);
console.log(`📝 Match decisions: ${matchActions.actions.length} actions\n`);
// 6. Persist
console.log('💾 Persisting to database...\n');
const { reinforced, created } = persistMatches(candidates, existingBefore, matchActions);
// 7. Get existing facts (after)
const existingAfter = getExistingFacts();
console.log('\n═══════════════════════════════════════════════════════');
console.log('📊 RESULTS');
console.log('═══════════════════════════════════════════════════════\n');
console.log(`Facts before: ${existingBefore.length}`);
console.log(`Facts after: ${existingAfter.length}`);
console.log(`Reinforced: ${reinforced}`);
console.log(`Created: ${created}`);
console.log();
// 8. Show top facts by score
const sorted = existingAfter
.map(f => ({
text: f.text,
count: f.meta?.reinforcement_count || 0,
score: f.meta?.reinforcement_score || 0
}))
.sort((a, b) => b.score - a.score)
.slice(0, 10);
console.log('🏆 Top 10 facts by score:\n');
sorted.forEach((f, i) => {
console.log(`${i+1}. [score: ${f.score}, count: ${f.count}]`);
console.log(` "${f.text}"`);
console.log();
});
db.close();
console.log('✅ Pipeline complete!\n');
}
runFullPipeline().catch(e => {
console.error('❌ Pipeline failed:', e.message);
console.error(e.stack);
db.close();
process.exit(1);
});
+164
View File
@@ -0,0 +1,164 @@
#!/usr/bin/env node
/**
* Standalone test script to verify memory extraction on last 100 logs
*/
const Database = require('better-sqlite3');
const OpenAI = require('openai');
const path = require('path');
const os = require('os');
const dbPath = path.join(os.homedir(), 'Library/Application Support/RA-H/db/rah.sqlite');
const db = new Database(dbPath);
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
async function chatJSON(model, system, user, maxTokens = 1500) {
const payload = {
model,
messages: [
{ role: 'system', content: system },
{ role: 'user', content: user }
]
};
if (model.includes('gpt-5')) {
payload.max_completion_tokens = maxTokens;
payload.response_format = { type: 'json_object' };
} else {
payload.temperature = 0.3;
payload.max_tokens = maxTokens;
}
const completion = await openai.chat.completions.create(payload);
const text = completion.choices[0]?.message?.content || '{}';
try {
return JSON.parse(text);
} catch {
const m = text.match(/\{[\s\S]*\}/);
if (m) {
try { return JSON.parse(m[0]); } catch {}
}
throw new Error('LLM did not return valid JSON');
}
}
async function testExtraction() {
console.log('📊 Testing memory extraction on last 100 logs\n');
// Get last 100 logs
const batch = db.prepare(`
SELECT id, ts, table_name, action, summary, snapshot_json, chat_helper,
chat_user_full, chat_assistant_full, node_title, edge_from_title, edge_to_title
FROM logs_v ORDER BY id DESC LIMIT 100
`).all().reverse();
console.log(`✅ Fetched ${batch.length} logs (ID ${batch[0].id}${batch[batch.length-1].id})\n`);
// Build input JSON
const inputJson = [];
for (const r of batch) {
if (r.table_name === 'chats') {
inputJson.push({
id: r.id,
type: 'chat',
ts: r.ts,
helper: r.chat_helper || null,
user: r.chat_user_full || '',
assistant: r.chat_assistant_full || ''
});
} else if (r.table_name === 'nodes') {
inputJson.push({
id: r.id,
type: 'node',
ts: r.ts,
title: r.node_title || r.summary || ''
});
} else if (r.table_name === 'edges') {
inputJson.push({
id: r.id,
type: 'edge',
ts: r.ts,
from_title: r.edge_from_title || '',
to_title: r.edge_to_title || ''
});
}
}
console.log('📝 Sample entries:');
console.log('First:', JSON.stringify(inputJson[0], null, 2));
console.log('Last:', JSON.stringify(inputJson[inputJson.length-1], null, 2));
console.log();
// Look for Paige mentions
const paigeLogs = inputJson.filter(e =>
e.type === 'chat' && (e.user.toLowerCase().includes('paige') || e.assistant.toLowerCase().includes('paige'))
);
console.log(`🔍 Found ${paigeLogs.length} entries mentioning "paige":`);
paigeLogs.forEach(log => {
console.log(` ID ${log.id}: "${log.user.substring(0, 60)}..."`);
});
console.log();
// Extract facts
const MODEL = process.env.MEMORY_MODEL || 'gpt-4o-mini';
console.log(`🤖 Using model: ${MODEL}\n`);
const system = `You are given a JSON array of the last 10100 activity logs. Each entry has id and fields: for chats {id,type:'chat',ts,helper,user,assistant}, for nodes {id,type:'node',ts,title}, for edges {id,type:'edge',ts,from_title,to_title}.
Extract ANY potentially important, durable facts the user explicitly stated or clearly implied — facts that help refine their research/thinking/learning process over time.
Keep each fact atomic and canonical (<=160 chars). Do not invent or generalize beyond evidence.
Acceptable, generic fact types include: identity/role, relationships, goals/projects, interests/domains, learning styles, preferences, beliefs/world model, workflows/tools, constraints/availability, and assets/channels (e.g., podcast/newsletter titles).
Return STRICT JSON only:
{ "facts": [ { "text": string, "explicit": boolean, "sources": [log_id, ...] } ] }
Rules:
- text is a single atomic fact (<=160 chars), canonical phrasing.
- explicit = true only for clear first-person/possessive or labeled statements (e.g., "my…", "I…", "partner named…").
- sources: include 13 representative log ids (from the provided id fields) for each fact.`;
try {
console.log('⏳ Calling LLM...\n');
const result = await chatJSON(MODEL, system, JSON.stringify(inputJson, null, 2), 1500);
console.log('════════════════════════════════════════');
console.log('📋 EXTRACTION RESULT');
console.log('════════════════════════════════════════\n');
if (!result || !result.facts || result.facts.length === 0) {
console.log('❌ NO FACTS EXTRACTED\n');
console.log('Raw response:', JSON.stringify(result, null, 2));
return;
}
console.log(`✅ Extracted ${result.facts.length} facts:\n`);
result.facts.forEach((f, i) => {
console.log(`${i+1}. "${f.text}"`);
console.log(` Explicit: ${f.explicit}`);
console.log(` Sources: [${f.sources.join(', ')}]`);
console.log();
});
// Check for Paige facts
const paigeFacts = result.facts.filter(f =>
f.text.toLowerCase().includes('paige')
);
if (paigeFacts.length > 0) {
console.log(`🎯 Found ${paigeFacts.length} fact(s) about Paige!`);
} else {
console.log(`⚠️ No facts extracted about Paige (despite ${paigeLogs.length} mentions in logs)`);
}
} catch (e) {
console.error('❌ ERROR:', e.message);
console.error(e.stack);
} finally {
db.close();
}
}
testExtraction().catch(console.error);
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env node
/**
* Test script for memory UPDATE operation
*
* This script:
* 1. Shows current memory facts
* 2. Triggers the memory pipeline manually
* 3. Shows updated facts after processing
*/
const Database = require('better-sqlite3');
const path = require('path');
const os = require('os');
const dbPath = path.join(os.homedir(), 'Library/Application Support/RA-H/db/rah.sqlite');
const db = new Database(dbPath);
console.log('=== MEMORY UPDATE OPERATION TEST ===\n');
// 1. Show current facts
console.log('📊 CURRENT MEMORY FACTS:\n');
const currentFacts = db.prepare(`
SELECT
id,
entity_id,
content,
json_extract(metadata, '$.reinforcement_count') as count,
json_extract(metadata, '$.reinforcement_score') as score,
json_extract(metadata, '$.entities') as entities
FROM memory
WHERE type='big_memory' AND entity_id LIKE 'fact:%' AND is_current=1
ORDER BY json_extract(metadata, '$.reinforcement_score') DESC
`).all();
currentFacts.forEach((fact, i) => {
console.log(`${i + 1}. [Score: ${fact.score}, Count: ${fact.count}]`);
console.log(` "${fact.content}"`);
if (fact.entities) {
try {
const entities = JSON.parse(fact.entities);
if (entities && entities.length > 0) {
console.log(` Entities: ${entities.join(', ')}`);
}
} catch {}
}
console.log();
});
// 2. Check pipeline state
console.log('\n📈 PIPELINE STATE:\n');
const pipelineState = db.prepare(`
SELECT last_processed_log_id, last_run_at
FROM memory_pipeline_state
WHERE id=1
`).get();
const maxLogId = db.prepare('SELECT MAX(id) as max FROM logs').get();
console.log(`Last processed log: ${pipelineState?.last_processed_log_id || 0}`);
console.log(`Max log ID: ${maxLogId?.max || 0}`);
console.log(`Logs pending: ${(maxLogId?.max || 0) - (pipelineState?.last_processed_log_id || 0)}`);
console.log(`Last run: ${pipelineState?.last_run_at || 'never'}\n`);
// 3. Show recent logs that mention key entities
console.log('\n📝 RECENT LOGS (mentioning ra-h, Paige, knowledge management):\n');
const recentLogs = db.prepare(`
SELECT id, ts, table_name, summary, chat_user_full
FROM logs_v
WHERE
(chat_user_full LIKE '%ra-h%' OR
chat_user_full LIKE '%Paige%' OR
chat_user_full LIKE '%knowledge management%' OR
chat_user_full LIKE '%dogfood%' OR
chat_user_full LIKE '%beta%' OR
summary LIKE '%ra-h%' OR
summary LIKE '%Paige%')
ORDER BY id DESC
LIMIT 15
`).all();
recentLogs.forEach(log => {
const preview = log.chat_user_full
? log.chat_user_full.substring(0, 100)
: log.summary.substring(0, 100);
console.log(`Log ${log.id} [${log.ts}]: ${preview}${preview.length >= 100 ? '...' : ''}`);
});
console.log('\n✅ To trigger pipeline manually:');
console.log(' 1. Start dev server: npm run dev');
console.log(' 2. Create 10+ new logs (chat, create nodes, etc.)');
console.log(' 3. Pipeline will auto-run and show UPDATE operations in console (if MEMORY_DEBUG=1)');
console.log('\n Or run this script again after interacting with the app to see updated facts.\n');
db.close();
+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env node
/**
* Test script for Python to TypeScript migration
* Tests extractors and embeddings using TypeScript modules
*/
const fs = require('fs');
const path = require('path');
const Module = require('module');
const ts = require('typescript');
const { getSubtitles } = require('youtube-captions-scraper');
function isNetworkError(error) {
if (!error) return false;
const message = typeof error === 'string' ? error : error.message || '';
const code = error.code || '';
return [
'fetch failed',
'ENOTFOUND',
'ECONNRESET',
'EAI_AGAIN',
'ECONNREFUSED',
'ETIMEDOUT'
].some(token => message.includes(token)) || ['ENOTFOUND', 'ECONNRESET', 'EAI_AGAIN', 'ECONNREFUSED', 'ETIMEDOUT'].includes(code);
}
function requireTs(modulePath) {
const resolved = path.resolve(__dirname, '..', modulePath);
const source = fs.readFileSync(resolved, 'utf8');
const transpiled = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020,
esModuleInterop: true,
moduleResolution: ts.ModuleResolutionKind.Node16,
resolveJsonModule: true,
skipLibCheck: true
},
fileName: resolved
});
const tempModule = new Module(resolved, module);
tempModule.filename = resolved;
tempModule.paths = Module._nodeModulePaths(path.dirname(resolved));
tempModule._compile(transpiled.outputText, resolved);
return tempModule.exports;
}
async function testTranscriptLibrary() {
console.log('🔍 Testing youtube-captions-scraper library directly...');
const testUrls = [
'dQw4w9WgXcQ', // Rick Roll
'MnrJzXM7a6o', // TED Talk
'jNQXAC9IVRw' // Popular video
];
let sawNetworkError = false;
let sawEmptyTranscript = false;
for (const videoId of testUrls) {
console.log(`\nTesting video: ${videoId}`);
try {
const transcript = await getSubtitles({ videoID: videoId });
if (!transcript || transcript.length === 0) {
sawEmptyTranscript = true;
console.log('⚠️ Library returned 0 segments (likely geo-blocked). Trying next video...');
continue;
}
console.log(`✅ Success! Got ${transcript.length} segments`);
console.log('First segment:', transcript[0]);
console.log('Sample formatted:', `[${transcript[0].start.toFixed(1)}s] ${transcript[0].text}`);
return 'passed'; // Exit on first success
} catch (error) {
if (isNetworkError(error)) {
sawNetworkError = true;
console.log('⚠️ Network unavailable, skipping this video');
} else {
console.log('❌ Failed:', error.message);
}
}
}
if (sawNetworkError) {
console.log('\n⚠️ Network unavailable for transcript tests, marking as SKIPPED');
return 'skipped';
}
if (sawEmptyTranscript) {
console.log('\n⚠️ Library could not fetch transcripts for any sample video (likely YouTube changes). Marking as SKIPPED.');
return 'skipped';
}
console.log('\n❌ No transcripts could be extracted from any test video');
return 'failed';
}
console.log('🧪 Testing TypeScript Migration...\n');
async function testYouTubeExtractor() {
console.log('📺 Testing YouTube Extractor...');
try {
const { extractYouTube } = requireTs('src/services/typescript/extractors/youtube.ts');
const result = await extractYouTube('https://www.youtube.com/watch?v=dQw4w9WgXcQ');
console.log('✅ YouTube extraction successful');
console.log(` Title: ${result.metadata.title}`);
console.log(` Channel: ${result.metadata.author_name}`);
console.log(` Transcript length: ${result.chunk.length} chars\n`);
return 'passed';
} catch (error) {
if (isNetworkError(error)) {
console.warn('⚠️ Network unavailable, skipping YouTube extractor test\n');
return 'skipped';
}
console.error('❌ YouTube extraction failed:', error.message, '\n');
return 'failed';
}
}
async function testWebsiteExtractor() {
console.log('🌐 Testing Website Extractor...');
try {
const { extractWebsite } = requireTs('src/services/typescript/extractors/website.ts');
const result = await extractWebsite('https://example.com');
console.log('✅ Website extraction successful');
console.log(` Title: ${result.metadata.title}`);
console.log(` Content length: ${result.chunk.length} chars\n`);
return 'passed';
} catch (error) {
if (isNetworkError(error)) {
console.warn('⚠️ Network unavailable, skipping website extractor test\n');
return 'skipped';
}
console.error('❌ Website extraction failed:', error.message, '\n');
return 'failed';
}
}
async function testPDFExtractor() {
console.log('📄 Testing PDF Extractor...');
try {
const { extractPaper } = requireTs('src/services/typescript/extractors/paper.ts');
// Using a sample PDF URL
const samplePdf = 'https://raw.githubusercontent.com/mozilla/pdf.js/master/web/compressed.tracemonkey-pldi-09.pdf';
const result = await extractPaper(samplePdf);
console.log('✅ PDF extraction successful');
console.log(` Pages: ${result.metadata.pages}`);
console.log(` Text length: ${result.metadata.text_length} chars\n`);
return 'passed';
} catch (error) {
if (isNetworkError(error)) {
console.warn('⚠️ Network unavailable, skipping PDF extractor test\n');
return 'skipped';
}
console.error('❌ PDF extraction failed:', error.message, '\n');
return 'failed';
}
}
async function testNodeEmbedding() {
console.log('🔮 Testing Node Embedding...');
try {
// This would require a real node ID and database connection
console.log('⚠️ Skipping node embedding test (requires database and API key)\n');
return 'skipped';
} catch (error) {
console.error('❌ Node embedding failed:', error.message, '\n');
return 'failed';
}
}
async function runTests() {
console.log('Environment:');
console.log(` Node version: ${process.version}\n`);
// First test the library directly
const libraryStatus = await testTranscriptLibrary();
const results = {
library: libraryStatus,
youtube: await testYouTubeExtractor(),
website: await testWebsiteExtractor(),
pdf: await testPDFExtractor(),
embedding: await testNodeEmbedding()
};
console.log('📊 Test Results:');
console.log('─────────────────');
let passed = 0;
let failed = 0;
let skipped = 0;
for (const [test, result] of Object.entries(results)) {
if (result === 'passed') {
console.log(` ${test}: ✅ PASSED`);
passed++;
} else if (result === 'skipped') {
console.log(` ${test}: ⚠️ SKIPPED`);
skipped++;
} else {
console.log(` ${test}: ❌ FAILED`);
failed++;
}
}
console.log('\n📈 Summary:');
console.log(` Passed: ${passed}`);
console.log(` Failed: ${failed}`);
console.log(` Skipped: ${skipped}`);
if (failed === 0) {
console.log('\n🎉 No hard failures. Re-run skipped tests when network/database access is available.');
} else {
console.log('\n⚠️ Some tests failed. Check the errors above.');
}
process.exit(failed > 0 ? 1 : 0);
}
// Run tests
runTests().catch(error => {
console.error('Fatal error:', error);
process.exit(1);
});
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
set -euo pipefail
# Smoke test the universal binaries by loading them under both architectures.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
DIST_ROOT="${DIST_ROOT:-$REPO_ROOT/dist/local-app}"
APP_DIR="$DIST_ROOT/app"
NODE_BIN="$DIST_ROOT/bin/node"
assert_exists() {
local path="$1"
if [ ! -e "$path" ]; then
echo "❌ Missing required artifact: $path" >&2
exit 1
fi
}
check_universal() {
local path="$1"
if ! file "$path" | grep -q "arm64"; then
echo "$path is missing arm64 slice" >&2
exit 1
fi
if ! file "$path" | grep -q "x86_64"; then
echo "$path is missing x86_64 slice" >&2
exit 1
fi
}
run_dual_arch_node() {
local arch="$1"
local script="$2"
if [ "$arch" = "x86_64" ]; then
arch -x86_64 "$NODE_BIN" -e "$script"
else
arch -arm64 "$NODE_BIN" -e "$script"
fi
}
main() {
assert_exists "$NODE_BIN"
assert_exists "$APP_DIR/node_modules/better-sqlite3/build/Release/better_sqlite3.node"
assert_exists "$DIST_ROOT/vendor/sqlite-extensions/vec0.dylib"
echo "️ Verifying universal slices"
check_universal "$NODE_BIN"
check_universal "$APP_DIR/node_modules/better-sqlite3/build/Release/better_sqlite3.node"
check_universal "$DIST_ROOT/vendor/sqlite-extensions/vec0.dylib"
echo "️ Running runtime smoke tests"
run_dual_arch_node arm64 "console.log('arm64 runtime ok')"
run_dual_arch_node x86_64 "console.log('x86 runtime ok')"
(
cd "$APP_DIR"
run_dual_arch_node arm64 "require('./node_modules/better-sqlite3'); console.log('better-sqlite3 arm64 ok')"
run_dual_arch_node x86_64 "require('./node_modules/better-sqlite3'); console.log('better-sqlite3 x86 ok')"
)
echo "✅ Universal build smoke test passed"
}
main "$@"
+20
View File
@@ -0,0 +1,20 @@
const Database = require('better-sqlite3');
const path = require('path');
const dbPath = process.argv[2];
if (!dbPath) {
console.error('Usage: node tmp-init-vec.js <dbPath>');
process.exit(1);
}
const db = new Database(dbPath);
const vecPath = path.join(process.cwd(), 'vendor', 'sqlite-extensions', 'vec0.dylib');
db.loadExtension(vecPath);
db.exec(`
CREATE VIRTUAL TABLE IF NOT EXISTS vec_nodes USING vec0(node_id INTEGER PRIMARY KEY, embedding FLOAT[1536]);
CREATE VIRTUAL TABLE IF NOT EXISTS vec_chunks USING vec0(chunk_id INTEGER PRIMARY KEY, embedding FLOAT[1536]);
`);
console.log('✓ vec tables ensured');
db.close();
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env bash
echo "Xcode 15.0"
echo "Build version 15A000"
+593
View File
@@ -0,0 +1,593 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from 'react';
import RAHChat from './RAHChat';
import QuickAddInput from './QuickAddInput';
import QuickAddStatus from './QuickAddStatus';
import { Zap, Flame } from 'lucide-react';
import type { AgentDelegation } from '@/services/agents/delegation';
import { Node } from '@/types/database';
interface AgentsPanelProps {
openTabsData: Node[];
activeTabId: number | null;
onNodeClick?: (nodeId: number) => void;
}
type ActiveTab = 'ra-h' | string; // 'ra-h' or delegation sessionId
type Mode = 'quickadd' | 'session';
export default function AgentsPanel({ openTabsData, activeTabId, onNodeClick }: AgentsPanelProps) {
const [delegationsMap, setDelegationsMap] = useState<Record<string, AgentDelegation>>({});
const [activeAgentTab, setActiveAgentTab] = useState<ActiveTab>('ra-h');
const [mode, setMode] = useState<Mode>('quickadd');
const [rahMode, setRahMode] = useState<'easy' | 'hard'>('easy');
const [modelDropdownOpen, setModelDropdownOpen] = useState(false);
// Lift messages state to prevent losing it on tab switch
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [rahMessages, setRahMessages] = useState<any[]>([]);
// Store delegation messages per sessionId
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [delegationMessages, setDelegationMessages] = useState<Record<string, any[]>>({});
const getDelegationMessages = useCallback((sessionId: string) => {
return delegationMessages[sessionId] || [];
}, [delegationMessages]);
const setDelegationMessagesFor = useCallback((sessionId: string) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (updater: (prev: any[]) => any[]) => {
setDelegationMessages(prev => ({
...prev,
[sessionId]: updater(prev[sessionId] || [])
}));
};
}, []);
const upsertDelegation = useCallback((delegation: AgentDelegation) => {
setDelegationsMap((prev) => ({
...prev,
[delegation.sessionId]: delegation,
}));
}, []);
useEffect(() => {
let cancelled = false;
const loadExisting = async () => {
try {
const response = await fetch('/api/rah/delegations?status=active&includeCompleted=true');
if (!response.ok) return;
const data = await response.json();
if (!Array.isArray(data.delegations)) return;
setDelegationsMap((prev) => {
if (cancelled) return prev;
const next = { ...prev };
for (const delegation of data.delegations as AgentDelegation[]) {
next[delegation.sessionId] = delegation;
}
return next;
});
} catch (error) {
console.error('[AgentsPanel] Failed to load delegations:', error);
}
};
loadExisting();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (typeof window === 'undefined') return;
const stored = window.localStorage.getItem('rah-mode');
if (stored === 'easy' || stored === 'hard') {
setRahMode(stored);
}
}, []);
useEffect(() => {
if (typeof window === 'undefined') return;
window.localStorage.setItem('rah-mode', rahMode);
}, [rahMode]);
useEffect(() => {
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ mode: 'easy' | 'hard' }>).detail;
if (detail?.mode) {
setRahMode(detail.mode);
}
};
const quickAddHandler = () => setMode('quickadd');
window.addEventListener('rah:mode-toggle', handler as EventListener);
window.addEventListener('rah:switch-quickadd', quickAddHandler);
return () => {
window.removeEventListener('rah:mode-toggle', handler as EventListener);
window.removeEventListener('rah:switch-quickadd', quickAddHandler);
};
}, []);
useEffect(() => {
const handleCreated = (event: Event) => {
const detail = (event as CustomEvent<{ delegation: AgentDelegation }>).detail;
console.log('[AgentsPanel] Delegation created:', detail?.delegation);
if (detail?.delegation) upsertDelegation(detail.delegation);
};
const handleUpdated = (event: Event) => {
const detail = (event as CustomEvent<{ delegation: AgentDelegation }>).detail;
console.log('[AgentsPanel] Delegation updated:', detail?.delegation);
if (detail?.delegation) upsertDelegation(detail.delegation);
};
window.addEventListener('delegations:created', handleCreated as EventListener);
window.addEventListener('delegations:updated', handleUpdated as EventListener);
return () => {
window.removeEventListener('delegations:created', handleCreated as EventListener);
window.removeEventListener('delegations:updated', handleUpdated as EventListener);
};
}, [upsertDelegation]);
const delegations = useMemo(() => Object.values(delegationsMap), [delegationsMap]);
const orderedDelegations = useMemo(() => {
const statusWeight = (status: AgentDelegation['status']) => {
switch (status) {
case 'queued':
return 0;
case 'in_progress':
return 1;
case 'completed':
return 2;
case 'failed':
default:
return 3;
}
};
// No staleness filtering - delegations persist until user closes them
const ordered = delegations.sort((a, b) => {
const diff = statusWeight(a.status) - statusWeight(b.status);
if (diff !== 0) return diff;
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
});
const wise = ordered.filter((d) => d.agentType === 'wise-rah');
const mini = ordered.filter((d) => d.agentType !== 'wise-rah');
return [...wise, ...mini];
}, [delegations]);
// Don't auto-switch tabs - user stays in ra-h to see responses
const selectedDelegation = orderedDelegations.find(d => d.sessionId === activeAgentTab);
const handleQuickAddSubmit = async ({ input, mode }: { input: string; mode: 'link' | 'note' | 'chat' }) => {
try {
const response = await fetch('/api/quick-add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input, mode })
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to submit Quick Add');
}
} catch (error) {
console.error('[AgentsPanel] Quick Add error:', error);
}
};
const handleDelegationClick = (sessionId: string) => {
setActiveAgentTab(sessionId);
};
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', background: '#0a0a0a' }}>
{/* Mode Header */}
{mode === 'quickadd' ? (
<div style={{
padding: '20px',
borderBottom: '1px solid #1a1a1a',
background: '#0a0a0a',
display: 'flex',
flexDirection: 'column',
gap: '24px',
justifyContent: 'space-between',
alignItems: 'center',
height: '100%'
}}>
{/* Top spacer */}
<div style={{ flex: 1 }} />
{/* Session Section */}
<div style={{
background: 'transparent',
border: 'none',
borderRadius: '8px',
padding: '20px',
textAlign: 'center',
width: '100%',
maxWidth: '600px'
}}>
<pre style={{
color: '#22c55e',
fontSize: '13px',
fontFamily: "'JetBrains Mono', ui-monospace, Courier, monospace",
lineHeight: '1.1',
margin: '0 0 20px 0',
letterSpacing: '0',
fontWeight: 700,
textShadow: '0 0 10px rgba(139, 212, 80, 0.4)'
}}>{`
██████╗ █████╗ ██╗ ██╗
██╔══██╗██╔══██╗ ██║ ██║
██████╔╝███████║█████╗███████║
██╔══██╗██╔══██║╚════╝██╔══██║
██║ ██║██║ ██║ ██║ ██║
╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝
`}</pre>
<button
onClick={() => {
setMode('session');
setRahMode('easy'); // Always start new session in easy mode
}}
style={{
width: 'auto',
padding: '14px 32px',
background: '#22c55e',
border: 'none',
borderRadius: '6px',
color: '#0a0a0a',
fontSize: '14px',
fontWeight: 600,
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
cursor: 'pointer',
transition: 'all 0.15s ease',
letterSpacing: '0.02em'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#2dd46d';
e.currentTarget.style.boxShadow = '0 4px 16px rgba(34, 197, 94, 0.4)';
e.currentTarget.style.transform = 'translateY(-2px)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#22c55e';
e.currentTarget.style.boxShadow = 'none';
e.currentTarget.style.transform = 'translateY(0)';
}}
>
Start Session
</button>
</div>
{/* Bottom spacer */}
<div style={{ flex: 1 }} />
{/* Divider */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
width: '100%',
maxWidth: '600px',
marginBottom: '12px'
}}>
<div style={{ flex: 1, height: '1px', background: '#1a1a1a' }} />
<span style={{
color: '#6b6b6b',
fontSize: '12px',
fontWeight: 700,
fontFamily: "'JetBrains Mono', ui-monospace",
textTransform: 'uppercase',
letterSpacing: '0.15em',
padding: '0 12px'
}}>or</span>
<div style={{ flex: 1, height: '1px', background: '#1a1a1a' }} />
</div>
{/* Quick Add Section */}
<div style={{
background: 'transparent',
padding: '0',
width: '100%',
maxWidth: '600px',
marginBottom: '12px'
}}>
<QuickAddInput
activeDelegations={orderedDelegations}
onSubmit={handleQuickAddSubmit}
/>
</div>
</div>
) : null}
{/* Tab Bar (only show in session mode) */}
{mode === 'session' && (
<div className="agent-tabs" style={{ position: 'relative' }}>
{/* Quick Add button - positioned at far right */}
<div style={{
position: 'absolute',
top: '50%',
right: '12px',
transform: 'translateY(-50%)',
display: 'flex',
alignItems: 'center',
gap: '8px',
zIndex: 20
}}>
<button
onClick={() => {
window.dispatchEvent(new CustomEvent('rah:switch-quickadd'));
}}
style={{
background: 'none',
border: 'none',
color: '#fff',
fontSize: '12px',
cursor: 'pointer',
padding: '4px 8px',
transition: 'color 0.2s',
fontFamily: 'inherit',
display: 'flex',
alignItems: 'center',
gap: '6px',
textTransform: 'uppercase',
letterSpacing: '0.05em'
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = '#d0d0d0';
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = '#fff';
}}
>
<span style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '16px',
height: '16px',
borderRadius: '50%',
background: '#fff',
color: '#0a0a0a',
fontSize: '12px',
lineHeight: 1,
fontWeight: 300,
flexShrink: 0
}}>+</span>
Quick Add
</button>
</div>
{/* RA-H Main Tab */}
<button
onClick={() => setActiveAgentTab('ra-h')}
className={`agent-tab ${activeAgentTab === 'ra-h' ? 'active' : ''}`}
>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '6px', color: rahMode === 'easy' ? '#22c55e' : '#f97316' }}>
{(rahMode === 'easy' ? <Zap size={12} strokeWidth={2.4} /> : <Flame size={12} strokeWidth={2.4} />)}
<span>RA-H · {rahMode === 'easy' ? 'Easy' : 'Hard'}</span>
</span>
</button>
{/* Delegation Tabs */}
{orderedDelegations.map((delegation) => {
const isActive = activeAgentTab === delegation.sessionId;
const isWiseRAH = delegation.agentType === 'wise-rah';
// Status colors based on agent type
const statusColor = isWiseRAH
? (delegation.status === 'in_progress' ? '#8b5cf6' :
delegation.status === 'completed' ? '#6b6b6b' :
delegation.status === 'failed' ? '#ff6b6b' : '#a78bfa')
: (delegation.status === 'in_progress' ? '#8bd450' :
delegation.status === 'completed' ? '#6b6b6b' :
delegation.status === 'failed' ? '#ff6b6b' : '#5c9aff');
const shortId = delegation.sessionId.split('_').pop() || '';
const tabLabel = isWiseRAH
? `WISE RA-H · ${shortId.slice(0, 6)}`
: `MINI · ${shortId.slice(0, 6)}`;
return (
<div
key={delegation.sessionId}
className={`agent-tab-wrapper ${isWiseRAH ? 'wise' : 'mini'} ${isActive ? 'active' : ''}`}
>
<button
onClick={() => setActiveAgentTab(delegation.sessionId)}
className="agent-tab"
>
<span className="status-dot" style={{ background: statusColor }} />
<span className="agent-tab-label">{tabLabel}</span>
</button>
<button
onClick={async (e) => {
e.stopPropagation();
console.log(`[AgentsPanel] User clicked close button for delegation ${delegation.sessionId}`);
// Delete from database
try {
await fetch(`/api/rah/delegations/${delegation.sessionId}`, { method: 'DELETE' });
} catch (error) {
console.error(`Failed to delete delegation ${delegation.sessionId}:`, error);
}
// Remove from UI state
setDelegationsMap((prev) => {
const { [delegation.sessionId]: _ignored, ...rest } = prev;
return rest;
});
setDelegationMessages((prev) => {
const { [delegation.sessionId]: _ignored, ...rest } = prev;
return rest;
});
if (activeAgentTab === delegation.sessionId) {
setActiveAgentTab('ra-h');
}
}}
className="agent-tab-close"
title="Close tab"
>
×
</button>
</div>
);
})}
</div>
)}
{/* Active Panel */}
<div style={{ flex: 1, minHeight: 0, position: 'relative', overflow: 'hidden' }}>
{mode === 'quickadd' ? (
<div style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}}>
<QuickAddStatus
delegations={orderedDelegations}
onDelegationClick={handleDelegationClick}
/>
</div>
) : (
<>
{/* Keep RAHChat always mounted, just hide when not active */}
<div style={{
height: '100%',
display: activeAgentTab === 'ra-h' ? 'block' : 'none'
}}>
<RAHChat
openTabsData={openTabsData}
activeTabId={activeTabId}
onNodeClick={onNodeClick}
delegations={orderedDelegations}
messages={rahMessages}
setMessages={setRahMessages}
mode={rahMode}
/>
</div>
{/* Show delegation chat when delegation tab is active */}
{selectedDelegation && activeAgentTab !== 'ra-h' && (
<div style={{ height: '100%', display: 'block' }}>
<RAHChat
openTabsData={openTabsData}
activeTabId={activeTabId}
onNodeClick={onNodeClick}
delegations={orderedDelegations}
messages={getDelegationMessages(selectedDelegation.sessionId)}
setMessages={setDelegationMessagesFor(selectedDelegation.sessionId)}
mode="easy"
delegationMode={true}
delegationSessionId={selectedDelegation.sessionId}
/>
</div>
)}
</>
)}
</div>
<style jsx>{`
.agent-tabs {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px 0 12px;
background: #0a0a0a;
border-bottom: 1px solid #1a1a1a;
overflow-x: auto;
overflow-y: hidden;
white-space: nowrap;
}
.agent-tabs::-webkit-scrollbar {
height: 6px;
}
.agent-tabs::-webkit-scrollbar-track {
background: #0d0d0d;
}
.agent-tabs::-webkit-scrollbar-thumb {
background: #1f1f1f;
border-radius: 999px;
}
.agent-tabs::-webkit-scrollbar-thumb:hover {
background: #2c2c2c;
}
.agent-tab-wrapper {
display: flex;
align-items: center;
background: transparent;
border-radius: 10px 10px 0 0;
transition: background 0.15s ease;
}
.agent-tab-wrapper.active {
background: #141414;
}
.agent-tab {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 14px;
background: transparent;
border: none;
cursor: pointer;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #6f6f6f;
transition: color 0.15s ease;
}
.agent-tab-wrapper.active .agent-tab {
color: #f3f3f3;
}
.agent-tab:hover {
color: #d0d0d0;
}
.agent-tab-wrapper.wise .agent-tab-label {
color: inherit;
}
.agent-tab-wrapper.mini .agent-tab-label {
color: inherit;
}
.agent-tab-close {
padding: 4px 8px;
background: transparent;
border: none;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 14px;
color: #5a5a5a;
cursor: pointer;
transition: color 0.15s ease;
}
.agent-tab-close:hover {
color: #ff6b6b;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
`}</style>
</div>
);
}
+67
View File
@@ -0,0 +1,67 @@
"use client";
interface AsciiBannerProps {
helperName: string;
displayName?: string;
}
export default function AsciiBanner({ helperName, displayName }: AsciiBannerProps) {
const name = displayName || helperName;
const nameUpper = name.toUpperCase();
// Create a unique minimal banner
const createBanner = () => {
// Dynamic sizing based on name length
const minWidth = 24;
const bannerWidth = Math.max(minWidth, nameUpper.length + 6);
const padding = Math.floor((bannerWidth - nameUpper.length - 2) / 2);
const paddedName = ' '.repeat(padding) + nameUpper + ' '.repeat(bannerWidth - nameUpper.length - padding - 2);
const topBottom = '─'.repeat(bannerWidth - 2);
return `${topBottom}
${paddedName}
${topBottom}`;
};
const banner = createBanner();
return (
<div style={{
padding: '32px 16px',
fontFamily: 'inherit',
opacity: 0,
animation: 'fadeIn 0.5s ease-out forwards',
textAlign: 'center'
}}>
<pre style={{
color: '#353535',
fontSize: '14px',
lineHeight: '1.3',
whiteSpace: 'pre',
margin: '0 auto',
display: 'inline-block',
letterSpacing: '0.05em'
}}>
{banner}
</pre>
<div style={{
marginTop: '12px',
fontSize: '11px',
letterSpacing: '0.15em',
textTransform: 'uppercase'
}}>
<span style={{ color: '#2a2a2a' }}></span>
<span style={{ color: '#353535', margin: '0 8px' }}>ready</span>
<span style={{ color: '#2a2a2a' }}></span>
</div>
<style jsx>{`
@keyframes fadeIn {
to { opacity: 1; }
}
`}</style>
</div>
);
}
@@ -0,0 +1,36 @@
import type { AgentDelegation } from '@/services/agents/delegation';
interface DelegationIndicatorProps {
delegations: AgentDelegation[];
}
export default function DelegationIndicator({ delegations }: DelegationIndicatorProps) {
if (!delegations.length) return null;
const activeCount = delegations.filter((d) => d.status === 'queued' || d.status === 'in_progress').length;
if (activeCount === 0) return null;
return (
<div style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '4px 8px',
borderRadius: '4px',
background: '#0f0f0f',
border: '1px solid #1a1a1a',
fontSize: '10px',
fontFamily: "'JetBrains Mono', ui-monospace",
color: '#6b6b6b'
}}>
<span style={{
width: '6px',
height: '6px',
borderRadius: '50%',
background: '#22c55e'
}} />
<span>{activeCount} active</span>
</div>
);
}
+237
View File
@@ -0,0 +1,237 @@
import { ReactNode } from 'react';
import type { AgentDelegation } from '@/services/agents/delegation';
interface MiniRAHPanelProps {
delegation: AgentDelegation;
onNodeClick?: (nodeId: number) => void;
}
const statusPalette: Record<string, { border: string; badge: string }> = {
queued: { border: '#1f3a5f', badge: '#5c9aff' },
in_progress: { border: '#3b5f2a', badge: '#8bd450' },
completed: { border: '#2a2a2a', badge: '#6b6b6b' },
failed: { border: '#5f2a2a', badge: '#ff6b6b' },
};
const NODE_LINK_REGEX = /\[NODE:(\d+):"([^"]+)"\]/g;
function formatStatus(status: string) {
switch (status) {
case 'queued':
return 'Queued';
case 'in_progress':
return 'Working';
case 'completed':
return 'Completed';
case 'failed':
return 'Failed';
default:
return status;
}
}
function renderNodeAwareLine(text: string, onNodeClick?: (nodeId: number) => void): ReactNode {
const parts: ReactNode[] = [];
let lastIndex = 0;
text.replace(NODE_LINK_REGEX, (match, id, title, offset) => {
if (offset > lastIndex) {
parts.push(<span key={`mini-text-${offset}`}>{text.slice(lastIndex, offset)}</span>);
}
const nodeId = Number(id);
const handleClick = () => {
if (onNodeClick) onNodeClick(nodeId);
};
parts.push(
<button
key={`mini-node-${offset}`}
type="button"
className="node-pill"
onClick={handleClick}
>
<span className="node-pill-id">NODE:{nodeId}</span>
<span className="node-pill-title">{title}</span>
</button>
);
lastIndex = offset + match.length;
return match;
});
if (lastIndex < text.length) {
parts.push(<span key="mini-text-end">{text.slice(lastIndex)}</span>);
}
return <>{parts}</>;
}
export default function MiniRAHPanel({ delegation, onNodeClick }: MiniRAHPanelProps) {
const palette = statusPalette[delegation.status] ?? statusPalette.queued;
const summaryLines = delegation.summary ? delegation.summary.split('\n').filter(Boolean) : [];
return (
<div className="mini-panel">
<header className="mini-header" style={{ borderBottomColor: palette.border }}>
<span className="status-dot" style={{ background: palette.badge }} />
<span className="header-title">MINI RA-H</span>
<span className="header-status">· {formatStatus(delegation.status)}</span>
<span className="header-time">{new Date(delegation.updatedAt).toLocaleTimeString()}</span>
</header>
<section className="section">
<h3>Task</h3>
<p>{delegation.task}</p>
</section>
{delegation.context.length > 0 && (
<section className="section">
<h3>Context</h3>
<ul className="context-list">
{delegation.context.map((item, idx) => (
<li key={idx}>{renderNodeAwareLine(item, onNodeClick)}</li>
))}
</ul>
</section>
)}
{summaryLines.length > 0 && (
<section className="section">
<h3>Summary</h3>
<div className="summary-card">
{summaryLines.map((line, idx) => (
<p key={idx}>{renderNodeAwareLine(line, onNodeClick)}</p>
))}
</div>
</section>
)}
<style jsx>{`
.mini-panel {
display: flex;
flex-direction: column;
gap: 16px;
padding: 24px;
background: #0a0a0a;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
height: 100%;
overflow-y: auto;
}
.mini-header {
display: flex;
align-items: center;
gap: 10px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(91, 154, 255, 0.25);
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.header-title {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.12em;
color: #84c8ff;
}
.header-status {
font-size: 12px;
color: #7da0b6;
}
.header-time {
margin-left: auto;
font-size: 11px;
color: #5f5f5f;
}
.section {
display: flex;
flex-direction: column;
gap: 10px;
}
.section h3 {
margin: 0;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #5c9aff;
}
.section p {
margin: 0;
font-size: 14px;
line-height: 1.6;
color: #d4d4d4;
}
.context-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.context-list li {
font-size: 13px;
color: #9aa7b6;
}
.summary-card {
background: #101010;
border: 1px solid rgba(92, 154, 255, 0.15);
border-radius: 12px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.summary-card p {
margin: 0;
font-size: 13px;
color: #f0f0f0;
}
.node-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 8px;
margin: 0 4px 4px 0;
font-size: 12px;
border-radius: 999px;
border: 1px solid rgba(92, 154, 255, 0.35);
background: rgba(92, 154, 255, 0.08);
color: #cfe4ff;
cursor: pointer;
transition: background 0.15s ease, border 0.15s ease;
}
.node-pill:hover {
background: rgba(92, 154, 255, 0.18);
border-color: rgba(92, 154, 255, 0.65);
}
.node-pill-id {
font-size: 11px;
opacity: 0.65;
}
.node-pill-title {
font-size: 12px;
font-weight: 500;
}
`}</style>
</div>
);
}
+433
View File
@@ -0,0 +1,433 @@
"use client";
import { useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import type { AgentDelegation } from '@/services/agents/delegation';
type QuickAddIntent = 'link' | 'note' | 'chat';
interface QuickAddSubmitPayload {
input: string;
mode: QuickAddIntent;
}
interface QuickAddInputProps {
activeDelegations: AgentDelegation[];
onSubmit: (payload: QuickAddSubmitPayload) => Promise<void>;
}
const MODE_CONFIG: Array<{
key: QuickAddIntent;
label: string;
hint: string;
icon: ReactNode;
}> = [
{
key: 'link',
label: 'Link',
hint: 'Drop URLs for auto YouTube/PDF/Web extraction',
icon: (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
<path d="M10 13a5 5 0 0 0 7.54.35l2.12-2.12a5 5 0 1 0-7.07-7.07l-1.29 1.29" strokeLinecap="round" strokeLinejoin="round" />
<path d="M14 11a5 5 0 0 0-7.54-.35l-2.12 2.12a5 5 0 1 0 7.07 7.07l1.29-1.29" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
},
{
key: 'note',
label: 'Note',
hint: 'Quick jot — no extraction, no summary',
icon: (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
<path d="M12 20h9" strokeLinecap="round" />
<path d="M12 4h9" strokeLinecap="round" />
<path d="M4 4h1v16H4z" />
<path d="M7 9h7" strokeLinecap="round" />
<path d="M7 13h5" strokeLinecap="round" />
</svg>
)
},
{
key: 'chat',
label: 'Chat',
hint: 'Paste messy transcripts — store raw text + summary',
icon: (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
<path d="M21 15a2 2 0 0 1-2 2H9l-4 4V5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2z" strokeLinecap="round" strokeLinejoin="round" />
<path d="M7 8h10" strokeLinecap="round" />
<path d="M7 12h6" strokeLinecap="round" />
</svg>
)
}
];
export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddInputProps) {
const [input, setInput] = useState('');
const [isPosting, setIsPosting] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const [manualMode, setManualMode] = useState<QuickAddIntent | null>(null);
const [autoMode, setAutoMode] = useState<QuickAddIntent>('link');
const [autoReason, setAutoReason] = useState<string | null>(null);
const effectiveMode: QuickAddIntent = manualMode ?? autoMode;
const currentPlaceholder = useMemo(() => {
if (effectiveMode === 'note') {
return 'Write a quick note — no extraction, just append to your graph';
}
if (effectiveMode === 'chat') {
return 'Paste any conversation (ChatGPT, Claude, etc.) and we will store raw text + summary';
}
return "Drop links, URL's, ideas or notes to add new nodes";
}, [effectiveMode]);
const maxConcurrent = 5;
const activeCount = activeDelegations.filter(
(d) => d.status === 'queued' || d.status === 'in_progress'
).length;
const isSoftLimited = activeCount >= maxConcurrent;
const inferChatIntent = (value: string) => {
const trimmed = value.trim();
if (!trimmed) {
setAutoMode('link');
setAutoReason(null);
return;
}
if (manualMode) return;
const newlineCount = (trimmed.match(/\n/g)?.length ?? 0);
const looksLikeTranscript =
newlineCount >= 2 ||
/You said:|ChatGPT said:|Claude said:|Assistant:|User:/i.test(trimmed) ||
/\b\d{1,2}:\d{2}\b/.test(trimmed);
if (looksLikeTranscript && trimmed.length > 280) {
setAutoMode('chat');
setAutoReason('Detected chat transcript');
} else {
setAutoMode('link');
setAutoReason(null);
}
};
const handleInputChange = (value: string) => {
setInput(value);
inferChatIntent(value);
};
const handleModeClick = (mode: QuickAddIntent) => {
setManualMode(mode);
setAutoMode(mode);
setAutoReason(null);
};
const handleSubmit = async () => {
if (!input.trim() || isPosting || isSoftLimited) return;
setIsPosting(true);
try {
await onSubmit({ input: input.trim(), mode: effectiveMode });
setInput('');
setManualMode(null);
setAutoMode('link');
setAutoReason(null);
} catch (error) {
console.error('[QuickAddInput] Submit error:', error);
} finally {
setIsPosting(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const isLinkMode = effectiveMode === 'link';
const shouldSubmit = isLinkMode
? (e.key === 'Enter' && !e.shiftKey)
: (e.key === 'Enter' && (e.metaKey || e.ctrlKey));
if (shouldSubmit) {
e.preventDefault();
handleSubmit();
}
};
const renderInputField = () => {
if (effectiveMode === 'link') {
return (
<input
type="text"
value={input}
onChange={(e) => handleInputChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={currentPlaceholder}
disabled={isPosting || isSoftLimited}
autoFocus
style={{
flex: 1,
padding: '12px 16px',
background: '#0a0a0a',
border: '2px solid #22c55e',
borderRadius: '6px',
color: '#e5e5e5',
fontSize: '13px',
fontFamily: "'JetBrains Mono', ui-monospace",
outline: 'none',
transition: 'all 0.15s ease',
boxShadow: '0 0 0 3px rgba(34, 197, 94, 0.15)'
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = '#22c55e';
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(34, 197, 94, 0.25)';
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = '#22c55e';
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(34, 197, 94, 0.15)';
}}
/>
);
}
return (
<textarea
value={input}
onChange={(e) => handleInputChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={currentPlaceholder}
disabled={isPosting || isSoftLimited}
autoFocus
rows={effectiveMode === 'chat' ? 6 : 4}
style={{
flex: 1,
padding: '12px 16px',
background: '#0a0a0a',
border: '2px solid #22c55e',
borderRadius: '6px',
color: '#e5e5e5',
fontSize: '13px',
fontFamily: "'JetBrains Mono', ui-monospace",
outline: 'none',
transition: 'all 0.15s ease',
resize: 'vertical',
minHeight: effectiveMode === 'chat' ? '150px' : '110px',
boxShadow: '0 0 0 3px rgba(34, 197, 94, 0.15)'
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = '#22c55e';
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(34, 197, 94, 0.25)';
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = '#22c55e';
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(34, 197, 94, 0.15)';
}}
/>
);
};
return (
<div style={{
display: 'flex',
flexDirection: 'column',
gap: '10px'
}}>
{!isExpanded ? (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '16px'
}}>
<div style={{
color: '#6b6b6b',
fontSize: '13px',
fontFamily: "'JetBrains Mono', ui-monospace",
textAlign: 'center'
}}>
Quickly add stuff
</div>
<button
onClick={() => setIsExpanded(true)}
style={{
width: '48px',
height: '48px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#22c55e',
border: 'none',
borderRadius: '50%',
color: '#0a0a0a',
cursor: 'pointer',
transition: 'all 150ms ease',
fontSize: '28px',
fontWeight: 300,
lineHeight: 1
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'scale(1.05)';
e.currentTarget.style.boxShadow = '0 4px 16px rgba(34, 197, 94, 0.4)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
e.currentTarget.style.boxShadow = 'none';
}}
title="Add new content"
>
+
</button>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div style={{ display: 'flex', gap: '8px', justifyContent: 'center' }}>
{MODE_CONFIG.map((mode) => {
const isActive = effectiveMode === mode.key && (manualMode === mode.key || (!manualMode && autoMode === mode.key));
return (
<button
key={mode.key}
type="button"
onClick={() => handleModeClick(mode.key)}
title={mode.hint}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '6px 12px',
borderRadius: '999px',
border: `1px solid ${isActive ? '#22c55e' : '#1f1f1f'}`,
background: isActive ? 'rgba(34, 197, 94, 0.15)' : 'transparent',
color: isActive ? '#22c55e' : '#9c9c9c',
fontSize: '11px',
fontFamily: "'JetBrains Mono', ui-monospace",
textTransform: 'uppercase',
letterSpacing: '0.08em',
cursor: 'pointer',
transition: 'all 0.15s ease'
}}
>
<span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>{mode.icon}</span>
{mode.label}
</button>
);
})}
</div>
{autoReason && !manualMode && (
<div style={{
textAlign: 'center',
color: '#9c9c9c',
fontSize: '11px',
letterSpacing: '0.05em',
textTransform: 'uppercase'
}}>
Auto-detected chat transcript
</div>
)}
<div style={{ display: 'flex', gap: '8px', alignItems: 'stretch' }}>
{renderInputField()}
<button
onClick={handleSubmit}
disabled={!input.trim() || isPosting || isSoftLimited}
aria-label={isPosting ? 'Adding' : 'Add'}
title={isPosting ? 'Adding…' : 'Add (Enter)'}
style={{
width: '40px',
height: '40px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: input.trim() && !isPosting && !isSoftLimited ? '#22c55e' : '#22c55e',
border: `2px solid #22c55e`,
borderRadius: '50%',
color: input.trim() && !isPosting && !isSoftLimited ? '#0a0a0a' : '#0a0a0a',
cursor: input.trim() && !isPosting && !isSoftLimited ? 'pointer' : 'not-allowed',
transition: 'all 150ms ease',
opacity: input.trim() && !isPosting && !isSoftLimited ? 1 : 0.7,
flexShrink: 0
}}
onMouseEnter={(e) => {
if (input.trim() && !isPosting && !isSoftLimited) {
e.currentTarget.style.transform = 'translateY(-1px)';
e.currentTarget.style.boxShadow = '0 4px 12px rgba(34, 197, 94, 0.4)';
}
}}
onMouseLeave={(e) => {
if (input.trim() && !isPosting && !isSoftLimited) {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = 'none';
}
}}
>
{isPosting ? (
<span style={{ fontSize: '12px' }}></span>
) : (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path d="M12 4l-6.5 6.5 1.42 1.42L11 8.84V20h2V8.84l4.08 3.08 1.42-1.42L12 4z"/>
</svg>
)}
</button>
</div>
</div>
)}
{/* Active processing indicator */}
{activeCount > 0 && (
<div style={{
padding: '10px 14px',
background: '#0a1a0a',
border: '1px solid #1a3a1a',
borderRadius: '6px',
color: '#22c55e',
fontSize: '11px',
fontFamily: "'JetBrains Mono', ui-monospace",
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<span style={{
width: '8px',
height: '8px',
borderRadius: '50%',
background: '#22c55e',
animation: 'pulse 2s ease-in-out infinite'
}} />
<span>
{activeCount === 1
? 'Adding 1 node to your database...'
: `Adding ${activeCount} nodes to your database...`}
</span>
</div>
)}
{isSoftLimited && (
<div style={{
padding: '10px 14px',
background: '#1a0a00',
border: '1px solid #3a2a1a',
borderRadius: '6px',
color: '#ff9b5c',
fontSize: '11px',
fontFamily: "'JetBrains Mono', ui-monospace",
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<span></span>
<span>Finish one of the 5 active Quick Adds before adding more.</span>
</div>
)}
<style jsx>{`
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
`}</style>
</div>
);
}
+204
View File
@@ -0,0 +1,204 @@
"use client";
import type { AgentDelegation } from '@/services/agents/delegation';
interface QuickAddStatusProps {
delegations: AgentDelegation[];
onDelegationClick: (sessionId: string) => void;
}
export default function QuickAddStatus({ delegations, onDelegationClick }: QuickAddStatusProps) {
const activeDelegations = delegations.filter(d => d.status === 'queued' || d.status === 'in_progress');
const completedDelegations = delegations.filter(d => d.status === 'completed' || d.status === 'failed');
console.log('[QuickAddStatus] Rendering with', delegations.length, 'total delegations,', activeDelegations.length, 'active');
const handleClearCompleted = async () => {
for (const delegation of completedDelegations) {
try {
await fetch(`/api/rah/delegations/${delegation.sessionId}`, { method: 'DELETE' });
} catch (error) {
console.error('Failed to delete delegation:', error);
}
}
window.location.reload();
};
return (
<div style={{
padding: '12px',
display: 'flex',
flexDirection: 'column',
gap: '8px',
overflowY: 'auto',
height: '100%',
background: '#0a0a0a'
}}>
{/* Header */}
{delegations.length > 0 && (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 12px',
background: '#151515',
borderRadius: '6px',
color: '#a8a8a8',
fontSize: '11px',
fontFamily: "'JetBrains Mono', ui-monospace",
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.05em',
borderLeft: activeDelegations.length > 0 ? '3px solid #22c55e' : '3px solid #6b6b6b'
}}>
<span>
{activeDelegations.length > 0
? `Processing ${activeDelegations.length} Quick Add${activeDelegations.length > 1 ? 's' : ''}...`
: `${delegations.length} Recent Quick Add${delegations.length > 1 ? 's' : ''}`
}
</span>
{completedDelegations.length > 0 && (
<button
onClick={handleClearCompleted}
style={{
padding: '4px 8px',
background: '#1a1a1a',
border: '1px solid #2a2a2a',
borderRadius: '4px',
color: '#6b6b6b',
fontSize: '10px',
fontFamily: "'JetBrains Mono', ui-monospace",
cursor: 'pointer',
textTransform: 'uppercase',
letterSpacing: '0.05em',
transition: 'all 0.15s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#222';
e.currentTarget.style.color = '#a8a8a8';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#1a1a1a';
e.currentTarget.style.color = '#6b6b6b';
}}
>
Clear Completed
</button>
)}
</div>
)}
{delegations.map((delegation) => {
const statusColor = delegation.status === 'in_progress' ? '#22c55e' :
delegation.status === 'completed' ? '#6b6b6b' :
delegation.status === 'failed' ? '#ff6b6b' : '#5c9aff';
const statusLabel = delegation.status === 'in_progress' ? 'Processing' :
delegation.status === 'completed' ? 'Done' :
delegation.status === 'failed' ? 'Failed' : 'Queued';
return (
<button
key={delegation.sessionId}
onClick={() => onDelegationClick(delegation.sessionId)}
style={{
padding: '12px',
background: '#151515',
border: '1px solid #2a2a2a',
borderRadius: '6px',
textAlign: 'left',
cursor: 'pointer',
transition: 'all 0.15s ease',
display: 'flex',
flexDirection: 'column',
gap: '6px'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#1a1a1a';
e.currentTarget.style.borderColor = '#3a3a3a';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#151515';
e.currentTarget.style.borderColor = '#2a2a2a';
}}
>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '8px'
}}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<span style={{
width: '8px',
height: '8px',
borderRadius: '50%',
background: statusColor,
flexShrink: 0
}} />
<span style={{
color: '#e5e5e5',
fontSize: '12px',
fontWeight: 600,
fontFamily: "'JetBrains Mono', ui-monospace",
textTransform: 'uppercase',
letterSpacing: '0.04em'
}}>
{statusLabel}
</span>
</div>
<span style={{
color: '#6b6b6b',
fontSize: '11px',
fontFamily: "'JetBrains Mono', ui-monospace"
}}>
{new Date(delegation.createdAt).toLocaleTimeString()}
</span>
</div>
<div style={{
color: '#a8a8a8',
fontSize: '12px',
fontFamily: "'JetBrains Mono', ui-monospace",
lineHeight: '1.5',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}>
{delegation.task}
</div>
{delegation.summary && delegation.status === 'completed' && (
<div style={{
color: '#6b6b6b',
fontSize: '11px',
fontFamily: "'JetBrains Mono', ui-monospace",
lineHeight: '1.4',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}>
{delegation.summary}
</div>
)}
{delegation.summary && delegation.status === 'failed' && (
<div style={{
color: '#ff6b6b',
fontSize: '11px',
fontFamily: "'JetBrains Mono', ui-monospace",
lineHeight: '1.4'
}}>
{delegation.summary}
</div>
)}
</button>
);
})}
</div>
);
}
+691
View File
@@ -0,0 +1,691 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Node } from '@/types/database';
import AsciiBanner from './AsciiBanner';
import TerminalMessage from './TerminalMessage';
import TerminalInput from './TerminalInput';
import { Zap, Flame } from 'lucide-react';
import DelegationIndicator from './DelegationIndicator';
import type { AgentDelegation } from '@/services/agents/delegation';
import { useSSEChat, ChatMessage, MessageRole } from './hooks/useSSEChat';
import { useQuotaHandler } from '@/hooks/useQuotaHandler';
import { apiKeyService } from '@/services/storage/apiKeys';
import { useVoiceSession } from './hooks/useVoiceSession';
import { useAssistantTTS } from './hooks/useAssistantTTS';
import { useRealtimeVoiceClient } from './hooks/useRealtimeVoiceClient';
import { useVoiceInterruption } from './hooks/useVoiceInterruption';
const createSessionId = () => `session_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const createVoiceRequestId = () =>
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `voice_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
interface RAHChatProps {
openTabsData: Node[];
activeTabId: number | null;
onNodeClick?: (nodeId: number) => void;
delegations?: AgentDelegation[];
messages?: ChatMessage[];
setMessages?: (updater: (prev: ChatMessage[]) => ChatMessage[]) => void;
mode?: 'easy' | 'hard';
delegationMode?: boolean;
delegationSessionId?: string;
}
export default function RAHChat({
openTabsData,
activeTabId,
onNodeClick,
delegations = [],
messages: externalMessages,
setMessages: externalSetMessages,
mode = 'easy',
delegationMode = false,
delegationSessionId,
}: RAHChatProps) {
// Use external state if provided (lifted state), otherwise use local state
const [internalMessages, internalSetMessages] = useState<ChatMessage[]>([]);
const messages = externalMessages !== undefined ? externalMessages : internalMessages;
const setMessages = externalSetMessages || internalSetMessages;
const [sessionId, setSessionId] = useState(() => createSessionId());
const messagesEndRef = useRef<HTMLDivElement>(null);
const chatMode = mode === 'hard' ? 'hard' : 'easy';
const helperKey = chatMode === 'hard' ? 'ra-h' : 'ra-h-easy';
const helperDisplayName = helperKey === 'ra-h' ? 'ra-h (hard)' : 'ra-h (easy)';
const {
quotaError,
handleAPIError: handleQuotaApiError,
checkQuotaBeforeRequest,
refetchUsage,
isQuotaExceeded,
} = useQuotaHandler();
const streamCompleteHandlerRef = useRef<() => Promise<void> | void>(async () => {
await refetchUsage();
});
const setMessagesRef = useRef(setMessages);
const voice = useVoiceSession();
const {
isActive: isVoiceActive,
amplitude: voiceAmplitude,
startSession: startVoice,
stopSession: stopVoice,
resetTranscript: resetVoiceTranscript,
setStatus: setVoiceStatus,
setAmplitude: setVoiceAmplitude,
setInterimTranscript,
appendFinalTranscript,
} = voice;
const pendingVoiceQueueRef = useRef<{ text: string; queuedAt: number }[]>([]);
const assistantSpeechMapRef = useRef<Map<string, string>>(new Map());
const [voiceError, setVoiceError] = useState<string | null>(null);
const voiceErrorHandledRef = useRef(false);
const voiceStartTimestampRef = useRef<number | null>(null);
const handleVoiceError = useCallback((error: Error) => {
console.error('[RAHChat] Voice error:', error);
setVoiceError(error.message);
if (isVoiceActive) {
stopVoice();
}
resetVoiceTranscript();
setVoiceAmplitude(0);
setVoiceStatus('idle');
pendingVoiceQueueRef.current = [];
assistantSpeechMapRef.current.clear();
}, [isVoiceActive, resetVoiceTranscript, setVoiceAmplitude, setVoiceStatus, stopVoice]);
const { speak: speakAssistantResponse, stop: stopAssistantTTS, status: ttsStatus } = useAssistantTTS({
onSpeechStart: () => {
if (isVoiceActive) {
setVoiceStatus('speaking');
}
},
onSpeechComplete: () => {
setVoiceStatus(isVoiceActive ? 'listening' : 'idle');
},
onError: handleVoiceError,
});
const handleVoiceInterruption = useCallback(() => {
stopAssistantTTS();
setVoiceStatus('listening');
}, [setVoiceStatus, stopAssistantTTS]);
const sse = useSSEChat('/api/rah/chat', setMessages, {
getAuthToken: () => null,
beforeRequest: checkQuotaBeforeRequest,
onRequestError: handleQuotaApiError,
onStreamComplete: async () => {
const handler = streamCompleteHandlerRef.current;
if (handler) {
await handler();
}
},
getApiKeys: () => apiKeyService.getStoredKeys(),
});
useVoiceInterruption({
amplitude: voiceAmplitude,
isVoiceActive,
ttsStatus,
onInterruption: handleVoiceInterruption,
});
const sendMessage = useCallback(async (text: string) => {
if (delegationMode) return; // Delegation chats are read-only
if (isQuotaExceeded) {
checkQuotaBeforeRequest();
return;
}
await sse.send({
text,
history: messages,
openTabs: openTabsData,
activeTabId,
sessionId,
mode: chatMode
});
}, [activeTabId, chatMode, checkQuotaBeforeRequest, delegationMode, isQuotaExceeded, messages, openTabsData, sse, sessionId]);
const handleVoiceFinalTranscript = useCallback(
(raw: string) => {
const normalized = raw.trim();
console.info('[RAHVoice] Final transcript received:', normalized || '(empty)');
setInterimTranscript('');
if (!normalized) {
console.info('[RAHVoice] Ignoring empty transcript');
return;
}
appendFinalTranscript(normalized);
if (sse.isLoading) {
pendingVoiceQueueRef.current.push({ text: normalized, queuedAt: Date.now() });
console.info('[RAHVoice] SSE busy, queueing transcript', {
queuedCount: pendingVoiceQueueRef.current.length,
});
setVoiceStatus('thinking');
return;
}
setVoiceStatus('thinking');
console.info('[RAHVoice] Dispatching transcript to /api/rah/chat');
void sendMessage(normalized);
},
[appendFinalTranscript, sendMessage, setInterimTranscript, setVoiceStatus, sse.isLoading]
);
const voiceRealtime = useRealtimeVoiceClient(
{
onStatusChange: (status) => {
if (!isVoiceActive && status !== 'idle') return;
if (status === 'listening' && (sse.isLoading || pendingVoiceQueueRef.current.length > 0)) {
setVoiceStatus('thinking');
return;
}
setVoiceStatus(status);
},
onInterimTranscript: setInterimTranscript,
onFinalTranscript: handleVoiceFinalTranscript,
onAmplitude: setVoiceAmplitude,
onError: handleVoiceError,
},
{
getAuthToken: () => null,
}
);
const handleStreamComplete = useCallback(async () => {
if (pendingVoiceQueueRef.current.length > 0) {
console.info('[RAHVoice] SSE stream complete, draining queued transcripts', {
queued: pendingVoiceQueueRef.current.length,
});
}
while (pendingVoiceQueueRef.current.length > 0) {
const nextQueued = pendingVoiceQueueRef.current.shift();
if (!nextQueued) {
break;
}
const queueLatency = Date.now() - nextQueued.queuedAt;
setVoiceStatus('thinking');
console.info('[RAHVoice] Dispatching queued transcript to /api/rah/chat', {
queuedMs: queueLatency,
});
await sendMessage(nextQueued.text);
}
await refetchUsage();
}, [sendMessage, setVoiceStatus, refetchUsage]);
useEffect(() => {
streamCompleteHandlerRef.current = handleStreamComplete;
}, [handleStreamComplete]);
useEffect(() => {
setMessagesRef.current = setMessages;
}, [setMessages]);
useEffect(() => {
if (!voiceError) {
voiceErrorHandledRef.current = false;
return;
}
if (voiceErrorHandledRef.current) return;
voiceErrorHandledRef.current = true;
voiceRealtime.stop();
stopAssistantTTS();
}, [voiceError, voiceRealtime, stopAssistantTTS]);
const focusSummary = useMemo(() => {
if (!openTabsData.length) return null;
const titles = openTabsData.map((node) => node?.title || 'Untitled');
const activeNode = openTabsData.find((node) => node.id === activeTabId) || openTabsData[0];
const truncate = (value: string, limit = 64) => {
if (value.length <= limit) return value;
return `${value.slice(0, limit - 1)}`;
};
return {
id: activeNode?.id ?? null,
title: truncate(activeNode?.title || 'Untitled'),
total: titles.length,
};
}, [openTabsData, activeTabId]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
useEffect(() => {
if (!isVoiceActive) {
assistantSpeechMapRef.current.clear();
stopAssistantTTS();
return;
}
if (sse.isLoading) return;
const assistantMessages = messages.filter((m) => m.role === MessageRole.ASSISTANT);
if (!assistantMessages.length) return;
const latest = assistantMessages[assistantMessages.length - 1];
const spokenContent = assistantSpeechMapRef.current.get(latest.id);
if (!latest.content.trim() || spokenContent === latest.content) return;
assistantSpeechMapRef.current.set(latest.id, latest.content);
const voiceRequestId = createVoiceRequestId();
speakAssistantResponse(latest.content, {
flush: true,
metadata: {
sessionId,
helper: helperKey,
requestId: voiceRequestId,
messageId: latest.id,
},
});
}, [helperKey, isVoiceActive, messages, sessionId, sse.isLoading, speakAssistantResponse, stopAssistantTTS]);
const handleNewChat = () => {
if (delegationMode) return;
sse.abort();
setMessages((_prev) => []);
setSessionId(createSessionId());
if (isVoiceActive) {
stopVoice();
resetVoiceTranscript();
}
};
// Subscribe to delegation stream if in delegation mode
useEffect(() => {
if (!delegationMode || !delegationSessionId) return;
const eventSource = new EventSource(`/api/rah/delegations/stream?sessionId=${delegationSessionId}`);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'text-delta' && data.delta) {
setMessagesRef.current((prev) => {
const lastMsg = prev[prev.length - 1];
if (lastMsg && lastMsg.role === MessageRole.ASSISTANT) {
const updated = [...prev];
updated[updated.length - 1] = { ...lastMsg, content: lastMsg.content + data.delta };
return updated;
}
return [...prev, {
id: crypto.randomUUID(),
role: MessageRole.ASSISTANT,
content: data.delta,
timestamp: new Date()
}];
});
}
if (data.type === 'tool-input-start' || data.type === 'tool-call') {
const toolMessage: ChatMessage = {
id: `tool-${data.toolCallId || crypto.randomUUID()}`,
role: MessageRole.TOOL,
content: data.toolName,
timestamp: new Date(),
toolName: data.toolName,
status: (data.status as ChatMessage['status']) || 'running',
toolArgs: data.input ?? data.args ?? data.parameters
};
setMessagesRef.current((prev) => [...prev, toolMessage]);
}
if (data.type === 'tool-output-available' || data.type === 'tool-result') {
setMessagesRef.current((prev) =>
prev.map((m) =>
m.id === `tool-${data.toolCallId}`
? {
...m,
content: `${m.toolName} ${data.status === 'error' ? '✗' : '✓'}`,
status: (data.status as ChatMessage['status']) || (data.error ? 'error' : 'complete'),
toolResult: data.result ?? data.output ?? (data.summary ? { summary: data.summary } : undefined),
}
: m
)
);
}
if (data.type === 'assistant-message') {
setMessagesRef.current((prev) => [...prev, {
id: crypto.randomUUID(),
role: MessageRole.ASSISTANT,
content: '',
timestamp: new Date()
}]);
}
} catch (error) {
console.error('[RAHChat] Failed to parse delegation stream event:', error);
}
};
eventSource.onerror = () => {
console.error('[RAHChat] Delegation stream connection error');
};
return () => {
eventSource.close();
};
}, [delegationMode, delegationSessionId]);
useEffect(() => {
if (delegationMode && isVoiceActive) {
voiceRealtime.stop();
stopVoice();
resetVoiceTranscript();
stopAssistantTTS();
}
}, [delegationMode, isVoiceActive, resetVoiceTranscript, stopAssistantTTS, stopVoice, voiceRealtime]);
const handleVoiceToggle = useCallback(async () => {
if (isVoiceActive) {
voiceRealtime.stop();
stopVoice();
resetVoiceTranscript();
setVoiceAmplitude(0);
setVoiceStatus('idle');
assistantSpeechMapRef.current.clear();
pendingVoiceQueueRef.current = [];
stopAssistantTTS();
voiceStartTimestampRef.current = null;
return;
}
setVoiceError(null);
try {
voiceStartTimestampRef.current = performance.now();
console.info('[RAHVoice] Voice session starting');
await voiceRealtime.start();
startVoice();
setVoiceStatus('listening');
} catch (error) {
voiceStartTimestampRef.current = null;
handleVoiceError(error instanceof Error ? error : new Error(String(error)));
}
}, [
handleVoiceError,
isVoiceActive,
resetVoiceTranscript,
setVoiceAmplitude,
setVoiceStatus,
setVoiceError,
startVoice,
stopAssistantTTS,
stopVoice,
voiceRealtime,
]);
return (
<div style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
background: '#0a0a0a'
}}>
{focusSummary && (
<header style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 16px',
borderBottom: '1px solid #1a1a1a',
background: '#0a0a0a'
}}>
{/* Focused node info */}
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', minWidth: 0, flex: 1 }}>
<span style={{
color: '#22c55e',
fontSize: '10px',
letterSpacing: '0.18em',
textTransform: 'uppercase'
}}>
Focused Node ({focusSummary.total})
</span>
<span style={{ color: '#d0d0d0', fontSize: '10px' }}>#{focusSummary.id}</span>
<span
style={{
color: '#f3f3f3',
fontSize: '12px',
fontWeight: 600,
flex: 1,
minWidth: 0,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}
title={focusSummary.title}
>
{focusSummary.title}
</span>
</div>
<DelegationIndicator delegations={delegations} />
</header>
)}
<div style={{ flex: 1, overflow: 'auto', padding: '16px', background: '#0a0a0a' }}>
{messages.length === 0 ? (
<AsciiBanner helperName="ra-h" displayName={helperDisplayName} />
) : (
<>
{messages.map((message) => (
<TerminalMessage
key={message.id}
role={message.role}
content={message.content}
timestamp={message.timestamp}
toolName={message.toolName}
status={message.status}
toolArgs={message.toolArgs}
toolResult={message.toolResult}
onNodeClick={onNodeClick}
/>
))}
</>
)}
{/* Voice transcript preview removed for streamlined UI */}
<div ref={messagesEndRef} />
</div>
{!delegationMode && (
<div style={{
padding: '0 16px 16px',
display: 'flex',
flexDirection: 'column',
gap: '12px'
}}>
{(quotaError || isQuotaExceeded) && (
<div style={{
background: 'rgba(239,68,68,0.12)',
border: '1px solid rgba(239,68,68,0.35)',
color: '#fca5a5',
fontSize: '11px',
padding: '10px 12px',
borderRadius: '6px',
lineHeight: 1.4
}}>
{quotaError?.message ?? 'Your monthly allowance has been used. Upgrade your plan to keep chatting with RA-H.'}
</div>
)}
<TerminalInput
onSubmit={sendMessage}
isProcessing={sse.isLoading || isQuotaExceeded}
placeholder={isVoiceActive ? 'voice mode active — end session to type…' : `ask ${helperDisplayName}...`}
helperId={activeTabId ?? undefined}
disabledExternally={isVoiceActive}
disabledMessage="voice mode active"
onVoiceToggle={handleVoiceToggle}
isVoiceActive={isVoiceActive}
voiceAmplitude={voiceAmplitude}
voiceError={voiceError || undefined}
/>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '12px',
flexWrap: 'wrap'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'wrap' }}>
<ModelSelector chatMode={chatMode} />
</div>
<button
onClick={handleNewChat}
style={{
background: 'none',
border: 'none',
color: '#fff',
fontSize: '12px',
cursor: 'pointer',
padding: '4px 8px',
transition: 'color 0.2s',
fontFamily: 'inherit',
display: 'flex',
alignItems: 'center',
gap: '6px',
textTransform: 'uppercase',
letterSpacing: '0.05em'
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = '#d0d0d0';
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = '#fff';
}}
>
<span style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '16px',
height: '16px',
borderRadius: '50%',
background: '#fff',
color: '#0a0a0a',
fontSize: '12px',
lineHeight: 1,
fontWeight: 300,
flexShrink: 0
}}>+</span>
New Chat
</button>
</div>
</div>
)}
</div>
);
}
interface ModelSelectorProps {
chatMode: 'easy' | 'hard';
}
function ModelSelector({ chatMode }: ModelSelectorProps) {
const [dropdownOpen, setDropdownOpen] = useState(false);
const currentModel = chatMode === 'easy' ? 'Easy (GPT)' : 'Hard (Claude)';
const Icon = chatMode === 'easy' ? Zap : Flame;
const activeColor = chatMode === 'easy' ? '#22c55e' : '#f97316';
const options = [
{ id: 'easy', label: 'Easy (GPT)', icon: Zap, color: '#22c55e' },
{ id: 'hard', label: 'Hard (Claude)', icon: Flame, color: '#f97316' },
{ id: 'soon', label: 'Ra-h (Soon)', icon: null, color: '#666', disabled: true }
];
return (
<div style={{ position: 'relative' }}>
<button
onClick={() => setDropdownOpen(!dropdownOpen)}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '8px 12px',
border: '1px solid #1a1a1a',
borderRadius: '6px',
background: '#0f0f0f',
color: '#e5e5e5',
fontSize: '12px',
cursor: 'pointer',
transition: 'all 0.2s'
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#333';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = '#1a1a1a';
}}
>
<Icon size={12} strokeWidth={2.4} color={activeColor} />
{currentModel}
<span style={{
marginLeft: '4px',
transform: dropdownOpen ? 'rotate(180deg)' : 'rotate(0deg)',
transition: 'transform 0.2s'
}}></span>
</button>
{dropdownOpen && (
<div style={{
position: 'absolute',
bottom: '100%', /* Open upward */
left: '0',
marginBottom: '4px',
background: '#1a1a1a',
border: '1px solid #333',
borderRadius: '6px',
minWidth: '150px',
zIndex: 1000,
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.4)'
}}>
{options.map((option) => {
const OptionIcon = option.icon;
const isActive = (option.id === 'easy' && chatMode === 'easy') || (option.id === 'hard' && chatMode === 'hard');
return (
<button
key={option.id}
onClick={() => {
if (!option.disabled && !isActive) {
window.dispatchEvent(new CustomEvent('rah:mode-toggle', { detail: { mode: option.id as 'easy' | 'hard' } }));
}
setDropdownOpen(false);
}}
disabled={option.disabled}
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 12px',
border: 'none',
background: isActive ? 'rgba(34, 197, 94, 0.1)' : 'transparent',
color: option.disabled ? '#666' : (isActive ? '#22c55e' : '#e5e5e5'),
fontSize: '12px',
cursor: option.disabled ? 'not-allowed' : 'pointer',
transition: 'all 0.2s',
borderRadius: '4px'
}}
onMouseEnter={(e) => {
if (!option.disabled && !isActive) {
e.currentTarget.style.background = '#0a0a0a';
}
}}
onMouseLeave={(e) => {
if (!option.disabled && !isActive) {
e.currentTarget.style.background = 'transparent';
}
}}
>
{OptionIcon && <OptionIcon size={12} strokeWidth={2} color={option.color} />}
{!OptionIcon && <div style={{ width: '12px' }} />} {/* Spacer for alignment */}
{option.label}
{isActive && <span style={{ marginLeft: 'auto', color: '#22c55e' }}></span>}
</button>
);
})}
</div>
)}
</div>
);
}
+380
View File
@@ -0,0 +1,380 @@
"use client";
import { useState, useRef, useEffect } from 'react';
import { Mic, MicOff } from 'lucide-react';
interface TerminalInputProps {
onSubmit: (text: string) => void;
isProcessing: boolean;
placeholder?: string;
helperId?: number;
disabledExternally?: boolean;
disabledMessage?: string;
onVoiceToggle?: () => void;
isVoiceActive?: boolean;
voiceAmplitude?: number;
voiceError?: string | null;
}
export default function TerminalInput({
onSubmit,
isProcessing,
placeholder,
helperId,
disabledExternally = false,
disabledMessage,
onVoiceToggle,
isVoiceActive = false,
voiceAmplitude = 0,
voiceError,
}: TerminalInputProps) {
const [input, setInput] = useState('');
const [rows, setRows] = useState(1);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [prompts, setPrompts] = useState<Array<{ id: string; name: string; content: string }>>([]);
const [showSlashMenu, setShowSlashMenu] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
useEffect(() => {
const load = async () => {
if (!helperId) return;
try {
const resp = await fetch(`/api/helpers/${helperId}/prompts`);
const data = await resp.json();
if (resp.ok && data.success) setPrompts(Array.isArray(data.data?.prompts) ? data.data.prompts : []);
} catch (e) {
console.error('Failed to load prompts:', e);
}
};
load();
}, [helperId]);
// Auto-resize textarea
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
const scrollHeight = textareaRef.current.scrollHeight;
const lineHeight = 20; // Approximate line height
const newRows = Math.min(Math.max(1, Math.floor(scrollHeight / lineHeight)), 5);
setRows(newRows);
textareaRef.current.style.height = `${scrollHeight}px`;
}
}, [input]);
const handleSubmit = () => {
if (input.trim() && !isProcessing && !disabledExternally) {
// Numeric slash expansion: only when input is exactly /N
const m = input.trim().match(/^\/(\d{1,2})$/);
if (m) {
const n = parseInt(m[1], 10);
const idx = n - 1;
if (!isNaN(idx) && idx >= 0 && idx < prompts.length) {
const content = String(prompts[idx]?.content || '').trim();
if (content) {
onSubmit(content);
setInput('');
setRows(1);
setShowSlashMenu(false);
return;
}
}
}
onSubmit(input.trim());
setInput('');
setRows(1);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
// Slash menu navigation
if (showSlashMenu) {
if (e.key === 'ArrowDown') { e.preventDefault(); setActiveIndex(i => Math.min(i + 1, prompts.length - 1)); return; }
if (e.key === 'ArrowUp') { e.preventDefault(); setActiveIndex(i => Math.max(i - 1, 0)); return; }
if (e.key === 'Tab') { e.preventDefault(); setActiveIndex(i => (i + 1) % Math.max(prompts.length, 1)); return; }
if (e.key === 'Enter') {
e.preventDefault();
const p = prompts[activeIndex];
if (p) { setInput(p.content); setShowSlashMenu(false); }
return;
}
if (e.key === 'Escape') { setShowSlashMenu(false); }
}
if (e.key === 'Enter' && !e.shiftKey && !disabledExternally) {
e.preventDefault();
handleSubmit();
}
};
useEffect(() => {
// Toggle slash menu when typing starting '/'
const trimmed = input.trimStart();
if (trimmed.startsWith('/')) {
setShowSlashMenu(true);
setActiveIndex(0);
} else {
setShowSlashMenu(false);
}
}, [input]);
const trimmedInput = input.trim();
const showVoiceStart = !isVoiceActive && !trimmedInput && Boolean(onVoiceToggle);
const showVoiceStop = Boolean(onVoiceToggle) && isVoiceActive;
const buttonIsDisabled =
showVoiceStart || showVoiceStop
? false
: (!trimmedInput || isProcessing || disabledExternally);
const handlePrimaryAction = () => {
if (showVoiceStart || showVoiceStop) {
onVoiceToggle?.();
return;
}
handleSubmit();
};
const amplitudeBars = Array.from({ length: 8 });
return (
<>
<style>{`
@keyframes subtle-pulse {
0%, 100% { opacity: 0.5; color: #3a3a3a; }
50% { opacity: 0.7; color: #22c55e; }
}
textarea::placeholder {
animation: subtle-pulse 4s ease-in-out infinite;
}
`}</style>
<div style={{
display: 'flex',
alignItems: 'flex-start',
gap: '8px',
padding: '8px 16px 12px',
background: 'transparent',
// Remove separator/border between chat area and input
borderTop: 'none',
fontFamily: 'inherit'
}}>
{/* Terminal Prompt Symbol */}
<span style={{
color: '#4a4a4a',
fontSize: '13px',
lineHeight: '1.5',
paddingTop: '6px',
userSelect: 'none',
fontWeight: 500
}}>
{'>'}
</span>
{/* Input Wrapper */}
<div style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
gap: '4px'
}}>
{isVoiceActive && (
<div style={{
border: 'none',
borderRadius: '0',
background: 'transparent',
padding: '12px 4px',
display: 'flex',
flexDirection: 'column',
gap: '12px',
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{
fontSize: '12px',
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: '#d4d4d4',
}}>
RA-H is listening
</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(24, minmax(0, 1fr))', gap: '3px', height: '20px', marginTop: '6px' }}>
{amplitudeBars.map((_, index) => {
const level = (index + 1) / amplitudeBars.length;
const active = voiceAmplitude >= level - 0.0001;
return (
<div
key={`amp-${index}`}
style={{
width: '100%',
borderRadius: '2px',
height: `${10 + index * 1.2}px`,
background: active ? '#22c55e' : '#1f1f1f',
transition: 'background 120ms ease',
}}
/>
);
})}
</div>
{voiceError && (
<span style={{ color: '#f87171', fontSize: '11px' }}>{voiceError}</span>
)}
</div>
)}
{/* Input Row with Textarea and Button */}
<div style={{
display: 'flex',
gap: '8px',
alignItems: 'flex-start',
position: 'relative'
}}>
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
disabled={isProcessing || disabledExternally}
placeholder={placeholder || `ask ra-h...`}
rows={rows}
style={{
flex: 1,
background: 'transparent',
border: 'none',
borderRadius: '0',
color: '#e5e5e5',
fontSize: '16px',
fontFamily: 'inherit',
padding: '8px 4px',
resize: 'none',
outline: 'none',
lineHeight: '1.5',
transition: 'all 200ms ease',
minHeight: '32px',
maxHeight: '120px',
overflowY: 'auto',
overflowX: 'hidden',
caretColor: '#22c55e',
...((isProcessing || disabledExternally) && {
opacity: 0.5,
cursor: 'not-allowed',
caretColor: 'transparent'
})
}}
// No focus border toggling; keep clean
onFocus={() => {}}
onBlur={() => {}}
/>
{/* Slash menu */}
{showSlashMenu && prompts.length > 0 && (
<div style={{ position: 'absolute', bottom: '48px', left: '40px', background: '#0f0f0f', border: '1px solid #2a2a2a', borderRadius: '4px', padding: '6px', minWidth: '260px', maxHeight: '200px', overflowY: 'auto', zIndex: 1000 }}>
{prompts.map((p, i) => (
<div
key={p.id}
onMouseDown={(e) => { e.preventDefault(); setInput(p.content); setShowSlashMenu(false); }}
onMouseEnter={() => setActiveIndex(i)}
style={{ display: 'flex', gap: '8px', alignItems: 'center', padding: '6px 8px', cursor: 'pointer', background: i === activeIndex ? '#1a1a1a' : 'transparent', color: '#cfcfcf', fontSize: '12px' }}
>
<span style={{ color: '#5c9aff', fontSize: '10px', width: '20px' }}>/ {i + 1}</span>
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{p.name}</span>
</div>
))}
</div>
)}
{/* Submit Button (minimal icon) */}
<button
onClick={handlePrimaryAction}
disabled={buttonIsDisabled}
aria-label={
showVoiceStop
? 'Stop voice session'
: showVoiceStart
? 'Start voice session'
: isProcessing
? 'Processing'
: 'Send message'
}
title={
showVoiceStop
? 'Stop voice session'
: showVoiceStart
? 'Start voice session'
: disabledExternally
? (disabledMessage || 'Voice mode active')
: isProcessing
? 'Processing…'
: 'Send (Enter)'
}
style={{
width: '36px',
height: '36px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#22c55e',
border: '2px solid #22c55e',
borderRadius: '50%',
color: '#0a0a0a',
cursor: buttonIsDisabled ? 'not-allowed' : 'pointer',
transition: 'all 150ms ease',
opacity: buttonIsDisabled ? 0.5 : 1,
}}
onMouseEnter={(e) => {
if (!buttonIsDisabled) {
e.currentTarget.style.transform = 'translateY(-1px)';
e.currentTarget.style.boxShadow = `0 4px 12px rgba(${showVoiceStart || showVoiceStop ? '124,58,237' : '34,197,94'}, 0.4)`;
}
}}
onMouseLeave={(e) => {
if (!buttonIsDisabled) {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = 'none';
}
}}
>
{showVoiceStop ? (
<MicOff size={16} strokeWidth={2.4} color="#0a0a0a" />
) : showVoiceStart ? (
<Mic size={16} strokeWidth={2.4} color="#0a0a0a" />
) : isProcessing ? (
<span style={{ fontSize: '12px' }}></span>
) : (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path d="M12 4l-6.5 6.5 1.42 1.42L11 8.84V20h2V8.84l4.08 3.08 1.42-1.42L12 4z" />
</svg>
)}
</button>
</div>
{/* Subtle Keyboard Hints */}
<div style={{
display: 'flex',
gap: '10px',
fontSize: '10px',
color: '#353535',
userSelect: 'none',
marginTop: '2px'
}}>
<span> send</span>
<span> newline</span>
{(isProcessing || disabledExternally) && (
<span style={{
marginLeft: 'auto',
color: disabledExternally ? '#a855f7' : '#ffcc66',
textTransform: 'uppercase',
letterSpacing: '0.12em'
}}>
{disabledExternally ? (disabledMessage || 'voice mode active') : 'processing...'}
</span>
)}
</div>
</div>
</div>
</>
);
}
+148
View File
@@ -0,0 +1,148 @@
"use client";
import { useMemo } from 'react';
import { parseAndRenderContent } from '@/components/helpers/NodeLabelRenderer';
import ToolDisplay from '@/components/helpers/ToolDisplay';
import MarkdownRenderer from '@/components/helpers/MarkdownRenderer';
import ReasoningTrace from '@/components/helpers/ReasoningTrace';
import { extractToolContext, extractSources } from '@/utils/toolFormatting';
interface TerminalMessageProps {
role: 'user' | 'assistant' | 'system' | 'tool' | 'thinking';
content: string;
timestamp: Date;
toolName?: string;
status?: 'sending' | 'delivered' | 'error' | 'processing' | 'starting' | 'running' | 'complete';
toolArgs?: any;
toolResult?: any;
onNodeClick?: (nodeId: number) => void;
}
// no local state needed
export default function TerminalMessage({
role,
content,
timestamp,
toolName,
status = 'delivered',
toolArgs,
toolResult,
onNodeClick
}: TerminalMessageProps) {
const dotColor = useMemo(() => {
const colors = {
user: '#5c9aff',
assistant: '#52d97a',
tool: '#ffcc66',
thinking: '#b794f6',
system: '#69d2e7'
};
return colors[role] || colors.assistant;
}, [role]);
const isAnimated = role === 'thinking' || status === 'processing';
const formatTime = (date: Date) => {
return date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
}).toLowerCase();
};
// Handle tool messages specially
if (role === 'tool') {
// THINK tool: display structured trace when present in toolResult
if (toolName === 'think') {
const trace = toolResult?.data?.trace || null;
return <ReasoningTrace trace={trace} collapsible />;
}
// Generic tool display with context and sources
const context =
extractToolContext(toolName, toolArgs) ||
(toolName === 'webSearch' && toolResult?.data?.query ? `Searching for: ${String(toolResult.data.query)}` : undefined);
const sources = extractSources(toolName, toolResult);
const normalizedStatus =
status === 'processing' ? 'running' : status === 'delivered' ? 'complete' : ((status as any) || 'complete');
return (
<ToolDisplay
name={toolName || 'tool'}
status={normalizedStatus}
context={context}
sources={sources}
result={toolResult}
onNodeClick={onNodeClick}
/>
);
}
return (
<div style={{
display: 'flex',
gap: '12px',
padding: '8px 0',
fontFamily: 'inherit'
}}>
{/* Status Dot */}
<span
style={{
flexShrink: 0,
width: '6px',
height: '6px',
marginTop: '7px',
borderRadius: '50%',
background: dotColor,
transition: 'all 150ms ease',
...(isAnimated && {
animation: 'pulse 1.5s infinite'
})
}}
/>
{/* Message Content */}
<div style={{ flex: 1, minWidth: 0 }}>
{content ? (
<MarkdownRenderer content={content} streaming={status === 'processing'} onNodeClick={onNodeClick} />
) : (
role === 'thinking' ? <span>Thinking...</span> : null
)}
{/* References block for assistant messages: extract [Title](URL) */}
{role === 'assistant' && typeof content === 'string' && (() => {
const linkRegex = /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g;
const refs: Array<{ title: string; url: string }> = [];
let m: RegExpExecArray | null;
while ((m = linkRegex.exec(content)) !== null) {
refs.push({ title: m[1], url: m[2] });
if (refs.length >= 12) break;
}
if (refs.length === 0) return null;
return (
<div style={{ marginTop: 8 }}>
<div style={{ color: '#8a8a8a', fontSize: 11, marginBottom: 4 }}>References</div>
<div style={{ display: 'grid', gap: 4 }}>
{refs.map((r, i) => (
<div key={i} style={{ color: '#bdbdbd', fontSize: 12 }}>
{i + 1}. <span style={{ color: '#e5e5e5' }}>{r.title}</span> <a href={r.url} target="_blank" rel="noreferrer" style={{ color: '#5c9aff' }}>{r.url}</a>
</div>
))}
</div>
</div>
);
})()}
{/* Timestamp */}
<span style={{
display: 'inline-block',
marginTop: '4px',
color: '#4a4a4a',
fontSize: '11px'
}}>
{formatTime(timestamp)}
</span>
</div>
</div>
);
}
+328
View File
@@ -0,0 +1,328 @@
import { Fragment, ReactNode, useMemo } from 'react';
import type { AgentDelegation } from '@/services/agents/delegation';
const statusPalette: Record<string, { border: string; badge: string }> = {
queued: { border: '#3a2f5f', badge: '#a78bfa' },
in_progress: { border: '#4a3a6f', badge: '#8b5cf6' },
completed: { border: '#2a2a2a', badge: '#6b6b6b' },
failed: { border: '#5f2a2a', badge: '#ff6b6b' },
};
const NODE_LINK_REGEX = /\[NODE:(\d+):"([^"]+)"\]/g;
const SUMMARY_HEADERS = ['Task', 'Actions', 'Result', 'Nodes', 'Follow-up'];
interface WiseRAHPanelProps {
delegation: AgentDelegation;
onNodeClick?: (nodeId: number) => void;
}
function formatStatus(status: string) {
switch (status) {
case 'queued':
return 'Queued';
case 'in_progress':
return 'Planning';
case 'completed':
return 'Completed';
case 'failed':
return 'Failed';
default:
return status;
}
}
function renderNodeAwareText(text: string, onNodeClick?: (nodeId: number) => void): ReactNode {
const segments: ReactNode[] = [];
let lastIndex = 0;
text.replace(NODE_LINK_REGEX, (match, id, title, offset) => {
if (offset > lastIndex) {
segments.push(<span key={`text-${offset}`}>{text.slice(lastIndex, offset)}</span>);
}
const nodeId = Number(id);
const handleClick = () => {
if (onNodeClick) {
onNodeClick(nodeId);
}
};
segments.push(
<button
key={`node-${offset}`}
type="button"
className="node-pill"
onClick={handleClick}
>
<span className="node-pill-id">NODE:{nodeId}</span>
<span className="node-pill-title">{title}</span>
</button>
);
lastIndex = offset + match.length;
return match;
});
if (lastIndex < text.length) {
segments.push(<span key={`text-end`}>{text.slice(lastIndex)}</span>);
}
return <>{segments}</>;
}
function parseSummary(summary: string) {
const lines = summary.split('\n');
const sections: Array<{ title: string; lines: string[] }> = [];
let current: { title: string; lines: string[] } | null = null;
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line) continue;
const header = SUMMARY_HEADERS.find(h => line.toLowerCase().startsWith(`${h.toLowerCase()}:`));
if (header) {
const content = line.slice(header.length + 1).trim();
current = { title: header, lines: [] };
if (content) current.lines.push(content);
sections.push(current);
} else if (current) {
current.lines.push(line);
} else {
if (!current) {
current = { title: 'Summary', lines: [] };
sections.push(current);
}
current.lines.push(line);
}
}
return sections;
}
function renderSectionLines(lines: string[], onNodeClick?: (nodeId: number) => void) {
const hasBullet = lines.some(line => /^[-*•]/.test(line.trim()));
if (hasBullet) {
return (
<ul className="summary-list">
{lines.map((line, idx) => {
const text = line.replace(/^[-*•]\s*/, '').trim();
return (
<li key={idx}>
{renderNodeAwareText(text, onNodeClick)}
</li>
);
})}
</ul>
);
}
return (
<div className="summary-paragraphs">
{lines.map((line, idx) => (
<p key={idx}>{renderNodeAwareText(line, onNodeClick)}</p>
))}
</div>
);
}
export default function WiseRAHPanel({ delegation, onNodeClick }: WiseRAHPanelProps) {
const palette = statusPalette[delegation.status] ?? statusPalette.queued;
const parsedSummary = useMemo(() => {
if (!delegation.summary) return [];
return parseSummary(delegation.summary);
}, [delegation.summary]);
return (
<div className="wise-panel">
<header className="wise-header" style={{ borderBottomColor: palette.border }}>
<span className="status-dot" style={{ background: palette.badge }} />
<span className="header-title">WISE RA-H</span>
<span className="header-status">· {formatStatus(delegation.status)}</span>
<span className="header-time">{new Date(delegation.updatedAt).toLocaleTimeString()}</span>
</header>
<section className="section">
<h3>Goal</h3>
<p>{delegation.task}</p>
</section>
{delegation.context.length > 0 && (
<section className="section">
<h3>Context</h3>
<ul className="context-list">
{delegation.context.map((item, idx) => (
<li key={idx}>{renderNodeAwareText(item, onNodeClick)}</li>
))}
</ul>
</section>
)}
{parsedSummary.length > 0 && (
<section className="section">
<h3>Summary</h3>
<div className="summary-card">
{parsedSummary.map(section => (
<div key={section.title} className="summary-section">
<div className="summary-title">{section.title}</div>
{renderSectionLines(section.lines, onNodeClick)}
</div>
))}
</div>
</section>
)}
<style jsx>{`
.wise-panel {
display: flex;
flex-direction: column;
gap: 18px;
padding: 24px;
background: #0a0a0a;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
height: 100%;
overflow-y: auto;
}
.wise-header {
display: flex;
align-items: center;
gap: 10px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(167, 139, 250, 0.35);
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.header-title {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.12em;
color: #bba4ff;
}
.header-status {
font-size: 12px;
color: #8b82a6;
}
.header-time {
margin-left: auto;
font-size: 11px;
color: #5f5f5f;
}
.section {
display: flex;
flex-direction: column;
gap: 10px;
}
.section h3 {
margin: 0;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #a78bfa;
}
.section p {
margin: 0;
font-size: 14px;
line-height: 1.6;
color: #d4d4d4;
}
.context-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.context-list li {
font-size: 13px;
color: #a0a0a0;
}
.summary-card {
background: #101010;
border: 1px solid rgba(167, 139, 250, 0.1);
border-radius: 12px;
padding: 18px;
display: flex;
flex-direction: column;
gap: 16px;
}
.summary-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.summary-title {
font-size: 12px;
font-weight: 600;
color: #c5b5ff;
letter-spacing: 0.05em;
}
.summary-paragraphs p {
margin: 0;
font-size: 13px;
color: #e5e5e5;
line-height: 1.6;
}
.summary-list {
margin: 0;
padding-left: 18px;
display: flex;
flex-direction: column;
gap: 6px;
}
.summary-list li {
font-size: 13px;
color: #e5e5e5;
line-height: 1.6;
}
.node-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
margin: 0 4px 4px 0;
font-size: 12px;
border-radius: 999px;
border: 1px solid rgba(167, 139, 250, 0.3);
background: rgba(167, 139, 250, 0.08);
color: #d8d3ff;
cursor: pointer;
transition: background 0.15s ease, border 0.15s ease;
}
.node-pill:hover {
background: rgba(167, 139, 250, 0.18);
border: 1px solid rgba(167, 139, 250, 0.6);
}
.node-pill-id {
font-size: 11px;
opacity: 0.7;
}
.node-pill-title {
font-size: 12px;
font-weight: 500;
}
`}</style>
</div>
);
}
@@ -0,0 +1,300 @@
import { useCallback, useEffect, useRef, useState } from 'react';
export type TTSStatus = 'idle' | 'loading' | 'speaking';
interface UseAssistantTTSOptions {
voice?: string;
onSpeechStart?: () => void;
onSpeechComplete?: () => void;
onError?: (error: Error) => void;
}
interface SpeakRequestMetadata {
sessionId?: string | null;
helper?: string | null;
requestId?: string;
messageId?: string | null;
}
interface SpeakOptions {
flush?: boolean;
metadata?: SpeakRequestMetadata;
}
type SpeakQueueItem = {
text: string;
metadata?: SpeakRequestMetadata;
};
export function useAssistantTTS(options: UseAssistantTTSOptions = {}) {
const [status, setStatus] = useState<TTSStatus>('idle');
const queueRef = useRef<SpeakQueueItem[]>([]);
const isProcessingRef = useRef(false);
const abortRef = useRef<AbortController | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const objectUrlRef = useRef<string | null>(null);
const mediaSourceRef = useRef<MediaSource | null>(null);
const sourceBufferRef = useRef<SourceBuffer | null>(null);
const chunkQueueRef = useRef<ArrayBuffer[]>([]);
const updateEndHandlerRef = useRef<(() => void) | null>(null);
const readerDoneRef = useRef(false);
const processQueueRef = useRef<(() => void) | null>(null);
const onSpeechStartRef = useRef(options.onSpeechStart);
const onSpeechCompleteRef = useRef(options.onSpeechComplete);
const onErrorRef = useRef(options.onError);
const voiceRef = useRef(options.voice);
useEffect(() => {
onSpeechStartRef.current = options.onSpeechStart;
}, [options.onSpeechStart]);
useEffect(() => {
onSpeechCompleteRef.current = options.onSpeechComplete;
}, [options.onSpeechComplete]);
useEffect(() => {
onErrorRef.current = options.onError;
}, [options.onError]);
useEffect(() => {
voiceRef.current = options.voice;
}, [options.voice]);
const setStatusSafe = useCallback((next: TTSStatus) => {
setStatus(next);
}, []);
const cleanupMedia = useCallback(() => {
if (sourceBufferRef.current && updateEndHandlerRef.current) {
try {
sourceBufferRef.current.removeEventListener('updateend', updateEndHandlerRef.current);
} catch (err) {
console.warn('[TTS] Failed to detach source buffer listener', err);
}
}
updateEndHandlerRef.current = null;
sourceBufferRef.current = null;
mediaSourceRef.current = null;
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current);
objectUrlRef.current = null;
}
chunkQueueRef.current = [];
readerDoneRef.current = false;
}, []);
const handleError = useCallback((error: Error) => {
console.error('[TTS] Playback error', error);
onErrorRef.current?.(error);
}, []);
const stopCurrentPlayback = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
const audio = audioRef.current;
if (audio) {
audio.pause();
audio.removeAttribute('src');
try {
audio.load();
} catch (err) {
console.warn('[TTS] Failed to reset audio element', err);
}
}
cleanupMedia();
isProcessingRef.current = false;
}, [cleanupMedia]);
const ensureAudioElement = useCallback(() => {
if (audioRef.current) {
return audioRef.current;
}
const audio = new Audio();
audio.autoplay = true;
audio.preload = 'auto';
audio.addEventListener('playing', () => {
setStatusSafe('speaking');
onSpeechStartRef.current?.();
});
audio.addEventListener('ended', () => {
cleanupMedia();
isProcessingRef.current = false;
setStatusSafe('idle');
onSpeechCompleteRef.current?.();
processQueueRef.current?.();
});
audio.addEventListener('error', () => {
handleError(new Error('Audio playback failed'));
stopCurrentPlayback();
processQueueRef.current?.();
});
audioRef.current = audio;
return audio;
}, [cleanupMedia, handleError, setStatusSafe, stopCurrentPlayback]);
const flushChunks = useCallback(() => {
const sourceBuffer = sourceBufferRef.current;
const mediaSource = mediaSourceRef.current;
if (!sourceBuffer || !mediaSource || sourceBuffer.updating) {
return;
}
const nextChunk = chunkQueueRef.current.shift();
if (nextChunk) {
try {
sourceBuffer.appendBuffer(nextChunk);
} catch (error) {
handleError(error instanceof Error ? error : new Error(String(error)));
}
return;
}
if (readerDoneRef.current && !sourceBuffer.updating) {
try {
mediaSource.endOfStream();
} catch (error) {
console.warn('[TTS] Failed to end media source stream', error);
}
}
}, [handleError]);
const processQueue = useCallback(async () => {
if (isProcessingRef.current) return;
const next = queueRef.current.shift();
if (!next) {
setStatusSafe('idle');
return;
}
isProcessingRef.current = true;
setStatusSafe('loading');
try {
const controller = new AbortController();
abortRef.current = controller;
const payload: Record<string, any> = {
text: next.text,
voice: voiceRef.current,
};
if (next.metadata?.sessionId) {
payload.sessionId = next.metadata.sessionId;
}
if (next.metadata?.helper) {
payload.helper = next.metadata.helper;
}
if (next.metadata?.requestId) {
payload.requestId = next.metadata.requestId;
}
if (next.metadata?.messageId) {
payload.messageId = next.metadata.messageId;
}
const response = await fetch('/api/voice/tts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: controller.signal,
});
if (!response.ok || !response.body) {
throw new Error((await response.text().catch(() => '')) || 'Failed to synthesize audio');
}
const reader = response.body.getReader();
const audio = ensureAudioElement();
cleanupMedia();
const mimeType = response.headers.get('Content-Type') || 'audio/mpeg';
const mediaSource = new MediaSource();
mediaSourceRef.current = mediaSource;
const objectUrl = URL.createObjectURL(mediaSource);
objectUrlRef.current = objectUrl;
audio.src = objectUrl;
audio.load();
const onSourceOpen = () => {
mediaSource.removeEventListener('sourceopen', onSourceOpen);
let sourceBuffer: SourceBuffer;
try {
sourceBuffer = mediaSource.addSourceBuffer(mimeType);
} catch {
throw new Error(`Unsupported audio format: ${mimeType}`);
}
sourceBufferRef.current = sourceBuffer;
const handleUpdateEnd = () => flushChunks();
updateEndHandlerRef.current = handleUpdateEnd;
sourceBuffer.addEventListener('updateend', handleUpdateEnd);
flushChunks();
};
mediaSource.addEventListener('sourceopen', onSourceOpen);
const pump = async () => {
while (true) {
const { value, done } = await reader.read();
if (done) {
readerDoneRef.current = true;
flushChunks();
break;
}
if (value) {
const buffer = value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength);
chunkQueueRef.current.push(buffer);
flushChunks();
}
}
};
pump().catch((error) => {
const err = error instanceof Error ? error : new Error(String(error));
if ((err as DOMException).name !== 'AbortError') {
handleError(err);
}
stopCurrentPlayback();
processQueueRef.current?.();
});
audio.play().catch((error) => {
handleError(error instanceof Error ? error : new Error(String(error)));
});
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
return;
}
isProcessingRef.current = false;
setStatusSafe('idle');
handleError(error instanceof Error ? error : new Error(String(error)));
processQueueRef.current?.();
}
}, [cleanupMedia, ensureAudioElement, flushChunks, handleError, setStatusSafe, stopCurrentPlayback]);
processQueueRef.current = processQueue;
const speak = useCallback(
(text: string, options?: SpeakOptions) => {
const trimmed = text.trim();
if (!trimmed) return;
if (options?.flush) {
queueRef.current = [{ text: trimmed, metadata: options.metadata }];
stopCurrentPlayback();
} else {
queueRef.current.push({ text: trimmed, metadata: options?.metadata });
}
processQueue();
},
[processQueue, stopCurrentPlayback]
);
const stop = useCallback(() => {
queueRef.current = [];
stopCurrentPlayback();
setStatusSafe('idle');
}, [setStatusSafe, stopCurrentPlayback]);
useEffect(() => {
return () => {
stop();
};
}, [stop]);
return {
status,
speak,
stop,
} as const;
}
@@ -0,0 +1,570 @@
"use client";
import { useCallback, useEffect, useRef } from 'react';
export type RealtimeConnectionState = 'idle' | 'connecting' | 'ready' | 'capturing';
interface VoiceRealtimeCallbacks {
onStatusChange?: (status: 'idle' | 'listening' | 'thinking' | 'speaking') => void;
onInterimTranscript?: (text: string) => void;
onFinalTranscript?: (text: string) => void;
onAmplitude?: (value: number) => void;
onError?: (error: Error) => void;
}
interface UseRealtimeVoiceClientOptions {
getAuthToken?: () => string | null | undefined;
fetchEphemeralToken?: (
authToken: string | null
) => Promise<{ client_secret: { value: string }; model: string; voice: string }>;
silenceThresholdMs?: number;
silenceAmplitudeCutoff?: number;
}
const DEFAULT_SILENCE_THRESHOLD_MS = 800;
const DEFAULT_SILENCE_AMPLITUDE = 0.0015;
type Nullable<T> = T | null;
function calculateRms(buffer: Float32Array) {
if (!buffer.length) return 0;
let sumSquares = 0;
for (let i = 0; i < buffer.length; i += 1) {
const value = buffer[i];
sumSquares += value * value;
}
return Math.sqrt(sumSquares / buffer.length);
}
export function useRealtimeVoiceClient(
callbacks: VoiceRealtimeCallbacks,
options: UseRealtimeVoiceClientOptions = {}
) {
const { onStatusChange, onInterimTranscript, onFinalTranscript, onAmplitude, onError } = callbacks;
const { getAuthToken, fetchEphemeralToken, silenceThresholdMs, silenceAmplitudeCutoff } = options;
const connectionStateRef = useRef<RealtimeConnectionState>('idle');
const peerConnectionRef = useRef<Nullable<RTCPeerConnection>>(null);
const dataChannelRef = useRef<Nullable<RTCDataChannel>>(null);
const audioContextRef = useRef<Nullable<AudioContext>>(null);
const processorNodeRef = useRef<Nullable<ScriptProcessorNode>>(null);
const mediaStreamRef = useRef<Nullable<MediaStream>>(null);
const mediaSourceRef = useRef<Nullable<MediaStreamAudioSourceNode>>(null);
const awaitingTranscriptRef = useRef(false);
const hasUncommittedAudioRef = useRef(false);
const lastSpeechAtRef = useRef<number | null>(null);
const destroyedRef = useRef(false);
const channelReadyRef = useRef(false);
const silenceWindowMs = silenceThresholdMs ?? DEFAULT_SILENCE_THRESHOLD_MS;
const amplitudeGate = silenceAmplitudeCutoff ?? DEFAULT_SILENCE_AMPLITUDE;
const onStatusChangeRef = useRef(onStatusChange);
const onInterimTranscriptRef = useRef(onInterimTranscript);
const onFinalTranscriptRef = useRef(onFinalTranscript);
const onAmplitudeRef = useRef(onAmplitude);
const onErrorRef = useRef(onError);
useEffect(() => {
onStatusChangeRef.current = onStatusChange;
}, [onStatusChange]);
useEffect(() => {
onInterimTranscriptRef.current = onInterimTranscript;
}, [onInterimTranscript]);
useEffect(() => {
onFinalTranscriptRef.current = onFinalTranscript;
}, [onFinalTranscript]);
useEffect(() => {
onAmplitudeRef.current = onAmplitude;
}, [onAmplitude]);
useEffect(() => {
onErrorRef.current = onError;
}, [onError]);
const setConnectionState = useCallback((next: RealtimeConnectionState) => {
connectionStateRef.current = next;
}, []);
const teardownInputNodes = useCallback(() => {
processorNodeRef.current?.disconnect();
mediaSourceRef.current?.disconnect();
processorNodeRef.current?.removeEventListener('audioprocess', () => undefined);
processorNodeRef.current = null;
mediaSourceRef.current = null;
if (mediaStreamRef.current) {
mediaStreamRef.current.getTracks().forEach((track) => track.stop());
mediaStreamRef.current = null;
}
}, []);
const closePeerConnection = useCallback(() => {
if (dataChannelRef.current) {
try {
dataChannelRef.current.close();
} catch (err) {
console.warn('[VoiceRealtime] Failed to close data channel:', err);
}
}
dataChannelRef.current = null;
if (peerConnectionRef.current) {
try {
peerConnectionRef.current.ontrack = null;
peerConnectionRef.current.onconnectionstatechange = null;
peerConnectionRef.current.close();
} catch (err) {
console.warn('[VoiceRealtime] Failed to close peer connection:', err);
}
}
peerConnectionRef.current = null;
channelReadyPromiseRef.current = null;
channelReadyResolveRef.current = null;
}, []);
const resetState = useCallback(() => {
awaitingTranscriptRef.current = false;
hasUncommittedAudioRef.current = false;
lastSpeechAtRef.current = null;
channelReadyPromiseRef.current = null;
channelReadyResolveRef.current = null;
}, []);
const notifyError = useCallback((message: string | Error) => {
const error = message instanceof Error ? message : new Error(message);
onErrorRef.current?.(error);
}, []);
const channelReadyPromiseRef = useRef<Promise<void> | null>(null);
const channelReadyResolveRef = useRef<(() => void) | null>(null);
const ensureChannelReady = useCallback(async () => {
const channel = dataChannelRef.current;
if (channel?.readyState === 'open') return;
if (!channelReadyPromiseRef.current) {
channelReadyPromiseRef.current = new Promise<void>((resolve) => {
channelReadyResolveRef.current = resolve;
});
}
await channelReadyPromiseRef.current;
}, []);
const sendEvent = useCallback(
async (event: Record<string, unknown>) => {
await ensureChannelReady();
const channel = dataChannelRef.current;
if (!channel || channel.readyState !== 'open') {
throw new Error('Realtime data channel is not open');
}
channel.send(JSON.stringify(event));
},
[ensureChannelReady]
);
const initialiseMicrophone = useCallback(async () => {
if (typeof window === 'undefined') {
throw new Error('Voice not supported in this environment');
}
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
channelCount: 1,
echoCancellation: false,
autoGainControl: false,
noiseSuppression: false,
},
});
if (destroyedRef.current) {
stream.getTracks().forEach((track) => {
try {
track.stop();
} catch (err) {
console.warn('[VoiceRealtime] Failed to stop track after destroy', err);
}
});
return stream;
}
const audioContext = new AudioContext();
const source = audioContext.createMediaStreamSource(stream);
const processor = audioContext.createScriptProcessor(2048, 1, 1);
processor.onaudioprocess = (event) => {
if (destroyedRef.current) return;
const inputBuffer = event.inputBuffer.getChannelData(0);
const amplitude = calculateRms(inputBuffer);
onAmplitudeRef.current?.(Math.min(1, amplitude * 8));
const now = Date.now();
const channelReady = channelReadyRef.current;
if (amplitude > amplitudeGate && channelReady) {
hasUncommittedAudioRef.current = true;
lastSpeechAtRef.current = now;
if (!awaitingTranscriptRef.current) {
onStatusChangeRef.current?.('listening');
}
} else if (
channelReady &&
hasUncommittedAudioRef.current &&
!awaitingTranscriptRef.current &&
lastSpeechAtRef.current &&
now - lastSpeechAtRef.current > silenceWindowMs
) {
awaitingTranscriptRef.current = true;
hasUncommittedAudioRef.current = false;
lastSpeechAtRef.current = null;
onStatusChangeRef.current?.('thinking');
}
};
source.connect(processor);
processor.connect(audioContext.destination);
audioContextRef.current = audioContext;
processorNodeRef.current = processor;
mediaSourceRef.current = source;
mediaStreamRef.current = stream;
return stream;
}, [amplitudeGate, silenceWindowMs]);
const disconnect = useCallback(() => {
destroyedRef.current = true;
teardownInputNodes();
closePeerConnection();
audioContextRef.current?.close().catch(() => undefined);
audioContextRef.current = null;
resetState();
channelReadyRef.current = false;
setConnectionState('idle');
onStatusChangeRef.current?.('idle');
onAmplitudeRef.current?.(0);
}, [closePeerConnection, resetState, setConnectionState, teardownInputNodes]);
const extractTextDelta = useCallback((payload: unknown): string => {
if (!payload) return '';
if (typeof payload === 'string') return payload;
if (typeof payload !== 'object') return '';
const record = payload as Record<string, unknown>;
if (typeof record.delta === 'string') return record.delta;
if (typeof record.text === 'string') return record.text;
const outputText = record.output_text as Record<string, unknown> | undefined;
if (typeof outputText?.text === 'string') return outputText.text;
if (Array.isArray(record.output)) {
return record.output
.flatMap((item) =>
Array.isArray((item as Record<string, unknown>)?.content)
? ((item as Record<string, unknown>).content as unknown[])
: []
)
.map((content) => {
if (!content || typeof content !== 'object') return '';
const entry = content as Record<string, unknown>;
const nestedText = entry.text as Record<string, unknown> | string | undefined;
if (typeof nestedText === 'string') return nestedText;
if (typeof nestedText === 'object' && nestedText !== null && typeof nestedText.value === 'string') {
return nestedText.value;
}
return '';
})
.filter(Boolean)
.join('');
}
if (Array.isArray(record.content)) {
return record.content
.map((content) => {
if (!content || typeof content !== 'object') return '';
const entry = content as Record<string, unknown>;
const nestedText = entry.text as Record<string, unknown> | string | undefined;
if (typeof nestedText === 'string') return nestedText;
if (typeof nestedText === 'object' && nestedText !== null && typeof nestedText.value === 'string') {
return nestedText.value;
}
return '';
})
.filter(Boolean)
.join('');
}
return '';
}, []);
const handleDataMessage = useCallback(
(raw: string) => {
try {
const data = JSON.parse(raw);
if (process.env.NODE_ENV !== 'production') {
console.debug('[VoiceRealtime] Event', data.type, data);
}
if (data.type === 'conversation.item.input_audio_transcription.completed') {
const transcriptSource =
typeof data.transcript === 'string'
? data.transcript
: extractTextDelta(data.item ?? data.content ?? data);
const transcript = transcriptSource?.trim();
awaitingTranscriptRef.current = false;
hasUncommittedAudioRef.current = false;
lastSpeechAtRef.current = null;
if (transcript) {
console.info('[VoiceRealtime] Transcript completed', transcript);
onInterimTranscriptRef.current?.('');
onFinalTranscriptRef.current?.(transcript);
} else {
console.warn('[VoiceRealtime] Received empty transcription event');
onInterimTranscriptRef.current?.('');
}
onStatusChangeRef.current?.('listening');
return;
}
if (data.type === 'error' && data.error) {
console.error('[VoiceRealtime] Server error', data.error);
}
if (data.type === 'response.error' && data.error) {
console.error('[VoiceRealtime] Response error', data.error);
}
} catch (err) {
notifyError(err instanceof Error ? err : new Error(String(err)));
}
},
[extractTextDelta, notifyError]
);
const waitForIceGatheringComplete = useCallback((pc: RTCPeerConnection, timeoutMs = 2000) => {
if (pc.iceGatheringState === 'complete') {
return Promise.resolve();
}
return new Promise<void>((resolve) => {
let resolved = false;
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
const finish = () => {
if (resolved) return;
resolved = true;
pc.removeEventListener('icegatheringstatechange', handleStateChange);
if (timeoutHandle) {
clearTimeout(timeoutHandle);
timeoutHandle = null;
}
resolve();
};
const handleStateChange = () => {
if (pc.iceGatheringState === 'complete') {
finish();
}
};
if (timeoutMs) {
timeoutHandle = setTimeout(() => {
console.warn('[VoiceRealtime] ICE gathering timeout reached, proceeding with partial candidates');
finish();
}, timeoutMs);
}
pc.addEventListener('icegatheringstatechange', handleStateChange);
});
}, []);
const start = useCallback(async () => {
try {
destroyedRef.current = false;
resetState();
setConnectionState('connecting');
const authToken = getAuthToken?.() ?? null;
const fetchToken = fetchEphemeralToken
? fetchEphemeralToken
: async (token: string | null) => {
const response = await fetch('/api/realtime/ephemeral-token', {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
throw new Error(payload.error || `Failed to mint ephemeral token (${response.status})`);
}
return response.json();
};
const sessionPromise = fetchToken(authToken);
const microphonePromise = initialiseMicrophone();
const session = await sessionPromise;
const clientSecret = session?.client_secret?.value || session?.client_secret;
if (!clientSecret || typeof clientSecret !== 'string') {
throw new Error('Realtime session did not include a client_secret');
}
const pc = new RTCPeerConnection({
iceServers: [{ urls: ['stun:stun.l.google.com:19302'] }],
});
peerConnectionRef.current = pc;
const channel = pc.createDataChannel('oai-events');
dataChannelRef.current = channel;
channelReadyPromiseRef.current = new Promise<void>((resolve) => {
channelReadyResolveRef.current = resolve;
});
channelReadyRef.current = false;
channel.onmessage = (event) => {
handleDataMessage(event.data);
};
channel.onopen = () => {
console.info('[VoiceRealtime] Data channel open');
setConnectionState('ready');
channelReadyResolveRef.current?.();
channelReadyResolveRef.current = null;
channelReadyRef.current = true;
void sendEvent({
type: 'session.update',
session: {
modalities: ['text'],
input_audio_transcription: {
model: 'whisper-1',
},
turn_detection: {
type: 'server_vad',
threshold: 0.5,
silence_duration_ms: 1800,
prefix_padding_ms: 300,
create_response: false,
},
instructions: 'You are the RA-H voice transport. Only transcribe user speech accurately, never speak responses.',
},
}).catch((err) => {
console.error('[VoiceRealtime] Failed to send session update:', err);
});
};
channel.onerror = (event) => {
console.error('[VoiceRealtime] Data channel error:', event);
notifyError(new Error('Realtime connection error'));
channelReadyRef.current = false;
disconnect();
};
channel.onclose = () => {
console.warn('[VoiceRealtime] Data channel closed');
channelReadyPromiseRef.current = null;
channelReadyResolveRef.current = null;
channelReadyRef.current = false;
if (!destroyedRef.current) {
notifyError(new Error('Realtime data channel closed unexpectedly'));
disconnect();
}
};
pc.onconnectionstatechange = () => {
const state = pc.connectionState;
console.info('[VoiceRealtime] Peer connection state:', state);
if (state === 'connected') {
onStatusChangeRef.current?.('listening');
setConnectionState('capturing');
}
if (state === 'failed' || state === 'closed' || state === 'disconnected') {
if (!destroyedRef.current) {
notifyError(new Error(`Realtime connection ${state}`));
}
disconnect();
}
};
pc.oniceconnectionstatechange = () => {
console.info('[VoiceRealtime] ICE connection state:', pc.iceConnectionState);
};
pc.ontrack = () => undefined;
const mediaStream = await microphonePromise;
mediaStream?.getAudioTracks().forEach((track) => {
if (peerConnectionRef.current?.signalingState !== 'closed') {
peerConnectionRef.current?.addTrack(track, mediaStream);
}
});
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
await waitForIceGatheringComplete(pc, 2000);
const localDescription = pc.localDescription;
if (!localDescription) {
throw new Error('Failed to create local description for realtime call');
}
const response = await fetch('https://api.openai.com/v1/realtime/calls', {
method: 'POST',
headers: {
Authorization: `Bearer ${clientSecret}`,
'Content-Type': 'application/sdp',
'OpenAI-Beta': 'realtime=v1',
},
body: localDescription.sdp,
});
if (!response.ok) {
let errorDetail = '';
try {
const text = await response.text();
errorDetail = text;
const maybeJson = text ? JSON.parse(text) : null;
if (maybeJson?.error?.message) {
errorDetail = maybeJson.error.message;
}
} catch {
// ignore JSON parse errors and fall back to raw text
}
const friendly = errorDetail || response.statusText || 'Unknown realtime error';
console.error('[VoiceRealtime] Failed to start realtime call:', friendly, { status: response.status });
throw new Error(`Failed to establish realtime call (${response.status}): ${friendly}`);
}
const answerSdp = await response.text();
await pc.setRemoteDescription({ type: 'answer', sdp: answerSdp });
onStatusChangeRef.current?.('listening');
setConnectionState('capturing');
} catch (err) {
disconnect();
notifyError(err instanceof Error ? err : new Error(String(err)));
throw err;
}
}, [disconnect, fetchEphemeralToken, getAuthToken, handleDataMessage, initialiseMicrophone, notifyError, resetState, sendEvent, setConnectionState, waitForIceGatheringComplete]);
const stop = useCallback(() => {
disconnect();
}, [disconnect]);
const latestStopRef = useRef<() => void>(() => {});
useEffect(() => {
latestStopRef.current = stop;
}, [stop]);
useEffect(() => {
return () => {
latestStopRef.current();
};
}, []);
return {
start,
stop,
} as const;
}
+212
View File
@@ -0,0 +1,212 @@
"use client";
import { useRef, useState } from 'react';
export enum MessageRole {
USER = 'user',
ASSISTANT = 'assistant',
TOOL = 'tool',
SYSTEM = 'system',
THINKING = 'thinking',
}
export interface ChatMessage {
id: string;
role: MessageRole.USER | MessageRole.ASSISTANT | MessageRole.TOOL | MessageRole.SYSTEM | MessageRole.THINKING;
content: string;
timestamp: Date;
toolName?: string;
status?: 'processing' | 'delivered' | 'error' | 'starting' | 'running' | 'complete';
toolArgs?: any;
toolResult?: any;
}
interface SendParams {
text: string;
history: ChatMessage[];
openTabs: any[];
activeTabId: number | null;
currentView?: 'nodes' | 'memory';
sessionId: string;
mode: 'easy' | 'hard';
}
interface UseSSEChatOptions {
getAuthToken?: () => string | null | undefined;
beforeRequest?: () => boolean;
onRequestError?: (error: unknown, response?: Response) => boolean | void;
onStreamComplete?: () => void | Promise<void>;
getApiKeys?: () => { openai?: string; anthropic?: string } | undefined;
}
export function useSSEChat(
endpoint: string,
setMessages: (updater: (prev: ChatMessage[]) => ChatMessage[]) => void,
options: UseSSEChatOptions = {}
) {
const { getAuthToken, beforeRequest, onRequestError, onStreamComplete, getApiKeys } = options;
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const abort = () => {
abortControllerRef.current?.abort();
};
const send = async ({ text, history, openTabs, activeTabId, currentView, sessionId, mode }: SendParams) => {
const trimmed = text.trim();
if (!trimmed || isLoading) return;
if (beforeRequest && !beforeRequest()) return;
setIsLoading(true);
setError(null);
const userMessage: ChatMessage = {
id: crypto.randomUUID(),
role: MessageRole.USER,
content: trimmed,
timestamp: new Date()
};
const assistantMessage: ChatMessage = {
id: crypto.randomUUID(),
role: MessageRole.ASSISTANT,
content: '',
timestamp: new Date()
};
setMessages((prev) => [...prev, userMessage, assistantMessage]);
let handledError = false;
let currentAssistantMessage = assistantMessage;
try {
abortControllerRef.current = new AbortController();
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
const token = getAuthToken?.();
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify({
messages: history
.concat(userMessage)
.filter((m) => [MessageRole.USER, MessageRole.ASSISTANT, MessageRole.SYSTEM].includes(m.role))
.map((m) => ({ role: m.role, parts: [{ type: 'text', text: m.content }] })),
openTabs,
activeTabId,
currentView,
sessionId,
mode,
apiKeys: getApiKeys?.(),
}),
signal: abortControllerRef.current.signal
});
if (!response.ok) {
const error = new Error(`HTTP error! status: ${response.status}`);
handledError = Boolean(onRequestError?.(error, response));
setMessages((prev) =>
prev.filter((m) => !(m.id === currentAssistantMessage.id && m.content === ''))
);
if (handledError) {
return;
}
throw error;
}
const reader = response.body?.getReader();
const decoder = new TextDecoder();
if (!reader) throw new Error('No response body');
let fullContent = '';
let toolCallsActive: Record<string, string> = {};
let hasToolCalls = false;
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n');
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
try {
const data = JSON.parse(line.substring(6));
if (data.type === 'text-delta' && data.delta) {
fullContent += data.delta;
setMessages((prev) => prev.map((m) => (m.id === currentAssistantMessage.id ? { ...m, content: fullContent } : m)));
}
if (data.type === 'tool-input-start') {
hasToolCalls = true;
toolCallsActive[data.toolCallId] = data.toolName;
const toolMessage: ChatMessage = {
id: `tool-${data.toolCallId}`,
role: MessageRole.TOOL,
content: data.toolName,
timestamp: new Date(),
toolName: data.toolName,
status: 'running',
toolArgs: (data.args ?? data.input ?? data.parameters ?? null)
};
setMessages((prev) => {
const filtered = prev.filter((m) => m.id !== toolMessage.id);
const index = filtered.findIndex((m) => m.id === currentAssistantMessage.id);
return [...filtered.slice(0, index), toolMessage, ...filtered.slice(index)];
});
}
if (data.type === 'tool-output-available') {
delete toolCallsActive[data.toolCallId];
setMessages((prev) =>
prev.map((m) =>
m.id === `tool-${data.toolCallId}`
? { ...m, content: `${m.toolName}`, status: 'complete', toolResult: (data.result ?? data.output ?? null) }
: m
)
);
if (Object.keys(toolCallsActive).length === 0 && hasToolCalls) {
const newAssistantMessage: ChatMessage = {
id: crypto.randomUUID(),
role: MessageRole.ASSISTANT,
content: '',
timestamp: new Date()
};
currentAssistantMessage = newAssistantMessage;
fullContent = '';
setMessages((prev) => [...prev, newAssistantMessage]);
}
}
} catch {
// ignore malformed lines
}
}
}
if (onStreamComplete) {
await onStreamComplete();
}
} catch (err: any) {
if (err?.name !== 'AbortError') {
if (!handledError) {
handledError = Boolean(onRequestError?.(err));
}
if (!handledError) {
setError(err?.message || 'Failed to send message');
}
setMessages((prev) =>
prev.filter((m) => !(m.id === currentAssistantMessage.id && m.content === ''))
);
}
} finally {
setIsLoading(false);
abortControllerRef.current = null;
}
};
return { isLoading, error, send, abort };
}

Some files were not shown because too many files have changed in this diff Show More