diff --git a/bin/log_append.py b/bin/log_append.py new file mode 100644 index 0000000..71e4167 --- /dev/null +++ b/bin/log_append.py @@ -0,0 +1,30 @@ +"""Append-only experiment log utilities.""" +import json +import os +from datetime import datetime, timezone + +LOG_PATH = os.path.expanduser('~/.claude/autoresearch/log.jsonl') + + +def append_entry(entry: dict, path: str = LOG_PATH) -> None: + entry = dict(entry) # don't mutate caller's dict + entry.setdefault('ts', datetime.now(timezone.utc).isoformat()) + os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) + with open(path, 'a') as f: + f.write(json.dumps(entry) + '\n') + + +def read_entries(path: str = LOG_PATH) -> list: + if not os.path.exists(path): + return [] + entries = [] + with open(path) as f: + for line in f: + line = line.strip() + if not line: + continue + try: + entries.append(json.loads(line)) + except json.JSONDecodeError: + pass + return entries diff --git a/bin/log_view.py b/bin/log_view.py new file mode 100644 index 0000000..9657182 --- /dev/null +++ b/bin/log_view.py @@ -0,0 +1,59 @@ +"""Live log viewer for overview pane. Run: python3 bin/log_view.py [--follow]""" +import sys +import time +import os + +sys.path.insert(0, os.path.dirname(__file__)) +from log_append import read_entries + +from rich.console import Console +from rich.table import Table +from rich import box + +LOG_PATH = os.path.expanduser('~/.claude/autoresearch/log.jsonl') +console = Console() + + +def render_table(entries): + table = Table(box=box.SIMPLE, show_header=True, header_style="bold #DA251C") + table.add_column("Time", style="dim", width=8) + table.add_column("Project", width=14) + table.add_column("Exp", width=6) + table.add_column("Description", width=42) + table.add_column("Δ", width=8, justify="right") + table.add_column("", width=2) + for e in entries[-40:]: + ts = (e.get('ts') or '')[11:16] # HH:MM from ISO8601 + before = e.get('metric_before') + after = e.get('metric_after') + if before is not None and after is not None: + delta = f"{after - before:+.3f}" + delta_style = "green" if after < before else "red" + else: + delta, delta_style = "n/a", "dim" + kept = "✓" if e.get('kept') else "✗" + kept_style = "green" if e.get('kept') else "red" + table.add_row( + ts, + (e.get('project') or '?')[:14], + str(e.get('exp') or '?'), + (e.get('description') or '')[:42], + f"[{delta_style}]{delta}[/{delta_style}]", + f"[{kept_style}]{kept}[/{kept_style}]", + ) + return table + + +if __name__ == '__main__': + follow = '--follow' in sys.argv + while True: + entries = read_entries() + console.clear() + console.print( + f"[bold #DA251C]autoresearch[/bold #DA251C] — {len(entries)} experiments", + justify="center" + ) + console.print(render_table(entries)) + if not follow: + break + time.sleep(5) diff --git a/tests/test_log.py b/tests/test_log.py new file mode 100644 index 0000000..94b4449 --- /dev/null +++ b/tests/test_log.py @@ -0,0 +1,44 @@ +import sys, os, json, tempfile, pytest +sys.path.insert(0, 'bin') +from log_append import append_entry, read_entries, LOG_PATH + +def test_append_creates_file(tmp_path): + log = str(tmp_path / "log.jsonl") + append_entry({"project": "test"}, path=log) + assert os.path.exists(log) + +def test_append_writes_valid_json(tmp_path): + log = str(tmp_path / "log.jsonl") + append_entry({"project": "x", "kept": True}, path=log) + with open(log) as f: + data = json.loads(f.read().strip()) + assert data["project"] == "x" + assert data["kept"] is True + +def test_append_adds_timestamp(tmp_path): + log = str(tmp_path / "log.jsonl") + append_entry({"project": "x"}, path=log) + with open(log) as f: + data = json.loads(f.read().strip()) + assert "ts" in data + +def test_append_multiple_entries(tmp_path): + log = str(tmp_path / "log.jsonl") + for i in range(3): + append_entry({"n": i}, path=log) + entries = read_entries(path=log) + assert len(entries) == 3 + assert entries[1]["n"] == 1 + +def test_read_entries_empty_if_no_file(tmp_path): + log = str(tmp_path / "nonexistent.jsonl") + assert read_entries(path=log) == [] + +def test_read_entries_skips_malformed_lines(tmp_path): + log = str(tmp_path / "log.jsonl") + with open(log, 'w') as f: + f.write('{"project": "ok"}\n') + f.write('not-valid-json\n') + f.write('{"project": "also-ok"}\n') + entries = read_entries(path=log) + assert len(entries) == 2