Jesús Pérez 0396e4037b
Some checks failed
Nickel Type Check / Nickel Type Checking (push) Has been cancelled
Rust CI / Security Audit (push) Has been cancelled
Rust CI / Check + Test + Lint (nightly) (push) Has been cancelled
Rust CI / Check + Test + Lint (stable) (push) Has been cancelled
chore: add ontology and reflection
2026-03-13 00:21:04 +00:00

392 lines
14 KiB
Plaintext

# 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<record> {
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<record>, since: string, until: string]: nothing -> list<record> {
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<record>, queries: list<string>]: nothing -> list<record> {
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<string> = [],
--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<string> = []] {
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
}