From 89552f7a598d404527937cd8b0b07d0c82cb4e77 Mon Sep 17 00:00:00 2001 From: spomichter Date: Mon, 9 Mar 2026 15:55:48 +0000 Subject: [PATCH 1/5] feat(cli): dimos log command for viewing per-run logs (DIM-698) Add log_viewer.py with log resolution, JSONL formatting, and tail -f follow mode. CLI wrapper in dimos.py is a thin 15-line command. Usage: dimos log # last 50 lines, human-readable dimos log -f # follow in real time dimos log -n 100 # last N lines dimos log --all # full log dimos log --json # raw JSONL dimos log --run # specific run Closes DIM-698 --- dimos/core/log_viewer.py | 99 ++++++++++++++++++++++++++++++++++++++++ dimos/robot/cli/dimos.py | 28 ++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 dimos/core/log_viewer.py diff --git a/dimos/core/log_viewer.py b/dimos/core/log_viewer.py new file mode 100644 index 0000000000..0b4d18c54c --- /dev/null +++ b/dimos/core/log_viewer.py @@ -0,0 +1,99 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Log viewer for per-run DimOS logs.""" + +from __future__ import annotations + +import json +from pathlib import Path +import time +from typing import TYPE_CHECKING + +from dimos.core.run_registry import get_most_recent, list_runs + +if TYPE_CHECKING: + from collections.abc import Iterator + +_SKIP_KEYS = frozenset({"timestamp", "level", "logger", "event", "func_name", "lineno"}) +_COLORS = {"err": "\033[31m", "war": "\033[33m", "deb": "\033[2m"} +_RESET = "\033[0m" + + +def resolve_log_path(run_id: str = "") -> Path | None: + """Find the log file: specific run → alive run → most recent.""" + if run_id: + for entry in list_runs(alive_only=False): + if entry.run_id == run_id: + path = Path(entry.log_dir) / "main.jsonl" + return path if path.exists() else None + return None + + for alive_only in (True, False): + result = get_most_recent(alive_only=alive_only) + if result is not None: + path = Path(result.log_dir) / "main.jsonl" + if path.exists(): + return path + return None + + +def format_line(raw: str, json_output: bool = False) -> str: + """Parse a JSONL line into human-readable format. + + Default format: ``HH:MM:SS [lvl] logger_name event extras`` + With *json_output*: returns the raw line stripped. + """ + if json_output: + return raw.rstrip() + try: + d: dict[str, object] = json.loads(raw) + except json.JSONDecodeError: + return raw.rstrip() + + ts = str(d.get("timestamp", "")) + time_str = ts[11:19] if len(ts) > 19 else ts + + level = str(d.get("level", "?"))[:3] + logger = Path(str(d.get("logger", "?"))).name + event = str(d.get("event", "")) + + extras = " ".join(f"{k}={v}" for k, v in d.items() if k not in _SKIP_KEYS) + color = _COLORS.get(level, "") + + line = f"{time_str} {color}[{level}]{_RESET} {logger:17} {event}" + if extras: + line += f" {extras}" + return line + + +def read_log(path: Path, count: int | None = 50) -> list[str]: + """Read last *count* lines from a log file. ``None`` returns all.""" + with open(path) as f: + lines = f.readlines() + if count is not None: + lines = lines[-count:] + return lines + + +def follow_log(path: Path) -> Iterator[str]: + """Yield new lines as they appear (``tail -f`` style).""" + with open(path) as f: + f.seek(0, 2) # seek to end + while True: + line = f.readline() + if line: + yield line + else: + time.sleep(0.1) diff --git a/dimos/robot/cli/dimos.py b/dimos/robot/cli/dimos.py index 0fc35c9801..fe3df802de 100644 --- a/dimos/robot/cli/dimos.py +++ b/dimos/robot/cli/dimos.py @@ -256,6 +256,34 @@ def stop( typer.echo(f" {msg}") +@main.command("log") +def log_cmd( + follow: bool = typer.Option(False, "--follow", "-f", help="Follow log output"), + lines: int = typer.Option(50, "--lines", "-n", help="Number of lines to show"), + all_lines: bool = typer.Option(False, "--all", "-a", help="Show full log"), + json_output: bool = typer.Option(False, "--json", help="Raw JSONL output"), + run_id: str = typer.Option("", "--run", "-r", help="Specific run ID"), +) -> None: + """View logs from a DimOS run.""" + from dimos.core.log_viewer import follow_log, format_line, read_log, resolve_log_path + + path = resolve_log_path(run_id) + if not path: + typer.echo("No log files found", err=True) + raise typer.Exit(1) + + if follow: + try: + for line in follow_log(path): + typer.echo(format_line(line, json_output)) + except KeyboardInterrupt: + pass + else: + count = None if all_lines else lines + for line in read_log(path, count): + typer.echo(format_line(line, json_output)) + + @main.command() def show_config(ctx: typer.Context) -> None: """Show current config settings and their values.""" From 473a71231a3a7f2663748bac4bb61c909a8411ab Mon Sep 17 00:00:00 2001 From: spomichter Date: Mon, 9 Mar 2026 16:00:15 +0000 Subject: [PATCH 2/5] =?UTF-8?q?refactor:=20cleaner=20log=5Fviewer=20?= =?UTF-8?q?=E2=80=94=20deque=20tail,=20extracted=20helper,=20keyword-only?= =?UTF-8?q?=20arg?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dimos/core/log_viewer.py | 68 ++++++++++++++++++++++------------------ 1 file changed, 37 insertions(+), 31 deletions(-) diff --git a/dimos/core/log_viewer.py b/dimos/core/log_viewer.py index 0b4d18c54c..e4f3a008e8 100644 --- a/dimos/core/log_viewer.py +++ b/dimos/core/log_viewer.py @@ -16,6 +16,7 @@ from __future__ import annotations +from collections import deque import json from pathlib import Path import time @@ -26,8 +27,8 @@ if TYPE_CHECKING: from collections.abc import Iterator -_SKIP_KEYS = frozenset({"timestamp", "level", "logger", "event", "func_name", "lineno"}) -_COLORS = {"err": "\033[31m", "war": "\033[33m", "deb": "\033[2m"} +_STANDARD_KEYS = {"timestamp", "level", "logger", "event", "func_name", "lineno"} +_LEVEL_COLORS = {"err": "\033[31m", "war": "\033[33m", "deb": "\033[2m"} _RESET = "\033[0m" @@ -36,64 +37,69 @@ def resolve_log_path(run_id: str = "") -> Path | None: if run_id: for entry in list_runs(alive_only=False): if entry.run_id == run_id: - path = Path(entry.log_dir) / "main.jsonl" - return path if path.exists() else None + return _log_path_if_exists(entry.log_dir) return None - for alive_only in (True, False): - result = get_most_recent(alive_only=alive_only) - if result is not None: - path = Path(result.log_dir) / "main.jsonl" - if path.exists(): - return path + # Prefer alive run, fall back to most recent stopped run. + alive = get_most_recent(alive_only=True) + if alive is not None: + return _log_path_if_exists(alive.log_dir) + recent = get_most_recent(alive_only=False) + if recent is not None: + return _log_path_if_exists(recent.log_dir) return None -def format_line(raw: str, json_output: bool = False) -> str: - """Parse a JSONL line into human-readable format. +def format_line(raw: str, *, json_output: bool = False) -> str: + """Format a JSONL log line for display. - Default format: ``HH:MM:SS [lvl] logger_name event extras`` - With *json_output*: returns the raw line stripped. + Default: ``HH:MM:SS [lvl] logger event k=v …`` """ if json_output: return raw.rstrip() try: - d: dict[str, object] = json.loads(raw) + rec: dict[str, object] = json.loads(raw) except json.JSONDecodeError: return raw.rstrip() - ts = str(d.get("timestamp", "")) - time_str = ts[11:19] if len(ts) > 19 else ts + ts = str(rec.get("timestamp", "")) + hms = ts[11:19] if len(ts) >= 19 else ts + level = str(rec.get("level", "?"))[:3] + logger = Path(str(rec.get("logger", "?"))).name + event = str(rec.get("event", "")) + color = _LEVEL_COLORS.get(level, "") - level = str(d.get("level", "?"))[:3] - logger = Path(str(d.get("logger", "?"))).name - event = str(d.get("event", "")) - - extras = " ".join(f"{k}={v}" for k, v in d.items() if k not in _SKIP_KEYS) - color = _COLORS.get(level, "") - - line = f"{time_str} {color}[{level}]{_RESET} {logger:17} {event}" + extras = " ".join(f"{k}={v}" for k, v in rec.items() if k not in _STANDARD_KEYS) + line = f"{hms} {color}[{level}]{_RESET} {logger:17} {event}" if extras: line += f" {extras}" return line def read_log(path: Path, count: int | None = 50) -> list[str]: - """Read last *count* lines from a log file. ``None`` returns all.""" + """Read last *count* lines from a log file (``None`` = all).""" + if count is None: + return path.read_text().splitlines(keepends=True) + # Only keep the tail — avoids loading the full file into a list. + tail: deque[str] = deque(maxlen=count) with open(path) as f: - lines = f.readlines() - if count is not None: - lines = lines[-count:] - return lines + for line in f: + tail.append(line) + return list(tail) def follow_log(path: Path) -> Iterator[str]: """Yield new lines as they appear (``tail -f`` style).""" with open(path) as f: - f.seek(0, 2) # seek to end + f.seek(0, 2) while True: line = f.readline() if line: yield line else: time.sleep(0.1) + + +def _log_path_if_exists(log_dir: str) -> Path | None: + path = Path(log_dir) / "main.jsonl" + return path if path.exists() else None From 2dffd0ae054a92cc615f746fd469cf78f5dbe2b4 Mon Sep 17 00:00:00 2001 From: spomichter Date: Mon, 9 Mar 2026 16:00:38 +0000 Subject: [PATCH 3/5] fix: keyword-only json_output in format_line calls --- dimos/robot/cli/dimos.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dimos/robot/cli/dimos.py b/dimos/robot/cli/dimos.py index fe3df802de..2166f493f4 100644 --- a/dimos/robot/cli/dimos.py +++ b/dimos/robot/cli/dimos.py @@ -275,13 +275,13 @@ def log_cmd( if follow: try: for line in follow_log(path): - typer.echo(format_line(line, json_output)) + typer.echo(format_line(line, json_output=json_output)) except KeyboardInterrupt: pass else: count = None if all_lines else lines for line in read_log(path, count): - typer.echo(format_line(line, json_output)) + typer.echo(format_line(line, json_output=json_output)) @main.command() From d9b87a3c12898c176a0503461656bb9d9429332b Mon Sep 17 00:00:00 2001 From: spomichter Date: Mon, 9 Mar 2026 16:20:25 +0000 Subject: [PATCH 4/5] fix: dimos log -f exits cleanly on Ctrl+C Use SIGINT handler + stop callback instead of KeyboardInterrupt. The follow_log generator checks the stop flag every 0.1s and exits cleanly, ensuring the file handle is closed and the terminal is restored. --- dimos/core/log_viewer.py | 12 ++++++++---- dimos/robot/cli/dimos.py | 15 ++++++++++++--- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/dimos/core/log_viewer.py b/dimos/core/log_viewer.py index e4f3a008e8..563c244a96 100644 --- a/dimos/core/log_viewer.py +++ b/dimos/core/log_viewer.py @@ -25,7 +25,7 @@ from dimos.core.run_registry import get_most_recent, list_runs if TYPE_CHECKING: - from collections.abc import Iterator + from collections.abc import Callable, Iterator _STANDARD_KEYS = {"timestamp", "level", "logger", "event", "func_name", "lineno"} _LEVEL_COLORS = {"err": "\033[31m", "war": "\033[33m", "deb": "\033[2m"} @@ -88,11 +88,15 @@ def read_log(path: Path, count: int | None = 50) -> list[str]: return list(tail) -def follow_log(path: Path) -> Iterator[str]: - """Yield new lines as they appear (``tail -f`` style).""" +def follow_log(path: Path, stop: Callable[[], bool] | None = None) -> Iterator[str]: + """Yield new lines as they appear (``tail -f`` style). + + *stop* is an optional callable; when it returns ``True`` the + generator exits cleanly. + """ with open(path) as f: f.seek(0, 2) - while True: + while stop is None or not stop(): line = f.readline() if line: yield line diff --git a/dimos/robot/cli/dimos.py b/dimos/robot/cli/dimos.py index 2166f493f4..24e9aeba67 100644 --- a/dimos/robot/cli/dimos.py +++ b/dimos/robot/cli/dimos.py @@ -273,11 +273,20 @@ def log_cmd( raise typer.Exit(1) if follow: + import signal + + _stop = False + + def _on_sigint(_sig: int, _frame: object) -> None: + nonlocal _stop + _stop = True + + prev = signal.signal(signal.SIGINT, _on_sigint) try: - for line in follow_log(path): + for line in follow_log(path, stop=lambda: _stop): typer.echo(format_line(line, json_output=json_output)) - except KeyboardInterrupt: - pass + finally: + signal.signal(signal.SIGINT, prev) else: count = None if all_lines else lines for line in read_log(path, count): From 1bc803cbee5a68917e740afb2f576f1ed79f801e Mon Sep 17 00:00:00 2001 From: spomichter <12108168+spomichter@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:52:06 +0000 Subject: [PATCH 5/5] CI code cleanup --- dimos/robot/cli/dimos.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dimos/robot/cli/dimos.py b/dimos/robot/cli/dimos.py index 5b8684a9ea..1137a612f3 100644 --- a/dimos/robot/cli/dimos.py +++ b/dimos/robot/cli/dimos.py @@ -298,6 +298,8 @@ def _on_sigint(_sig: int, _frame: object) -> None: count = None if all_lines else lines for line in read_log(path, count): typer.echo(format_line(line, json_output=json_output)) + + mcp_app = typer.Typer(help="Interact with the running MCP server") main.add_typer(mcp_app, name="mcp")