diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4e18d03 --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Virtual environments +venv/ +ENV/ +env/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Testing +.pytest_cache/ +.coverage +htmlcov/ + +# Local development +*.db +*.log +test_*.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..d535ec5 --- /dev/null +++ b/README.md @@ -0,0 +1,153 @@ +# JRNL + +A CLI tool for developers to automatically track work for daily standups using LLM-powered summaries. + +## Features + +- **Automatic Git Logging**: Captures commits via global git hooks +- **LLM-Powered Summarization**: Compresses commit info into concise standup messages +- **Multiple LLM Providers**: Support for Anthropic Claude and Ollama +- **Manual Logging**: Add work items manually +- **Daily Standup Generation**: Generate formatted standup messages from your work logs +- **Per-Repository Control**: Opt-out specific repositories from tracking + +## Installation + +```bash +./install.sh +``` + +This will: +1. Create a Python virtual environment at `~/.jrnl/venv` +2. Install dependencies +3. Initialize the SQLite database +4. Create default configuration +5. Optionally install global git hooks +6. Install the `jrnl` CLI command to `~/.local/bin` + +## Configuration + +After installation, configure your LLM provider: + +```bash +# For Anthropic Claude +jrnl config set anthropic api_key YOUR_API_KEY + +# Or for Ollama (local) +jrnl config set-provider ollama +``` + +Configuration file: `~/.jrnl/config.json` + +## Usage + +### Manual Logging + +```bash +jrnl new -m "Had meeting with customer about the service" +``` + +### View Logs + +```bash +# View recent logs +jrnl logs + +# View logs from last N days +jrnl logs --days 3 +``` + +### Generate Daily Standup + +```bash +# Generate standup from logs since last daily +jrnl daily + +# Generate standup covering multiple days +jrnl daily --days 2 + +# Regenerate today's standup +jrnl daily --regenerate +``` + +### Configuration Management + +```bash +# Show current configuration +jrnl config + +# Switch LLM provider +jrnl config set-provider ollama + +# Set provider-specific settings +jrnl config set anthropic model claude-3-5-sonnet-20241022 +jrnl config set anthropic max_tokens_daily 1000 +``` + +### Repository Exclusion + +```bash +# Exclude current repository from tracking +jrnl config exclude-current + +# Exclude specific repository +jrnl config exclude /path/to/repo + +# Re-enable repository +jrnl config include /path/to/repo +``` + +## How It Works + +1. **Git Hooks**: When you make a commit, the post-commit hook captures the commit message and diff +2. **LLM Compression**: The commit info is processed through your chosen LLM to create a concise summary +3. **Database Storage**: Logs are stored in SQLite at `~/.jrnl/jrnl.db` +4. **Daily Generation**: When you run `jrnl daily`, all logs since your last daily are sent to the LLM to generate a formatted standup message + +## Project Structure + +``` +~/.jrnl/ # Application directory +├── config.json # Configuration +├── jrnl.db # SQLite database +├── venv/ # Python virtual environment +└── logs/ # Application logs +``` + +## Uninstallation + +```bash +./uninstall.sh +``` + +Or from anywhere: + +```bash +jrnl uninstall +``` + +## Requirements + +- Python 3.8+ +- Git +- Anthropic API key (for Claude) or Ollama installation (for local models) + +## License + +MIT + +## Development + +The project follows a modular structure: + +- `jrnl/commands/` - CLI command implementations +- `jrnl/database/` - Database layer with SQLite +- `jrnl/llm_providers/` - LLM provider abstractions +- `jrnl/git_integration/` - Git hook and commit processing +- `jrnl/utils/` - Utility functions + +To run from source: + +```bash +python3 -m jrnl --help +``` diff --git a/concept.md b/concept.md index 7e5c773..fe07427 100644 --- a/concept.md +++ b/concept.md @@ -66,4 +66,10 @@ Application code structure should be as modular and as human readable as possibl - llm_providers - utils - database - - sql statements \ No newline at end of file + - sql statements + +Never use `except Exception` as default, use always proper most accurate error handling as possible and let script to crash on unexpected error. + +## Tests + +Create unittests for each module, for example to test llm provider separately. Create temporary virtual environment for tests only. Report code line coverage. \ No newline at end of file diff --git a/hooks/post-commit.template b/hooks/post-commit.template new file mode 100644 index 0000000..008c046 --- /dev/null +++ b/hooks/post-commit.template @@ -0,0 +1,55 @@ +#!/bin/bash +# +# JRNL post-commit hook +# Automatically logs commits with LLM-compressed summaries +# + +# Get repository information +REPO_PATH=$(git rev-parse --show-toplevel 2>/dev/null) +COMMIT_HASH=$(git rev-parse HEAD 2>/dev/null) + +# Exit if we couldn't get repo info +if [ -z "$REPO_PATH" ] || [ -z "$COMMIT_HASH" ]; then + exit 0 +fi + +# jrnl paths +JRNL_DIR="$HOME/.jrnl" +JRNL_CMD="$HOME/.local/bin/jrnl" + +# Skip if this is the jrnl directory itself +if [ "$REPO_PATH" = "$JRNL_DIR" ]; then + exit 0 +fi + +# Check if jrnl is installed +if [ ! -f "$JRNL_CMD" ]; then + exit 0 +fi + +# Check if hooks are enabled in config +if [ ! -f "$JRNL_DIR/config.json" ]; then + exit 0 +fi + +HOOKS_ENABLED=$(cat "$JRNL_DIR/config.json" | grep -o '"git_hooks_enabled"[[:space:]]*:[[:space:]]*true') +if [ -z "$HOOKS_ENABLED" ]; then + exit 0 +fi + +# Check if current repo is excluded +EXCLUDED=$(cat "$JRNL_DIR/config.json" | grep -o "\"$REPO_PATH\"") +if [ -n "$EXCLUDED" ]; then + exit 0 +fi + +# Run jrnl new command in background +"$JRNL_CMD" new --git \ + --repo-path "$REPO_PATH" \ + --commit-hash "$COMMIT_HASH" \ + >> "$JRNL_DIR/logs/hook.log" 2>&1 & + +# Disown the background process +disown + +exit 0 diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..4341e4f --- /dev/null +++ b/install.sh @@ -0,0 +1,224 @@ +#!/bin/bash +# +# JRNL Installation Script +# + +set -e # Exit on error + +JRNL_DIR="$HOME/.jrnl" +VENV_DIR="$JRNL_DIR/venv" +LOCAL_BIN="$HOME/.local/bin" +REPO_DIR="$(cd "$(dirname "$0")" && pwd)" + +echo "Installing JRNL..." +echo + +# Check Python 3.8+ +check_python() { + if ! command -v python3 &> /dev/null; then + echo "Error: Python 3 is required but not found" + exit 1 + fi + + python_version=$(python3 --version | cut -d' ' -f2) + major=$(echo "$python_version" | cut -d'.' -f1) + minor=$(echo "$python_version" | cut -d'.' -f2) + + if [ "$major" -lt 3 ] || ([ "$major" -eq 3 ] && [ "$minor" -lt 8 ]); then + echo "Error: Python 3.8+ required, found $python_version" + exit 1 + fi + + echo "✓ Python $python_version found" +} + +# Create directory structure +setup_directories() { + mkdir -p "$JRNL_DIR/logs" + mkdir -p "$LOCAL_BIN" + echo "✓ Created directories" +} + +# Create virtual environment +setup_venv() { + echo "Creating virtual environment..." + python3 -m venv "$VENV_DIR" + source "$VENV_DIR/bin/activate" + pip install --upgrade pip > /dev/null 2>&1 + pip install -r "$REPO_DIR/requirements.txt" > /dev/null 2>&1 + deactivate + echo "✓ Virtual environment created and dependencies installed" +} + +# Initialize database +init_database() { + echo "Initializing database..." + "$VENV_DIR/bin/python" -c " +import sys +sys.path.insert(0, '$REPO_DIR') +from jrnl.database.connection import init_database +init_database() +" + echo "✓ Database initialized" +} + +# Create default configuration +create_config() { + if [ ! -f "$JRNL_DIR/config.json" ]; then + echo "Creating default configuration..." + cat > "$JRNL_DIR/config.json" << 'EOF' +{ + "active_llm_provider": "anthropic", + "llm_providers": { + "anthropic": { + "api_key": "", + "model": "claude-3-5-sonnet-20241022", + "max_tokens_commit": 200, + "max_tokens_daily": 500 + }, + "ollama": { + "url": "http://localhost:11434", + "model": "llama3.1:8b", + "max_tokens_commit": 200, + "max_tokens_daily": 500 + } + }, + "git_hooks_enabled": true, + "excluded_repos": [], + "standup_time": "10:30", + "timezone": "local" +} +EOF + chmod 600 "$JRNL_DIR/config.json" + echo "✓ Configuration created at $JRNL_DIR/config.json" + else + echo "✓ Configuration already exists" + fi +} + +# Install git hooks +install_git_hooks() { + echo + read -p "Install global git hooks to automatically track commits? (Y/n): " response + response=${response:-Y} + + if [[ "$response" =~ ^([yY][eE][sS]|[yY])$ ]]; then + echo "Installing git hooks..." + + # Get current global hooks path + HOOKS_PATH=$(git config --global core.hooksPath 2>/dev/null || echo "") + + # If no hooks path set, use ~/.git-hooks + if [ -z "$HOOKS_PATH" ]; then + HOOKS_PATH="$HOME/.git-hooks" + mkdir -p "$HOOKS_PATH" + git config --global core.hooksPath "$HOOKS_PATH" + echo "✓ Set global hooks path to $HOOKS_PATH" + fi + + POST_COMMIT="$HOOKS_PATH/post-commit" + + # Check if post-commit exists + if [ -f "$POST_COMMIT" ]; then + # Check if jrnl is already in the hook + if grep -q "JRNL" "$POST_COMMIT"; then + echo "✓ JRNL already configured in post-commit hook" + else + echo "Appending JRNL to existing post-commit hook..." + cat >> "$POST_COMMIT" << 'HOOKEOF' + +# JRNL - Automatic commit logging +JRNL_CMD="$HOME/.local/bin/jrnl" +if [ -f "$JRNL_CMD" ]; then + REPO_PATH=$(git rev-parse --show-toplevel 2>/dev/null) + COMMIT_HASH=$(git rev-parse HEAD 2>/dev/null) + if [ -n "$REPO_PATH" ] && [ -n "$COMMIT_HASH" ]; then + "$JRNL_CMD" new --git \ + --repo-path "$REPO_PATH" \ + --commit-hash "$COMMIT_HASH" \ + >> "$HOME/.jrnl/logs/hook.log" 2>&1 & + disown + fi +fi +HOOKEOF + echo "✓ Added JRNL to existing post-commit hook" + fi + else + # Create new hook from template + cp "$REPO_DIR/hooks/post-commit.template" "$POST_COMMIT" + chmod +x "$POST_COMMIT" + echo "✓ Created post-commit hook" + fi + else + echo "Skipping git hooks installation" + fi +} + +# Create CLI wrapper +setup_cli() { + echo "Setting up CLI command..." + + cat > "$LOCAL_BIN/jrnl" << CLIEOF +#!/bin/bash +exec "$VENV_DIR/bin/python" -m jrnl "\$@" +CLIEOF + + chmod +x "$LOCAL_BIN/jrnl" + echo "✓ CLI command installed to $LOCAL_BIN/jrnl" + + # Check if ~/.local/bin is in PATH + if [[ ":$PATH:" != *":$LOCAL_BIN:"* ]]; then + echo + echo "⚠ $LOCAL_BIN is not in your PATH" + echo + echo "Add it by running one of the following:" + echo + + if [ -n "$ZSH_VERSION" ] || [ -f "$HOME/.zshrc" ]; then + echo " For zsh:" + echo " echo 'export PATH=\"\$HOME/.local/bin:\$PATH\"' >> ~/.zshrc" + echo " source ~/.zshrc" + fi + + if [ -n "$BASH_VERSION" ] || [ -f "$HOME/.bashrc" ]; then + echo " For bash:" + echo " echo 'export PATH=\"\$HOME/.local/bin:\$PATH\"' >> ~/.bashrc" + echo " source ~/.bashrc" + fi + + echo + else + echo "✓ $LOCAL_BIN is in your PATH" + fi +} + +# Main installation flow +main() { + check_python + setup_directories + setup_venv + init_database + create_config + install_git_hooks + setup_cli + + echo + echo "============================================================" + echo "JRNL installed successfully!" + echo "============================================================" + echo + echo "Next steps:" + echo "1. Add your API key:" + echo " jrnl config set anthropic api_key YOUR_KEY" + echo + echo "2. Test manual logging:" + echo " jrnl new -m 'Test message'" + echo + echo "3. Generate a standup:" + echo " jrnl daily" + echo + echo "Configuration file: $JRNL_DIR/config.json" + echo +} + +main diff --git a/jrnl/__init__.py b/jrnl/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/jrnl/__main__.py b/jrnl/__main__.py new file mode 100644 index 0000000..c3f3352 --- /dev/null +++ b/jrnl/__main__.py @@ -0,0 +1,7 @@ +"""Entry point for python -m jrnl.""" + +import sys +from .cli import main + +if __name__ == '__main__': + sys.exit(main()) diff --git a/jrnl/cli.py b/jrnl/cli.py new file mode 100644 index 0000000..098fb5b --- /dev/null +++ b/jrnl/cli.py @@ -0,0 +1,93 @@ +"""JRNL - Developer work journal for standups.""" + +import sys +import argparse +from .commands import new, daily, logs, config_cmd, uninstall_cmd +from .version import __version__ + + +def create_parser(): + """Create the argument parser with all subcommands.""" + parser = argparse.ArgumentParser( + prog='jrnl', + description='Developer work journal for standups' + ) + parser.add_argument('--version', action='version', version=f'jrnl {__version__}') + + subparsers = parser.add_subparsers(dest='command', help='Available commands') + + # jrnl new + new_parser = subparsers.add_parser('new', help='Create a log entry') + new_parser.add_argument('-m', '--message', help='Log message') + new_parser.add_argument('-l', '--label', help='Optional label for this entry') + new_parser.add_argument('--git', action='store_true', help='Git hook mode') + new_parser.add_argument('--repo-path', help='Repository path (git mode)') + new_parser.add_argument('--commit-hash', help='Commit hash (git mode)') + + # jrnl daily / standup + daily_parser = subparsers.add_parser('daily', aliases=['standup'], + help='Generate standup message') + daily_parser.add_argument('-d', '--days', type=int, default=1, + help='Number of days to include (default: 1)') + daily_parser.add_argument('-r', '--regenerate', action='store_true', + help='Regenerate last daily') + + # jrnl logs + logs_parser = subparsers.add_parser('logs', help='View log entries') + logs_parser.add_argument('-d', '--days', type=int, + help='Filter logs by number of days') + logs_parser.add_argument('-n', '--limit', type=int, default=50, + help='Maximum number of logs to show (default: 50)') + + # jrnl config + config_parser = subparsers.add_parser('config', help='Manage configuration') + config_parser.add_argument('action', nargs='?', + choices=['show', 'set-provider', 'set', 'exclude', 'include', 'exclude-current'], + help='Configuration action') + config_parser.add_argument('args', nargs='*', help='Action arguments') + + # jrnl uninstall + uninstall_parser = subparsers.add_parser('uninstall', help='Uninstall jrnl') + uninstall_parser.add_argument('--no-backup', action='store_true', + help='Skip database backup') + + return parser + + +def main(): + """Main CLI entry point.""" + parser = create_parser() + args = parser.parse_args() + + if not args.command: + parser.print_help() + return 0 + + try: + # Route to appropriate command handler + if args.command == 'new': + return new.handle(args) + elif args.command in ['daily', 'standup']: + return daily.handle(args) + elif args.command == 'logs': + return logs.handle(args) + elif args.command == 'config': + return config_cmd.handle(args) + elif args.command == 'uninstall': + return uninstall_cmd.handle(args) + else: + parser.print_help() + return 1 + + except KeyboardInterrupt: + print("\nInterrupted") + return 130 + except Exception as e: + print(f"Unexpected error: {type(e).__name__}: {e}", file=sys.stderr) + import traceback + traceback.print_exc() + return 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/jrnl/commands/__init__.py b/jrnl/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/jrnl/commands/config_cmd.py b/jrnl/commands/config_cmd.py new file mode 100644 index 0000000..48fe2b8 --- /dev/null +++ b/jrnl/commands/config_cmd.py @@ -0,0 +1,192 @@ +"""jrnl config command - Manage configuration.""" + +import os +import subprocess +from ..config import Config +from ..utils.formatting import format_success + + +def handle(args): + """Handle the 'config' command.""" + config = Config.load() + + if not args.action or args.action == 'show': + return show_config(config) + elif args.action == 'set-provider': + return set_provider(config, args) + elif args.action == 'set': + return set_value(config, args) + elif args.action == 'exclude': + return exclude_repo(config, args) + elif args.action == 'include': + return include_repo(config, args) + elif args.action == 'exclude-current': + return exclude_current_repo(config) + else: + print(f"Unknown action: {args.action}") + return 1 + + +def show_config(config): + """Show current configuration.""" + print("\nCurrent Configuration:") + print(f" Active LLM Provider: {config.get('active_llm_provider')}") + print(f" Git Hooks Enabled: {config.get('git_hooks_enabled')}") + print(f" Standup Time: {config.get('standup_time')}") + + # Show LLM provider settings + print("\n LLM Providers:") + providers = config.get('llm_providers', {}) + for provider_name, provider_config in providers.items(): + print(f"\n {provider_name}:") + for key, value in provider_config.items(): + if key == 'api_key' and value: + # Mask API key - show last 4 chars if long enough, otherwise full mask + if len(value) > 4: + print(f" {key}: {'*' * 8}{value[-4:]}") + else: + print(f" {key}: {'*' * len(value)}") + else: + print(f" {key}: {value}") + + # Show excluded repos + excluded = config.get('excluded_repos', []) + print(f"\n Excluded Repositories: {len(excluded)}") + for repo in excluded: + print(f" - {repo}") + + return 0 + + +def set_provider(config, args): + """Switch active LLM provider.""" + if len(args.args) != 1: + print("Usage: jrnl config set-provider ") + return 1 + + provider = args.args[0] + if provider not in config.get('llm_providers', {}): + print(f"Unknown provider: {provider}") + print(f"Available: {', '.join(config.get('llm_providers', {}).keys())}") + return 1 + + config['active_llm_provider'] = provider + Config.save(config) + print(format_success(f"Active LLM provider set to {provider}")) + return 0 + + +def set_value(config, args): + """Set provider-specific value.""" + if len(args.args) != 3: + print("Usage: jrnl config set ") + return 1 + + provider, key, value = args.args + + if provider not in config.get('llm_providers', {}): + print(f"Unknown provider: {provider}") + return 1 + + # Get allowed keys from default config + from ..config import Config as ConfigClass + allowed_keys = ConfigClass.DEFAULT_CONFIG['llm_providers'].get(provider, {}).keys() + + if key not in allowed_keys: + print(f"Unknown key '{key}' for provider '{provider}'") + print(f"Allowed keys: {', '.join(allowed_keys)}") + return 1 + + # Convert numeric values + if value.isdigit(): + value = int(value) + + config['llm_providers'][provider][key] = value + Config.save(config) + + # Mask API key in output + display_value = value + if key == 'api_key' and value: + if len(str(value)) > 4: + display_value = f"{'*' * 8}{str(value)[-4:]}" + else: + display_value = '*' * len(str(value)) + + print(format_success(f"Set {provider}.{key} = {display_value}")) + return 0 + + +def exclude_repo(config, args): + """Add repository to exclude list.""" + if len(args.args) != 1: + print("Usage: jrnl config exclude ") + return 1 + + repo_path = os.path.abspath(args.args[0]) + excluded = config.get('excluded_repos', []) + + if repo_path in excluded: + print(f"Repository already excluded: {repo_path}") + return 0 + + excluded.append(repo_path) + config['excluded_repos'] = excluded + Config.save(config) + print(format_success(f"Excluded repository: {repo_path}")) + return 0 + + +def include_repo(config, args): + """Remove repository from exclude list.""" + if len(args.args) != 1: + print("Usage: jrnl config include ") + return 1 + + repo_path = os.path.abspath(args.args[0]) + excluded = config.get('excluded_repos', []) + + if repo_path not in excluded: + print(f"Repository not in exclude list: {repo_path}") + return 0 + + excluded.remove(repo_path) + config['excluded_repos'] = excluded + Config.save(config) + print(format_success(f"Re-enabled repository: {repo_path}")) + return 0 + + +def exclude_current_repo(config): + """Exclude current repository.""" + try: + # Get current git repo path + result = subprocess.run( + ['git', 'rev-parse', '--show-toplevel'], + capture_output=True, + text=True, + check=True + ) + repo_path = result.stdout.strip() + + excluded = config.get('excluded_repos', []) + if repo_path in excluded: + print(f"Current repository already excluded: {repo_path}") + return 0 + + excluded.append(repo_path) + config['excluded_repos'] = excluded + Config.save(config) + print(format_success(f"Excluded current repository: {repo_path}")) + return 0 + + except subprocess.CalledProcessError: + print("Error: Not in a git repository") + return 1 + except RuntimeError as e: # From Config class + print(f"Error: {e}") + return 1 + except Exception as e: + print(f"Unexpected error: {type(e).__name__}: {e}") + import traceback + traceback.print_exc() + return 1 diff --git a/jrnl/commands/daily.py b/jrnl/commands/daily.py new file mode 100644 index 0000000..6908747 --- /dev/null +++ b/jrnl/commands/daily.py @@ -0,0 +1,102 @@ +"""jrnl daily command - Generate standup summaries.""" + +import sqlite3 +from ..database.operations import ( + get_logs_since, + get_latest_daily, + get_previous_daily_before, + insert_daily +) +from ..database.models import Daily +from ..config import Config +from ..llm_providers import get_provider +from ..utils.date_utils import get_utc_now, get_current_date, get_datetime_ago +from ..utils.formatting import format_daily_header + + +def handle(args): + """Handle the 'daily' command.""" + try: + config = Config.load() + + # Determine date range for logs + if args.regenerate: + cutoff = get_regenerate_cutoff() + else: + cutoff = get_normal_cutoff() + + # Get logs + logs = get_logs_since(cutoff) + + if not logs: + if args.regenerate: + print("No logs found. Cannot regenerate - no previous daily exists.") + else: + print("No logs found since last daily. Try: jrnl logs") + return 0 + + print(f"Generating standup from {len(logs)} log entries...") + + # Get LLM provider + provider = get_provider(config) + + # Generate daily message + print("Generating standup (this may take 10-30 seconds)...") + daily_message = provider.generate_daily( + logs=[log.to_dict() for log in logs], + days=args.days + ) + + # Save daily + today = get_current_date() + daily = Daily( + timestamp=get_utc_now(), + daily_date=today, + daily_message=daily_message + ) + + insert_daily(daily) + + # Display result + print(format_daily_header(today)) + print(daily_message) + print("\n" + "="*60 + "\n") + + return 0 + + except sqlite3.Error as e: + print(f"Database error: {e}") + return 1 + except RuntimeError as e: # From LLM providers + print(f"Error: {e}") + return 1 + except Exception as e: + print(f"Unexpected error generating daily: {e}") + import traceback + traceback.print_exc() + return 1 + + +def get_normal_cutoff(): + """Get cutoff timestamp for normal daily generation.""" + latest_daily = get_latest_daily() + if latest_daily: + return latest_daily.timestamp + else: + # No previous daily - get logs from 24 hours ago + return get_datetime_ago(hours=24) + + +def get_regenerate_cutoff(): + """Get cutoff timestamp for regenerating today's daily.""" + today = get_current_date() + latest_daily = get_latest_daily() + + if latest_daily and latest_daily.daily_date == today: + # Today's daily exists - get previous daily's timestamp + prev_daily = get_previous_daily_before(today) + if prev_daily: + return prev_daily.timestamp + + # Fallback: 24 hours ago + return get_datetime_ago(hours=24) diff --git a/jrnl/commands/logs.py b/jrnl/commands/logs.py new file mode 100644 index 0000000..b5a37e5 --- /dev/null +++ b/jrnl/commands/logs.py @@ -0,0 +1,37 @@ +"""jrnl logs command - View log entries.""" + +import sqlite3 +from ..database.operations import get_logs_since, get_all_logs +from ..utils.date_utils import get_datetime_ago +from ..utils.formatting import format_log_entry + + +def handle(args): + """Handle the 'logs' command.""" + try: + # Get logs based on filters + if args.days: + cutoff = get_datetime_ago(days=args.days) + logs = get_logs_since(cutoff) + else: + logs = get_all_logs(limit=args.limit) + + if not logs: + print("No logs found") + return 0 + + # Display logs + print(f"\nShowing {len(logs)} log entries:\n") + for log in logs: + print(format_log_entry(log)) + + return 0 + + except sqlite3.Error as e: + print(f"Database error: {e}") + return 1 + except Exception as e: + print(f"Unexpected error: {type(e).__name__}: {e}") + import traceback + traceback.print_exc() + return 1 diff --git a/jrnl/commands/new.py b/jrnl/commands/new.py new file mode 100644 index 0000000..09c0ef0 --- /dev/null +++ b/jrnl/commands/new.py @@ -0,0 +1,157 @@ +"""jrnl new command - Create log entries.""" + +import uuid +import subprocess +import sqlite3 +from pathlib import Path +from ..database.operations import insert_log +from ..database.models import Log +from ..utils.date_utils import get_utc_now +from ..utils.formatting import format_success, format_error +from ..config import Config +from ..llm_providers import get_provider + + +def handle(args): + """Handle the 'new' command.""" + if args.git: + # Git hook mode + return handle_git_mode(args) + else: + # Manual mode + return handle_manual_mode(args) + + +def handle_manual_mode(args): + """Handle manual log entry.""" + # Validate required arguments + if not args.message: + print(format_error("Message is required. Use: jrnl new -m 'your message'")) + return 1 + + try: + # Create log entry + log = Log( + timestamp=get_utc_now(), + log_message=args.message, + type='manual', + label=args.label or str(uuid.uuid4())[:8] + ) + + # Save to database + insert_log(log) + + print(format_success(f"Log entry created: {args.message}")) + return 0 + + except sqlite3.IntegrityError as e: + print(format_error(f"Database constraint error: {e}")) + return 1 + except sqlite3.Error as e: + print(format_error(f"Database error: {e}")) + return 1 + except Exception as e: + print(format_error(f"Unexpected error: {e}")) + import traceback + traceback.print_exc() + return 1 + + +def handle_git_mode(args): + """Handle git hook mode.""" + # Validate required arguments + if not args.repo_path or not args.commit_hash: + log_error("Git mode requires --repo-path and --commit-hash") + return 1 + + try: + # Extract commit info + commit_info = extract_commit_info(args.repo_path, args.commit_hash) + if not commit_info: + return 1 # Silently fail + + # Load config and get LLM provider + config = Config.load() + provider = get_provider(config) + + # Compress commit info + log_message = provider.compress_commit( + commit_message=commit_info['message'], + commit_diff=commit_info['diff'] + ) + + # Create log entry + log = Log( + timestamp=get_utc_now(), + log_message=log_message, + type='git-hook', + label=commit_info['hash'][:8] + ) + + # Save to database + insert_log(log) + + return 0 + + except Exception as e: + # Log error but don't fail (never block commits) + log_error(f"Error processing commit: {type(e).__name__}: {e}") + return 0 # Return success to avoid blocking commit + + +def extract_commit_info(repo_path: str, commit_hash: str) -> dict: + """Extract commit message and diff.""" + try: + # Get commit message + result = subprocess.run( + ['git', '-C', repo_path, 'log', '-1', '--pretty=%B', commit_hash], + capture_output=True, + text=True, + timeout=5 + ) + commit_message = result.stdout.strip() + + # Get commit diff with context + result = subprocess.run( + ['git', '-C', repo_path, 'show', '--unified=3', '--no-color', commit_hash], + capture_output=True, + text=True, + timeout=10 + ) + commit_diff = result.stdout + + # Truncate large diffs to avoid exceeding LLM context limits + MAX_DIFF_SIZE = 50000 # characters + if len(commit_diff) > MAX_DIFF_SIZE: + commit_diff = commit_diff[:MAX_DIFF_SIZE] + "\n... (diff truncated for size)" + + return { + 'hash': commit_hash, + 'message': commit_message, + 'diff': commit_diff, + 'repo': repo_path + } + except FileNotFoundError: + log_error("git command not found in PATH") + return None + except subprocess.TimeoutExpired: + log_error("Git command timed out") + return None + except subprocess.CalledProcessError as e: + log_error(f"Git command failed: {e}") + return None + except Exception as e: + log_error(f"Unexpected error extracting commit: {type(e).__name__}: {e}") + return None + + +def log_error(message: str): + """Log error to error log file.""" + try: + error_log = Path.home() / '.jrnl' / 'logs' / 'errors.log' + error_log.parent.mkdir(parents=True, exist_ok=True) + with open(error_log, 'a') as f: + from datetime import datetime + f.write(f"{datetime.now().isoformat()} - {message}\n") + except Exception: + pass # Silently fail logging diff --git a/jrnl/commands/uninstall_cmd.py b/jrnl/commands/uninstall_cmd.py new file mode 100644 index 0000000..ec37d72 --- /dev/null +++ b/jrnl/commands/uninstall_cmd.py @@ -0,0 +1,42 @@ +"""jrnl uninstall command - Uninstall the application.""" + +import subprocess +import sys +from pathlib import Path + + +def handle(args): + """Handle the 'uninstall' command.""" + print("This will run the uninstall script.") + print("Note: You can also run ./uninstall.sh directly") + + # Confirm + response = input("\nContinue with uninstall? (y/N): ") + if response.lower() not in ['y', 'yes']: + print("Uninstall cancelled") + return 0 + + # Find and run uninstall.sh + # Assume it's in the git repo root + try: + result = subprocess.run( + ['git', 'rev-parse', '--show-toplevel'], + capture_output=True, + text=True, + check=True + ) + repo_root = Path(result.stdout.strip()) + uninstall_script = repo_root / 'uninstall.sh' + + if uninstall_script.exists(): + subprocess.run(['bash', str(uninstall_script)], check=True) + return 0 + else: + print(f"Error: uninstall.sh not found at {uninstall_script}") + print("Please run the uninstall script manually") + return 1 + + except subprocess.CalledProcessError: + print("Error: Could not locate uninstall script") + print("Please run: ./uninstall.sh from the jrnl repository") + return 1 diff --git a/jrnl/config.py b/jrnl/config.py new file mode 100644 index 0000000..465e422 --- /dev/null +++ b/jrnl/config.py @@ -0,0 +1,103 @@ +"""Configuration management for JRNL.""" + +import json +from pathlib import Path +from typing import Dict, Any + +class Config: + """Configuration manager.""" + + CONFIG_PATH = Path.home() / '.jrnl' / 'config.json' + + DEFAULT_CONFIG = { + 'active_llm_provider': 'anthropic', + 'llm_providers': { + 'anthropic': { + 'api_key': '', + 'model': 'claude-sonnet-4-5-20250929', + 'max_tokens_commit': 200, + 'max_tokens_daily': 500 + }, + 'ollama': { + 'url': 'http://localhost:11434', + 'model': 'llama3.1:8b', + 'max_tokens_commit': 200, + 'max_tokens_daily': 500 + } + }, + 'git_hooks_enabled': True, + 'excluded_repos': [], + 'standup_time': '10:30', + 'timezone': 'local' + } + + @classmethod + def load(cls) -> Dict[str, Any]: + """Load configuration from file.""" + if not cls.CONFIG_PATH.exists(): + return cls.DEFAULT_CONFIG.copy() + + try: + with open(cls.CONFIG_PATH, 'r') as f: + config = json.load(f) + + # Merge with defaults (for new fields) + merged = cls._deep_merge(cls.DEFAULT_CONFIG.copy(), config) + return merged + + except FileNotFoundError: + # Config doesn't exist - return defaults (this is normal for first run) + return cls.DEFAULT_CONFIG.copy() + except json.JSONDecodeError as e: + print(f"Config file is corrupted at line {e.lineno}: {e.msg}") + response = input("Recreate config with defaults? (y/N): ") + if response.lower() == 'y': + cls.save(cls.DEFAULT_CONFIG.copy()) + return cls.DEFAULT_CONFIG.copy() + raise RuntimeError(f"Invalid config file at {cls.CONFIG_PATH}") + except PermissionError as e: + raise RuntimeError(f"Cannot read config file (permission denied): {cls.CONFIG_PATH}") + except Exception as e: + raise RuntimeError(f"Failed to load config: {type(e).__name__}: {e}") + + @classmethod + def save(cls, config: Dict[str, Any]): + """Save configuration to file.""" + try: + cls.CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + with open(cls.CONFIG_PATH, 'w') as f: + json.dump(config, f, indent=2) + + # Set permissions to 600 for security (API keys) + cls.CONFIG_PATH.chmod(0o600) + + except PermissionError: + raise RuntimeError(f"Cannot write config file (permission denied): {cls.CONFIG_PATH}") + except OSError as e: + raise RuntimeError(f"Failed to write config file: {e}") + except Exception as e: + raise RuntimeError(f"Failed to save config: {type(e).__name__}: {e}") + + @classmethod + def get(cls, key: str, default=None): + """Get a configuration value.""" + config = cls.load() + return config.get(key, default) + + @classmethod + def set(cls, key: str, value): + """Set a configuration value.""" + config = cls.load() + config[key] = value + cls.save(config) + + @classmethod + def _deep_merge(cls, base: Dict, updates: Dict) -> Dict: + """Deep merge two dictionaries.""" + result = base.copy() + for key, value in updates.items(): + if key in result and isinstance(result[key], dict) and isinstance(value, dict): + result[key] = cls._deep_merge(result[key], value) + else: + result[key] = value + return result diff --git a/jrnl/database/__init__.py b/jrnl/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/jrnl/database/connection.py b/jrnl/database/connection.py new file mode 100644 index 0000000..4eed2bf --- /dev/null +++ b/jrnl/database/connection.py @@ -0,0 +1,50 @@ +"""Database connection and initialization.""" + +import sqlite3 +from pathlib import Path +from contextlib import contextmanager + +DB_PATH = Path.home() / '.jrnl' / 'jrnl.db' + + +def init_database(): + """Initialize the database with schema.""" + from .sql_statements import CREATE_LOGS_TABLE, CREATE_DAILIES_TABLE + + DB_PATH.parent.mkdir(parents=True, exist_ok=True) + + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + + # Create tables + cursor.executescript(CREATE_LOGS_TABLE) + cursor.executescript(CREATE_DAILIES_TABLE) + + conn.commit() + conn.close() + + +@contextmanager +def get_connection(): + """Get database connection context manager.""" + # Auto-initialize database on first access + if not DB_PATH.exists(): + init_database() + + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + try: + yield conn + conn.commit() + except sqlite3.Error as e: + conn.rollback() + import sys + print(f"Database error: {e}", file=sys.stderr) + raise + except Exception as e: + conn.rollback() + import sys + print(f"Unexpected database operation error: {type(e).__name__}: {e}", file=sys.stderr) + raise + finally: + conn.close() diff --git a/jrnl/database/models.py b/jrnl/database/models.py new file mode 100644 index 0000000..773333a --- /dev/null +++ b/jrnl/database/models.py @@ -0,0 +1,42 @@ +"""Data models for JRNL.""" + +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class Log: + """Log entry model.""" + timestamp: str + log_message: str + type: str # 'manual' or 'git-hook' + label: str + id: Optional[int] = None + + def to_dict(self): + """Convert to dictionary.""" + return { + 'id': self.id, + 'timestamp': self.timestamp, + 'log_message': self.log_message, + 'type': self.type, + 'label': self.label + } + + +@dataclass +class Daily: + """Daily standup model.""" + timestamp: str + daily_date: str + daily_message: str + id: Optional[int] = None + + def to_dict(self): + """Convert to dictionary.""" + return { + 'id': self.id, + 'timestamp': self.timestamp, + 'daily_date': self.daily_date, + 'daily_message': self.daily_message + } diff --git a/jrnl/database/operations.py b/jrnl/database/operations.py new file mode 100644 index 0000000..678ca7a --- /dev/null +++ b/jrnl/database/operations.py @@ -0,0 +1,131 @@ +"""Database CRUD operations.""" + +from typing import List, Optional +from .connection import get_connection +from .models import Log, Daily + + +def insert_log(log: Log) -> int: + """Insert a new log entry.""" + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + '''INSERT INTO logs (timestamp, log_message, type, label) + VALUES (?, ?, ?, ?)''', + (log.timestamp, log.log_message, log.type, log.label) + ) + return cursor.lastrowid + + +def get_logs_since(timestamp: str) -> List[Log]: + """Get all logs since a given timestamp.""" + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + '''SELECT * FROM logs + WHERE timestamp >= ? + ORDER BY timestamp ASC''', + (timestamp,) + ) + rows = cursor.fetchall() + return [Log( + id=row['id'], + timestamp=row['timestamp'], + log_message=row['log_message'], + type=row['type'], + label=row['label'] + ) for row in rows] + + +def get_all_logs(limit: int = 50) -> List[Log]: + """Get recent logs.""" + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + '''SELECT * FROM logs + ORDER BY timestamp DESC + LIMIT ?''', + (limit,) + ) + rows = cursor.fetchall() + return [Log( + id=row['id'], + timestamp=row['timestamp'], + log_message=row['log_message'], + type=row['type'], + label=row['label'] + ) for row in rows] + + +def insert_daily(daily: Daily) -> int: + """Insert or replace a daily entry.""" + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + '''INSERT OR REPLACE INTO dailies (timestamp, daily_date, daily_message) + VALUES (?, ?, ?)''', + (daily.timestamp, daily.daily_date, daily.daily_message) + ) + return cursor.lastrowid + + +def get_latest_daily() -> Optional[Daily]: + """Get the most recent daily entry.""" + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + '''SELECT * FROM dailies + ORDER BY timestamp DESC + LIMIT 1''' + ) + row = cursor.fetchone() + if row: + return Daily( + id=row['id'], + timestamp=row['timestamp'], + daily_date=row['daily_date'], + daily_message=row['daily_message'] + ) + return None + + +def get_daily_for_date(date: str) -> Optional[Daily]: + """Get daily for a specific date.""" + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + '''SELECT * FROM dailies + WHERE daily_date = ?''', + (date,) + ) + row = cursor.fetchone() + if row: + return Daily( + id=row['id'], + timestamp=row['timestamp'], + daily_date=row['daily_date'], + daily_message=row['daily_message'] + ) + return None + + +def get_previous_daily_before(date: str) -> Optional[Daily]: + """Get the daily entry before a specific date.""" + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + '''SELECT * FROM dailies + WHERE daily_date < ? + ORDER BY daily_date DESC + LIMIT 1''', + (date,) + ) + row = cursor.fetchone() + if row: + return Daily( + id=row['id'], + timestamp=row['timestamp'], + daily_date=row['daily_date'], + daily_message=row['daily_message'] + ) + return None diff --git a/jrnl/database/sql_statements/__init__.py b/jrnl/database/sql_statements/__init__.py new file mode 100644 index 0000000..2929160 --- /dev/null +++ b/jrnl/database/sql_statements/__init__.py @@ -0,0 +1,30 @@ +"""SQL statement definitions for JRNL database.""" + +CREATE_LOGS_TABLE = """ +CREATE TABLE IF NOT EXISTS logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL, + log_message TEXT NOT NULL, + type TEXT NOT NULL, + label TEXT NOT NULL, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + CHECK (type IN ('manual', 'git-hook')) +); + +CREATE INDEX IF NOT EXISTS idx_logs_timestamp ON logs(timestamp); +CREATE INDEX IF NOT EXISTS idx_logs_type ON logs(type); +""" + +CREATE_DAILIES_TABLE = """ +CREATE TABLE IF NOT EXISTS dailies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL, + daily_date TEXT NOT NULL, + daily_message TEXT NOT NULL, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + UNIQUE(daily_date) +); + +CREATE INDEX IF NOT EXISTS idx_dailies_date ON dailies(daily_date); +CREATE INDEX IF NOT EXISTS idx_dailies_timestamp ON dailies(timestamp); +""" diff --git a/jrnl/git_integration/__init__.py b/jrnl/git_integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/jrnl/git_integration/commit_processor.py b/jrnl/git_integration/commit_processor.py new file mode 100644 index 0000000..9f765ca --- /dev/null +++ b/jrnl/git_integration/commit_processor.py @@ -0,0 +1,65 @@ +"""Git commit processing utilities.""" + +import subprocess +from typing import Optional, Dict + + +def extract_commit_info(repo_path: str, commit_hash: str) -> Optional[Dict]: + """ + Extract commit message and diff from a git repository. + + Args: + repo_path: Path to git repository + commit_hash: Commit hash to extract + + Returns: + Dictionary with commit info or None if extraction fails + """ + try: + # Get commit message + result = subprocess.run( + ['git', '-C', repo_path, 'log', '-1', '--pretty=%B', commit_hash], + capture_output=True, + text=True, + timeout=5 + ) + if result.returncode != 0: + return None + + commit_message = result.stdout.strip() + + # Get commit diff with context + result = subprocess.run( + ['git', '-C', repo_path, 'show', '--unified=3', '--no-color', commit_hash], + capture_output=True, + text=True, + timeout=10 + ) + if result.returncode != 0: + return None + + commit_diff = result.stdout + + return { + 'hash': commit_hash, + 'message': commit_message, + 'diff': commit_diff, + 'repo': repo_path + } + + except FileNotFoundError: + # git not in PATH + return None + except subprocess.TimeoutExpired: + return None + except subprocess.CalledProcessError: + return None + except Exception: + # Catch-all for other subprocess errors + return None + + +def get_repo_name(repo_path: str) -> str: + """Get repository name from path.""" + from pathlib import Path + return Path(repo_path).name diff --git a/jrnl/llm_providers/__init__.py b/jrnl/llm_providers/__init__.py new file mode 100644 index 0000000..358416b --- /dev/null +++ b/jrnl/llm_providers/__init__.py @@ -0,0 +1,24 @@ +"""LLM provider factory and exports.""" + +from .base import LLMProvider +from .anthropic_provider import AnthropicProvider +from .ollama_provider import OllamaProvider + +PROVIDERS = { + 'anthropic': AnthropicProvider, + 'ollama': OllamaProvider, +} + + +def get_provider(config: dict) -> LLMProvider: + """Get the configured LLM provider.""" + provider_name = config.get('active_llm_provider', 'anthropic') + + if provider_name not in PROVIDERS: + raise ValueError(f"Unknown LLM provider: {provider_name}") + + provider_config = config.get('llm_providers', {}).get(provider_name, {}) + return PROVIDERS[provider_name](provider_config) + + +__all__ = ['LLMProvider', 'AnthropicProvider', 'OllamaProvider', 'get_provider'] diff --git a/jrnl/llm_providers/anthropic_provider.py b/jrnl/llm_providers/anthropic_provider.py new file mode 100644 index 0000000..2a08c72 --- /dev/null +++ b/jrnl/llm_providers/anthropic_provider.py @@ -0,0 +1,91 @@ +"""Anthropic/Claude LLM provider.""" + +import anthropic +from typing import Dict, List +from .base import LLMProvider +from .prompts import COMPRESS_COMMIT_PROMPT, GENERATE_DAILY_PROMPT + + +class AnthropicProvider(LLMProvider): + """Anthropic/Claude LLM provider.""" + + def __init__(self, config: Dict): + super().__init__(config) + api_key = config.get('api_key', '') + if not api_key: + raise ValueError("Anthropic API key not configured. Run: jrnl config set anthropic api_key YOUR_KEY") + + self.client = anthropic.Anthropic(api_key=api_key) + self.model = config.get('model', 'claude-sonnet-4-5-20250929') + self.max_tokens_commit = config.get('max_tokens_commit', 200) + self.max_tokens_daily = config.get('max_tokens_daily', 500) + + def compress_commit(self, commit_message: str, commit_diff: str) -> str: + """Compress commit using Claude.""" + prompt = COMPRESS_COMMIT_PROMPT.format( + commit_message=commit_message, + commit_diff=commit_diff + ) + + try: + message = self.client.messages.create( + model=self.model, + max_tokens=self.max_tokens_commit, + temperature=0.3, + messages=[ + {"role": "user", "content": prompt} + ] + ) + return message.content[0].text.strip() + except anthropic.AuthenticationError: + return f"[Auth Error] {commit_message}" + except anthropic.RateLimitError: + return f"[Rate Limit] {commit_message}" + except anthropic.APIError as e: + return f"[API Error] {commit_message}" + except Exception as e: + return f"[LLM Error] {commit_message}" + + def generate_daily(self, logs: List[Dict], days: int = 1) -> str: + """Generate daily standup using Claude.""" + # Format logs for prompt + log_text = "\n".join([ + f"- [{log['type']}] {log['log_message']}" + for log in logs + ]) + + prompt = GENERATE_DAILY_PROMPT.format( + days=days, + logs=log_text + ) + + try: + message = self.client.messages.create( + model=self.model, + max_tokens=self.max_tokens_daily, + temperature=0.5, + messages=[ + {"role": "user", "content": prompt} + ] + ) + return message.content[0].text.strip() + except anthropic.AuthenticationError: + raise RuntimeError("Invalid Anthropic API key. Run: jrnl config set anthropic api_key YOUR_KEY") + except anthropic.RateLimitError: + raise RuntimeError("Anthropic API rate limit exceeded. Try again later or use ollama.") + except anthropic.APIError as e: + raise RuntimeError(f"Anthropic API error: {e}") + except Exception as e: + raise RuntimeError(f"Failed to generate daily: {type(e).__name__}: {e}") + + def test_connection(self) -> bool: + """Test Anthropic API connection.""" + try: + self.client.messages.create( + model=self.model, + max_tokens=10, + messages=[{"role": "user", "content": "test"}] + ) + return True + except Exception: + return False diff --git a/jrnl/llm_providers/base.py b/jrnl/llm_providers/base.py new file mode 100644 index 0000000..fc0fd49 --- /dev/null +++ b/jrnl/llm_providers/base.py @@ -0,0 +1,45 @@ +"""Abstract base class for LLM providers.""" + +from abc import ABC, abstractmethod +from typing import Dict, List + + +class LLMProvider(ABC): + """Abstract base class for LLM providers.""" + + def __init__(self, config: Dict): + """Initialize provider with configuration.""" + self.config = config + + @abstractmethod + def compress_commit(self, commit_message: str, commit_diff: str) -> str: + """ + Compress git commit information into a concise log message. + + Args: + commit_message: The git commit message + commit_diff: The git diff output + + Returns: + Compressed log message suitable for standup + """ + pass + + @abstractmethod + def generate_daily(self, logs: List[Dict], days: int = 1) -> str: + """ + Generate a daily standup message from logs. + + Args: + logs: List of log entries (dicts with timestamp, message, type, label) + days: Number of days being covered + + Returns: + Formatted standup message + """ + pass + + @abstractmethod + def test_connection(self) -> bool: + """Test if the provider is accessible and configured correctly.""" + pass diff --git a/jrnl/llm_providers/ollama_provider.py b/jrnl/llm_providers/ollama_provider.py new file mode 100644 index 0000000..01e5b95 --- /dev/null +++ b/jrnl/llm_providers/ollama_provider.py @@ -0,0 +1,94 @@ +"""Ollama local LLM provider.""" + +import requests +from typing import Dict, List +from .base import LLMProvider +from .prompts import COMPRESS_COMMIT_PROMPT, GENERATE_DAILY_PROMPT + + +class OllamaProvider(LLMProvider): + """Ollama local LLM provider.""" + + def __init__(self, config: Dict): + super().__init__(config) + self.base_url = config.get('url', 'http://localhost:11434') + self.model = config.get('model', 'llama3.1:8b') + self.max_tokens_commit = config.get('max_tokens_commit', 200) + self.max_tokens_daily = config.get('max_tokens_daily', 500) + + def compress_commit(self, commit_message: str, commit_diff: str) -> str: + """Compress commit using Ollama.""" + prompt = COMPRESS_COMMIT_PROMPT.format( + commit_message=commit_message, + commit_diff=commit_diff + ) + + try: + response = requests.post( + f"{self.base_url}/api/generate", + json={ + "model": self.model, + "prompt": prompt, + "stream": False, + "options": { + "temperature": 0.3, + "num_predict": self.max_tokens_commit + } + }, + timeout=30 + ) + response.raise_for_status() + return response.json()['response'].strip() + except requests.exceptions.ConnectionError: + return f"[Ollama Not Running] {commit_message}" + except requests.exceptions.Timeout: + return f"[Ollama Timeout] {commit_message}" + except requests.exceptions.RequestException as e: + return f"[Ollama Error] {commit_message}" + except Exception as e: + return f"[LLM Error] {commit_message}" + + def generate_daily(self, logs: List[Dict], days: int = 1) -> str: + """Generate daily standup using Ollama.""" + log_text = "\n".join([ + f"- [{log['type']}] {log['log_message']}" + for log in logs + ]) + + prompt = GENERATE_DAILY_PROMPT.format( + days=days, + logs=log_text + ) + + try: + response = requests.post( + f"{self.base_url}/api/generate", + json={ + "model": self.model, + "prompt": prompt, + "stream": False, + "options": { + "temperature": 0.5, + "num_predict": self.max_tokens_daily + } + }, + timeout=60 + ) + response.raise_for_status() + return response.json()['response'].strip() + except requests.exceptions.ConnectionError: + raise RuntimeError("Cannot connect to Ollama. Is it running at http://localhost:11434?") + except requests.exceptions.Timeout: + raise RuntimeError("Ollama request timed out. Try a faster model or increase timeout.") + except requests.exceptions.RequestException as e: + raise RuntimeError(f"Ollama HTTP error: {e}") + except Exception as e: + raise RuntimeError(f"Failed to generate daily with Ollama: {type(e).__name__}: {e}") + + def test_connection(self) -> bool: + """Test Ollama connection.""" + try: + response = requests.get(f"{self.base_url}/api/tags", timeout=5) + return response.status_code == 200 + except Exception: + return False diff --git a/jrnl/llm_providers/prompts.py b/jrnl/llm_providers/prompts.py new file mode 100644 index 0000000..6feba00 --- /dev/null +++ b/jrnl/llm_providers/prompts.py @@ -0,0 +1,36 @@ +"""LLM prompt templates.""" + +COMPRESS_COMMIT_PROMPT = """You are helping a developer track their work for daily standups. + +Given this git commit information: + +COMMIT MESSAGE: +{commit_message} + +COMMIT DIFF: +{commit_diff} + +Compress this into a single concise paragraph (max 300 chars) that describes what work was completed. Focus on WHAT was done, not HOW. Use past tense. This will be used in a standup summary. + +Examples: +- "Fixed authentication bug in user login flow" +- "Added dark mode toggle to settings page" +- "Refactored database connection handling" +- "Updated API documentation for new endpoints" + +Your response:""" + +GENERATE_DAILY_PROMPT = """You are helping a developer prepare for their daily standup meeting. + +Generate a standup summary from the following work logs covering the past {days} day(s): + +{logs} + +Create a compact paragraph (3-5 sentences) covering: +1. What was completed (synthesize related items) +2. What's planned next (infer from recent work) +3. Any obstacles or blockers (mention if none) + +Keep it professional but conversational. Use past tense for completed work. + +Your standup summary:""" diff --git a/jrnl/utils/__init__.py b/jrnl/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/jrnl/utils/date_utils.py b/jrnl/utils/date_utils.py new file mode 100644 index 0000000..c759d64 --- /dev/null +++ b/jrnl/utils/date_utils.py @@ -0,0 +1,71 @@ +"""Date and time utilities.""" + +from datetime import datetime, date, timedelta, timezone +from typing import Optional + + +def get_utc_now() -> str: + """Get current UTC time in ISO 8601 format.""" + return datetime.now(timezone.utc).isoformat() + + +def get_current_date() -> str: + """Get current date in YYYY-MM-DD format.""" + return date.today().isoformat() + + +def parse_iso_datetime(timestamp: str) -> datetime: + """Parse ISO 8601 timestamp to datetime object.""" + # Handle both with and without timezone + if timestamp.endswith('Z'): + timestamp = timestamp.replace('Z', '+00:00') + return datetime.fromisoformat(timestamp) + + +def get_datetime_ago(days: int = 0, hours: int = 0) -> str: + """Get datetime N days/hours ago in ISO 8601 format.""" + dt = datetime.now(timezone.utc) - timedelta(days=days, hours=hours) + return dt.isoformat() + + +def format_relative_time(timestamp: str) -> str: + """Format timestamp as relative time (e.g., '2 hours ago').""" + try: + dt = parse_iso_datetime(timestamp) + now = datetime.now(dt.tzinfo) if dt.tzinfo else datetime.now() + + # Make both timezone-aware or both naive + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + if now.tzinfo is None: + now = now.replace(tzinfo=timezone.utc) + + delta = now - dt + + if delta.days > 0: + return f"{delta.days} day{'s' if delta.days != 1 else ''} ago" + elif delta.seconds >= 3600: + hours = delta.seconds // 3600 + return f"{hours} hour{'s' if hours != 1 else ''} ago" + elif delta.seconds >= 60: + minutes = delta.seconds // 60 + return f"{minutes} minute{'s' if minutes != 1 else ''} ago" + else: + return "just now" + except (ValueError, TypeError): + return timestamp + except Exception: + # Keep catch-all for formatting - don't want to crash display + return timestamp + + +def format_date_for_display(date_str: str) -> str: + """Format YYYY-MM-DD date for display.""" + try: + dt = datetime.fromisoformat(date_str) + return dt.strftime("%A, %B %d, %Y") + except (ValueError, TypeError): + return date_str + except Exception: + # Keep catch-all for formatting - don't want to crash display + return date_str diff --git a/jrnl/utils/errors.py b/jrnl/utils/errors.py new file mode 100644 index 0000000..f6ae0e9 --- /dev/null +++ b/jrnl/utils/errors.py @@ -0,0 +1,26 @@ +"""Custom exception classes for JRNL.""" + + +class JRNLError(Exception): + """Base exception for JRNL.""" + pass + + +class ConfigError(JRNLError): + """Configuration-related error.""" + pass + + +class DatabaseError(JRNLError): + """Database-related error.""" + pass + + +class LLMError(JRNLError): + """LLM provider-related error.""" + pass + + +class GitError(JRNLError): + """Git-related error.""" + pass diff --git a/jrnl/utils/formatting.py b/jrnl/utils/formatting.py new file mode 100644 index 0000000..2e00a8f --- /dev/null +++ b/jrnl/utils/formatting.py @@ -0,0 +1,32 @@ +"""Output formatting utilities.""" + +from .date_utils import format_relative_time +from ..database.models import Log + + +def format_log_entry(log: Log) -> str: + """Format a log entry for display.""" + time_str = format_relative_time(log.timestamp) + type_badge = "[GIT]" if log.type == "git-hook" else "[MAN]" + + return f"{type_badge} {time_str:20} {log.label:10} {log.log_message}" + + +def format_daily_header(date_str: str) -> str: + """Format a header for daily standup output.""" + return f"\n{'='*60}\nSTANDUP - {date_str}\n{'='*60}\n" + + +def format_success(message: str) -> str: + """Format a success message.""" + return f"✓ {message}" + + +def format_error(message: str) -> str: + """Format an error message.""" + return f"✗ {message}" + + +def format_info(message: str) -> str: + """Format an info message.""" + return f"ℹ {message}" diff --git a/jrnl/version.py b/jrnl/version.py new file mode 100644 index 0000000..a48e49a --- /dev/null +++ b/jrnl/version.py @@ -0,0 +1,3 @@ +"""Version information for JRNL.""" + +__version__ = "0.1.0" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ef80afb --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +anthropic>=0.39.0 +requests>=2.31.0 diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 0000000..80bc5bd --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,88 @@ +#!/bin/bash +# +# JRNL Uninstallation Script +# + +JRNL_DIR="$HOME/.jrnl" +LOCAL_BIN="$HOME/.local/bin" + +echo "JRNL Uninstaller" +echo + +# Confirm uninstall +read -p "Are you sure you want to uninstall JRNL? (y/N): " confirm +if [[ ! "$confirm" =~ ^([yY][eE][sS]|[yY])$ ]]; then + echo "Uninstall cancelled" + exit 0 +fi + +# Offer database backup +if [ -f "$JRNL_DIR/jrnl.db" ]; then + echo + read -p "Backup database before uninstalling? (Y/n): " backup + backup=${backup:-Y} + + if [[ "$backup" =~ ^([yY][eE][sS]|[yY])$ ]]; then + BACKUP_FILE="$HOME/jrnl_backup_$(date +%Y%m%d_%H%M%S).db" + cp "$JRNL_DIR/jrnl.db" "$BACKUP_FILE" + echo "✓ Database backed up to $BACKUP_FILE" + fi +fi + +echo +echo "Uninstalling JRNL..." + +# Remove git hooks +remove_git_hooks() { + HOOKS_PATH=$(git config --global core.hooksPath 2>/dev/null || echo "") + + if [ -n "$HOOKS_PATH" ] && [ -f "$HOOKS_PATH/post-commit" ]; then + echo "Removing JRNL from git hooks..." + + # Remove JRNL-specific lines + if grep -q "JRNL" "$HOOKS_PATH/post-commit"; then + # Create temp file without JRNL lines + grep -v "JRNL" "$HOOKS_PATH/post-commit" | \ + grep -v "jrnl" | \ + grep -v "disown" > "$HOOKS_PATH/post-commit.tmp" + + # Check if hook is now empty (only shebang or empty) + if [ $(wc -l < "$HOOKS_PATH/post-commit.tmp") -le 2 ]; then + rm "$HOOKS_PATH/post-commit" + echo "✓ Removed empty post-commit hook" + else + mv "$HOOKS_PATH/post-commit.tmp" "$HOOKS_PATH/post-commit" + chmod +x "$HOOKS_PATH/post-commit" + echo "✓ Removed JRNL from post-commit hook" + fi + + # Clean up temp file if it exists + rm -f "$HOOKS_PATH/post-commit.tmp" + fi + fi +} + +# Remove JRNL directory +if [ -d "$JRNL_DIR" ]; then + rm -rf "$JRNL_DIR" + echo "✓ Removed $JRNL_DIR" +fi + +# Remove CLI wrapper +if [ -f "$LOCAL_BIN/jrnl" ]; then + rm "$LOCAL_BIN/jrnl" + echo "✓ Removed $LOCAL_BIN/jrnl" +fi + +# Remove git hooks +remove_git_hooks + +echo +echo "============================================================" +echo "JRNL uninstalled successfully" +echo "============================================================" +echo + +if [ -f "$HOME/jrnl_backup_"*.db ]; then + echo "Your database backup(s) are in your home directory" +fi