Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/basic_memory/cli/commands/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,16 @@ def write_note(
tags: Annotated[
Optional[List[str]], typer.Option(help="A list of tags to apply to the note")
] = None,
note_type: Annotated[
str,
typer.Option(
"--type",
help=(
"Note type stored in frontmatter (e.g. 'guide', 'report'). "
"A 'type:' in the note's own content frontmatter takes precedence."
),
),
] = "note",
project: Annotated[
Optional[str],
typer.Option(
Expand All @@ -100,6 +110,7 @@ def write_note(
Examples:

bm tool write-note --title "My Note" --folder "notes" --content "Note content"
bm tool write-note --title "My Guide" --folder "notes" --content "..." --type guide
echo "content" | bm tool write-note --title "My Note" --folder "notes"
bm tool write-note --title "My Note" --folder "notes" --local
"""
Expand Down Expand Up @@ -132,6 +143,7 @@ def write_note(
project=project,
project_id=project_id,
tags=tags,
note_type=note_type,
output_format="json",
)
)
Expand Down
94 changes: 94 additions & 0 deletions test-int/cli/test_cli_tool_write_note_type_integration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""Integration coverage for `bm tool write-note --type` (Issue #875)."""

import json

from typer.testing import CliRunner

from basic_memory.cli.main import app as cli_app

runner = CliRunner()


def test_write_note_type_flag_round_trip(app, app_config, test_project, config_manager):
"""`--type` sets the persisted note type and is searchable via `--type`."""
write_result = runner.invoke(
cli_app,
[
"tool",
"write-note",
"--title",
"CLI Typed Note",
"--folder",
"typed",
"--content",
"# CLI Typed Note\n\nCliTypeToken body.",
"--type",
"guide",
],
)
assert write_result.exit_code == 0, write_result.output
write_data = json.loads(write_result.stdout)
permalink = write_data["permalink"]

# Read back the frontmatter to confirm the persisted type.
read_result = runner.invoke(
cli_app,
["tool", "read-note", permalink, "--include-frontmatter"],
)
assert read_result.exit_code == 0, read_result.output
read_data = json.loads(read_result.stdout)
assert read_data["frontmatter"]["type"] == "guide"

# The search note-type filter must return the typed note.
search_result = runner.invoke(
cli_app,
[
"tool",
"search-notes",
"CliTypeToken",
"--type",
"guide",
"--local",
"--page-size",
"20",
],
)
assert search_result.exit_code == 0, search_result.output
search_data = json.loads(search_result.stdout)
permalinks = {item["permalink"] for item in search_data["results"]}
assert permalink in permalinks


def test_write_note_content_frontmatter_type_wins_over_flag(
app, app_config, test_project, config_manager
):
"""A `type:` in content frontmatter takes precedence over `--type` (documented behavior)."""
content = "---\ntype: session\n---\n# Frontmatter Wins\n\nFrontmatterWinsToken body."

write_result = runner.invoke(
cli_app,
[
"tool",
"write-note",
"--title",
"Frontmatter Wins",
"--folder",
"typed",
"--content",
content,
"--type",
"guide",
],
)
assert write_result.exit_code == 0, write_result.output
write_data = json.loads(write_result.stdout)
permalink = write_data["permalink"]

read_result = runner.invoke(
cli_app,
["tool", "read-note", permalink, "--include-frontmatter"],
)
assert read_result.exit_code == 0, read_result.output
read_data = json.loads(read_result.stdout)
# Content frontmatter "session" wins over the --type "guide" flag.
assert read_data["frontmatter"]["type"] == "session"
154 changes: 154 additions & 0 deletions test-int/mcp/test_write_note_type_integration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
"""Integration tests locking in write_note note_type behavior (Issue #875).

These exercise the real MCP harness (no mocks) to confirm that:
- content frontmatter ``type:`` is persisted as the note type and is searchable
via the ``note_types`` filter;
- overwriting a note with a different content ``type:`` flips the persisted type.

The CLI ``--type`` passthrough is covered separately in
``test-int/cli/test_cli_tool_write_note_type_integration.py``.
"""

import json
from textwrap import dedent
from typing import Any

import pytest
from fastmcp import Client


def _json_content(tool_result) -> dict[str, Any]:
"""Parse a FastMCP tool result content block into a JSON object."""
assert len(tool_result.content) == 1
assert tool_result.content[0].type == "text"
payload = json.loads(tool_result.content[0].text) # pyright: ignore [reportAttributeAccessIssue]
assert isinstance(payload, dict)
return payload


@pytest.mark.asyncio
async def test_write_note_content_type_is_persisted_and_searchable(mcp_server, app, test_project):
"""Content frontmatter ``type:`` persists and is found by the note_types filter."""
note = dedent("""
---
title: Session Log
type: session
---

# Session Log

SessionTypeToken content body.
""").strip()

async with Client(mcp_server) as client:
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Session Log",
"directory": "logs",
"content": note,
"output_format": "json",
},
)

# The persisted frontmatter should report the content-declared type.
read_result = await client.call_tool(
"read_note",
{
"project": test_project.name,
"identifier": "logs/session-log",
"include_frontmatter": True,
"output_format": "json",
},
)
read_payload = _json_content(read_result)
assert read_payload["frontmatter"]["type"] == "session"

# And the note_types filter must return it (and only it for this token).
search_result = await client.call_tool(
"search_notes",
{
"project": test_project.name,
"query": "SessionTypeToken",
"search_type": "text",
"note_types": ["session"],
"output_format": "json",
},
)
search_payload = _json_content(search_result)
permalinks = {item["permalink"] for item in search_payload["results"]}
assert any(p.endswith("logs/session-log") for p in permalinks)


@pytest.mark.asyncio
async def test_write_note_overwrite_flips_persisted_type(mcp_server, app, test_project):
"""Overwriting with a different content ``type:`` flips the persisted note type."""
session_note = dedent("""
---
title: Type Flip
type: session
---

# Type Flip

Original session body.
""").strip()

schema_note = dedent("""
---
title: Type Flip
type: schema
---

# Type Flip

Replacement schema body.
""").strip()

async with Client(mcp_server) as client:
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Type Flip",
"directory": "flip",
"content": session_note,
"output_format": "json",
},
)

before = await client.call_tool(
"read_note",
{
"project": test_project.name,
"identifier": "flip/type-flip",
"include_frontmatter": True,
"output_format": "json",
},
)
assert _json_content(before)["frontmatter"]["type"] == "session"

# Overwrite with a different content type.
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Type Flip",
"directory": "flip",
"content": schema_note,
"overwrite": True,
"output_format": "json",
},
)

after = await client.call_tool(
"read_note",
{
"project": test_project.name,
"identifier": "flip/type-flip",
"include_frontmatter": True,
"output_format": "json",
},
)
assert _json_content(after)["frontmatter"]["type"] == "schema"
52 changes: 52 additions & 0 deletions tests/cli/test_cli_tool_json_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,58 @@ def test_write_note_with_tags(mock_mcp_write):
assert mock_mcp_write.call_args.kwargs["tags"] == ["python", "async"]


@patch(
"basic_memory.cli.commands.tool.mcp_write_note",
new_callable=AsyncMock,
return_value=WRITE_NOTE_RESULT,
)
def test_write_note_type_passthrough(mock_mcp_write):
"""--type forwards to the MCP tool's note_type parameter."""
result = runner.invoke(
cli_app,
[
"tool",
"write-note",
"--title",
"Test Note",
"--folder",
"notes",
"--content",
"hello",
"--type",
"guide",
],
)

assert result.exit_code == 0, f"CLI failed: {result.output}"
assert mock_mcp_write.call_args.kwargs["note_type"] == "guide"


@patch(
"basic_memory.cli.commands.tool.mcp_write_note",
new_callable=AsyncMock,
return_value=WRITE_NOTE_RESULT,
)
def test_write_note_type_defaults_to_note(mock_mcp_write):
"""write-note defaults note_type to 'note' when --type is omitted."""
result = runner.invoke(
cli_app,
[
"tool",
"write-note",
"--title",
"Test Note",
"--folder",
"notes",
"--content",
"hello",
],
)

assert result.exit_code == 0, f"CLI failed: {result.output}"
assert mock_mcp_write.call_args.kwargs["note_type"] == "note"


# --- read-note ---


Expand Down
Loading