diff --git a/README.md b/README.md index d47a2cb..797c4ce 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,26 @@ jrnl config set anthropic api_key YOUR_API_KEY jrnl config set-provider ollama ``` +Configuration file: `~/.jrnl/config.json` +After installation, configure your LLM provider: + +```bash +# For Anthropic Claude +jrnl config set anthropic api_key YOUR_API_KEY + +# For Ollama (local) +jrnl config set-provider ollama + +# For llama.cpp (local) +jrnl config set-provider llamacpp +# Set llama.cpp binary path (if not default) +jrnl config set llamacpp llamacpp_bin /path/to/llamacpp/bin +# Set model directory (optional) +jrnl config set llamacpp model_dir ~/.llamacpp/models +# Set model name (optional) +jrnl config set llamacpp model_name Qwen/Qwen3-8B-GGUF +``` + Configuration file: `~/.jrnl/config.json` ## Usage @@ -83,6 +103,10 @@ jrnl config # Switch LLM provider jrnl config set-provider ollama + +# Switch LLM provider +jrnl config set-provider ollama +jrnl config set-provider llamacpp # Set provider-specific settings jrnl config set anthropic model claude-3-5-sonnet-20241022 diff --git a/jrnl/cli.py b/jrnl/cli.py index 5dcacf2..056792a 100644 --- a/jrnl/cli.py +++ b/jrnl/cli.py @@ -2,26 +2,26 @@ import sys import argparse -from .commands import new, daily, logs, config_cmd, uninstall_cmd +from .commands import new, daily, logs, config_cmd, uninstall_cmd, regenerate 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 - automatically track your work with git hooks and LLM-powered summaries', - epilog='Run "jrnl --help" for more information on a specific command.' + prog="jrnl", + description="Developer work journal for standups - automatically track your work with git hooks and LLM-powered summaries", + epilog='Run "jrnl --help" for more information on a specific command.', ) - parser.add_argument('--version', action='version', version=f'jrnl {__version__}') + parser.add_argument("--version", action="version", version=f"jrnl {__version__}") - subparsers = parser.add_subparsers(dest='command', help='Available commands') + subparsers = parser.add_subparsers(dest="command", help="Available commands") # jrnl new new_parser = subparsers.add_parser( - 'new', - help='Create a log entry', - epilog=''' + "new", + help="Create a log entry", + epilog=""" Examples: # Create a manual log entry jrnl new -m "fixed authentication bug" @@ -33,22 +33,30 @@ Examples: for hash in $(git log -5 --format=%%H); do jrnl new --git --repo-path "$(pwd)" --commit-hash "$hash" done - ''', - formatter_class=argparse.RawDescriptionHelpFormatter + """, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + new_parser.add_argument("-m", "--message", help="Log message for manual entry") + new_parser.add_argument("-l", "--label", help="Optional label for this entry") + new_parser.add_argument( + "--git", + action="store_true", + help="Git mode: process a commit with LLM compression", + ) + new_parser.add_argument("--repo-path", help="Repository path (required with --git)") + new_parser.add_argument( + "--commit-hash", help="Commit hash to process (required with --git)" + ) + new_parser.add_argument( + "--debug", action="store_true", help="Enable debug output for troubleshooting" ) - new_parser.add_argument('-m', '--message', help='Log message for manual entry') - new_parser.add_argument('-l', '--label', help='Optional label for this entry') - new_parser.add_argument('--git', action='store_true', - help='Git mode: process a commit with LLM compression') - new_parser.add_argument('--repo-path', help='Repository path (required with --git)') - new_parser.add_argument('--commit-hash', help='Commit hash to process (required with --git)') # jrnl daily / standup daily_parser = subparsers.add_parser( - 'daily', - aliases=['standup'], - help='Generate standup message from your logs', - epilog=''' + "daily", + aliases=["standup"], + help="Generate standup message from your logs", + epilog=""" Examples: # Generate today's standup jrnl daily @@ -63,21 +71,33 @@ Examples: jrnl daily --delete 2024-12-12 jrnl daily --delete today jrnl daily --delete latest - ''', - formatter_class=argparse.RawDescriptionHelpFormatter + """, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + 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 today's standup with latest logs", + ) + daily_parser.add_argument( + "--delete", + metavar="DATE", + help='Delete a daily entry (DATE: YYYY-MM-DD, "today", or "latest")', ) - 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 today\'s standup with latest logs') - daily_parser.add_argument('--delete', metavar='DATE', - help='Delete a daily entry (DATE: YYYY-MM-DD, "today", or "latest")') # jrnl logs logs_parser = subparsers.add_parser( - 'logs', - help='View log entries', - epilog=''' + "logs", + help="View log entries", + epilog=""" Examples: # View last 50 logs (default) jrnl logs @@ -90,21 +110,28 @@ Examples: # Delete a log entry by hash/label jrnl logs --delete 5a546f30 - ''', - formatter_class=argparse.RawDescriptionHelpFormatter + """, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + 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)", + ) + logs_parser.add_argument( + "--delete", metavar="HASH", help="Delete a log entry by its hash/label" ) - 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)') - logs_parser.add_argument('--delete', metavar='HASH', - help='Delete a log entry by its hash/label') # jrnl config config_parser = subparsers.add_parser( - 'config', - help='Manage configuration', - epilog=''' + "config", + help="Manage configuration", + epilog=""" Examples: # Show current configuration jrnl config @@ -123,18 +150,56 @@ Examples: # Re-enable a repository jrnl config include /path/to/repo - ''', - formatter_class=argparse.RawDescriptionHelpFormatter + """, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + 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 regenerate + regenerate_parser = subparsers.add_parser( + "regenerate", + help="Regenerate commit compressions", + epilog=""" +Examples: + # Regenerate compressions for all commits from a repository + jrnl regenerate --repo-path /path/to/repo + + # Regenerate without confirmation prompt + jrnl regenerate --repo-path /path/to/repo --yes + + # Regenerate with debug output + jrnl regenerate --repo-path /path/to/repo --debug + """, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + regenerate_parser.add_argument( + "--repo-path", required=True, help="Repository path containing the commits" + ) + regenerate_parser.add_argument( + "-y", "--yes", action="store_true", help="Skip confirmation prompt" + ) + regenerate_parser.add_argument( + "--debug", action="store_true", help="Enable debug output" ) - 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') + uninstall_parser = subparsers.add_parser("uninstall", help="Uninstall jrnl") + uninstall_parser.add_argument( + "--no-backup", action="store_true", help="Skip database backup" + ) return parser @@ -150,15 +215,17 @@ def main(): try: # Route to appropriate command handler - if args.command == 'new': + if args.command == "new": return new.handle(args) - elif args.command in ['daily', 'standup']: + elif args.command in ["daily", "standup"]: return daily.handle(args) - elif args.command == 'logs': + elif args.command == "logs": return logs.handle(args) - elif args.command == 'config': + elif args.command == "config": return config_cmd.handle(args) - elif args.command == 'uninstall': + elif args.command == "regenerate": + return regenerate.handle(args) + elif args.command == "uninstall": return uninstall_cmd.handle(args) else: parser.print_help() @@ -170,9 +237,10 @@ def main(): 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__': +if __name__ == "__main__": sys.exit(main()) diff --git a/jrnl/commands/config_cmd.py b/jrnl/commands/config_cmd.py index 48fe2b8..7a02566 100644 --- a/jrnl/commands/config_cmd.py +++ b/jrnl/commands/config_cmd.py @@ -1,26 +1,26 @@ """jrnl config command - Manage configuration.""" import os -import subprocess from ..config import Config from ..utils.formatting import format_success +from ..utils.git_utils import get_repo_root def handle(args): """Handle the 'config' command.""" config = Config.load() - if not args.action or args.action == 'show': + if not args.action or args.action == "show": return show_config(config) - elif args.action == 'set-provider': + elif args.action == "set-provider": return set_provider(config, args) - elif args.action == 'set': + elif args.action == "set": return set_value(config, args) - elif args.action == 'exclude': + elif args.action == "exclude": return exclude_repo(config, args) - elif args.action == 'include': + elif args.action == "include": return include_repo(config, args) - elif args.action == 'exclude-current': + elif args.action == "exclude-current": return exclude_current_repo(config) else: print(f"Unknown action: {args.action}") @@ -36,11 +36,11 @@ def show_config(config): # Show LLM provider settings print("\n LLM Providers:") - providers = config.get('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: + 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:]}") @@ -50,7 +50,7 @@ def show_config(config): print(f" {key}: {value}") # Show excluded repos - excluded = config.get('excluded_repos', []) + excluded = config.get("excluded_repos", []) print(f"\n Excluded Repositories: {len(excluded)}") for repo in excluded: print(f" - {repo}") @@ -65,12 +65,12 @@ def set_provider(config, args): return 1 provider = args.args[0] - if provider not in config.get('llm_providers', {}): + 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["active_llm_provider"] = provider Config.save(config) print(format_success(f"Active LLM provider set to {provider}")) return 0 @@ -84,13 +84,14 @@ def set_value(config, args): provider, key, value = args.args - if provider not in config.get('llm_providers', {}): + 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() + + allowed_keys = ConfigClass.DEFAULT_CONFIG["llm_providers"].get(provider, {}).keys() if key not in allowed_keys: print(f"Unknown key '{key}' for provider '{provider}'") @@ -101,16 +102,16 @@ def set_value(config, args): if value.isdigit(): value = int(value) - config['llm_providers'][provider][key] = 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 key == "api_key" and value: if len(str(value)) > 4: display_value = f"{'*' * 8}{str(value)[-4:]}" else: - display_value = '*' * len(str(value)) + display_value = "*" * len(str(value)) print(format_success(f"Set {provider}.{key} = {display_value}")) return 0 @@ -123,14 +124,14 @@ def exclude_repo(config, args): return 1 repo_path = os.path.abspath(args.args[0]) - excluded = config.get('excluded_repos', []) + 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["excluded_repos"] = excluded Config.save(config) print(format_success(f"Excluded repository: {repo_path}")) return 0 @@ -143,14 +144,14 @@ def include_repo(config, args): return 1 repo_path = os.path.abspath(args.args[0]) - excluded = config.get('excluded_repos', []) + 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["excluded_repos"] = excluded Config.save(config) print(format_success(f"Re-enabled repository: {repo_path}")) return 0 @@ -160,33 +161,29 @@ 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() + repo_path = get_repo_root() - excluded = config.get('excluded_repos', []) + if repo_path is None: + print("Error: Not in a git repository") + return 1 + + 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["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 index 3f89e4e..ea3fbbc 100644 --- a/jrnl/commands/daily.py +++ b/jrnl/commands/daily.py @@ -7,7 +7,7 @@ from ..database.operations import ( get_previous_daily_before, get_daily_for_date, insert_daily, - delete_daily + delete_daily, ) from ..database.models import Daily from ..config import Config @@ -20,7 +20,7 @@ def handle(args): """Handle the 'daily' command.""" try: # Handle deletion if requested - if hasattr(args, 'delete') and args.delete: + if hasattr(args, "delete") and args.delete: return handle_delete(args.delete) config = Config.load() @@ -29,7 +29,8 @@ def handle(args): if args.regenerate: cutoff = get_regenerate_cutoff() else: - cutoff = get_normal_cutoff() + # Use days parameter to determine cutoff + cutoff = get_datetime_ago(days=args.days) # Get logs logs = get_logs_since(cutoff) @@ -49,16 +50,13 @@ def handle(args): # 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 + 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 + timestamp=get_utc_now(), daily_date=today, daily_message=daily_message ) insert_daily(daily) @@ -66,7 +64,7 @@ def handle(args): # Display result print(format_daily_header(today)) print(daily_message) - print("\n" + "="*60 + "\n") + print("\n" + "=" * 60 + "\n") return 0 @@ -79,6 +77,7 @@ def handle(args): except Exception as e: print(f"Unexpected error generating daily: {e}") import traceback + traceback.print_exc() return 1 @@ -87,9 +86,9 @@ def handle_delete(date_arg: str) -> int: """Handle deleting a daily entry.""" try: # Resolve date argument - if date_arg.lower() in ['today', 'latest']: + if date_arg.lower() in ["today", "latest"]: # Get today's date or latest daily date - if date_arg.lower() == 'today': + if date_arg.lower() == "today": date = get_current_date() else: latest = get_latest_daily() diff --git a/jrnl/commands/new.py b/jrnl/commands/new.py index 09c0ef0..476a2ec 100644 --- a/jrnl/commands/new.py +++ b/jrnl/commands/new.py @@ -1,13 +1,13 @@ """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 ..utils.git_utils import extract_commit_info from ..config import Config from ..llm_providers import get_provider @@ -34,8 +34,8 @@ def handle_manual_mode(args): log = Log( timestamp=get_utc_now(), log_message=args.message, - type='manual', - label=args.label or str(uuid.uuid4())[:8] + type="manual", + label=args.label or str(uuid.uuid4())[:8], ) # Save to database @@ -53,6 +53,7 @@ def handle_manual_mode(args): except Exception as e: print(format_error(f"Unexpected error: {e}")) import traceback + traceback.print_exc() return 1 @@ -68,24 +69,35 @@ def handle_git_mode(args): # Extract commit info commit_info = extract_commit_info(args.repo_path, args.commit_hash) if not commit_info: + if getattr(args, "debug", False): + print("[DEBUG] Failed to extract commit info.") return 1 # Silently fail # Load config and get LLM provider config = Config.load() provider = get_provider(config) + if getattr(args, "debug", False): + print(f"[DEBUG] Commit info: {commit_info}") + print(f"[DEBUG] Provider: {provider.__class__.__name__}") + print(f"[DEBUG] Provider config: {getattr(provider, 'config', {})}") + # Compress commit info + if hasattr(provider, "set_debug"): + provider.set_debug(True) log_message = provider.compress_commit( - commit_message=commit_info['message'], - commit_diff=commit_info['diff'] + commit_message=commit_info["message"], commit_diff=commit_info["diff"] ) + if getattr(args, "debug", False): + print(f"[DEBUG] LLM output: {log_message}") + # Create log entry log = Log( timestamp=get_utc_now(), log_message=log_message, - type='git-hook', - label=commit_info['hash'][:8] + type="git-hook", + label=commit_info["hash"][:8], ) # Save to database @@ -95,63 +107,26 @@ def handle_git_mode(args): except Exception as e: # Log error but don't fail (never block commits) + if getattr(args, "debug", False): + import traceback + + print(f"[DEBUG] Exception: {type(e).__name__}: {e}") + traceback.print_exc() 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 +# extract_commit_info is now imported from utils.git_utils def log_error(message: str): """Log error to error log file.""" try: - error_log = Path.home() / '.jrnl' / 'logs' / 'errors.log' + error_log = Path.home() / ".jrnl" / "logs" / "errors.log" error_log.parent.mkdir(parents=True, exist_ok=True) - with open(error_log, 'a') as f: + 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/regenerate.py b/jrnl/commands/regenerate.py new file mode 100644 index 0000000..51d3e6f --- /dev/null +++ b/jrnl/commands/regenerate.py @@ -0,0 +1,123 @@ +"""jrnl regenerate command - Regenerate commit compressions.""" + +import sqlite3 +from ..database.operations import get_all_logs, get_connection +from ..config import Config +from ..llm_providers import get_provider +from ..utils.git_utils import extract_commit_info +from ..utils.formatting import format_error + + +def handle(args): + """Handle the 'regenerate' command.""" + try: + # Get all git-hook logs + all_logs = get_all_logs(limit=10000) # Get a large number + git_logs = [log for log in all_logs if log.type == "git-hook"] + + if not git_logs: + print("No git commit logs found to regenerate.") + return 0 + + print(f"Found {len(git_logs)} git commit log(s).") + + # Check if repo path is provided + if not args.repo_path: + print( + format_error( + "Repository path is required. Use: jrnl regenerate --repo-path /path/to/repo" + ) + ) + print("\nNote: The label field in logs contains commit hashes.") + print(" Provide the repo path where these commits exist.") + return 1 + + # Confirm with user + if not args.yes: + response = input( + f"\nRegenerate compressions for {len(git_logs)} log(s) from {args.repo_path}? (y/N): " + ) + if response.lower() not in ["y", "yes"]: + print("Cancelled.") + return 0 + + # Load config and get LLM provider + config = Config.load() + provider = get_provider(config) + + if args.debug and hasattr(provider, "set_debug"): + provider.set_debug(True) + + # Process each log + success_count = 0 + skip_count = 0 + error_count = 0 + + for i, log in enumerate(git_logs, 1): + commit_hash = log.label + print( + f"\n[{i}/{len(git_logs)}] Processing commit {commit_hash}...", end=" " + ) + + # Extract commit info + commit_info = extract_commit_info(args.repo_path, commit_hash) + if not commit_info: + print("⊘ Skipped (commit not found in repo)") + skip_count += 1 + continue + + # Compress with LLM + try: + new_message = provider.compress_commit( + commit_message=commit_info["message"], + commit_diff=commit_info["diff"], + ) + + # Update the log + if update_log_message(log.id, new_message): + print("✓ Updated") + success_count += 1 + else: + print("❌ Failed to update database") + error_count += 1 + + except Exception as e: + print(f"❌ Error: {e}") + error_count += 1 + + # Summary + print("\n" + "=" * 60) + print("Regeneration complete:") + print(f" ✓ Successfully regenerated: {success_count}") + print(f" ⊘ Skipped: {skip_count}") + print(f" ❌ Errors: {error_count}") + print("=" * 60) + + return 0 if error_count == 0 else 1 + + except sqlite3.Error as e: + print(format_error(f"Database error: {e}")) + return 1 + except RuntimeError as e: # From LLM providers or config + print(format_error(f"Error: {e}")) + return 1 + except Exception as e: + print(format_error(f"Unexpected error: {e}")) + import traceback + + traceback.print_exc() + return 1 + + +def update_log_message(log_id: int, new_message: str) -> bool: + """Update a log entry's message.""" + try: + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + """UPDATE logs SET log_message = ? WHERE id = ?""", + (new_message, log_id), + ) + return cursor.rowcount > 0 + except Exception: + return False diff --git a/jrnl/commands/uninstall_cmd.py b/jrnl/commands/uninstall_cmd.py index ec37d72..d2f93dc 100644 --- a/jrnl/commands/uninstall_cmd.py +++ b/jrnl/commands/uninstall_cmd.py @@ -1,8 +1,8 @@ """jrnl uninstall command - Uninstall the application.""" -import subprocess import sys from pathlib import Path +from ..utils.git_utils import get_repo_root def handle(args): @@ -12,24 +12,27 @@ def handle(args): # Confirm response = input("\nContinue with uninstall? (y/N): ") - if response.lower() not in ['y', 'yes']: + 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' + repo_root_str = get_repo_root() + if repo_root_str is None: + print("Error: Could not locate uninstall script") + print("Please run: ./uninstall.sh from the jrnl repository") + return 1 + + repo_root = Path(repo_root_str) + uninstall_script = repo_root / "uninstall.sh" if uninstall_script.exists(): - subprocess.run(['bash', str(uninstall_script)], check=True) + # Read and execute the uninstall script using Python + import subprocess + + subprocess.run(["bash", str(uninstall_script)], check=True) return 0 else: print(f"Error: uninstall.sh not found at {uninstall_script}") @@ -37,6 +40,9 @@ def handle(args): return 1 except subprocess.CalledProcessError: - print("Error: Could not locate uninstall script") + print("Error: Could not execute uninstall script") print("Please run: ./uninstall.sh from the jrnl repository") return 1 + except Exception as e: + print(f"Error: {e}") + return 1 diff --git a/jrnl/config.py b/jrnl/config.py index 465e422..da7951e 100644 --- a/jrnl/config.py +++ b/jrnl/config.py @@ -4,31 +4,41 @@ import json from pathlib import Path from typing import Dict, Any + class Config: """Configuration manager.""" - CONFIG_PATH = Path.home() / '.jrnl' / 'config.json' + 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 + "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, + }, + "llamacpp": { + "model_dir": f"{str(Path.home())}/.llamacpp/models", + "model_name": "Qwen3-8B.Q4_K_M.gguf", + "max_tokens_commit": 200, + "max_tokens_daily": 500, + "n_ctx": 0, + "n_threads": None, + "strip_thinking": False, }, - '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' + "git_hooks_enabled": True, + "excluded_repos": [], + "standup_time": "10:30", + "timezone": "local", } @classmethod @@ -38,7 +48,7 @@ class Config: return cls.DEFAULT_CONFIG.copy() try: - with open(cls.CONFIG_PATH, 'r') as f: + with open(cls.CONFIG_PATH, "r") as f: config = json.load(f) # Merge with defaults (for new fields) @@ -51,12 +61,14 @@ class Config: 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': + 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}") + 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}") @@ -65,14 +77,16 @@ class Config: """Save configuration to file.""" try: cls.CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) - with open(cls.CONFIG_PATH, 'w') as f: + 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}") + 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: @@ -96,7 +110,11 @@ class Config: """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): + 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 diff --git a/jrnl/git_integration/commit_processor.py b/jrnl/git_integration/commit_processor.py index 9f765ca..b2e8426 100644 --- a/jrnl/git_integration/commit_processor.py +++ b/jrnl/git_integration/commit_processor.py @@ -1,7 +1,7 @@ """Git commit processing utilities.""" -import subprocess from typing import Optional, Dict +from ..utils.git_utils import extract_commit_info as _extract_commit_info def extract_commit_info(repo_path: str, commit_hash: str) -> Optional[Dict]: @@ -15,51 +15,12 @@ def extract_commit_info(repo_path: str, commit_hash: str) -> Optional[Dict]: 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 + # Delegate to the git_utils implementation + return _extract_commit_info(repo_path, commit_hash) 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 index 358416b..8ef9843 100644 --- a/jrnl/llm_providers/__init__.py +++ b/jrnl/llm_providers/__init__.py @@ -3,10 +3,12 @@ from .base import LLMProvider from .anthropic_provider import AnthropicProvider from .ollama_provider import OllamaProvider +from .llamacpp_provider import LlamaCppProvider PROVIDERS = { 'anthropic': AnthropicProvider, 'ollama': OllamaProvider, + 'llamacpp': LlamaCppProvider, } @@ -21,4 +23,4 @@ def get_provider(config: dict) -> LLMProvider: return PROVIDERS[provider_name](provider_config) -__all__ = ['LLMProvider', 'AnthropicProvider', 'OllamaProvider', 'get_provider'] +__all__ = ['LLMProvider', 'AnthropicProvider', 'OllamaProvider', 'LlamaCppProvider', 'get_provider'] diff --git a/jrnl/llm_providers/anthropic_provider.py b/jrnl/llm_providers/anthropic_provider.py index 2e10460..fcc0ebf 100644 --- a/jrnl/llm_providers/anthropic_provider.py +++ b/jrnl/llm_providers/anthropic_provider.py @@ -11,47 +11,40 @@ class AnthropicProvider(LLMProvider): def __init__(self, config: Dict): super().__init__(config) - self.api_key = config.get('api_key', '') + self.api_key = config.get("api_key", "") if not self.api_key: - raise ValueError("Anthropic API key not configured. Run: jrnl config set anthropic api_key YOUR_KEY") + raise ValueError( + "Anthropic API key not configured. Run: jrnl config set anthropic api_key YOUR_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) - 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 _send_message(self, prompt: dict, max_tokens=200) -> dict: res = requests.post( "https://api.anthropic.com/v1/messages", headers={ - 'x-api-key': self.api_key, - 'anthropic-version': '2023-06-01', - 'Content-Type': 'application/json' + "x-api-key": self.api_key, + "anthropic-version": "2023-06-01", + "Content-Type": "application/json", }, json={ - 'model': self.model, - 'max_tokens': max_tokens, - 'temperature': 0.3, - 'messages': [ - { - 'role': 'user', - 'content': [ - { - 'type': 'text', - 'text': prompt - } - ] - } - ] - } + "model": self.model, + "max_tokens": max_tokens, + "temperature": 0.3, + "messages": [ + {"role": "user", "content": [{"type": "text", "text": prompt}]} + ], + }, ) return res.json() - + 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 + commit_message=commit_message, commit_diff=commit_diff ) try: @@ -59,8 +52,8 @@ class AnthropicProvider(LLMProvider): prompt=prompt, max_tokens=self.max_tokens_commit, ) - - return message.get('content', [])[0].get('text').strip() + + return message.get("content", [])[0].get("text").strip() except IndexError as e: return f"[Anthropic Error] {commit_message}" except Exception as e: @@ -69,26 +62,24 @@ class AnthropicProvider(LLMProvider): 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 + 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._send_message( prompt=prompt, max_tokens=self.max_tokens_daily, ) - - return message.get('content', [])[0].get('text').strip() + + return message.get("content", [])[0].get("text").strip() except IndexError as e: - return f"[Anthropic Error] Failed to generate daily: {type(e).__name__}: {e}" + return ( + f"[Anthropic Error] Failed to generate daily: {type(e).__name__}: {e}" + ) except Exception as e: raise RuntimeError(f"Failed to generate daily: {type(e).__name__}: {e}") @@ -98,7 +89,7 @@ class AnthropicProvider(LLMProvider): self.client.messages.create( model=self.model, max_tokens=10, - messages=[{"role": "user", "content": "test"}] + messages=[{"role": "user", "content": "test"}], ) return True except Exception: diff --git a/jrnl/llm_providers/llamacpp_provider.py b/jrnl/llm_providers/llamacpp_provider.py new file mode 100644 index 0000000..495ccac --- /dev/null +++ b/jrnl/llm_providers/llamacpp_provider.py @@ -0,0 +1,219 @@ +"""llama.cpp local LLM provider.""" + +from typing import Dict, List, Optional, Any, Iterator, TYPE_CHECKING +from .base import LLMProvider +from .prompts import COMPRESS_COMMIT_PROMPT, GENERATE_DAILY_PROMPT +import json +import os +import sys +from contextlib import contextmanager + +if TYPE_CHECKING: + from llama_cpp import Llama + + LlamaType = Llama +else: + try: + from llama_cpp import Llama + + LlamaType = Llama + except ImportError: + Llama = None # type: ignore + LlamaType = Any # type: ignore + + +@contextmanager +def suppress_stderr() -> Iterator[None]: + """ + Context manager to temporarily suppress stderr output. + + Useful for silencing verbose library initialization messages. + + Yields: + None + + Example: + with suppress_stderr(): + # Code that writes to stderr + pass + """ + original_stderr = sys.stderr + try: + sys.stderr = open(os.devnull, "w") + yield + finally: + sys.stderr.close() + sys.stderr = original_stderr + + +class LlamaCppProvider(LLMProvider): + """LLM provider using llama.cpp Python bindings.""" + + # Instance variable type hints + model_dir: str + model_name: str + model_path: str + max_tokens_commit: int + max_tokens_daily: int + n_ctx: int + n_threads: Optional[int] + _llama_instance: Optional[LlamaType] + _debug: bool + + def test_connection(self) -> bool: + """ + Test if model is accessible and can be loaded. + + Returns: + True if model file exists and can be initialized, False otherwise + """ + try: + model_exists = os.path.isfile(self._get_model_path()) + if not model_exists: + return False + # Try to initialize the model + _ = self._get_llama_instance() + return True + except Exception: + return False + + def set_debug(self, debug: bool) -> None: + """Enable or disable debug mode.""" + self._debug = debug + + def __init__(self, config: Dict[str, Any]) -> None: + """Initialize LlamaCpp provider with configuration.""" + super().__init__(config) + # Model directory and default model + self.model_dir = config.get( + "model_dir", f"{os.path.expanduser('~')}/.llamacpp/models" + ) + self.model_name = config.get("model_name", "Qwen3-8B.Q4_K_M.gguf") + self.model_path = config.get( + "model_path", f"{self.model_dir}/{self.model_name}" + ) + self.max_tokens_commit = config.get("max_tokens_commit", 200) + self.max_tokens_daily = config.get("max_tokens_daily", 500) + self._llama_instance: Optional[LlamaType] = None + # n_ctx=0 uses the model's maximum context size from metadata + self.n_ctx = config.get("n_ctx", 0) # 0 = use model's max context + self.n_threads = config.get("n_threads", None) # CPU threads (None = auto) + self._debug = False + + def _get_model_path(self) -> str: + """Get the full path to the model file.""" + return self.model_path + + def _get_llama_instance(self) -> LlamaType: + """ + Get or create Llama instance (lazy loading). + + Returns: + Initialized Llama model instance + + Raises: + Exception: If model file doesn't exist or fails to load + """ + if self._llama_instance is None: + debug_mode = getattr(self, "_debug", False) + if debug_mode: + print(f"[DEBUG] Loading model: {self._get_model_path()}") + ctx_msg = "model's maximum" if self.n_ctx == 0 else str(self.n_ctx) + print(f"[DEBUG] Context window: {ctx_msg}") + + # Suppress stderr output unless in debug mode + if debug_mode: + self._llama_instance = Llama( + model_path=self._get_model_path(), + n_ctx=self.n_ctx, + n_threads=self.n_threads, + verbose=False, + ) + else: + with suppress_stderr(): + self._llama_instance = Llama( + model_path=self._get_model_path(), + n_ctx=self.n_ctx, + n_threads=self.n_threads, + verbose=False, + ) + + if debug_mode: + actual_ctx = self._llama_instance.n_ctx() + print(f"[DEBUG] Actual context size loaded: {actual_ctx}") + return self._llama_instance + + def _run_llamacpp(self, prompt: str, max_tokens: int) -> str: + """ + Run inference using llama-cpp-python bindings. + + Args: + prompt: The prompt to send to the model + max_tokens: Maximum number of tokens to generate + + Returns: + Generated text response from the model + """ + if getattr(self, "_debug", False): + print(f"[DEBUG] llama.cpp prompt: {prompt}") + print(f"[DEBUG] max_tokens: {max_tokens}") + try: + llama = self._get_llama_instance() + output = llama.create_completion( + prompt, + max_tokens=max_tokens, + echo=False, + repeat_penalty=1, + temperature=1.0, + stop=["\n\n"], + ) + if getattr(self, "_debug", False): + print(f"[DEBUG] llama.cpp output: {output}") + + # Extract text from response + text: str + if isinstance(output, dict) and "choices" in output: + text = output["choices"][0]["text"] + elif isinstance(output, dict) and "text" in output: + text = output["text"] + else: + text = str(output) + + return text.strip() + except Exception as e: + if getattr(self, "_debug", False): + print(f"[DEBUG] llama.cpp error: {e}") + import traceback + + traceback.print_exc() + return f"llama.cpp error: {e}" + + def compress_commit(self, commit_message: str, commit_diff: str) -> str: + """ + Compress a git commit into a concise summary. + + Args: + commit_message: The original commit message + commit_diff: The commit diff content + + Returns: + Compressed commit summary + """ + prompt = COMPRESS_COMMIT_PROMPT.format( + commit_message=commit_message, commit_diff=commit_diff + ) + return self._run_llamacpp(prompt, self.max_tokens_commit) + + def generate_daily(self, logs: List[Dict[str, Any]], days: int = 1) -> str: + """ + Generate a daily standup summary from log entries. + + Args: + logs: List of log entry dictionaries + days: Number of days to include in the summary + + Returns: + Generated daily standup summary + """ + prompt = GENERATE_DAILY_PROMPT.format(logs=json.dumps(logs), days=days) + return self._run_llamacpp(prompt, self.max_tokens_daily) diff --git a/jrnl/llm_providers/prompts.py b/jrnl/llm_providers/prompts.py index 6c27bde..96e42f9 100644 --- a/jrnl/llm_providers/prompts.py +++ b/jrnl/llm_providers/prompts.py @@ -33,6 +33,6 @@ Create a compact paragraph (3-5 sentences) covering: 2. What's planned next, if this is deductable from the information. 3. Any obstacles or blockers (mention if none). Good indication of a blocker is commit message or todo-comments. -Keep it professional but conversational. Use past tense for completed work. +Keep it professional but conversational. Use past tense for completed work. Do not give instructions to developer, just summarize the work like you would be participating the standup meeting yourself. Your standup summary:""" diff --git a/jrnl/utils/git_utils.py b/jrnl/utils/git_utils.py new file mode 100644 index 0000000..6d714bc --- /dev/null +++ b/jrnl/utils/git_utils.py @@ -0,0 +1,74 @@ +"""Git utilities using GitPython library.""" + +from typing import Optional, Dict +import os +from git import Repo, exc as git_exc + + +def get_repo_root(path: Optional[str] = None) -> Optional[str]: + """ + Get the root directory of a git repository. + + Args: + path: Path to search from (defaults to current directory) + + Returns: + Absolute path to repository root, or None if not in a git repo + """ + try: + if path: + repo = Repo(path, search_parent_directories=True) + else: + repo = Repo(os.getcwd(), search_parent_directories=True) + return repo.working_dir + except (git_exc.InvalidGitRepositoryError, git_exc.NoSuchPathError): + return None + except Exception: + return None + + +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: + repo = Repo(repo_path) + commit = repo.commit(commit_hash) + + # Get commit message + commit_message = commit.message.strip() + + # Get commit diff with context + # Get diff against parent (or empty tree for first commit) + if commit.parents: + diff_text = repo.git.show(commit_hash, unified=3, no_color=True) + else: + # First commit - diff against empty tree + diff_text = repo.git.show(commit_hash, unified=3, no_color=True) + + # Truncate large diffs to avoid exceeding LLM context limits + MAX_DIFF_SIZE = 50000 # characters + if len(diff_text) > MAX_DIFF_SIZE: + diff_text = diff_text[:MAX_DIFF_SIZE] + "\n... (diff truncated for size)" + + return { + "hash": commit_hash, + "message": commit_message, + "diff": diff_text, + "repo": repo_path, + } + except ( + git_exc.InvalidGitRepositoryError, + git_exc.BadName, + git_exc.GitCommandError, + ): + return None + except Exception: + return None diff --git a/requirements.txt b/requirements.txt index 0eb8cae..539cb95 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,3 @@ requests>=2.31.0 +llama-cpp-python>=0.2.0 +GitPython>=3.1.0