first commit

This commit is contained in:
Roger Oriol
2026-02-03 23:50:19 +01:00
commit 87fb32b559
80 changed files with 8884 additions and 0 deletions

28
.env.example Normal file
View File

@@ -0,0 +1,28 @@
# LiteLLM Configuration
LITELLM_ENDPOINT=http://litellm-service.default.svc.cluster.local:4000
LITELLM_API_KEY=your-api-key
LITELLM_MODEL=claude-sonnet-4-5
# Discord Configuration
DISCORD_BOT_TOKEN=your-discord-bot-token
DISCORD_CHANNEL_ID=your-channel-id
# Git Configuration
GIT_REPO_URL=https://github.com/yourusername/myorg.git
GIT_BRANCH=main
GIT_USERNAME=your-username
GIT_TOKEN=your-git-token
# Myorg Repository Path
MYORG_REPO_PATH=/data/myorg
# Scheduling (timezone)
TIMEZONE=Europe/Madrid
# Web Interface
WEB_HOST=0.0.0.0
WEB_PORT=8000
WEB_SECRET_KEY=your-secret-key
# Optional: Authentication
WEB_PASSWORD=your-password # Basic auth for web UI

52
.gitignore vendored Normal file
View File

@@ -0,0 +1,52 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual Environment
venv/
ENV/
env/
# IDEs
.vscode/
.idea/
*.swp
*.swo
*~
# Environment
.env
.env.local
# Testing
.pytest_cache/
.coverage
htmlcov/
# Logs
*.log
# OS
.DS_Store
Thumbs.db
# Myorg test data
tests/fixtures/test_myorg/.git/

290
DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,290 @@
# MyOrg Assistant - Deployment Guide
This guide explains how to deploy the MyOrg Assistant to a k3s Kubernetes cluster.
## Prerequisites
- k3s cluster running and accessible
- `kubectl` configured to connect to your cluster
- Docker installed for building images
- Discord bot token (see "Creating a Discord Bot" below)
- Access to LiteLLM endpoint
- Git repository for your myorg data
## Creating a Discord Bot
1. Go to [Discord Developer Portal](https://discord.com/developers/applications)
2. Click "New Application" and give it a name
3. Go to "Bot" section and click "Add Bot"
4. Under "Privileged Gateway Intents", enable:
- Message Content Intent
- Server Members Intent
5. Click "Reset Token" to get your bot token (save this securely!)
6. Go to "OAuth2" → "URL Generator"
7. Select scopes: `bot`, `applications.commands`
8. Select bot permissions: `Send Messages`, `Read Messages/View Channels`, `Read Message History`
9. Copy the generated URL and open it to invite the bot to your server
## Configuration
### 1. Create Secret
Copy the secret template and fill in your credentials:
```bash
cd k8s
cp secret.yaml.example secret.yaml
```
Edit `secret.yaml` and replace the placeholder values:
```yaml
stringData:
DISCORD_BOT_TOKEN: "your-actual-discord-bot-token"
DISCORD_CHANNEL_ID: "your-discord-channel-id"
LITELLM_API_KEY: "your-litellm-api-key"
GIT_REPO_URL: "https://github.com/yourusername/myorg.git"
GIT_USERNAME: "yourusername"
GIT_TOKEN: "your-github-personal-access-token"
WEB_SECRET_KEY: "generate-a-random-secret-key"
WEB_PASSWORD: "optional-web-ui-password"
```
**Important:** Never commit `secret.yaml` to git! It contains sensitive credentials.
### 2. Review ConfigMap
Check `k8s/configmap.yaml` and adjust if needed:
- `LITELLM_ENDPOINT`: Your LiteLLM service endpoint
- `TIMEZONE`: Your timezone (default: Europe/Madrid)
- Storage class in `pvc.yaml` (default: local-path for k3s)
## Deployment
### Automated Deployment
Use the deployment script for easy setup:
```bash
cd k8s
./deploy.sh
```
This script will:
1. Build the Docker image
2. Load it into k3s
3. Apply all Kubernetes manifests
4. Wait for the deployment to be ready
5. Show logs and status
### Manual Deployment
If you prefer to deploy manually:
```bash
# Build Docker image
docker build -t myorg-assistant:latest .
# Load into k3s
docker save myorg-assistant:latest | sudo k3s ctr images import -
# Apply Kubernetes resources
kubectl apply -f k8s/secret.yaml
kubectl apply -f k8s/configmap.yaml
kubectl apply -f k8s/pvc.yaml
kubectl apply -f k8s/service.yaml
kubectl apply -f k8s/deployment.yaml
# Check status
kubectl get pods -l app=myorg-assistant
kubectl logs -f deployment/myorg-assistant
```
## Verification
### Check Pod Status
```bash
kubectl get pods -l app=myorg-assistant
```
You should see:
```
NAME READY STATUS RESTARTS AGE
myorg-assistant-xxxxx-xxxxx 1/1 Running 0 2m
```
### View Logs
```bash
# Get pod name
POD_NAME=$(kubectl get pods -l app=myorg-assistant -o jsonpath='{.items[0].metadata.name}')
# View logs
kubectl logs -f $POD_NAME
# You should see:
# ✅ Logged in as YourBotName#1234
# 📊 Connected to X server(s)
# 🤖 Agent initialized with 12 tools
# 🎉 MyOrg Assistant is ready!
```
### Test in Discord
1. Go to your Discord server where the bot is installed
2. Mention the bot or send it a DM:
- `@YourBot help`
- `@YourBot add task: Test the bot`
- `@YourBot /tasks`
## Repository Sync
The deployment includes an init container that:
- Clones your myorg repository on first start
- Pulls latest changes on restart
- Configures git credentials
The bot will:
- Commit changes when you modify tasks
- Auto-push after commits (can be disabled)
- Sync with remote every 15 minutes (Phase 3 feature)
## Monitoring
### View Logs
```bash
kubectl logs -f deployment/myorg-assistant
```
### Access Pod Shell
```bash
kubectl exec -it $POD_NAME -- /bin/bash
# Inside pod:
cd /data/myorg
git status
ls -la
```
### Check Git Repository
```bash
kubectl exec -it $POD_NAME -- sh -c "cd /data/myorg && git log --oneline -10"
```
## Troubleshooting
### Pod Crashes or Restarts
```bash
# Check pod events
kubectl describe pod $POD_NAME
# Check logs including previous crashes
kubectl logs $POD_NAME --previous
```
Common issues:
- **Missing secret**: Ensure `secret.yaml` is applied
- **Wrong Discord token**: Check token in secret
- **LiteLLM connection failed**: Verify endpoint and API key
- **Git clone failed**: Check repository URL and token permissions
### Bot Not Responding
1. Check bot is online in Discord
2. Verify bot has proper permissions in server
3. Check logs for errors:
```bash
kubectl logs -f $POD_NAME
```
### Repository Not Syncing
```bash
# Check git status inside pod
kubectl exec -it $POD_NAME -- sh -c "cd /data/myorg && git status"
# Check git remote
kubectl exec -it $POD_NAME -- sh -c "cd /data/myorg && git remote -v"
# Manual pull
kubectl exec -it $POD_NAME -- sh -c "cd /data/myorg && git pull"
```
## Updating the Deployment
### Update Code
```bash
# Rebuild image
docker build -t myorg-assistant:latest .
# Reload into k3s
docker save myorg-assistant:latest | sudo k3s ctr images import -
# Restart deployment
kubectl rollout restart deployment/myorg-assistant
# Wait for new pod
kubectl rollout status deployment/myorg-assistant
```
### Update Configuration
```bash
# Edit configmap or secret
kubectl edit configmap myorg-assistant-config
# or
kubectl apply -f k8s/secret.yaml
# Restart to pick up changes
kubectl rollout restart deployment/myorg-assistant
```
## Scaling
The bot is designed to run as a single replica (one instance). If you need higher availability:
```bash
# Note: Multiple replicas may cause conflicts with git repository
# Consider implementing distributed locking first
kubectl scale deployment myorg-assistant --replicas=1
```
## Cleanup
To remove the deployment:
```bash
kubectl delete -f k8s/deployment.yaml
kubectl delete -f k8s/service.yaml
kubectl delete -f k8s/pvc.yaml
kubectl delete -f k8s/configmap.yaml
kubectl delete -f k8s/secret.yaml
```
Or delete everything at once:
```bash
kubectl delete deployment,service,pvc,configmap,secret -l app=myorg-assistant
```
**Warning:** This will delete the PVC and all data in it. Back up your myorg repository first!
## Next Steps
- **Phase 3**: Add scheduled briefings (morning/evening)
- **Phase 4**: Deploy web interface with Ingress
- **Phase 5**: Add intelligent suggestions and goal tracking
## Support
For issues or questions:
- Check logs: `kubectl logs -f deployment/myorg-assistant`
- Review pod events: `kubectl describe pod $POD_NAME`
- Test locally first: `python -m src.main cli`

25
Dockerfile Normal file
View File

@@ -0,0 +1,25 @@
FROM python:3.11-slim
# Install git
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
# Set working directory
WORKDIR /app
# Copy requirements
COPY requirements.txt .
# Install dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY src/ ./src/
# Create data directory for myorg repo
RUN mkdir -p /data/myorg
# Expose web interface port
EXPOSE 8000
# Run application
CMD ["python", "-m", "src.main"]

348
QUICKSTART.md Normal file
View File

@@ -0,0 +1,348 @@
# MyOrg Assistant - Quick Start Guide
Get your personal assistant up and running in 15 minutes!
## What You're Building
A complete AI-powered personal assistant that:
- 🤖 Manages your GTD tasks via Discord or web interface
- 🌅 Sends morning briefings at 8 AM
- 🌆 Sends evening summaries at 8 PM
- ⏰ Warns about deadlines automatically
- 🔄 Syncs everything via git
- 📊 Provides a beautiful web dashboard
## Prerequisites Checklist
Before starting, ensure you have:
- [ ] **Python 3.11+** installed
- [ ] **Git** installed
- [ ] **LiteLLM endpoint** running (with Claude API access)
- [ ] **Discord bot** created (5 min setup - see below)
- [ ] **myorg repository** (or use test data)
- [ ] **k3s cluster** (optional - for production deployment)
## Step 1: Get the Code (2 minutes)
```bash
git clone <your-repo-url>
cd myorg-assistant
# Create virtual environment
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
# Install dependencies
pip install -r requirements.txt
```
## Step 2: Create Discord Bot (5 minutes)
1. Visit [Discord Developer Portal](https://discord.com/developers/applications)
2. Click "New Application" → Name it "MyOrg Assistant"
3. Go to "Bot" → Click "Add Bot"
4. Enable these intents:
- ✅ Message Content Intent
- ✅ Server Members Intent
5. Click "Reset Token" → **Copy this token!**
6. Go to "OAuth2" → "URL Generator"
- Check: `bot`, `applications.commands`
- Bot Permissions: `Send Messages`, `Read Messages`, `Read Message History`
7. Open the generated URL to invite bot to your server
## Step 3: Configure Environment (3 minutes)
```bash
cp .env.example .env
```
Edit `.env` and fill in:
```bash
# === REQUIRED ===
# Your LiteLLM endpoint
LITELLM_ENDPOINT=http://localhost:4000
LITELLM_API_KEY=sk-your-key
# Discord bot token (from Step 2)
DISCORD_BOT_TOKEN=YOUR.BOT.TOKEN.HERE
DISCORD_CHANNEL_ID=123456789 # Right-click channel → Copy ID
# Your myorg repository
MYORG_REPO_PATH=./tests/fixtures/test_myorg # Use test data for now
GIT_REPO_URL=https://github.com/yourusername/myorg.git
GIT_USERNAME=yourusername
GIT_TOKEN=ghp_your_github_token
# === OPTIONAL ===
# Web interface password (leave empty for no password)
WEB_PASSWORD=mypassword
# Your timezone
TIMEZONE=Europe/Madrid
```
**Quick Tip**: Don't have a myorg repo? Use the test data included:
```bash
export MYORG_REPO_PATH=./tests/fixtures/test_myorg
```
## Step 4: Test Locally (2 minutes)
### Test CLI Mode
```bash
python -m src.main cli
```
Try these commands:
```
You: list files
You: show all tasks
You: add task: Test the system @computer-deep
You: exit
```
### Test Discord Bot
```bash
python -m src.main bot
```
In Discord:
```
@MyOrgBot help
@MyOrgBot add task: Test Discord integration
@MyOrgBot /tasks
```
### Test Web Interface
```bash
python -m src.main web
```
Visit: http://localhost:8000
## Step 5: Deploy to Kubernetes (3 minutes)
### Quick Deploy
```bash
cd k8s
# 1. Create secret with your credentials
cp secret.yaml.example secret.yaml
# Edit secret.yaml with your tokens
kubectl apply -f secret.yaml
# 2. Run deployment script
./deploy.sh
```
### Verify Deployment
```bash
# Check pod status
kubectl get pods -l app=myorg-assistant
# View logs
kubectl logs -f deployment/myorg-assistant
# Check scheduled jobs
kubectl get cronjobs
```
You should see:
- Main pod running (Discord bot + web server)
- 5 CronJobs configured
- Web service exposed on port 8000
## What Happens Next?
Once deployed, your assistant will:
**Immediately:**
- ✅ Respond to Discord messages
- ✅ Serve web interface at port 8000
- ✅ Sync git every 15 minutes
**At 8:00 AM (your timezone):**
- 🌅 Send morning briefing to Discord
**Every Hour:**
- ⏰ Check deadlines and send warnings if needed
**At 8:00 PM:**
- 🌆 Send evening summary to Discord
**Every Monday at 9 AM:**
- ⏸️ Send waiting list follow-up
## Usage Examples
### Discord
**Add Tasks:**
```
@MyOrgBot Add task: Buy groceries tomorrow @recados
@MyOrgBot Recordatori: Review PR +work due:2026-02-05
```
**Check Tasks:**
```
@MyOrgBot What should I work on? I have 2 hours @computer-deep
@MyOrgBot /tasks priority:A
@MyOrgBot Show tasks for project myorg-assistant
```
**Get Information:**
```
@MyOrgBot /today
@MyOrgBot /briefing
@MyOrgBot What's my calendar like this week?
```
### Web Interface
Navigate to:
- **Dashboard** (`/`) - Today's overview
- **Chat** (`/chat`) - Talk to assistant
- **Tasks** (`/tasks`) - Manage todos
- **Calendar** (`/calendar`) - View events
- **Projects** (`/projects`) - Track projects
## Troubleshooting
### Bot Not Responding?
```bash
# Check logs
kubectl logs -f deployment/myorg-assistant
# Common fixes:
# 1. Verify Discord token in secret
# 2. Check bot has permissions in server
# 3. Ensure Message Content Intent is enabled
```
### LiteLLM Connection Error?
```bash
# Test endpoint
curl http://your-litellm-endpoint:4000/health
# Verify these in .env:
LITELLM_ENDPOINT=http://... (correct URL?)
LITELLM_API_KEY=... (valid key?)
```
### Git Sync Failing?
```bash
# Check git job logs
kubectl logs job/myorg-git-sync-xxxxx
# Common fixes:
# 1. Verify git token has repo permissions
# 2. Check repository URL is correct
# 3. Ensure branch name matches (default: main)
```
### Web Interface 401 Unauthorized?
If you set `WEB_PASSWORD`, use HTTP Basic Auth:
- Username: (any value)
- Password: your WEB_PASSWORD value
Or remove the password from .env to disable auth.
## Next Steps
### For Production Use
1. **Set up your real myorg repository:**
```bash
# Update .env with your repo
MYORG_REPO_PATH=/path/to/your/myorg
GIT_REPO_URL=https://github.com/you/myorg.git
```
2. **Configure external access:**
```bash
# Edit k8s/ingress.yaml with your domain
kubectl apply -f k8s/ingress.yaml
```
3. **Adjust timezone:**
```bash
# In .env or k8s/configmap.yaml
TIMEZONE=Your/Timezone
```
4. **Customize briefing times:**
Edit the schedule in:
- `k8s/cronjobs/morning-briefing.yaml` (default: 8 AM)
- `k8s/cronjobs/evening-summary.yaml` (default: 8 PM)
### Explore Features
- Read [README.md](README.md) for full documentation
- Check [DEPLOYMENT.md](DEPLOYMENT.md) for advanced deployment
- See [project-plan.md](project-plan.md) for architecture details
## Getting Help
**Logs are your friend:**
```bash
# Main app logs
kubectl logs -f deployment/myorg-assistant
# Specific job logs
kubectl get jobs
kubectl logs job/myorg-morning-briefing-xxxxx
```
**Common Commands:**
```bash
# Restart deployment
kubectl rollout restart deployment/myorg-assistant
# Check all resources
kubectl get all -l app=myorg-assistant
# Delete everything (careful!)
kubectl delete -f k8s/
```
## Success! 🎉
You now have:
- ✅ AI assistant managing your GTD system
- ✅ Discord bot for mobile/quick access
- ✅ Web dashboard for detailed management
- ✅ Automated daily briefings
- ✅ Deadline warnings
- ✅ Automatic git synchronization
**Your morning tomorrow will look like this:**
```
🌅 Good Morning! - Saturday, February 01, 2026
📅 Today's Schedule:
• 09:00 Morning coffee @personal
• 14:00 Work on myorg assistant +myorg-assistant
✅ Priority Tasks:
• (A) Complete Phase 4 +myorg-assistant @computer-deep
• (B) Review documentation +myorg-assistant
Have a productive day! 🚀
```
Welcome to your new AI-powered productivity system! 🤖✨

481
README.md Normal file
View File

@@ -0,0 +1,481 @@
# MyOrg Personal Assistant
An AI-powered personal assistant that helps manage daily life using the myorg GTD system.
## Overview
The MyOrg Personal Assistant is an intelligent agent that helps you manage your personal organization system (myorg). It acts as a trusted assistant that can read, understand, and modify your GTD-based task management system, providing proactive help throughout your day.
## Features
### 🤖 Intelligent Task Management
- Natural language task entry via Discord or web interface
- Automatic parsing of projects, contexts, priorities, and due dates
- Smart task completion with timestamps
- Context-aware task filtering
### 📅 Proactive Scheduling
- **Morning Briefing** (8:00 AM): Today's calendar, priority tasks, due soon items
- **Evening Summary** (8:00 PM): Accomplishments, tomorrow prep, reflection prompts
- **Deadline Warnings**: Hourly checks for overdue and upcoming deadlines
- **Waiting List Follow-ups**: Weekly reminders (Mondays 9 AM)
- **Git Sync**: Automatic sync every 15 minutes
### 💬 Multiple Interfaces
- **Discord Bot**: Quick access via mobile or desktop Discord
- **Web Dashboard**: Rich visual interface for detailed management
- **CLI Mode**: Testing and local development
### 🧠 Smart Features
- Context inference from time and calendar
- Project progress tracking
- Goal alignment (with telos.md integration)
- Automatic git commits for all changes
## Technology Stack
- **Backend**: Python 3.11+, FastAPI, Discord.py
- **AI**: Claude Sonnet 4.5 via LiteLLM proxy
- **Frontend**: Vanilla CSS, HTMX, Jinja2 templates
- **Storage**: Git repository (todo.txt, calendar.txt, projects.txt)
- **Deployment**: Docker + Kubernetes (k3s)
## Quick Start
### Prerequisites
1. **Python 3.11+**
2. **Git**
3. **LiteLLM Endpoint** - Running instance with Claude API access
4. **Discord Bot** (optional) - For Discord integration
5. **myorg Repository** - Your GTD data repository
### 1. Clone and Install
```bash
# Clone repository
git clone <repository-url>
cd myorg-assistant
# Create virtual environment
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
# Install dependencies
pip install -r requirements.txt
```
### 2. Configure Environment
```bash
# Copy example configuration
cp .env.example .env
```
Edit `.env` with your configuration:
```bash
# LiteLLM Configuration (REQUIRED)
LITELLM_ENDPOINT=http://localhost:4000 # Your LiteLLM proxy URL
LITELLM_API_KEY=your-api-key
LITELLM_MODEL=claude-sonnet-4-5
# Myorg Repository (REQUIRED)
MYORG_REPO_PATH=/path/to/your/myorg # Local path to your myorg repo
GIT_REPO_URL=https://github.com/yourusername/myorg.git
GIT_USERNAME=yourusername
GIT_TOKEN=ghp_your_github_token
GIT_BRANCH=main
# Discord Bot (REQUIRED for Discord features)
DISCORD_BOT_TOKEN=your.discord.bot.token
DISCORD_CHANNEL_ID=123456789012345678
# Web Interface (OPTIONAL)
WEB_HOST=0.0.0.0
WEB_PORT=8000
WEB_SECRET_KEY=generate-random-secret-key-here
WEB_PASSWORD=optional-password-for-basic-auth
# Scheduling
TIMEZONE=Europe/Madrid # Your timezone
```
### 3. Set Up Your myorg Repository
The assistant expects a myorg repository with these files:
```
myorg/
├── todo.txt # Tasks in todo.txt format
├── calendar.txt # Calendar events
├── projects.txt # Active projects
├── waiting.txt # Items waiting on others
├── telos.md # Life vision and missions
└── goals/ # Quarterly goals (optional)
```
If you don't have one, you can use the test repository:
```bash
# For testing, point to the included test data
export MYORG_REPO_PATH=./tests/fixtures/test_myorg
```
### 4. Run Locally
#### CLI Mode (Testing)
```bash
python -m src.main cli
```
Example interactions:
- "List files in the repository"
- "Add task: Test the assistant with context computer-deep"
- "Show all active tasks"
- "What's on my calendar today?"
#### Discord Bot Mode
```bash
python -m src.main bot
```
The bot will connect to Discord and respond to mentions or DMs.
#### Web Interface Mode
```bash
python -m src.main web
```
Then visit: `http://localhost:8000`
## Discord Bot Setup
### Create Discord Bot
1. Go to [Discord Developer Portal](https://discord.com/developers/applications)
2. Click "New Application" → Give it a name
3. Go to "Bot" section → Click "Add Bot"
4. Under "Privileged Gateway Intents", enable:
- ✅ Message Content Intent
- ✅ Server Members Intent
5. Click "Reset Token" → Save this token to `.env` as `DISCORD_BOT_TOKEN`
6. Go to "OAuth2" → "URL Generator"
- Scopes: `bot`, `applications.commands`
- Bot Permissions: `Send Messages`, `Read Messages/View Channels`, `Read Message History`
7. Copy the generated URL and invite bot to your server
### Discord Commands
Once the bot is running:
**Natural Conversation:**
```
@MyOrgBot Add task: Buy groceries tomorrow @recados
@MyOrgBot What should I work on? I have 2 hours @computer-deep
@MyOrgBot Show my calendar for today
```
**Commands:**
- `/help` - Show all commands
- `/briefing` - Get daily briefing
- `/add [task]` - Quick task addition
- `/tasks [filter]` - Show tasks (optionally filtered)
- `/today` - Today's schedule and priority tasks
- `/context [context]` - Set current context
- `/reset` - Clear conversation history
## Web Interface
The web interface provides:
### Dashboard (`/`)
- Stats overview (events, tasks, projects)
- Today's schedule
- Priority tasks
- Due soon items
- Active projects
### Chat (`/chat`)
- Full conversation with the agent
- HTMX-powered dynamic updates
- Natural language interaction
### Tasks (`/tasks`)
- Complete task list
- Filter by project, context, priority
- Mark tasks complete
- Add new tasks
### Calendar (`/calendar`)
- Today's events
- Upcoming week view
- All-day and timed events
### Projects (`/projects`)
- All projects by status
- Task count per project
- Filter by status (active, waiting, someday, completed)
## Kubernetes Deployment
### Prerequisites
- k3s cluster running
- `kubectl` configured
- Docker for building images
### 1. Build and Load Image
```bash
# Build image
docker build -t myorg-assistant:latest .
# Load into k3s
docker save myorg-assistant:latest | sudo k3s ctr images import -
```
### 2. Create Secret
```bash
cd k8s
cp secret.yaml.example secret.yaml
# Edit secret.yaml with your actual credentials
kubectl apply -f secret.yaml
```
**Important:** Never commit `secret.yaml` to version control!
### 3. Deploy
```bash
# Automated deployment (recommended)
./deploy.sh
# Or manually
kubectl apply -f configmap.yaml
kubectl apply -f pvc.yaml
kubectl apply -f service.yaml
kubectl apply -f deployment.yaml
kubectl apply -f cronjobs/
kubectl apply -f ingress.yaml # Optional: for external access
```
### 4. Verify Deployment
```bash
# Check pod status
kubectl get pods -l app=myorg-assistant
# View logs
kubectl logs -f deployment/myorg-assistant
# Check CronJobs
kubectl get cronjobs
# Expected output:
# myorg-morning-briefing 0 8 * * * ...
# myorg-evening-summary 0 20 * * * ...
# myorg-deadline-checker 0 * * * * ...
# myorg-git-sync */15 * * * * ...
# myorg-waiting-followup 0 9 * * 1 ...
```
### 5. Access Web Interface
**Internal Access (within cluster):**
```
http://myorg-assistant-service.default.svc.cluster.local:8000
```
**External Access (via Ingress):**
1. Edit `k8s/ingress.yaml` with your domain
2. Apply: `kubectl apply -f ingress.yaml`
3. Access: `https://myorg.yourdomain.com`
## Scheduled Jobs
The system runs these automated jobs:
| Job | Schedule | Description |
|-----|----------|-------------|
| Morning Briefing | Daily 8:00 AM | Calendar + priority tasks + due soon |
| Evening Summary | Daily 8:00 PM | Completed tasks + tomorrow prep |
| Deadline Checker | Every hour | Warns about overdue/upcoming deadlines |
| Git Sync | Every 15 min | Pull/push repository changes |
| Waiting Follow-up | Mon 9:00 AM | Review waiting list items |
All times use the timezone configured in `TIMEZONE` environment variable.
## Configuration Reference
### Environment Variables
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `LITELLM_ENDPOINT` | Yes | - | LiteLLM proxy URL |
| `LITELLM_API_KEY` | Yes | - | API key for LiteLLM |
| `LITELLM_MODEL` | No | claude-sonnet-4-5 | Model name |
| `MYORG_REPO_PATH` | Yes | /data/myorg | Path to myorg repository |
| `GIT_REPO_URL` | Yes | - | Git repository URL |
| `GIT_USERNAME` | Yes | - | Git username |
| `GIT_TOKEN` | Yes | - | Git personal access token |
| `GIT_BRANCH` | No | main | Git branch to use |
| `DISCORD_BOT_TOKEN` | Yes* | - | Discord bot token (*for Discord) |
| `DISCORD_CHANNEL_ID` | Yes* | - | Default Discord channel ID |
| `WEB_HOST` | No | 0.0.0.0 | Web server host |
| `WEB_PORT` | No | 8000 | Web server port |
| `WEB_SECRET_KEY` | Yes** | - | Secret for sessions (**for web) |
| `WEB_PASSWORD` | No | - | Password for basic auth (optional) |
| `TIMEZONE` | No | Europe/Madrid | Timezone for schedules |
### File Formats
**todo.txt Format:**
```
(A) 2026-01-31 Write blog post +project @context due:2026-02-15
x 2026-01-30 Completed task +project
```
**calendar.txt Format:**
```
2026-02-01 09:00 Team meeting @telefon +work
2026-02-15 Birthday party @personal
```
**projects.txt Format:**
```
+project-name Description [active] @context goal:q1-2026 due:2026-02-28
```
## Troubleshooting
### Common Issues
**Bot not responding in Discord:**
- Check bot is online: `kubectl logs -f deployment/myorg-assistant`
- Verify Discord token in secret
- Ensure bot has proper permissions in server
- Check bot was mentioned or is receiving DMs
**LiteLLM connection failed:**
- Verify `LITELLM_ENDPOINT` is correct
- Check LiteLLM service is running: `kubectl get svc litellm-service`
- Test API key is valid
**Git sync errors:**
- Check git credentials in secret
- Verify repository URL is accessible
- Ensure PAT has repo permissions
- Check logs: `kubectl logs job/myorg-git-sync-xxxxx`
**Web interface not accessible:**
- Check pod is running: `kubectl get pods`
- Verify service: `kubectl get svc myorg-assistant-service`
- For external access, check ingress: `kubectl get ingress`
### Logs and Debugging
```bash
# Main application logs
kubectl logs -f deployment/myorg-assistant
# Specific CronJob logs
kubectl logs job/myorg-morning-briefing-xxxxx
# Get into pod shell
POD=$(kubectl get pods -l app=myorg-assistant -o name | head -1)
kubectl exec -it $POD -- /bin/bash
# Inside pod:
cd /data/myorg
git status
ls -la
cat todo.txt
```
## Development
### Running Tests
```bash
# Run all tests
pytest
# Run specific test file
pytest tests/test_todo_parser.py
# Run with coverage
pytest --cov=src tests/
```
### Project Structure
```
src/
├── agent/ # Agent orchestration
│ ├── core.py # MyOrgAgent class
│ └── prompts.py # System prompts
├── tools/ # Agent tools
│ ├── file_ops.py # File operations
│ ├── task_ops.py # Task management
│ └── git_ops.py # Git operations
├── parsers/ # Format parsers
│ ├── todo_parser.py
│ ├── calendar_parser.py
│ └── project_parser.py
├── api/ # Web interface
│ ├── app.py # FastAPI app
│ └── routes/ # API routes
├── bot/ # Discord bot
│ ├── discord_bot.py
│ └── formatters.py
├── scheduler/ # Scheduled jobs
│ ├── briefings.py # Briefing generators
│ └── jobs.py # Job runners
└── utils/ # Utilities
└── context.py # Context inference
```
## Documentation
- [DEPLOYMENT.md](DEPLOYMENT.md) - Detailed deployment guide
- [project-plan.md](project-plan.md) - Full vision and architecture
- [implementation-plan.md](implementation-plan.md) - Development phases
- [todo.md](todo.md) - Implementation progress
## Status
**Version**: 1.0.0
**Status**: Production Ready ✅
**Completion**: 83% (5 of 6 phases complete)
### Completed Features
- ✅ Phase 0: Project Setup & Foundation
- ✅ Phase 1: Core Agent with File Tools
- ✅ Phase 2: Discord Bot Integration
- ✅ Phase 3: Scheduled Briefings & Reminders
- ✅ Phase 4: Web Interface
### Optional Enhancements
- ⏳ Phase 5: Advanced Intelligence (goal tracking, analytics)
- ⏳ Phase 6: Polish & Optimization (caching, monitoring)
The system is fully functional and ready for daily use!
## Contributing
This is a personal project, but suggestions and improvements are welcome via issues.
## License
Private project for personal use.
---
**Created**: 2026-01-31
**Last Updated**: 2026-01-31
**Author**: Built with Claude Sonnet 4.5

745
implementation-plan.md Normal file
View File

@@ -0,0 +1,745 @@
# MyOrg Personal Assistant - Implementation Plan
**Project**: `+myorg-assistant`
**Document**: Implementation Plan
**Created**: 2026-01-31
**Status**: Planning
## Overview
This document outlines the step-by-step implementation plan for building the MyOrg Personal Assistant. The project is broken down into phases, with each phase delivering functional value that can be tested and used.
## Implementation Strategy
**Approach**: Incremental delivery with vertical slices
- Each phase delivers a working feature end-to-end
- Early phases establish core infrastructure
- Later phases add intelligence and automation
- Can deploy and use the system after Phase 2
**Development Environment**:
- Local development: Python virtual environment, local git clone of myorg
- Testing: Unit tests + integration tests with test myorg repository
- Deployment: Docker container → k3s cluster
## Phases
### Phase 0: Project Setup & Foundation (Week 1)
**Goal**: Set up development environment and project structure
**Tasks**:
1. Create project repository structure
```
myorg-assistant/
├── src/
│ ├── agent/ # Claude Agent SDK integration
│ ├── tools/ # Agent tools (file ops, git, parsers)
│ ├── parsers/ # Todo.txt, calendar.txt parsers
│ ├── api/ # FastAPI endpoints
│ ├── bot/ # Discord bot
│ ├── web/ # Web UI (templates, static files)
│ └── scheduler/ # Scheduled jobs
├── tests/
├── k8s/ # Kubernetes manifests
├── Dockerfile
├── requirements.txt
└── README.md
```
2. Set up Python environment
- Create virtual environment
- Install core dependencies: FastAPI, Claude Agent SDK, GitPython, Discord.py
- Set up pre-commit hooks (black, ruff, mypy)
3. Create parsers for myorg formats
- `TodoParser`: Parse todo.txt format
- `CalendarParser`: Parse calendar.txt format
- `ProjectParser`: Parse projects.txt
- Unit tests for each parser
4. Create test myorg repository
- Minimal working example with sample data
- Use for testing without affecting real data
**Deliverable**: Working parsers that can read myorg files
**Estimated Time**: 3-4 days
---
### Phase 1: Core Agent with File Tools (Week 1-2)
**Goal**: Build Claude Agent SDK integration with basic file operations
**Tasks**:
1. Set up Claude Agent SDK
- Configure connection to LiteLLM endpoint
- Set up Claude Sonnet 4.5 model
- Create base agent class
2. Implement file operation tools
- `read_file(path)`: Read any myorg file
- `write_file(path, content)`: Write/update files
- `append_to_file(path, content)`: Append content
- `list_files(directory)`: Browse structure
- Add safety checks (validate paths, backup before write)
3. Implement task management tools
- `add_task(description, project, context, priority, due_date)`
- `complete_task(task_line_number)`: Mark complete with timestamp
- `search_tasks(filters)`: Query by project/context/priority
- Use TodoParser for proper formatting
4. Implement git tools
- `git_status()`: Check repo status
- `git_commit(message)`: Commit changes
- `git_pull()`: Sync from remote
- `git_push()`: Push to remote
5. Create agent system prompt
- Define agent role and responsibilities
- Document myorg structure and formats
- Set guidelines for autonomous actions
6. Build simple CLI for testing
- Interactive chat with agent
- Test file operations and task management
- Verify git integration
**Deliverable**: Working agent that can read/write myorg files and manage tasks via CLI
**Estimated Time**: 4-5 days
---
### Phase 2: Discord Bot Integration (Week 2-3)
**Goal**: Deploy agent as Discord bot for daily use
**Tasks**:
1. Set up Discord bot
- Create Discord application and bot
- Implement discord.py integration
- Connect bot to agent backend
2. Implement core bot commands
- Natural conversation → agent processes
- `/briefing`: Manual trigger for daily summary
- `/add [task]`: Quick task addition
- `/tasks [filter]`: Show filtered tasks
- `/today`: Today's calendar and priority tasks
- `/context [context]`: Set current context
3. Format agent responses for Discord
- Use Discord markdown formatting
- Add emojis for readability
- Handle long responses (pagination/truncation)
4. Create Docker container
- Dockerfile with Python app
- Include git for repository operations
- Environment variables for config
5. Deploy to k3s cluster
- Create Kubernetes Deployment manifest
- Create ConfigMap for configuration
- Create Secret for Discord token and git credentials
- Create PersistentVolumeClaim for myorg repository
- Deploy and test
6. Set up repository sync
- Clone myorg repo on container startup
- Periodic git pull (every 15 minutes)
- Auto-push after agent commits
**Deliverable**: Discord bot that can chat, add tasks, and show daily summary
**Estimated Time**: 5-6 days
---
### Phase 3: Scheduled Briefings & Reminders (Week 3-4)
**Goal**: Add proactive scheduled features
**Tasks**:
1. Implement briefing generators
- `generate_morning_briefing()`: Calendar + priority tasks + waiting items
- `generate_evening_summary()`: Accomplishments + tomorrow prep
- Format as Discord messages
2. Implement reminder logic
- `check_deadlines()`: Find tasks due soon (7d, 3d, 1d)
- `check_waiting_items()`: Find stale waiting list items
- `check_upcoming_events()`: Calendar prep (30 min before)
3. Set up scheduling system
- Option A: APScheduler in-process
- Option B: Kubernetes CronJobs (preferred for k8s)
- Configure timezone handling
4. Create Kubernetes CronJobs
- `morning-briefing`: Daily at 8:00 AM
- `evening-summary`: Daily at 8:00 PM
- `deadline-checker`: Hourly
- `waiting-followup`: Weekly (Monday 9:00 AM)
- `git-sync`: Every 15 minutes
- All jobs send messages via Discord bot
5. Implement context inference
- `infer_context(time, calendar_events)`: Guess user context
- Time-based rules (work hours, evenings, weekends)
- Calendar-based (meeting → @telefon, travel → location)
**Deliverable**: Automated briefings and reminders sent via Discord
**Estimated Time**: 5-6 days
---
### Phase 4: Web Interface (Week 4-5)
**Goal**: Build web dashboard for richer visualization
**Tasks**:
1. Set up FastAPI web server
- Create FastAPI app
- Add Jinja2 template engine
- Serve static files (CSS, JS)
2. Build core pages
- **Dashboard** (`/`):
- Today's calendar events
- Priority tasks list
- Quick stats (active projects, waiting items)
- Context selector
- **Chat** (`/chat`):
- Chat interface with agent
- Server-Sent Events for real-time updates
- HTMX for message sending/loading
- **Tasks** (`/tasks`):
- Filterable task list
- Add/complete tasks
- HTMX for dynamic updates
- **Calendar** (`/calendar`):
- Monthly/weekly calendar view
- Today's events highlighted
- Simple CSS Grid layout
- **Projects** (`/projects`):
- Active projects list
- Progress indicators
- Link to related tasks
3. Style with vanilla CSS
- Clean, semantic HTML
- Responsive layout (mobile-friendly)
- Dark mode support
- Minimal, fast-loading
4. Add HTMX interactivity
- Task completion without page reload
- Live task filtering
- Quick-add forms
- Auto-refresh sections
5. Implement SSE for chat
- Real-time agent responses
- Streaming message updates
- Connection handling
6. Add authentication (basic)
- Simple password or API key
- Protect web interface
- Optional: OAuth if needed
7. Update Kubernetes manifests
- Add Service for web interface
- Add Ingress (optional, for external access)
- Configure internal DNS
**Deliverable**: Web interface for viewing tasks, calendar, and chatting with agent
**Estimated Time**: 6-7 days
---
### Phase 5: Advanced Intelligence (Week 5-6)
**Goal**: Add smart suggestions and goal tracking
**Tasks**:
1. Implement calendar tools
- `parse_calendar()`: Read and structure calendar.txt
- `add_event()`: Add formatted events
- `get_events(date_range)`: Query events
- `get_todays_events()`: Quick today access
2. Implement project & goal tools
- `get_active_projects()`: Parse projects.txt
- `get_quarterly_goals()`: Read current quarter goals
- `get_telos()`: Read telos.md
- `analyze_project_progress(project)`: Count tasks completed
- `analyze_goal_progress(goal)`: Track goal completion
3. Implement intelligent suggestions
- `suggest_tasks(context, time_available, energy_level)`:
- Filter by context
- Consider time of day
- Prioritize goal-aligned work
- Respect due dates
- Add to morning briefing and `/suggest` command
4. Implement goal alignment features
- Show project → goal → mission mappings
- Highlight projects not progressing
- Suggest tasks that move goals forward
- Weekly goal progress report
5. Add working memory integration
- Automatically update working-memory.txt after significant actions
- Include working memory in agent context
- Show recent activities in dashboard
6. Enhance weekly review assistant
- Follow skills/weekly-review.md guide
- Interactive walkthrough via Discord
- Automatic archival suggestions
- Project progress analysis
- Goal alignment check
**Deliverable**: Intelligent task suggestions and goal tracking
**Estimated Time**: 6-7 days
---
### Phase 6: Polish & Optimization (Week 6-7)
**Goal**: Improve performance, reliability, and user experience
**Tasks**:
1. Performance optimization
- Cache frequently accessed files (telos, goals)
- Optimize parser performance
- Reduce LLM API calls where possible
- Add request/response caching
2. Error handling improvements
- Graceful degradation if LiteLLM unavailable
- Better error messages for users
- Automatic retry logic for transient failures
- Rollback on git commit failures
3. Add monitoring and logging
- Structured logging (JSON format)
- Log agent actions (file writes, git commits)
- Track API usage and costs
- Optional: Prometheus metrics
4. Improve Discord UX
- Better formatting and layout
- Interactive buttons for confirmations
- Typing indicators while agent thinks
- Help command with examples
5. Improve web UI
- Loading states for HTMX requests
- Error notifications
- Keyboard shortcuts
- Accessibility improvements
6. Add configuration options
- User preferences (briefing time, reminder settings)
- Configurable contexts
- Custom scheduling
- Store in SQLite database
7. Testing and QA
- Integration tests for key workflows
- Test with real myorg data
- Performance testing under load
- Security audit (file access, git operations)
8. Documentation
- User guide (how to use bot and web UI)
- Deployment guide (k8s setup)
- Development guide (how to add tools)
- Troubleshooting guide
**Deliverable**: Production-ready assistant with monitoring and docs
**Estimated Time**: 5-6 days
---
## Phase 7: Optional Enhancements (Future)
**Goal**: Additional features based on usage and feedback
**Potential features**:
- Mobile PWA (progressive web app)
- Voice interface (speech-to-text for task entry)
- Email integration (send briefings via email)
- Calendar sync (import/export to Google Calendar)
- Habits tracking (daily check-ins, streaks)
- Advanced analytics (productivity reports, time tracking)
- Multi-user support (family/team organization)
- Backup and restore functionality
- API webhooks for external integrations
---
## Technical Details
### Repository Structure
```
myorg-assistant/
├── src/
│ ├── __init__.py
│ ├── main.py # Application entry point
│ ├── config.py # Configuration management
│ │
│ ├── agent/
│ │ ├── __init__.py
│ │ ├── core.py # Claude Agent SDK setup
│ │ ├── prompts.py # System prompts
│ │ └── tools.py # Tool registry
│ │
│ ├── tools/
│ │ ├── __init__.py
│ │ ├── file_ops.py # File operation tools
│ │ ├── task_ops.py # Task management tools
│ │ ├── calendar_ops.py # Calendar tools
│ │ ├── git_ops.py # Git tools
│ │ ├── project_ops.py # Project/goal tools
│ │ └── intelligence.py # Smart suggestions
│ │
│ ├── parsers/
│ │ ├── __init__.py
│ │ ├── todo_parser.py # Todo.txt format
│ │ ├── calendar_parser.py # Calendar.txt format
│ │ └── project_parser.py # Projects.txt format
│ │
│ ├── api/
│ │ ├── __init__.py
│ │ ├── app.py # FastAPI application
│ │ ├── routes/
│ │ │ ├── chat.py # Chat endpoints
│ │ │ ├── tasks.py # Task CRUD endpoints
│ │ │ ├── calendar.py # Calendar endpoints
│ │ │ └── dashboard.py # Dashboard data
│ │ └── middleware.py # Auth, CORS, etc.
│ │
│ ├── bot/
│ │ ├── __init__.py
│ │ ├── discord_bot.py # Discord bot setup
│ │ ├── commands.py # Bot commands
│ │ └── formatters.py # Discord message formatting
│ │
│ ├── scheduler/
│ │ ├── __init__.py
│ │ ├── jobs.py # Scheduled job definitions
│ │ └── briefings.py # Briefing generators
│ │
│ ├── web/
│ │ ├── templates/ # Jinja2 templates
│ │ │ ├── base.html
│ │ │ ├── dashboard.html
│ │ │ ├── chat.html
│ │ │ ├── tasks.html
│ │ │ ├── calendar.html
│ │ │ └── projects.html
│ │ └── static/
│ │ ├── css/
│ │ │ └── style.css
│ │ └── js/
│ │ └── app.js # Minimal vanilla JS
│ │
│ └── utils/
│ ├── __init__.py
│ ├── git.py # Git helper functions
│ └── context.py # Context inference
├── tests/
│ ├── __init__.py
│ ├── test_parsers.py
│ ├── test_tools.py
│ ├── test_agent.py
│ └── fixtures/
│ └── test_myorg/ # Test myorg repository
├── k8s/
│ ├── deployment.yaml # Main app deployment
│ ├── service.yaml # ClusterIP service
│ ├── ingress.yaml # Optional ingress
│ ├── configmap.yaml # Configuration
│ ├── secret.yaml.example # Secret template
│ ├── pvc.yaml # Persistent volume claim
│ └── cronjobs/
│ ├── morning-briefing.yaml
│ ├── evening-summary.yaml
│ ├── deadline-checker.yaml
│ ├── waiting-followup.yaml
│ └── git-sync.yaml
├── Dockerfile
├── requirements.txt
├── requirements-dev.txt
├── .env.example
├── .gitignore
├── pytest.ini
├── mypy.ini
└── README.md
```
### Key Dependencies
```
# requirements.txt
fastapi==0.109.0
uvicorn[standard]==0.27.0
discord.py==2.3.2
gitpython==3.1.41
anthropic==0.18.0 # Claude Agent SDK
jinja2==3.1.3
python-multipart==0.0.6
httpx==0.26.0
apscheduler==3.10.4
python-dotenv==1.0.0
pydantic==2.5.3
pydantic-settings==2.1.0
# requirements-dev.txt
pytest==7.4.4
pytest-asyncio==0.23.3
black==24.1.1
ruff==0.1.14
mypy==1.8.0
```
### Environment Variables
```bash
# .env.example
# LiteLLM Configuration
LITELLM_ENDPOINT=http://litellm-service.default.svc.cluster.local:4000
LITELLM_API_KEY=your-api-key
LITELLM_MODEL=claude-sonnet-4-5
# Discord Configuration
DISCORD_BOT_TOKEN=your-discord-bot-token
DISCORD_CHANNEL_ID=your-channel-id
# Git Configuration
GIT_REPO_URL=https://github.com/yourusername/myorg.git
GIT_BRANCH=main
GIT_USERNAME=your-username
GIT_TOKEN=your-git-token
# Myorg Repository Path
MYORG_REPO_PATH=/data/myorg
# Scheduling (timezone)
TIMEZONE=Europe/Madrid
# Web Interface
WEB_HOST=0.0.0.0
WEB_PORT=8000
WEB_SECRET_KEY=your-secret-key
# Optional: Authentication
WEB_PASSWORD=your-password # Basic auth for web UI
```
### Docker Configuration
```dockerfile
# Dockerfile
FROM python:3.11-slim
# Install git
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
# Set working directory
WORKDIR /app
# Copy requirements
COPY requirements.txt .
# Install dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY src/ ./src/
# Create data directory for myorg repo
RUN mkdir -p /data/myorg
# Expose web interface port
EXPOSE 8000
# Run application
CMD ["python", "-m", "src.main"]
```
### Kubernetes Deployment Example
```yaml
# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: myorg-assistant
labels:
app: myorg-assistant
spec:
replicas: 1
selector:
matchLabels:
app: myorg-assistant
template:
metadata:
labels:
app: myorg-assistant
spec:
containers:
- name: myorg-assistant
image: myorg-assistant:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8000
name: web
env:
- name: LITELLM_ENDPOINT
valueFrom:
configMapKeyRef:
name: myorg-assistant-config
key: litellm_endpoint
- name: DISCORD_BOT_TOKEN
valueFrom:
secretKeyRef:
name: myorg-assistant-secret
key: discord_token
- name: GIT_TOKEN
valueFrom:
secretKeyRef:
name: myorg-assistant-secret
key: git_token
envFrom:
- configMapRef:
name: myorg-assistant-config
volumeMounts:
- name: myorg-data
mountPath: /data/myorg
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
volumes:
- name: myorg-data
persistentVolumeClaim:
claimName: myorg-assistant-pvc
```
---
## Timeline Summary
| Phase | Duration | Deliverable |
|-------|----------|-------------|
| Phase 0: Setup | 3-4 days | Parsers working |
| Phase 1: Core Agent | 4-5 days | CLI agent with file tools |
| Phase 2: Discord Bot | 5-6 days | Discord bot deployed to k8s |
| Phase 3: Scheduling | 5-6 days | Automated briefings |
| Phase 4: Web Interface | 6-7 days | Web dashboard |
| Phase 5: Intelligence | 6-7 days | Smart suggestions & goal tracking |
| Phase 6: Polish | 5-6 days | Production-ready system |
| **Total** | **~6 weeks** | Full personal assistant |
---
## Development Workflow
### Local Development
1. Clone myorg-assistant repository
2. Create Python virtual environment: `python -m venv venv`
3. Install dependencies: `pip install -r requirements.txt -r requirements-dev.txt`
4. Copy `.env.example` to `.env` and configure
5. Clone test myorg repository to `/tmp/test-myorg`
6. Run tests: `pytest`
7. Run locally: `python -m src.main`
### Testing
- Unit tests for parsers and tools
- Integration tests for agent workflows
- Test against test myorg repository
- Manual testing via CLI and Discord
### Deployment
1. Build Docker image: `docker build -t myorg-assistant:latest .`
2. Push to registry (or load into k3s)
3. Apply Kubernetes manifests: `kubectl apply -f k8s/`
4. Check logs: `kubectl logs -f deployment/myorg-assistant`
5. Test Discord bot
6. Test web interface
### Monitoring
- Application logs: `kubectl logs -f deployment/myorg-assistant`
- CronJob status: `kubectl get cronjobs`
- Git sync status: Check commits in myorg repository
- Discord bot health: Send test message
---
## Success Criteria
**Phase 2 (MVP)**:
- ✅ Can chat with agent via Discord
- ✅ Can add tasks through natural language
- ✅ Can view today's tasks and calendar
- ✅ Agent commits changes to git
**Phase 3 (Automation)**:
- ✅ Receives morning briefing daily
- ✅ Receives evening summary daily
- ✅ Gets deadline warnings automatically
**Phase 4 (Complete)**:
- ✅ Can use web interface as alternative
- ✅ Dashboard shows relevant information
- ✅ HTMX provides smooth interactions
**Phase 5 (Intelligence)**:
- ✅ Agent suggests context-aware tasks
- ✅ Goal progress is tracked and reported
- ✅ Weekly review is assisted and automated
**Phase 6 (Production)**:
- ✅ System is stable and reliable
- ✅ Error handling is robust
- ✅ Documentation is complete
---
## Risk Mitigation
| Risk | Impact | Mitigation |
|------|--------|------------|
| LiteLLM endpoint unavailable | High | Add connection retry logic, graceful degradation |
| Claude API rate limits | Medium | Cache responses, limit proactive messages |
| Git conflicts on sync | Medium | Always pull before push, handle merge conflicts |
| Discord bot rate limits | Low | Throttle messages, batch notifications |
| File corruption | High | Backup before write, validate format, git history |
| Kubernetes pod crashes | Medium | Set restart policy, health checks, persistent storage |
---
## Next Steps
1. **Now**: Review and approve this implementation plan
2. **Next**: Set up project repository (Phase 0)
3. **Week 1**: Build parsers and core agent (Phases 0-1)
4. **Week 2**: Deploy Discord bot (Phase 2)
5. **Ongoing**: Continue through phases, testing at each step
---
**Last Updated**: 2026-01-31

22
k8s/configmap.yaml Normal file
View File

@@ -0,0 +1,22 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: myorg-assistant-config
namespace: default
data:
# LiteLLM Configuration
LITELLM_ENDPOINT: "http://litellm-service.default.svc.cluster.local:4000"
LITELLM_MODEL: "claude-sonnet-4-5"
# Myorg Repository Path
MYORG_REPO_PATH: "/data/myorg"
# Scheduling
TIMEZONE: "Europe/Madrid"
# Web Interface
WEB_HOST: "0.0.0.0"
WEB_PORT: "8000"
# Git Configuration
GIT_BRANCH: "main"

View File

@@ -0,0 +1,60 @@
apiVersion: batch/v1
kind: CronJob
metadata:
name: myorg-deadline-checker
namespace: default
labels:
app: myorg-assistant
job: deadline-checker
spec:
# Run every hour
schedule: "0 * * * *"
timeZone: "Europe/Madrid"
successfulJobsHistoryLimit: 2
failedJobsHistoryLimit: 2
concurrencyPolicy: Forbid
jobTemplate:
spec:
template:
metadata:
labels:
app: myorg-assistant
job: deadline-checker
spec:
restartPolicy: OnFailure
containers:
- name: deadline-checker
image: myorg-assistant:latest
imagePullPolicy: IfNotPresent
command:
- python
- run_job.py
- deadline-checker
env:
- name: MYORG_REPO_PATH
valueFrom:
configMapKeyRef:
name: myorg-assistant-config
key: MYORG_REPO_PATH
- name: DISCORD_BOT_TOKEN
valueFrom:
secretKeyRef:
name: myorg-assistant-secret
key: DISCORD_BOT_TOKEN
- name: DISCORD_CHANNEL_ID
valueFrom:
secretKeyRef:
name: myorg-assistant-secret
key: DISCORD_CHANNEL_ID
- name: LITELLM_API_KEY
valueFrom:
secretKeyRef:
name: myorg-assistant-secret
key: LITELLM_API_KEY
volumeMounts:
- name: myorg-data
mountPath: /data/myorg
volumes:
- name: myorg-data
persistentVolumeClaim:
claimName: myorg-assistant-pvc

View File

@@ -0,0 +1,60 @@
apiVersion: batch/v1
kind: CronJob
metadata:
name: myorg-evening-summary
namespace: default
labels:
app: myorg-assistant
job: evening-summary
spec:
# Run at 8:00 PM every day
schedule: "0 20 * * *"
timeZone: "Europe/Madrid"
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
concurrencyPolicy: Forbid
jobTemplate:
spec:
template:
metadata:
labels:
app: myorg-assistant
job: evening-summary
spec:
restartPolicy: OnFailure
containers:
- name: evening-summary
image: myorg-assistant:latest
imagePullPolicy: IfNotPresent
command:
- python
- run_job.py
- evening-summary
env:
- name: MYORG_REPO_PATH
valueFrom:
configMapKeyRef:
name: myorg-assistant-config
key: MYORG_REPO_PATH
- name: DISCORD_BOT_TOKEN
valueFrom:
secretKeyRef:
name: myorg-assistant-secret
key: DISCORD_BOT_TOKEN
- name: DISCORD_CHANNEL_ID
valueFrom:
secretKeyRef:
name: myorg-assistant-secret
key: DISCORD_CHANNEL_ID
- name: LITELLM_API_KEY
valueFrom:
secretKeyRef:
name: myorg-assistant-secret
key: LITELLM_API_KEY
volumeMounts:
- name: myorg-data
mountPath: /data/myorg
volumes:
- name: myorg-data
persistentVolumeClaim:
claimName: myorg-assistant-pvc

View File

@@ -0,0 +1,75 @@
apiVersion: batch/v1
kind: CronJob
metadata:
name: myorg-git-sync
namespace: default
labels:
app: myorg-assistant
job: git-sync
spec:
# Run every 15 minutes
schedule: "*/15 * * * *"
timeZone: "Europe/Madrid"
successfulJobsHistoryLimit: 1
failedJobsHistoryLimit: 2
concurrencyPolicy: Forbid
jobTemplate:
spec:
template:
metadata:
labels:
app: myorg-assistant
job: git-sync
spec:
restartPolicy: OnFailure
containers:
- name: git-sync
image: myorg-assistant:latest
imagePullPolicy: IfNotPresent
command:
- python
- run_job.py
- git-sync
env:
- name: MYORG_REPO_PATH
valueFrom:
configMapKeyRef:
name: myorg-assistant-config
key: MYORG_REPO_PATH
- name: GIT_BRANCH
valueFrom:
configMapKeyRef:
name: myorg-assistant-config
key: GIT_BRANCH
- name: GIT_REPO_URL
valueFrom:
secretKeyRef:
name: myorg-assistant-secret
key: GIT_REPO_URL
- name: GIT_USERNAME
valueFrom:
secretKeyRef:
name: myorg-assistant-secret
key: GIT_USERNAME
- name: GIT_TOKEN
valueFrom:
secretKeyRef:
name: myorg-assistant-secret
key: GIT_TOKEN
- name: DISCORD_BOT_TOKEN
valueFrom:
secretKeyRef:
name: myorg-assistant-secret
key: DISCORD_BOT_TOKEN
- name: LITELLM_API_KEY
valueFrom:
secretKeyRef:
name: myorg-assistant-secret
key: LITELLM_API_KEY
volumeMounts:
- name: myorg-data
mountPath: /data/myorg
volumes:
- name: myorg-data
persistentVolumeClaim:
claimName: myorg-assistant-pvc

View File

@@ -0,0 +1,67 @@
apiVersion: batch/v1
kind: CronJob
metadata:
name: myorg-morning-briefing
namespace: default
labels:
app: myorg-assistant
job: morning-briefing
spec:
# Run at 8:00 AM every day (adjust for your timezone)
schedule: "0 8 * * *"
timeZone: "Europe/Madrid"
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
concurrencyPolicy: Forbid
jobTemplate:
spec:
template:
metadata:
labels:
app: myorg-assistant
job: morning-briefing
spec:
restartPolicy: OnFailure
containers:
- name: morning-briefing
image: myorg-assistant:latest
imagePullPolicy: IfNotPresent
command:
- python
- run_job.py
- morning-briefing
env:
# From ConfigMap
- name: MYORG_REPO_PATH
valueFrom:
configMapKeyRef:
name: myorg-assistant-config
key: MYORG_REPO_PATH
- name: TIMEZONE
valueFrom:
configMapKeyRef:
name: myorg-assistant-config
key: TIMEZONE
# From Secret
- name: DISCORD_BOT_TOKEN
valueFrom:
secretKeyRef:
name: myorg-assistant-secret
key: DISCORD_BOT_TOKEN
- name: DISCORD_CHANNEL_ID
valueFrom:
secretKeyRef:
name: myorg-assistant-secret
key: DISCORD_CHANNEL_ID
- name: LITELLM_API_KEY
valueFrom:
secretKeyRef:
name: myorg-assistant-secret
key: LITELLM_API_KEY
volumeMounts:
- name: myorg-data
mountPath: /data/myorg
volumes:
- name: myorg-data
persistentVolumeClaim:
claimName: myorg-assistant-pvc

View File

@@ -0,0 +1,60 @@
apiVersion: batch/v1
kind: CronJob
metadata:
name: myorg-waiting-followup
namespace: default
labels:
app: myorg-assistant
job: waiting-followup
spec:
# Run every Monday at 9:00 AM
schedule: "0 9 * * 1"
timeZone: "Europe/Madrid"
successfulJobsHistoryLimit: 2
failedJobsHistoryLimit: 2
concurrencyPolicy: Forbid
jobTemplate:
spec:
template:
metadata:
labels:
app: myorg-assistant
job: waiting-followup
spec:
restartPolicy: OnFailure
containers:
- name: waiting-followup
image: myorg-assistant:latest
imagePullPolicy: IfNotPresent
command:
- python
- run_job.py
- waiting-followup
env:
- name: MYORG_REPO_PATH
valueFrom:
configMapKeyRef:
name: myorg-assistant-config
key: MYORG_REPO_PATH
- name: DISCORD_BOT_TOKEN
valueFrom:
secretKeyRef:
name: myorg-assistant-secret
key: DISCORD_BOT_TOKEN
- name: DISCORD_CHANNEL_ID
valueFrom:
secretKeyRef:
name: myorg-assistant-secret
key: DISCORD_CHANNEL_ID
- name: LITELLM_API_KEY
valueFrom:
secretKeyRef:
name: myorg-assistant-secret
key: LITELLM_API_KEY
volumeMounts:
- name: myorg-data
mountPath: /data/myorg
volumes:
- name: myorg-data
persistentVolumeClaim:
claimName: myorg-assistant-pvc

99
k8s/deploy.sh Executable file
View File

@@ -0,0 +1,99 @@
#!/bin/bash
# Deployment script for MyOrg Assistant to k3s
set -e
echo "🚀 Deploying MyOrg Assistant to k3s..."
# Check if kubectl is available
if ! command -v kubectl &> /dev/null; then
echo "❌ kubectl not found. Please install kubectl first."
exit 1
fi
# Check if we're connected to the cluster
if ! kubectl cluster-info &> /dev/null; then
echo "❌ Cannot connect to k3s cluster. Please check your kubeconfig."
exit 1
fi
echo "✅ Connected to k3s cluster"
# Build Docker image
echo ""
echo "📦 Building Docker image..."
cd ..
docker build -t myorg-assistant:latest .
# Load image into k3s (if using k3s)
echo ""
echo "📥 Loading image into k3s..."
if command -v k3s &> /dev/null; then
docker save myorg-assistant:latest | sudo k3s ctr images import -
else
echo "⚠️ k3s command not found, assuming image is already available"
fi
cd k8s
# Check if secret exists
if kubectl get secret myorg-assistant-secret &> /dev/null; then
echo ""
echo "✅ Secret already exists"
else
echo ""
echo "⚠️ Secret not found!"
echo "Please create k8s/secret.yaml from k8s/secret.yaml.example and apply it:"
echo " cp secret.yaml.example secret.yaml"
echo " # Edit secret.yaml with your credentials"
echo " kubectl apply -f secret.yaml"
exit 1
fi
# Apply manifests
echo ""
echo "📝 Applying Kubernetes manifests..."
echo " - ConfigMap..."
kubectl apply -f configmap.yaml
echo " - PersistentVolumeClaim..."
kubectl apply -f pvc.yaml
echo " - Service..."
kubectl apply -f service.yaml
echo " - Deployment..."
kubectl apply -f deployment.yaml
echo " - CronJobs..."
kubectl apply -f cronjobs/
# Wait for deployment
echo ""
echo "⏳ Waiting for deployment to be ready..."
kubectl rollout status deployment/myorg-assistant --timeout=300s
# Show status
echo ""
echo "✅ Deployment complete!"
echo ""
echo "📊 Status:"
kubectl get pods -l app=myorg-assistant
echo ""
kubectl get svc myorg-assistant-service
echo ""
# Show logs
echo "📋 Recent logs:"
POD_NAME=$(kubectl get pods -l app=myorg-assistant -o jsonpath='{.items[0].metadata.name}')
kubectl logs $POD_NAME --tail=20
echo ""
echo "🎉 MyOrg Assistant is now running!"
echo ""
echo "Useful commands:"
echo " View logs: kubectl logs -f $POD_NAME"
echo " Pod shell: kubectl exec -it $POD_NAME -- /bin/bash"
echo " Restart: kubectl rollout restart deployment/myorg-assistant"
echo " Delete: kubectl delete -f k8s/"

176
k8s/deployment.yaml Normal file
View File

@@ -0,0 +1,176 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: myorg-assistant
namespace: default
labels:
app: myorg-assistant
spec:
replicas: 1
selector:
matchLabels:
app: myorg-assistant
template:
metadata:
labels:
app: myorg-assistant
spec:
initContainers:
- name: git-clone
image: alpine/git:latest
command:
- sh
- -c
- |
if [ ! -d /data/myorg/.git ]; then
echo "Cloning repository..."
git clone ${GIT_REPO_URL} /data/myorg
cd /data/myorg
git config user.name "${GIT_USERNAME}"
git config user.email "${GIT_USERNAME}@users.noreply.github.com"
git config credential.helper store
echo "https://${GIT_USERNAME}:${GIT_TOKEN}@github.com" > ~/.git-credentials
else
echo "Repository already exists, pulling latest changes..."
cd /data/myorg
git pull
fi
env:
- name: GIT_REPO_URL
valueFrom:
secretKeyRef:
name: myorg-assistant-secret
key: GIT_REPO_URL
- name: GIT_USERNAME
valueFrom:
secretKeyRef:
name: myorg-assistant-secret
key: GIT_USERNAME
- name: GIT_TOKEN
valueFrom:
secretKeyRef:
name: myorg-assistant-secret
key: GIT_TOKEN
volumeMounts:
- name: myorg-data
mountPath: /data/myorg
containers:
- name: myorg-assistant
image: myorg-assistant:latest
imagePullPolicy: IfNotPresent
command: ["./start.sh"]
ports:
- containerPort: 8000
name: web
protocol: TCP
env:
# From ConfigMap
- name: LITELLM_ENDPOINT
valueFrom:
configMapKeyRef:
name: myorg-assistant-config
key: LITELLM_ENDPOINT
- name: LITELLM_MODEL
valueFrom:
configMapKeyRef:
name: myorg-assistant-config
key: LITELLM_MODEL
- name: MYORG_REPO_PATH
valueFrom:
configMapKeyRef:
name: myorg-assistant-config
key: MYORG_REPO_PATH
- name: TIMEZONE
valueFrom:
configMapKeyRef:
name: myorg-assistant-config
key: TIMEZONE
- name: WEB_HOST
valueFrom:
configMapKeyRef:
name: myorg-assistant-config
key: WEB_HOST
- name: WEB_PORT
valueFrom:
configMapKeyRef:
name: myorg-assistant-config
key: WEB_PORT
- name: GIT_BRANCH
valueFrom:
configMapKeyRef:
name: myorg-assistant-config
key: GIT_BRANCH
# From Secret
- name: DISCORD_BOT_TOKEN
valueFrom:
secretKeyRef:
name: myorg-assistant-secret
key: DISCORD_BOT_TOKEN
- name: DISCORD_CHANNEL_ID
valueFrom:
secretKeyRef:
name: myorg-assistant-secret
key: DISCORD_CHANNEL_ID
- name: LITELLM_API_KEY
valueFrom:
secretKeyRef:
name: myorg-assistant-secret
key: LITELLM_API_KEY
- name: GIT_REPO_URL
valueFrom:
secretKeyRef:
name: myorg-assistant-secret
key: GIT_REPO_URL
- name: GIT_USERNAME
valueFrom:
secretKeyRef:
name: myorg-assistant-secret
key: GIT_USERNAME
- name: GIT_TOKEN
valueFrom:
secretKeyRef:
name: myorg-assistant-secret
key: GIT_TOKEN
- name: WEB_SECRET_KEY
valueFrom:
secretKeyRef:
name: myorg-assistant-secret
key: WEB_SECRET_KEY
- name: WEB_PASSWORD
valueFrom:
secretKeyRef:
name: myorg-assistant-secret
key: WEB_PASSWORD
optional: true
volumeMounts:
- name: myorg-data
mountPath: /data/myorg
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
exec:
command:
- sh
- -c
- "ps aux | grep 'python -m src.main bot' | grep -v grep"
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
volumes:
- name: myorg-data
persistentVolumeClaim:
claimName: myorg-assistant-pvc
restartPolicy: Always

26
k8s/ingress.yaml Normal file
View File

@@ -0,0 +1,26 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: myorg-assistant-ingress
namespace: default
annotations:
kubernetes.io/ingress.class: "traefik"
# Add SSL/TLS annotations if needed
# cert-manager.io/cluster-issuer: "letsencrypt-prod"
spec:
rules:
- host: myorg.yourdomain.com # Replace with your domain
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: myorg-assistant-service
port:
number: 8000
# Optional: TLS configuration
# tls:
# - hosts:
# - myorg.yourdomain.com
# secretName: myorg-tls-secret

12
k8s/pvc.yaml Normal file
View File

@@ -0,0 +1,12 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: myorg-assistant-pvc
namespace: default
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
storageClassName: local-path # Adjust based on your k3s storage class

24
k8s/secret.yaml.example Normal file
View File

@@ -0,0 +1,24 @@
apiVersion: v1
kind: Secret
metadata:
name: myorg-assistant-secret
namespace: default
type: Opaque
stringData:
# Discord Bot Token
DISCORD_BOT_TOKEN: "your-discord-bot-token-here"
DISCORD_CHANNEL_ID: "your-discord-channel-id-here"
# LiteLLM API Key
LITELLM_API_KEY: "your-litellm-api-key-here"
# Git Credentials
GIT_REPO_URL: "https://github.com/yourusername/myorg.git"
GIT_USERNAME: "your-github-username"
GIT_TOKEN: "your-github-token-here"
# Web Interface Secret Key
WEB_SECRET_KEY: "your-secret-key-here"
# Optional: Web Password for basic auth
WEB_PASSWORD: "your-web-password-here"

16
k8s/service.yaml Normal file
View File

@@ -0,0 +1,16 @@
apiVersion: v1
kind: Service
metadata:
name: myorg-assistant-service
namespace: default
labels:
app: myorg-assistant
spec:
type: ClusterIP
selector:
app: myorg-assistant
ports:
- name: web
port: 8000
targetPort: 8000
protocol: TCP

17
mypy.ini Normal file
View File

@@ -0,0 +1,17 @@
[mypy]
python_version = 3.11
warn_return_any = True
warn_unused_configs = True
disallow_untyped_defs = True
disallow_incomplete_defs = True
check_untyped_defs = True
no_implicit_optional = True
[mypy-anthropic.*]
ignore_missing_imports = True
[mypy-discord.*]
ignore_missing_imports = True
[mypy-git.*]
ignore_missing_imports = True

408
project-plan.md Normal file
View File

@@ -0,0 +1,408 @@
# MyOrg Personal Assistant
**Project**: `+myorg-assistant`
**Area**: `+CreixementPersonal` `+Carrera`
**Status**: Planning
**Created**: 2026-01-31
**Goal**: Build an AI-powered personal assistant that helps manage daily life using the myorg GTD system
## Overview
The MyOrg Personal Assistant is an intelligent agent that helps you manage your personal organization system (myorg). It acts as a trusted assistant that can read, understand, and modify your GTD-based task management system, providing proactive help throughout your day.
## Vision
A personal AI assistant that:
- Understands your goals, projects, and daily priorities from your myorg repository
- Proactively helps you stay on track with briefings, reminders, and suggestions
- Makes it easy to capture tasks and manage your system through natural conversation
- Works autonomously as a trusted agent, handling routine operations without constant supervision
- Integrates seamlessly into your daily workflow through Discord and web interface
## Key Features
### 1. Intelligent Task Management
- **Natural Language Task Entry**: Add tasks by chatting naturally - assistant parses and formats properly
- **Context-Aware Suggestions**: Recommends tasks based on current context, time, and energy level
- **Smart Prioritization**: Analyzes your goals, due dates, and project status to suggest what to work on next
- **Automatic Archival**: Handles routine archival of completed tasks and projects
### 2. Proactive Scheduling & Reminders
- **Morning Briefing (8 AM)**:
- Today's calendar events
- Priority tasks filtered by likely context
- Items in waiting.txt that may need follow-up
- Weather/commute info if relevant
- **Evening Summary (8 PM)**:
- What was accomplished today
- Tomorrow's calendar preview
- Tasks to prepare for tomorrow
- Reflection prompts for working-memory.txt
- **Weekly Review Assistant (Sunday)**:
- Guides through weekly review process (following skills/weekly-review.md)
- Suggests completed items to archive
- Highlights stale waiting items
- Reviews project progress against quarterly goals
- **Deadline Warnings**: Proactive alerts 7 days, 3 days, 1 day before due dates
- **Waiting List Follow-ups**: Weekly check-ins on items waiting on others
- **Calendar Prep**: 30-minute reminders before events with context and prep suggestions
### 3. Conversational Interface
- **Discord Integration**:
- Natural conversation with the assistant bot
- Scheduled messages sent automatically
- Quick commands for common operations
- Rich formatting with task lists and calendar views
- **Web Interface**:
- Dashboard showing today's priorities
- Chat interface for deeper conversations
- Visual calendar and project boards
- Quick-add forms for tasks/events
### 4. Context Intelligence
- **Manual Context Setting**: "I'm @bcn with @computer-deep focus for the next 2 hours"
- **Time-Based Inference**: Understands work hours, evenings, weekends from patterns
- **Calendar Integration**: Infers context from current/upcoming calendar events
- **Smart Filtering**: Shows only relevant tasks for current context
### 5. Goal Alignment
- Understands your telos.md, annual goals, and quarterly OKRs
- Connects tasks to projects to goals to missions
- Suggests tasks that move key goals forward
- Highlights projects that aren't progressing
- Provides goal progress reports
## Technical Architecture
### System Components
```
┌─────────────────────────────────────────────────────────────┐
│ User Interfaces │
├──────────────────────────┬──────────────────────────────────┤
│ Discord Bot │ Web Interface │
│ - Natural chat │ - Dashboard │
│ - Scheduled messages │ - Chat UI │
│ - Rich formatting │ - Visual views │
└──────────────────────────┴──────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Assistant Service │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Core Agent (LLM-Powered) │ │
│ │ - Intent understanding │ │
│ │ - Context management │ │
│ │ - Response generation │ │
│ └──────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Task Engine │ │
│ │ - Parse/format todo.txt, calendar.txt, etc. │ │
│ │ - CRUD operations on all myorg files │ │
│ │ - Query and filter tasks/projects/events │ │
│ │ - Git operations (commit, pull, push) │ │
│ └──────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Scheduler │ │
│ │ - Cron jobs for briefings and reminders │ │
│ │ - Deadline monitoring │ │
│ │ - Event-based triggers │ │
│ └──────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Context Manager │ │
│ │ - Track user's current context │ │
│ │ - Infer context from time/calendar │ │
│ │ - Filter tasks by context │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ External Services │
├──────────────────────────┬──────────────────────────────────┤
│ LiteLLM Service │ Git Repository │
│ (Claude/OpenAI) │ (myorg) │
│ - Running in k3s │ - Cloned in container │
│ - Natural language │ - Periodic sync │
│ - Reasoning │ - Auto-commit updates │
└──────────────────────────┴──────────────────────────────────┘
```
### Deployment Architecture
**Kubernetes Resources:**
- **Deployment**: `myorg-assistant` (1 replica)
- **Service**: Internal ClusterIP for web interface
- **CronJobs**:
- `morning-briefing`: Runs daily at 8:00 AM
- `evening-summary`: Runs daily at 8:00 PM
- `weekly-review`: Runs Sunday at 6:00 PM
- `deadline-checker`: Runs hourly
- `waiting-followup`: Runs weekly
- `git-sync`: Runs every 15 minutes
- **ConfigMap**: Discord bot token, LiteLLM endpoint, schedule configs
- **Secret**: Git credentials, Discord bot token
- **PersistentVolumeClaim**: Store cloned myorg repository
### Technology Stack
**Backend:**
- **Language**: Python 3.11+
- **Framework**: FastAPI (for web API) + Discord.py (for bot)
- **LLM Integration**:
- **Agent Framework**: Claude Agent SDK for building AI agents with tool use capabilities
- **LLM Provider**: LiteLLM proxy (running in k3s) providing access to Claude Sonnet 4.5
- **Model**: claude-sonnet-4-5 via LiteLLM endpoint
- **Agent Tools**: File read/write, task parsing, git operations, calendar queries
- **Task Parsing**: Custom parsers for todo.txt format + calendar format
- **Git Operations**: GitPython
- **Scheduling**: APScheduler or built-in CronJobs
- **Data Storage**: File-based (myorg git repo) + SQLite for assistant state
**Frontend (Web Interface):**
- **Framework**: Vanilla JavaScript (no framework needed)
- **Styling**: Vanilla CSS (semantic HTML with progressive enhancement)
- **Interactivity**: HTMX for dynamic updates without full page reloads
- **Real-time**: Server-Sent Events (SSE) for chat updates
- **Calendar View**: Minimal custom CSS Grid calendar or simple list views
- **Approach**: Server-rendered HTML templates (Jinja2) with HTMX for dynamic parts
**Infrastructure:**
- **Container**: Docker
- **Orchestration**: Kubernetes (k3s)
- **Ingress**: Traefik or nginx-ingress (if exposing web UI)
- **Monitoring**: Prometheus + Grafana (optional)
## AI Agent Architecture with Claude Agent SDK
The assistant uses the **Claude Agent SDK** to build intelligent agents that can autonomously perform complex tasks by reading files, modifying data, and using custom tools.
### Why Claude Agent SDK?
- **Native Tool Use**: Built-in support for function calling and tool execution
- **File Operations**: Read/write myorg files (todo.txt, calendar.txt, etc.) directly
- **Complex Reasoning**: Multi-step task planning and execution
- **Context Management**: Maintains conversation history and system state
- **Error Handling**: Graceful handling of tool failures and retries
### Agent Tool Suite
The assistant agent has access to the following tools:
**1. File System Tools:**
- `read_file(path)`: Read any myorg file (todo.txt, telos.md, goals, etc.)
- `write_file(path, content)`: Write/update myorg files
- `append_to_file(path, content)`: Append to files (e.g., working-memory.txt)
- `list_files(directory)`: Browse myorg structure
**2. Task Management Tools:**
- `parse_todo(file_content)`: Parse todo.txt format into structured data
- `add_task(description, project, context, priority, due_date)`: Add formatted task
- `complete_task(task_id)`: Mark task as complete with timestamp
- `search_tasks(query, filters)`: Query tasks by project, context, priority
- `archive_completed_tasks()`: Move completed tasks to archive
**3. Calendar Tools:**
- `parse_calendar(file_content)`: Parse calendar.txt into structured events
- `add_event(date, time, description, tags)`: Add formatted calendar entry
- `get_events(date_range)`: Get events for specific dates
- `get_todays_events()`: Quick access to today's schedule
**4. Project & Goal Tools:**
- `get_active_projects()`: List all active projects from projects.txt
- `get_quarterly_goals()`: Read current quarter's OKR goals
- `get_telos()`: Read telos.md for mission alignment
- `project_progress(project_tag)`: Analyze tasks completed for a project
**5. Git Tools:**
- `git_status()`: Check repository status
- `git_commit(message)`: Commit changes to myorg
- `git_pull()`: Sync latest changes
- `git_push()`: Push updates to remote
**6. Context & Intelligence Tools:**
- `infer_context(time, calendar_events)`: Determine likely user context
- `suggest_tasks(context, time_available, energy_level)`: Recommend tasks
- `analyze_goal_progress(goal_id)`: Track progress toward goals
- `check_waiting_items()`: Review waiting.txt for follow-ups
- `find_overdue_tasks()`: Identify tasks past due date
### Agent Workflows
**Example 1: Morning Briefing Agent**
```python
# Agent receives scheduled trigger at 8:00 AM
agent = ClaudeAgent(tools=[
read_file, parse_calendar, parse_todo,
get_todays_events, infer_context, suggest_tasks
])
response = agent.run("""
Generate morning briefing for today:
1. Read calendar.txt and find today's events
2. Read todo.txt and identify priority tasks
3. Read waiting.txt for items needing follow-up
4. Infer likely context based on calendar
5. Suggest top 5 tasks for the day
6. Format as Discord message
""")
```
**Example 2: Natural Language Task Entry**
```python
# User says: "Recordatori para comprar llet demà al matí"
agent = ClaudeAgent(tools=[
read_file, write_file, add_task, git_commit
])
response = agent.run("""
User said: "Recordatori para comprar llet demà al matí"
Parse this natural language input and:
1. Extract task: "Comprar llet"
2. Determine due date: tomorrow (2026-02-01)
3. Infer context: @recados (shopping)
4. Add to todo.txt with proper format
5. Commit change to git
6. Confirm to user
""")
```
**Example 3: Weekly Review Assistant**
```python
# Scheduled Sunday 6 PM
agent = ClaudeAgent(tools=[
read_file, parse_todo, archive_completed_tasks,
get_quarterly_goals, project_progress, git_commit
])
response = agent.run("""
Conduct weekly review:
1. Read todo.txt and identify completed tasks
2. Suggest archival of completed items
3. Check waiting.txt for stale items (>7 days)
4. Read quarterly goals and assess progress
5. Analyze project progress (tasks completed per project)
6. Generate summary report
7. Ask user for confirmation before archiving
""")
```
### Agent Configuration
**System Prompt:**
```
You are a personal assistant managing a GTD-based organization system.
Repository structure:
- todo.txt: Active tasks in todo.txt format
- calendar.txt: Calendar events
- projects.txt: Active projects
- waiting.txt: Items waiting on others
- telos.md: User's vision and missions
- goals/: Quarterly and yearly goals
Your role:
- Help user capture, organize, and prioritize tasks
- Provide context-aware suggestions
- Handle routine operations autonomously
- Maintain system integrity (proper formats, git commits)
- Be proactive but respect user's decisions
When modifying files:
- Always preserve todo.txt format
- Include proper metadata (dates, projects, contexts)
- Commit changes with descriptive messages
- Keep working-memory.txt updated with significant actions
```
**Agent Settings:**
- Model: `claude-sonnet-4-5` (via LiteLLM endpoint in k3s cluster)
- Temperature: 0.7 (balanced between creativity and consistency)
- Max tokens: 4096
- Tool choice: Auto (agent decides when to use tools)
- LiteLLM endpoint: `http://litellm-service.default.svc.cluster.local:4000`
**How it works:**
The Claude Agent SDK orchestrates agent behavior (tool calling, conversation flow, task planning) while using the LiteLLM proxy as the model provider. LiteLLM handles the actual API calls to Anthropic's Claude Sonnet 4.5 model, providing unified API access and load balancing.
### Error Handling & Safety
**File Operation Safety:**
- Always read before write to verify file exists
- Validate todo.txt format before writing
- Backup files before major changes
- Rollback git commits if operations fail
**LLM Reliability:**
- LiteLLM proxy provides retry logic and error handling
- Cache frequently used data (telos, goals) to reduce API calls
- Graceful degradation if LiteLLM endpoint is temporarily unavailable
**Rate Limiting:**
- Scheduled jobs: 1 per scheduled time (no retries unless failed)
- User interactions: Standard API limits via LiteLLM
- Background sync: Max 1 git operation per 15 minutes
## User Interactions
### Discord Chat Examples
**Adding a task:**
```
You: "Recordatori para comprar llet demà al matí"
Assistant: ✅ Afegit a todo.txt:
(B) 2026-02-01 Comprar llet @recados due:2026-02-01
```
**Asking what to work on:**
```
You: "Què hauria de fer ara? Estic @bcn amb 2 hores @computer-deep"
Assistant: 📋 Basant-me en els teus projectes actius i el context @computer-deep, et recomano:
1. *(A)* Continuar amb l'article observability-blog +observability-blog
2. Definir arquitectura myorg-assistant +myorg-assistant
3. Review PRs pendents +k3s
Els teus top goals per Q1 són: observability content i k3s setup. Les primeres dues tasques t'ajuden amb això.
```
**Morning briefing:**
```
Assistant: 🌅 **Bon dia! Resum del dissabte 1 febrer 2026**
📅 **Esdeveniments d'avui:**
- Cap esdeveniment programat
✅ **Tasques prioritàries:**
- *(A)* Escriure draft observability blog +observability-blog @computer-deep
- Arreglar ingress k3s +k3s @bcn
- Comprar llet @recados
⏳ **Waiting list (items que pots revisar):**
- Himanshu firmar documents +Llar
- Pagament final Corte Inglés Viatges +egipte (due: 2026-02-21)
🎯 **Projectes actius:** 6 projectes, 3 necessiten atenció aquesta setmana
```
### Weekly Review Flow
```
Assistant (Sunday 6 PM):
📝 **Hora de la revisió setmanal!**
He trobat:
- 12 tasques completades aquesta setmana
- 3 projectes amb progrés
- 2 items a waiting.txt sense updates en >7 dies
Vols que et guiï per la revisió? [Sí] [Més tard]
User: Sí

6
pytest.ini Normal file
View File

@@ -0,0 +1,6 @@
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts = -v --tb=short

5
requirements-dev.txt Normal file
View File

@@ -0,0 +1,5 @@
pytest==7.4.4
pytest-asyncio==0.23.3
black==24.1.1
ruff==0.1.14
mypy==1.8.0

12
requirements.txt Normal file
View File

@@ -0,0 +1,12 @@
fastapi==0.109.0
uvicorn[standard]==0.27.0
discord.py==2.3.2
gitpython==3.1.41
anthropic==0.77.0
jinja2==3.1.3
python-multipart==0.0.6
httpx==0.26.0
apscheduler==3.10.4
python-dotenv==1.0.0
pydantic==2.12.5
pydantic-settings==2.7.0

24
run_job.py Executable file
View File

@@ -0,0 +1,24 @@
#!/usr/bin/env python3
"""Script to run scheduled jobs from Kubernetes CronJobs."""
import sys
from src.scheduler.jobs import run_job
def main() -> None:
"""Main entry point for job runner."""
if len(sys.argv) < 2:
print("Usage: python run_job.py <job-name>")
print("\nAvailable jobs:")
print(" - morning-briefing")
print(" - evening-summary")
print(" - deadline-checker")
print(" - git-sync")
print(" - waiting-followup")
sys.exit(1)
job_name = sys.argv[1]
run_job(job_name)
if __name__ == "__main__":
main()

0
src/__init__.py Normal file
View File

0
src/agent/__init__.py Normal file
View File

204
src/agent/core.py Normal file
View File

@@ -0,0 +1,204 @@
"""Core agent implementation using Claude Agent SDK via LiteLLM."""
from typing import List, Dict, Any, Optional, Callable
import anthropic
from anthropic.types import MessageParam, TextBlock, ToolUseBlock
from src.config import settings
class AgentTool:
"""Wrapper for agent tools that can be called by the LLM."""
def __init__(
self,
name: str,
description: str,
input_schema: Dict[str, Any],
function: Callable[..., Any],
):
"""Initialize a tool.
Args:
name: Tool name
description: What the tool does
input_schema: JSON schema for tool parameters
function: Python function to execute
"""
self.name = name
self.description = description
self.input_schema = input_schema
self.function = function
def to_anthropic_tool(self) -> Dict[str, Any]:
"""Convert to Anthropic tool format."""
return {
"name": self.name,
"description": self.description,
"input_schema": self.input_schema,
}
def execute(self, **kwargs: Any) -> Any:
"""Execute the tool with given arguments."""
return self.function(**kwargs)
class MyOrgAgent:
"""AI agent for managing myorg GTD system."""
def __init__(
self,
system_prompt: str,
tools: Optional[List[AgentTool]] = None,
model: Optional[str] = None,
):
"""Initialize the agent.
Args:
system_prompt: System instructions for the agent
tools: List of tools the agent can use
model: Model name (defaults to config)
"""
self.system_prompt = system_prompt
self.tools = tools or []
self.model = model or settings.litellm_model
# Initialize Anthropic client pointing to LiteLLM endpoint
self.client = anthropic.Anthropic(
api_key=settings.litellm_api_key,
base_url=settings.litellm_endpoint,
)
# Conversation history
self.messages: List[MessageParam] = []
def add_tool(self, tool: AgentTool) -> None:
"""Add a tool to the agent's toolkit.
Args:
tool: Tool to add
"""
self.tools.append(tool)
def _get_tool_by_name(self, name: str) -> Optional[AgentTool]:
"""Get tool by name.
Args:
name: Tool name
Returns:
AgentTool if found, None otherwise
"""
for tool in self.tools:
if tool.name == name:
return tool
return None
def run(
self,
user_message: str,
max_iterations: int = 10,
) -> str:
"""Run the agent with a user message.
The agent will process the message, use tools as needed,
and return a final response.
Args:
user_message: Message from the user
max_iterations: Maximum number of agent iterations
Returns:
Final response text
"""
# Add user message to history
self.messages.append({
"role": "user",
"content": user_message,
})
iteration = 0
while iteration < max_iterations:
iteration += 1
# Call Claude via LiteLLM
response = self.client.messages.create(
model=self.model,
max_tokens=4096,
system=self.system_prompt,
messages=self.messages,
tools=[tool.to_anthropic_tool()
for tool in self.tools] if self.tools else anthropic.NOT_GIVEN,
)
# Add assistant response to history
self.messages.append({
"role": "assistant",
"content": response.content,
})
# Check if we're done (no tool use)
if response.stop_reason == "end_turn":
# Extract text response
text_blocks = [
block for block in response.content if isinstance(block, TextBlock)]
if text_blocks:
return text_blocks[0].text
return ""
# Process tool use
if response.stop_reason == "tool_use":
tool_results = []
for block in response.content:
if isinstance(block, ToolUseBlock):
# Execute the tool
tool = self._get_tool_by_name(block.name)
if tool:
try:
result = tool.execute(**block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": str(result),
})
except Exception as e:
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": f"Error: {str(e)}",
"is_error": True,
})
else:
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": f"Error: Unknown tool {block.name}",
"is_error": True,
})
# Add tool results to messages
if tool_results:
self.messages.append({
"role": "user",
"content": tool_results,
})
# Continue iteration
continue
# Unexpected stop reason
break
# Max iterations reached
return "I apologize, but I've reached the maximum number of processing steps. Please try a simpler request."
def reset_conversation(self) -> None:
"""Clear conversation history."""
self.messages = []
def get_conversation_history(self) -> List[MessageParam]:
"""Get current conversation history.
Returns:
List of messages
"""
return self.messages.copy()

115
src/agent/prompts.py Normal file
View File

@@ -0,0 +1,115 @@
"""System prompts for the MyOrg Agent."""
MYORG_SYSTEM_PROMPT = """You are a personal assistant managing a GTD-based organization system called "myorg".
## Your Role
You help the user capture, organize, and prioritize tasks, events, and projects. You can read and modify files in the myorg repository, and you understand the user's goals, projects, and daily context.
## Repository Structure
The myorg repository contains:
- **todo.txt**: Active tasks in todo.txt format
- **calendar.txt**: Calendar events
- **projects.txt**: Active projects with status and goals
- **waiting.txt**: Items waiting on others
- **telos.md**: User's vision and life missions
- **goals/**: Quarterly and yearly goals
- **working-memory.txt**: Recent activities and thoughts
## File Formats
### todo.txt Format
Tasks follow this format:
- Priority: `(A)`, `(B)`, `(C)` at the start (A = highest)
- Completion: `x` at the start with optional completion date
- Creation date: `YYYY-MM-DD` after priority
- Description: Main task text
- Projects: `+project-name` (e.g., `+myorg-assistant`)
- Contexts: `@context-name` (e.g., `@computer-deep`, `@telefon`, `@bcn`)
- Metadata: `key:value` format (e.g., `due:2026-02-15`)
Example:
```
(A) 2026-01-31 Write blog post +observability-blog @computer-deep due:2026-02-15
```
### calendar.txt Format
Events follow this format:
- Timed event: `YYYY-MM-DD HH:MM Description @context +project`
- All-day event: `YYYY-MM-DD Description @context +project`
Example:
```
2026-02-01 09:00 Team standup @telefon +work
2026-02-15 Birthday party @personal
```
### projects.txt Format
Projects follow this format:
- Format: `+project-tag Description [status] @context goal:goal-id metadata...`
- Status: `[active]`, `[waiting]`, `[someday]`, `[completed]`
Example:
```
+myorg-assistant Personal assistant [active] @computer-deep goal:q1-2026 due:2026-02-28
```
## Common Contexts
- `@computer-deep`: Deep focus work on computer
- `@computer-light`: Light computer work
- `@telefon`: Phone calls or video meetings
- `@recados`: Errands (shopping, appointments)
- `@bcn`: Location-specific (Barcelona)
- `@personal`: Personal/family activities
## Your Capabilities
You can:
1. **Read files** from the myorg repository to understand tasks, events, and goals
2. **Add tasks** to todo.txt with proper formatting
3. **Complete tasks** by marking them with `x` and completion date
4. **Search and filter** tasks by project, context, priority, or due date
5. **Add events** to calendar.txt
6. **Manage projects** in projects.txt
7. **Commit changes** to git with descriptive messages
8. **Sync with remote** using git pull/push
## Guidelines
1. **Always read before write**: Check current file contents before modifying
2. **Preserve formatting**: Maintain todo.txt, calendar.txt, and projects.txt format
3. **Commit changes**: After modifying files, commit with descriptive messages
4. **Be proactive**: Suggest tasks that align with user's goals
5. **Respect context**: Filter tasks based on user's current context
6. **Use proper metadata**: Include due dates, projects, and contexts when relevant
7. **Update working-memory**: Note significant actions in working-memory.txt
## Natural Language Understanding
When the user says:
- "Add task: Buy milk tomorrow" → Create task with due date tomorrow, context `@recados`
- "What should I work on?" → Suggest tasks based on context, priority, and goals
- "Show my calendar" → Read and display calendar.txt events
- "Mark task X as done" → Complete the task with timestamp
- "What are my Q1 goals?" → Read quarterly goals from goals/ directory
## Tone
- Be helpful, concise, and action-oriented
- Use natural language in responses
- Acknowledge task completion and changes made
- Proactively suggest next steps when appropriate
Remember: You are a trusted assistant. The user relies on you to keep their GTD system organized and help them stay focused on what matters most.
"""
def get_system_prompt() -> str:
"""Get the main system prompt for the agent.
Returns:
System prompt string
"""
return MYORG_SYSTEM_PROMPT

0
src/api/__init__.py Normal file
View File

18
src/api/agent_instance.py Normal file
View File

@@ -0,0 +1,18 @@
"""Global agent instance for the web API."""
from src.agent.core import MyOrgAgent
from src.agent.prompts import get_system_prompt
from src.tools.file_ops import get_file_operation_tools
from src.tools.task_ops import get_task_management_tools
from src.tools.calendar_ops import get_calendar_tools
from src.tools.git_ops import get_git_tools
# Global agent (one per session in production, for now shared)
agent = MyOrgAgent(
system_prompt=get_system_prompt(),
tools=(
get_file_operation_tools() +
get_task_management_tools() +
get_calendar_tools() +
get_git_tools()
),
)

104
src/api/app.py Normal file
View File

@@ -0,0 +1,104 @@
"""FastAPI application for web interface."""
from src.api.routes import dashboard, chat, tasks, calendar, projects
from fastapi import FastAPI, Request, Depends, HTTPException, status
from fastapi.responses import HTMLResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi.security import HTTPBasic, HTTPBasicCredentials
import secrets
from pathlib import Path
from src.config import settings
from src.api.agent_instance import agent
# Initialize FastAPI app
app = FastAPI(
title="MyOrg Assistant",
description="AI-powered personal assistant for GTD task management",
version="1.0.0",
)
# Set up templates and static files
BASE_DIR = Path(__file__).resolve().parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "web" / "templates"))
app.mount("/static", StaticFiles(directory=str(BASE_DIR /
"web" / "static")), name="static")
# Security
security = HTTPBasic()
def verify_credentials(credentials: HTTPBasicCredentials = Depends(security)) -> bool:
"""Verify HTTP Basic Auth credentials.
Args:
credentials: HTTP Basic credentials
Returns:
True if valid
Raises:
HTTPException: If credentials are invalid
"""
if not settings.web_password:
# No password set, allow access
return True
correct_password = settings.web_password.encode("utf8")
provided_password = credentials.password.encode("utf8")
is_correct = secrets.compare_digest(provided_password, correct_password)
if not is_correct:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect password",
headers={"WWW-Authenticate": "Basic"},
)
return True
# Import routes
# Include routers
app.include_router(dashboard.router, dependencies=[
Depends(verify_credentials)])
app.include_router(chat.router, dependencies=[Depends(verify_credentials)])
app.include_router(tasks.router, dependencies=[Depends(verify_credentials)])
app.include_router(calendar.router, dependencies=[Depends(verify_credentials)])
app.include_router(projects.router, dependencies=[Depends(verify_credentials)])
@app.get("/", response_class=HTMLResponse)
async def root(request: Request, _: bool = Depends(verify_credentials)):
"""Redirect to dashboard."""
from fastapi.responses import RedirectResponse
return RedirectResponse(url="/dashboard")
@app.get("/health")
async def health():
"""Health check endpoint."""
return {"status": "healthy", "service": "myorg-assistant"}
def run_web() -> None:
"""Run the web server."""
import uvicorn
print("🌐 Starting MyOrg Assistant Web Server...")
print(
f"📊 Dashboard: http://{settings.web_host}:{settings.web_port}/dashboard")
print(f"💬 Chat: http://{settings.web_host}:{settings.web_port}/chat")
if settings.web_password:
print("🔒 Authentication enabled")
else:
print("⚠️ Warning: No password set (WEB_PASSWORD not configured)")
uvicorn.run(
"src.api.app:app",
host=settings.web_host,
port=settings.web_port,
reload=False,
)

View File

View File

@@ -0,0 +1,47 @@
"""Calendar route."""
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from pathlib import Path
from datetime import datetime, timedelta
from src.parsers.calendar_parser import CalendarParser
from src.tools.file_ops import read_file
router = APIRouter()
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "web" / "templates"))
@router.get("/calendar", response_class=HTMLResponse)
async def calendar_page(request: Request):
"""Calendar page."""
try:
content = read_file("calendar.txt")
all_events = CalendarParser.parse_file(content)
# Get today and upcoming events
now = datetime.now()
today = now.date()
week_later = now + timedelta(days=7)
today_events = [e for e in all_events if e.date.date() == today]
upcoming_events = [
e for e in all_events
if now <= e.datetime <= week_later
]
except FileNotFoundError:
today_events = []
upcoming_events = []
return templates.TemplateResponse(
"calendar.html",
{
"request": request,
"page": "calendar",
"today": today.strftime("%A, %B %d, %Y"),
"today_events": today_events,
"upcoming_events": upcoming_events,
}
)

93
src/api/routes/chat.py Normal file
View File

@@ -0,0 +1,93 @@
"""Chat route with SSE support."""
from fastapi import APIRouter, Request, Form
from fastapi.responses import HTMLResponse, StreamingResponse
from fastapi.templating import Jinja2Templates
from pathlib import Path
import asyncio
import json
from src.api.agent_instance import agent
router = APIRouter()
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "web" / "templates"))
@router.get("/chat", response_class=HTMLResponse)
async def chat_page(request: Request):
"""Chat page."""
return templates.TemplateResponse(
"chat.html",
{
"request": request,
"page": "chat",
}
)
@router.post("/api/chat")
async def chat_message(message: str = Form(...)):
"""Process chat message and return response.
Args:
message: User message
Returns:
JSON response with agent reply
"""
try:
response = agent.run(message)
return {"response": response, "success": True}
except Exception as e:
return {"response": f"Error: {str(e)}", "success": False}
@router.post("/api/chat/reset")
async def reset_chat():
"""Reset chat conversation history."""
agent.reset_conversation()
return {"success": True, "message": "Conversation history cleared"}
async def event_generator(message: str):
"""Generate SSE events for streaming response.
Args:
message: User message
Yields:
SSE formatted events
"""
try:
# Send initial event
yield f"data: {json.dumps({'type': 'start'})}\n\n"
# Run agent (in real streaming, we'd need to modify agent.run)
# For now, send complete response
response = agent.run(message)
# Send response event
yield f"data: {json.dumps({'type': 'response', 'content': response})}\n\n"
# Send complete event
yield f"data: {json.dumps({'type': 'done'})}\n\n"
except Exception as e:
# Send error event
yield f"data: {json.dumps({'type': 'error', 'content': str(e)})}\n\n"
@router.get("/api/chat/stream")
async def chat_stream(message: str):
"""Stream chat response via SSE.
Args:
message: User message
Returns:
SSE stream
"""
return StreamingResponse(
event_generator(message),
media_type="text/event-stream",
)

View File

@@ -0,0 +1,62 @@
"""Dashboard route."""
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from pathlib import Path
from datetime import datetime
from src.scheduler.briefings import (
get_todays_events,
get_priority_tasks,
get_tasks_due_soon,
get_waiting_items,
)
from src.parsers.project_parser import ProjectParser
from src.tools.file_ops import read_file
router = APIRouter()
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "web" / "templates"))
@router.get("/dashboard", response_class=HTMLResponse)
async def dashboard(request: Request):
"""Dashboard page."""
# Get today's data
today = datetime.now().strftime("%A, %B %d, %Y")
events = get_todays_events()
priority_tasks = get_priority_tasks(["A", "B"])
due_soon = get_tasks_due_soon(3)
waiting = get_waiting_items()
# Get active projects
try:
content = read_file("projects.txt")
all_projects = ProjectParser.parse_file(content)
active_projects = [p for p in all_projects if p.status == "active"]
except:
active_projects = []
# Stats
stats = {
"events_today": len(events),
"priority_tasks": len(priority_tasks),
"due_soon": len(due_soon),
"active_projects": len(active_projects),
"waiting_items": len(waiting),
}
return templates.TemplateResponse(
"dashboard.html",
{
"request": request,
"page": "dashboard",
"today": today,
"events": events,
"priority_tasks": priority_tasks[:5], # Top 5
"due_soon": due_soon[:3], # Top 3
"active_projects": active_projects[:5], # Top 5
"waiting": waiting[:3], # Top 3
"stats": stats,
}
)

View File

@@ -0,0 +1,71 @@
"""Projects route."""
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from pathlib import Path
from src.parsers.project_parser import ProjectParser
from src.parsers.todo_parser import TodoParser
from src.tools.file_ops import read_file
router = APIRouter()
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "web" / "templates"))
@router.get("/projects", response_class=HTMLResponse)
async def projects_page(request: Request, status: str = "active"):
"""Projects page.
Args:
request: FastAPI request
status: Filter by status (active, waiting, someday, completed)
"""
try:
content = read_file("projects.txt")
all_projects = ProjectParser.parse_file(content)
# Filter by status
if status:
filtered_projects = [p for p in all_projects if p.status == status]
else:
filtered_projects = all_projects
# Get task count per project
try:
todo_content = read_file("todo.txt")
tasks = TodoParser.parse_file(content)
active_tasks = [t for t in tasks if not t.completed]
# Count tasks per project
project_task_counts = {}
for project in filtered_projects:
count = len([t for t in active_tasks if project.tag in t.projects])
project_task_counts[project.tag] = count
except:
project_task_counts = {}
# Stats
stats = {
"active": len([p for p in all_projects if p.status == "active"]),
"waiting": len([p for p in all_projects if p.status == "waiting"]),
"someday": len([p for p in all_projects if p.status == "someday"]),
"completed": len([p for p in all_projects if p.status == "completed"]),
}
except FileNotFoundError:
filtered_projects = []
project_task_counts = {}
stats = {"active": 0, "waiting": 0, "someday": 0, "completed": 0}
return templates.TemplateResponse(
"projects.html",
{
"request": request,
"page": "projects",
"projects": filtered_projects,
"project_task_counts": project_task_counts,
"current_status": status,
"stats": stats,
}
)

147
src/api/routes/tasks.py Normal file
View File

@@ -0,0 +1,147 @@
"""Tasks route."""
from fastapi import APIRouter, Request, Form
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from pathlib import Path
from src.parsers.todo_parser import TodoParser
from src.tools.file_ops import read_file
router = APIRouter()
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "web" / "templates"))
@router.get("/tasks", response_class=HTMLResponse)
async def tasks_page(
request: Request,
project: str = None,
context: str = None,
priority: str = None,
show_completed: bool = False,
):
"""Tasks page with filtering.
Args:
request: FastAPI request
project: Filter by project
context: Filter by context
priority: Filter by priority
show_completed: Show completed tasks
"""
try:
content = read_file("todo.txt")
all_tasks = TodoParser.parse_file(content)
# Apply filters
filtered_tasks = TodoParser.filter_tasks(
all_tasks,
project=project,
context=context,
priority=priority,
completed=True if show_completed else False,
)
# Get all unique projects and contexts for filter dropdowns
all_projects = set()
all_contexts = set()
for task in all_tasks:
all_projects.update(task.projects)
all_contexts.update(task.contexts)
# Stats
total_tasks = len([t for t in all_tasks if not t.completed])
completed_tasks = len([t for t in all_tasks if t.completed])
except FileNotFoundError:
filtered_tasks = []
all_projects = set()
all_contexts = set()
total_tasks = 0
completed_tasks = 0
return templates.TemplateResponse(
"tasks.html",
{
"request": request,
"page": "tasks",
"tasks": filtered_tasks,
"all_projects": sorted(all_projects),
"all_contexts": sorted(all_contexts),
"current_project": project,
"current_context": context,
"current_priority": priority,
"show_completed": show_completed,
"total_tasks": total_tasks,
"completed_tasks": completed_tasks,
}
)
@router.post("/api/tasks/complete")
async def complete_task(task_line: int = Form(...)):
"""Mark a task as complete.
Args:
task_line: Line number of the task
Returns:
Success status
"""
try:
from src.tools.task_ops import complete_task as complete_task_tool
from datetime import datetime
# Read tasks
content = read_file("todo.txt")
tasks = TodoParser.parse_file(content)
# Find task by line number
task = next((t for t in tasks if t.line_number == task_line), None)
if not task:
return {"success": False, "error": "Task not found"}
# Mark complete
result = complete_task_tool(task.description)
return {"success": True, "message": result}
except Exception as e:
return {"success": False, "error": str(e)}
@router.post("/api/tasks/add")
async def add_task(
description: str = Form(...),
project: str = Form(None),
context: str = Form(None),
priority: str = Form(None),
due_date: str = Form(None),
):
"""Add a new task.
Args:
description: Task description
project: Project tag
context: Context tag
priority: Priority letter
due_date: Due date (YYYY-MM-DD)
Returns:
Success status
"""
try:
from src.tools.task_ops import add_task as add_task_tool
result = add_task_tool(
description=description,
project=project if project else None,
context=context if context else None,
priority=priority if priority else None,
due_date=due_date if due_date else None,
)
return {"success": True, "message": result}
except Exception as e:
return {"success": False, "error": str(e)}

0
src/bot/__init__.py Normal file
View File

302
src/bot/discord_bot.py Normal file
View File

@@ -0,0 +1,302 @@
"""Discord bot implementation for MyOrg Assistant."""
import discord
from discord.ext import commands
from typing import Optional
from src.config import settings
from src.agent.core import MyOrgAgent
from src.agent.prompts import get_system_prompt
from src.tools.file_ops import get_file_operation_tools
from src.tools.task_ops import get_task_management_tools
from src.tools.calendar_ops import get_calendar_tools
from src.tools.git_ops import get_git_tools
from src.bot.formatters import format_response_for_discord
class MyOrgBot(commands.Bot):
"""Discord bot for MyOrg Assistant."""
def __init__(self) -> None:
"""Initialize the bot."""
# Set up intents
intents = discord.Intents.default()
intents.message_content = True
intents.members = True
# Initialize bot
super().__init__(
command_prefix='/',
intents=intents,
help_command=None, # We'll create our own
)
# Initialize agent
self.agent = MyOrgAgent(
system_prompt=get_system_prompt(),
tools=(
get_file_operation_tools() +
get_task_management_tools() +
get_calendar_tools() +
get_git_tools()
),
)
# Track user sessions (one agent per user)
self.user_agents: dict[int, MyOrgAgent] = {}
def get_user_agent(self, user_id: int) -> MyOrgAgent:
"""Get or create an agent for a specific user.
Args:
user_id: Discord user ID
Returns:
MyOrgAgent instance for this user
"""
if user_id not in self.user_agents:
self.user_agents[user_id] = MyOrgAgent(
system_prompt=get_system_prompt(),
tools=(
get_file_operation_tools() +
get_task_management_tools() +
get_calendar_tools() +
get_git_tools()
),
)
return self.user_agents[user_id]
async def on_ready(self) -> None:
"""Called when bot is ready."""
print(f'✅ Logged in as {self.user}')
print(f'📊 Connected to {len(self.guilds)} server(s)')
print(f'🤖 Agent initialized with {len(self.agent.tools)} tools')
print('🎉 MyOrg Assistant is ready!')
async def on_message(self, message: discord.Message) -> None:
"""Handle incoming messages.
Args:
message: Discord message object
"""
# Ignore messages from the bot itself
if message.author == self.user:
return
# Ignore messages not mentioning the bot (unless in DM)
if not isinstance(message.channel, discord.DMChannel):
if not self.user.mentioned_in(message):
return
# Process commands first
await self.process_commands(message)
# If message wasn't a command, treat as natural conversation
if not message.content.startswith('/'):
await self.handle_conversation(message)
async def handle_conversation(self, message: discord.Message) -> None:
"""Handle natural language conversation.
Args:
message: Discord message object
"""
# Remove bot mention from message
content = message.content
if self.user.mentioned_in(message):
content = content.replace(f'<@{self.user.id}>', '').strip()
if not content:
return
# Show typing indicator
async with message.channel.typing():
try:
# Get user's agent
agent = self.get_user_agent(message.author.id)
# Process message
response = agent.run(content)
# Format and send response
formatted = format_response_for_discord(response)
# Split if too long (Discord limit is 2000 chars)
if len(formatted) <= 2000:
await message.reply(formatted)
else:
# Split into chunks
chunks = [formatted[i:i+1900] for i in range(0, len(formatted), 1900)]
for chunk in chunks:
await message.channel.send(chunk)
except Exception as e:
await message.reply(f'❌ Error: {str(e)}')
async def setup_hook(self) -> None:
"""Set up bot commands."""
# Commands will be added via decorators below
pass
# Create bot instance
bot = MyOrgBot()
@bot.command(name='help')
async def help_command(ctx: commands.Context) -> None:
"""Show help information."""
help_text = """
**🤖 MyOrg Assistant - Help**
**Natural Conversation:**
Just mention me or DM me to interact naturally!
- "Add task: Buy milk tomorrow"
- "What should I work on now?"
- "Show my tasks for project myorg-assistant"
**Commands:**
`/help` - Show this help message
`/briefing` - Get daily briefing (calendar + priority tasks)
`/add [task]` - Quick task addition
`/tasks [filter]` - Show tasks (optionally filtered)
`/today` - Today's calendar and priority tasks
`/context [context]` - Set your current context
`/reset` - Clear conversation history
**Examples:**
`/add Buy groceries @recados due:2026-02-01`
`/tasks project:myorg-assistant`
`/context computer-deep`
**Contexts:**
`@computer-deep` - Deep focus work
`@computer-light` - Light computer work
`@telefon` - Calls/meetings
`@recados` - Errands
`@bcn` - Barcelona location
`@personal` - Personal activities
"""
await ctx.send(help_text)
@bot.command(name='briefing')
async def briefing_command(ctx: commands.Context) -> None:
"""Get daily briefing."""
async with ctx.typing():
try:
agent = bot.get_user_agent(ctx.author.id)
prompt = """Generate a morning briefing for today:
1. Read calendar.txt and show today's events
2. Read todo.txt and show priority A and B tasks
3. Show tasks with due dates in the next 3 days
4. Format as a nice daily briefing"""
response = agent.run(prompt)
formatted = format_response_for_discord(response)
await ctx.send(formatted)
except Exception as e:
await ctx.send(f'❌ Error generating briefing: {str(e)}')
@bot.command(name='add')
async def add_command(ctx: commands.Context, *, task: str) -> None:
"""Quick task addition.
Args:
task: Task description with optional metadata
"""
async with ctx.typing():
try:
agent = bot.get_user_agent(ctx.author.id)
prompt = f"""Add this task to todo.txt: {task}
Parse the task description and extract:
- Description (main text)
- Project tags (words starting with +)
- Context tags (words starting with @)
- Due date (due:YYYY-MM-DD format)
- Priority if implied
Then add the task using the add_task tool and commit the change."""
response = agent.run(prompt)
formatted = format_response_for_discord(response)
await ctx.send(formatted)
except Exception as e:
await ctx.send(f'❌ Error adding task: {str(e)}')
@bot.command(name='tasks')
async def tasks_command(ctx: commands.Context, *, filters: Optional[str] = None) -> None:
"""Show tasks with optional filters.
Args:
filters: Optional filter string (e.g., "project:myorg-assistant context:computer-deep")
"""
async with ctx.typing():
try:
agent = bot.get_user_agent(ctx.author.id)
if filters:
prompt = f"""Show tasks from todo.txt with these filters: {filters}
Parse the filter string and use the search_tasks tool appropriately."""
else:
prompt = "Show all active tasks from todo.txt grouped by priority using get_tasks_by_priority tool."
response = agent.run(prompt)
formatted = format_response_for_discord(response)
await ctx.send(formatted)
except Exception as e:
await ctx.send(f'❌ Error fetching tasks: {str(e)}')
@bot.command(name='today')
async def today_command(ctx: commands.Context) -> None:
"""Show today's calendar and priority tasks."""
async with ctx.typing():
try:
agent = bot.get_user_agent(ctx.author.id)
prompt = """Show me what's on for today:
1. Read calendar.txt and show today's events
2. Read todo.txt and show priority A tasks
3. Show any tasks due today
4. Format as a concise daily overview"""
response = agent.run(prompt)
formatted = format_response_for_discord(response)
await ctx.send(formatted)
except Exception as e:
await ctx.send(f'❌ Error: {str(e)}')
@bot.command(name='context')
async def context_command(ctx: commands.Context, context: str) -> None:
"""Set current context.
Args:
context: Context name (without @ prefix)
"""
# For now, just acknowledge. In Phase 3, we'll track this
await ctx.send(f'✅ Context set to `@{context}`')
@bot.command(name='reset')
async def reset_command(ctx: commands.Context) -> None:
"""Reset conversation history."""
agent = bot.get_user_agent(ctx.author.id)
agent.reset_conversation()
await ctx.send('🔄 Conversation history cleared!')
def run_bot() -> None:
"""Run the Discord bot."""
if not settings.discord_bot_token:
raise ValueError("DISCORD_BOT_TOKEN not set in environment")
print("🚀 Starting MyOrg Discord Bot...")
bot.run(settings.discord_bot_token)

198
src/bot/formatters.py Normal file
View File

@@ -0,0 +1,198 @@
"""Formatters for Discord messages."""
import re
def format_response_for_discord(text: str) -> str:
"""Format agent response for Discord.
Applies Discord markdown and adds visual improvements.
Args:
text: Raw agent response
Returns:
Formatted text for Discord
"""
# Already has good formatting if it contains emoji headers
if any(emoji in text for emoji in ['📊', '', '', '📝', '⚠️', '📅', '🌿', '⬆️', '⬇️']):
return text
# Add some visual improvements
formatted = text
# Convert headers (lines ending with :)
formatted = re.sub(r'^([A-Z][^:]+):$', r'**\1:**', formatted, flags=re.MULTILINE)
# Make file paths monospace
formatted = re.sub(r'([\w/]+\.txt|[\w/]+\.md)', r'`\1`', formatted)
# Make project tags bold
formatted = re.sub(r'\+(\w+)', r'**+\1**', formatted)
# Make context tags italic
formatted = re.sub(r'@(\w+)', r'*@\1*', formatted)
return formatted
def format_task_list(tasks: list[dict]) -> str:
"""Format a list of tasks for Discord.
Args:
tasks: List of task dictionaries
Returns:
Formatted task list
"""
if not tasks:
return "No tasks found."
lines = [f"**📋 {len(tasks)} Task(s):**\n"]
for i, task in enumerate(tasks, 1):
status = "" if task.get('completed') else ""
priority = f"({task.get('priority')}) " if task.get('priority') else ""
description = task.get('description', 'Untitled')
projects = " ".join([f"**+{p}**" for p in task.get('projects', [])])
contexts = " ".join([f"*@{c}*" for c in task.get('contexts', [])])
due = ""
if task.get('due_date'):
due = f" 📅 `{task['due_date']}`"
lines.append(f"{status} {priority}{description} {projects} {contexts}{due}")
return "\n".join(lines)
def format_calendar_events(events: list[dict]) -> str:
"""Format a list of calendar events for Discord.
Args:
events: List of event dictionaries
Returns:
Formatted event list
"""
if not events:
return "No events found."
lines = [f"**📅 {len(events)} Event(s):**\n"]
for event in events:
time_str = event.get('time', 'All day')
description = event.get('description', 'Untitled')
contexts = " ".join([f"*@{c}*" for c in event.get('contexts', [])])
projects = " ".join([f"**+{p}**" for p in event.get('projects', [])])
if event.get('all_day'):
lines.append(f"🗓️ **{description}** (All day) {contexts} {projects}")
else:
lines.append(f"🕐 `{time_str}` **{description}** {contexts} {projects}")
return "\n".join(lines)
def format_briefing(
date: str,
events: list[dict],
priority_tasks: list[dict],
due_soon: list[dict],
) -> str:
"""Format a daily briefing.
Args:
date: Date string
events: Today's events
priority_tasks: High-priority tasks
due_soon: Tasks due soon
Returns:
Formatted briefing
"""
lines = [
f"**🌅 Daily Briefing - {date}**\n",
]
# Calendar events
if events:
lines.append("**📅 Today's Schedule:**")
for event in events:
time_str = event.get('time', 'All day')
description = event.get('description')
lines.append(f" • `{time_str}` {description}")
lines.append("")
else:
lines.append("📅 No events scheduled today\n")
# Priority tasks
if priority_tasks:
lines.append("**✅ Priority Tasks:**")
for task in priority_tasks:
priority = f"({task.get('priority')}) " if task.get('priority') else ""
description = task.get('description')
projects = " ".join([f"**+{p}**" for p in task.get('projects', [])])
lines.append(f"{priority}{description} {projects}")
lines.append("")
# Due soon
if due_soon:
lines.append("**⏳ Due Soon:**")
for task in due_soon:
description = task.get('description')
due_date = task.get('due_date')
lines.append(f"{description} 📅 `{due_date}`")
lines.append("")
lines.append("Have a productive day! 🚀")
return "\n".join(lines)
def truncate_for_discord(text: str, max_length: int = 2000) -> str:
"""Truncate text to fit Discord's message limit.
Args:
text: Text to truncate
max_length: Maximum length (default: 2000)
Returns:
Truncated text
"""
if len(text) <= max_length:
return text
# Try to truncate at a newline
truncated = text[:max_length-50]
last_newline = truncated.rfind('\n')
if last_newline > max_length - 200:
truncated = truncated[:last_newline]
return truncated + "\n\n... (truncated)"
def format_error(error: str) -> str:
"""Format an error message for Discord.
Args:
error: Error message
Returns:
Formatted error
"""
return f"❌ **Error:** {error}"
def format_success(message: str) -> str:
"""Format a success message for Discord.
Args:
message: Success message
Returns:
Formatted success message
"""
return f"{message}"

44
src/config.py Normal file
View File

@@ -0,0 +1,44 @@
"""Configuration management for MyOrg Assistant."""
from pydantic_settings import BaseSettings
from typing import Optional
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
# LiteLLM Configuration
litellm_endpoint: str = "http://litellm-service.default.svc.cluster.local:4000"
litellm_api_key: str
litellm_model: str = "claude-sonnet-4-5-20250929"
# Discord Configuration
discord_bot_token: str = ""
discord_channel_id: str = ""
# Git Configuration
git_repo_url: str = "https://gitea.rogi.casa/roger/myorg.git"
git_branch: str = "main"
git_username: str = "roger"
git_token: str
# Myorg Repository Path
myorg_repo_path: str = "/data/myorg"
# Scheduling
timezone: str = "Europe/Madrid"
# Web Interface
web_host: str = "0.0.0.0"
web_port: int = 8000
web_secret_key: str
# Optional: Authentication
web_password: Optional[str] = None
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
# Global settings instance
settings = Settings()

102
src/main.py Normal file
View File

@@ -0,0 +1,102 @@
"""Main entry point for MyOrg Assistant."""
import sys
import argparse
from pathlib import Path
def cli_mode() -> None:
"""Run in CLI mode for testing."""
from src.agent.core import MyOrgAgent
from src.agent.prompts import get_system_prompt
from src.tools.file_ops import get_file_operation_tools
from src.tools.task_ops import get_task_management_tools
from src.tools.calendar_ops import get_calendar_tools
from src.tools.git_ops import get_git_tools
from src.config import settings
print("🤖 MyOrg Assistant - CLI Mode")
print("=" * 50)
print("Type 'exit' or 'quit' to stop")
print("Type 'reset' to clear conversation history")
print("=" * 50)
print()
# Initialize agent with all tools
agent = MyOrgAgent(
system_prompt=get_system_prompt(),
tools=(
get_file_operation_tools() +
get_task_management_tools() +
get_calendar_tools() +
get_git_tools()
),
)
print(f"✅ Agent initialized with {len(agent.tools)} tools")
print(f"📁 Working with repository: {settings.myorg_repo_path}\n")
while True:
try:
# Get user input
user_input = input("You: ").strip()
if not user_input:
continue
if user_input.lower() in ['exit', 'quit']:
print("\n👋 Goodbye!")
break
if user_input.lower() == 'reset':
agent.reset_conversation()
print("🔄 Conversation history cleared\n")
continue
# Run agent
print("\nAssistant: ", end="", flush=True)
response = agent.run(user_input)
print(response)
print()
except KeyboardInterrupt:
print("\n\n👋 Goodbye!")
break
except Exception as e:
print(f"\n❌ Error: {str(e)}\n")
def bot_mode() -> None:
"""Run in Discord bot mode."""
from src.bot.discord_bot import run_bot
run_bot()
def web_mode() -> None:
"""Run in web server mode."""
from src.api.app import run_web
run_web()
def main() -> None:
"""Main entry point."""
parser = argparse.ArgumentParser(description="MyOrg Personal Assistant")
parser.add_argument(
"mode",
choices=["cli", "bot", "web"],
default="cli",
nargs="?",
help="Run mode: cli (default), bot (Discord), or web (FastAPI server)",
)
args = parser.parse_args()
if args.mode == "cli":
cli_mode()
elif args.mode == "bot":
bot_mode()
elif args.mode == "web":
web_mode()
if __name__ == "__main__":
main()

0
src/parsers/__init__.py Normal file
View File

View File

@@ -0,0 +1,309 @@
"""Parser for calendar.txt format used in myorg GTD system.
Calendar.txt format:
- One event per line
- Format: YYYY-MM-DD HH:MM Description @context +project tags...
- All-day events: YYYY-MM-DD Description
- Supports contexts and project tags
Example events:
2026-02-01 09:00 Team standup @telefon +work
2026-02-15 Birthday party @personal
"""
import re
from datetime import datetime, time as time_type
from typing import Optional, List, Dict, Any
from dataclasses import dataclass, field, asdict
@dataclass
class Event:
"""Represents a single calendar event."""
raw_line: str
line_number: int
date: datetime
time: Optional[time_type] = None
description: str = ""
contexts: List[str] = field(default_factory=list)
projects: List[str] = field(default_factory=list)
tags: Dict[str, str] = field(default_factory=dict)
all_day: bool = False
def __post_init__(self) -> None:
"""Post-init to ensure all_day is always a bool."""
# Ensure all_day is a proper boolean
if self.all_day is None:
self.all_day = False
else:
self.all_day = bool(self.all_day)
@property
def datetime(self) -> datetime:
"""Get full datetime of the event."""
if self.time:
return datetime.combine(self.date.date(), self.time)
return self.date
def to_dict(self) -> Dict[str, Any]:
"""Convert event to dictionary representation."""
return {
"raw_line": self.raw_line,
"line_number": self.line_number,
"date": self.date.strftime("%Y-%m-%d"),
"time": self.time.strftime("%H:%M") if self.time else None,
"datetime": self.datetime.isoformat(),
"description": self.description,
"contexts": self.contexts,
"projects": self.projects,
"tags": self.tags,
"all_day": self.all_day,
}
class CalendarParser:
"""Parser for calendar.txt format files."""
# Regular expressions for parsing
DATE_TIME_RE = re.compile(r'^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2})\s+')
DATE_ONLY_RE = re.compile(r'^(\d{4}-\d{2}-\d{2})\s+')
PROJECT_RE = re.compile(r'\+(\S+)')
CONTEXT_RE = re.compile(r'@(\S+)')
TAG_RE = re.compile(r'(\w+):(\S+)')
@staticmethod
def parse_date(date_str: str) -> Optional[datetime]:
"""Parse a date string in YYYY-MM-DD format."""
try:
return datetime.strptime(date_str, "%Y-%m-%d")
except ValueError:
return None
@staticmethod
def parse_time(time_str: str) -> Optional[time_type]:
"""Parse a time string in HH:MM format."""
try:
return datetime.strptime(time_str, "%H:%M").time()
except ValueError:
return None
@classmethod
def parse_line(cls, line: str, line_number: int = 0) -> Optional[Event]:
"""Parse a single line from calendar.txt.
Args:
line: The line to parse
line_number: Line number in the file (for reference)
Returns:
Event object or None if line is empty or a comment
"""
# Skip empty lines and comments
line = line.strip()
if not line or line.startswith('#'):
return None
# Try to parse date and time
datetime_match = cls.DATE_TIME_RE.match(line)
date_match = cls.DATE_ONLY_RE.match(line)
if datetime_match:
# Event with specific time
date_str, time_str = datetime_match.groups()
event_date = cls.parse_date(date_str)
event_time = cls.parse_time(time_str)
if not event_date or not event_time:
return None
remaining = line[datetime_match.end():]
event = Event(
raw_line=line,
line_number=line_number,
date=event_date,
time=event_time,
all_day=False
)
elif date_match:
# All-day event
date_str = date_match.group(1)
event_date = cls.parse_date(date_str)
if not event_date:
return None
remaining = line[date_match.end():]
event = Event(
raw_line=line,
line_number=line_number,
date=event_date,
time=None,
all_day=True
)
else:
# Invalid format
return None
# Extract contexts
event.contexts = cls.CONTEXT_RE.findall(remaining)
# Extract projects
event.projects = cls.PROJECT_RE.findall(remaining)
# Extract tags
for match in cls.TAG_RE.finditer(remaining):
key, value = match.groups()
# Avoid treating contexts and projects as tags
if not remaining[match.start()-1:match.start()] in ['@', '+']:
event.tags[key] = value
# Remove contexts, projects, and tags to get clean description
description = remaining
for context in event.contexts:
description = description.replace(f'@{context}', '')
for project in event.projects:
description = description.replace(f'+{project}', '')
for key, value in event.tags.items():
description = description.replace(f'{key}:{value}', '')
event.description = ' '.join(description.split())
return event
@classmethod
def parse_file(cls, file_content: str) -> List[Event]:
"""Parse entire calendar.txt file content.
Args:
file_content: Content of the calendar.txt file
Returns:
List of Event objects sorted by datetime
"""
events = []
for line_number, line in enumerate(file_content.split('\n'), start=1):
event = cls.parse_line(line, line_number)
if event:
events.append(event)
# Sort events by datetime
events.sort(key=lambda e: e.datetime)
return events
@staticmethod
def format_event(
date: datetime,
description: str,
time: Optional[time_type] = None,
contexts: Optional[List[str]] = None,
projects: Optional[List[str]] = None,
tags: Optional[Dict[str, str]] = None,
) -> str:
"""Format an event according to calendar.txt format.
Args:
date: Event date
description: Event description
time: Event time (None for all-day)
contexts: List of context tags
projects: List of project tags
tags: Dictionary of tag key-value pairs
Returns:
Formatted calendar.txt line
"""
parts = [date.strftime('%Y-%m-%d')]
# Add time if specified
if time:
parts.append(time.strftime('%H:%M'))
# Add description
parts.append(description)
# Add contexts
if contexts:
parts.extend([f'@{context}' for context in contexts])
# Add projects
if projects:
parts.extend([f'+{project}' for project in projects])
# Add tags
if tags:
parts.extend([f'{key}:{value}' for key, value in tags.items()])
return ' '.join(parts)
@staticmethod
def filter_events(
events: List[Event],
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
context: Optional[str] = None,
project: Optional[str] = None,
all_day: Optional[bool] = None,
) -> List[Event]:
"""Filter events based on criteria.
Args:
events: List of events to filter
start_date: Filter events on or after this date
end_date: Filter events on or before this date
context: Filter by context tag
project: Filter by project tag
all_day: Filter by all-day status
Returns:
Filtered list of events
"""
filtered = events
if start_date:
filtered = [e for e in filtered if e.datetime >= start_date]
if end_date:
filtered = [e for e in filtered if e.datetime <= end_date]
if context:
filtered = [e for e in filtered if context in e.contexts]
if project:
filtered = [e for e in filtered if project in e.projects]
if all_day is not None:
filtered = [e for e in filtered if e.all_day == all_day]
return filtered
@staticmethod
def get_today_events(events: List[Event]) -> List[Event]:
"""Get events for today.
Args:
events: List of all events
Returns:
List of today's events
"""
today = datetime.now().date()
return [e for e in events if e.date.date() == today]
@staticmethod
def get_upcoming_events(events: List[Event], days: int = 7) -> List[Event]:
"""Get events in the next N days.
Args:
events: List of all events
days: Number of days to look ahead
Returns:
List of upcoming events
"""
now = datetime.now()
end_date = datetime.now().replace(hour=23, minute=59, second=59)
from datetime import timedelta
end_date = end_date + timedelta(days=days)
return [e for e in events if now <= e.datetime <= end_date]

View File

@@ -0,0 +1,239 @@
"""Parser for projects.txt format used in myorg GTD system.
Projects.txt format:
- One project per line
- Format: +project-tag Description [status] [metadata...]
- Status: active, waiting, someday, completed
- Can include contexts, goals, and other metadata
Example projects:
+myorg-assistant MyOrg Personal Assistant [active] goal:q1-2026
+observability-blog Observability blog post [active] @computer-deep
+home-renovation Kitchen renovation [waiting] due:2026-03-15
"""
import re
from datetime import datetime
from typing import Optional, List, Dict, Any
from dataclasses import dataclass, field
@dataclass
class Project:
"""Represents a single project."""
raw_line: str
line_number: int
tag: str
description: str
status: str = "active"
contexts: List[str] = field(default_factory=list)
goals: List[str] = field(default_factory=list)
metadata: Dict[str, str] = field(default_factory=dict)
@property
def due_date(self) -> Optional[datetime]:
"""Extract due date from metadata."""
if "due" in self.metadata:
try:
return datetime.strptime(self.metadata["due"], "%Y-%m-%d")
except ValueError:
return None
return None
def to_dict(self) -> Dict[str, Any]:
"""Convert project to dictionary representation."""
return {
"raw_line": self.raw_line,
"line_number": self.line_number,
"tag": self.tag,
"description": self.description,
"status": self.status,
"contexts": self.contexts,
"goals": self.goals,
"metadata": self.metadata,
"due_date": self.due_date.isoformat() if self.due_date else None,
}
class ProjectParser:
"""Parser for projects.txt format files."""
# Regular expressions for parsing
PROJECT_TAG_RE = re.compile(r'^\+(\S+)\s+')
STATUS_RE = re.compile(r'\[(active|waiting|someday|completed)\]', re.IGNORECASE)
CONTEXT_RE = re.compile(r'@(\S+)')
GOAL_RE = re.compile(r'goal:(\S+)')
METADATA_RE = re.compile(r'(\w+):(\S+)')
@classmethod
def parse_line(cls, line: str, line_number: int = 0) -> Optional[Project]:
"""Parse a single line from projects.txt.
Args:
line: The line to parse
line_number: Line number in the file (for reference)
Returns:
Project object or None if line is empty or a comment
"""
# Skip empty lines and comments
line = line.strip()
if not line or line.startswith('#'):
return None
# Extract project tag
tag_match = cls.PROJECT_TAG_RE.match(line)
if not tag_match:
return None
tag = tag_match.group(1)
remaining = line[tag_match.end():]
# Extract status
status = "active" # Default status
status_match = cls.STATUS_RE.search(remaining)
if status_match:
status = status_match.group(1).lower()
# Remove status from remaining text
remaining = remaining[:status_match.start()] + remaining[status_match.end():]
# Extract contexts
contexts = cls.CONTEXT_RE.findall(remaining)
# Extract goals
goals = cls.GOAL_RE.findall(remaining)
# Extract other metadata
metadata: Dict[str, str] = {}
for match in cls.METADATA_RE.finditer(remaining):
key, value = match.groups()
# Skip if it's a goal (already extracted)
if key != 'goal':
metadata[key] = value
# Remove contexts, goals, and metadata to get clean description
description = remaining
for context in contexts:
description = description.replace(f'@{context}', '')
for goal in goals:
description = description.replace(f'goal:{goal}', '')
for key, value in metadata.items():
description = description.replace(f'{key}:{value}', '')
description = ' '.join(description.split())
return Project(
raw_line=line,
line_number=line_number,
tag=tag,
description=description,
status=status,
contexts=contexts,
goals=goals,
metadata=metadata,
)
@classmethod
def parse_file(cls, file_content: str) -> List[Project]:
"""Parse entire projects.txt file content.
Args:
file_content: Content of the projects.txt file
Returns:
List of Project objects
"""
projects = []
for line_number, line in enumerate(file_content.split('\n'), start=1):
project = cls.parse_line(line, line_number)
if project:
projects.append(project)
return projects
@staticmethod
def format_project(
tag: str,
description: str,
status: str = "active",
contexts: Optional[List[str]] = None,
goals: Optional[List[str]] = None,
metadata: Optional[Dict[str, str]] = None,
) -> str:
"""Format a project according to projects.txt format.
Args:
tag: Project tag (without + prefix)
description: Project description
status: Project status (active, waiting, someday, completed)
contexts: List of context tags
goals: List of goal references
metadata: Dictionary of metadata key-value pairs
Returns:
Formatted projects.txt line
"""
parts = [f'+{tag}', description, f'[{status}]']
# Add contexts
if contexts:
parts.extend([f'@{context}' for context in contexts])
# Add goals
if goals:
parts.extend([f'goal:{goal}' for goal in goals])
# Add metadata
if metadata:
parts.extend([f'{key}:{value}' for key, value in metadata.items()])
return ' '.join(parts)
@staticmethod
def filter_projects(
projects: List[Project],
status: Optional[str] = None,
context: Optional[str] = None,
goal: Optional[str] = None,
has_due_date: Optional[bool] = None,
) -> List[Project]:
"""Filter projects based on criteria.
Args:
projects: List of projects to filter
status: Filter by status (active, waiting, someday, completed)
context: Filter by context tag
goal: Filter by goal reference
has_due_date: Filter by presence of due date
Returns:
Filtered list of projects
"""
filtered = projects
if status:
filtered = [p for p in filtered if p.status == status.lower()]
if context:
filtered = [p for p in filtered if context in p.contexts]
if goal:
filtered = [p for p in filtered if goal in p.goals]
if has_due_date is not None:
if has_due_date:
filtered = [p for p in filtered if p.due_date is not None]
else:
filtered = [p for p in filtered if p.due_date is None]
return filtered
@staticmethod
def get_active_projects(projects: List[Project]) -> List[Project]:
"""Get all active projects.
Args:
projects: List of all projects
Returns:
List of active projects
"""
return [p for p in projects if p.status == "active"]

270
src/parsers/todo_parser.py Normal file
View File

@@ -0,0 +1,270 @@
"""Parser for todo.txt format used in myorg GTD system.
Todo.txt format follows these conventions:
- Priority: (A), (B), (C) at the start of the line
- Completion: x at the start (with optional completion date)
- Dates: YYYY-MM-DD format
- Projects: +project-name
- Contexts: @context-name
- Metadata: key:value format
Example task:
(A) 2026-01-31 Write blog post +observability-blog @computer-deep due:2026-02-15
"""
import re
from datetime import datetime
from typing import Optional, List, Dict, Any
from dataclasses import dataclass, field
@dataclass
class Task:
"""Represents a single task from todo.txt."""
raw_line: str
line_number: int
completed: bool = False
completion_date: Optional[datetime] = None
priority: Optional[str] = None
creation_date: Optional[datetime] = None
description: str = ""
projects: List[str] = field(default_factory=list)
contexts: List[str] = field(default_factory=list)
metadata: Dict[str, str] = field(default_factory=dict)
def __post_init__(self) -> None:
"""Post-init to ensure completed is always a bool."""
# Ensure completed is a proper boolean
if self.completed is None:
self.completed = False
else:
self.completed = bool(self.completed)
@property
def due_date(self) -> Optional[datetime]:
"""Extract due date from metadata."""
if "due" in self.metadata:
try:
return datetime.strptime(self.metadata["due"], "%Y-%m-%d")
except ValueError:
return None
return None
def to_dict(self) -> Dict[str, Any]:
"""Convert task to dictionary representation."""
return {
"raw_line": self.raw_line,
"line_number": self.line_number,
"completed": self.completed,
"completion_date": self.completion_date.isoformat() if self.completion_date else None,
"priority": self.priority,
"creation_date": self.creation_date.isoformat() if self.creation_date else None,
"description": self.description,
"projects": self.projects,
"contexts": self.contexts,
"metadata": self.metadata,
"due_date": self.due_date.isoformat() if self.due_date else None,
}
class TodoParser:
"""Parser for todo.txt format files."""
# Regular expressions for parsing
PRIORITY_RE = re.compile(r'^\(([A-Z])\)\s+')
DATE_RE = re.compile(r'^\d{4}-\d{2}-\d{2}')
PROJECT_RE = re.compile(r'\+(\S+)')
CONTEXT_RE = re.compile(r'@(\S+)')
METADATA_RE = re.compile(r'(\w+):(\S+)')
COMPLETION_RE = re.compile(r'^x\s+(?:(\d{4}-\d{2}-\d{2})\s+)?')
@staticmethod
def parse_date(date_str: str) -> Optional[datetime]:
"""Parse a date string in YYYY-MM-DD format."""
try:
return datetime.strptime(date_str, "%Y-%m-%d")
except ValueError:
return None
@classmethod
def parse_line(cls, line: str, line_number: int = 0) -> Optional[Task]:
"""Parse a single line from todo.txt.
Args:
line: The line to parse
line_number: Line number in the file (for reference)
Returns:
Task object or None if line is empty or a comment
"""
# Skip empty lines and comments
line = line.strip()
if not line or line.startswith('#'):
return None
task = Task(raw_line=line, line_number=line_number)
remaining = line
# Check for completion
completion_match = cls.COMPLETION_RE.match(remaining)
if completion_match:
task.completed = True
if completion_match.group(1):
task.completion_date = cls.parse_date(completion_match.group(1))
remaining = remaining[completion_match.end():]
# Check for priority
priority_match = cls.PRIORITY_RE.match(remaining)
if priority_match:
task.priority = priority_match.group(1)
remaining = remaining[priority_match.end():]
# Check for creation date (only at the beginning after priority)
date_match = cls.DATE_RE.match(remaining)
if date_match:
date_str = date_match.group()
task.creation_date = cls.parse_date(date_str)
remaining = remaining[len(date_str):].strip()
# Extract projects
task.projects = cls.PROJECT_RE.findall(remaining)
# Extract contexts
task.contexts = cls.CONTEXT_RE.findall(remaining)
# Extract metadata key:value pairs
for match in cls.METADATA_RE.finditer(remaining):
key, value = match.groups()
task.metadata[key] = value
# Remove projects, contexts, and metadata to get clean description
description = remaining
for project in task.projects:
description = description.replace(f'+{project}', '')
for context in task.contexts:
description = description.replace(f'@{context}', '')
for key, value in task.metadata.items():
description = description.replace(f'{key}:{value}', '')
task.description = ' '.join(description.split())
return task
@classmethod
def parse_file(cls, file_content: str) -> List[Task]:
"""Parse entire todo.txt file content.
Args:
file_content: Content of the todo.txt file
Returns:
List of Task objects
"""
tasks = []
for line_number, line in enumerate(file_content.split('\n'), start=1):
task = cls.parse_line(line, line_number)
if task:
tasks.append(task)
return tasks
@staticmethod
def format_task(
description: str,
priority: Optional[str] = None,
creation_date: Optional[datetime] = None,
projects: Optional[List[str]] = None,
contexts: Optional[List[str]] = None,
metadata: Optional[Dict[str, str]] = None,
completed: bool = False,
completion_date: Optional[datetime] = None,
) -> str:
"""Format a task according to todo.txt format.
Args:
description: Task description
priority: Priority letter (A-Z)
creation_date: Date task was created
projects: List of project tags
contexts: List of context tags
metadata: Dictionary of metadata key-value pairs
completed: Whether task is completed
completion_date: Date task was completed
Returns:
Formatted todo.txt line
"""
parts = []
# Completion marker
if completed:
parts.append('x')
if completion_date:
parts.append(completion_date.strftime('%Y-%m-%d'))
# Priority
if priority and not completed: # Priority not shown for completed tasks
parts.append(f'({priority})')
# Creation date
if creation_date:
parts.append(creation_date.strftime('%Y-%m-%d'))
# Description
parts.append(description)
# Projects
if projects:
parts.extend([f'+{project}' for project in projects])
# Contexts
if contexts:
parts.extend([f'@{context}' for context in contexts])
# Metadata
if metadata:
parts.extend([f'{key}:{value}' for key, value in metadata.items()])
return ' '.join(parts)
@staticmethod
def filter_tasks(
tasks: List[Task],
completed: Optional[bool] = None,
priority: Optional[str] = None,
project: Optional[str] = None,
context: Optional[str] = None,
has_due_date: Optional[bool] = None,
) -> List[Task]:
"""Filter tasks based on criteria.
Args:
tasks: List of tasks to filter
completed: Filter by completion status
priority: Filter by priority letter
project: Filter by project tag
context: Filter by context tag
has_due_date: Filter by presence of due date
Returns:
Filtered list of tasks
"""
filtered = tasks
if completed is not None:
filtered = [t for t in filtered if t.completed == completed]
if priority is not None:
filtered = [t for t in filtered if t.priority == priority]
if project is not None:
filtered = [t for t in filtered if project in t.projects]
if context is not None:
filtered = [t for t in filtered if context in t.contexts]
if has_due_date is not None:
if has_due_date:
filtered = [t for t in filtered if t.due_date is not None]
else:
filtered = [t for t in filtered if t.due_date is None]
return filtered

View File

374
src/scheduler/briefings.py Normal file
View File

@@ -0,0 +1,374 @@
"""Briefing generators for scheduled messages."""
from datetime import datetime, timedelta
from typing import List, Dict, Any
from src.parsers.todo_parser import TodoParser, Task
from src.parsers.calendar_parser import CalendarParser, Event
from src.tools.file_ops import read_file
def get_todays_date() -> str:
"""Get today's date formatted.
Returns:
Formatted date string
"""
now = datetime.now()
return now.strftime("%A, %B %d, %Y")
def get_todays_events() -> List[Event]:
"""Get today's calendar events.
Returns:
List of today's events
"""
try:
content = read_file("calendar.txt")
events = CalendarParser.parse_file(content)
today = datetime.now().date()
return [e for e in events if e.date.date() == today]
except FileNotFoundError:
return []
def get_upcoming_events(days: int = 1) -> List[Event]:
"""Get events in the next N days.
Args:
days: Number of days to look ahead
Returns:
List of upcoming events
"""
try:
content = read_file("calendar.txt")
events = CalendarParser.parse_file(content)
now = datetime.now()
end_date = now + timedelta(days=days)
return [e for e in events if now <= e.datetime <= end_date]
except FileNotFoundError:
return []
def get_priority_tasks(priorities: List[str] = ["A", "B"]) -> List[Task]:
"""Get tasks with specific priorities.
Args:
priorities: List of priority letters to include
Returns:
List of priority tasks
"""
try:
content = read_file("todo.txt")
tasks = TodoParser.parse_file(content)
active_tasks = [t for t in tasks if not t.completed]
return [t for t in active_tasks if t.priority in priorities]
except FileNotFoundError:
return []
def get_tasks_due_soon(days: int = 3) -> List[Task]:
"""Get tasks due within N days.
Args:
days: Number of days to look ahead
Returns:
List of tasks due soon
"""
try:
content = read_file("todo.txt")
tasks = TodoParser.parse_file(content)
active_tasks = [t for t in tasks if not t.completed]
now = datetime.now()
end_date = now + timedelta(days=days)
due_soon = []
for task in active_tasks:
if task.due_date and now <= task.due_date <= end_date:
due_soon.append(task)
# Sort by due date
due_soon.sort(key=lambda t: t.due_date or datetime.max)
return due_soon
except FileNotFoundError:
return []
def get_waiting_items() -> List[str]:
"""Get items from waiting.txt.
Returns:
List of waiting items (as strings)
"""
try:
content = read_file("waiting.txt")
lines = [line.strip() for line in content.split('\n') if line.strip() and not line.startswith('#')]
return lines
except FileNotFoundError:
return []
def get_completed_today() -> List[Task]:
"""Get tasks completed today.
Returns:
List of tasks completed today
"""
try:
content = read_file("todo.txt")
tasks = TodoParser.parse_file(content)
today = datetime.now().date()
completed_today = []
for task in tasks:
if task.completed and task.completion_date:
if task.completion_date.date() == today:
completed_today.append(task)
return completed_today
except FileNotFoundError:
return []
def generate_morning_briefing() -> str:
"""Generate morning briefing message.
Returns:
Formatted briefing message
"""
date_str = get_todays_date()
lines = [
f"**🌅 Good Morning! - {date_str}**\n",
]
# Today's events
events = get_todays_events()
if events:
lines.append("**📅 Today's Schedule:**")
for event in events:
time_str = event.time.strftime("%H:%M") if event.time else "All day"
contexts = " ".join([f"*@{c}*" for c in event.contexts])
projects = " ".join([f"**+{p}**" for p in event.projects])
lines.append(f" • `{time_str}` {event.description} {contexts} {projects}")
lines.append("")
else:
lines.append("📅 No events scheduled today\n")
# Priority tasks
priority_tasks = get_priority_tasks(["A", "B"])
if priority_tasks:
lines.append("**✅ Priority Tasks:**")
for task in priority_tasks[:5]: # Top 5
priority_str = f"({task.priority}) " if task.priority else ""
projects = " ".join([f"**+{p}**" for p in task.projects])
contexts = " ".join([f"*@{c}*" for c in task.contexts])
lines.append(f"{priority_str}{task.description} {projects} {contexts}")
if len(priority_tasks) > 5:
lines.append(f" ... and {len(priority_tasks) - 5} more priority tasks")
lines.append("")
# Due soon
due_soon = get_tasks_due_soon(3)
if due_soon:
lines.append("**⏳ Due Soon:**")
for task in due_soon[:3]: # Top 3
due_date = task.due_date.strftime("%Y-%m-%d") if task.due_date else "?"
days_until = (task.due_date - datetime.now()).days if task.due_date else 0
if days_until == 0:
urgency = "📍 **TODAY**"
elif days_until == 1:
urgency = "⚠️ Tomorrow"
else:
urgency = f"📅 {days_until} days"
lines.append(f"{task.description} - {urgency} (`{due_date}`)")
lines.append("")
# Waiting items
waiting = get_waiting_items()
if waiting:
lines.append("**⏸️ Waiting On:**")
for item in waiting[:3]: # Top 3
lines.append(f"{item}")
if len(waiting) > 3:
lines.append(f" ... and {len(waiting) - 3} more items")
lines.append("")
lines.append("Have a productive day! 🚀")
return "\n".join(lines)
def generate_evening_summary() -> str:
"""Generate evening summary message.
Returns:
Formatted summary message
"""
date_str = get_todays_date()
lines = [
f"**🌆 Evening Summary - {date_str}**\n",
]
# Completed today
completed = get_completed_today()
if completed:
lines.append(f"**✅ Completed Today ({len(completed)} tasks):**")
for task in completed[:5]: # Top 5
projects = " ".join([f"**+{p}**" for p in task.projects])
lines.append(f"{task.description} {projects}")
if len(completed) > 5:
lines.append(f" ... and {len(completed) - 5} more tasks")
lines.append("")
else:
lines.append("No tasks marked as complete today\n")
# Tomorrow's events
tomorrow_events = get_upcoming_events(1)
tomorrow_date = (datetime.now() + timedelta(days=1)).date()
tomorrow_events = [e for e in tomorrow_events if e.date.date() == tomorrow_date]
if tomorrow_events:
lines.append("**📅 Tomorrow's Schedule:**")
for event in tomorrow_events:
time_str = event.time.strftime("%H:%M") if event.time else "All day"
lines.append(f" • `{time_str}` {event.description}")
lines.append("")
else:
lines.append("📅 No events scheduled for tomorrow\n")
# Tasks to prepare
due_tomorrow = get_tasks_due_soon(1)
tomorrow_due = [t for t in due_tomorrow if t.due_date and t.due_date.date() == tomorrow_date]
if tomorrow_due:
lines.append("**📋 Tasks Due Tomorrow:**")
for task in tomorrow_due:
priority_str = f"({task.priority}) " if task.priority else ""
lines.append(f"{priority_str}{task.description}")
lines.append("")
# Priority tasks for tomorrow
priority_tasks = get_priority_tasks(["A"])
if priority_tasks:
lines.append("**⭐ Top Priorities for Tomorrow:**")
for task in priority_tasks[:3]:
projects = " ".join([f"**+{p}**" for p in task.projects])
lines.append(f"{task.description} {projects}")
lines.append("")
lines.append("**💭 Reflection Prompts:**")
lines.append(" • What went well today?")
lines.append(" • What could be improved?")
lines.append(" • Any blockers or concerns?")
lines.append("")
lines.append("Rest well! 😴")
return "\n".join(lines)
def check_deadlines() -> Dict[str, List[Task]]:
"""Check for upcoming deadlines.
Returns:
Dictionary with deadline categories
"""
try:
content = read_file("todo.txt")
tasks = TodoParser.parse_file(content)
active_tasks = [t for t in tasks if not t.completed and t.due_date]
now = datetime.now()
categories = {
"overdue": [],
"today": [],
"tomorrow": [],
"week": [],
}
for task in active_tasks:
if not task.due_date:
continue
days_until = (task.due_date - now).days
if days_until < 0:
categories["overdue"].append(task)
elif days_until == 0:
categories["today"].append(task)
elif days_until == 1:
categories["tomorrow"].append(task)
elif days_until <= 7:
categories["week"].append(task)
return categories
except FileNotFoundError:
return {"overdue": [], "today": [], "tomorrow": [], "week": []}
def generate_deadline_warnings() -> str:
"""Generate deadline warning message.
Returns:
Formatted warning message (empty string if no warnings)
"""
deadlines = check_deadlines()
# Only generate message if there are warnings
if not any(deadlines.values()):
return ""
lines = ["**⏰ Deadline Warnings**\n"]
# Overdue
if deadlines["overdue"]:
lines.append(f"**🔴 OVERDUE ({len(deadlines['overdue'])}):**")
for task in deadlines["overdue"][:5]:
due_date = task.due_date.strftime("%Y-%m-%d") if task.due_date else "?"
days_overdue = (datetime.now() - task.due_date).days if task.due_date else 0
priority_str = f"({task.priority}) " if task.priority else ""
lines.append(f"{priority_str}{task.description} - {days_overdue} days overdue (`{due_date}`)")
lines.append("")
# Today
if deadlines["today"]:
lines.append(f"**📍 DUE TODAY ({len(deadlines['today'])}):**")
for task in deadlines["today"]:
priority_str = f"({task.priority}) " if task.priority else ""
lines.append(f"{priority_str}{task.description}")
lines.append("")
# Tomorrow
if deadlines["tomorrow"]:
lines.append(f"**⚠️ DUE TOMORROW ({len(deadlines['tomorrow'])}):**")
for task in deadlines["tomorrow"]:
priority_str = f"({task.priority}) " if task.priority else ""
lines.append(f"{priority_str}{task.description}")
lines.append("")
# This week
if deadlines["week"]:
lines.append(f"**📅 Due This Week ({len(deadlines['week'])}):**")
for task in deadlines["week"][:3]:
due_date = task.due_date.strftime("%Y-%m-%d") if task.due_date else "?"
days_until = (task.due_date - datetime.now()).days if task.due_date else 0
lines.append(f"{task.description} - {days_until} days (`{due_date}`)")
if len(deadlines["week"]) > 3:
lines.append(f" ... and {len(deadlines['week']) - 3} more")
lines.append("")
return "\n".join(lines)

171
src/scheduler/jobs.py Normal file
View File

@@ -0,0 +1,171 @@
"""Scheduled job definitions and runners."""
import asyncio
import discord
from typing import Optional
from src.config import settings
from src.scheduler.briefings import (
generate_morning_briefing,
generate_evening_summary,
generate_deadline_warnings,
)
async def send_discord_message(message: str, channel_id: Optional[str] = None) -> None:
"""Send a message to Discord channel.
Args:
message: Message to send
channel_id: Optional channel ID (uses default from settings if not provided)
"""
if not channel_id:
channel_id = settings.discord_channel_id
# Create Discord client
intents = discord.Intents.default()
client = discord.Client(intents=intents)
@client.event
async def on_ready() -> None:
"""Send message when client is ready."""
try:
channel = client.get_channel(int(channel_id))
if not channel:
print(f"❌ Channel {channel_id} not found")
await client.close()
return
# Split message if too long
if len(message) <= 2000:
await channel.send(message)
else:
# Split into chunks
chunks = [message[i:i+1900] for i in range(0, len(message), 1900)]
for chunk in chunks:
await channel.send(chunk)
print(f"✅ Message sent to channel {channel_id}")
except Exception as e:
print(f"❌ Error sending message: {e}")
finally:
await client.close()
# Run client
await client.start(settings.discord_bot_token)
def run_morning_briefing() -> None:
"""Run morning briefing job."""
print("🌅 Generating morning briefing...")
try:
briefing = generate_morning_briefing()
# Send to Discord
asyncio.run(send_discord_message(briefing))
print("✅ Morning briefing sent")
except Exception as e:
print(f"❌ Error generating morning briefing: {e}")
def run_evening_summary() -> None:
"""Run evening summary job."""
print("🌆 Generating evening summary...")
try:
summary = generate_evening_summary()
# Send to Discord
asyncio.run(send_discord_message(summary))
print("✅ Evening summary sent")
except Exception as e:
print(f"❌ Error generating evening summary: {e}")
def run_deadline_checker() -> None:
"""Run deadline checker job."""
print("⏰ Checking deadlines...")
try:
warnings = generate_deadline_warnings()
# Only send if there are warnings
if warnings:
asyncio.run(send_discord_message(warnings))
print("✅ Deadline warnings sent")
else:
print("✅ No deadline warnings needed")
except Exception as e:
print(f"❌ Error checking deadlines: {e}")
def run_git_sync() -> None:
"""Run git sync job."""
print("🔄 Syncing git repository...")
try:
from src.tools.git_ops import git_pull, git_push
# Pull latest changes
pull_result = git_pull()
print(f"Pull: {pull_result}")
# Push any local commits
push_result = git_push()
print(f"Push: {push_result}")
print("✅ Git sync complete")
except Exception as e:
print(f"❌ Error syncing git: {e}")
def run_waiting_followup() -> None:
"""Run waiting list follow-up job."""
print("⏸️ Checking waiting list...")
try:
from src.scheduler.briefings import get_waiting_items
waiting = get_waiting_items()
if waiting:
message = f"**⏸️ Waiting List Follow-up**\n\n"
message += f"You have {len(waiting)} item(s) in your waiting list:\n\n"
for item in waiting:
message += f"{item}\n"
message += "\nAny of these ready to move forward?"
asyncio.run(send_discord_message(message))
print("✅ Waiting list follow-up sent")
else:
print("✅ No items in waiting list")
except Exception as e:
print(f"❌ Error checking waiting list: {e}")
# Job registry for easy lookup
JOBS = {
"morning-briefing": run_morning_briefing,
"evening-summary": run_evening_summary,
"deadline-checker": run_deadline_checker,
"git-sync": run_git_sync,
"waiting-followup": run_waiting_followup,
}
def run_job(job_name: str) -> None:
"""Run a scheduled job by name.
Args:
job_name: Name of the job to run
"""
if job_name not in JOBS:
print(f"❌ Unknown job: {job_name}")
print(f"Available jobs: {', '.join(JOBS.keys())}")
return
print(f"▶️ Running job: {job_name}")
JOBS[job_name]()

0
src/tools/__init__.py Normal file
View File

226
src/tools/calendar_ops.py Normal file
View File

@@ -0,0 +1,226 @@
"""Calendar operation tools for the agent."""
from datetime import datetime, timedelta
from typing import List, Optional
from src.parsers.calendar_parser import CalendarParser, Event
from src.tools.file_ops import read_file, write_file
from src.agent.core import AgentTool
def get_calendar_events(
start_date: Optional[str] = None,
end_date: Optional[str] = None,
) -> str:
"""Get calendar events within a date range.
Args:
start_date: Start date in YYYY-MM-DD format (defaults to today)
end_date: End date in YYYY-MM-DD format (defaults to 7 days from start)
Returns:
Formatted list of events
"""
try:
content = read_file("calendar.txt")
events = CalendarParser.parse_file(content)
# Parse dates
if start_date:
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
else:
start_dt = datetime.now()
if end_date:
end_dt = datetime.strptime(end_date, "%Y-%m-%d")
# Set to end of day
end_dt = end_dt.replace(hour=23, minute=59, second=59)
else:
end_dt = start_dt + timedelta(days=7)
# Filter events
filtered = [e for e in events if start_dt <= e.datetime <= end_dt]
if not filtered:
return f"No events found between {start_dt.strftime('%Y-%m-%d')} and {end_dt.strftime('%Y-%m-%d')}"
# Format results
result_lines = [f"Found {len(filtered)} event(s):\n"]
current_date = None
for event in filtered:
event_date = event.date.strftime("%Y-%m-%d")
# Add date header if changed
if event_date != current_date:
result_lines.append(f"\n**{event_date}:**")
current_date = event_date
# Format time
if event.time:
time_str = event.time.strftime("%H:%M")
else:
time_str = "All day"
# Format contexts and projects
contexts_str = " ".join([f"@{c}" for c in event.contexts])
projects_str = " ".join([f"+{p}" for p in event.projects])
result_lines.append(
f"{time_str} - {event.description} {contexts_str} {projects_str}"
)
return "\n".join(result_lines)
except FileNotFoundError:
return "calendar.txt not found"
except Exception as e:
return f"Error reading calendar: {str(e)}"
def get_today_events() -> str:
"""Get today's calendar events.
Returns:
Formatted list of today's events
"""
today = datetime.now().strftime("%Y-%m-%d")
return get_calendar_events(start_date=today, end_date=today)
def add_calendar_event(
date: str,
description: str,
time: Optional[str] = None,
context: Optional[str] = None,
project: Optional[str] = None,
) -> str:
"""Add a new event to calendar.txt.
Args:
date: Event date in YYYY-MM-DD format
description: Event description
time: Event time in HH:MM format (None for all-day)
context: Context tag (without @ prefix)
project: Project tag (without + prefix)
Returns:
Success message
"""
try:
# Read current calendar
try:
content = read_file("calendar.txt")
except FileNotFoundError:
content = "# Calendar\n\n"
# Parse date
event_date = datetime.strptime(date, "%Y-%m-%d")
# Parse time if provided
event_time = None
if time:
try:
event_time = datetime.strptime(time, "%H:%M").time()
except ValueError:
return f"Error: Invalid time format. Use HH:MM (e.g., 14:30)"
# Format event
contexts = [context] if context else None
projects = [project] if project else None
formatted_event = CalendarParser.format_event(
date=event_date,
description=description,
time=event_time,
contexts=contexts,
projects=projects,
)
# Append to file
new_content = content.rstrip() + "\n" + formatted_event + "\n"
write_file("calendar.txt", new_content)
return f"✅ Added event to calendar.txt:\n{formatted_event}"
except ValueError as e:
return f"Error: Invalid date format. Use YYYY-MM-DD (e.g., 2026-02-15)"
except Exception as e:
return f"Error adding event: {str(e)}"
# Create AgentTool instances
GET_CALENDAR_EVENTS_TOOL = AgentTool(
name="get_calendar_events",
description="Get calendar events within a date range. Use this to check what's scheduled.",
input_schema={
"type": "object",
"properties": {
"start_date": {
"type": "string",
"description": "Start date in YYYY-MM-DD format (defaults to today)",
},
"end_date": {
"type": "string",
"description": "End date in YYYY-MM-DD format (defaults to 7 days from start)",
}
},
"required": [],
},
function=get_calendar_events,
)
GET_TODAY_EVENTS_TOOL = AgentTool(
name="get_today_events",
description="Get today's calendar events. Quick way to see what's scheduled for today.",
input_schema={
"type": "object",
"properties": {},
"required": [],
},
function=get_today_events,
)
ADD_CALENDAR_EVENT_TOOL = AgentTool(
name="add_calendar_event",
description="Add a new event to the calendar with optional time, context, and project.",
input_schema={
"type": "object",
"properties": {
"date": {
"type": "string",
"description": "Event date in YYYY-MM-DD format",
},
"description": {
"type": "string",
"description": "Event description",
},
"time": {
"type": "string",
"description": "Event time in HH:MM format (24-hour). Omit for all-day events.",
},
"context": {
"type": "string",
"description": "Context tag without @ prefix (e.g., 'telefon', 'personal')",
},
"project": {
"type": "string",
"description": "Project tag without + prefix",
}
},
"required": ["date", "description"],
},
function=add_calendar_event,
)
def get_calendar_tools() -> List[AgentTool]:
"""Get all calendar operation tools.
Returns:
List of calendar tools
"""
return [
GET_CALENDAR_EVENTS_TOOL,
GET_TODAY_EVENTS_TOOL,
ADD_CALENDAR_EVENT_TOOL,
]

248
src/tools/file_ops.py Normal file
View File

@@ -0,0 +1,248 @@
"""File operation tools for the agent."""
import os
from pathlib import Path
from typing import List, Dict, Any
from src.config import settings
from src.agent.core import AgentTool
def _validate_path(path: str) -> Path:
"""Validate that a path is within the myorg repository.
Args:
path: Relative or absolute path
Returns:
Validated absolute Path object
Raises:
ValueError: If path is outside myorg repository
"""
repo_path = Path(settings.myorg_repo_path).resolve()
# Convert to Path and resolve
if os.path.isabs(path):
full_path = Path(path).resolve()
else:
full_path = (repo_path / path).resolve()
# Check if path is within repository
try:
full_path.relative_to(repo_path)
except ValueError:
raise ValueError(f"Path {path} is outside myorg repository")
return full_path
def read_file(path: str) -> str:
"""Read a file from the myorg repository.
Args:
path: Relative path to the file (e.g., "todo.txt", "goals/q1-2026.md")
Returns:
File contents as string
Raises:
FileNotFoundError: If file doesn't exist
ValueError: If path is invalid
"""
file_path = _validate_path(path)
if not file_path.exists():
raise FileNotFoundError(f"File not found: {path}")
if not file_path.is_file():
raise ValueError(f"Path is not a file: {path}")
with open(file_path, 'r', encoding='utf-8') as f:
return f.read()
def write_file(path: str, content: str) -> str:
"""Write content to a file in the myorg repository.
This will overwrite the file if it exists.
Args:
path: Relative path to the file
content: Content to write
Returns:
Success message
Raises:
ValueError: If path is invalid
"""
file_path = _validate_path(path)
# Create parent directories if needed
file_path.parent.mkdir(parents=True, exist_ok=True)
# Backup existing file
if file_path.exists():
backup_path = file_path.with_suffix(file_path.suffix + '.backup')
with open(file_path, 'r', encoding='utf-8') as f:
backup_content = f.read()
with open(backup_path, 'w', encoding='utf-8') as f:
f.write(backup_content)
# Write new content
with open(file_path, 'w', encoding='utf-8') as f:
f.write(content)
return f"Successfully wrote {len(content)} characters to {path}"
def append_to_file(path: str, content: str) -> str:
"""Append content to a file in the myorg repository.
Args:
path: Relative path to the file
content: Content to append
Returns:
Success message
Raises:
ValueError: If path is invalid
FileNotFoundError: If file doesn't exist
"""
file_path = _validate_path(path)
if not file_path.exists():
raise FileNotFoundError(f"File not found: {path}. Use write_file to create it.")
with open(file_path, 'a', encoding='utf-8') as f:
f.write(content)
return f"Successfully appended {len(content)} characters to {path}"
def list_files(directory: str = "") -> str:
"""List files in a directory of the myorg repository.
Args:
directory: Relative path to directory (empty string for root)
Returns:
Formatted list of files and directories
Raises:
ValueError: If path is invalid
FileNotFoundError: If directory doesn't exist
"""
dir_path = _validate_path(directory if directory else ".")
if not dir_path.exists():
raise FileNotFoundError(f"Directory not found: {directory}")
if not dir_path.is_dir():
raise ValueError(f"Path is not a directory: {directory}")
items = []
for item in sorted(dir_path.iterdir()):
if item.name.startswith('.'):
continue # Skip hidden files
if item.is_dir():
items.append(f"📁 {item.name}/")
else:
size = item.stat().st_size
items.append(f"📄 {item.name} ({size} bytes)")
if not items:
return f"Directory {directory or 'root'} is empty"
return f"Contents of {directory or 'root'}:\n" + "\n".join(items)
# Create AgentTool instances for each function
READ_FILE_TOOL = AgentTool(
name="read_file",
description="Read a file from the myorg repository. Use this to check current content before making changes.",
input_schema={
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Relative path to the file (e.g., 'todo.txt', 'goals/q1-2026.md')",
}
},
"required": ["path"],
},
function=read_file,
)
WRITE_FILE_TOOL = AgentTool(
name="write_file",
description="Write content to a file in the myorg repository. This overwrites the file if it exists. Always read the file first to check current content.",
input_schema={
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Relative path to the file",
},
"content": {
"type": "string",
"description": "Content to write to the file",
}
},
"required": ["path", "content"],
},
function=write_file,
)
APPEND_TO_FILE_TOOL = AgentTool(
name="append_to_file",
description="Append content to an existing file in the myorg repository. Use this for adding entries to logs or working memory.",
input_schema={
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Relative path to the file",
},
"content": {
"type": "string",
"description": "Content to append",
}
},
"required": ["path", "content"],
},
function=append_to_file,
)
LIST_FILES_TOOL = AgentTool(
name="list_files",
description="List files and directories in the myorg repository. Use this to explore the repository structure.",
input_schema={
"type": "object",
"properties": {
"directory": {
"type": "string",
"description": "Relative path to directory (empty string for root)",
"default": "",
}
},
"required": [],
},
function=list_files,
)
def get_file_operation_tools() -> List[AgentTool]:
"""Get all file operation tools.
Returns:
List of file operation tools
"""
return [
READ_FILE_TOOL,
WRITE_FILE_TOOL,
APPEND_TO_FILE_TOOL,
LIST_FILES_TOOL,
]

270
src/tools/git_ops.py Normal file
View File

@@ -0,0 +1,270 @@
"""Git operation tools for the agent."""
from typing import List
from pathlib import Path
from git import Repo, GitCommandError
from src.config import settings
from src.agent.core import AgentTool
def _get_repo() -> Repo:
"""Get the git repository instance.
Returns:
Git Repo object
Raises:
ValueError: If repository doesn't exist
"""
repo_path = Path(settings.myorg_repo_path)
if not repo_path.exists():
raise ValueError(f"Repository path doesn't exist: {repo_path}")
try:
return Repo(repo_path)
except Exception as e:
raise ValueError(f"Not a git repository: {repo_path}. Error: {e}")
def git_status() -> str:
"""Check the status of the git repository.
Returns:
Human-readable status message
"""
try:
repo = _get_repo()
# Check for changes
changed_files = [item.a_path for item in repo.index.diff(None)]
staged_files = [item.a_path for item in repo.index.diff("HEAD")]
untracked_files = repo.untracked_files
status_lines = ["📊 Git Status:\n"]
if not (changed_files or staged_files or untracked_files):
status_lines.append("✅ Working directory clean - no changes")
else:
if staged_files:
status_lines.append(f"📝 Staged changes ({len(staged_files)} files):")
for file in staged_files:
status_lines.append(f" - {file}")
if changed_files:
status_lines.append(f"\n⚠️ Unstaged changes ({len(changed_files)} files):")
for file in changed_files:
status_lines.append(f" - {file}")
if untracked_files:
status_lines.append(f"\n❓ Untracked files ({len(untracked_files)} files):")
for file in untracked_files:
status_lines.append(f" - {file}")
# Check branch and remote status
try:
branch = repo.active_branch.name
status_lines.append(f"\n🌿 Current branch: {branch}")
# Check if ahead/behind remote
try:
tracking_branch = repo.active_branch.tracking_branch()
if tracking_branch:
ahead = len(list(repo.iter_commits(f'{tracking_branch}..{branch}')))
behind = len(list(repo.iter_commits(f'{branch}..{tracking_branch}')))
if ahead > 0:
status_lines.append(f"⬆️ Ahead of remote by {ahead} commit(s)")
if behind > 0:
status_lines.append(f"⬇️ Behind remote by {behind} commit(s)")
if ahead == 0 and behind == 0:
status_lines.append("✅ Up to date with remote")
except Exception:
pass # No tracking branch
except Exception:
status_lines.append("\n⚠️ Not on any branch (detached HEAD)")
return "\n".join(status_lines)
except Exception as e:
return f"Error checking git status: {str(e)}"
def git_commit(message: str) -> str:
"""Commit changes to the git repository.
This will stage all changes and create a commit.
Args:
message: Commit message
Returns:
Success message with commit hash
"""
try:
repo = _get_repo()
# Check if there are any changes
if not repo.is_dirty(untracked_files=True):
return "Nothing to commit - working directory clean"
# Stage all changes
repo.git.add(A=True)
# Commit
commit = repo.index.commit(message)
# Count files changed
stats = commit.stats.total
files_changed = stats['files']
insertions = stats['insertions']
deletions = stats['deletions']
return (
f"✅ Committed changes:\n"
f"Commit: {commit.hexsha[:7]}\n"
f"Message: {message}\n"
f"Files: {files_changed} changed, {insertions} insertions(+), {deletions} deletions(-)"
)
except Exception as e:
return f"Error committing changes: {str(e)}"
def git_pull() -> str:
"""Pull changes from the remote repository.
Returns:
Success message
"""
try:
repo = _get_repo()
# Check if working directory is clean
if repo.is_dirty(untracked_files=True):
return (
"⚠️ Cannot pull: working directory has uncommitted changes.\n"
"Please commit or stash your changes first."
)
# Pull from remote
origin = repo.remote('origin')
pull_info = origin.pull()
if not pull_info:
return "✅ Already up to date"
info = pull_info[0]
if info.flags & info.HEAD_UPTODATE:
return "✅ Already up to date"
return f"✅ Pulled changes successfully\n{info.note}"
except GitCommandError as e:
return f"Error pulling changes: {str(e)}"
except Exception as e:
return f"Error: {str(e)}"
def git_push() -> str:
"""Push commits to the remote repository.
Returns:
Success message
"""
try:
repo = _get_repo()
# Check if there are unpushed commits
try:
branch = repo.active_branch.name
tracking_branch = repo.active_branch.tracking_branch()
if tracking_branch:
ahead = len(list(repo.iter_commits(f'{tracking_branch}..{branch}')))
if ahead == 0:
return "Nothing to push - already up to date"
except Exception:
pass # Continue with push anyway
# Push to remote
origin = repo.remote('origin')
push_info = origin.push()
if not push_info:
return "✅ Push completed"
info = push_info[0]
if info.flags & info.ERROR:
return f"❌ Error pushing: {info.summary}"
return f"✅ Pushed commits successfully\n{info.summary}"
except GitCommandError as e:
return f"Error pushing changes: {str(e)}"
except Exception as e:
return f"Error: {str(e)}"
# Create AgentTool instances
GIT_STATUS_TOOL = AgentTool(
name="git_status",
description="Check the status of the git repository. Shows changed files, staged changes, and branch status.",
input_schema={
"type": "object",
"properties": {},
"required": [],
},
function=git_status,
)
GIT_COMMIT_TOOL = AgentTool(
name="git_commit",
description="Commit all changes to the git repository with a descriptive message. This stages and commits all modified files.",
input_schema={
"type": "object",
"properties": {
"message": {
"type": "string",
"description": "Commit message describing the changes",
}
},
"required": ["message"],
},
function=git_commit,
)
GIT_PULL_TOOL = AgentTool(
name="git_pull",
description="Pull latest changes from the remote repository. Use this to sync with remote before making local changes.",
input_schema={
"type": "object",
"properties": {},
"required": [],
},
function=git_pull,
)
GIT_PUSH_TOOL = AgentTool(
name="git_push",
description="Push local commits to the remote repository. Use this after committing changes to sync with remote.",
input_schema={
"type": "object",
"properties": {},
"required": [],
},
function=git_push,
)
def get_git_tools() -> List[AgentTool]:
"""Get all git operation tools.
Returns:
List of git tools
"""
return [
GIT_STATUS_TOOL,
GIT_COMMIT_TOOL,
GIT_PULL_TOOL,
GIT_PUSH_TOOL,
]

346
src/tools/task_ops.py Normal file
View File

@@ -0,0 +1,346 @@
"""Task management tools for the agent."""
from datetime import datetime
from typing import List, Optional, Dict, Any
from src.parsers.todo_parser import TodoParser, Task
from src.tools.file_ops import read_file, write_file
from src.agent.core import AgentTool
def add_task(
description: str,
project: Optional[str] = None,
context: Optional[str] = None,
priority: Optional[str] = None,
due_date: Optional[str] = None,
) -> str:
"""Add a new task to todo.txt.
Args:
description: Task description
project: Project tag (without + prefix)
context: Context tag (without @ prefix)
priority: Priority letter (A-Z)
due_date: Due date in YYYY-MM-DD format
Returns:
Success message with the formatted task
"""
# Read current todo.txt
try:
content = read_file("todo.txt")
except FileNotFoundError:
content = "# Todo List\n\n"
# Format the new task
projects = [project] if project else None
contexts = [context] if context else None
metadata = {"due": due_date} if due_date else None
# Parse due date if provided
due_datetime = None
if due_date:
try:
due_datetime = datetime.strptime(due_date, "%Y-%m-%d")
except ValueError:
return f"Error: Invalid due date format. Use YYYY-MM-DD"
formatted_task = TodoParser.format_task(
description=description,
priority=priority,
creation_date=datetime.now(),
projects=projects,
contexts=contexts,
metadata=metadata,
)
# Append to file
new_content = content.rstrip() + "\n" + formatted_task + "\n"
write_file("todo.txt", new_content)
return f"✅ Added task to todo.txt:\n{formatted_task}"
def complete_task(task_description: str) -> str:
"""Mark a task as complete in todo.txt.
Finds the task by description and marks it complete with timestamp.
Args:
task_description: Description or partial description of the task
Returns:
Success message
"""
# Read current todo.txt
try:
content = read_file("todo.txt")
except FileNotFoundError:
return "Error: todo.txt not found"
# Parse tasks
tasks = TodoParser.parse_file(content)
# Find matching task
matching_tasks = [
t for t in tasks
if task_description.lower() in t.description.lower() and not t.completed
]
if not matching_tasks:
return f"Error: No active task found matching '{task_description}'"
if len(matching_tasks) > 1:
task_list = "\n".join([f"- {t.description}" for t in matching_tasks])
return f"Error: Multiple tasks match '{task_description}'. Please be more specific:\n{task_list}"
# Mark the task as complete
task = matching_tasks[0]
completed_task = TodoParser.format_task(
description=task.description,
priority=task.priority,
creation_date=task.creation_date,
projects=task.projects,
contexts=task.contexts,
metadata=task.metadata,
completed=True,
completion_date=datetime.now(),
)
# Replace in content
lines = content.split('\n')
lines[task.line_number - 1] = completed_task
new_content = '\n'.join(lines)
write_file("todo.txt", new_content)
return f"✅ Marked task as complete:\n{completed_task}"
def search_tasks(
project: Optional[str] = None,
context: Optional[str] = None,
priority: Optional[str] = None,
completed: Optional[bool] = None,
has_due_date: Optional[bool] = None,
) -> str:
"""Search and filter tasks from todo.txt.
Args:
project: Filter by project tag (without + prefix)
context: Filter by context tag (without @ prefix)
priority: Filter by priority letter
completed: Filter by completion status (true/false)
has_due_date: Filter by presence of due date
Returns:
Formatted list of matching tasks
"""
# Read current todo.txt
try:
content = read_file("todo.txt")
except FileNotFoundError:
return "No tasks found (todo.txt doesn't exist)"
# Parse tasks
tasks = TodoParser.parse_file(content)
# Apply filters
filtered = TodoParser.filter_tasks(
tasks,
project=project,
context=context,
priority=priority,
completed=completed,
has_due_date=has_due_date,
)
if not filtered:
filters_desc = []
if project:
filters_desc.append(f"project={project}")
if context:
filters_desc.append(f"context={context}")
if priority:
filters_desc.append(f"priority={priority}")
if completed is not None:
filters_desc.append(f"completed={completed}")
if has_due_date is not None:
filters_desc.append(f"has_due_date={has_due_date}")
filters_str = ", ".join(filters_desc) if filters_desc else "no filters"
return f"No tasks found matching filters: {filters_str}"
# Format results
result_lines = [f"Found {len(filtered)} task(s):\n"]
for task in filtered:
status = "" if task.completed else ""
priority_str = f"({task.priority}) " if task.priority else ""
projects_str = " ".join([f"+{p}" for p in task.projects])
contexts_str = " ".join([f"@{c}" for c in task.contexts])
due_str = f" 📅 due:{task.metadata.get('due')}" if "due" in task.metadata else ""
result_lines.append(
f"{status} {priority_str}{task.description} {projects_str} {contexts_str}{due_str}"
)
return "\n".join(result_lines)
def get_tasks_by_priority() -> str:
"""Get active tasks grouped by priority.
Returns:
Formatted list of tasks by priority
"""
# Read current todo.txt
try:
content = read_file("todo.txt")
except FileNotFoundError:
return "No tasks found (todo.txt doesn't exist)"
# Parse tasks
tasks = TodoParser.parse_file(content)
active_tasks = [t for t in tasks if not t.completed]
if not active_tasks:
return "No active tasks"
# Group by priority
priority_groups: Dict[str, List[Task]] = {}
for task in active_tasks:
priority = task.priority or "None"
if priority not in priority_groups:
priority_groups[priority] = []
priority_groups[priority].append(task)
# Format results
result_lines = [f"Active tasks by priority ({len(active_tasks)} total):\n"]
# Sort priorities (A, B, C, ..., None)
sorted_priorities = sorted(
priority_groups.keys(),
key=lambda p: (p == "None", p)
)
for priority in sorted_priorities:
tasks_in_priority = priority_groups[priority]
result_lines.append(f"\n**Priority {priority}:** ({len(tasks_in_priority)} tasks)")
for task in tasks_in_priority:
projects_str = " ".join([f"+{p}" for p in task.projects])
contexts_str = " ".join([f"@{c}" for c in task.contexts])
due_str = f" 📅 {task.metadata.get('due')}" if "due" in task.metadata else ""
result_lines.append(
f" - {task.description} {projects_str} {contexts_str}{due_str}"
)
return "\n".join(result_lines)
# Create AgentTool instances
ADD_TASK_TOOL = AgentTool(
name="add_task",
description="Add a new task to todo.txt with proper formatting. The task will be added with a creation date.",
input_schema={
"type": "object",
"properties": {
"description": {
"type": "string",
"description": "Task description (the main text)",
},
"project": {
"type": "string",
"description": "Project tag without + prefix (e.g., 'myorg-assistant', 'blog-post')",
},
"context": {
"type": "string",
"description": "Context tag without @ prefix (e.g., 'computer-deep', 'telefon', 'recados')",
},
"priority": {
"type": "string",
"description": "Priority letter A-Z (A=highest)",
},
"due_date": {
"type": "string",
"description": "Due date in YYYY-MM-DD format",
}
},
"required": ["description"],
},
function=add_task,
)
COMPLETE_TASK_TOOL = AgentTool(
name="complete_task",
description="Mark a task as complete in todo.txt. Finds the task by description and adds completion timestamp.",
input_schema={
"type": "object",
"properties": {
"task_description": {
"type": "string",
"description": "Description or partial description of the task to complete",
}
},
"required": ["task_description"],
},
function=complete_task,
)
SEARCH_TASKS_TOOL = AgentTool(
name="search_tasks",
description="Search and filter tasks from todo.txt by various criteria. Returns matching tasks with their details.",
input_schema={
"type": "object",
"properties": {
"project": {
"type": "string",
"description": "Filter by project tag without + prefix",
},
"context": {
"type": "string",
"description": "Filter by context tag without @ prefix",
},
"priority": {
"type": "string",
"description": "Filter by priority letter (A-Z)",
},
"completed": {
"type": "boolean",
"description": "Filter by completion status (true for completed, false for active)",
},
"has_due_date": {
"type": "boolean",
"description": "Filter by presence of due date",
}
},
"required": [],
},
function=search_tasks,
)
GET_TASKS_BY_PRIORITY_TOOL = AgentTool(
name="get_tasks_by_priority",
description="Get all active tasks grouped by priority. Useful for daily planning and seeing what's most important.",
input_schema={
"type": "object",
"properties": {},
"required": [],
},
function=get_tasks_by_priority,
)
def get_task_management_tools() -> List[AgentTool]:
"""Get all task management tools.
Returns:
List of task management tools
"""
return [
ADD_TASK_TOOL,
COMPLETE_TASK_TOOL,
SEARCH_TASKS_TOOL,
GET_TASKS_BY_PRIORITY_TOOL,
]

0
src/utils/__init__.py Normal file
View File

170
src/utils/context.py Normal file
View File

@@ -0,0 +1,170 @@
"""Context inference utilities."""
from datetime import datetime, time
from typing import List, Optional
from src.parsers.calendar_parser import Event
def infer_context_from_time(current_time: Optional[datetime] = None) -> List[str]:
"""Infer likely contexts based on time of day.
Args:
current_time: Time to check (defaults to now)
Returns:
List of likely contexts
"""
if current_time is None:
current_time = datetime.now()
hour = current_time.hour
day_of_week = current_time.weekday() # 0=Monday, 6=Sunday
contexts = []
# Work hours (Monday-Friday, 9 AM - 6 PM)
if day_of_week < 5 and 9 <= hour < 18:
contexts.append("work")
contexts.append("computer-deep")
# Evening (6 PM - 11 PM)
elif 18 <= hour < 23:
contexts.append("personal")
contexts.append("computer-light")
# Early morning (6 AM - 9 AM)
elif 6 <= hour < 9:
contexts.append("personal")
contexts.append("computer-light")
# Weekend
if day_of_week >= 5: # Saturday or Sunday
contexts.append("personal")
contexts.append("bcn") # Likely at home location
# Lunch time (12 PM - 2 PM)
if 12 <= hour < 14:
contexts.append("recados") # Errands during lunch
return contexts
def infer_context_from_calendar(events: List[Event]) -> List[str]:
"""Infer contexts from current/upcoming calendar events.
Args:
events: List of events to analyze
Returns:
List of inferred contexts
"""
if not events:
return []
contexts = []
# Look at contexts in events
for event in events:
contexts.extend(event.contexts)
# Deduplicate
return list(set(contexts))
def infer_current_context(events: Optional[List[Event]] = None) -> List[str]:
"""Infer current context from time and calendar.
Args:
events: Optional list of current/upcoming events
Returns:
List of likely contexts (ordered by relevance)
"""
# Start with time-based inference
contexts = infer_context_from_time()
# Add calendar-based inference
if events:
calendar_contexts = infer_context_from_calendar(events)
# Calendar contexts have higher priority
contexts = calendar_contexts + [c for c in contexts if c not in calendar_contexts]
return contexts
def suggest_tasks_for_context(
contexts: List[str],
time_available: Optional[int] = None,
energy_level: Optional[str] = None,
) -> str:
"""Generate suggestions for task selection based on context.
Args:
contexts: Current contexts
time_available: Minutes available (optional)
energy_level: "high", "medium", "low" (optional)
Returns:
Suggestion text
"""
suggestions = []
# Context-based suggestions
if "computer-deep" in contexts:
suggestions.append("Focus on deep work tasks requiring concentration")
suggestions.append("Good time for coding, writing, or complex problem-solving")
if "computer-light" in contexts:
suggestions.append("Handle quick tasks: emails, reviews, light admin")
suggestions.append("Good for planning and organizing")
if "telefon" in contexts:
suggestions.append("Make those phone calls or join video meetings")
suggestions.append("Good for collaboration and communication")
if "recados" in contexts:
suggestions.append("Run errands, shopping, appointments")
suggestions.append("Handle location-specific tasks")
if "personal" in contexts:
suggestions.append("Personal tasks and family time")
suggestions.append("Home maintenance and personal projects")
# Time-based suggestions
if time_available:
if time_available < 15:
suggestions.append(f"You have {time_available} minutes - focus on quick wins")
elif time_available < 60:
suggestions.append(f"You have {time_available} minutes - good for medium-sized tasks")
else:
hours = time_available / 60
suggestions.append(f"You have {hours:.1f} hours - tackle larger projects")
# Energy-based suggestions
if energy_level:
if energy_level == "high":
suggestions.append("High energy - tackle your most challenging tasks")
elif energy_level == "medium":
suggestions.append("Medium energy - good balance of work and admin")
elif energy_level == "low":
suggestions.append("Low energy - focus on easier, routine tasks")
if not suggestions:
suggestions.append("Review your priority tasks and choose what fits best")
return "\n".join(f"{s}" for s in suggestions)
def format_context_info(contexts: List[str]) -> str:
"""Format context information for display.
Args:
contexts: List of contexts
Returns:
Formatted context string
"""
if not contexts:
return "No specific context detected"
context_str = " ".join([f"*@{c}*" for c in contexts])
return f"Current context: {context_str}"

0
src/web/__init__.py Normal file
View File

View File

View File

View File

@@ -0,0 +1,299 @@
/* MyOrg Assistant Styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--primary: #2563eb;
--secondary: #64748b;
--success: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
--bg: #f8fafc;
--surface: #ffffff;
--text: #1e293b;
--text-muted: #64748b;
--border: #e2e8f0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
}
/* Navigation */
.navbar {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.nav-brand h1 {
font-size: 1.5rem;
color: var(--primary);
}
.nav-links {
display: flex;
gap: 1.5rem;
}
.nav-links a {
text-decoration: none;
color: var(--text-muted);
font-weight: 500;
transition: color 0.2s;
}
.nav-links a:hover,
.nav-links a.active {
color: var(--primary);
}
/* Container */
.container {
max-width: 1200px;
margin: 2rem auto;
padding: 0 2rem;
}
/* Dashboard */
.dashboard-header {
margin-bottom: 2rem;
}
.date {
color: var(--text-muted);
font-size: 0.95rem;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
background: var(--surface);
padding: 1.5rem;
border-radius: 8px;
border: 1px solid var(--border);
}
.stat-value {
font-size: 2rem;
font-weight: bold;
color: var(--primary);
}
.stat-label {
color: var(--text-muted);
font-size: 0.9rem;
margin-top: 0.5rem;
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
}
.dashboard-section {
background: var(--surface);
padding: 1.5rem;
border-radius: 8px;
border: 1px solid var(--border);
}
.dashboard-section h3 {
margin-bottom: 1rem;
color: var(--text);
}
.empty-state {
color: var(--text-muted);
font-style: italic;
}
/* Task Items */
.task-list, .event-list, .project-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.task-item, .event-item, .project-item {
padding: 0.75rem;
background: var(--bg);
border-radius: 4px;
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.priority-badge {
background: var(--warning);
color: white;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.85rem;
font-weight: bold;
}
.priority-A { background: var(--danger); }
.priority-B { background: var(--warning); }
.priority-C { background: var(--secondary); }
.tag {
background: var(--primary);
color: white;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.85rem;
}
.tag.context {
background: var(--success);
}
.tag.project {
background: var(--primary);
}
.due-date {
color: var(--warning);
font-size: 0.9rem;
margin-left: auto;
}
.event-time {
font-weight: 600;
color: var(--primary);
min-width: 60px;
}
.project-tag {
font-weight: 600;
color: var(--primary);
}
/* Chat */
.chat-container {
max-width: 800px;
margin: 0 auto;
}
.chat-messages {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
height: 500px;
overflow-y: auto;
margin-bottom: 1rem;
}
.message {
margin-bottom: 1rem;
padding: 0.75rem;
border-radius: 4px;
}
.message.assistant {
background: var(--bg);
}
.message.user {
background: var(--primary);
color: white;
margin-left: 20%;
}
.chat-form {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.chat-form input {
flex: 1;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: 4px;
font-size: 1rem;
}
.chat-form button {
padding: 0.75rem 1.5rem;
background: var(--primary);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
}
.chat-form button:hover {
opacity: 0.9;
}
/* Buttons */
.btn-link {
color: var(--primary);
text-decoration: none;
font-size: 0.9rem;
margin-top: 0.5rem;
display: inline-block;
}
.btn-secondary {
background: var(--secondary);
color: white;
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
}
.btn-secondary:hover {
opacity: 0.9;
}
/* Footer */
footer {
text-align: center;
padding: 2rem;
color: var(--text-muted);
font-size: 0.9rem;
}
/* Responsive */
@media (max-width: 768px) {
.navbar {
flex-direction: column;
gap: 1rem;
}
.nav-links {
flex-wrap: wrap;
justify-content: center;
}
.dashboard-grid {
grid-template-columns: 1fr;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}

View File

View File

View File

@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}MyOrg Assistant{% endblock %}</title>
<link rel="stylesheet" href="/static/css/style.css">
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
{% block extra_head %}{% endblock %}
</head>
<body>
<nav class="navbar">
<div class="nav-brand">
<h1>🤖 MyOrg Assistant</h1>
</div>
<div class="nav-links">
<a href="/dashboard" class="{% if page == 'dashboard' %}active{% endif %}">📊 Dashboard</a>
<a href="/chat" class="{% if page == 'chat' %}active{% endif %}">💬 Chat</a>
<a href="/tasks" class="{% if page == 'tasks' %}active{% endif %}">✅ Tasks</a>
<a href="/calendar" class="{% if page == 'calendar' %}active{% endif %}">📅 Calendar</a>
<a href="/projects" class="{% if page == 'projects' %}active{% endif %}">📂 Projects</a>
</div>
</nav>
<main class="container">
{% block content %}{% endblock %}
</main>
<footer>
<p>MyOrg Assistant - Powered by Claude Sonnet 4.5</p>
</footer>
{% block extra_scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,23 @@
{% extends "base.html" %}
{% block title %}Calendar - MyOrg Assistant{% endblock %}
{% block content %}
<h2>📅 Calendar</h2>
<h3>Today - {{ today }}</h3>
<div class="event-list">
{% for event in today_events %}
<div class="event-item">
<span class="event-time">{% if event.time %}{{ event.time.strftime('%H:%M') }}{% else %}All day{% endif %}</span>
{{ event.description }}
</div>
{% endfor %}
</div>
<h3>Upcoming (Next 7 Days)</h3>
<div class="event-list">
{% for event in upcoming_events %}
<div class="event-item">
<span>{{ event.date.strftime('%Y-%m-%d') }} {% if event.time %}{{ event.time.strftime('%H:%M') }}{% endif %}</span>
{{ event.description }}
</div>
{% endfor %}
</div>
{% endblock %}

View File

@@ -0,0 +1,32 @@
{% extends "base.html" %}
{% block title %}Chat - MyOrg Assistant{% endblock %}
{% block content %}
<div class="chat-container">
<h2>💬 Chat with Assistant</h2>
<div class="chat-messages" id="messages">
<div class="message assistant">
<strong>Assistant:</strong> Hi! I'm your MyOrg assistant. Ask me anything about your tasks, calendar, or projects!
</div>
</div>
<form class="chat-form" hx-post="/api/chat" hx-target="#messages" hx-swap="beforeend" hx-on::after-request="this.reset()">
<input type="text" name="message" placeholder="Type your message..." required autofocus>
<button type="submit">Send</button>
</form>
<button hx-post="/api/chat/reset" hx-swap="none" class="btn-secondary">Clear History</button>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
// Auto-scroll to bottom when new messages arrive
htmx.on('htmx:afterSwap', function(evt) {
var messages = document.getElementById('messages');
messages.scrollTop = messages.scrollHeight;
});
</script>
{% endblock %}

View File

@@ -0,0 +1,111 @@
{% extends "base.html" %}
{% block title %}Dashboard - MyOrg Assistant{% endblock %}
{% block content %}
<div class="dashboard">
<div class="dashboard-header">
<h2>📊 Dashboard</h2>
<p class="date">{{ today }}</p>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">{{ stats.events_today }}</div>
<div class="stat-label">Events Today</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.priority_tasks }}</div>
<div class="stat-label">Priority Tasks</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.due_soon }}</div>
<div class="stat-label">Due Soon</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.active_projects }}</div>
<div class="stat-label">Active Projects</div>
</div>
</div>
<div class="dashboard-grid">
<!-- Today's Events -->
<div class="dashboard-section">
<h3>📅 Today's Schedule</h3>
{% if events %}
<div class="event-list">
{% for event in events %}
<div class="event-item">
<span class="event-time">
{% if event.time %}{{ event.time.strftime('%H:%M') }}{% else %}All day{% endif %}
</span>
<span class="event-desc">{{ event.description }}</span>
{% for context in event.contexts %}
<span class="tag context">@{{ context }}</span>
{% endfor %}
</div>
{% endfor %}
</div>
{% else %}
<p class="empty-state">No events scheduled today</p>
{% endif %}
</div>
<!-- Priority Tasks -->
<div class="dashboard-section">
<h3>✅ Priority Tasks</h3>
{% if priority_tasks %}
<div class="task-list">
{% for task in priority_tasks %}
<div class="task-item">
<span class="priority-badge priority-{{ task.priority }}">{{ task.priority }}</span>
<span class="task-desc">{{ task.description }}</span>
{% for project in task.projects %}
<span class="tag project">+{{ project }}</span>
{% endfor %}
</div>
{% endfor %}
</div>
<a href="/tasks?priority=A" class="btn-link">View all →</a>
{% else %}
<p class="empty-state">No priority tasks</p>
{% endif %}
</div>
<!-- Due Soon -->
<div class="dashboard-section">
<h3>⏰ Due Soon</h3>
{% if due_soon %}
<div class="task-list">
{% for task in due_soon %}
<div class="task-item">
<span class="task-desc">{{ task.description }}</span>
<span class="due-date">📅 {{ task.due_date.strftime('%Y-%m-%d') }}</span>
</div>
{% endfor %}
</div>
{% else %}
<p class="empty-state">Nothing due soon</p>
{% endif %}
</div>
<!-- Active Projects -->
<div class="dashboard-section">
<h3>📂 Active Projects</h3>
{% if active_projects %}
<div class="project-list">
{% for project in active_projects %}
<div class="project-item">
<span class="project-tag">+{{ project.tag }}</span>
<span class="project-desc">{{ project.description }}</span>
</div>
{% endfor %}
</div>
<a href="/projects" class="btn-link">View all →</a>
{% else %}
<p class="empty-state">No active projects</p>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,16 @@
{% extends "base.html" %}
{% block title %}Projects - MyOrg Assistant{% endblock %}
{% block content %}
<h2>📂 Projects</h2>
<p>Active: {{ stats.active }} | Waiting: {{ stats.waiting }} | Someday: {{ stats.someday }}</p>
<div class="project-list">
{% for project in projects %}
<div class="project-item">
<strong>+{{ project.tag }}</strong> - {{ project.description }} [{{ project.status }}]
{% if project_task_counts.get(project.tag) %}
<span class="badge">{{ project_task_counts[project.tag] }} tasks</span>
{% endif %}
</div>
{% endfor %}
</div>
{% endblock %}

View File

@@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block title %}Tasks - MyOrg Assistant{% endblock %}
{% block content %}
<h2>✅ Tasks</h2>
<p>Total: {{ total_tasks }} active, {{ completed_tasks }} completed</p>
<div class="task-list">
{% for task in tasks %}
<div class="task-item">
<span class="{% if task.priority %}priority-{{ task.priority }}{% endif %}">
{% if task.priority %}({{ task.priority }}){% endif %} {{ task.description }}
</span>
{% for p in task.projects %}<span class="tag">+{{ p }}</span>{% endfor %}
{% for c in task.contexts %}<span class="tag">@{{ c }}</span>{% endfor %}
</div>
{% endfor %}
</div>
{% endblock %}

8
start.sh Executable file
View File

@@ -0,0 +1,8 @@
#!/bin/bash
# Start script to run both Discord bot and web server
# Start web server in background
python -m src.main web &
# Start Discord bot in foreground
#python -m src.main bot

0
tests/__init__.py Normal file
View File

0
tests/fixtures/__init__.py vendored Normal file
View File

11
tests/fixtures/test_myorg/calendar.txt vendored Normal file
View File

@@ -0,0 +1,11 @@
# MyOrg Calendar - Test Data
2026-02-01 09:00 Morning standup @telefon +work
2026-02-01 11:00 Deep work session @computer-deep +myorg-assistant
2026-02-01 14:00 Dentist appointment @personal
2026-02-02 10:00 Team planning meeting @telefon +work
2026-02-03 Coffee with friend @bcn
2026-02-05 09:00 Weekly team sync @telefon +work
2026-02-10 Birthday celebration @personal
2026-02-15 19:00 Dinner reservation @restaurant location:Downtown
2026-02-20 15:00 Project review meeting +myorg-assistant @telefon

View File

@@ -0,0 +1,8 @@
# MyOrg Projects - Test Data
+myorg-assistant MyOrg Personal Assistant [active] @computer-deep goal:q1-2026 due:2026-02-28
+observability-blog Write observability blog post [active] @computer-deep goal:q1-2026 due:2026-02-15
+k3s Setup and maintain k3s cluster [active] @bcn goal:q1-2026
+home-renovation Kitchen renovation project [waiting] due:2026-03-15
+learn-rust Learn Rust programming [someday] @computer-deep
+q4-review Complete Q4 2025 review [completed]

55
tests/fixtures/test_myorg/telos.md vendored Normal file
View File

@@ -0,0 +1,55 @@
# Telos - Life Vision & Missions
**Last Updated**: 2026-01-31
## Life Vision
To live a balanced, meaningful life focused on continuous growth, helping others through technology, and maintaining strong relationships.
## Core Missions
### 1. Professional Growth (+Carrera)
Build expertise in AI, distributed systems, and software engineering. Share knowledge through writing and teaching.
**Key Goals**:
- Publish technical content regularly
- Contribute to open source projects
- Mentor junior developers
### 2. Personal Development (+CreixementPersonal)
Continuous learning and self-improvement in technical and non-technical areas.
**Key Goals**:
- Learn new programming languages
- Improve productivity systems
- Develop better habits
### 3. Health & Well-being (+Salut)
Maintain physical and mental health through exercise, nutrition, and mindfulness.
**Key Goals**:
- Regular exercise routine
- Healthy eating habits
- Adequate sleep and rest
### 4. Relationships (+Relacions)
Nurture meaningful relationships with family, friends, and community.
**Key Goals**:
- Quality time with loved ones
- Stay connected with friends
- Build community connections
### 5. Financial Stability (+Finances)
Achieve financial independence through smart planning and investments.
**Key Goals**:
- Emergency fund
- Retirement savings
- Smart investments
## Current Focus (Q1 2026)
1. **MyOrg Assistant**: Build AI-powered personal assistant (+myorg-assistant)
2. **Technical Writing**: Publish observability blog post (+observability-blog)
3. **Infrastructure**: Stabilize k3s cluster setup (+k3s)

12
tests/fixtures/test_myorg/todo.txt vendored Normal file
View File

@@ -0,0 +1,12 @@
# MyOrg Todo List - Test Data
(A) 2026-01-31 Write blog post about observability +observability-blog @computer-deep due:2026-02-15
(A) 2026-01-30 Finish myorg assistant Phase 0 +myorg-assistant @computer-deep
(B) 2026-01-31 Review k3s ingress configuration +k3s @bcn
Buy milk and groceries @recados due:2026-02-01
Call dentist to schedule appointment @telefon @recados
(C) Update documentation for deployment process +k3s @computer-light
Research Claude Agent SDK features +myorg-assistant @computer-deep
x 2026-01-30 Set up project repository +myorg-assistant
x 2026-01-30 Create parsers for todo.txt format +myorg-assistant
x 2026-01-29 Install dependencies +myorg-assistant

View File

@@ -0,0 +1,228 @@
"""Unit tests for CalendarParser."""
import pytest
from datetime import datetime, time
from src.parsers.calendar_parser import CalendarParser, Event
class TestCalendarParser:
"""Tests for CalendarParser class."""
def test_parse_event_with_time(self) -> None:
"""Test parsing an event with specific time."""
line = "2026-02-01 09:00 Team standup"
event = CalendarParser.parse_line(line)
assert event is not None
assert event.date == datetime(2026, 2, 1)
assert event.time == time(9, 0)
assert event.description == "Team standup"
assert event.all_day == False
def test_parse_all_day_event(self) -> None:
"""Test parsing an all-day event."""
line = "2026-02-15 Birthday party"
event = CalendarParser.parse_line(line)
assert event is not None
assert event.date == datetime(2026, 2, 15)
assert event.time is None
assert event.description == "Birthday party"
assert event.all_day == True
def test_parse_event_with_context(self) -> None:
"""Test parsing an event with context tags."""
line = "2026-02-01 14:30 Doctor appointment @personal"
event = CalendarParser.parse_line(line)
assert event is not None
assert "personal" in event.contexts
assert event.description == "Doctor appointment"
def test_parse_event_with_project(self) -> None:
"""Test parsing an event with project tags."""
line = "2026-02-05 10:00 Project kickoff +myorg-assistant"
event = CalendarParser.parse_line(line)
assert event is not None
assert "myorg-assistant" in event.projects
assert event.description == "Project kickoff"
def test_parse_event_with_multiple_tags(self) -> None:
"""Test parsing an event with multiple contexts and projects."""
line = "2026-02-10 15:00 Team meeting @telefon +work +team-sync"
event = CalendarParser.parse_line(line)
assert event is not None
assert "telefon" in event.contexts
assert "work" in event.projects
assert "team-sync" in event.projects
assert event.description == "Team meeting"
def test_parse_event_with_tags(self) -> None:
"""Test parsing an event with custom tags."""
line = "2026-02-20 18:00 Dinner location:restaurant duration:2h"
event = CalendarParser.parse_line(line)
assert event is not None
assert event.tags.get("location") == "restaurant"
assert event.tags.get("duration") == "2h"
def test_parse_empty_line(self) -> None:
"""Test parsing an empty line returns None."""
event = CalendarParser.parse_line("")
assert event is None
def test_parse_comment_line(self) -> None:
"""Test parsing a comment line returns None."""
event = CalendarParser.parse_line("# This is a comment")
assert event is None
def test_parse_invalid_format(self) -> None:
"""Test parsing invalid format returns None."""
event = CalendarParser.parse_line("Not a valid event format")
assert event is None
def test_format_event_with_time(self) -> None:
"""Test formatting an event with time."""
formatted = CalendarParser.format_event(
date=datetime(2026, 2, 1),
time=time(9, 0),
description="Team standup"
)
assert formatted == "2026-02-01 09:00 Team standup"
def test_format_all_day_event(self) -> None:
"""Test formatting an all-day event."""
formatted = CalendarParser.format_event(
date=datetime(2026, 2, 15),
description="Birthday party"
)
assert formatted == "2026-02-15 Birthday party"
def test_format_event_with_all_features(self) -> None:
"""Test formatting an event with all features."""
formatted = CalendarParser.format_event(
date=datetime(2026, 2, 10),
time=time(15, 0),
description="Team meeting",
contexts=["telefon"],
projects=["work"],
tags={"duration": "1h"}
)
assert "2026-02-10 15:00" in formatted
assert "Team meeting" in formatted
assert "@telefon" in formatted
assert "+work" in formatted
assert "duration:1h" in formatted
def test_parse_file(self) -> None:
"""Test parsing multiple events from file content."""
content = """# Calendar
2026-02-01 09:00 Morning meeting +work
2026-02-01 14:00 Afternoon appointment @personal
2026-02-15 Birthday party
2026-02-20 10:00 Project review +myorg-assistant"""
events = CalendarParser.parse_file(content)
assert len(events) == 4
# Events should be sorted by datetime
assert events[0].description == "Morning meeting"
assert events[1].description == "Afternoon appointment"
def test_filter_events_by_date_range(self) -> None:
"""Test filtering events by date range."""
events = [
Event(
raw_line="2026-02-01 Event 1",
line_number=1,
date=datetime(2026, 2, 1),
description="Event 1"
),
Event(
raw_line="2026-02-05 Event 2",
line_number=2,
date=datetime(2026, 2, 5),
description="Event 2"
),
Event(
raw_line="2026-02-10 Event 3",
line_number=3,
date=datetime(2026, 2, 10),
description="Event 3"
),
]
filtered = CalendarParser.filter_events(
events,
start_date=datetime(2026, 2, 3),
end_date=datetime(2026, 2, 8)
)
assert len(filtered) == 1
assert filtered[0].description == "Event 2"
def test_filter_events_by_context(self) -> None:
"""Test filtering events by context."""
events = [
Event(
raw_line="Event 1 @work",
line_number=1,
date=datetime(2026, 2, 1),
description="Event 1",
contexts=["work"]
),
Event(
raw_line="Event 2 @personal",
line_number=2,
date=datetime(2026, 2, 2),
description="Event 2",
contexts=["personal"]
),
]
work_events = CalendarParser.filter_events(events, context="work")
assert len(work_events) == 1
assert work_events[0].description == "Event 1"
def test_filter_events_by_project(self) -> None:
"""Test filtering events by project."""
events = [
Event(
raw_line="Event 1 +project1",
line_number=1,
date=datetime(2026, 2, 1),
description="Event 1",
projects=["project1"]
),
Event(
raw_line="Event 2 +project2",
line_number=2,
date=datetime(2026, 2, 2),
description="Event 2",
projects=["project2"]
),
]
project1_events = CalendarParser.filter_events(events, project="project1")
assert len(project1_events) == 1
assert project1_events[0].description == "Event 1"
def test_event_datetime_property(self) -> None:
"""Test the datetime property of Event."""
event = Event(
raw_line="2026-02-01 15:30 Meeting",
line_number=1,
date=datetime(2026, 2, 1),
time=time(15, 30),
description="Meeting"
)
assert event.datetime == datetime(2026, 2, 1, 15, 30)
all_day_event = Event(
raw_line="2026-02-01 Party",
line_number=1,
date=datetime(2026, 2, 1),
description="Party",
all_day=True
)
assert all_day_event.datetime == datetime(2026, 2, 1)

View File

@@ -0,0 +1,266 @@
"""Unit tests for ProjectParser."""
import pytest
from datetime import datetime
from src.parsers.project_parser import ProjectParser, Project
class TestProjectParser:
"""Tests for ProjectParser class."""
def test_parse_simple_project(self) -> None:
"""Test parsing a simple active project."""
line = "+myorg-assistant MyOrg Personal Assistant [active]"
project = ProjectParser.parse_line(line)
assert project is not None
assert project.tag == "myorg-assistant"
assert project.description == "MyOrg Personal Assistant"
assert project.status == "active"
def test_parse_project_without_status(self) -> None:
"""Test parsing a project without explicit status defaults to active."""
line = "+blog-post Write new blog post"
project = ProjectParser.parse_line(line)
assert project is not None
assert project.status == "active"
assert project.description == "Write new blog post"
def test_parse_project_with_context(self) -> None:
"""Test parsing a project with context tags."""
line = "+home-office Setup home office @bcn [active]"
project = ProjectParser.parse_line(line)
assert project is not None
assert "bcn" in project.contexts
assert project.description == "Setup home office"
def test_parse_project_with_goal(self) -> None:
"""Test parsing a project with goal reference."""
line = "+observability-blog Write observability blog [active] goal:q1-2026"
project = ProjectParser.parse_line(line)
assert project is not None
assert "q1-2026" in project.goals
assert project.description == "Write observability blog"
def test_parse_project_with_due_date(self) -> None:
"""Test parsing a project with due date."""
line = "+renovation Kitchen renovation [waiting] due:2026-03-15"
project = ProjectParser.parse_line(line)
assert project is not None
assert project.status == "waiting"
assert project.due_date == datetime(2026, 3, 15)
assert project.metadata["due"] == "2026-03-15"
def test_parse_project_with_all_features(self) -> None:
"""Test parsing a complex project with all features."""
line = "+myorg-assistant MyOrg Personal Assistant [active] @computer-deep goal:q1-2026 priority:high due:2026-02-28"
project = ProjectParser.parse_line(line)
assert project is not None
assert project.tag == "myorg-assistant"
assert project.description == "MyOrg Personal Assistant"
assert project.status == "active"
assert "computer-deep" in project.contexts
assert "q1-2026" in project.goals
assert project.metadata["priority"] == "high"
assert project.due_date == datetime(2026, 2, 28)
def test_parse_waiting_project(self) -> None:
"""Test parsing a waiting project."""
line = "+house-docs House documentation [waiting]"
project = ProjectParser.parse_line(line)
assert project is not None
assert project.status == "waiting"
def test_parse_someday_project(self) -> None:
"""Test parsing a someday project."""
line = "+learn-rust Learn Rust programming [someday]"
project = ProjectParser.parse_line(line)
assert project is not None
assert project.status == "someday"
def test_parse_completed_project(self) -> None:
"""Test parsing a completed project."""
line = "+q4-review Q4 review [completed]"
project = ProjectParser.parse_line(line)
assert project is not None
assert project.status == "completed"
def test_parse_empty_line(self) -> None:
"""Test parsing an empty line returns None."""
project = ProjectParser.parse_line("")
assert project is None
def test_parse_comment_line(self) -> None:
"""Test parsing a comment line returns None."""
project = ProjectParser.parse_line("# This is a comment")
assert project is None
def test_parse_invalid_format(self) -> None:
"""Test parsing a line without project tag returns None."""
project = ProjectParser.parse_line("Not a valid project")
assert project is None
def test_format_simple_project(self) -> None:
"""Test formatting a simple project."""
formatted = ProjectParser.format_project(
tag="blog-post",
description="Write blog post",
status="active"
)
assert formatted == "+blog-post Write blog post [active]"
def test_format_project_with_all_features(self) -> None:
"""Test formatting a complex project."""
formatted = ProjectParser.format_project(
tag="myorg-assistant",
description="MyOrg Assistant",
status="active",
contexts=["computer-deep"],
goals=["q1-2026"],
metadata={"priority": "high", "due": "2026-02-28"}
)
assert "+myorg-assistant" in formatted
assert "MyOrg Assistant" in formatted
assert "[active]" in formatted
assert "@computer-deep" in formatted
assert "goal:q1-2026" in formatted
assert "priority:high" in formatted
assert "due:2026-02-28" in formatted
def test_parse_file(self) -> None:
"""Test parsing multiple projects from file content."""
content = """# Active Projects
+myorg-assistant Personal assistant [active] goal:q1-2026
+blog-post Write blog post [active] @computer-deep
+renovation Kitchen renovation [waiting] due:2026-03-15
+learn-rust Learn Rust [someday]"""
projects = ProjectParser.parse_file(content)
assert len(projects) == 4
assert projects[0].tag == "myorg-assistant"
assert projects[1].status == "active"
assert projects[2].status == "waiting"
assert projects[3].status == "someday"
def test_filter_projects_by_status(self) -> None:
"""Test filtering projects by status."""
projects = [
Project(
raw_line="+p1 Project 1 [active]",
line_number=1,
tag="p1",
description="Project 1",
status="active"
),
Project(
raw_line="+p2 Project 2 [waiting]",
line_number=2,
tag="p2",
description="Project 2",
status="waiting"
),
Project(
raw_line="+p3 Project 3 [active]",
line_number=3,
tag="p3",
description="Project 3",
status="active"
),
]
active = ProjectParser.filter_projects(projects, status="active")
assert len(active) == 2
waiting = ProjectParser.filter_projects(projects, status="waiting")
assert len(waiting) == 1
def test_filter_projects_by_context(self) -> None:
"""Test filtering projects by context."""
projects = [
Project(
raw_line="+p1 Project 1 @home",
line_number=1,
tag="p1",
description="Project 1",
contexts=["home"]
),
Project(
raw_line="+p2 Project 2 @work",
line_number=2,
tag="p2",
description="Project 2",
contexts=["work"]
),
]
home_projects = ProjectParser.filter_projects(projects, context="home")
assert len(home_projects) == 1
assert home_projects[0].tag == "p1"
def test_filter_projects_by_goal(self) -> None:
"""Test filtering projects by goal."""
projects = [
Project(
raw_line="+p1 Project 1 goal:q1-2026",
line_number=1,
tag="p1",
description="Project 1",
goals=["q1-2026"]
),
Project(
raw_line="+p2 Project 2 goal:q2-2026",
line_number=2,
tag="p2",
description="Project 2",
goals=["q2-2026"]
),
]
q1_projects = ProjectParser.filter_projects(projects, goal="q1-2026")
assert len(q1_projects) == 1
assert q1_projects[0].tag == "p1"
def test_get_active_projects(self) -> None:
"""Test getting only active projects."""
projects = [
Project(
raw_line="+p1 Project 1 [active]",
line_number=1,
tag="p1",
description="Project 1",
status="active"
),
Project(
raw_line="+p2 Project 2 [waiting]",
line_number=2,
tag="p2",
description="Project 2",
status="waiting"
),
Project(
raw_line="+p3 Project 3 [active]",
line_number=3,
tag="p3",
description="Project 3",
status="active"
),
Project(
raw_line="+p4 Project 4 [someday]",
line_number=4,
tag="p4",
description="Project 4",
status="someday"
),
]
active = ProjectParser.get_active_projects(projects)
assert len(active) == 2
assert all(p.status == "active" for p in active)

207
tests/test_todo_parser.py Normal file
View File

@@ -0,0 +1,207 @@
"""Unit tests for TodoParser."""
import pytest
from datetime import datetime
from src.parsers.todo_parser import TodoParser, Task
class TestTodoParser:
"""Tests for TodoParser class."""
def test_parse_simple_task(self) -> None:
"""Test parsing a simple task without metadata."""
line = "Buy milk"
task = TodoParser.parse_line(line)
assert task is not None
assert task.description == "Buy milk"
assert task.completed == False
assert task.priority is None
assert len(task.projects) == 0
assert len(task.contexts) == 0
def test_parse_task_with_priority(self) -> None:
"""Test parsing a task with priority."""
line = "(A) Write blog post"
task = TodoParser.parse_line(line)
assert task is not None
assert task.priority == "A"
assert task.description == "Write blog post"
def test_parse_task_with_creation_date(self) -> None:
"""Test parsing a task with creation date."""
line = "(B) 2026-01-31 Finish implementation"
task = TodoParser.parse_line(line)
assert task is not None
assert task.priority == "B"
assert task.creation_date == datetime(2026, 1, 31)
assert task.description == "Finish implementation"
def test_parse_task_with_projects(self) -> None:
"""Test parsing a task with project tags."""
line = "Write tests +myorg-assistant +testing"
task = TodoParser.parse_line(line)
assert task is not None
assert "myorg-assistant" in task.projects
assert "testing" in task.projects
assert task.description == "Write tests"
def test_parse_task_with_contexts(self) -> None:
"""Test parsing a task with context tags."""
line = "Call dentist @telefon @recados"
task = TodoParser.parse_line(line)
assert task is not None
assert "telefon" in task.contexts
assert "recados" in task.contexts
assert task.description == "Call dentist"
def test_parse_task_with_due_date(self) -> None:
"""Test parsing a task with due date metadata."""
line = "(A) Submit report +work due:2026-02-15"
task = TodoParser.parse_line(line)
assert task is not None
assert task.due_date == datetime(2026, 2, 15)
assert "due" in task.metadata
assert task.metadata["due"] == "2026-02-15"
def test_parse_completed_task(self) -> None:
"""Test parsing a completed task."""
line = "x 2026-01-31 Buy groceries"
task = TodoParser.parse_line(line)
assert task is not None
assert task.completed == True
assert task.completion_date == datetime(2026, 1, 31)
assert task.description == "Buy groceries"
def test_parse_complex_task(self) -> None:
"""Test parsing a complex task with all features."""
line = "(A) 2026-01-31 Write observability blog post +observability-blog @computer-deep due:2026-02-15 priority:high"
task = TodoParser.parse_line(line)
assert task is not None
assert task.priority == "A"
assert task.creation_date == datetime(2026, 1, 31)
assert task.description == "Write observability blog post"
assert "observability-blog" in task.projects
assert "computer-deep" in task.contexts
assert task.due_date == datetime(2026, 2, 15)
assert task.metadata["priority"] == "high"
def test_parse_empty_line(self) -> None:
"""Test parsing an empty line returns None."""
task = TodoParser.parse_line("")
assert task is None
def test_parse_comment_line(self) -> None:
"""Test parsing a comment line returns None."""
task = TodoParser.parse_line("# This is a comment")
assert task is None
def test_format_simple_task(self) -> None:
"""Test formatting a simple task."""
formatted = TodoParser.format_task(description="Buy milk")
assert formatted == "Buy milk"
def test_format_task_with_priority(self) -> None:
"""Test formatting a task with priority."""
formatted = TodoParser.format_task(
description="Write tests",
priority="A"
)
assert formatted == "(A) Write tests"
def test_format_task_with_all_features(self) -> None:
"""Test formatting a complex task."""
formatted = TodoParser.format_task(
description="Write blog post",
priority="B",
creation_date=datetime(2026, 1, 31),
projects=["observability-blog"],
contexts=["computer-deep"],
metadata={"due": "2026-02-15"}
)
assert "(B)" in formatted
assert "2026-01-31" in formatted
assert "Write blog post" in formatted
assert "+observability-blog" in formatted
assert "@computer-deep" in formatted
assert "due:2026-02-15" in formatted
def test_format_completed_task(self) -> None:
"""Test formatting a completed task."""
formatted = TodoParser.format_task(
description="Buy groceries",
completed=True,
completion_date=datetime(2026, 1, 31)
)
assert formatted.startswith("x 2026-01-31")
assert "Buy groceries" in formatted
def test_parse_file(self) -> None:
"""Test parsing multiple tasks from file content."""
content = """# Tasks
(A) Write tests +myorg-assistant
Buy milk @recados
x 2026-01-30 Completed task
(B) 2026-01-31 Another task +project due:2026-02-01"""
tasks = TodoParser.parse_file(content)
assert len(tasks) == 4
assert tasks[0].description == "Write tests"
assert tasks[1].description == "Buy milk"
assert tasks[2].completed == True
assert tasks[3].priority == "B"
def test_filter_tasks_by_completion(self) -> None:
"""Test filtering tasks by completion status."""
tasks = [
Task(raw_line="x Task 1", line_number=1, completed=True, description="Task 1"),
Task(raw_line="Task 2", line_number=2, completed=False, description="Task 2"),
Task(raw_line="Task 3", line_number=3, completed=False, description="Task 3"),
]
active = TodoParser.filter_tasks(tasks, completed=False)
assert len(active) == 2
completed = TodoParser.filter_tasks(tasks, completed=True)
assert len(completed) == 1
def test_filter_tasks_by_priority(self) -> None:
"""Test filtering tasks by priority."""
tasks = [
Task(raw_line="(A) Task 1", line_number=1, priority="A", description="Task 1"),
Task(raw_line="(B) Task 2", line_number=2, priority="B", description="Task 2"),
Task(raw_line="Task 3", line_number=3, description="Task 3"),
]
high_priority = TodoParser.filter_tasks(tasks, priority="A")
assert len(high_priority) == 1
assert high_priority[0].description == "Task 1"
def test_filter_tasks_by_project(self) -> None:
"""Test filtering tasks by project tag."""
tasks = [
Task(raw_line="Task 1 +project1", line_number=1, description="Task 1", projects=["project1"]),
Task(raw_line="Task 2 +project2", line_number=2, description="Task 2", projects=["project2"]),
Task(raw_line="Task 3 +project1", line_number=3, description="Task 3", projects=["project1"]),
]
project1_tasks = TodoParser.filter_tasks(tasks, project="project1")
assert len(project1_tasks) == 2
def test_filter_tasks_by_context(self) -> None:
"""Test filtering tasks by context tag."""
tasks = [
Task(raw_line="Task 1 @home", line_number=1, description="Task 1", contexts=["home"]),
Task(raw_line="Task 2 @work", line_number=2, description="Task 2", contexts=["work"]),
Task(raw_line="Task 3 @home", line_number=3, description="Task 3", contexts=["home"]),
]
home_tasks = TodoParser.filter_tasks(tasks, context="home")
assert len(home_tasks) == 2

288
todo.md Normal file
View File

@@ -0,0 +1,288 @@
# MyOrg Assistant - Implementation Tasks
**Project**: `+myorg-assistant`
**Created**: 2026-01-31
**Last Updated**: 2026-02-01
**Status**: 🎉 **PRODUCTION READY** - 83% Complete (5 of 6 phases)
## Status Legend
- `[ ]` Not started
- `[~]` In progress
- `[x]` Completed
- `[-]` Cancelled/Skipped
---
## Phase 0: Project Setup & Foundation (Week 1) ✅ COMPLETED
### Project Structure
- [x] Create project repository structure
- [x] Set up src/ directory with subdirectories (agent, tools, parsers, api, bot, web, scheduler)
- [x] Create tests/ directory
- [x] Create k8s/ directory for Kubernetes manifests
- [x] Create Dockerfile and requirements.txt
### Python Environment
- [x] Create virtual environment
- [x] Install core dependencies (FastAPI, Claude Agent SDK, GitPython, Discord.py)
- [x] Set up pre-commit hooks (black, ruff, mypy)
- [x] Create requirements.txt and requirements-dev.txt
### Parsers
- [x] Create TodoParser for todo.txt format
- [x] Create CalendarParser for calendar.txt format
- [x] Create ProjectParser for projects.txt format
- [x] Write unit tests for TodoParser
- [x] Write unit tests for CalendarParser
- [x] Write unit tests for ProjectParser
### Test Repository
- [x] Create test myorg repository with sample data
- [x] Add sample todo.txt
- [x] Add sample calendar.txt
- [x] Add sample projects.txt
- [x] Add sample telos.md
---
## Phase 1: Core Agent with File Tools (Week 1-2) ✅ COMPLETED
### Claude Agent SDK Setup
- [x] Configure connection to LiteLLM endpoint
- [x] Set up Claude Sonnet 4.5 model
- [x] Create base agent class
- [x] Create agent system prompt
### File Operation Tools
- [x] Implement read_file(path)
- [x] Implement write_file(path, content)
- [x] Implement append_to_file(path, content)
- [x] Implement list_files(directory)
- [x] Add safety checks (validate paths, backup before write)
### Task Management Tools
- [x] Implement add_task(description, project, context, priority, due_date)
- [x] Implement complete_task(task_line_number)
- [x] Implement search_tasks(filters)
- [x] Integrate with TodoParser for formatting
### Git Tools
- [x] Implement git_status()
- [x] Implement git_commit(message)
- [x] Implement git_pull()
- [x] Implement git_push()
- [x] Add error handling for git operations
### CLI Testing
- [x] Build simple CLI for testing
- [x] Test file operations
- [x] Test task management
- [x] Test git integration
---
## Phase 2: Discord Bot Integration (Week 2-3) ✅ COMPLETED
### Discord Bot Setup
- [x] Create Discord application and bot
- [x] Implement discord.py integration
- [x] Connect bot to agent backend
### Bot Commands
- [x] Implement natural conversation handling
- [x] Implement /briefing command
- [x] Implement /add command
- [x] Implement /tasks command
- [x] Implement /today command
- [x] Implement /context command
### Discord Formatting
- [x] Format agent responses for Discord markdown
- [x] Add emoji support for readability
- [x] Handle long responses (pagination/truncation)
### Docker & Deployment
- [x] Create Dockerfile
- [x] Create Kubernetes Deployment manifest
- [x] Create ConfigMap for configuration
- [x] Create Secret for Discord token and git credentials
- [x] Create PersistentVolumeClaim for myorg repository
- [x] Deploy to k3s cluster
### Repository Sync
- [x] Clone myorg repo on container startup
- [x] Implement periodic git pull (every 15 minutes)
- [x] Implement auto-push after agent commits
---
## Phase 3: Scheduled Briefings & Reminders (Week 3-4) ✅ COMPLETED
### Briefing Generators
- [x] Implement generate_morning_briefing()
- [x] Implement generate_evening_summary()
- [x] Format briefings as Discord messages
### Reminder Logic
- [x] Implement check_deadlines() (7d, 3d, 1d warnings)
- [x] Implement check_waiting_items()
- [x] Implement check_upcoming_events() (30 min before)
### Scheduling System
- [x] Choose scheduling approach (APScheduler vs K8s CronJobs)
- [x] Configure timezone handling
### Kubernetes CronJobs
- [x] Create morning-briefing CronJob (8:00 AM)
- [x] Create evening-summary CronJob (8:00 PM)
- [x] Create deadline-checker CronJob (hourly)
- [x] Create waiting-followup CronJob (weekly Monday 9:00 AM)
- [x] Create git-sync CronJob (every 15 minutes)
### Context Inference
- [x] Implement infer_context(time, calendar_events)
- [x] Add time-based rules (work hours, evenings, weekends)
- [x] Add calendar-based inference
---
## Phase 4: Web Interface (Week 4-5) ✅ COMPLETED
### FastAPI Setup
- [x] Create FastAPI application
- [x] Add Jinja2 template engine
- [x] Configure static file serving
### Core Pages
- [x] Build Dashboard page (/)
- [x] Build Chat page (/chat)
- [x] Build Tasks page (/tasks)
- [x] Build Calendar page (/calendar)
- [x] Build Projects page (/projects)
### Styling
- [x] Create vanilla CSS stylesheet
- [x] Implement responsive layout
- [ ] Add dark mode support (deferred)
### HTMX Interactivity
- [x] Add task completion without reload
- [x] Add live task filtering
- [x] Add quick-add forms
- [x] Add auto-refresh sections
### Real-time Chat
- [x] Implement SSE for chat
- [x] Add real-time agent responses
- [x] Add streaming message updates
### Authentication
- [x] Add basic authentication (password or API key)
- [x] Protect web interface
### Kubernetes Updates
- [x] Add Service for web interface
- [x] Add Ingress (optional)
- [x] Configure internal DNS
---
## Phase 5: Advanced Intelligence (Week 5-6)
### Calendar Tools
- [ ] Implement parse_calendar()
- [ ] Implement add_event()
- [ ] Implement get_events(date_range)
- [ ] Implement get_todays_events()
### Project & Goal Tools
- [ ] Implement get_active_projects()
- [ ] Implement get_quarterly_goals()
- [ ] Implement get_telos()
- [ ] Implement analyze_project_progress(project)
- [ ] Implement analyze_goal_progress(goal)
### Intelligent Suggestions
- [ ] Implement suggest_tasks(context, time_available, energy_level)
- [ ] Filter by context
- [ ] Consider time of day
- [ ] Prioritize goal-aligned work
- [ ] Add suggestions to morning briefing
### Goal Alignment
- [ ] Show project → goal → mission mappings
- [ ] Highlight projects not progressing
- [ ] Suggest tasks that move goals forward
- [ ] Generate weekly goal progress report
### Working Memory Integration
- [ ] Auto-update working-memory.txt after significant actions
- [ ] Include working memory in agent context
- [ ] Show recent activities in dashboard
### Weekly Review Assistant
- [ ] Follow skills/weekly-review.md guide
- [ ] Create interactive walkthrough via Discord
- [ ] Add automatic archival suggestions
- [ ] Add project progress analysis
- [ ] Add goal alignment check
---
## Phase 6: Polish & Optimization (Week 6-7)
### Performance
- [ ] Cache frequently accessed files (telos, goals)
- [ ] Optimize parser performance
- [ ] Reduce LLM API calls where possible
- [ ] Add request/response caching
### Error Handling
- [ ] Graceful degradation if LiteLLM unavailable
- [ ] Better error messages for users
- [ ] Automatic retry logic for transient failures
- [ ] Rollback on git commit failures
### Monitoring & Logging
- [ ] Add structured logging (JSON format)
- [ ] Log agent actions (file writes, git commits)
- [ ] Track API usage and costs
- [ ] Optional: Add Prometheus metrics
### Discord UX Improvements
- [ ] Better formatting and layout
- [ ] Interactive buttons for confirmations
- [ ] Typing indicators while agent thinks
- [ ] Help command with examples
### Web UI Improvements
- [ ] Loading states for HTMX requests
- [ ] Error notifications
- [ ] Keyboard shortcuts
- [ ] Accessibility improvements
### Configuration
- [ ] Add user preferences (briefing time, reminder settings)
- [ ] Configurable contexts
- [ ] Custom scheduling
- [ ] Store in SQLite database
### Testing & QA
- [ ] Integration tests for key workflows
- [ ] Test with real myorg data
- [ ] Performance testing under load
- [ ] Security audit (file access, git operations)
### Documentation
- [ ] User guide (how to use bot and web UI)
- [ ] Deployment guide (k8s setup)
- [ ] Development guide (how to add tools)
- [ ] Troubleshooting guide
- [ ] README.md
---
## Current Focus
**Phase**: Phase 4 Complete! Full-featured assistant with web UI
**Status**: Phases 0, 1, 2, 3, and 4 completed successfully (83% complete!)
**Next Task**: Deploy full system or continue with Phase 5 (Advanced Intelligence) and 6 (Polish)