nooa-cli 0.0.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,145 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Start the development viewer.
4
+
5
+ Usage:
6
+ nooa start-dev # Start viewer on :5001
7
+ nooa start-dev --port 5002 # Custom port
8
+ """
9
+
10
+ import logging
11
+
12
+ import click
13
+
14
+ NAME = "start-dev"
15
+
16
+ # Routes that are too noisy to log on every request.
17
+ _SUPPRESSED_PATHS = ("/v1/traces", "/api/trace", "/api/refresh")
18
+
19
+
20
+ class _AccessLogFilter(logging.Filter):
21
+ def filter(self, record: logging.LogRecord) -> bool:
22
+ msg = record.getMessage()
23
+ return not any(p in msg for p in _SUPPRESSED_PATHS)
24
+
25
+
26
+ def _find_pid_on_port(port: int) -> str | None:
27
+ """Return the PID of the process listening on *port*, or None."""
28
+ import shutil
29
+ import subprocess
30
+
31
+ # Try lsof first (macOS + most Linux)
32
+ if shutil.which("lsof"):
33
+ try:
34
+ out = subprocess.check_output(
35
+ ["lsof", "-ti", f":{port}"],
36
+ text=True,
37
+ stderr=subprocess.DEVNULL,
38
+ ).strip()
39
+ if out:
40
+ # May return multiple PIDs; take the first.
41
+ return out.splitlines()[0]
42
+ except subprocess.CalledProcessError:
43
+ pass
44
+
45
+ # Fallback: ss (Linux)
46
+ if shutil.which("ss"):
47
+ try:
48
+ out = subprocess.check_output(
49
+ ["ss", "-tlnp", f"sport = :{port}"],
50
+ text=True,
51
+ stderr=subprocess.DEVNULL,
52
+ )
53
+ import re
54
+
55
+ m = re.search(r"pid=(\d+)", out)
56
+ if m:
57
+ return m.group(1)
58
+ except subprocess.CalledProcessError:
59
+ pass
60
+
61
+ return None
62
+
63
+
64
+ @click.command()
65
+ @click.option("--port", "-p", type=int, default=5001, help="Port number (default: 5001).")
66
+ @click.option("--host", "-h", default="0.0.0.0", help="Host to bind to (default: 0.0.0.0).")
67
+ @click.option(
68
+ "--db",
69
+ "db_path_opt",
70
+ type=click.Path(dir_okay=False),
71
+ default=None,
72
+ help="SQLite trace store path. Defaults to ~/.config/nooa/traces.db "
73
+ "(or $NOOA_TRACE_DB if set). Pass an explicit path to run a second viewer "
74
+ "side-by-side with the default one.",
75
+ )
76
+ def command(port: int, host: str, db_path_opt: str | None):
77
+ """Start the unified trace + evaluation viewer."""
78
+ import os
79
+ from pathlib import Path
80
+
81
+ from nooa.paths import get_user_dir
82
+
83
+ # Resolve the DB path with --db winning, then $NOOA_TRACE_DB, then the
84
+ # legacy $NEMO_OO_TRACE_DB, then the user-dir default. Set both env vars
85
+ # unconditionally so the viewer module picks it up at import time.
86
+ if db_path_opt:
87
+ db_path = Path(db_path_opt).expanduser().resolve()
88
+ elif "NOOA_TRACE_DB" in os.environ:
89
+ db_path = Path(os.environ["NOOA_TRACE_DB"]).expanduser().resolve()
90
+ elif "NEMO_OO_TRACE_DB" in os.environ:
91
+ db_path = Path(os.environ["NEMO_OO_TRACE_DB"]).expanduser().resolve()
92
+ else:
93
+ db_path = get_user_dir("traces.db")
94
+ db_path.parent.mkdir(parents=True, exist_ok=True)
95
+ os.environ["NOOA_TRACE_DB"] = str(db_path)
96
+ os.environ["NEMO_OO_TRACE_DB"] = str(db_path)
97
+
98
+ try:
99
+ from nooa.viewer.main import app
100
+ except ImportError:
101
+ click.secho(
102
+ "Error: viewer dependencies are not installed.\n"
103
+ 'Install them with: uv add "nooa[viewer]"',
104
+ fg="red",
105
+ err=True,
106
+ )
107
+ raise SystemExit(1) from None
108
+
109
+ import copy
110
+
111
+ import uvicorn
112
+
113
+ logging.getLogger("uvicorn.access").addFilter(_AccessLogFilter())
114
+
115
+ log_config = copy.deepcopy(uvicorn.config.LOGGING_CONFIG)
116
+ log_config["loggers"]["nooa.viewer"] = {
117
+ "handlers": ["default"],
118
+ "level": "INFO",
119
+ "propagate": False,
120
+ }
121
+
122
+ click.echo()
123
+ click.secho(" NVIDIA OO Agents Viewer", fg="cyan", bold=True)
124
+ click.echo(f" URL: http://localhost:{port}")
125
+ click.echo(f" DB: {db_path}")
126
+ click.echo()
127
+
128
+ try:
129
+ uvicorn.run(app, host=host, port=port, log_config=log_config)
130
+ except SystemExit:
131
+ raise
132
+ except OSError as exc:
133
+ import errno as _errno
134
+
135
+ if exc.errno == _errno.EADDRINUSE:
136
+ pid = _find_pid_on_port(port)
137
+ pid_suffix = f" by process {pid}" if pid else ""
138
+ click.secho(
139
+ f"[Errno 48] error while attempting to bind on address "
140
+ f"('{host}', {port}): address already in use{pid_suffix}",
141
+ fg="red",
142
+ err=True,
143
+ )
144
+ raise SystemExit(1) from None
145
+ raise
@@ -0,0 +1,316 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Manage trace and evaluation files.
4
+
5
+ Usage:
6
+ nooa traces delete # Delete, with confirmation
7
+ nooa traces delete -n # Dry run
8
+ nooa traces delete --older-than 7 # Only files >7 days old
9
+ nooa traces list # List trace directories
10
+ nooa traces stats # Show trace file statistics
11
+ """
12
+
13
+ import os
14
+ import re
15
+ import sys
16
+ from datetime import datetime, timedelta
17
+ from pathlib import Path
18
+
19
+ import click
20
+
21
+ from nooa_cli._common import find_project_root, format_size
22
+
23
+ SESSION_ID_RE = re.compile(r'"session\.id"\s*:\s*"[^"]+"')
24
+
25
+ # Directories to skip when scanning for trace files
26
+ EXCLUDE_DIRS = {
27
+ ".venv",
28
+ "venv",
29
+ "node_modules",
30
+ ".git",
31
+ "__pycache__",
32
+ ".mypy_cache",
33
+ ".ruff_cache",
34
+ }
35
+
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # File discovery helpers
39
+ # ---------------------------------------------------------------------------
40
+
41
+
42
+ def _discover_trace_dirs(root: Path | None = None) -> list[Path]:
43
+ """Discover configured file-backed trace directories without a recursive scan.
44
+
45
+ The live trace viewer stores spans in SQLite via OTLP/Journal ingestion; JSONL
46
+ trace files are an explicit fallback/export path. Keep this helper bounded to
47
+ locations NeMo OO itself can write: the project trace dir used by TUI
48
+ ``--trace``, the legacy ``./traces`` convention at the project root, and an
49
+ explicit ``TRACE_DIR`` override when present.
50
+ """
51
+ project_root = root.resolve() if root is not None else find_project_root().resolve()
52
+ candidates = [
53
+ _project_trace_dir(project_root),
54
+ project_root / "traces",
55
+ ]
56
+
57
+ env_trace_dir = os.getenv("TRACE_DIR")
58
+ if env_trace_dir:
59
+ env_path = Path(env_trace_dir).expanduser()
60
+ candidates.append(env_path if env_path.is_absolute() else Path.cwd() / env_path)
61
+
62
+ return sorted({path.resolve() for path in candidates if path.is_dir()})
63
+
64
+
65
+ def _project_trace_dir(root: Path | None = None) -> Path:
66
+ """Return the project-local JSONL trace directory used by TUI ``--trace``."""
67
+ project_root = root.resolve() if root is not None else find_project_root().resolve()
68
+ return project_root / ".nooa" / "traces"
69
+
70
+
71
+ def _find_trace_files(root: Path) -> list[Path]:
72
+ """Find all trace JSONL files, excluding annotation and eval files."""
73
+ trace_files: list[Path] = []
74
+ _collect_files(root, "*.jsonl", trace_files, exclude_non_trace=True)
75
+ return sorted(trace_files)
76
+
77
+
78
+ def _find_eval_files(root: Path) -> list[Path]:
79
+ """Find all .noo-eval.jsonl and .006eval.json files."""
80
+ eval_files: list[Path] = []
81
+ _collect_files(root, "*.noo-eval.jsonl", eval_files)
82
+ _collect_files(root, "*.006eval.json", eval_files)
83
+ return sorted(eval_files)
84
+
85
+
86
+ def _collect_files(root: Path, pattern: str, out: list[Path], *, exclude_non_trace: bool = False):
87
+ """Walk directory tree collecting files matching pattern, skipping excluded dirs."""
88
+ try:
89
+ for f in root.glob(pattern):
90
+ if exclude_non_trace and (
91
+ f.name.endswith(".annotations.jsonl")
92
+ or f.name.endswith(".noo-eval.jsonl")
93
+ or f.name.endswith(".006eval.json")
94
+ or f.name.endswith(".006summary.json")
95
+ ):
96
+ continue
97
+ out.append(f)
98
+ for entry in root.iterdir():
99
+ if entry.is_dir() and entry.name not in EXCLUDE_DIRS:
100
+ _collect_files(entry, pattern, out, exclude_non_trace=exclude_non_trace)
101
+ except PermissionError:
102
+ pass
103
+
104
+
105
+ def _has_session_id(file_path: Path) -> bool:
106
+ """Check if a trace file has session.id in the first 4KB."""
107
+ try:
108
+ with open(file_path, encoding="utf-8") as f:
109
+ chunk = f.read(4096)
110
+ except OSError:
111
+ return True # Conservatively keep
112
+ return bool(SESSION_ID_RE.search(chunk))
113
+
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # CLI group + subcommands
117
+ # ---------------------------------------------------------------------------
118
+
119
+
120
+ @click.group()
121
+ def command():
122
+ """Manage trace and evaluation files."""
123
+
124
+
125
+ @command.command()
126
+ @click.argument("directory", type=click.Path(exists=True), default=".")
127
+ @click.option("--dry-run", "-n", is_flag=True, help="Show what would be deleted without deleting.")
128
+ @click.option("--older-than", type=int, metavar="DAYS", help="Only files older than N days.")
129
+ @click.option("--all", "delete_all", is_flag=True, help="Delete ALL traces (ignore session.id).")
130
+ @click.option("--evals", is_flag=True, help="Also delete eval files (.noo-eval.jsonl).")
131
+ @click.option("--evals-only", is_flag=True, help="Only delete eval files (skip traces).")
132
+ @click.option("-y", "--yes", is_flag=True, help="Skip confirmation prompt.")
133
+ def delete(
134
+ directory: str,
135
+ dry_run: bool,
136
+ older_than: int | None,
137
+ delete_all: bool,
138
+ evals: bool,
139
+ evals_only: bool,
140
+ yes: bool,
141
+ ):
142
+ """Delete trace and evaluation files.
143
+
144
+ DIRECTORY is the root directory to scan (default: current directory).
145
+
146
+ \b
147
+ By default, traces with a session.id are preserved (they belong to a
148
+ tracked session). Use --all to override this.
149
+
150
+ \b
151
+ Examples:
152
+ nooa traces delete # Delete, with confirmation
153
+ nooa traces delete -n # Dry run (show what would go)
154
+ nooa traces delete --older-than 7 # Only files >7 days old
155
+ nooa traces delete --all -y # Delete everything, no prompt
156
+ nooa traces delete --evals # Include eval files
157
+ """
158
+ root = Path(directory).resolve()
159
+ click.echo(f"Scanning: {root}")
160
+
161
+ all_files: list[tuple[str, Path]] = []
162
+
163
+ if not evals_only:
164
+ trace_files = _find_trace_files(root)
165
+ click.echo(f"Found {len(trace_files)} trace files")
166
+ all_files.extend(("trace", f) for f in trace_files)
167
+
168
+ if evals or evals_only:
169
+ eval_files = _find_eval_files(root)
170
+ click.echo(f"Found {len(eval_files)} eval files")
171
+ all_files.extend(("eval", f) for f in eval_files)
172
+
173
+ if not all_files:
174
+ click.echo("\nNo files found.")
175
+ return
176
+
177
+ if older_than:
178
+ cutoff = datetime.now() - timedelta(days=older_than)
179
+ cutoff_ts = cutoff.timestamp()
180
+ all_files = [(t, f) for t, f in all_files if f.stat().st_mtime < cutoff_ts]
181
+ click.echo(f"Filtered to {len(all_files)} files older than {older_than} days")
182
+
183
+ to_delete = []
184
+ for file_type, f in all_files:
185
+ if file_type == "eval":
186
+ to_delete.append((file_type, f))
187
+ elif delete_all or not _has_session_id(f):
188
+ to_delete.append((file_type, f))
189
+
190
+ if not to_delete:
191
+ click.echo("\nNo files to delete.")
192
+ return
193
+
194
+ total_size = sum(f.stat().st_size for _, f in to_delete)
195
+ trace_count = sum(1 for t, _ in to_delete if t == "trace")
196
+ eval_count = sum(1 for t, _ in to_delete if t == "eval")
197
+
198
+ click.echo(f"\nFiles to delete: {len(to_delete)} ({format_size(total_size)})")
199
+ if trace_count:
200
+ click.echo(f" - {trace_count} trace files")
201
+ if eval_count:
202
+ click.echo(f" - {eval_count} eval files")
203
+
204
+ if dry_run:
205
+ click.secho("\n[DRY RUN] Files that would be deleted:", fg="yellow")
206
+ for file_type, f in to_delete[:20]:
207
+ click.echo(f" [{file_type}] {f}")
208
+ if len(to_delete) > 20:
209
+ click.echo(f" ... and {len(to_delete) - 20} more")
210
+ return
211
+
212
+ if not yes:
213
+ click.confirm(
214
+ f"\nDelete {len(to_delete)} files ({format_size(total_size)})?",
215
+ abort=True,
216
+ )
217
+
218
+ click.echo("\nDeleting files...")
219
+ deleted = 0
220
+ for file_type, f in to_delete:
221
+ try:
222
+ f.unlink()
223
+ deleted += 1
224
+ if not sys.stdout.isatty() or deleted <= 20 or deleted == len(to_delete):
225
+ click.echo(f" Deleted [{file_type}]: {f}")
226
+ except OSError as e:
227
+ click.secho(f" Error deleting {f}: {e}", fg="red")
228
+
229
+ click.secho(f"\nDeleted {deleted} files ({format_size(total_size)} freed).", fg="green")
230
+
231
+
232
+ @command.command("list")
233
+ @click.option(
234
+ "--root",
235
+ type=click.Path(exists=True),
236
+ default=None,
237
+ help="Project root to scan (auto-detected by default).",
238
+ )
239
+ def list_dirs(root: str | None):
240
+ """List discovered trace directories.
241
+
242
+ \b
243
+ Examples:
244
+ nooa traces list
245
+ nooa traces list --root /path/to/project
246
+ """
247
+ project_root = (Path(root) if root else find_project_root()).resolve()
248
+ dirs = _discover_trace_dirs(project_root)
249
+
250
+ if not dirs:
251
+ click.echo("No trace directories found.")
252
+ return
253
+
254
+ click.echo(f"Trace directories (from {project_root}):\n")
255
+ for d in dirs:
256
+ trace_count = len(
257
+ [
258
+ f
259
+ for f in d.glob("*.jsonl")
260
+ if not f.name.endswith(".annotations.jsonl")
261
+ and not f.name.endswith(".noo-eval.jsonl")
262
+ ]
263
+ )
264
+ total_size = sum(
265
+ f.stat().st_size
266
+ for f in d.glob("*.jsonl")
267
+ if not f.name.endswith(".annotations.jsonl") and not f.name.endswith(".noo-eval.jsonl")
268
+ )
269
+ rel = d.relative_to(project_root) if d.is_relative_to(project_root) else d
270
+
271
+ count_str = f"{trace_count} traces" if trace_count else "empty"
272
+ size_str = f", {format_size(total_size)}" if total_size else ""
273
+
274
+ click.echo(f" {rel}/ ({count_str}{size_str})")
275
+
276
+
277
+ @command.command()
278
+ @click.argument("directory", type=click.Path(exists=True), default=".")
279
+ def stats(directory: str):
280
+ """Show statistics about trace files.
281
+
282
+ \b
283
+ Examples:
284
+ nooa traces stats
285
+ nooa traces stats ./traces
286
+ """
287
+ root = Path(directory).resolve()
288
+ trace_files = _find_trace_files(root)
289
+ eval_files = _find_eval_files(root)
290
+
291
+ if not trace_files and not eval_files:
292
+ click.echo(f"No trace or eval files found in {root}")
293
+ return
294
+
295
+ click.echo(f"Trace statistics for: {root}\n")
296
+
297
+ if trace_files:
298
+ total_size = sum(f.stat().st_size for f in trace_files)
299
+ with_session = sum(1 for f in trace_files if _has_session_id(f))
300
+ without_session = len(trace_files) - with_session
301
+
302
+ now = datetime.now().timestamp()
303
+ ages = [(now - f.stat().st_mtime) / 86400 for f in trace_files]
304
+
305
+ click.echo(f" Trace files: {len(trace_files)}")
306
+ click.echo(f" Total size: {format_size(total_size)}")
307
+ click.echo(f" With session.id: {with_session}")
308
+ click.echo(f" Without session: {without_session}")
309
+ if ages:
310
+ click.echo(f" Oldest: {max(ages):.0f} days")
311
+ click.echo(f" Newest: {min(ages):.1f} days")
312
+
313
+ if eval_files:
314
+ total_size = sum(f.stat().st_size for f in eval_files)
315
+ click.echo(f"\n Eval files: {len(eval_files)}")
316
+ click.echo(f" Eval size: {format_size(total_size)}")