Compare commits

...

2 Commits

Author SHA1 Message Date
Niki Vihtola
0ee34eb56d update working solution for llamacpp for non thinking models 2026-03-13 15:22:37 +02:00
Niki Vihtola
3a13161ec6 removed anthropic from requirements, replaced sdk with direct rest call 2025-12-17 08:14:58 +02:00
15 changed files with 736 additions and 280 deletions

View File

@@ -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
@@ -84,6 +104,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
jrnl config set anthropic max_tokens_daily 1000

View File

@@ -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 <command> --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 <command> --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())

View File

@@ -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

View File

@@ -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)
@@ -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()

View File

@@ -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

123
jrnl/commands/regenerate.py Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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
}
"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'
"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,
},
},
"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

View File

@@ -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

View File

@@ -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']

View File

@@ -1,6 +1,5 @@
"""Anthropic/Claude LLM provider."""
# import anthropic
from typing import Dict, List
from .base import LLMProvider
from .prompts import COMPRESS_COMMIT_PROMPT, GENERATE_DAILY_PROMPT
@@ -12,39 +11,32 @@ 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.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)
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()
@@ -52,8 +44,7 @@ class AnthropicProvider(LLMProvider):
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:
@@ -62,7 +53,7 @@ class AnthropicProvider(LLMProvider):
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:
@@ -71,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}")
@@ -100,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:

View File

@@ -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)

View File

@@ -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:"""

74
jrnl/utils/git_utils.py Normal file
View File

@@ -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

View File

@@ -1,2 +1,3 @@
anthropic>=0.39.0
requests>=2.31.0
llama-cpp-python>=0.2.0
GitPython>=3.1.0