# reflection/nulib/logger.nu — JSONL action logger with config-driven rotation. # Config source: .ontoref/config.ncl → log { level, path, rotation, compress, archive, max_files } # Env override: ONTOREF_LOG_LEVEL overrides config level if set (for CI/scripts). use ./shared.nu [project-root] use ../modules/store.nu [daemon-export-safe] def level-rank [level: string]: nothing -> int { match $level { "write" => 0, "read" => 1, "interactive" => 2, _ => 1, } } # Load log config from .ontoref/config.ncl, with defaults for missing/broken config. def load-log-config [root: string]: nothing -> record { let defaults = { level: "none", path: ".coder/actions.jsonl", rotation: "none", compress: false, archive: ".coder/archive", max_files: 10, } let config_file = $"($root)/.ontoref/config.ncl" if not ($config_file | path exists) { return $defaults } let cfg = (daemon-export-safe $config_file) if $cfg == null { return $defaults } let log = ($cfg.log? | default {}) { level: ($log.level? | default $defaults.level), path: ($log.path? | default $defaults.path), rotation: ($log.rotation? | default $defaults.rotation), compress: ($log.compress? | default $defaults.compress), archive: ($log.archive? | default $defaults.archive), max_files: ($log.max_files? | default $defaults.max_files), } } # Determine rotation suffix from current date and policy. def rotation-suffix [policy: string, ts: datetime]: nothing -> string { match $policy { "daily" => ($ts | format date "%Y-%m-%d"), "weekly" => ($ts | format date "%Y-W%V"), "monthly" => ($ts | format date "%Y-%m"), _ => "", } } # Check if the current log file needs rotation. Returns true if rotation boundary crossed. def needs-rotation [log_file: string, policy: string]: nothing -> bool { if $policy == "none" { return false } if not ($log_file | path exists) { return false } let stat = ls -l $log_file | first let file_modified = $stat.modified let now = (date now) let file_suffix = (rotation-suffix $policy $file_modified) let current_suffix = (rotation-suffix $policy $now) $file_suffix != $current_suffix } # Rotate: move current log to archive with date suffix, optionally compress, prune old files. def rotate-log [root: string, cfg: record] { let log_file = $"($root)/($cfg.path)" if not ($log_file | path exists) { return } let archive_dir = $"($root)/($cfg.archive)" if not ($archive_dir | path exists) { mkdir $archive_dir } let basename = ($log_file | path parse | get stem) let stat = ls -l $log_file | first let suffix = (rotation-suffix $cfg.rotation $stat.modified) let archive_name = $"($basename)-($suffix).jsonl" let archive_path = $"($archive_dir)/($archive_name)" mv $log_file $archive_path if $cfg.compress { let gz_result = do { ^gzip $archive_path } | complete if $gz_result.exit_code != 0 { let err_msg = ($gz_result.stderr | str trim) print -e $" warn: gzip failed for ($archive_path): ($err_msg)" } } # Prune: keep only max_files most recent archives let pattern = $"($archive_dir)/($basename)-*" let archives = (glob $pattern | sort -r) if ($archives | length) > $cfg.max_files { let to_remove = ($archives | skip $cfg.max_files) for f in $to_remove { rm $f } } } # ── Query commands (exported for dispatcher) ──────────────────────────────── # Show log config: path, resolved file, settings. export def log-show-config [] { let root = (project-root) let cfg = (load-log-config $root) let log_file = $"($root)/($cfg.path)" let archive_dir = $"($root)/($cfg.archive)" let file_exists = ($log_file | path exists) let file_size = if $file_exists { (ls $log_file | first | get size) } else { 0 } let line_count = if $file_exists { (open $log_file --raw | lines | where { $in | is-not-empty } | length) } else { 0 } let archive_count = if ($archive_dir | path exists) { let basename = ($log_file | path parse | get stem) glob $"($archive_dir)/($basename)-*" | length } else { 0 } print "" print $" (ansi white_bold)Log config(ansi reset) (ansi dark_gray)\(.ontoref/config.ncl → log\)(ansi reset)" print "" print $" (ansi cyan)level(ansi reset) ($cfg.level)" print $" (ansi cyan)path(ansi reset) ($cfg.path)" print $" (ansi cyan)rotation(ansi reset) ($cfg.rotation)" print $" (ansi cyan)compress(ansi reset) ($cfg.compress)" print $" (ansi cyan)archive(ansi reset) ($cfg.archive)" print $" (ansi cyan)max_files(ansi reset) ($cfg.max_files)" print "" print $" (ansi white_bold)Levels(ansi reset) (ansi dark_gray)\(cumulative — each includes all below\)(ansi reset)" print "" let levels = [ ["level" "logs" "rank"]; ["none" "nothing" "—"] ["write" "mutations only (add, done, apply, accept...)" "0"] ["read" "mutations + queries (list, show, status, describe...)" "0+1"] ["all" "mutations + queries + interactive (help, selectors)" "0+1+2"] ] let active_level = ($env.ONTOREF_LOG_LEVEL? | default $cfg.level) for row in $levels { let marker = if $row.level == $active_level { $"(ansi green_bold)●(ansi reset)" } else { $"(ansi dark_gray)○(ansi reset)" } print $" ($marker) (ansi cyan)($row.level | fill -w 6)(ansi reset) ($row.logs)" } if ($env.ONTOREF_LOG_LEVEL? | is-not-empty) { print "" print $" (ansi dark_gray)env override: ONTOREF_LOG_LEVEL=($env.ONTOREF_LOG_LEVEL)(ansi reset)" } print "" print $" (ansi white_bold)Current file(ansi reset)" print $" (ansi cyan)path(ansi reset) ($log_file)" print $" (ansi cyan)exists(ansi reset) ($file_exists)" print $" (ansi cyan)size(ansi reset) ($file_size)" print $" (ansi cyan)entries(ansi reset) ($line_count)" print $" (ansi cyan)archived(ansi reset) ($archive_count) files" print "" print $" (ansi white_bold)Columns(ansi reset) ts | author | actor | level | action" print "" } # Parse JSONL log entries from the active log file. # Guards against malformed lines: from json is an internal command and cannot # be captured with | complete, so we pre-filter to lines that look like objects. def load-entries [root: string, cfg: record]: nothing -> list { let log_file = $"($root)/($cfg.path)" if not ($log_file | path exists) { return [] } open $log_file --raw | lines | where { $in | str trim | is-not-empty } | where { ($in | str trim | str starts-with "{") and ($in | str trim | str ends-with "}") } | each { |line| try { $line | str trim | from json } catch { null } } | where { $in != null } } # Filter entries by --since/--until (ISO 8601 string comparison). def filter-time-range [entries: list, since: string, until: string]: nothing -> list { mut result = $entries if ($since | is-not-empty) { $result = ($result | where { |e| ($e.ts? | default "") >= $since }) } if ($until | is-not-empty) { $result = ($result | where { |e| ($e.ts? | default "") <= $until }) } $result } # Apply column:match-text query filters. Multiple queries are AND-combined. # Each query is "column:text" — matches if the column value contains text. def filter-query [entries: list, queries: list]: nothing -> list { mut result = $entries for q in $queries { let parts = ($q | split row ":" | collect) if ($parts | length) < 2 { continue } let col = ($parts | first) let pattern = ($parts | skip 1 | str join ":") $result = ($result | where { |e| let val = ($e | get -o $col | default "") ($val | str contains $pattern) }) } $result } # Format a single entry for terminal display. def fmt-entry [entry: record, timestamps: bool]: nothing -> string { let level_color = match ($entry.level? | default "read") { "write" => (ansi yellow), "read" => (ansi cyan), "interactive" => (ansi magenta), _ => (ansi default_dimmed), } let actor_str = $"(ansi dark_gray)($entry.actor? | default '?')(ansi reset)" let action_str = $"($entry.action? | default '')" if $timestamps { let ts_str = $"(ansi dark_gray)($entry.ts? | default '')(ansi reset)" $" ($ts_str) ($level_color)($entry.level? | default '?')(ansi reset) ($actor_str) ($action_str)" } else { $" ($level_color)($entry.level? | default '?')(ansi reset) ($actor_str) ($action_str)" } } # Query log entries with filters. Used by `strat log`. export def log-query [ --tail_n: int = -1, --since: string = "", --until: string = "", --latest, --timestamps (-t), --level: string = "", --actor: string = "", --query (-q): list = [], --fmt (-f): string = "text", ] { let root = (project-root) let cfg = (load-log-config $root) let log_file = $"($root)/($cfg.path)" if not ($log_file | path exists) { print " No log entries." return } mut entries = (load-entries $root $cfg) $entries = (filter-time-range $entries $since $until) if ($level | is-not-empty) { $entries = ($entries | where { |e| ($e.level? | default "") == $level }) } if ($actor | is-not-empty) { $entries = ($entries | where { |e| ($e.actor? | default "") == $actor }) } if ($query | is-not-empty) { $entries = (filter-query $entries $query) } if $latest { $entries = if ($entries | is-empty) { [] } else { [($entries | last)] } } else if $tail_n > 0 { let total = ($entries | length) if $total > $tail_n { $entries = ($entries | skip ($total - $tail_n)) } } match $fmt { "json" => { print ($entries | to json) }, "jsonl" => { for e in $entries { print ($e | to json -r) } }, "table" => { print ($entries | table --expand) }, _ => { if ($entries | is-empty) { print " No log entries." return } for e in $entries { print (fmt-entry $e $timestamps) } }, } } # Follow log output — polls for new entries every 2 seconds. export def log-follow [--timestamps (-t), --query (-q): list = []] { let root = (project-root) let cfg = (load-log-config $root) let log_file = $"($root)/($cfg.path)" let has_filter = ($query | is-not-empty) let filter_label = if $has_filter { $" (ansi cyan)filter: ($query | str join ', ')(ansi reset)" } else { "" } print $" (ansi dark_gray)Following ($cfg.path)(ansi reset)($filter_label)(ansi dark_gray) — Ctrl+C to stop(ansi reset)" print "" mut last_count = if ($log_file | path exists) { (load-entries $root $cfg) | length } else { 0 } # Show last 10 entries as context (filtered if query given) if $last_count > 0 { mut entries = (load-entries $root $cfg) if $has_filter { $entries = (filter-query $entries $query) } let total = ($entries | length) let start = if $total > 10 { $total - 10 } else { 0 } let tail_entries = ($entries | skip $start) for e in $tail_entries { print (fmt-entry $e $timestamps) } } loop { sleep 2sec let current_count = if ($log_file | path exists) { (load-entries $root $cfg) | length } else { 0 } if $current_count < $last_count { # File was rotated (replaced with a shorter one) — reset position. $last_count = 0 } if $current_count > $last_count { let entries = (load-entries $root $cfg) mut new_entries = ($entries | skip $last_count) if $has_filter { $new_entries = (filter-query $new_entries $query) } for e in $new_entries { print (fmt-entry $e $timestamps) } $last_count = $current_count } } } # ── Write ──────────────────────────────────────────────────────────────────── # Manual record — for agents, hooks, scripts, and any external actor. # Bypasses level filtering (always writes if log is not "none"). export def log-record [ action: string, --level (-l): string = "write", --author (-a): string = "", --actor: string = "", ] { let root = (project-root) let cfg = (load-log-config $root) let effective_level = ($env.ONTOREF_LOG_LEVEL? | default $cfg.level) if $effective_level == "none" { return } let log_file = $"($root)/($cfg.path)" let dir = ($log_file | path dirname) if not ($dir | path exists) { mkdir $dir } if (needs-rotation $log_file $cfg.rotation) { rotate-log $root $cfg } let ts = (date now | format date "%Y-%m-%dT%H:%M:%S%z") let resolved_author = if ($author | is-not-empty) { $author } else { ($env.ONTOREF_AUTHOR? | default "unknown") } let resolved_actor = if ($actor | is-not-empty) { $actor } else { ($env.ONTOREF_ACTOR? | default "developer") } let entry = { ts: $ts, author: $resolved_author, actor: $resolved_actor, level: $level, action: $action } let line = ($entry | to json --raw) $line + "\n" | save -a $log_file } # Auto log — called internally by dispatcher on each canonical command. export def log-action [action: string, level: string = "read"] { let root = (project-root) let cfg = (load-log-config $root) # Env override: ONTOREF_LOG_LEVEL takes precedence over config let effective_level = ($env.ONTOREF_LOG_LEVEL? | default $cfg.level) if $effective_level == "none" { return } # "all" maps to interactive (rank 2) — allows everything through let threshold_key = if $effective_level == "all" { "interactive" } else { $effective_level } let action_rank = (level-rank $level) let threshold_rank = (level-rank $threshold_key) if $action_rank > $threshold_rank { return } let log_file = $"($root)/($cfg.path)" let dir = ($log_file | path dirname) if not ($dir | path exists) { mkdir $dir } # Rotation check before write if (needs-rotation $log_file $cfg.rotation) { rotate-log $root $cfg } let ts = (date now | format date "%Y-%m-%dT%H:%M:%S%z") let author = ($env.ONTOREF_AUTHOR? | default "unknown") let actor = ($env.ONTOREF_ACTOR? | default "developer") let entry = { ts: $ts, author: $author, actor: $actor, level: $level, action: $action } let line = ($entry | to json --raw) $line + "\n" | save -a $log_file }