diff --git a/scripts/update_man_pages.py b/scripts/update_man_pages.py index 9ec5940d9..a66eb6f88 100644 --- a/scripts/update_man_pages.py +++ b/scripts/update_man_pages.py @@ -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: @@ -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] = [] @@ -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) diff --git a/src/basic_memory/man/__init__.py b/src/basic_memory/man/__init__.py index bde9f9d9f..74b27f82e 100644 --- a/src/basic_memory/man/__init__.py +++ b/src/basic_memory/man/__init__.py @@ -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.""" @@ -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) @@ -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. diff --git a/src/basic_memory/man/man3/build-context(3).md b/src/basic_memory/man/man3/build-context(3).md index 53f9eda93..f5d665ea2 100644 --- a/src/basic_memory/man/man3/build-context(3).md +++ b/src/basic_memory/man/man3/build-context(3).md @@ -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 diff --git a/src/basic_memory/man/man3/chatgpt-fetch(3).md b/src/basic_memory/man/man3/chatgpt-fetch(3).md index 227b4d7d0..ec7c2888e 100644 --- a/src/basic_memory/man/man3/chatgpt-fetch(3).md +++ b/src/basic_memory/man/man3/chatgpt-fetch(3).md @@ -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). diff --git a/src/basic_memory/man/man3/chatgpt-search(3).md b/src/basic_memory/man/man3/chatgpt-search(3).md index f3e61ec3f..9c4ea5cf2 100644 --- a/src/basic_memory/man/man3/chatgpt-search(3).md +++ b/src/basic_memory/man/man3/chatgpt-search(3).md @@ -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). 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 94df6eba9..d02096545 100644 --- a/src/basic_memory/man/man3/create-memory-project(3).md +++ b/src/basic_memory/man/man3/create-memory-project(3).md @@ -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; diff --git a/src/basic_memory/man/man3/delete-note(3).md b/src/basic_memory/man/man3/delete-note(3).md index bcaaf71a4..52d478ad5 100644 --- a/src/basic_memory/man/man3/delete-note(3).md +++ b/src/basic_memory/man/man3/delete-note(3).md @@ -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 diff --git a/src/basic_memory/man/man3/delete-project(3).md b/src/basic_memory/man/man3/delete-project(3).md index 9e984d5af..ca922fd6d 100644 --- a/src/basic_memory/man/man3/delete-project(3).md +++ b/src/basic_memory/man/man3/delete-project(3).md @@ -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 diff --git a/src/basic_memory/man/man3/edit-note(3).md b/src/basic_memory/man/man3/edit-note(3).md index d179667f7..638432c3a 100644 --- a/src/basic_memory/man/man3/edit-note(3).md +++ b/src/basic_memory/man/man3/edit-note(3).md @@ -55,23 +55,18 @@ permalink, or memory:// URL — there is no fuzzy fallback for edits. ## PARAMETERS -- **identifier** — exact title, permalink, or memory:// URL (CLI: positional) -- **operation** — one of the six operations above -- **content** — the content to add or substitute -- **section** — heading for the section operations (e.g. `"## Observations"`) -- **find_text** — target text for find_replace -- **expected_replacements** — if set, the edit fails unless the occurrence - count matches exactly -- **replace_subsections** — for replace_section. Default (true): the section - runs to the next heading of the same or higher level, so replacing - `## Section` replaces its `###` subsections too. `False` stops at the next - heading of any level and preserves subsections -- **metadata** — dict of frontmatter fields merged in alongside any operation; - given keys overwrite or add, other keys and the body are untouched. - `title` and `permalink` are ignored; `type` is applied like any other - frontmatter field; keys cannot be deleted -- **project** / **project_id** / **workspace** — routing; same semantics as - [[write-note(3)]] +- **identifier** (string, required) — The exact title, permalink, or memory:// URL of the note to edit. Must be an exact match - fuzzy matching is not supported for edit operations. Use search_notes() or read_note() first to find the correct identifier if uncertain. From the CLI this is a positional argument, not a flag. +- **operation** (string, required) — The editing operation to perform: - "append": Add content to the end of the note (creates the note if it doesn't exist) - "prepend": Add content to the beginning of the note (creates the note if it doesn't exist) - "find_replace": Replace occurrences of find_text with content (note must exist) - "replace_section": Replace a markdown section identified by its header (note must exist). By default the section spans through the next heading of the same or higher level, so its subsections are replaced too; see replace_subsections. - "insert_before_section": Insert content before a section heading without consuming it (note must exist) - "insert_after_section": Insert content after a section heading without consuming it (note must exist) +- **content** (string, required) — The content to add or use for replacement +- **project** (string | null, optional, default: None) — Project name to edit in. Optional - server will resolve using hierarchy. Use "workspace/project" to route to a project in a specific cloud workspace. If unknown, use list_memory_projects() to discover available projects. +- **workspace** (string | null, optional, default: None) — Workspace slug, name, or tenant_id. When provided with `project`, routes as `workspace/project`. Cannot be combined with `project_id`. +- **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(). +- **section** (string | null, optional, default: None) — For replace_section operation - the markdown header to replace content under (e.g., "## Notes", "### Implementation") +- **find_text** (string | null, optional, default: None) — For find_replace operation - the text to find and replace +- **expected_replacements** (integer | null, optional, default: None) — For find_replace operation - the expected number of replacements (validation will fail if actual doesn't match) +- **replace_subsections** (boolean | null, optional, default: None) — For replace_section operation. Default (true): the section spans everything through the next heading of the same or higher level in the original note, so replacing "## Section" also replaces its "###" subsections — the replacement content may freely introduce new headings. Set to false to replace only the immediate content under the header, stopping at the next heading of any level and preserving subsections. +- **metadata** (object | null, optional, default: None) — Optional dict of frontmatter fields to merge, independent of `operation`. Provided keys overwrite existing frontmatter values (or are added if new); unrelated frontmatter keys and the note body are left untouched. Can be combined with any operation in the same call. `title` and `permalink` are ignored since those have their own dedicated handling; `type` is applied like any other frontmatter field. Key deletion is not supported. +- **output_format** (string, optional, default: "text") — "text" returns the existing markdown summary. "json" returns machine-readable edit metadata. ## MCP USAGE diff --git a/src/basic_memory/man/man3/list-directory(3).md b/src/basic_memory/man/man3/list-directory(3).md index f1f1db174..a075fc088 100644 --- a/src/basic_memory/man/man3/list-directory(3).md +++ b/src/basic_memory/man/man3/list-directory(3).md @@ -25,6 +25,18 @@ list_directory(dir_name="/", depth=1, file_name_glob=None, sort=None, project_id=None) ``` +## PARAMETERS + +- **dir_name** (string, optional, default: "/") — Directory path to list (default: root "/") Examples: "/", "/projects", "/research/ml" +- **depth** (integer, optional, default: 1) — Recursion depth (1-10, default: 1 for immediate children only) Higher values show subdirectory contents recursively +- **file_name_glob** (string | null, optional, default: None) — Optional glob pattern for filtering file names Examples: "*.md", "*meeting*", "project_*" +- **sort** (string | null, optional, default: None) — Optional file ordering: "title_asc", "title_desc", "updated_asc", or "updated_desc". Directories remain first. +- **page** (integer, optional, default: 1) — One-indexed result page (default: 1) +- **page_size** (integer, optional, default: 10) — Number of nodes per page (default: 10, maximum: 200) +- **output_format** (string, optional, default: "text") — "text" for a readable listing or "json" for structured pagination data +- **project** (string | null, optional, default: None) — Project name to list directory 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(). + ## DESCRIPTION Returns a tree-style listing of a project directory: subfolders with paths, 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 f380ee512..910b3242b 100644 --- a/src/basic_memory/man/man3/list-memory-projects(3).md +++ b/src/basic_memory/man/man3/list-memory-projects(3).md @@ -30,6 +30,10 @@ bm tool list-projects bm project list # richer table, includes routing and sync columns ``` +## PARAMETERS + +- **output_format** (string, optional, default: "text") — "text" returns the existing human-readable project list. "json" returns structured project metadata. + ## DESCRIPTION Returns a unified view of every reachable project: local projects from diff --git a/src/basic_memory/man/man3/list-workspaces(3).md b/src/basic_memory/man/man3/list-workspaces(3).md index bfc7a35c4..6be8087c5 100644 --- a/src/basic_memory/man/man3/list-workspaces(3).md +++ b/src/basic_memory/man/man3/list-workspaces(3).md @@ -29,6 +29,10 @@ CLI: bm tool list-workspaces ``` +## PARAMETERS + +- **output_format** (string, optional, default: "text") — "text" returns human-readable workspace list. "json" returns structured workspace metadata. + ## DESCRIPTION Returns the cloud tenants the current user belongs to: `tenant_id`, `slug`, diff --git a/src/basic_memory/man/man3/move-note(3).md b/src/basic_memory/man/man3/move-note(3).md index 5cfeb575d..6c3c9a27a 100644 --- a/src/basic_memory/man/man3/move-note(3).md +++ b/src/basic_memory/man/man3/move-note(3).md @@ -25,6 +25,16 @@ move_note(identifier, destination_path="", destination_folder=None, output_format="text") ``` +## PARAMETERS + +- **identifier** (string, required) — For files: exact entity identifier (title, permalink, or memory:// URL). For directories: the directory path (e.g., "docs", "projects/2025"). Must be an exact match - fuzzy matching is not supported for move operations. Use search_notes() or list_directory() first to find the correct path if uncertain. +- **destination_path** (string, optional, default: "") — For files: new path relative to project root (e.g., "work/meetings/note.md") For directories: new directory path (e.g., "archive/docs") Mutually exclusive with destination_folder. +- **destination_folder** (string | null, optional, default: None) — Move the note into this folder, preserving the original filename. Mutually exclusive with destination_path. Only for single-file moves. +- **is_directory** (boolean, optional, default: False) — If True, moves an entire directory and all its contents. When True, identifier and destination_path should be directory paths (without file extensions). Defaults to False. +- **project** (string | null, optional, default: None) — Project name to move within. 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" returns existing markdown guidance/success text. "json" returns machine-readable move metadata. + ## DESCRIPTION Relocates a note (or, with `is_directory=True`, a whole directory tree) and diff --git a/src/basic_memory/man/man3/read-content(3).md b/src/basic_memory/man/man3/read-content(3).md index 902a693cc..b4ef41fa2 100644 --- a/src/basic_memory/man/man3/read-content(3).md +++ b/src/basic_memory/man/man3/read-content(3).md @@ -21,6 +21,12 @@ verified: 0.21.6 mcp read_content(path, project=None, project_id=None) ``` +## PARAMETERS + +- **path** (string, required) — The path or permalink to the file. Can be: - A regular file path (docs/example.md) - A memory URL (memory://docs/example) - A permalink (docs/example) +- **project** (string | null, optional, default: None) — Project name to read 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(). + ## DESCRIPTION Returns a file's content with no identifier cascade, no miss suggestions, diff --git a/src/basic_memory/man/man3/read-note(3).md b/src/basic_memory/man/man3/read-note(3).md index 4bbb1efd4..13b7f9ae2 100644 --- a/src/basic_memory/man/man3/read-note(3).md +++ b/src/basic_memory/man/man3/read-note(3).md @@ -50,18 +50,13 @@ Accepted identifier forms (all verified): ## PARAMETERS -- **identifier** — title, permalink, or memory:// URL (CLI: positional - argument, not a flag) -- **project** / **project_id** — target project; same semantics as - [[write-note(3)]] -- **page**, **page_size** — apply only to the fallback suggestion listing. - They never paginate the note itself: a direct or exact-title match always - returns the full note. Aliases accepted: `page_number`, `limit`, `per_page` -- **output_format** — `text` (raw markdown) or `json` (structured object - with title/permalink/file_path/content/frontmatter) -- **include_frontmatter** — json mode only: when true, `content` includes the - opening YAML block; the parsed `frontmatter` object is returned either way. - CLI flag: `--frontmatter` (`--include-frontmatter` is a deprecated alias) +- **identifier** (string, required) — The title or permalink of the note to read. Can be a full memory:// URL, a permalink, a title, or search text. From the CLI this is a positional argument, not a flag. +- **project** (string | null, optional, default: None) — Project name to read from. Optional - server will resolve using the hierarchy above. 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(). +- **page** (integer, optional, default: 1) — Page of fallback-search results to use when the identifier does not resolve to a note directly (default: 1). A direct or exact-title match always returns the full note content — page/page_size never chunk the note itself, and the title-match lookup pages through fixed-size pages of title results until an exact match is found or results are exhausted, regardless of page or page_size. Aliases: page_number. +- **page_size** (integer, optional, default: 10) — Number of fallback-search results per page (default: 10). When no match is found, this caps how many related-note suggestions are listed. Aliases: limit, per_page. +- **output_format** (string, optional, default: "text") — "text" returns markdown content or guidance text. "json" returns a structured object with title/permalink/file_path/content/frontmatter. +- **include_frontmatter** (boolean, optional, default: False) — When output_format="json", whether content should include the opening YAML frontmatter block; the parsed frontmatter object is returned either way. The CLI flag is --frontmatter (--include-frontmatter is a deprecated alias). ## MCP USAGE diff --git a/src/basic_memory/man/man3/recent-activity(3).md b/src/basic_memory/man/man3/recent-activity(3).md index c7df55ffb..9d762ae05 100644 --- a/src/basic_memory/man/man3/recent-activity(3).md +++ b/src/basic_memory/man/man3/recent-activity(3).md @@ -48,15 +48,14 @@ that project's activity — not a cross-project view. ## PARAMETERS -- **type** — filter by item type: `entity` (default), `observation`, - `relation`, or a list combining them; case-insensitive -- **depth** — relation hops to include around recent items (1–3 recommended) -- **timeframe** — how far back to look (aliases: `since`, `time_range`, - `lookback`) -- **project** / **project_id** — target project; omitted, the active or - default project is used, and discovery mode only when neither resolves -- **output_format** — `text` (human summary grouped by kind) or `json` - (flat item list) +- **type** (string | array, optional, default: "") — Filter by content type(s). Can be a string or list of strings. Valid options: - "entity" or ["entity"] for knowledge entities - "relation" or ["relation"] for connections between entities - "observation" or ["observation"] for notes and observations Multiple types can be combined: ["entity", "relation"] Case-insensitive: "ENTITY" and "entity" are treated the same. Default is entity-only. Specify other types explicitly to include observations and relations. +- **depth** (integer, optional, default: 1) — How many relation hops to traverse (1-3 recommended) +- **timeframe** (string, optional, default: "7d") — Time window to search. Supports natural language: - Relative: "2 days ago", "last week", "yesterday" - Points in time: "2024-01-01", "January 1st" - Standard format: "7d", "24h" Aliases: since, time_range, lookback. +- **page** (integer, optional, default: 1) — Page number for pagination (default 1) +- **page_size** (integer, optional, default: 10) — Number of items per page (default 10) +- **project** (string | null, optional, default: None) — Project name to query. Optional - server will resolve using the hierarchy above: omitted, the active or default project is used, and discovery mode across all projects applies only when neither resolves. 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" returns human-readable summary text. "json" returns a flat list of recent items. ## MCP USAGE diff --git a/src/basic_memory/man/man3/schema-diff(3).md b/src/basic_memory/man/man3/schema-diff(3).md index 59f6bdf7f..fed622df4 100644 --- a/src/basic_memory/man/man3/schema-diff(3).md +++ b/src/basic_memory/man/man3/schema-diff(3).md @@ -21,6 +21,13 @@ verified: 0.21.6 mcp schema_diff(note_type, project=None, project_id=None, output_format="text") ``` +## PARAMETERS + +- **note_type** (string, required) — The note type to check for drift (e.g., "person"). +- **project** (string | null, optional, default: None) — Project name. Optional -- server will resolve. +- **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") + ## DESCRIPTION Compares the declared schema for a type against how notes of that type are diff --git a/src/basic_memory/man/man3/schema-infer(3).md b/src/basic_memory/man/man3/schema-infer(3).md index aa229476f..875d53cc0 100644 --- a/src/basic_memory/man/man3/schema-infer(3).md +++ b/src/basic_memory/man/man3/schema-infer(3).md @@ -22,6 +22,14 @@ schema_infer(note_type, threshold=0.25, project=None, project_id=None, output_format="text") ``` +## PARAMETERS + +- **note_type** (string, required) — The note type to analyze (e.g., "person", "meeting"). +- **threshold** (number, optional, default: 0.25) — Minimum frequency (0-1) for a field to be suggested as optional. Default 0.25 (25%). Fields above 95% become required. +- **project** (string | null, optional, default: None) — Project name. Optional -- server will resolve. +- **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") + ## DESCRIPTION Analyzes every note of a type and proposes a schema from observed usage: diff --git a/src/basic_memory/man/man3/schema-validate(3).md b/src/basic_memory/man/man3/schema-validate(3).md index 6e7224ea7..0137025d0 100644 --- a/src/basic_memory/man/man3/schema-validate(3).md +++ b/src/basic_memory/man/man3/schema-validate(3).md @@ -31,6 +31,14 @@ bm tool schema-validate [TARGET] [--project NAME] # TARGET: a note type ("manpage"), a note path, or omitted for everything ``` +## PARAMETERS + +- **note_type** (string | null, optional, default: None) — Note type to batch-validate (e.g., "person", "meeting"). If provided, validates all notes of this type. +- **identifier** (string | null, optional, default: None) — Specific note to validate (permalink, title, or path). If provided, validates only this note. +- **project** (string | null, optional, default: None) — Project name. Optional -- server will resolve. +- **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") + ## DESCRIPTION Checks notes against the schema resolved for their type (see diff --git a/src/basic_memory/man/man3/search-notes(3).md b/src/basic_memory/man/man3/search-notes(3).md index 337af10e4..5aa8bbf1d 100644 --- a/src/basic_memory/man/man3/search-notes(3).md +++ b/src/basic_memory/man/man3/search-notes(3).md @@ -74,29 +74,25 @@ matched. ## PARAMETERS -- **query** — search string; optional. Omit it for filter-only searches -- **search_type** — see modes above; default is dynamic (`hybrid` if semantic - search is enabled, else `text`) -- **metadata_filters** — dict of frontmatter field → value; integer values - match integer YAML fields (`{"section": 3}` works). A `None` value is an - is-null match — notes where the key is absent or explicitly null. `None` - inside `$in`, `$between`, a contains list, or a comparison is refused: those - compare against the value, and a comparison with null is never true -- **tags** — list or comma string, same convention as [[write-note(3)]] -- **min_similarity** — float override for vector/hybrid threshold; `0.0` - shows everything, `0.8` is high precision -- **valid_at** — date (`2026-07-28`) or RFC 3339 instant - (`2026-07-28T09:00:00Z`) that the authored range must contain; a timestamp - written without an offset is read as UTC (aliases: `as_of`, `valid_on`) -- **valid_overlaps** — range literal the authored range must overlap: - `[2026-06-10,2026-07-27)`, `(,2026-07-27]`, `[2026-06-10,)`. Mutually - exclusive with `valid_at` (aliases: `overlaps`, `valid_during`) -- **time_kind** — kind of valid time: `effective`, `valid`, `occurred`, `due`, - or `mentioned`; usable on its own (alias: `kind`) -- **search_all_projects** — opt-in cross-project search; ignored when - `project`/`project_id` is given -- **page**, **page_size** — pagination (aliases: `page_number`, `limit`, - `per_page`) +- **query** (string | null, optional, default: None) — Optional search query string (supports boolean operators, phrases, patterns). Omit or pass None for filter-only searches using metadata_filters, tags, or status. +- **project** (string | null, optional, default: None) — Project name to search in. 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(). +- **search_all_projects** (boolean, optional, default: False) — Optional opt-in to search every accessible project. Ignored when `project` or `project_id` is supplied. +- **page** (integer, optional, default: 1) — The page number of results to return (default 1). Aliases: page_number. +- **page_size** (integer, optional, default: 10) — The number of results to return per page (default 10). Aliases: limit, per_page. +- **search_type** (string | null, optional, default: None) — Type of search to perform, one of: "text", "title", "permalink", "vector", "semantic", "hybrid". Default is dynamic: "hybrid" when semantic search is enabled, otherwise "text". +- **output_format** (string, optional, default: "text") — "text" preserves existing structured search response behavior. "json" returns a machine-readable dictionary payload. +- **note_types** (array | null, optional, default: None) — Optional list of note types to search (e.g., ["note", "person"]) +- **entity_types** (array | null, optional, default: None) — Optional list of entity types to filter by (e.g., ["entity", "observation"]) +- **categories** (array | null, optional, default: None) — Optional list of observation categories for exact matching (e.g., ["requirement"]). Pair with entity_types=["observation"] to return only observations whose category matches exactly. +- **after_date** (string | null, optional, default: None) — Optional date filter for recent content (e.g., "1 week", "2d", "2024-01-01") +- **metadata_filters** (object | null, optional, default: None) — Optional structured frontmatter filters (e.g., {"status": "in-progress"}). Integer values match integer YAML fields ({"section": 3} works). A None value is an is-null match: notes where the key is absent or explicitly null. None inside $in/$between/a contains list/a comparison is refused — those compare against the value, and a comparison with null is never true. +- **tags** (array | null, optional, default: None) — Optional tag filter (frontmatter tags); shorthand for metadata_filters["tags"]. Accepts a list (["a", "b"]) or a comma-separated string ("a,b"), matching the write_note tags convention and the tag: query shorthand. +- **status** (string | null, optional, default: None) — Optional status filter (frontmatter status); shorthand for metadata_filters["status"] +- **min_similarity** (number | null, optional, default: None) — Optional float to override the global semantic_min_similarity threshold for this query. E.g., 0.0 to see all vector results, or 0.8 for high precision. Only applies to vector and hybrid search types. +- **valid_at** (string | null, optional, default: None) — Optional date ("2026-07-28") or RFC 3339 instant ("2026-07-28T09:00:00Z"; a timestamp with no offset is read as UTC). Returns sources whose authored valid range contains it. Sources with no temporal qualifier are excluded. Aliases: as_of, valid_on. +- **valid_overlaps** (string | null, optional, default: None) — Optional PostgreSQL-style range literal ("[2026-06-10,2026-07-27)", "(,2026-07-27]", "[2026-06-10,)"). Returns sources whose authored valid range overlaps it. Mutually exclusive with valid_at; also excludes undated sources. Aliases: overlaps, valid_during. +- **time_kind** (string | null, optional, default: None) — Optional kind of valid time to narrow to: "effective", "valid", "occurred", "due", or "mentioned". Valid on its own. Alias: kind. ## MCP USAGE diff --git a/src/basic_memory/man/man3/view-note(3).md b/src/basic_memory/man/man3/view-note(3).md index 5e160792f..ba27991d2 100644 --- a/src/basic_memory/man/man3/view-note(3).md +++ b/src/basic_memory/man/man3/view-note(3).md @@ -21,6 +21,12 @@ verified: 0.21.6 mcp view_note(identifier, project=None, project_id=None) ``` +## PARAMETERS + +- **identifier** (string, required) — The title or permalink of the note to view +- **project** (string | null, optional, default: None) — Project name to read 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(). + ## DESCRIPTION A thin presentational wrapper over [[read-note(3)]]: returns the note's full diff --git a/src/basic_memory/man/man3/write-note(3).md b/src/basic_memory/man/man3/write-note(3).md index 540154d33..5bd9b77b0 100644 --- a/src/basic_memory/man/man3/write-note(3).md +++ b/src/basic_memory/man/man3/write-note(3).md @@ -47,34 +47,17 @@ prepends, or edits sections in place without rewriting the file. ## PARAMETERS -- **title** — note title; written to frontmatter and drives the permalink. - No H1 is added for you: `content` is saved as given, so include - `# Title` yourself if the note should open with a heading -- **content** — markdown body; may include observations, relations, and its own - frontmatter (a `type:` in content frontmatter takes precedence over the - `note_type` parameter) -- **directory** — folder path relative to project root; `/` or empty writes to - root. MCP accepts the aliases `folder`, `dir`, and `path`; the CLI flag is - `--folder` -- **project** / **project_id** — target project by name or UUID; `project_id` - wins and is unambiguous across workspaces. Omitting both writes to the - session's active project — the last one this session touched — and only - falls back to the configured default when there is none, so after working - in another project pass `project` explicitly. Qualified names - (`workspace/project`) route across workspaces -- **workspace** — cloud workspace slug, name, or tenant_id; with `project`, - routes as `workspace/project`. Cannot be combined with `project_id` -- **tags** — list or comma-separated string; external MCP clients should pass - the string form (`"a,b,c"`) -- **note_type** (CLI: `--type`) — frontmatter `type:`, default `note`; this is - what schema validation keys on (see [[bm-schema(5)]]) -- **metadata** — dict merged into frontmatter; the reliable way to write - nested YAML (schema notes, custom fields). Not available from the CLI -- **overwrite** — `True` replaces on conflict; `False` errors; unset consults - the `write_note_overwrite_default` config setting -- **output_format** — `text` (markdown summary) or `json` (machine-readable; - conflicts come back as `action: "conflict"` with an `error` code instead of - raising) +- **title** (string, required) — The title of the note; written to frontmatter and drives the permalink. No H1 is added for you: content is saved as given, so include a "# Title" heading yourself if the note should open with one. +- **content** (string, required) — Markdown content for the note, can include observations and relations. May carry its own frontmatter; a `type:` in content frontmatter takes precedence over the note_type parameter. +- **directory** (string, required) — Directory path relative to project root where the file should be saved. Use forward slashes (/) as separators. Use "/" or "" to write to project root. Examples: "notes", "projects/2025", "research/ml", "/" (root). MCP accepts the aliases folder, dir, and path; the CLI flag is --folder. +- **project** (string | null, optional, default: None) — Project name to write to. Optional - server will resolve using the hierarchy above. Omitting both project and project_id writes to the session's active project (the last one this session touched), and only falls back to the configured default project when there is none — so after working in another project, pass project explicitly. Use "workspace/project" to route to a project in a specific cloud workspace. A bare name that exists in multiple workspaces resolves to the default workspace, so use the qualified form (or project_id) to disambiguate. If unknown, use list_memory_projects() to discover available projects and their qualified names. +- **workspace** (string | null, optional, default: None) — Workspace slug, name, or tenant_id. When provided with `project`, routes as `workspace/project`. Cannot be combined with `project_id`. +- **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(). +- **tags** (array | string | null, optional, default: None) — Tags to categorize the note. Can be a list of strings, a comma-separated string, or None. Note: If passing from external MCP clients, use a string format (e.g. "tag1,tag2,tag3") +- **note_type** (string, optional, default: "note") — Type of note to create (stored in frontmatter `type:`). Defaults to "note". Can be "guide", "report", "config", "person", etc. The CLI flag is --type. A `type:` in content frontmatter takes precedence over this parameter, and this is what schema validation keys on. +- **metadata** (object | null, optional, default: None) — Optional dict of extra frontmatter fields merged into entity_metadata. Useful for schema notes or any note that needs custom YAML frontmatter beyond title/type/tags. Nested dicts are supported. Not available from the CLI. +- **overwrite** (boolean | null, optional, default: None) — If True, replace existing note on conflict. If False, error on conflict. If None (default), consult write_note_overwrite_default config setting. +- **output_format** (string, optional, default: "text") — "text" returns the existing markdown summary. "json" returns machine-readable metadata; on conflict it returns action: "conflict" with an error code instead of raising. ## MCP USAGE diff --git a/src/basic_memory/mcp/tools/build_context.py b/src/basic_memory/mcp/tools/build_context.py index 544dafbf1..576fc3315 100644 --- a/src/basic_memory/mcp/tools/build_context.py +++ b/src/basic_memory/mcp/tools/build_context.py @@ -205,7 +205,8 @@ async def build_context( project_id: 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(). - url: memory:// URI pointing to discussion content (e.g. memory://specs/search) + url: memory:// URI pointing to discussion content (e.g. memory://specs/search), + or a bare permalink path. depth: How many relation hops to traverse (1-3 recommended for performance) timeframe: How far back to look. Supports natural language like "2 days ago", "last week" page: Page number of results to return (default: 1) diff --git a/src/basic_memory/mcp/tools/edit_note.py b/src/basic_memory/mcp/tools/edit_note.py index 0b0dc7aa9..723e52831 100644 --- a/src/basic_memory/mcp/tools/edit_note.py +++ b/src/basic_memory/mcp/tools/edit_note.py @@ -414,6 +414,7 @@ async def edit_note( identifier: The exact title, permalink, or memory:// URL of the note to edit. Must be an exact match - fuzzy matching is not supported for edit operations. Use search_notes() or read_note() first to find the correct identifier if uncertain. + From the CLI this is a positional argument, not a flag. operation: The editing operation to perform: - "append": Add content to the end of the note (creates the note if it doesn't exist) - "prepend": Add content to the beginning of the note (creates the note if it doesn't exist) diff --git a/src/basic_memory/mcp/tools/read_note.py b/src/basic_memory/mcp/tools/read_note.py index d24da403a..5db3f0569 100644 --- a/src/basic_memory/mcp/tools/read_note.py +++ b/src/basic_memory/mcp/tools/read_note.py @@ -113,20 +113,23 @@ async def read_note( project_id: 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(). - identifier: The title or permalink of the note to read - Can be a full memory:// URL, a permalink, a title, or search text + identifier: The title or permalink of the note to read. + Can be a full memory:// URL, a permalink, a title, or search text. + From the CLI this is a positional argument, not a flag. page: Page of fallback-search results to use when the identifier does not resolve to a note directly (default: 1). A direct or exact-title match always returns the full note content — page/page_size never chunk the note itself, and the title-match lookup pages through fixed-size pages of title results until an exact match is found or results are - exhausted, regardless of page or page_size. + exhausted, regardless of page or page_size. Aliases: page_number. page_size: Number of fallback-search results per page (default: 10). When no match is found, this caps how many related-note suggestions are listed. + Aliases: limit, per_page. output_format: "text" returns markdown content or guidance text. "json" returns a structured object with title/permalink/file_path/content/frontmatter. include_frontmatter: When output_format="json", whether content should include the - opening YAML frontmatter block. + opening YAML frontmatter block; the parsed frontmatter object is returned either + way. The CLI flag is --frontmatter (--include-frontmatter is a deprecated alias). context: Optional FastMCP context for performance caching. Returns: diff --git a/src/basic_memory/mcp/tools/recent_activity.py b/src/basic_memory/mcp/tools/recent_activity.py index b83656b24..8a42acdeb 100644 --- a/src/basic_memory/mcp/tools/recent_activity.py +++ b/src/basic_memory/mcp/tools/recent_activity.py @@ -108,9 +108,11 @@ async def recent_activity( - Relative: "2 days ago", "last week", "yesterday" - Points in time: "2024-01-01", "January 1st" - Standard format: "7d", "24h" + Aliases: since, time_range, lookback. project: Project name to query. Optional - server will resolve using the - hierarchy above. If unknown, use list_memory_projects() to discover - available projects. + hierarchy above: omitted, the active or default project is used, and + discovery mode across all projects applies only when neither resolves. + If unknown, use list_memory_projects() to discover available projects. project_id: 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(). diff --git a/src/basic_memory/mcp/tools/search.py b/src/basic_memory/mcp/tools/search.py index b321a0f68..c41b35635 100644 --- a/src/basic_memory/mcp/tools/search.py +++ b/src/basic_memory/mcp/tools/search.py @@ -1025,8 +1025,10 @@ async def search_notes( workspaces. Takes precedence over `project`. Get from list_memory_projects(). search_all_projects: Optional opt-in to search every accessible project. Ignored when `project` or `project_id` is supplied. - page: The page number of results to return (default 1) - page_size: The number of results to return per page (default 10) + page: The page number of results to return (default 1). + Aliases: page_number. + page_size: The number of results to return per page (default 10). + Aliases: limit, per_page. search_type: Type of search to perform, one of: "text", "title", "permalink", "vector", "semantic", "hybrid". Default is dynamic: "hybrid" when semantic search is enabled, otherwise "text". @@ -1039,6 +1041,7 @@ async def search_notes( observations whose category matches exactly. after_date: Optional date filter for recent content (e.g., "1 week", "2d", "2024-01-01") metadata_filters: Optional structured frontmatter filters (e.g., {"status": "in-progress"}). + Integer values match integer YAML fields ({"section": 3} works). A None value is an is-null match: notes where the key is absent or explicitly null. None inside $in/$between/a contains list/a comparison is refused — those compare against the value, and a comparison with null is never true. @@ -1052,11 +1055,13 @@ async def search_notes( valid_at: Optional date ("2026-07-28") or RFC 3339 instant ("2026-07-28T09:00:00Z"; a timestamp with no offset is read as UTC). Returns sources whose authored valid range contains it. Sources with no temporal qualifier are excluded. + Aliases: as_of, valid_on. valid_overlaps: Optional PostgreSQL-style range literal ("[2026-06-10,2026-07-27)", "(,2026-07-27]", "[2026-06-10,)"). Returns sources whose authored valid range overlaps it. Mutually exclusive with valid_at; also excludes undated sources. + Aliases: overlaps, valid_during. time_kind: Optional kind of valid time to narrow to: "effective", "valid", - "occurred", "due", or "mentioned". Valid on its own. + "occurred", "due", or "mentioned". Valid on its own. Alias: kind. context: Optional FastMCP context for performance caching. Returns: diff --git a/src/basic_memory/mcp/tools/write_note.py b/src/basic_memory/mcp/tools/write_note.py index 181855799..70cfca64f 100644 --- a/src/basic_memory/mcp/tools/write_note.py +++ b/src/basic_memory/mcp/tools/write_note.py @@ -262,18 +262,26 @@ async def write_note( `- This feature extends [[Base Design]] and uses [[Core Utils]]` Args: - title: The title of the note - content: Markdown content for the note, can include observations and relations + title: The title of the note; written to frontmatter and drives the permalink. + No H1 is added for you: content is saved as given, so include a + "# Title" heading yourself if the note should open with one. + content: Markdown content for the note, can include observations and relations. + May carry its own frontmatter; a `type:` in content frontmatter takes + precedence over the note_type parameter. directory: Directory path relative to project root where the file should be saved. Use forward slashes (/) as separators. Use "/" or "" to write to project root. - Examples: "notes", "projects/2025", "research/ml", "/" (root) + Examples: "notes", "projects/2025", "research/ml", "/" (root). + MCP accepts the aliases folder, dir, and path; the CLI flag is --folder. project: Project name to write to. Optional - server will resolve using the - hierarchy above. Use "workspace/project" to route to a project in a - specific cloud workspace. A bare name that exists in multiple - workspaces resolves to the default workspace, so use the qualified - form (or project_id) to disambiguate. If unknown, use - list_memory_projects() to discover available projects and their - qualified names. + hierarchy above. Omitting both project and project_id writes to the + session's active project (the last one this session touched), and only + falls back to the configured default project when there is none — so + after working in another project, pass project explicitly. Use + "workspace/project" to route to a project in a specific cloud workspace. + A bare name that exists in multiple workspaces resolves to the default + workspace, so use the qualified form (or project_id) to disambiguate. If + unknown, use list_memory_projects() to discover available projects and + their qualified names. workspace: Workspace slug, name, or tenant_id. When provided with `project`, routes as `workspace/project`. Cannot be combined with `project_id`. project_id: Project external_id (UUID). Prefer this over `project` when known — @@ -281,15 +289,18 @@ async def write_note( workspaces. Takes precedence over `project`. Get from list_memory_projects(). tags: Tags to categorize the note. Can be a list of strings, a comma-separated string, or None. Note: If passing from external MCP clients, use a string format (e.g. "tag1,tag2,tag3") - note_type: Type of note to create (stored in frontmatter). Defaults to "note". - Can be "guide", "report", "config", "person", etc. + note_type: Type of note to create (stored in frontmatter `type:`). Defaults to "note". + Can be "guide", "report", "config", "person", etc. The CLI flag is --type. + A `type:` in content frontmatter takes precedence over this parameter, and + this is what schema validation keys on. metadata: Optional dict of extra frontmatter fields merged into entity_metadata. Useful for schema notes or any note that needs custom YAML frontmatter - beyond title/type/tags. Nested dicts are supported. + beyond title/type/tags. Nested dicts are supported. Not available from the CLI. overwrite: If True, replace existing note on conflict. If False, error on conflict. If None (default), consult write_note_overwrite_default config setting. output_format: "text" returns the existing markdown summary. "json" returns - machine-readable metadata. + machine-readable metadata; on conflict it returns action: "conflict" + with an error code instead of raising. context: Optional FastMCP context for performance caching. Returns: diff --git a/tests/test_man_pages.py b/tests/test_man_pages.py index b4cd359c5..d469076b2 100644 --- a/tests/test_man_pages.py +++ b/tests/test_man_pages.py @@ -2,7 +2,9 @@ from __future__ import annotations +import importlib.util import re +from pathlib import Path import pytest @@ -12,15 +14,39 @@ bundled_pages, declare_registry_ownership, extract_mcp_synopsis, + extract_parameters, find_page, parse_page_ref, + remove_parameters, render_index, + render_parameters, render_synopsis, replace_mcp_synopsis, + replace_parameters, ) from basic_memory.mcp.server import mcp from basic_memory.mcp.tools import __all__ as registered_tools +# scripts/ is not a package (no __init__.py) and CI runs pytest with +# --import-mode=importlib, so `from scripts...` fails collection. Load the file +# directly, matching the convention in tests/test_update_versions.py. +MODULE_PATH = Path(__file__).resolve().parents[1] / "scripts" / "update_man_pages.py" +SPEC = importlib.util.spec_from_file_location("update_man_pages", MODULE_PATH) +assert SPEC is not None +assert SPEC.loader is not None +update_man_pages = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(update_man_pages) +regenerate_page = update_man_pages.regenerate_page + + +def _has_parameters_block(page_text: str) -> bool: + """True when the page carries a ## PARAMETERS section.""" + try: + extract_parameters(page_text) + except ValueError: + return False + return True + @pytest.mark.parametrize( ("text", "expected"), @@ -42,6 +68,11 @@ def test_parse_page_ref_accepts_every_common_spelling(text: str, expected: PageR assert parse_page_ref(text) == expected +def test_page_ref_display_shows_section_when_present() -> None: + assert PageRef("search-notes", 3).display == "search-notes(3)" + assert PageRef("search-notes", None).display == "search-notes" + + @pytest.mark.parametrize("text", ["", "/", "docs/search-notes"]) def test_parse_page_ref_rejects_what_cannot_name_a_page(text: str) -> None: with pytest.raises(ValueError, match="not a manual page reference"): @@ -165,6 +196,159 @@ async def test_section_3_synopsis_is_exactly_the_registry_rendering() -> None: ) +def test_render_parameters_formats_required_first_and_shows_types() -> None: + parameters = { + "required": ["query"], + "properties": { + "alpha": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "a union"}, + "query": {"type": "string", "description": "what to search for"}, + "page": {"type": "integer", "default": 1}, + "tags": {"type": "array"}, + }, + } + assert render_parameters("demo", parameters) == ( + "- **query** (string, required) — what to search for\n" + "- **alpha** (string | null, optional) — a union\n" + "- **page** (integer, optional, default: 1)\n" + "- **tags** (array, optional)" + ) + assert render_parameters("bare", {"properties": {}}) == "" + # A property with no type info renders without a type name. + typeless = {"required": ["x"], "properties": {"x": {}}} + assert render_parameters("demo", typeless) == "- **x** (required)" + # A list `type` field joins its members, and a multi-line description collapses + # to a single bullet line (no raw docstring indentation reaches the page). + list_type = { + "properties": { + "kind": {"type": ["string", "null"], "description": "one of\n a\n b"}, + } + } + assert ( + render_parameters("demo", list_type) == "- **kind** (string | null, optional) — one of a b" + ) + # A $ref enum + null (how Pydantic emits an optional Enum) resolves the $ref to + # the enum's underlying JSON type, so it reads `string | null`, not a bare `null`. + ref_enum = { + "$defs": {"SortOrder": {"enum": ["asc", "desc"], "type": "string"}}, + "properties": { + "sort": { + "anyOf": [{"$ref": "#/$defs/SortOrder"}, {"type": "null"}], + "default": None, + "description": "ordering", + } + }, + } + assert ( + render_parameters("demo", ref_enum) + == "- **sort** (string | null, optional, default: None) — ordering" + ) + # A $ref that resolves to nothing (no $defs) contributes no type name; the null + # member still renders, so the union degrades to `null` rather than crashing. + unresolved_ref = { + "properties": {"sort": {"anyOf": [{"$ref": "#/$defs/Missing"}, {"type": "null"}]}} + } + assert render_parameters("demo", unresolved_ref) == "- **sort** (null, optional)" + + +def test_replace_parameters_touches_only_the_parameters_block() -> None: + with_block = ( + "# t\n\n## SYNOPSIS\n\n```\nt()\n```\n\n" + "## PARAMETERS\n\n- **a** (string, required)\n\n" + "## DESCRIPTION\n\nprose\n" + ) + replaced = replace_parameters(with_block, "- **b** (integer, optional)") + assert extract_parameters(replaced) == "- **b** (integer, optional)" + assert "```\nt()\n```" in replaced # SYNOPSIS untouched + assert "## DESCRIPTION\n\nprose\n" in replaced # DESCRIPTION untouched + + # A page with no PARAMETERS: the section is inserted before DESCRIPTION. + without = "# t\n\n## SYNOPSIS\n\n```\nt()\n```\n\n## DESCRIPTION\n\nprose\n" + inserted = replace_parameters(without, "- **c** (string, required)") + assert extract_parameters(inserted) == "- **c** (string, required)" + assert "## PARAMETERS\n\n- **c** (string, required)\n\n## DESCRIPTION" in inserted + + # No DESCRIPTION: the section lands after the SYNOPSIS block. + no_desc = "# t\n\n## SYNOPSIS\n\n```\nt()\n```\n\n## EXAMPLES\n\nx\n" + after = replace_parameters(no_desc, "- **d** (string, required)") + assert extract_parameters(after) == "- **d** (string, required)" + assert "```\n\n## PARAMETERS\n\n- **d** (string, required)\n\n## EXAMPLES" in after + + with pytest.raises(ValueError, match="nowhere to place PARAMETERS"): + replace_parameters("# t\n\nno anchors here\n", "- **e** (required)") + with pytest.raises(ValueError, match="no PARAMETERS block"): + extract_parameters("# t\n\n## DESCRIPTION\n") + + +def test_remove_parameters_strips_the_whole_section() -> None: + # A tool that loses its last parameter must lose its section too: the whole + # block comes out, leaving one blank line between the surrounding sections and + # every other section byte-identical. + with_block = ( + "# t\n\n## SYNOPSIS\n\n```\nt()\n```\n\n" + "## PARAMETERS\n\n- **a** (string, required)\n\n" + "## DESCRIPTION\n\nprose\n" + ) + stripped = remove_parameters(with_block) + assert stripped == "# t\n\n## SYNOPSIS\n\n```\nt()\n```\n\n## DESCRIPTION\n\nprose\n" + assert "## PARAMETERS" not in stripped + assert "\n\n\n" not in stripped # exactly one blank line between headings + + # A page with no PARAMETERS block is returned unchanged. + without = "# t\n\n## SYNOPSIS\n\n```\nt()\n```\n\n## DESCRIPTION\n\nprose\n" + assert remove_parameters(without) == without + + # A block at end of file (no following heading) is removed cleanly, leaving a + # single trailing newline and no dangling blank line. + at_end = "# t\n\n## SYNOPSIS\n\n```\nt()\n```\n\n## PARAMETERS\n\n- **a** (string, required)\n" + assert remove_parameters(at_end) == "# t\n\n## SYNOPSIS\n\n```\nt()\n```\n" + + +@pytest.mark.parametrize("empty_schema", [{"properties": {}}, {}]) +def test_regenerate_page_drops_stale_parameters_when_tool_becomes_parameterless( + empty_schema: dict[str, object], +) -> None: + # Transition: a tool that once had parameters now has none. Running the real + # regeneration path over the old page must strip the generated PARAMETERS block + # so the page never keeps advertising the removed argument. + page = ( + "---\ntitle: demo(3)\ngenerated: registry\ntool: demo\n---\n\n" + "# demo(3)\n\n## SYNOPSIS\n\n```\ndemo(old_arg)\n```\n\n" + "## PARAMETERS\n\n- **old_arg** (string, required) — soon to be removed\n\n" + "## DESCRIPTION\n\nprose\n" + ) + regenerated = regenerate_page(page, "demo", empty_schema) + assert "## PARAMETERS" not in regenerated + assert "old_arg" not in regenerated # the stale bullet text is gone entirely + assert extract_mcp_synopsis(regenerated) == "demo()" # SYNOPSIS still rewritten + assert "## DESCRIPTION\n\nprose\n" in regenerated # curated section untouched + + +@pytest.mark.asyncio +async def test_section_3_parameters_is_exactly_the_registry_rendering() -> None: + # PARAMETERS is registry-owned wherever a tool has parameters: byte-equal to the + # rendering of the schema clients receive. A tool change without regenerating the + # pages fails here, pointing at the fix. Tools with no parameters get no section. + 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 = tools[page.tool].parameters + page_text = page.read() + if schema.get("properties"): + expected = render_parameters(page.tool, schema) + assert extract_parameters(page_text) == expected, ( + f"{page.title} PARAMETERS is stale; run `just man-regen` and commit the result" + ) + else: + # A parameterless tool (basic_memory_diagnostics) owns no PARAMETERS + # section; a leftover block would keep advertising removed arguments. + assert not _has_parameters_block(page_text), ( + f"{page.title} still carries a PARAMETERS block but {page.tool} has no " + "parameters; 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.