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
39 changes: 29 additions & 10 deletions scripts/update_man_pages.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
"""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.
The MCP SYNOPSIS and PARAMETERS blocks on every section-3 page whose tool this
build registers are mechanical: they must show exactly what 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, EXAMPLES, GOTCHAS, SEE ALSO — are never touched.

Run after changing any MCP tool signature:

Expand All @@ -18,17 +18,39 @@
from __future__ import annotations

import asyncio
from collections.abc import Mapping
from typing import Any

from basic_memory.man import (
bundled_pages,
declare_registry_ownership,
remove_parameters,
render_parameters,
render_synopsis,
replace_mcp_synopsis,
replace_parameters,
)
from basic_memory.mcp.server import mcp
import basic_memory.mcp.tools # noqa: F401 (importing registers the tools)


def regenerate_page(text: str, tool_name: str, schema: Mapping[str, Any]) -> str:
"""Rewrite the registry-owned sections of one section-3 page from its schema.

SYNOPSIS is always mechanical. PARAMETERS exists exactly when the schema has
properties: a tool with parameters gets the rendered block (rewritten in place
or inserted), and a parameterless tool gets none — any previously generated
block is stripped so the page never advertises removed arguments. Ownership is
then declared by flipping ``generated:`` to ``registry``.
"""
updated = replace_mcp_synopsis(text, render_synopsis(tool_name, schema))
if schema.get("properties"):
updated = replace_parameters(updated, render_parameters(tool_name, schema))
else:
updated = remove_parameters(updated)
return declare_registry_ownership(updated)


async def main() -> None:
tools = {tool.name: tool for tool in await mcp.list_tools(run_middleware=False)}
changed: list[str] = []
Expand All @@ -38,10 +60,7 @@ async def main() -> None:
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)
updated = regenerate_page(text, page.tool, tools[page.tool].parameters)
if updated != text:
page.path.write_text(updated, encoding="utf-8")
changed.append(page.title)
Expand Down
149 changes: 149 additions & 0 deletions src/basic_memory/man/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,11 @@ def find_page(ref: PageRef) -> ManPage | None:
# 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)

# Matches the body under ## PARAMETERS. Unlike SYNOPSIS there is no fenced code to
# anchor on, so a lookahead stops the match at the blank line before the next
# `## ` heading (or EOF), leaving that separator out of the captured body.
_PARAMETERS_RE = re.compile(r"(## PARAMETERS\n\n)(.*?)(?=\n+## |\n*\Z)", re.S)


def _default_literal(value: object) -> str:
"""Render a schema default the way the call would be written in Python."""
Expand Down Expand Up @@ -209,6 +214,90 @@ def render_synopsis(tool_name: str, parameters: Mapping[str, Any]) -> str:
return "\n".join(lines)


def _normalise_description(description: object) -> str:
"""Collapse a schema description to one line for a PARAMETERS bullet.

Tool descriptions come from the tools' docstring ``Args:`` blocks, so they
carry the source's line breaks and hanging indentation. A bullet is a single
line, so runs of whitespace (newlines and indentation included) collapse to
single spaces; the renderer never reflows or reinterprets the prose beyond that.
"""
if not description:
return ""
return " ".join(str(description).split())


def _schema_type(prop: Mapping[str, Any], defs: Mapping[str, Any] | None = None) -> str:
"""A readable type name for a property's schema, or "" when unknown.

A plain ``type`` passes through; a list type or a union schema
(``anyOf``/``oneOf``) joins its member names with `` | `` so a nullable
string reads ``string | null``. A union member may be a ``$ref`` into the
schema's ``$defs`` (how Pydantic emits an enum): it resolves to the enum's
underlying JSON type, so a ``$ref`` enum + null reads ``string | null`` like
any other nullable union rather than a bare ``null``.
"""
type_field = prop.get("type")
if isinstance(type_field, str):
return type_field
if isinstance(type_field, list):
return " | ".join(str(member) for member in type_field)
for key in ("anyOf", "oneOf"):
members = prop.get(key)
if members:
names: list[str] = []
for member in members:
member_type = member.get("type")
if isinstance(member_type, str):
names.append(member_type)
continue
ref = member.get("$ref")
if isinstance(ref, str) and defs is not None:
target = defs.get(ref.rsplit("/", 1)[-1], {})
target_type = target.get("type")
if isinstance(target_type, str):
names.append(target_type)
if names:
return " | ".join(names)
return ""


def render_parameters(tool_name: str, parameters: Mapping[str, Any]) -> str:
"""Render a tool's ## PARAMETERS body from the JSON schema clients receive.

Required parameters come first, then optional ones, each group in schema order
(the order clients see) — mirroring render_synopsis. Each bullet names the
parameter, its type when the schema gives one, whether it is required or
optional (with the default for optionals that carry one), and its description.
Returns "" when the schema has no properties, so tools like
basic_memory_diagnostics get no section.
"""
required: list[str] = parameters.get("required") or []
properties: Mapping[str, Any] = parameters.get("properties") or {}
if not properties:
return ""

ordered = [name for name in properties if name in required]
ordered += [name for name in properties if name not in required]

bullets: list[str] = []
for name in ordered:
prop = properties[name]
type_name = _schema_type(prop, parameters.get("$defs"))
if name in required:
qualifiers = f"{type_name}, required" if type_name else "required"
else:
qualifiers = f"{type_name}, optional" if type_name else "optional"
# A default factory leaves no `default` in the schema; render just
# `optional`, since there is no literal value to show.
if "default" in prop:
qualifiers += f", default: {_default_literal(prop['default'])}"
head = f"- **{name}** ({qualifiers})"
description = _normalise_description(prop.get("description"))
bullets.append(f"{head} — {description}" if description else head)
return "\n".join(bullets)


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)
Expand All @@ -225,6 +314,66 @@ def replace_mcp_synopsis(page_text: str, synopsis: str) -> str:
return f"{page_text[: match.start()]}{match.group(1)}{synopsis}{match.group(3)}{page_text[match.end() :]}"


def extract_parameters(page_text: str) -> str:
"""The bullet body a page currently shows under ## PARAMETERS."""
match = _PARAMETERS_RE.search(page_text)
if match is None:
raise ValueError("page has no PARAMETERS block")
return match.group(2)


def replace_parameters(page_text: str, parameters: str) -> str:
"""Return the page with its ## PARAMETERS body replaced, inserting the section
if the page has none.

An existing block is rewritten in place. Otherwise the section is placed just
before ## DESCRIPTION if present, else right after the SYNOPSIS block (before
the next `## ` heading following ## SYNOPSIS). Other blocks are untouched.
"""
match = _PARAMETERS_RE.search(page_text)
if match is not None:
# The lookahead leaves the trailing heading out of the match, so append
# the rest of the page from match.end() unchanged.
return f"{page_text[: match.start()]}{match.group(1)}{parameters}{page_text[match.end() :]}"

block = f"## PARAMETERS\n\n{parameters}\n\n"
description = page_text.find("## DESCRIPTION")
if description != -1:
return f"{page_text[:description]}{block}{page_text[description:]}"

# No DESCRIPTION anchor: land the section after the SYNOPSIS block, at the
# next `## ` heading that follows ## SYNOPSIS.
synopsis = page_text.find("## SYNOPSIS")
if synopsis != -1:
following = page_text.find("\n## ", synopsis + len("## SYNOPSIS"))
if following != -1:
insert = following + 1 # after the newline, at the `## ` heading
return f"{page_text[:insert]}{block}{page_text[insert:]}"

raise ValueError("page has nowhere to place PARAMETERS")


def remove_parameters(page_text: str) -> str:
"""Return the page with any ## PARAMETERS section stripped; unchanged if none.

A tool that loses its last parameter must lose its section too, so a page can
never keep advertising removed arguments. The whole section — heading, body,
and one blank-line separator — comes out, leaving exactly one blank line
between the surrounding sections (or a clean single trailing newline when the
section sat at end of file). A page with no PARAMETERS block is returned as is.
"""
match = _PARAMETERS_RE.search(page_text)
if match is None:
return page_text
# The heading's leading separator lives in the preceding section's trailing
# newlines, and the lookahead leaves the following separator out of the match;
# strip both sides to a single blank line so no double gap or dangling section
# heading is left behind.
before = page_text[: match.start()].rstrip("\n")
after = page_text[match.end() :].lstrip("\n")
return f"{before}\n\n{after}" if after else f"{before}\n"


def declare_registry_ownership(page_text: str) -> str:
"""Flip ``generated: hand`` to ``registry`` — in the frontmatter only.

Expand Down
16 changes: 9 additions & 7 deletions src/basic_memory/man/man3/build-context(3).md
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,15 @@ costs two depth levels internally (relation, then entity).

## PARAMETERS

- **url** — memory:// URI or bare permalink path
- **depth** — relation hops (1–3 recommended; higher gets slow)
- **timeframe** — recency filter on traversed items; natural language
accepted (`"last week"`, `"2 days ago"`, `"7d"`)
- **max_related** — cap on related results per primary note
- **output_format** — `json` (structured, default) or `text` (compact
markdown for LLM consumption)
- **url** (string, required) — memory:// URI pointing to discussion content (e.g. memory://specs/search), or a bare permalink path.
- **project** (string | null, optional, default: None) — Project name to build context from. Optional - server will resolve using hierarchy. If unknown, use list_memory_projects() to discover available projects.
- **project_id** (string | null, optional, default: None) — Project external_id (UUID). Prefer this over `project` when known — it routes to the exact project regardless of name collisions across cloud workspaces. Takes precedence over `project`. Get from list_memory_projects().
- **depth** (string | integer | null, optional, default: 1) — How many relation hops to traverse (1-3 recommended for performance)
- **timeframe** (string | null, optional, default: "7d") — How far back to look. Supports natural language like "2 days ago", "last week"
- **page** (integer, optional, default: 1) — Page number of results to return (default: 1)
- **page_size** (integer, optional, default: 10) — Number of primary results to return per page (default: 10, maximum: 50)
- **max_related** (integer, optional, default: 10) — Maximum total related results to return (default: 10, maximum: 100)
- **output_format** (string, optional, default: "json") — Response format - "json" for structured JSON dict, "text" for compact markdown text

## MCP USAGE

Expand Down
4 changes: 4 additions & 0 deletions src/basic_memory/man/man3/chatgpt-fetch(3).md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ verified: 0.21.6 mcp
fetch(id)
```

## PARAMETERS

- **id** (string, required) — Document identifier (permalink, title, or memory URL)

## DESCRIPTION

**Availability:** `fetch` answers only OpenAI clients (ChatGPT connectors).
Expand Down
4 changes: 4 additions & 0 deletions src/basic_memory/man/man3/chatgpt-search(3).md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ verified: 0.21.6 mcp
search(query)
```

## PARAMETERS

- **query** (string, required) — Search query (full-text syntax supported by `search_notes`)

## DESCRIPTION

**Availability:** `search` answers only OpenAI clients (ChatGPT connectors).
Expand Down
8 changes: 8 additions & 0 deletions src/basic_memory/man/man3/create-memory-project(3).md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ bm project add NAME [PATH] [--cloud] [--workspace SELECTOR]
[--visibility shared|private] [--local-path PATH]
```

## PARAMETERS

- **project_name** (string, required) — Name for the new project (must be unique)
- **project_path** (string, required) — File system path where the project will be stored
- **set_default** (boolean, optional, default: False) — Whether to set this project as the default (optional, defaults to False)
- **workspace** (string | null, optional, default: None) — Optional cloud workspace selector to create the project in. Slug is preferred for AI callers, but tenant_id and unique name are also accepted. When omitted, the connection's default workspace is used. Discover values via `list_workspaces`. A workspace selector implies cloud routing: without cloud credentials the call fails fast instead of silently creating a local project (#954).
- **output_format** (string, optional, default: "text") — "text" returns the existing human-readable result text. "json" returns structured project creation metadata.

## DESCRIPTION

Creates and registers a project. Local projects take a filesystem path;
Expand Down
8 changes: 8 additions & 0 deletions src/basic_memory/man/man3/delete-note(3).md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ CLI:
bm tool delete-note IDENTIFIER [--is-directory] [--project NAME]
```

## PARAMETERS

- **identifier** (string, required) — For files: note title or permalink to delete. For directories: the directory path (e.g., "docs", "projects/2025"). Can be a title like "Meeting Notes" or permalink like "notes/meeting-notes"
- **is_directory** (boolean, optional, default: False) — If True, deletes an entire directory and all its contents. When True, identifier should be a directory path (without file extensions). Defaults to False.
- **project** (string | null, optional, default: None) — Project name to delete from. Optional - server will resolve using hierarchy. If unknown, use list_memory_projects() to discover available projects.
- **project_id** (string | null, optional, default: None) — Project external_id (UUID). Prefer this over `project` when known — it routes to the exact project regardless of name collisions across cloud workspaces. Takes precedence over `project`. Get from list_memory_projects().
- **output_format** (string, optional, default: "text") — "text" preserves existing behavior (bool/string). "json" returns machine-readable deletion metadata.

## DESCRIPTION

Removes a note (or, with `is_directory=True`, an entire directory and its
Expand Down
6 changes: 6 additions & 0 deletions src/basic_memory/man/man3/delete-project(3).md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ CLI:
bm project remove NAME
```

## PARAMETERS

- **project_name** (string, required) — Name of the project to delete
- **delete_notes** (boolean, optional, default: False) — Also delete the project's note files (from local disk for local projects, from cloud storage for cloud projects). Defaults to False, which only stops tracking the project.
- **workspace** (string | null, optional, default: None) — Optional cloud workspace selector to delete the project from. Slug is preferred for AI callers, but tenant_id and unique name are also accepted. When omitted, the connection's default workspace is used. A workspace selector implies cloud routing: without cloud credentials the call fails fast, matching create_memory_project behavior (#954).

## DESCRIPTION

Unregisters a project from Basic Memory's configuration and database. By
Expand Down
Loading
Loading