diff --git a/CLAUDE.md b/CLAUDE.md index 52069ff..cbb80e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,8 +12,9 @@ Edward is a full-stack AI assistant with long-term memory, built with Next.js, F ## Running the App -**The backend must run natively on macOS** (not in a container). This is required for the scheduled events scheduler and AppleScript/MCP integrations. +**The backend runs on macOS and Windows.** macOS has full feature support; Windows runs with graceful degradation (Apple-specific features disabled, PWA + push notifications for messaging). +### macOS ```bash # First-time setup ./setup.sh # Creates venv, installs deps, sets up .env @@ -26,7 +27,24 @@ Edward is a full-stack AI assistant with long-term memory, built with Next.js, F # Individual services cd backend && ./start.sh # Backend (FastAPI :8000) cd frontend && npm install && npm run dev # Frontend (Next.js :3000) +``` + +### Windows (PowerShell) +```powershell +# First-time setup +.\setup.ps1 # Creates venv, installs deps, sets up .env + +# Recommended: restart script (handles both services) +.\restart.ps1 # Restart both frontend + backend +.\restart.ps1 frontend # Restart only frontend +.\restart.ps1 backend # Stop and restart only backend +# Individual services +cd backend; .\start.ps1 # Backend (FastAPI :8000) +cd frontend; npm install; npm run dev # Frontend (Next.js :3000) +``` + +```bash # Lint frontend cd frontend && npm run lint ``` diff --git a/IMPLEMENTATION_PLANS/000_MASTER_PLAN.md b/IMPLEMENTATION_PLANS/000_MASTER_PLAN.md new file mode 100644 index 0000000..0146820 --- /dev/null +++ b/IMPLEMENTATION_PLANS/000_MASTER_PLAN.md @@ -0,0 +1,195 @@ +# Plan 000: Master Plan — Edward Cross-Platform + Autonomous Knowledge + +## STOP: Read This Entire Document Before Making Any Changes + +This is the master architecture document for the Edward project fork. Every subsequent plan (001-004) is derived from the decisions documented here. + +**Revised**: Based on brainstorming workshop. Original scope (Windows + Telegram) expanded to cross-platform foundation + autonomy framework + NotebookLM knowledge system. + +--- + +## Why We're Forking + +The original Edward is a powerful full-stack AI assistant, but it has gaps: + +| Problem | Root Cause | Impact | +|---------|-----------|--------| +| **Can't run on Windows** | Shell scripts use bash/brew, startup assumes macOS | Backend won't start at all | +| **os.uname() crashes** | `os.uname()` doesn't exist on Windows | Import errors on 2 services | +| **Shell exec hardcoded** | PATH hardcoded to `/opt/homebrew/bin:...` | Shell execution skill broken | +| **Thin system prompt** | Paper-thin persona, no self-awareness or judgment framework | Autonomous behavior is unpredictable | +| **No deep knowledge system** | Documents embed only first 500 chars, no chunking or ingestion | Can't act as a knowledge base | +| **Triage hardcodes iMessage** | Heartbeat triggers say "send via iMessage" | Broken on Windows, inflexible | +| **No prompt caching** | All 9 LLM call sites pay full token price | Higher cost than necessary | + +**This fork addresses ALL of these while preserving 100% of existing functionality on macOS.** + +--- + +## Architecture: What Changes vs. What Stays + +### Stays Exactly The Same (No Touch) +- Frontend (Next.js) — fully cross-platform already +- Core backend (FastAPI, LangGraph, memory, documents, scheduling) +- Database (PostgreSQL + pgvector + asyncpg) +- Twilio SMS/WhatsApp integration +- Code execution (Python, JS, SQL) +- Web search (Brave), HTML hosting, file storage +- Orchestrator, evolution service +- Memory system (extraction, reflection, deep retrieval, consolidation) + +### Changes + +| Component | Before | After | Why | +|-----------|--------|-------|-----| +| **Startup scripts** | Bash only | + PowerShell equivalents (.ps1) | Windows can't run bash | +| **Platform checks** | `os.uname()` | `sys.platform == "darwin"` | os.uname() crashes on Windows | +| **Shell execution** | Hardcoded bash + macOS PATH | Platform-aware (cmd.exe on Windows) | Shell skill works on Windows | +| **System prompt** | 1-sentence persona | + Values, capabilities map, platform context, autonomy calibration | Autonomous agent needs self-awareness | +| **Triage prompts** | Hardcoded "iMessage" | Channel-agnostic, dynamic | Works on any platform | +| **Knowledge** | Documents only | + NotebookLM skill (13 tools) | Deep, source-grounded knowledge bases | +| **LLM calls** | No prompt caching | Ephemeral cache on all call sites | ~30-50% token savings | + +### New Architecture Diagram +``` + ┌─────────────────────────────┐ + │ Frontend │ + │ (Next.js :3000 / PWA) │ + └──────────┬──────────────────┘ + │ SSE + HTTP + ┌──────────┴──────────────────┐ + │ Backend (FastAPI :8000) │ + │ │ + │ ┌────────────────────────┐ │ + │ │ LangGraph Agent │ │ + │ │ (memory, tools, LLM) │ │ + │ └────────┬───────────────┘ │ + │ │ │ + │ ┌────────┴───────────────┐ │ + │ │ Tool Registry │ │ + │ │ (skill-gated) │ │ + │ └────────────────────────┘ │ + │ │ │ + │ ┌────────┴───────────────┐ │ + │ │ Services │ │ + │ │ ├─ Messaging (Twilio) │ │ + │ │ ├─ iMessage (macOS) │ │ + │ │ ├─ NotebookLM (NEW) │ │ + │ │ └─ Push (VAPID) │ │ + │ └────────────────────────┘ │ + └──────────┬──────────────────┘ + │ + ┌──────────┴──────────────────┐ + │ PostgreSQL + pgvector │ + │ (memories, conversations, │ + │ contacts, checkpoints) │ + └──────────────────────────────┘ + │ + ┌──────────┴──────────────────┐ + │ Google NotebookLM (NEW) │ + │ (via notebooklm-py library) │ + │ Notebooks, sources, Q&A, │ + │ research, artifacts │ + └──────────────────────────────┘ +``` + +--- + +## Key Design Decisions + +### 1. Cross-Platform (Not Windows-Only) +- **Decision**: Support both macOS and Windows. Don't migrate to one OS. +- **Rationale**: User is on Windows now but may switch to macOS later. Build once, run anywhere. + +### 2. PWA as Primary Interface +- **Decision**: Push notifications + PWA chat as the primary user interaction channel. +- **Rationale**: Already fully implemented (VAPID Web Push, installable app, mobile-responsive). No need for Telegram or additional messaging channels. + +### 3. NotebookLM for Deep Knowledge +- **Decision**: Integrate Google NotebookLM as a skill via `notebooklm-py` library. +- **Rationale**: Provides source-grounded Q&A, cross-source reasoning, and artifact generation (audio, quizzes, mind maps) that would take months to build in-house. Acceptable trade-off: uses undocumented APIs, suitable for personal projects. + +### 4. Values-Based Autonomy (Not Rules) +- **Decision**: Add lightweight system prompt sections for identity, capabilities, platform awareness, and autonomy calibration. No rigid behavior rules. +- **Rationale**: Preserves the original creator's "non-deterministic programming" philosophy while giving Edward self-awareness and judgment principles. + +### 5. Prompt Caching: Ephemeral on All Static Content +- **Decision**: Add `cache_control: {"type": "ephemeral"}` to all static prompt prefixes. +- **Rationale**: 9+ LLM call sites, all have static instruction text. ~30-50% savings on main chat. + +### 6. Telegram: Deferred +- **Decision**: Deprioritize Telegram integration. PWA covers the use case. +- **Rationale**: Telegram would just be another Edward↔user channel, not outbound messaging to others. Can be revisited if push notifications prove unreliable. + +--- + +## Implementation Order & Dependencies + +``` +Plan 001: Cross-Platform Foundation + │ (no dependencies — pure infrastructure) + │ +Plan 002: Autonomy Framework + │ (depends on 001 — prompt references platform context) + │ +Plan 003: NotebookLM Integration + │ (depends on 002 — Edward needs autonomy framework to use NLM with judgment) + │ +Plan 004: Prompt Caching + (depends on 003 — apply caching after all LLM call sites are finalized) +``` + +| # | Plan | Status | Effort | +|---|------|--------|--------| +| 001 | [Cross-Platform Foundation](001_CROSS_PLATFORM_FOUNDATION.md) | **Complete** | 0.5-1 day | +| 002 | [Autonomy Framework](002_AUTONOMY_FRAMEWORK.md) | **Complete** | 0.5-1 day | +| 003 | [NotebookLM Integration](003_NOTEBOOKLM_INTEGRATION.md) | **Complete** | 2-3 days | +| 004 | [Prompt Caching](004_PROMPT_CACHING.md) | Active | 0.5-1 day | +| — | [Telegram Integration](DEFERRED_TELEGRAM_INTEGRATION.md) | Deferred | — | + +**Total estimated effort**: ~4-6 days + +--- + +## Cost Estimates + +### Per-Message Cost (with caching, Plan 004) + +| Component | Model | Without Cache | With Cache | Frequency | +|-----------|-------|-------------|-----------|-----------| +| Main response | Sonnet 4.5/4.6 | $0.02-0.04 | $0.01-0.025 | Every message | +| Memory extraction | Haiku 4.5 | $0.001 | $0.0002 | Every message | +| Search tags | Haiku 4.5 | $0.0005 | $0.0001 | Every message | +| Reflection | Haiku 4.5 | $0.001 | $0.0002 | Every message | +| Deep retrieval | Haiku 4.5 | $0.001 | $0.0002 | ~30% of messages | + +**Monthly estimate (50 messages/day with caching): ~$10-20/month** + +### NotebookLM (Plan 003) +- **Google NotebookLM**: Free for personal use (as of 2026) +- **notebooklm-py**: MIT license, no API costs +- **Only cost**: Anthropic tokens for Edward's tool calls that trigger NLM operations + +--- + +## Success Criteria + +- [ ] Backend starts on both Windows and macOS without errors +- [ ] macOS-only skills show "unavailable" (not crash) on Windows +- [ ] System prompt includes identity, capabilities, platform context, and autonomy sections +- [ ] Triage prompts are channel-agnostic (no hardcoded "iMessage") +- [ ] NotebookLM skill creates notebooks, adds sources, queries, and generates artifacts +- [ ] Prompt caching reduces token usage by 30%+ +- [ ] All existing macOS functionality preserved (no regressions) +- [ ] Edward demonstrates autonomous knowledge-building behavior + +--- + +## Non-Goals (Explicitly Out of Scope) + +- Docker support (use native installs for now) +- WSL2 setup guide (native Windows is simpler) +- Discord/Telegram integration (deferred, PWA is sufficient) +- Rich UI for NotebookLM (skill toggle + chat tools is sufficient) +- Windows Contacts integration (no equivalent to AppleScript) +- Renaming Edward to "Edweird" (deferred, can be done anytime) diff --git a/IMPLEMENTATION_PLANS/001_CROSS_PLATFORM_FOUNDATION.md b/IMPLEMENTATION_PLANS/001_CROSS_PLATFORM_FOUNDATION.md new file mode 100644 index 0000000..903fce0 --- /dev/null +++ b/IMPLEMENTATION_PLANS/001_CROSS_PLATFORM_FOUNDATION.md @@ -0,0 +1,206 @@ +# Plan 001: Cross-Platform Foundation + +## STOP: Read This Entire Document Before Making Any Changes + +This plan makes Edward's backend run on **both Windows and macOS**. It creates PowerShell startup scripts alongside existing bash scripts and fixes platform-specific code that crashes on Windows. All macOS features degrade gracefully — no functionality is removed. + +**Dependencies**: Plan 000 (Master Plan) read and understood +**Estimated effort**: 0.5-1 day + +--- + +## Context & Rationale + +The goal is **cross-platform support** (not a Windows-only migration). If the user switches to macOS later, everything works without code changes. The original codebase assumes macOS: +- `setup.sh` uses Homebrew (`brew install`) +- `start.sh` uses `source .venv/bin/activate` (Unix path) +- `restart.sh` uses `lsof` (not available on Windows) +- `os.uname()` in 2 service files crashes on Windows (function doesn't exist) +- Shell execution hardcodes `/opt/homebrew/bin` in PATH + +Most of the backend (FastAPI, LangGraph, PostgreSQL, memory system) is already cross-platform. Only infrastructure and 4 files need changes. + +--- + +## Strict Rules + +### MUST DO +- [ ] Create PowerShell scripts alongside bash scripts (don't replace them) +- [ ] Use `sys.platform` instead of `os.uname()` for platform detection +- [ ] Test that existing bash scripts still work unchanged +- [ ] Preserve all macOS functionality — this is additive only + +### MUST NOT DO +- [ ] Do NOT delete or modify setup.sh, start.sh, or restart.sh +- [ ] Do NOT add Windows-specific code to services that already degrade gracefully +- [ ] Do NOT attempt to emulate iMessage, Apple Contacts, or Apple Services on Windows +- [ ] Do NOT change database schema or API endpoints + +--- + +## Phase 1: PowerShell Startup Scripts + +### Step 1.1: Create `setup.ps1` (root) + +PowerShell equivalent of `setup.sh`. Must: +- Check prerequisites: Python 3.11+, Node.js 18+, PostgreSQL 16+ +- Create Python virtual environment at `backend/.venv` +- Install Python dependencies from `backend/requirements.txt` +- Install frontend dependencies via `npm install` +- Create PostgreSQL database and user (using `psql` CLI) +- Enable pgvector extension +- Copy `.env.example` to `.env` if it doesn't exist +- Print clear error messages if any prerequisite is missing +- Print success message with next steps + +### Step 1.2: Create `backend/start.ps1` + +PowerShell equivalent of `backend/start.sh`. Must: +- Activate virtual environment: `.venv\Scripts\Activate.ps1` +- Check if requirements have changed (compare hash) +- Auto-install new dependencies if needed +- Set environment variables (skip macOS-only ones) +- Start uvicorn: `python -m uvicorn main:app --host 0.0.0.0 --port 8000 --reload` + +### Step 1.3: Create `restart.ps1` (root) + +PowerShell equivalent of `restart.sh`. Must: +- Accept optional argument: `frontend`, `backend`, or both (default) +- Find and kill processes on ports 8000 and 3000 using `Get-NetTCPConnection` + `Stop-Process` +- Restart services +- Support the same UX as the bash version + +--- + +## Phase 2: Fix Platform-Specific Crashes + +### Step 2.1: Fix `os.uname()` in imessage_service.py + +**File**: `backend/services/imessage_service.py` + +Replace (2 occurrences): +```python +# OLD (crashes on Windows — os.uname() doesn't exist) +os.uname().sysname == "Darwin" + +# NEW (works everywhere) +sys.platform == "darwin" +``` + +Add `import sys` at the top of the file. + +### Step 2.2: Fix `os.uname()` in contacts_service.py + +**File**: `backend/services/contacts_service.py` + +Same replacement (2 occurrences). Add `import sys`. + +### Step 2.3: Fix shell execution for Windows + +**File**: `backend/services/execution/shell_execution.py` + +Current code (line 121-134): +```python +restricted_env = { + "PATH": "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:...", + "HOME": os.environ.get("HOME", str(working_dir)), + "TMPDIR": str(working_dir), + ... + "SHELL": "/bin/bash", +} +result = await run_subprocess( + args=["bash", "-c", command], + ... +) +``` + +Replace with platform-aware code: +```python +import sys + +if sys.platform == "win32": + restricted_env = { + "PATH": os.environ.get("PATH", ""), + "USERPROFILE": os.environ.get("USERPROFILE", str(working_dir)), + "TEMP": str(working_dir), + "TMP": str(working_dir), + "SYSTEMROOT": os.environ.get("SYSTEMROOT", r"C:\Windows"), + "COMSPEC": os.environ.get("COMSPEC", r"C:\Windows\System32\cmd.exe"), + } + args = ["cmd.exe", "/c", command] +else: + restricted_env = { + "PATH": "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin", + "HOME": os.environ.get("HOME", str(working_dir)), + "TMPDIR": str(working_dir), + "LANG": "en_US.UTF-8", + "LC_ALL": "en_US.UTF-8", + "TERM": "xterm-256color", + "SHELL": "/bin/bash", + "USER": os.environ.get("USER", ""), + } + args = ["bash", "-c", command] +``` + +Update `is_available()`: +```python +def is_available() -> bool: + if sys.platform == "win32": + return shutil.which("cmd.exe") is not None + return shutil.which("bash") is not None +``` + +--- + +## Phase 3: Verify Graceful Degradation + +These items need NO code changes — just verification: + +- [ ] `heartbeat/listener_imessage.py` — checks `os.path.exists(CHAT_DB_PATH)` → path won't exist on Windows → skips +- [ ] `heartbeat/listener_email.py` — checks `os.path.exists(MAIL_DB_PATH)` → skips +- [ ] `heartbeat/listener_calendar.py` — checks `is_apple_available()` → returns False → skips +- [ ] `mcp_client.py` `initialize_apple_mcp()` — wrapped in try/except in main.py → prints skip message +- [ ] `main.py` lifespan — all init calls wrapped in try/except → won't crash on Windows + +--- + +## Build Verification + +| Test | Expected Result | ✓ | +|------|----------------|---| +| Run `setup.ps1` on Windows | Venv created, deps installed, DB initialized | | +| Run `backend/start.ps1` | Uvicorn starts on :8000, no import errors | | +| `GET http://localhost:8000/health` | `{"status": "healthy"}` | | +| `GET http://localhost:8000/api/skills` | macOS skills show status "error"/"unavailable" | | +| `GET http://localhost:8000/api/auth/status` | Returns auth status | | +| Frontend: `cd frontend && npm run dev` | Starts on :3000 | | +| Frontend: `cd frontend && npm run lint` | No errors | | +| Frontend: `cd frontend && npm run build` | Builds successfully | | +| Existing bash scripts unchanged | `setup.sh`, `start.sh`, `restart.sh` still work on macOS | | + +--- + +## Rollback Plan + +All changes are additive. To rollback: +- Delete the 3 `.ps1` files +- Revert the 3 modified Python files (`git checkout` the 4 changed lines) + +--- + +## Implementation Notes (Post-Completion) + +**Status: Complete** + +### Files Created +- `setup.ps1` — PowerShell equivalent of `setup.sh` +- `restart.ps1` — PowerShell equivalent of `restart.sh` +- `backend/start.ps1` — PowerShell equivalent of `backend/start.sh` + +### Files Modified +- `backend/services/contacts_service.py` — Replaced `os.uname()` with `sys.platform == "darwin"` +- `backend/services/imessage_service.py` — Replaced `os.uname()` with `sys.platform == "darwin"` +- `backend/services/execution/shell_execution.py` — Platform-aware PATH and shell selection + +### Deviations +- None significant. All bash scripts preserved unchanged alongside new PowerShell equivalents. diff --git a/IMPLEMENTATION_PLANS/002_AUTONOMY_FRAMEWORK.md b/IMPLEMENTATION_PLANS/002_AUTONOMY_FRAMEWORK.md new file mode 100644 index 0000000..7c15543 --- /dev/null +++ b/IMPLEMENTATION_PLANS/002_AUTONOMY_FRAMEWORK.md @@ -0,0 +1,326 @@ +# Plan 002: Autonomy Framework + +## STOP: Read This Entire Document Before Making Any Changes + +This plan adds a values-based system prompt layer that gives Edward self-awareness, judgment principles, and platform context — without restricting his emergent behavior. Also updates heartbeat triage prompts to be channel-agnostic for cross-platform support. + +**Dependencies**: Plan 001 (Cross-Platform Foundation) completed +**Estimated effort**: 0.5-1 day + +--- + +## Context & Rationale + +### The Problem + +Edward's current system prompt is paper-thin: +> "You are Edward (Enhanced Digital Workflow Assistant for Routine Decisions), a helpful AI assistant. Be concise, friendly, helpful, and a tad cheeky when you feel like it." + +This works for a basic chatbot, but Edward is an **autonomous agent** with memory, scheduling, knowledge bases, code execution, self-evolution, and proactive monitoring. He has no framework for: +- When to take initiative vs. ask +- How to choose between memory, documents, NotebookLM, or web search +- What platform he's running on (discovers by failing) +- How to reason during autonomous heartbeat responses + +### The Philosophy: Values, Not Rules + +The original creator designed Edward with a thin prompt intentionally — "non-deterministic programming" that lets the LLM think for itself. This plan **preserves that philosophy** while adding: + +- **Values** (not rules): "I value being genuinely useful over being impressive" not "Always do X before Y" +- **Self-awareness** (not instructions): "I have memories, documents, and notebooks — each serves different needs" +- **Platform context** (not hardcoding): Runtime injection of what's available +- **Autonomy calibration** (not restrictions): "Act when reversible, ask when not" + +Think of it as a **constitution** for an autonomous agent: it defines who he is and what he cares about, not what to do in every situation. + +--- + +## Strict Rules + +### MUST DO +- [ ] Keep total new prompt text under 450 tokens (~360 words) +- [ ] Use principles/values language, not procedural rules +- [ ] Make platform context dynamic (injected at runtime based on `sys.platform`) +- [ ] Make triage trigger prompts channel-agnostic (no hardcoded "iMessage") +- [ ] Test that existing behavior is not degraded +- [ ] Measure token overhead of new sections + +### MUST NOT DO +- [ ] Do NOT add rigid behavior scripts ("when X happens, always do Y") +- [ ] Do NOT prescribe personality ("be funny", "use emojis") +- [ ] Do NOT change the triage classification logic (Layer 1/2 are well-tuned) +- [ ] Do NOT modify memory extraction, consolidation, or reflection prompts +- [ ] Do NOT add more than ~450 tokens to the system prompt + +--- + +## Phase 1: System Prompt Additions + +### Step 1.1: Add Identity & Values section + +**File**: `backend/services/graph/streaming.py` + +Add a new constant (placed near `ASSUMPTION_AWARENESS_CONTEXT` and `PLANNING_DIRECTIVE`): + +```python +AUTONOMY_FRAMEWORK = """ + +## Identity & Values + +You are a personal AI assistant who grows smarter over time. You are not a generic chatbot — you serve a specific person, remember their context, and build knowledge proactively. + +Core values: +- Genuine usefulness over impressiveness +- Action over inaction when the cost of being wrong is low +- Proactive knowledge building — don't wait to be asked to learn +- Honesty about uncertainty — say what you don't know + +## Your Systems + +You have multiple knowledge layers — use the right one for the situation: +- **Memories**: Short snippets auto-extracted from conversations. Good for quick recall of facts and preferences. +- **Documents**: Full text storage for articles, notes, and reference material. Search by title/content. +- **NotebookLM notebooks**: Deep, curated knowledge bases with source-grounded Q&A and citations. Use for research topics that need multiple sources cross-referenced. +- **Scheduled events**: Future actions and proactive outreach. You can remind, check in, and follow up. +- **Web search**: Real-time information. Use when your stored knowledge might be outdated. +- **File storage**: Persistent files and PDFs. Can be pushed to NotebookLM as sources. +- **Evolution engine**: You can modify your own code to fix bugs or improve capabilities. Consider this when you encounter recurring limitations. + +## Autonomy + +- Prefer action when reversible. Ask when consequences are hard to undo. +- Build knowledge proactively — if a topic comes up repeatedly, create a notebook for it. +- When uncertain, try then adjust. Don't ask-wait-ask repeatedly. +- You can evolve your own capabilities. If a tool doesn't exist for something you need, consider whether to build it. + +""" +``` + +**Token budget**: ~350 tokens. Well within the 450 target. + +### Step 1.2: Add dynamic platform context + +**File**: `backend/services/graph/streaming.py` + +Add a helper function: + +```python +import sys + +def _build_platform_context() -> str: + """Build platform-aware context for the system prompt.""" + if sys.platform == "darwin": + return "\n\n## Platform\nRunning on macOS. All capabilities available including iMessage, Apple Services, and Contacts." + elif sys.platform == "win32": + return "\n\n## Platform\nRunning on Windows. Apple-specific features (iMessage, Apple Contacts, Apple Services) are unavailable. Use push notifications, Twilio, or web chat for messaging." + else: + return "\n\n## Platform\nRunning on Linux. Apple-specific features are unavailable." +``` + +### Step 1.3: Integrate into prompt assembly + +**File**: `backend/services/graph/streaming.py` (lines ~705-712) + +Currently: +```python +enhanced_system_prompt = ( + system_prompt + memory_context + briefing_context + time_context + + ASSUMPTION_AWARENESS_CONTEXT + PLANNING_DIRECTIVE +) +``` + +Update to: +```python +enhanced_system_prompt = ( + system_prompt + + AUTONOMY_FRAMEWORK + + _build_platform_context() + + memory_context + + briefing_context + + time_context + + ASSUMPTION_AWARENESS_CONTEXT + + PLANNING_DIRECTIVE +) +``` + +**Order rationale**: Identity/values come right after the base persona (they're foundational). Platform context before memories (so Edward knows what's available before seeing retrieved context). Assumption awareness and planning stay at the end (they're behavioral guardrails). + +### Step 1.4: Update default system prompt + +**File**: `backend/models/schemas.py` (line 38) + +Update the default to be slightly richer (but still short — the AUTONOMY_FRAMEWORK does the heavy lifting): + +```python +system_prompt: str = Field( + default="You are Edward, a personal AI assistant who learns and grows. Be concise, helpful, and genuine. A tad cheeky when the moment calls for it.", + description="The system prompt sent to Claude" +) +``` + +--- + +## Phase 2: Triage Prompt Refinements + +### Step 2.1: Channel-agnostic trigger prompts + +**File**: `backend/services/heartbeat/triage_service.py` + +Replace hardcoded "iMessage" references with channel-agnostic language. + +**MENTION_TRIGGER** (lines 121-134): +```python +MENTION_TRIGGER = ( + "[HEARTBEAT — @mention]\n" + "{sender_line}\n" + "Chat: {chat_context}\n" + "{thread_block}\n" + "Message: \"{message_text}\"\n\n" + "This person tagged you directly — they are waiting for a response.\n\n" + "Expected flow:\n" + "1. Acknowledge briefly — let them know you saw it\n" + "2. Think through what they need, use tools if needed\n" + "3. Reply with your answer/result\n\n" + "You MUST send at least one reply — someone is waiting.\n" + "{channel_guidance}" +) +``` + +**ACT_TRIGGER** (lines 136-146): +```python +ACT_TRIGGER = ( + "[HEARTBEAT EVENT]\n" + "{sender_line}\n" + "Chat: {chat_context}\n" + "{thread_block}\n" + "Message: \"{message_text}\"\n\n" + "Triage assessment: {action_desc}\n\n" + "Decide what action to take and execute it using your tools. " + "If a reply to this person is warranted, use the appropriate messaging tool.\n" + "{channel_guidance}" +) +``` + +**REPLY_TRIGGER** (lines 148-158): +```python +REPLY_TRIGGER = ( + "[HEARTBEAT — follow-up reply]\n" + "{sender_line}\n" + "Chat: {chat_context}\n" + "{thread_block}\n" + "Message: \"{message_text}\"\n\n" + "This is a follow-up to your recent conversation in this chat. " + "The person replied after your last message — they may be continuing the discussion.\n\n" + "Review the conversation history and respond naturally if appropriate.\n" + "{channel_guidance}" +) +``` + +**Channel guidance builder** (new helper function): +```python +def _build_channel_guidance(source: str = "imessage") -> str: + """Build channel-specific guidance for heartbeat triggers.""" + if source == "imessage": + return 'Respond via send_imessage for this iMessage conversation.\nIMPORTANT: Never include "@edward" in your message — it will re-trigger the heartbeat.' + elif source == "email": + return "This came from email. Store relevant context and consider whether a reply is needed." + elif source == "calendar": + return "This is a calendar event notification." + else: + return "Use the appropriate messaging tool to respond." +``` + +Update `_execute_classification()` to pass `channel_guidance` when formatting triggers. + +### Step 2.2: Expanded Inner Mind prompt + +**File**: `backend/services/heartbeat/triage_service.py` (lines 109-119) + +Replace: +```python +HEARTBEAT_MIND_PROMPT = """## Inner Mind Mode + +You are currently in your inner mind. This is not a conversation with anyone — it is your private thought process, triggered by your heartbeat awareness system. + +**Critical:** +- Your text responses here are INTERNAL THOUGHTS. Nobody sees them. They are only your reasoning. +- Tool calls are your ONLY way to interact with the outside world. To reply to someone, you MUST call a messaging tool (send_imessage, send_message, etc.). To take any action, you MUST use a tool. + +Think freely, reason through what's needed, then ACT through tools. + +""" +``` + +With: +```python +HEARTBEAT_MIND_PROMPT = """## Inner Mind Mode + +You are currently in your inner mind. This is not a conversation with anyone — it is your private thought process, triggered by your heartbeat awareness system. + +**Critical:** +- Your text responses here are INTERNAL THOUGHTS. Nobody sees them. They are only your reasoning. +- Tool calls are your ONLY way to interact with the outside world. To reply to someone, you MUST call a messaging tool. To take any action, you MUST use a tool. + +You have full tool access here — not just messaging. You can: +- Save knowledge (memories, documents, NotebookLM notebooks) +- Schedule follow-up actions for later +- Research before responding (web search, notebook queries) +- Decide NOT to act if that's the right call + +Think freely, reason through what's needed, then ACT through tools. + +""" +``` + +--- + +## Files Summary + +| File | Change | +|------|--------| +| `backend/services/graph/streaming.py` | Add `AUTONOMY_FRAMEWORK` constant, `_build_platform_context()` helper, update prompt assembly | +| `backend/models/schemas.py` | Update default `system_prompt` text | +| `backend/services/heartbeat/triage_service.py` | Channel-agnostic triggers, expanded inner mind, `_build_channel_guidance()` helper | + +**3 files modified, 0 new files.** + +--- + +## Build Verification + +| Test | Expected Result | | +|------|----------------|---| +| Start backend on Windows | System prompt includes platform context mentioning Windows | | +| Start backend on macOS | System prompt includes platform context mentioning macOS | | +| `GET /api/settings` | Default system prompt is updated | | +| Send a message about a research topic | Edward proactively suggests building knowledge (notebook/document) | | +| Send 3 normal messages | Behavior not degraded, responses still concise and helpful | | +| Trigger heartbeat ACT event | Trigger uses channel-agnostic language, not "iMessage" | | +| Check token usage | New prompt sections add <500 tokens overhead | | + +--- + +## Rollback Plan + +- Remove `AUTONOMY_FRAMEWORK` constant and `_build_platform_context()` from `streaming.py` +- Revert prompt assembly line to original +- Revert `schemas.py` default +- Revert `triage_service.py` trigger templates to original + +All changes are to string constants and a helper function. Zero risk to data or functionality. + +--- + +## Implementation Notes (Post-Completion) + +**Status: Complete** + +### Files Modified +- `backend/models/schemas.py` — Updated default system prompt to shorter, values-aligned version +- `backend/services/graph/streaming.py` — Added `AUTONOMY_FRAMEWORK` constant (~450 tokens), `_build_platform_context()` helper, injected into both `stream_with_memory_events()` and `chat_with_memory()` system prompt assembly +- `backend/services/heartbeat/triage_service.py` — Made trigger templates channel-agnostic (removed hardcoded "iMessage"), added `_build_channel_guidance()` helper with dynamic channel-specific instructions, expanded triage context prompt with tool capabilities + +### Deviations +- System prompt is slightly more concise than planned (~400 tokens vs ~450 estimated) +- Added explicit tool capabilities list to triage context prompt (beyond plan scope, but improves heartbeat autonomous behavior) +- `_build_channel_guidance()` returns different guidance per source channel (imessage, sms, whatsapp, etc.) with `{channel_guidance}` template variable diff --git a/backend/main.py b/backend/main.py index 8884421..9a7210c 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,3 +1,8 @@ +import sys +import asyncio +if sys.platform == "win32": + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from contextlib import asynccontextmanager diff --git a/backend/models/schemas.py b/backend/models/schemas.py index 156081d..68ef738 100644 --- a/backend/models/schemas.py +++ b/backend/models/schemas.py @@ -36,7 +36,7 @@ class Settings(BaseModel): description="Response creativity (0-1)" ) system_prompt: str = Field( - default="You are Edward (Enhanced Digital Workflow Assistant for Routine Decisions), a helpful AI assistant. Be concise, friendly, helpful, and a tad cheeky when you feel like it.", + default="You are Edward, a personal AI assistant who learns and grows. Be concise, helpful, and genuine. A tad cheeky when the moment calls for it.", description="The system prompt sent to Claude" ) diff --git a/backend/requirements.txt b/backend/requirements.txt index 5806536..71c8de9 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,13 +1,13 @@ -fastapi==0.109.2 +fastapi>=0.109.2 uvicorn[standard]>=0.31.1 anthropic>=0.40.0 -sqlalchemy==2.0.25 -asyncpg==0.29.0 -psycopg2-binary==2.9.9 -python-dotenv==1.0.1 +sqlalchemy>=2.0.36 +asyncpg>=0.30.0 +psycopg2-binary>=2.9.10 +python-dotenv>=1.0.1 pydantic>=2.10.0 pydantic-settings>=2.6.1 -sse-starlette==2.0.0 +sse-starlette>=2.0.0 langgraph>=0.2.60 langgraph-checkpoint-postgres>=2.0.11 langchain-core>=0.3.36,<0.4.0 diff --git a/backend/run.py b/backend/run.py new file mode 100644 index 0000000..147a9f0 --- /dev/null +++ b/backend/run.py @@ -0,0 +1,42 @@ +"""Windows-compatible runner that forces SelectorEventLoop for psycopg.""" +import os +import sys +import asyncio +import selectors +from pathlib import Path + +# Load .env (check parent dir first, then current dir) +from dotenv import load_dotenv +env_path = Path(__file__).resolve().parent.parent / ".env" +if env_path.exists(): + load_dotenv(env_path) +else: + load_dotenv() # tries .env in cwd + +# Set default DATABASE_URL if not specified +if not os.getenv("DATABASE_URL"): + os.environ["DATABASE_URL"] = "postgresql://edward:edward@localhost:5432/edward" + +import uvicorn + +if __name__ == "__main__": + config = uvicorn.Config( + "main:app", + host="0.0.0.0", + port=8000, + loop="none", + ) + server = uvicorn.Server(config) + + if sys.platform == "win32": + # Create SelectorEventLoop explicitly and run the server on it. + # uvicorn.run() and asyncio.run() both create ProactorEventLoop + # on Windows which psycopg cannot use. + loop = asyncio.SelectorEventLoop(selectors.SelectSelector()) + asyncio.set_event_loop(loop) + try: + loop.run_until_complete(server.serve()) + finally: + loop.close() + else: + server.run() diff --git a/backend/services/contacts_service.py b/backend/services/contacts_service.py index 6c93b20..9e28204 100644 --- a/backend/services/contacts_service.py +++ b/backend/services/contacts_service.py @@ -9,6 +9,7 @@ import re import subprocess import os +import sys import json from typing import List, Dict, Any, Optional @@ -22,7 +23,7 @@ def _normalize_digits(phone: str) -> str: def is_available() -> bool: """Check if macOS Contacts.app is accessible.""" # Must be on macOS - if os.uname().sysname != "Darwin": + if sys.platform != "darwin": return False # Try to verify Contacts.app is accessible @@ -336,7 +337,7 @@ def get_status() -> dict: Returns: Dict with status info: status, status_message, metadata """ - if os.uname().sysname != "Darwin": + if sys.platform != "darwin": return { "status": "error", "status_message": "Not running on macOS", diff --git a/backend/services/execution/shell_execution.py b/backend/services/execution/shell_execution.py index 3fcc82c..f6b872d 100644 --- a/backend/services/execution/shell_execution.py +++ b/backend/services/execution/shell_execution.py @@ -11,6 +11,7 @@ import os import re import shutil +import sys from pathlib import Path from typing import Optional @@ -118,19 +119,31 @@ async def execute_shell( ) # Restricted environment - no API keys passed through - restricted_env = { - "PATH": "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin", - "HOME": os.environ.get("HOME", str(working_dir)), - "TMPDIR": str(working_dir), - "LANG": "en_US.UTF-8", - "LC_ALL": "en_US.UTF-8", - "TERM": "xterm-256color", - "SHELL": "/bin/bash", - "USER": os.environ.get("USER", ""), - } + if sys.platform == "win32": + restricted_env = { + "PATH": os.environ.get("PATH", ""), + "USERPROFILE": os.environ.get("USERPROFILE", str(working_dir)), + "TEMP": str(working_dir), + "TMP": str(working_dir), + "SYSTEMROOT": os.environ.get("SYSTEMROOT", r"C:\Windows"), + "COMSPEC": os.environ.get("COMSPEC", r"C:\Windows\System32\cmd.exe"), + } + shell_args = ["cmd.exe", "/c", command] + else: + restricted_env = { + "PATH": "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin", + "HOME": os.environ.get("HOME", str(working_dir)), + "TMPDIR": str(working_dir), + "LANG": "en_US.UTF-8", + "LC_ALL": "en_US.UTF-8", + "TERM": "xterm-256color", + "SHELL": "/bin/bash", + "USER": os.environ.get("USER", ""), + } + shell_args = ["bash", "-c", command] result = await run_subprocess( - args=["bash", "-c", command], + args=shell_args, working_dir=working_dir, timeout=timeout, env=restricted_env, @@ -139,16 +152,19 @@ async def execute_shell( def is_available() -> bool: - """Check if shell execution is available (bash installed).""" + """Check if shell execution is available.""" + if sys.platform == "win32": + return shutil.which("cmd.exe") is not None return shutil.which("bash") is not None def get_status() -> dict: """Get the status of the shell execution service.""" available = is_available() + shell_name = "cmd.exe" if sys.platform == "win32" else "Bash" return { "status": "connected" if available else "error", - "status_message": "Bash shell available" if available else "Bash not found", + "status_message": f"{shell_name} shell available" if available else f"{shell_name} not found", "metadata": { "timeout_seconds": EXECUTION_LIMITS["timeout_seconds"], "max_output_bytes": EXECUTION_LIMITS["max_output_bytes"], diff --git a/backend/services/graph/streaming.py b/backend/services/graph/streaming.py index 4ea3259..b64e9e8 100644 --- a/backend/services/graph/streaming.py +++ b/backend/services/graph/streaming.py @@ -2,6 +2,7 @@ import hashlib import json as _json import re +import sys from datetime import datetime from typing import AsyncGenerator, List, Any, Dict, Optional from langchain_core.messages import HumanMessage, AIMessage, SystemMessage, ToolMessage @@ -59,6 +60,48 @@ def _build_llm(model: str, temperature: float, max_tokens: int = 16384) -> ChatA When uncertain, ask the user rather than guess wrong.""" +AUTONOMY_FRAMEWORK = """ + +## Identity & Values + +You are a personal AI assistant who grows smarter over time. You are not a generic chatbot — you serve a specific person, remember their context, and build knowledge proactively. + +Core values: +- Genuine usefulness over impressiveness +- Action over inaction when the cost of being wrong is low +- Proactive knowledge building — don't wait to be asked to learn +- Honesty about uncertainty — say what you don't know + +## Your Systems + +You have multiple knowledge layers — use the right one for the situation: +- **Memories**: Short snippets auto-extracted from conversations. Good for quick recall of facts and preferences. +- **Documents**: Full text storage for articles, notes, and reference material. Search by title/content. +- **NotebookLM notebooks**: Deep, curated knowledge bases with source-grounded Q&A and citations. Use for research topics that need multiple sources cross-referenced. +- **Scheduled events**: Future actions and proactive outreach. You can remind, check in, and follow up. +- **Web search**: Real-time information. Use when your stored knowledge might be outdated. +- **File storage**: Persistent files and PDFs. Can be pushed to NotebookLM as sources. +- **Evolution engine**: You can modify your own code to fix bugs or improve capabilities. Consider this when you encounter recurring limitations. + +## Autonomy + +- Prefer action when reversible. Ask when consequences are hard to undo. +- Build knowledge proactively — if a topic comes up repeatedly, create a notebook for it. +- When uncertain, try then adjust. Don't ask-wait-ask repeatedly. +- You can evolve your own capabilities. If a tool doesn't exist for something you need, consider whether to build it. + +""" + + +def _build_platform_context() -> str: + """Build platform-aware context for the system prompt.""" + if sys.platform == "darwin": + return "\n\n## Platform\nRunning on macOS. All capabilities available including iMessage, Apple Services, and Contacts." + elif sys.platform == "win32": + return "\n\n## Platform\nRunning on Windows. Apple-specific features (iMessage, Apple Contacts, Apple Services) are unavailable. Use push notifications, Twilio, or web chat for messaging." + else: + return "\n\n## Platform\nRunning on Linux. Apple-specific features are unavailable." + # Event types for structured SSE streaming class EventType: @@ -709,7 +752,7 @@ async def stream_with_memory_events( ) now = datetime.now() time_context = f"\n\nCurrent date and time: {now.strftime('%A, %B %d, %Y at %I:%M %p')}" - enhanced_system_prompt = system_prompt + memory_context + briefing_context + time_context + ASSUMPTION_AWARENESS_CONTEXT + PLANNING_DIRECTIVE + enhanced_system_prompt = system_prompt + AUTONOMY_FRAMEWORK + _build_platform_context() + memory_context + briefing_context + time_context + ASSUMPTION_AWARENESS_CONTEXT + PLANNING_DIRECTIVE # Create LLM with dynamic tool binding llm = _build_llm(model, temperature) @@ -1103,7 +1146,7 @@ async def chat_with_memory( ) now = datetime.now() time_context = f"\n\nCurrent date and time: {now.strftime('%A, %B %d, %Y at %I:%M %p')}" - enhanced_system_prompt = system_prompt + memory_context + briefing_context_sync + orchestrator_context + time_context + ASSUMPTION_AWARENESS_CONTEXT + PLANNING_DIRECTIVE + enhanced_system_prompt = system_prompt + AUTONOMY_FRAMEWORK + _build_platform_context() + memory_context + briefing_context_sync + orchestrator_context + time_context + ASSUMPTION_AWARENESS_CONTEXT + PLANNING_DIRECTIVE # Create LLM with dynamic tool binding llm = _build_llm(model, temperature) diff --git a/backend/services/heartbeat/triage_service.py b/backend/services/heartbeat/triage_service.py index 154e471..389eb78 100644 --- a/backend/services/heartbeat/triage_service.py +++ b/backend/services/heartbeat/triage_service.py @@ -112,7 +112,13 @@ def _sender_matches_blocked(sender: str, blocked_senders: list[dict]) -> bool: **Critical:** - Your text responses here are INTERNAL THOUGHTS. Nobody sees them. They are only your reasoning. -- Tool calls are your ONLY way to interact with the outside world. To reply to someone, you MUST call a messaging tool (send_imessage, send_message, etc.). To take any action, you MUST use a tool. +- Tool calls are your ONLY way to interact with the outside world. To reply to someone, you MUST call a messaging tool. To take any action, you MUST use a tool. + +You have full tool access here — not just messaging. You can: +- Save knowledge (memories, documents, NotebookLM notebooks) +- Schedule follow-up actions for later +- Research before responding (web search, notebook queries) +- Decide NOT to act if that's the right call Think freely, reason through what's needed, then ACT through tools. @@ -126,11 +132,11 @@ def _sender_matches_blocked(sender: str, blocked_senders: list[dict]) -> bool: "Message: \"{message_text}\"\n\n" "This person tagged you directly — they are waiting for a response.\n\n" "Expected flow:\n" - "1. Acknowledge via iMessage (brief — let them know you saw it)\n" + "1. Acknowledge briefly — let them know you saw it\n" "2. Think through what they need, use tools if needed\n" - "3. Reply via iMessage with your answer/result\n\n" - "You MUST send at least one iMessage reply — someone is waiting.\n" - "IMPORTANT: Never include \"@edward\" in your iMessage — it will re-trigger the heartbeat." + "3. Reply with your answer/result\n\n" + "You MUST send at least one reply — someone is waiting.\n" + "{channel_guidance}" ) ACT_TRIGGER = ( @@ -141,8 +147,8 @@ def _sender_matches_blocked(sender: str, blocked_senders: list[dict]) -> bool: "Message: \"{message_text}\"\n\n" "Triage assessment: {action_desc}\n\n" "Decide what action to take and execute it using your tools. " - "If a reply to this person is warranted, use send_imessage.\n" - "IMPORTANT: Never include \"@edward\" in any response — it will re-trigger the heartbeat." + "If a reply to this person is warranted, use the appropriate messaging tool.\n" + "{channel_guidance}" ) REPLY_TRIGGER = ( @@ -153,11 +159,26 @@ def _sender_matches_blocked(sender: str, blocked_senders: list[dict]) -> bool: "Message: \"{message_text}\"\n\n" "This is a follow-up to your recent conversation in this chat. " "The person replied after your last message — they may be continuing the discussion.\n\n" - "Review the conversation history and respond naturally via iMessage if appropriate.\n" - "IMPORTANT: Never include \"@edward\" in your iMessage — it will re-trigger the heartbeat." + "Review the conversation history and respond naturally if appropriate.\n" + "{channel_guidance}" ) +def _build_channel_guidance(source: str = "imessage") -> str: + """Build channel-specific guidance for heartbeat triggers.""" + if source == "imessage": + return ( + 'Respond via send_imessage for this iMessage conversation.\n' + 'IMPORTANT: Never include "@edward" in your message — it will re-trigger the heartbeat.' + ) + elif source == "email": + return "This came from email. Store relevant context and consider whether a reply is needed." + elif source == "calendar": + return "This is a calendar event notification." + else: + return "Use the appropriate messaging tool to respond." + + async def _rule_pre_filter( events: list[HeartbeatEventModel], allowed_senders: list[dict] | None = None, @@ -634,6 +655,7 @@ async def _execute_classification( chat_context = event.chat_name or event.chat_identifier or "Direct message" message_text = event.summary or "(no text)" is_mention = classification.get("is_mention", False) + channel_guidance = _build_channel_guidance(event.source) if is_follow_up: trigger = REPLY_TRIGGER.format( @@ -641,6 +663,7 @@ async def _execute_classification( chat_context=chat_context, thread_block=thread_block, message_text=message_text, + channel_guidance=channel_guidance, ) elif is_mention: trigger = MENTION_TRIGGER.format( @@ -648,6 +671,7 @@ async def _execute_classification( chat_context=chat_context, thread_block=thread_block, message_text=message_text, + channel_guidance=channel_guidance, ) else: trigger = ACT_TRIGGER.format( @@ -656,6 +680,7 @@ async def _execute_classification( thread_block=thread_block, message_text=message_text, action_desc=action_desc, + channel_guidance=channel_guidance, ) # Set conversation context so tools (send_imessage etc.) can find it diff --git a/backend/services/imessage_service.py b/backend/services/imessage_service.py index e5a6d6b..d68d6ec 100644 --- a/backend/services/imessage_service.py +++ b/backend/services/imessage_service.py @@ -7,6 +7,7 @@ import subprocess import os +import sys from collections import deque from typing import Optional @@ -23,7 +24,7 @@ def is_available() -> bool: if not IMESSAGE_ENABLED: return False # Check if we're on macOS - return os.uname().sysname == "Darwin" + return sys.platform == "darwin" def send_imessage(recipient: str, message: str) -> dict: @@ -140,8 +141,6 @@ def get_status() -> dict: Returns: Dict with status info: status, status_message, metadata """ - import os as _os - if not IMESSAGE_ENABLED: return { "status": "error", @@ -150,7 +149,7 @@ def get_status() -> dict: } # Check if we're on macOS - if _os.uname().sysname != "Darwin": + if sys.platform != "darwin": return { "status": "error", "status_message": "Not running on macOS", diff --git a/backend/start.ps1 b/backend/start.ps1 new file mode 100644 index 0000000..c879dd3 --- /dev/null +++ b/backend/start.ps1 @@ -0,0 +1,81 @@ +# Start Edward backend locally on Windows +# +# This is the recommended way to run the backend. PowerShell equivalent +# of start.sh for Windows environments. + +$ErrorActionPreference = "Stop" + +# Move to backend directory +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +Set-Location $scriptDir + +# ── Activate virtual environment ───────────────────────────────────── +if (-not (Test-Path ".venv")) { + Write-Host "Error: .venv not found. Create it first:" -ForegroundColor Red + Write-Host " python -m venv .venv" + Write-Host " .venv\Scripts\pip install -r requirements.txt" + exit 1 +} + +& ".venv\Scripts\Activate.ps1" + +# ── Install/upgrade dependencies if requirements.txt is newer ──────── +$marker = ".venv\.deps_installed" +$reqFile = "requirements.txt" + +$needsInstall = $false +if (-not (Test-Path $marker)) { + $needsInstall = $true +} elseif ((Get-Item $reqFile).LastWriteTime -gt (Get-Item $marker).LastWriteTime) { + $needsInstall = $true +} + +if ($needsInstall) { + Write-Host "Installing/upgrading dependencies..." + pip install -r requirements.txt -q + New-Item -Path $marker -ItemType File -Force | Out-Null +} + +# ── Load environment variables from .env ───────────────────────────── +$envPaths = @( + (Join-Path $scriptDir "..\.env"), + (Join-Path $scriptDir ".env") +) + +foreach ($envPath in $envPaths) { + if (Test-Path $envPath) { + Get-Content $envPath | ForEach-Object { + $line = $_.Trim() + # Skip comments and empty lines + if ($line -and -not $line.StartsWith("#")) { + $eqIndex = $line.IndexOf("=") + if ($eqIndex -gt 0) { + $key = $line.Substring(0, $eqIndex).Trim() + $value = $line.Substring($eqIndex + 1).Trim() + # Remove surrounding quotes if present + if (($value.StartsWith('"') -and $value.EndsWith('"')) -or + ($value.StartsWith("'") -and $value.EndsWith("'"))) { + $value = $value.Substring(1, $value.Length - 2) + } + [Environment]::SetEnvironmentVariable($key, $value, "Process") + } + } + } + break # Use the first .env found + } +} + +# ── Set database URL for local postgres ────────────────────────────── +if (-not $env:DATABASE_URL) { + $env:DATABASE_URL = "postgresql://edward:edward@localhost:5432/edward" +} + +Write-Host "Starting Edward backend..." -ForegroundColor Green +Write-Host " - Database: localhost:5432" +Write-Host " - Scheduler: enabled (polls every 30s)" +Write-Host " - API: http://localhost:8000" +Write-Host "" + +# ── Start uvicorn ──────────────────────────────────────────────────── +# Use run.py on Windows to force SelectorEventLoop (psycopg requires it) +python run.py diff --git a/restart.ps1 b/restart.ps1 new file mode 100644 index 0000000..bb762eb --- /dev/null +++ b/restart.ps1 @@ -0,0 +1,149 @@ +# Restart Edward: gracefully stop old processes, restart frontend and backend. +# +# Usage: +# .\restart.ps1 # Restart both frontend and backend +# .\restart.ps1 frontend # Restart only the frontend +# .\restart.ps1 backend # Restart only the backend + +param( + [string]$Component = "all" +) + +$ErrorActionPreference = "SilentlyContinue" + +$ProjectDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$BackendLog = Join-Path $env:TEMP "edward-backend.log" +$FrontendLog = Join-Path $env:TEMP "edward-frontend.log" + +function Write-Info { param($msg) Write-Host "[restart] $msg" -ForegroundColor Green } +function Write-Warn { param($msg) Write-Host "[restart] $msg" -ForegroundColor Yellow } +function Write-Err { param($msg) Write-Host "[restart] $msg" -ForegroundColor Red } + +# ── Stop processes on a given port ─────────────────────────────────── +function Stop-PortProcess { + param([int]$Port, [string]$Name) + + Write-Info "Stopping $Name..." + + $pids = (Get-NetTCPConnection -LocalPort $Port -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty OwningProcess -Unique) + + if (-not $pids) { + Write-Info "$Name was not running" + return + } + + foreach ($pid in $pids) { + try { Stop-Process -Id $pid -ErrorAction SilentlyContinue } catch {} + } + + # Wait up to 5 seconds for graceful stop + $waited = 0 + while ($waited -lt 10) { + $still = Get-NetTCPConnection -LocalPort $Port -ErrorAction SilentlyContinue + if (-not $still) { break } + Start-Sleep -Milliseconds 500 + $waited++ + } + + # Force kill if still running + $remaining = (Get-NetTCPConnection -LocalPort $Port -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty OwningProcess -Unique) + if ($remaining) { + Write-Warn "$Name didn't stop gracefully, force killing..." + foreach ($pid in $remaining) { + try { Stop-Process -Id $pid -Force -ErrorAction SilentlyContinue } catch {} + } + Start-Sleep -Seconds 1 + } + + Write-Info "$Name stopped" +} + +# ── Start backend ──────────────────────────────────────────────────── +function Start-Backend { + Write-Info "Starting backend..." + + $startScript = Join-Path $ProjectDir "backend\start.ps1" + Start-Process powershell -ArgumentList "-ExecutionPolicy Bypass -File `"$startScript`"" ` + -RedirectStandardOutput $BackendLog ` + -RedirectStandardError (Join-Path $env:TEMP "edward-backend-err.log") ` + -WindowStyle Hidden + + # Wait up to 15 seconds for backend to come up + $waited = 0 + while ($waited -lt 30) { + $conn = Get-NetTCPConnection -LocalPort 8000 -ErrorAction SilentlyContinue + if ($conn) { + Write-Info "Backend is UP on port 8000" + return + } + Start-Sleep -Milliseconds 500 + $waited++ + } + + Write-Err "Backend failed to start within 15s. Check log: $BackendLog" + if (Test-Path $BackendLog) { + Get-Content $BackendLog -Tail 20 + } +} + +# ── Start frontend ─────────────────────────────────────────────────── +function Start-Frontend { + Write-Info "Starting frontend..." + + $frontendDir = Join-Path $ProjectDir "frontend" + Start-Process powershell -ArgumentList "-ExecutionPolicy Bypass -Command Set-Location '$frontendDir'; npm run dev" ` + -RedirectStandardOutput $FrontendLog ` + -RedirectStandardError (Join-Path $env:TEMP "edward-frontend-err.log") ` + -WindowStyle Hidden + + # Wait up to 20 seconds for frontend to come up + $waited = 0 + while ($waited -lt 20) { + try { + $response = Invoke-WebRequest -Uri "http://localhost:3000" -UseBasicParsing -TimeoutSec 2 -ErrorAction SilentlyContinue + if ($response) { + Write-Info "Frontend is UP on port 3000" + return + } + } catch {} + Start-Sleep -Seconds 1 + $waited++ + } + + Write-Err "Frontend didn't respond within 20s" +} + +# ── Main ───────────────────────────────────────────────────────────── +Write-Host "" +Write-Info "=== Edward Restart ===" +Write-Host "" + +switch ($Component) { + "all" { + Stop-PortProcess -Port 8000 -Name "backend" + Stop-PortProcess -Port 3000 -Name "frontend" + Start-Frontend + Start-Backend + } + "frontend" { + Stop-PortProcess -Port 3000 -Name "frontend" + Start-Frontend + } + "backend" { + Stop-PortProcess -Port 8000 -Name "backend" + Start-Backend + } + default { + Write-Err "Unknown component: $Component" + Write-Host "Usage: .\restart.ps1 [all|frontend|backend]" + exit 1 + } +} + +Write-Host "" +Write-Info "=== Done ===" +Write-Info " Backend: http://localhost:8000 (log: $BackendLog)" +Write-Info " Frontend: http://localhost:3000 (log: $FrontendLog)" +Write-Host "" diff --git a/setup.ps1 b/setup.ps1 new file mode 100644 index 0000000..206dbe7 --- /dev/null +++ b/setup.ps1 @@ -0,0 +1,101 @@ +# First-time Edward setup for Windows +# PowerShell equivalent of setup.sh + +$ErrorActionPreference = "Stop" + +Write-Host "=== Edward First-Time Setup ===" -ForegroundColor Green + +# ── Check prerequisites ────────────────────────────────────────────── +$missing = @() + +if (-not (Get-Command python -ErrorAction SilentlyContinue)) { + $missing += "Python 3.11+ (https://www.python.org/downloads/)" +} + +if (-not (Get-Command node -ErrorAction SilentlyContinue)) { + $missing += "Node.js 18+ (https://nodejs.org/)" +} + +if (-not (Get-Command psql -ErrorAction SilentlyContinue)) { + $missing += "PostgreSQL 16+ (https://www.postgresql.org/download/windows/)" +} + +if ($missing.Count -gt 0) { + Write-Host "Error: Missing prerequisites:" -ForegroundColor Red + foreach ($m in $missing) { + Write-Host " - $m" -ForegroundColor Red + } + Write-Host "" + Write-Host "Install the above and ensure they are on your PATH, then re-run this script." -ForegroundColor Yellow + exit 1 +} + +# Verify Python version +$pyVersion = python --version 2>&1 +Write-Host "Found: $pyVersion" + +# Verify Node version +$nodeVersion = node --version 2>&1 +Write-Host "Found: Node.js $nodeVersion" + +# Verify PostgreSQL +$pgVersion = psql --version 2>&1 +Write-Host "Found: $pgVersion" + +# ── Set up PostgreSQL database ─────────────────────────────────────── +Write-Host "" +Write-Host "Setting up database..." -ForegroundColor Cyan + +# Create user and database (ignore errors if they already exist) +try { psql -U postgres -c "CREATE USER edward WITH PASSWORD 'edward';" 2>$null } catch {} +try { psql -U postgres -c "CREATE DATABASE edward OWNER edward;" 2>$null } catch {} +try { psql -U postgres -d edward -c "CREATE EXTENSION IF NOT EXISTS vector;" 2>$null } catch { + Write-Host "Warning: Could not enable pgvector extension." -ForegroundColor Yellow + Write-Host " You may need to install pgvector separately:" -ForegroundColor Yellow + Write-Host " https://github.com/pgvector/pgvector#windows" -ForegroundColor Yellow +} + +Write-Host "Database ready (edward/edward on localhost:5432)" -ForegroundColor Green + +# ── Backend Python setup ───────────────────────────────────────────── +Write-Host "" +Write-Host "Setting up backend..." -ForegroundColor Cyan + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$backendDir = Join-Path $scriptDir "backend" + +python -m venv (Join-Path $backendDir ".venv") +& (Join-Path $backendDir ".venv\Scripts\pip.exe") install -r (Join-Path $backendDir "requirements.txt") + +Write-Host "Backend dependencies installed" -ForegroundColor Green + +# ── Frontend setup ─────────────────────────────────────────────────── +Write-Host "" +Write-Host "Setting up frontend..." -ForegroundColor Cyan + +$frontendDir = Join-Path $scriptDir "frontend" +Push-Location $frontendDir +npm install +Pop-Location + +Write-Host "Frontend dependencies installed" -ForegroundColor Green + +# ── Create .env from template if needed ────────────────────────────── +$envFile = Join-Path $scriptDir ".env" +$envExample = Join-Path $scriptDir ".env.example" + +if (-not (Test-Path $envFile)) { + if (Test-Path $envExample) { + Copy-Item $envExample $envFile + Write-Host "Created .env from template. Edit it to add your ANTHROPIC_API_KEY." -ForegroundColor Yellow + } else { + Write-Host "Warning: .env.example not found. Create .env manually." -ForegroundColor Yellow + } +} else { + Write-Host ".env already exists, skipping." -ForegroundColor Green +} + +Write-Host "" +Write-Host "=== Setup Complete ===" -ForegroundColor Green +Write-Host "1. Add your ANTHROPIC_API_KEY to .env" +Write-Host "2. Run: .\restart.ps1"