diff --git a/CHANGELOG.md b/CHANGELOG.md index 32364b7e6..685999a11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ ### Features +- **#610**: The manual's SYNOPSIS blocks are now generated from the tool registry. + `just man-regen` renders the MCP call on every section-3 page from the schema + clients actually receive (required parameters first, then defaults, in schema + order) and a test holds the shipped blocks byte-equal to the rendering -- change + a tool signature without regenerating and CI points at the fix. Those pages + declare `generated: registry`; curated sections stay hand-owned. The manual also + gains `basic-memory-diagnostics(3)`, closing the one gap between the section-3 + corpus and the tool registry. + - **#610**: The manual ships in the package and is served over MCP. The 21 section-3 pages (one per MCP tool -- `search-notes(3)`, `write-note(3)`, ...) now live in `src/basic_memory/man/man3/` as canonical, portable notes. The MCP server exposes diff --git a/docs/manual-pages.md b/docs/manual-pages.md index 538ad204c..a76092fc5 100644 --- a/docs/manual-pages.md +++ b/docs/manual-pages.md @@ -186,10 +186,12 @@ GOTCHAS, SEE ALSO, observations) survives — that ownership split is what the ## Roadmap -- **Registry generator** — section-3 SYNOPSIS/PARAMETERS generated from the - MCP tool registry (docstrings + pydantic schemas), section-1 from Typer - help; the hand-written corpus is the template spec. Regenerate-and-diff in - CI becomes the drift gate. +- **Registry generator (SYNOPSIS: shipped)** — `just man-regen` renders every + section-3 MCP SYNOPSIS block from the live tool registry and a test holds + the shipped blocks byte-equal to the rendering, so a tool change without a + regenerate fails CI. Those pages declare `generated: registry`. Still to + come: PARAMETERS from the schema descriptions, and section-1 from Typer + help — the hand-written corpus remains the template spec. - **Projects as consumers** — `bm man install --project ` copies the bundled pages into a project as notes, so `SEE ALSO` becomes traversable relations and the pages join search. (`bm man `, `bm man list`, the diff --git a/justfile b/justfile index 424fb1132..cae2230c8 100644 --- a/justfile +++ b/justfile @@ -510,6 +510,10 @@ clean: format: uv run ruff format . +# Regenerate the manual's registry-owned SYNOPSIS blocks from the MCP tool registry +man-regen: + uv run python scripts/update_man_pages.py + # Run MCP inspector tool run-inspector: npx @modelcontextprotocol/inspector diff --git a/scripts/update_man_pages.py b/scripts/update_man_pages.py new file mode 100644 index 000000000..9ec5940d9 --- /dev/null +++ b/scripts/update_man_pages.py @@ -0,0 +1,55 @@ +"""Regenerate the registry-owned sections of the bundled manual. + +The MCP SYNOPSIS block on every section-3 page whose tool this build registers is +mechanical: it must show exactly the call the tool schema advertises. This script +renders those blocks from the live registry (``mcp.list_tools()``) and rewrites +them in place, flipping the page's ``generated:`` field to ``registry`` so the +ownership split is declared. Curated sections — DESCRIPTION, PARAMETERS, +EXAMPLES, GOTCHAS, SEE ALSO — are never touched. + +Run after changing any MCP tool signature: + + just man-regen (or: uv run python scripts/update_man_pages.py) + +A test (tests/test_man_pages.py) holds every shipped block byte-equal to the +rendering, so a forgotten run fails CI with a pointer here. +""" + +from __future__ import annotations + +import asyncio + +from basic_memory.man import ( + bundled_pages, + declare_registry_ownership, + render_synopsis, + replace_mcp_synopsis, +) +from basic_memory.mcp.server import mcp +import basic_memory.mcp.tools # noqa: F401 (importing registers the tools) + + +async def main() -> None: + tools = {tool.name: tool for tool in await mcp.list_tools(run_middleware=False)} + changed: list[str] = [] + for page in bundled_pages(): + # Pages for tools this build does not register (hosted-only ones like + # cloud_info) stay hand-owned: there is no schema here to render from. + if page.section != 3 or page.tool not in tools: + continue + text = page.read() + updated = replace_mcp_synopsis( + text, render_synopsis(page.tool, tools[page.tool].parameters) + ) + updated = declare_registry_ownership(updated) + if updated != text: + page.path.write_text(updated, encoding="utf-8") + changed.append(page.title) + if changed: + print(f"updated {len(changed)} page(s): {', '.join(changed)}") + else: + print("all pages already match the registry") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/src/basic_memory/man/__init__.py b/src/basic_memory/man/__init__.py index 8b059288e..bde9f9d9f 100644 --- a/src/basic_memory/man/__init__.py +++ b/src/basic_memory/man/__init__.py @@ -12,10 +12,13 @@ from __future__ import annotations +import json import re +from collections.abc import Mapping from dataclasses import dataclass from functools import cache from pathlib import Path +from typing import Any from urllib.parse import unquote from basic_memory.file_utils import parse_frontmatter, remove_frontmatter @@ -85,6 +88,7 @@ class ManPage: section: int name: str summary: str + generated: str tool: str | None path: Path @@ -117,6 +121,7 @@ def bundled_pages() -> tuple[ManPage, ...]: section=int(frontmatter["section"]), name=str(frontmatter["name"]), summary=str(frontmatter["summary"]), + generated=str(frontmatter["generated"]), tool=str(tool) if tool is not None else None, path=path, ) @@ -143,6 +148,96 @@ def find_page(ref: PageRef) -> ManPage | None: return None +# --- Registry-generated SYNOPSIS --- +# The MCP SYNOPSIS block on a section-3 page is a mechanical section: it must show +# exactly the call the tool schema advertises. These helpers render it from the +# schema and splice it into a page; scripts/update_man_pages.py runs them over the +# corpus and a test holds every shipped block byte-equal to the rendering. + +SYNOPSIS_WIDTH = 76 + +# Matches the MCP call block under ## SYNOPSIS. Pages that also show a CLI form +# label the MCP block "MCP:"; MCP-only pages have a single unlabelled block. +_MCP_SYNOPSIS_RE = re.compile(r"(## SYNOPSIS\n\n(?:MCP:\n\n)?```\n)(.*?)(\n```)", re.S) + + +def _default_literal(value: object) -> str: + """Render a schema default the way the call would be written in Python.""" + if isinstance(value, str): + # json.dumps escapes quotes, backslashes, and control characters, and its + # double-quoted output is also a valid Python string literal. + return json.dumps(value) + # None, booleans, and numbers all repr() to their Python spelling. + return repr(value) + + +def render_synopsis(tool_name: str, parameters: Mapping[str, Any]) -> str: + """Render a tool's MCP SYNOPSIS call from the JSON schema clients receive. + + Required parameters come first as bare names, then optional ones as + ``name=default``, each group in schema order — the order clients see. Lines + wrap at the code block's width with continuations aligned under the first + argument. + """ + required: list[str] = parameters.get("required") or [] + properties: Mapping[str, Any] = parameters.get("properties") or {} + + ordered = [name for name in properties if name in required] + for name, prop in properties.items(): + if name in required: + continue + # A default factory leaves no `default` in the schema; render `name=...` so + # the parameter still reads as optional, not as a bare required name. + ordered.append( + f"{name}={_default_literal(prop['default'])}" if "default" in prop else f"{name}=..." + ) + + indent = " " * (len(tool_name) + 1) + current = f"{tool_name}(" + lines: list[str] = [] + for position, argument in enumerate(ordered): + piece = argument + ("," if position < len(ordered) - 1 else ")") + trial = current + piece if current.endswith("(") else f"{current} {piece}" + if len(trial) > SYNOPSIS_WIDTH and not current.endswith("("): + lines.append(current) + current = indent + piece + else: + current = trial + if not ordered: + current += ")" + lines.append(current) + return "\n".join(lines) + + +def extract_mcp_synopsis(page_text: str) -> str: + """The MCP call block a page currently shows under ## SYNOPSIS.""" + match = _MCP_SYNOPSIS_RE.search(page_text) + if match is None: + raise ValueError("page has no MCP SYNOPSIS block") + return match.group(2) + + +def replace_mcp_synopsis(page_text: str, synopsis: str) -> str: + """Return the page with its MCP SYNOPSIS block replaced; other blocks untouched.""" + match = _MCP_SYNOPSIS_RE.search(page_text) + if match is None: + raise ValueError("page has no MCP SYNOPSIS block") + return f"{page_text[: match.start()]}{match.group(1)}{synopsis}{match.group(3)}{page_text[match.end() :]}" + + +def declare_registry_ownership(page_text: str) -> str: + """Flip ``generated: hand`` to ``registry`` — in the frontmatter only. + + A curated body may legally contain a literal ``generated: hand`` line (a YAML + example, say); only the opening frontmatter block is the generator's to rewrite. + """ + frontmatter, fence, body = page_text.partition("\n---\n") + frontmatter = re.sub( + r"^generated: hand$", "generated: registry", frontmatter, count=1, flags=re.M + ) + return frontmatter + fence + body + + def render_index(pages: tuple[ManPage, ...], registered_tools: frozenset[str] | None = None) -> str: """The apropos view: every page, grouped by section, one line each. diff --git a/src/basic_memory/man/man3/basic-memory-diagnostics(3).md b/src/basic_memory/man/man3/basic-memory-diagnostics(3).md new file mode 100644 index 000000000..4bedd0218 --- /dev/null +++ b/src/basic_memory/man/man3/basic-memory-diagnostics(3).md @@ -0,0 +1,68 @@ +--- +title: basic-memory-diagnostics(3) +type: manpage +section: 3 +name: basic-memory-diagnostics +summary: report version, system, and redacted configuration for troubleshooting +generated: registry +tool: basic_memory_diagnostics +verified: 0.23.2 local +--- + +# basic-memory-diagnostics(3) + +## NAME + +**basic-memory-diagnostics** — report version, system, and redacted configuration for troubleshooting + +## SYNOPSIS + +``` +basic_memory_diagnostics() +``` + +## DESCRIPTION + +Returns a markdown report for support requests and install debugging: the +basic-memory package version and API version, the Python version, platform, +and architecture, and the config file path with its contents as a JSON block. +Secrets and API keys are redacted before anything is emitted. + +Read-only by contract: the tool only computes the config path — it never +creates the data directory or touches the database, so it works on a broken +or half-installed setup, which is exactly when it is needed. + +## MCP USAGE + +Verified locally (0.23.2): + +``` +basic_memory_diagnostics() +# → "# Basic Memory Diagnostics +# +# ## Version +# - basic-memory: 0.23.2 +# - API: v2 +# +# ## System +# - Python: 3.14.5 (...) +# - Platform: macOS-26.6-arm64-arm-64bit-Mach-O +# - Architecture: arm64 +# +# ## Configuration +# - Config path: ~/.basic-memory/config.json +# - Config exists: True +# ```json +# { redacted config ... } +# ```" +``` + +## GOTCHAS + +- [gotcha] The config JSON block lists every configured project with its path and mode — redaction removes secrets, not project names, so treat the report as private when sharing #privacy +- [gotcha] A missing or unreadable config file is reported inline (`` / ``) instead of failing — no result is still a diagnostic #resilience + +## SEE ALSO + +- see_also [[list-memory-projects(3)]] +- see_also [[cloud-info(3)]] diff --git a/src/basic_memory/man/man3/build-context(3).md b/src/basic_memory/man/man3/build-context(3).md index 9b74b9652..53f9eda93 100644 --- a/src/basic_memory/man/man3/build-context(3).md +++ b/src/basic_memory/man/man3/build-context(3).md @@ -4,7 +4,7 @@ type: manpage section: 3 name: build-context summary: traverse the knowledge graph outward from a memory:// URL -generated: hand +generated: registry tool: build_context verified: 0.21.6 mcp --- @@ -20,9 +20,8 @@ verified: 0.21.6 mcp MCP: ``` -build_context(url, depth=1, timeframe="7d", max_related=10, - project=None, project_id=None, - page=1, page_size=10, output_format="json") +build_context(url, project=None, project_id=None, depth=1, timeframe="7d", + page=1, page_size=10, max_related=10, output_format="json") ``` CLI: diff --git a/src/basic_memory/man/man3/chatgpt-fetch(3).md b/src/basic_memory/man/man3/chatgpt-fetch(3).md index 04bfaf87e..227b4d7d0 100644 --- a/src/basic_memory/man/man3/chatgpt-fetch(3).md +++ b/src/basic_memory/man/man3/chatgpt-fetch(3).md @@ -4,7 +4,7 @@ type: manpage section: 3 name: chatgpt-fetch summary: OpenAI-actions-compatible document fetch adapter -generated: hand +generated: registry tool: fetch verified: 0.21.6 mcp --- diff --git a/src/basic_memory/man/man3/chatgpt-search(3).md b/src/basic_memory/man/man3/chatgpt-search(3).md index 696b83483..f3e61ec3f 100644 --- a/src/basic_memory/man/man3/chatgpt-search(3).md +++ b/src/basic_memory/man/man3/chatgpt-search(3).md @@ -4,7 +4,7 @@ type: manpage section: 3 name: chatgpt-search summary: OpenAI-actions-compatible search adapter -generated: hand +generated: registry tool: search verified: 0.21.6 mcp --- diff --git a/src/basic_memory/man/man3/create-memory-project(3).md b/src/basic_memory/man/man3/create-memory-project(3).md index e92e90648..94df6eba9 100644 --- a/src/basic_memory/man/man3/create-memory-project(3).md +++ b/src/basic_memory/man/man3/create-memory-project(3).md @@ -4,7 +4,7 @@ type: manpage section: 3 name: create-memory-project summary: create a new project, locally or in a cloud workspace -generated: hand +generated: registry tool: create_memory_project verified: 0.21.6 mcp+cli --- @@ -20,9 +20,8 @@ verified: 0.21.6 mcp+cli MCP: ``` -create_memory_project(project_name, project_path, - set_default=False, workspace=None, - output_format="text") +create_memory_project(project_name, project_path, set_default=False, + workspace=None, output_format="text") ``` CLI: diff --git a/src/basic_memory/man/man3/delete-note(3).md b/src/basic_memory/man/man3/delete-note(3).md index 42d8aa396..bcaaf71a4 100644 --- a/src/basic_memory/man/man3/delete-note(3).md +++ b/src/basic_memory/man/man3/delete-note(3).md @@ -4,7 +4,7 @@ type: manpage section: 3 name: delete-note summary: delete a note or directory from the knowledge base -generated: hand +generated: registry tool: delete_note verified: 0.21.6 mcp+cli --- @@ -20,8 +20,8 @@ verified: 0.21.6 mcp+cli MCP: ``` -delete_note(identifier, is_directory=False, - project=None, project_id=None, output_format="text") +delete_note(identifier, is_directory=False, project=None, project_id=None, + output_format="text") ``` CLI: diff --git a/src/basic_memory/man/man3/delete-project(3).md b/src/basic_memory/man/man3/delete-project(3).md index 2cfc4bac5..9e984d5af 100644 --- a/src/basic_memory/man/man3/delete-project(3).md +++ b/src/basic_memory/man/man3/delete-project(3).md @@ -4,7 +4,7 @@ type: manpage section: 3 name: delete-project summary: remove a project from configuration and index (files survive by default) -generated: hand +generated: registry tool: delete_project verified: 0.21.6 mcp --- diff --git a/src/basic_memory/man/man3/edit-note(3).md b/src/basic_memory/man/man3/edit-note(3).md index 9ac4053bd..644edbe00 100644 --- a/src/basic_memory/man/man3/edit-note(3).md +++ b/src/basic_memory/man/man3/edit-note(3).md @@ -4,7 +4,7 @@ type: manpage section: 3 name: edit-note summary: 'edit a note in place: append, prepend, find/replace, or section surgery' -generated: hand +generated: registry tool: edit_note verified: 0.21.6 mcp+cli --- @@ -20,11 +20,10 @@ verified: 0.21.6 mcp+cli MCP: ``` -edit_note(identifier, operation, content, - section=None, find_text=None, expected_replacements=None, - replace_subsections=None, metadata=None, - project=None, workspace=None, project_id=None, - output_format="text") +edit_note(identifier, operation, content, project=None, workspace=None, + project_id=None, section=None, find_text=None, + expected_replacements=None, replace_subsections=None, + metadata=None, output_format="text") ``` CLI: diff --git a/src/basic_memory/man/man3/list-directory(3).md b/src/basic_memory/man/man3/list-directory(3).md index 95d2b543b..f1f1db174 100644 --- a/src/basic_memory/man/man3/list-directory(3).md +++ b/src/basic_memory/man/man3/list-directory(3).md @@ -4,7 +4,7 @@ type: manpage section: 3 name: list-directory summary: browse project folders with depth and glob filtering -generated: hand +generated: registry tool: list_directory verified: 0.21.6 mcp --- @@ -20,9 +20,9 @@ verified: 0.21.6 mcp MCP: ``` -list_directory(dir_name="/", depth=1, file_name_glob=None, - sort=None, page=1, page_size=10, - project=None, project_id=None, output_format="text") +list_directory(dir_name="/", depth=1, file_name_glob=None, sort=None, + page=1, page_size=10, output_format="text", project=None, + project_id=None) ``` ## DESCRIPTION diff --git a/src/basic_memory/man/man3/list-memory-projects(3).md b/src/basic_memory/man/man3/list-memory-projects(3).md index 928bfcc19..f380ee512 100644 --- a/src/basic_memory/man/man3/list-memory-projects(3).md +++ b/src/basic_memory/man/man3/list-memory-projects(3).md @@ -4,7 +4,7 @@ type: manpage section: 3 name: list-memory-projects summary: list all projects across local config and cloud workspaces -generated: hand +generated: registry tool: list_memory_projects verified: 0.21.6 mcp+cli --- diff --git a/src/basic_memory/man/man3/list-workspaces(3).md b/src/basic_memory/man/man3/list-workspaces(3).md index 8fa3d8653..bfc7a35c4 100644 --- a/src/basic_memory/man/man3/list-workspaces(3).md +++ b/src/basic_memory/man/man3/list-workspaces(3).md @@ -4,7 +4,7 @@ type: manpage section: 3 name: list-workspaces summary: list cloud workspaces available to the authenticated user -generated: hand +generated: registry tool: list_workspaces verified: 0.21.6 mcp+cli --- diff --git a/src/basic_memory/man/man3/move-note(3).md b/src/basic_memory/man/man3/move-note(3).md index 0816a21bf..5cfeb575d 100644 --- a/src/basic_memory/man/man3/move-note(3).md +++ b/src/basic_memory/man/man3/move-note(3).md @@ -4,7 +4,7 @@ type: manpage section: 3 name: move-note summary: move a note or directory, keeping the database consistent -generated: hand +generated: registry tool: move_note verified: 0.21.6 mcp --- @@ -20,10 +20,9 @@ verified: 0.21.6 mcp MCP: ``` -move_note(identifier, - destination_path="" | destination_folder=None, - is_directory=False, - project=None, project_id=None, output_format="text") +move_note(identifier, destination_path="", destination_folder=None, + is_directory=False, project=None, project_id=None, + output_format="text") ``` ## DESCRIPTION diff --git a/src/basic_memory/man/man3/read-content(3).md b/src/basic_memory/man/man3/read-content(3).md index 18bff6df6..902a693cc 100644 --- a/src/basic_memory/man/man3/read-content(3).md +++ b/src/basic_memory/man/man3/read-content(3).md @@ -4,7 +4,7 @@ type: manpage section: 3 name: read-content summary: read a file's content without knowledge-graph processing -generated: hand +generated: registry tool: read_content verified: 0.21.6 mcp --- diff --git a/src/basic_memory/man/man3/read-note(3).md b/src/basic_memory/man/man3/read-note(3).md index 4e93bf15a..4bbb1efd4 100644 --- a/src/basic_memory/man/man3/read-note(3).md +++ b/src/basic_memory/man/man3/read-note(3).md @@ -4,7 +4,7 @@ type: manpage section: 3 name: read-note summary: read a note by title, permalink, or memory:// URL -generated: hand +generated: registry tool: read_note verified: 0.21.6 mcp+cli --- @@ -20,8 +20,7 @@ verified: 0.21.6 mcp+cli MCP: ``` -read_note(identifier, - project=None, project_id=None, page=1, page_size=10, +read_note(identifier, project=None, project_id=None, page=1, page_size=10, output_format="text", include_frontmatter=False) ``` diff --git a/src/basic_memory/man/man3/recent-activity(3).md b/src/basic_memory/man/man3/recent-activity(3).md index fef4611ff..c7df55ffb 100644 --- a/src/basic_memory/man/man3/recent-activity(3).md +++ b/src/basic_memory/man/man3/recent-activity(3).md @@ -4,7 +4,7 @@ type: manpage section: 3 name: recent-activity summary: list recently changed notes, observations, and relations -generated: hand +generated: registry tool: recent_activity verified: 0.21.6 mcp+cli --- @@ -20,9 +20,8 @@ verified: 0.21.6 mcp+cli MCP: ``` -recent_activity(type="", depth=1, timeframe="7d", - project=None, project_id=None, - page=1, page_size=10, output_format="text") +recent_activity(type="", depth=1, timeframe="7d", page=1, page_size=10, + project=None, project_id=None, output_format="text") ``` CLI: diff --git a/src/basic_memory/man/man3/schema-diff(3).md b/src/basic_memory/man/man3/schema-diff(3).md index ed8d94e19..59f6bdf7f 100644 --- a/src/basic_memory/man/man3/schema-diff(3).md +++ b/src/basic_memory/man/man3/schema-diff(3).md @@ -4,7 +4,7 @@ type: manpage section: 3 name: schema-diff summary: detect drift between a schema and actual note usage -generated: hand +generated: registry tool: schema_diff verified: 0.21.6 mcp --- diff --git a/src/basic_memory/man/man3/schema-infer(3).md b/src/basic_memory/man/man3/schema-infer(3).md index 933c5fadb..aa229476f 100644 --- a/src/basic_memory/man/man3/schema-infer(3).md +++ b/src/basic_memory/man/man3/schema-infer(3).md @@ -4,7 +4,7 @@ type: manpage section: 3 name: schema-infer summary: derive a Picoschema suggestion from existing notes -generated: hand +generated: registry tool: schema_infer verified: 0.21.6 mcp --- @@ -18,8 +18,8 @@ verified: 0.21.6 mcp ## SYNOPSIS ``` -schema_infer(note_type, threshold=0.25, - project=None, project_id=None, output_format="text") +schema_infer(note_type, threshold=0.25, project=None, project_id=None, + output_format="text") ``` ## DESCRIPTION diff --git a/src/basic_memory/man/man3/schema-validate(3).md b/src/basic_memory/man/man3/schema-validate(3).md index e045dc946..6e7224ea7 100644 --- a/src/basic_memory/man/man3/schema-validate(3).md +++ b/src/basic_memory/man/man3/schema-validate(3).md @@ -4,7 +4,7 @@ type: manpage section: 3 name: schema-validate summary: validate notes against their Picoschema definitions -generated: hand +generated: registry tool: schema_validate verified: 0.21.6 mcp+cli --- @@ -20,8 +20,8 @@ verified: 0.21.6 mcp+cli MCP: ``` -schema_validate(note_type=None, identifier=None, - project=None, project_id=None, output_format="text") +schema_validate(note_type=None, identifier=None, project=None, + project_id=None, output_format="text") ``` CLI: diff --git a/src/basic_memory/man/man3/search-notes(3).md b/src/basic_memory/man/man3/search-notes(3).md index 65106f9c9..25b1d668c 100644 --- a/src/basic_memory/man/man3/search-notes(3).md +++ b/src/basic_memory/man/man3/search-notes(3).md @@ -4,7 +4,7 @@ type: manpage section: 3 name: search-notes summary: search the knowledge base by text, similarity, or metadata -generated: hand +generated: registry tool: search_notes verified: 0.21.6 mcp+cli --- @@ -20,12 +20,12 @@ verified: 0.21.6 mcp+cli MCP: ``` -search_notes(query=None, - project=None, project_id=None, search_type=None, - note_types=None, entity_types=None, categories=None, - metadata_filters=None, tags=None, status=None, after_date=None, - min_similarity=None, search_all_projects=False, - page=1, page_size=10, output_format="text") +search_notes(query=None, project=None, project_id=None, + search_all_projects=False, page=1, page_size=10, + search_type=None, output_format="text", note_types=None, + entity_types=None, categories=None, after_date=None, + metadata_filters=None, tags=None, status=None, + min_similarity=None) ``` CLI: diff --git a/src/basic_memory/man/man3/view-note(3).md b/src/basic_memory/man/man3/view-note(3).md index a1068f9db..5e160792f 100644 --- a/src/basic_memory/man/man3/view-note(3).md +++ b/src/basic_memory/man/man3/view-note(3).md @@ -4,7 +4,7 @@ type: manpage section: 3 name: view-note summary: retrieve a note formatted for artifact display -generated: hand +generated: registry tool: view_note verified: 0.21.6 mcp --- diff --git a/src/basic_memory/man/man3/write-note(3).md b/src/basic_memory/man/man3/write-note(3).md index f5a74f47e..540154d33 100644 --- a/src/basic_memory/man/man3/write-note(3).md +++ b/src/basic_memory/man/man3/write-note(3).md @@ -4,7 +4,7 @@ type: manpage section: 3 name: write-note summary: create or overwrite a markdown note in the knowledge base -generated: hand +generated: registry tool: write_note verified: 0.21.6 mcp+cli --- @@ -20,10 +20,9 @@ verified: 0.21.6 mcp+cli MCP: ``` -write_note(title, content, directory, - project=None, workspace=None, project_id=None, - tags=None, note_type="note", metadata=None, overwrite=None, - output_format="text") +write_note(title, content, directory, project=None, workspace=None, + project_id=None, tags=None, note_type="note", metadata=None, + overwrite=None, output_format="text") ``` CLI: diff --git a/tests/test_man_pages.py b/tests/test_man_pages.py index 8b7465c94..bb758009d 100644 --- a/tests/test_man_pages.py +++ b/tests/test_man_pages.py @@ -8,12 +8,15 @@ from basic_memory.man import ( MAN_DIR, - ManPage, PageRef, bundled_pages, + declare_registry_ownership, + extract_mcp_synopsis, find_page, parse_page_ref, render_index, + render_synopsis, + replace_mcp_synopsis, ) from basic_memory.mcp.server import mcp from basic_memory.mcp.tools import __all__ as registered_tools @@ -88,7 +91,7 @@ def test_bundled_pages_are_well_formed_and_sorted() -> None: # The section-3 corpus and the tool registry are meant to match one to one. Both # lists change deliberately; this pins the known gaps so a new tool without a page # (or a page for a retired tool) shows up here instead of going unnoticed. -TOOLS_WITHOUT_PAGES = {"basic_memory_diagnostics"} +TOOLS_WITHOUT_PAGES: set[str] = set() PAGES_WITHOUT_LOCAL_TOOLS = {"cloud_info"} # hosted-only; see cloud-info(3) @@ -99,37 +102,90 @@ def test_section_3_matches_the_tool_registry_except_known_gaps() -> None: assert documented - set(registered_tools) == PAGES_WITHOUT_LOCAL_TOOLS -def _synopsis_parameters(page: ManPage) -> set[str]: - """Parameter names in the MCP call shown under SYNOPSIS, positional or keyword.""" - synopsis = re.search(r"## SYNOPSIS\n(.*?)\n## ", page.body(), re.S) - assert synopsis is not None, f"{page.title} has no SYNOPSIS" - # Pages with a CLI form label the MCP block "MCP:"; MCP-only pages have one block. - call = re.search(r"MCP:\s*```\n(.*?)```", synopsis.group(1), re.S) or re.search( - r"```\n(.*?)```", synopsis.group(1), re.S - ) - assert call is not None, f"{page.title} SYNOPSIS has no MCP call" - arguments = call.group(1)[call.group(1).find("(") + 1 : call.group(1).rfind(")")] - return set(re.findall(r"\b([a-z_][a-z0-9_]*)\b(?=\s*[=,)\n]|$)", arguments)) - { - "none", - "true", - "false", +def test_render_synopsis_orders_required_first_and_wraps() -> None: + parameters = { + "required": ["query"], + "properties": { + "alpha": {"default": None}, + "query": {"type": "string"}, + "flag": {"default": False}, + "mode": {"default": "text"}, + "count": {"default": 10}, + }, } + assert ( + render_synopsis("demo", parameters) + == 'demo(query, alpha=None, flag=False, mode="text", count=10)' + ) + + wide = {"required": [], "properties": {f"parameter_{i}": {"default": None} for i in range(9)}} + rendered = render_synopsis("demo_tool", wide) + assert all(len(line) <= 76 for line in rendered.splitlines()) + assert rendered.splitlines()[1].startswith(" " * len("demo_tool(")) + assert rendered.endswith(")") + assert render_synopsis("bare", {"properties": {}}) == "bare()" + # A control character in a default must be escaped, not embedded literally. + tricky = {"properties": {"sep": {"default": "a\nb"}, "q": {"default": 'say "hi"'}}} + assert render_synopsis("demo", tricky) == 'demo(sep="a\\nb", q="say \\"hi\\"")' + # A default factory leaves no schema default; the parameter must still read + # as optional (name=...), never as a bare required name. + factory = {"required": ["query"], "properties": {"query": {}, "tags": {}}} + assert render_synopsis("demo", factory) == "demo(query, tags=...)" + + +def test_replace_mcp_synopsis_touches_only_the_mcp_block() -> None: + labelled = "# t\n\n## SYNOPSIS\n\nMCP:\n\n```\nold()\n```\n\nCLI:\n\n```\nbm t\n```\n\n## DESCRIPTION\n" + bare = "# t\n\n## SYNOPSIS\n\n```\nold()\n```\n\n## DESCRIPTION\n" + + replaced = replace_mcp_synopsis(labelled, "new(a, b=1)") + assert extract_mcp_synopsis(replaced) == "new(a, b=1)" + assert "```\nbm t\n```" in replaced # the CLI block is not the generator's to rewrite + assert extract_mcp_synopsis(replace_mcp_synopsis(bare, "new()")) == "new()" + with pytest.raises(ValueError, match="no MCP SYNOPSIS block"): + replace_mcp_synopsis("# t\n\n## DESCRIPTION\n", "new()") + with pytest.raises(ValueError, match="no MCP SYNOPSIS block"): + extract_mcp_synopsis("# t\n\n## DESCRIPTION\n") @pytest.mark.asyncio -async def test_section_3_synopsis_names_every_tool_parameter() -> None: - # The SYNOPSIS is the page's contract with the tool schema clients receive. A - # parameter added to a tool without updating its page is exactly the drift the - # manual exists to prevent, so it fails here rather than waiting for a reader. +async def test_section_3_synopsis_is_exactly_the_registry_rendering() -> None: + # The MCP SYNOPSIS block is a mechanical section owned by the registry + # generator: byte-equal to the rendering of the schema clients receive. A tool + # change without regenerating the pages fails here, pointing at the fix. tools = {tool.name: tool for tool in await mcp.list_tools(run_middleware=False)} for page in bundled_pages(): if page.section != 3 or page.tool not in tools: continue - schema = set(tools[page.tool].parameters["properties"]) - documented = _synopsis_parameters(page) - assert schema - documented == set(), f"{page.title} SYNOPSIS is missing parameters" - assert documented - schema == set(), f"{page.title} SYNOPSIS names unknown parameters" + expected = render_synopsis(page.tool, tools[page.tool].parameters) + assert extract_mcp_synopsis(page.read()) == expected, ( + f"{page.title} SYNOPSIS is stale; run `just man-regen` and commit the result" + ) + + +def test_declare_registry_ownership_touches_frontmatter_only() -> None: + # A curated body may contain a literal `generated: hand` line (a YAML example); + # only the opening frontmatter block is the generator's to rewrite. + page = ( + "---\ntitle: t(3)\ngenerated: hand\ntool: t\n---\n\n# t(3)\n\n" + "```yaml\ngenerated: hand\n```\n" + ) + + flipped = declare_registry_ownership(page) + + assert flipped.startswith("---\ntitle: t(3)\ngenerated: registry\ntool: t\n---\n") + assert "```yaml\ngenerated: hand\n```" in flipped + assert declare_registry_ownership(flipped) == flipped + + +def test_registry_pages_declare_registry_ownership() -> None: + # generated: declares who may rewrite the mechanical sections. Every page whose + # tool this build registers is generator-managed; hosted-only pages stay hand. + for page in bundled_pages(): + if page.section != 3: + continue + expected = "registry" if page.tool in set(registered_tools) else "hand" + assert page.generated == expected, f"{page.title} declares generated: {page.generated}" def test_section_3_links_resolve_to_bundled_pages() -> None: