diff --git a/src/basic_memory/cli/commands/tool.py b/src/basic_memory/cli/commands/tool.py index aae42932..e6643b15 100644 --- a/src/basic_memory/cli/commands/tool.py +++ b/src/basic_memory/cli/commands/tool.py @@ -113,10 +113,11 @@ def _display_search_results(result: dict[str, Any], query: str = "") -> None: def _display_read_note(result: dict[str, Any]) -> None: - """Render read-note result: header panel + rendered Markdown content.""" + """Render read-note result: header panel + optional frontmatter + rendered Markdown content.""" title = result.get("title", "") permalink = result.get("permalink", "") content = result.get("content", "") + frontmatter: dict[str, Any] = result.get("frontmatter") or {} header = Text() header.append(title, style="bold cyan") @@ -125,6 +126,18 @@ def _display_read_note(result: dict[str, Any]) -> None: console.print(Panel(header, expand=False)) + # Trigger: --include-frontmatter was passed; the MCP tool populates "frontmatter". + # Why: without rendering it here, the explicitly requested metadata is silently + # dropped in the Rich path — users must also know to add --json to see it. + # Outcome: print a dim key/value block above the content when frontmatter is present. + if frontmatter: + fm_table = Table(show_header=False, box=None, padding=(0, 1), expand=False) + fm_table.add_column("key", style="dim") + fm_table.add_column("value", style="dim") + for key, value in frontmatter.items(): + fm_table.add_row(str(key), str(value)) + console.print(Panel(fm_table, title="[dim]frontmatter[/dim]", expand=False)) + if content: console.print(Markdown(content)) else: @@ -165,6 +178,24 @@ def _display_build_context(result: dict[str, Any]) -> None: primary_label = f"[dim]{p_type}[/dim] {primary_label}" primary_node = tree.add(primary_label) + # --- Observations as children (category + truncated content) --- + # Trigger: ContextResult.observations exists in the JSON output but was + # never rendered in the Rich path. + # Why: users running interactively lost core entity facts (observations) + # that the --json path exposes; the TTY view must be at least as + # informative as the JSON view for the primary entity. + # Outcome: each observation appears as a dim "[category] content" leaf + # under its primary node, truncated at 120 chars. + observations: list[dict[str, Any]] = list(context_result.get("observations", [])) + for obs in observations: + category = obs.get("category", "") + obs_content = obs.get("content", "") + # Truncate long observations so the tree stays readable. + if len(obs_content) > 120: + obs_content = obs_content[:117] + "..." + obs_label = f"[dim][{category}] {obs_content}[/dim]" + primary_node.add(obs_label) + # --- Related items as children --- related: list[dict[str, Any]] = list(context_result.get("related_results", [])) for rel_item in related: @@ -182,7 +213,8 @@ def _display_build_context(result: dict[str, Any]) -> None: # Count total related items across all primary results. total_related = sum(len(cr.get("related_results", [])) for cr in context_items) - subtitle = f"{len(context_items)} primary • {total_related} related" + total_observations = sum(len(cr.get("observations", [])) for cr in context_items) + subtitle = f"{len(context_items)} primary • {total_observations} observations • {total_related} related" console.print(Panel(tree, subtitle=subtitle, expand=False)) diff --git a/tests/cli/test_cli_tool_rich_output.py b/tests/cli/test_cli_tool_rich_output.py index 23a2a5c7..9a7963ce 100644 --- a/tests/cli/test_cli_tool_rich_output.py +++ b/tests/cli/test_cli_tool_rich_output.py @@ -66,6 +66,7 @@ SEARCH_RESULT_EMPTY = { BUILD_CONTEXT_RESULT = { # Real GraphContext.model_dump() shape: results is a list of ContextResult dicts. # Each ContextResult has primary_result + observations + related_results. + # ObservationSummary fields: type, category, content, permalink, file_path, created_at. "results": [ { "primary_result": { @@ -76,7 +77,16 @@ BUILD_CONTEXT_RESULT = { "file_path": "notes/Test Note.md", "created_at": "2025-01-01T00:00:00", }, - "observations": [], + "observations": [ + { + "type": "observation", + "category": "fact", + "content": "This is a key fact about the test note", + "permalink": "notes/test-note", + "file_path": "notes/Test Note.md", + "created_at": "2025-01-01T00:00:00", + } + ], "related_results": [ { "type": "relation", @@ -398,3 +408,58 @@ def test_recent_activity_non_tty_gives_json(mock_mcp): data = json.loads(result.output) assert isinstance(data, list) assert len(data) == 2 + + +# --------------------------------------------------------------------------- +# read-note – frontmatter rendering (issue #678) +# --------------------------------------------------------------------------- + + +@patch( + "basic_memory.cli.commands.tool.mcp_read_note", + new_callable=AsyncMock, + return_value=READ_NOTE_RESULT, +) +def test_read_note_rich_include_frontmatter(mock_mcp): + """read-note --include-frontmatter renders frontmatter keys in Rich path. + + Regression: previously the Rich renderer silently dropped frontmatter even + when --include-frontmatter was passed, requiring --json to see the data. + """ + result = _tty_runner(["tool", "read-note", "test-note", "--include-frontmatter"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + # Frontmatter section header should appear + assert "frontmatter" in result.output + # The frontmatter key and value from READ_NOTE_RESULT should be visible + assert "tags" in result.output + assert "test" in result.output + # The note content should still appear + assert "hello world" in result.output + + +# --------------------------------------------------------------------------- +# build-context – observations rendering (issue #678) +# --------------------------------------------------------------------------- + + +@patch( + "basic_memory.cli.commands.tool.mcp_build_context", + new_callable=AsyncMock, + return_value=BUILD_CONTEXT_RESULT, +) +def test_build_context_rich_renders_observations(mock_mcp): + """build-context Rich tree includes observations under each primary node. + + Regression: ContextResult.observations was exposed in JSON output but never + rendered in the Rich path, so interactive users lost core entity facts. + """ + result = _tty_runner(["tool", "build-context", "memory://notes/test-note"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + # The observation category should appear in the tree + assert "fact" in result.output + # The observation content should appear (possibly truncated) + assert "key fact" in result.output + # The subtitle should include an observations count + assert "observations" in result.output