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 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` Configuration file: `~/.jrnl/config.json`
## Usage ## Usage
@@ -84,6 +104,10 @@ jrnl config
# Switch LLM provider # Switch LLM provider
jrnl config set-provider ollama jrnl config set-provider ollama
# Switch LLM provider
jrnl config set-provider ollama
jrnl config set-provider llamacpp
# Set provider-specific settings # Set provider-specific settings
jrnl config set anthropic model claude-3-5-sonnet-20241022 jrnl config set anthropic model claude-3-5-sonnet-20241022
jrnl config set anthropic max_tokens_daily 1000 jrnl config set anthropic max_tokens_daily 1000

View File

@@ -2,26 +2,26 @@
import sys import sys
import argparse 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__ from .version import __version__
def create_parser(): def create_parser():
"""Create the argument parser with all subcommands.""" """Create the argument parser with all subcommands."""
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog='jrnl', prog="jrnl",
description='Developer work journal for standups - automatically track your work with git hooks and LLM-powered summaries', 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.' 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 # jrnl new
new_parser = subparsers.add_parser( new_parser = subparsers.add_parser(
'new', "new",
help='Create a log entry', help="Create a log entry",
epilog=''' epilog="""
Examples: Examples:
# Create a manual log entry # Create a manual log entry
jrnl new -m "fixed authentication bug" jrnl new -m "fixed authentication bug"
@@ -33,22 +33,30 @@ Examples:
for hash in $(git log -5 --format=%%H); do for hash in $(git log -5 --format=%%H); do
jrnl new --git --repo-path "$(pwd)" --commit-hash "$hash" jrnl new --git --repo-path "$(pwd)" --commit-hash "$hash"
done 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 # jrnl daily / standup
daily_parser = subparsers.add_parser( daily_parser = subparsers.add_parser(
'daily', "daily",
aliases=['standup'], aliases=["standup"],
help='Generate standup message from your logs', help="Generate standup message from your logs",
epilog=''' epilog="""
Examples: Examples:
# Generate today's standup # Generate today's standup
jrnl daily jrnl daily
@@ -63,21 +71,33 @@ Examples:
jrnl daily --delete 2024-12-12 jrnl daily --delete 2024-12-12
jrnl daily --delete today jrnl daily --delete today
jrnl daily --delete latest 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 # jrnl logs
logs_parser = subparsers.add_parser( logs_parser = subparsers.add_parser(
'logs', "logs",
help='View log entries', help="View log entries",
epilog=''' epilog="""
Examples: Examples:
# View last 50 logs (default) # View last 50 logs (default)
jrnl logs jrnl logs
@@ -90,21 +110,28 @@ Examples:
# Delete a log entry by hash/label # Delete a log entry by hash/label
jrnl logs --delete 5a546f30 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 # jrnl config
config_parser = subparsers.add_parser( config_parser = subparsers.add_parser(
'config', "config",
help='Manage configuration', help="Manage configuration",
epilog=''' epilog="""
Examples: Examples:
# Show current configuration # Show current configuration
jrnl config jrnl config
@@ -123,18 +150,56 @@ Examples:
# Re-enable a repository # Re-enable a repository
jrnl config include /path/to/repo 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 # jrnl uninstall
uninstall_parser = subparsers.add_parser('uninstall', help='Uninstall jrnl') uninstall_parser = subparsers.add_parser("uninstall", help="Uninstall jrnl")
uninstall_parser.add_argument('--no-backup', action='store_true', uninstall_parser.add_argument(
help='Skip database backup') "--no-backup", action="store_true", help="Skip database backup"
)
return parser return parser
@@ -150,15 +215,17 @@ def main():
try: try:
# Route to appropriate command handler # Route to appropriate command handler
if args.command == 'new': if args.command == "new":
return new.handle(args) return new.handle(args)
elif args.command in ['daily', 'standup']: elif args.command in ["daily", "standup"]:
return daily.handle(args) return daily.handle(args)
elif args.command == 'logs': elif args.command == "logs":
return logs.handle(args) return logs.handle(args)
elif args.command == 'config': elif args.command == "config":
return config_cmd.handle(args) 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) return uninstall_cmd.handle(args)
else: else:
parser.print_help() parser.print_help()
@@ -170,9 +237,10 @@ def main():
except Exception as e: except Exception as e:
print(f"Unexpected error: {type(e).__name__}: {e}", file=sys.stderr) print(f"Unexpected error: {type(e).__name__}: {e}", file=sys.stderr)
import traceback import traceback
traceback.print_exc() traceback.print_exc()
return 1 return 1
if __name__ == '__main__': if __name__ == "__main__":
sys.exit(main()) sys.exit(main())

View File

@@ -1,26 +1,26 @@
"""jrnl config command - Manage configuration.""" """jrnl config command - Manage configuration."""
import os import os
import subprocess
from ..config import Config from ..config import Config
from ..utils.formatting import format_success from ..utils.formatting import format_success
from ..utils.git_utils import get_repo_root
def handle(args): def handle(args):
"""Handle the 'config' command.""" """Handle the 'config' command."""
config = Config.load() config = Config.load()
if not args.action or args.action == 'show': if not args.action or args.action == "show":
return show_config(config) return show_config(config)
elif args.action == 'set-provider': elif args.action == "set-provider":
return set_provider(config, args) return set_provider(config, args)
elif args.action == 'set': elif args.action == "set":
return set_value(config, args) return set_value(config, args)
elif args.action == 'exclude': elif args.action == "exclude":
return exclude_repo(config, args) return exclude_repo(config, args)
elif args.action == 'include': elif args.action == "include":
return include_repo(config, args) return include_repo(config, args)
elif args.action == 'exclude-current': elif args.action == "exclude-current":
return exclude_current_repo(config) return exclude_current_repo(config)
else: else:
print(f"Unknown action: {args.action}") print(f"Unknown action: {args.action}")
@@ -36,11 +36,11 @@ def show_config(config):
# Show LLM provider settings # Show LLM provider settings
print("\n LLM Providers:") print("\n LLM Providers:")
providers = config.get('llm_providers', {}) providers = config.get("llm_providers", {})
for provider_name, provider_config in providers.items(): for provider_name, provider_config in providers.items():
print(f"\n {provider_name}:") print(f"\n {provider_name}:")
for key, value in provider_config.items(): 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 # Mask API key - show last 4 chars if long enough, otherwise full mask
if len(value) > 4: if len(value) > 4:
print(f" {key}: {'*' * 8}{value[-4:]}") print(f" {key}: {'*' * 8}{value[-4:]}")
@@ -50,7 +50,7 @@ def show_config(config):
print(f" {key}: {value}") print(f" {key}: {value}")
# Show excluded repos # Show excluded repos
excluded = config.get('excluded_repos', []) excluded = config.get("excluded_repos", [])
print(f"\n Excluded Repositories: {len(excluded)}") print(f"\n Excluded Repositories: {len(excluded)}")
for repo in excluded: for repo in excluded:
print(f" - {repo}") print(f" - {repo}")
@@ -65,12 +65,12 @@ def set_provider(config, args):
return 1 return 1
provider = args.args[0] 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"Unknown provider: {provider}")
print(f"Available: {', '.join(config.get('llm_providers', {}).keys())}") print(f"Available: {', '.join(config.get('llm_providers', {}).keys())}")
return 1 return 1
config['active_llm_provider'] = provider config["active_llm_provider"] = provider
Config.save(config) Config.save(config)
print(format_success(f"Active LLM provider set to {provider}")) print(format_success(f"Active LLM provider set to {provider}"))
return 0 return 0
@@ -84,13 +84,14 @@ def set_value(config, args):
provider, key, value = args.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}") print(f"Unknown provider: {provider}")
return 1 return 1
# Get allowed keys from default config # Get allowed keys from default config
from ..config import Config as ConfigClass 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: if key not in allowed_keys:
print(f"Unknown key '{key}' for provider '{provider}'") print(f"Unknown key '{key}' for provider '{provider}'")
@@ -101,16 +102,16 @@ def set_value(config, args):
if value.isdigit(): if value.isdigit():
value = int(value) value = int(value)
config['llm_providers'][provider][key] = value config["llm_providers"][provider][key] = value
Config.save(config) Config.save(config)
# Mask API key in output # Mask API key in output
display_value = value display_value = value
if key == 'api_key' and value: if key == "api_key" and value:
if len(str(value)) > 4: if len(str(value)) > 4:
display_value = f"{'*' * 8}{str(value)[-4:]}" display_value = f"{'*' * 8}{str(value)[-4:]}"
else: else:
display_value = '*' * len(str(value)) display_value = "*" * len(str(value))
print(format_success(f"Set {provider}.{key} = {display_value}")) print(format_success(f"Set {provider}.{key} = {display_value}"))
return 0 return 0
@@ -123,14 +124,14 @@ def exclude_repo(config, args):
return 1 return 1
repo_path = os.path.abspath(args.args[0]) repo_path = os.path.abspath(args.args[0])
excluded = config.get('excluded_repos', []) excluded = config.get("excluded_repos", [])
if repo_path in excluded: if repo_path in excluded:
print(f"Repository already excluded: {repo_path}") print(f"Repository already excluded: {repo_path}")
return 0 return 0
excluded.append(repo_path) excluded.append(repo_path)
config['excluded_repos'] = excluded config["excluded_repos"] = excluded
Config.save(config) Config.save(config)
print(format_success(f"Excluded repository: {repo_path}")) print(format_success(f"Excluded repository: {repo_path}"))
return 0 return 0
@@ -143,14 +144,14 @@ def include_repo(config, args):
return 1 return 1
repo_path = os.path.abspath(args.args[0]) repo_path = os.path.abspath(args.args[0])
excluded = config.get('excluded_repos', []) excluded = config.get("excluded_repos", [])
if repo_path not in excluded: if repo_path not in excluded:
print(f"Repository not in exclude list: {repo_path}") print(f"Repository not in exclude list: {repo_path}")
return 0 return 0
excluded.remove(repo_path) excluded.remove(repo_path)
config['excluded_repos'] = excluded config["excluded_repos"] = excluded
Config.save(config) Config.save(config)
print(format_success(f"Re-enabled repository: {repo_path}")) print(format_success(f"Re-enabled repository: {repo_path}"))
return 0 return 0
@@ -160,33 +161,29 @@ def exclude_current_repo(config):
"""Exclude current repository.""" """Exclude current repository."""
try: try:
# Get current git repo path # Get current git repo path
result = subprocess.run( repo_path = get_repo_root()
['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 is None:
print("Error: Not in a git repository")
return 1
excluded = config.get("excluded_repos", [])
if repo_path in excluded: if repo_path in excluded:
print(f"Current repository already excluded: {repo_path}") print(f"Current repository already excluded: {repo_path}")
return 0 return 0
excluded.append(repo_path) excluded.append(repo_path)
config['excluded_repos'] = excluded config["excluded_repos"] = excluded
Config.save(config) Config.save(config)
print(format_success(f"Excluded current repository: {repo_path}")) print(format_success(f"Excluded current repository: {repo_path}"))
return 0 return 0
except subprocess.CalledProcessError:
print("Error: Not in a git repository")
return 1
except RuntimeError as e: # From Config class except RuntimeError as e: # From Config class
print(f"Error: {e}") print(f"Error: {e}")
return 1 return 1
except Exception as e: except Exception as e:
print(f"Unexpected error: {type(e).__name__}: {e}") print(f"Unexpected error: {type(e).__name__}: {e}")
import traceback import traceback
traceback.print_exc() traceback.print_exc()
return 1 return 1

View File

@@ -7,7 +7,7 @@ from ..database.operations import (
get_previous_daily_before, get_previous_daily_before,
get_daily_for_date, get_daily_for_date,
insert_daily, insert_daily,
delete_daily delete_daily,
) )
from ..database.models import Daily from ..database.models import Daily
from ..config import Config from ..config import Config
@@ -20,7 +20,7 @@ def handle(args):
"""Handle the 'daily' command.""" """Handle the 'daily' command."""
try: try:
# Handle deletion if requested # Handle deletion if requested
if hasattr(args, 'delete') and args.delete: if hasattr(args, "delete") and args.delete:
return handle_delete(args.delete) return handle_delete(args.delete)
config = Config.load() config = Config.load()
@@ -29,7 +29,8 @@ def handle(args):
if args.regenerate: if args.regenerate:
cutoff = get_regenerate_cutoff() cutoff = get_regenerate_cutoff()
else: else:
cutoff = get_normal_cutoff() # Use days parameter to determine cutoff
cutoff = get_datetime_ago(days=args.days)
# Get logs # Get logs
logs = get_logs_since(cutoff) logs = get_logs_since(cutoff)
@@ -49,16 +50,13 @@ def handle(args):
# Generate daily message # Generate daily message
print("Generating standup (this may take 10-30 seconds)...") print("Generating standup (this may take 10-30 seconds)...")
daily_message = provider.generate_daily( daily_message = provider.generate_daily(
logs=[log.to_dict() for log in logs], logs=[log.to_dict() for log in logs], days=args.days
days=args.days
) )
# Save daily # Save daily
today = get_current_date() today = get_current_date()
daily = Daily( daily = Daily(
timestamp=get_utc_now(), timestamp=get_utc_now(), daily_date=today, daily_message=daily_message
daily_date=today,
daily_message=daily_message
) )
insert_daily(daily) insert_daily(daily)
@@ -79,6 +77,7 @@ def handle(args):
except Exception as e: except Exception as e:
print(f"Unexpected error generating daily: {e}") print(f"Unexpected error generating daily: {e}")
import traceback import traceback
traceback.print_exc() traceback.print_exc()
return 1 return 1
@@ -87,9 +86,9 @@ def handle_delete(date_arg: str) -> int:
"""Handle deleting a daily entry.""" """Handle deleting a daily entry."""
try: try:
# Resolve date argument # 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 # Get today's date or latest daily date
if date_arg.lower() == 'today': if date_arg.lower() == "today":
date = get_current_date() date = get_current_date()
else: else:
latest = get_latest_daily() latest = get_latest_daily()

View File

@@ -1,13 +1,13 @@
"""jrnl new command - Create log entries.""" """jrnl new command - Create log entries."""
import uuid import uuid
import subprocess
import sqlite3 import sqlite3
from pathlib import Path from pathlib import Path
from ..database.operations import insert_log from ..database.operations import insert_log
from ..database.models import Log from ..database.models import Log
from ..utils.date_utils import get_utc_now from ..utils.date_utils import get_utc_now
from ..utils.formatting import format_success, format_error from ..utils.formatting import format_success, format_error
from ..utils.git_utils import extract_commit_info
from ..config import Config from ..config import Config
from ..llm_providers import get_provider from ..llm_providers import get_provider
@@ -34,8 +34,8 @@ def handle_manual_mode(args):
log = Log( log = Log(
timestamp=get_utc_now(), timestamp=get_utc_now(),
log_message=args.message, log_message=args.message,
type='manual', type="manual",
label=args.label or str(uuid.uuid4())[:8] label=args.label or str(uuid.uuid4())[:8],
) )
# Save to database # Save to database
@@ -53,6 +53,7 @@ def handle_manual_mode(args):
except Exception as e: except Exception as e:
print(format_error(f"Unexpected error: {e}")) print(format_error(f"Unexpected error: {e}"))
import traceback import traceback
traceback.print_exc() traceback.print_exc()
return 1 return 1
@@ -68,24 +69,35 @@ def handle_git_mode(args):
# Extract commit info # Extract commit info
commit_info = extract_commit_info(args.repo_path, args.commit_hash) commit_info = extract_commit_info(args.repo_path, args.commit_hash)
if not commit_info: if not commit_info:
if getattr(args, "debug", False):
print("[DEBUG] Failed to extract commit info.")
return 1 # Silently fail return 1 # Silently fail
# Load config and get LLM provider # Load config and get LLM provider
config = Config.load() config = Config.load()
provider = get_provider(config) 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 # Compress commit info
if hasattr(provider, "set_debug"):
provider.set_debug(True)
log_message = provider.compress_commit( log_message = provider.compress_commit(
commit_message=commit_info['message'], commit_message=commit_info["message"], commit_diff=commit_info["diff"]
commit_diff=commit_info['diff']
) )
if getattr(args, "debug", False):
print(f"[DEBUG] LLM output: {log_message}")
# Create log entry # Create log entry
log = Log( log = Log(
timestamp=get_utc_now(), timestamp=get_utc_now(),
log_message=log_message, log_message=log_message,
type='git-hook', type="git-hook",
label=commit_info['hash'][:8] label=commit_info["hash"][:8],
) )
# Save to database # Save to database
@@ -95,63 +107,26 @@ def handle_git_mode(args):
except Exception as e: except Exception as e:
# Log error but don't fail (never block commits) # 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}") log_error(f"Error processing commit: {type(e).__name__}: {e}")
return 0 # Return success to avoid blocking commit return 0 # Return success to avoid blocking commit
def extract_commit_info(repo_path: str, commit_hash: str) -> dict: # extract_commit_info is now imported from utils.git_utils
"""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): def log_error(message: str):
"""Log error to error log file.""" """Log error to error log file."""
try: 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) 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 from datetime import datetime
f.write(f"{datetime.now().isoformat()} - {message}\n") f.write(f"{datetime.now().isoformat()} - {message}\n")
except Exception: except Exception:
pass # Silently fail logging 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.""" """jrnl uninstall command - Uninstall the application."""
import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
from ..utils.git_utils import get_repo_root
def handle(args): def handle(args):
@@ -12,24 +12,27 @@ def handle(args):
# Confirm # Confirm
response = input("\nContinue with uninstall? (y/N): ") response = input("\nContinue with uninstall? (y/N): ")
if response.lower() not in ['y', 'yes']: if response.lower() not in ["y", "yes"]:
print("Uninstall cancelled") print("Uninstall cancelled")
return 0 return 0
# Find and run uninstall.sh # Find and run uninstall.sh
# Assume it's in the git repo root # Assume it's in the git repo root
try: try:
result = subprocess.run( repo_root_str = get_repo_root()
['git', 'rev-parse', '--show-toplevel'], if repo_root_str is None:
capture_output=True, print("Error: Could not locate uninstall script")
text=True, print("Please run: ./uninstall.sh from the jrnl repository")
check=True return 1
)
repo_root = Path(result.stdout.strip()) repo_root = Path(repo_root_str)
uninstall_script = repo_root / 'uninstall.sh' uninstall_script = repo_root / "uninstall.sh"
if uninstall_script.exists(): 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 return 0
else: else:
print(f"Error: uninstall.sh not found at {uninstall_script}") print(f"Error: uninstall.sh not found at {uninstall_script}")
@@ -37,6 +40,9 @@ def handle(args):
return 1 return 1
except subprocess.CalledProcessError: 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") print("Please run: ./uninstall.sh from the jrnl repository")
return 1 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 pathlib import Path
from typing import Dict, Any from typing import Dict, Any
class Config: class Config:
"""Configuration manager.""" """Configuration manager."""
CONFIG_PATH = Path.home() / '.jrnl' / 'config.json' CONFIG_PATH = Path.home() / ".jrnl" / "config.json"
DEFAULT_CONFIG = { DEFAULT_CONFIG = {
'active_llm_provider': 'anthropic', "active_llm_provider": "anthropic",
'llm_providers': { "llm_providers": {
'anthropic': { "anthropic": {
'api_key': '', "api_key": "",
'model': 'claude-sonnet-4-5-20250929', "model": "claude-sonnet-4-5-20250929",
'max_tokens_commit': 200, "max_tokens_commit": 200,
'max_tokens_daily': 500 "max_tokens_daily": 500,
}, },
'ollama': { "ollama": {
'url': 'http://localhost:11434', "url": "http://localhost:11434",
'model': 'llama3.1:8b', "model": "llama3.1:8b",
'max_tokens_commit': 200, "max_tokens_commit": 200,
'max_tokens_daily': 500 "max_tokens_daily": 500,
}
}, },
'git_hooks_enabled': True, "llamacpp": {
'excluded_repos': [], "model_dir": f"{str(Path.home())}/.llamacpp/models",
'standup_time': '10:30', "model_name": "Qwen3-8B.Q4_K_M.gguf",
'timezone': 'local' "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 @classmethod
@@ -38,7 +48,7 @@ class Config:
return cls.DEFAULT_CONFIG.copy() return cls.DEFAULT_CONFIG.copy()
try: try:
with open(cls.CONFIG_PATH, 'r') as f: with open(cls.CONFIG_PATH, "r") as f:
config = json.load(f) config = json.load(f)
# Merge with defaults (for new fields) # Merge with defaults (for new fields)
@@ -51,12 +61,14 @@ class Config:
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
print(f"Config file is corrupted at line {e.lineno}: {e.msg}") print(f"Config file is corrupted at line {e.lineno}: {e.msg}")
response = input("Recreate config with defaults? (y/N): ") response = input("Recreate config with defaults? (y/N): ")
if response.lower() == 'y': if response.lower() == "y":
cls.save(cls.DEFAULT_CONFIG.copy()) cls.save(cls.DEFAULT_CONFIG.copy())
return cls.DEFAULT_CONFIG.copy() return cls.DEFAULT_CONFIG.copy()
raise RuntimeError(f"Invalid config file at {cls.CONFIG_PATH}") raise RuntimeError(f"Invalid config file at {cls.CONFIG_PATH}")
except PermissionError as e: 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: except Exception as e:
raise RuntimeError(f"Failed to load config: {type(e).__name__}: {e}") raise RuntimeError(f"Failed to load config: {type(e).__name__}: {e}")
@@ -65,14 +77,16 @@ class Config:
"""Save configuration to file.""" """Save configuration to file."""
try: try:
cls.CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) 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) json.dump(config, f, indent=2)
# Set permissions to 600 for security (API keys) # Set permissions to 600 for security (API keys)
cls.CONFIG_PATH.chmod(0o600) cls.CONFIG_PATH.chmod(0o600)
except PermissionError: 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: except OSError as e:
raise RuntimeError(f"Failed to write config file: {e}") raise RuntimeError(f"Failed to write config file: {e}")
except Exception as e: except Exception as e:
@@ -96,7 +110,11 @@ class Config:
"""Deep merge two dictionaries.""" """Deep merge two dictionaries."""
result = base.copy() result = base.copy()
for key, value in updates.items(): 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) result[key] = cls._deep_merge(result[key], value)
else: else:
result[key] = value result[key] = value

View File

@@ -1,7 +1,7 @@
"""Git commit processing utilities.""" """Git commit processing utilities."""
import subprocess
from typing import Optional, Dict 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]: 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: Returns:
Dictionary with commit info or None if extraction fails Dictionary with commit info or None if extraction fails
""" """
try: # Delegate to the git_utils implementation
# Get commit message return _extract_commit_info(repo_path, commit_hash)
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: def get_repo_name(repo_path: str) -> str:
"""Get repository name from path.""" """Get repository name from path."""
from pathlib import Path from pathlib import Path
return Path(repo_path).name return Path(repo_path).name

View File

@@ -3,10 +3,12 @@
from .base import LLMProvider from .base import LLMProvider
from .anthropic_provider import AnthropicProvider from .anthropic_provider import AnthropicProvider
from .ollama_provider import OllamaProvider from .ollama_provider import OllamaProvider
from .llamacpp_provider import LlamaCppProvider
PROVIDERS = { PROVIDERS = {
'anthropic': AnthropicProvider, 'anthropic': AnthropicProvider,
'ollama': OllamaProvider, 'ollama': OllamaProvider,
'llamacpp': LlamaCppProvider,
} }
@@ -21,4 +23,4 @@ def get_provider(config: dict) -> LLMProvider:
return PROVIDERS[provider_name](provider_config) 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.""" """Anthropic/Claude LLM provider."""
# import anthropic
from typing import Dict, List from typing import Dict, List
from .base import LLMProvider from .base import LLMProvider
from .prompts import COMPRESS_COMMIT_PROMPT, GENERATE_DAILY_PROMPT from .prompts import COMPRESS_COMMIT_PROMPT, GENERATE_DAILY_PROMPT
@@ -12,39 +11,32 @@ class AnthropicProvider(LLMProvider):
def __init__(self, config: Dict): def __init__(self, config: Dict):
super().__init__(config) super().__init__(config)
self.api_key = config.get('api_key', '') self.api_key = config.get("api_key", "")
if not self.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.model = config.get('model', 'claude-sonnet-4-5-20250929') self.max_tokens_commit = config.get("max_tokens_commit", 200)
self.max_tokens_commit = config.get('max_tokens_commit', 200) self.max_tokens_daily = config.get("max_tokens_daily", 500)
self.max_tokens_daily = config.get('max_tokens_daily', 500)
def _send_message(self, prompt: dict, max_tokens=200) -> dict: def _send_message(self, prompt: dict, max_tokens=200) -> dict:
res = requests.post( res = requests.post(
"https://api.anthropic.com/v1/messages", "https://api.anthropic.com/v1/messages",
headers={ headers={
'x-api-key': self.api_key, "x-api-key": self.api_key,
'anthropic-version': '2023-06-01', "anthropic-version": "2023-06-01",
'Content-Type': 'application/json' "Content-Type": "application/json",
}, },
json={ json={
'model': self.model, "model": self.model,
'max_tokens': max_tokens, "max_tokens": max_tokens,
'temperature': 0.3, "temperature": 0.3,
'messages': [ "messages": [
{ {"role": "user", "content": [{"type": "text", "text": prompt}]}
'role': 'user', ],
'content': [ },
{
'type': 'text',
'text': prompt
}
]
}
]
}
) )
return res.json() return res.json()
@@ -52,8 +44,7 @@ class AnthropicProvider(LLMProvider):
def compress_commit(self, commit_message: str, commit_diff: str) -> str: def compress_commit(self, commit_message: str, commit_diff: str) -> str:
"""Compress commit using Claude.""" """Compress commit using Claude."""
prompt = COMPRESS_COMMIT_PROMPT.format( prompt = COMPRESS_COMMIT_PROMPT.format(
commit_message=commit_message, commit_message=commit_message, commit_diff=commit_diff
commit_diff=commit_diff
) )
try: try:
@@ -62,7 +53,7 @@ class AnthropicProvider(LLMProvider):
max_tokens=self.max_tokens_commit, 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: except IndexError as e:
return f"[Anthropic Error] {commit_message}" return f"[Anthropic Error] {commit_message}"
except Exception as e: except Exception as e:
@@ -71,26 +62,24 @@ class AnthropicProvider(LLMProvider):
def generate_daily(self, logs: List[Dict], days: int = 1) -> str: def generate_daily(self, logs: List[Dict], days: int = 1) -> str:
"""Generate daily standup using Claude.""" """Generate daily standup using Claude."""
# Format logs for prompt # Format logs for prompt
log_text = "\n".join([ log_text = "\n".join(
f"- [{log['type']}] {log['log_message']}" [f"- [{log['type']}] {log['log_message']}" for log in logs]
for log in logs
])
prompt = GENERATE_DAILY_PROMPT.format(
days=days,
logs=log_text
) )
prompt = GENERATE_DAILY_PROMPT.format(days=days, logs=log_text)
try: try:
message = self._send_message( message = self._send_message(
prompt=prompt, prompt=prompt,
max_tokens=self.max_tokens_daily, 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: 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: except Exception as e:
raise RuntimeError(f"Failed to generate daily: {type(e).__name__}: {e}") raise RuntimeError(f"Failed to generate daily: {type(e).__name__}: {e}")
@@ -100,7 +89,7 @@ class AnthropicProvider(LLMProvider):
self.client.messages.create( self.client.messages.create(
model=self.model, model=self.model,
max_tokens=10, max_tokens=10,
messages=[{"role": "user", "content": "test"}] messages=[{"role": "user", "content": "test"}],
) )
return True return True
except Exception: 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. 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. 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:""" 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 requests>=2.31.0
llama-cpp-python>=0.2.0
GitPython>=3.1.0