From f28f6ea23ea0d7824e5ac7e3b2496cb69e30d804 Mon Sep 17 00:00:00 2001 From: phernandez Date: Sun, 30 Aug 2026 12:50:26 -0500 Subject: [PATCH 1/9] feat(mcp): ship the manual in the package and serve it as resources Second slice of #610, grown out of the #952 manual: - src/basic_memory/man/man3/: the 23 section-3 pages (one per MCP tool) pulled from the `manual` project and made canonical in the package, as portable notes -- frontmatter intact minus the cloud-assigned permalink. Every install now ships the same pages: local, cloud, offline. - basic_memory.man: the page model, a lenient page-reference parser, and man(1)-style resolution. Parse, don't validate: search-notes(3), search-notes.3, 3/search-notes, man3/search-notes, search_notes and percent-encoded forms all name the same page; the section is optional and the lowest wins, as in man. - MCP resources: memory://man is the index (apropos) and memory://man/{ref*} answers any spelling of a page. Every page is also registered as a concrete resource so clients that browse resources/list see each one with its summary. Unknown pages raise a ResourceError that points at the index. Server instructions send agents to a tool's page before first use. - bm man prints a page as Markdown (any spelling; a first argument that is not a subcommand is a topic, like man), bm man list is apropos, bm man install is unchanged. - Tests pin the section-3 corpus against the tool registry: the one tool without a page (basic_memory_diagnostics) and the three pages without a local tool (canvas, cloud_info, release_notes) are named explicitly, so a new tool or a retired page shows up as a test change. Supersedes the flat generated reference from #1380/#1385 (#404); the registry generator for SYNOPSIS/PARAMETERS is the next slice. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014pmKq6bqCi6Zp6BTHuZjrp Signed-off-by: phernandez --- CHANGELOG.md | 11 ++ docs/manual-pages.md | 34 +++- src/basic_memory/cli/commands/man.py | 56 ++++++- src/basic_memory/man/__init__.py | 150 ++++++++++++++++++ src/basic_memory/man/man3/build-context(3).md | 81 ++++++++++ src/basic_memory/man/man3/canvas(3).md | 58 +++++++ src/basic_memory/man/man3/chatgpt-fetch(3).md | 50 ++++++ .../man/man3/chatgpt-search(3).md | 52 ++++++ src/basic_memory/man/man3/cloud-info(3).md | 47 ++++++ .../man/man3/create-memory-project(3).md | 75 +++++++++ src/basic_memory/man/man3/delete-note(3).md | 63 ++++++++ .../man/man3/delete-project(3).md | 61 +++++++ src/basic_memory/man/man3/edit-note(3).md | 119 ++++++++++++++ .../man/man3/list-directory(3).md | 57 +++++++ .../man/man3/list-memory-projects(3).md | 72 +++++++++ .../man/man3/list-workspaces(3).md | 64 ++++++++ src/basic_memory/man/man3/move-note(3).md | 65 ++++++++ src/basic_memory/man/man3/read-content(3).md | 51 ++++++ src/basic_memory/man/man3/read-note(3).md | 110 +++++++++++++ .../man/man3/recent-activity(3).md | 82 ++++++++++ src/basic_memory/man/man3/release-notes(3).md | 47 ++++++ src/basic_memory/man/man3/schema-diff(3).md | 59 +++++++ src/basic_memory/man/man3/schema-infer(3).md | 59 +++++++ .../man/man3/schema-validate(3).md | 76 +++++++++ src/basic_memory/man/man3/search-notes(3).md | 111 +++++++++++++ src/basic_memory/man/man3/view-note(3).md | 49 ++++++ src/basic_memory/man/man3/write-note(3).md | 125 +++++++++++++++ src/basic_memory/mcp/resources/__init__.py | 3 +- src/basic_memory/mcp/resources/man.py | 64 ++++++++ src/basic_memory/mcp/server.py | 9 +- tests/cli/test_man_command.py | 50 +++++- tests/mcp/test_man_resources.py | 70 ++++++++ tests/test_man_pages.py | 94 +++++++++++ 33 files changed, 2158 insertions(+), 16 deletions(-) create mode 100644 src/basic_memory/man/__init__.py create mode 100644 src/basic_memory/man/man3/build-context(3).md create mode 100644 src/basic_memory/man/man3/canvas(3).md create mode 100644 src/basic_memory/man/man3/chatgpt-fetch(3).md create mode 100644 src/basic_memory/man/man3/chatgpt-search(3).md create mode 100644 src/basic_memory/man/man3/cloud-info(3).md create mode 100644 src/basic_memory/man/man3/create-memory-project(3).md create mode 100644 src/basic_memory/man/man3/delete-note(3).md create mode 100644 src/basic_memory/man/man3/delete-project(3).md create mode 100644 src/basic_memory/man/man3/edit-note(3).md create mode 100644 src/basic_memory/man/man3/list-directory(3).md create mode 100644 src/basic_memory/man/man3/list-memory-projects(3).md create mode 100644 src/basic_memory/man/man3/list-workspaces(3).md create mode 100644 src/basic_memory/man/man3/move-note(3).md create mode 100644 src/basic_memory/man/man3/read-content(3).md create mode 100644 src/basic_memory/man/man3/read-note(3).md create mode 100644 src/basic_memory/man/man3/recent-activity(3).md create mode 100644 src/basic_memory/man/man3/release-notes(3).md create mode 100644 src/basic_memory/man/man3/schema-diff(3).md create mode 100644 src/basic_memory/man/man3/schema-infer(3).md create mode 100644 src/basic_memory/man/man3/schema-validate(3).md create mode 100644 src/basic_memory/man/man3/search-notes(3).md create mode 100644 src/basic_memory/man/man3/view-note(3).md create mode 100644 src/basic_memory/man/man3/write-note(3).md create mode 100644 src/basic_memory/mcp/resources/man.py create mode 100644 tests/mcp/test_man_resources.py create mode 100644 tests/test_man_pages.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0683b4a3c..edc3cddad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ ### Features +- **#610**: The manual ships in the package and is served over MCP. The 23 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 + them as resources: `memory://man` is the index and `memory://man/` is a page, + with every page also listed as a concrete resource so clients that browse + `resources/list` see each one with its summary. Page references parse the way people + and models actually write them -- `search-notes(3)`, `3/search-notes`, + `search_notes`, `man3/search-notes.md` -- and the server instructions point agents at + a tool's page before first use. `bm man ` prints a page as Markdown and + `bm man list` is apropos; `bm man install` is unchanged. + - **#1259**: `write_note` now tells the caller when a freshly created note looks like notes that already exist. On a server with semantic search enabled, a create probes the vector index with the new note's title and opening content and appends a diff --git a/docs/manual-pages.md b/docs/manual-pages.md index f2501ff84..538ad204c 100644 --- a/docs/manual-pages.md +++ b/docs/manual-pages.md @@ -9,9 +9,14 @@ documents the tools; the tools verify the manual. ## Where it lives -The canonical manual is the **`manual` project in the Basic Memory team -workspace** (cloud, shared). Anyone can build their own: the schema ships as -an opt-in seed at `plugins/claude-code/schemas/manpage.md` — copy it into any +Section 3 — one page per MCP tool — is canonical **in the package**, at +`src/basic_memory/man/man3/`, so every install ships the same pages: local, +cloud, or offline. The MCP server serves them as resources (`memory://man` +is the index, `memory://man/search-notes(3)` a page) and `bm man ` +prints one in a shell. The `manual` project in the Basic Memory team +workspace (cloud, shared) holds the full manual — sections 5 and 7 are +canonical there — and anyone can build their own: the schema ships as an +opt-in seed at `plugins/claude-code/schemas/manpage.md` — copy it into any project's folder and start writing pages against it. Layout: @@ -102,7 +107,17 @@ bm tool search-notes --project manual # then filter, or via MCP: # build_context(url="man3/write-note-3", project="manual") ``` -A future `bm man ` command is thin sugar over exactly these calls. +From an MCP client, the same pages are resources — no project required: + +``` +memory://man # the index (apropos) +memory://man/search-notes(3) # one page +memory://man/3/search-notes # any common spelling resolves, +memory://man/search_notes # including the tool name itself +``` + +And in a shell, `bm man search-notes` prints the page as Markdown and +`bm man list` lists every page with its summary. And for the real thing — `man bm` in an actual terminal: @@ -175,9 +190,12 @@ GOTCHAS, SEE ALSO, observations) survives — that ownership split is what 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. -- **`bm man `** — CLI sugar over `read_note` + metadata search. - (`bm man install` + a hand-written `bm.1` already ship — the first slice - of [#610](https://github.com/basicmachines-co/basic-memory/issues/610); - the generator will produce per-command pages from the same extraction.) +- **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 + `memory://man` resources, and the bundled section 3 already ship — the + second slice of [#610](https://github.com/basicmachines-co/basic-memory/issues/610).) +- **Groff for section 3** — render the bundled pages to roff so + `man search-notes` works after `bm man install`, alongside `bm.1`. - **Docs site** — the notes remain canonical for sections 5 and 7, code is canonical for 1 and 3; both render to the hosted docs site. diff --git a/src/basic_memory/cli/commands/man.py b/src/basic_memory/cli/commands/man.py index eb24d6a38..5bb145c58 100644 --- a/src/basic_memory/cli/commands/man.py +++ b/src/basic_memory/cli/commands/man.py @@ -1,24 +1,74 @@ -"""Install the bundled man pages so `man bm` works.""" +"""`bm man`: read the bundled manual, and install the groff pages so `man bm` works.""" import shutil import subprocess +import sys from pathlib import Path -from typing import Annotated, Optional +from typing import Annotated, Optional, override import typer from rich.console import Console +from typer.core import TyperGroup + +# Typer vendors its own click; an override must be typed with the base class's +# types, and these are the ones TyperGroup.resolve_command is declared with. +from typer._click.core import Command, Context from basic_memory.cli.app import app +from basic_memory.man import bundled_pages, find_page, parse_page_ref console = Console() -man_app = typer.Typer(help="Manage the bm man pages.") + +class ManGroup(TyperGroup): + """Let `bm man ` read like man(1). + + A first argument that is not a subcommand is a page name, so `bm man search-notes` + is `bm man show search-notes` without the ceremony. Real subcommands (`install`, + `list`, `show`) and options keep their meaning. + """ + + @override + def resolve_command( + self, ctx: Context, args: list[str] + ) -> tuple[str | None, Command | None, list[str]]: + if args and not args[0].startswith("-") and self.get_command(ctx, args[0]) is None: + args = ["show", *args] + return super().resolve_command(ctx, args) + + +man_app = typer.Typer(help="Read the Basic Memory manual, or install the man pages.", cls=ManGroup) app.add_typer(man_app, name="man") # Bundled groff sources ship inside the package (src/basic_memory/man). _MAN_SOURCE_DIR = Path(__file__).parent.parent.parent / "man" +@man_app.command() +def show( + topic: Annotated[str, typer.Argument(help="Page name, e.g. search-notes or search-notes(3)")], +) -> None: + """Print a manual page as Markdown.""" + try: + page = find_page(parse_page_ref(topic)) + except ValueError as error: + console.print(f"[red]{error}[/red]") + raise typer.Exit(1) from error + if page is None: + console.print(f"[red]No manual entry for {topic}[/red] (try: bm man list)") + raise typer.Exit(1) + # Raw Markdown, unwrapped: agents and pagers read this as often as eyes do. + sys.stdout.write(page.body()) + sys.stdout.write("\n") + + +@man_app.command(name="list") +def list_pages() -> None: + """List every manual page with its one-line summary (apropos).""" + for page in bundled_pages(): + sys.stdout.write(f"{page.title:<28} {page.summary}\n") + + def _default_man_root() -> Path: # Why ~/.local/share/man: manpath(1) derives man directories from PATH # entries on both man-db (Linux) and BSD man (macOS), so ~/.local/bin on diff --git a/src/basic_memory/man/__init__.py b/src/basic_memory/man/__init__.py new file mode 100644 index 000000000..ac50493f3 --- /dev/null +++ b/src/basic_memory/man/__init__.py @@ -0,0 +1,150 @@ +"""The bundled Basic Memory manual. + +Pages are Markdown notes in Unix man-page form, kept in numbered section +directories (``man1/``, ``man3/``, ...). Section 3 — one page per MCP tool — is +canonical here in the package, so every install ships the same pages whether it +is local, cloud, or offline. Three consumers read them: the MCP server serves +them as ``memory://man`` resources, ``bm man `` renders them in a +terminal, and projects can take copies as ordinary notes. + +See ``docs/manual-pages.md`` for the page anatomy and the verification rules. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from functools import cache +from pathlib import Path +from urllib.parse import unquote + +from basic_memory.file_utils import parse_frontmatter, remove_frontmatter + +MAN_DIR = Path(__file__).resolve().parent + + +@dataclass(frozen=True) +class PageRef: + """A reference to a page, as the caller wrote it: a name and maybe a section.""" + + name: str + section: int | None + + @property + def display(self) -> str: + return f"{self.name}({self.section})" if self.section is not None else self.name + + +_SECTION_DIR_RE = re.compile(r"(?:man)?([1-9])") +_NAME_WITH_SECTION_RE = re.compile( + r"(?P.+?)(?:\((?P[1-9])\)|\.(?P[1-9])|-(?P[1-9]))" +) + + +def parse_page_ref(text: str) -> PageRef: + """Read a page reference in any of the forms people and models actually write. + + Parse, don't validate: the reference is whatever the caller reached for first, + so every common spelling of the same page is accepted — + + search-notes(3) search-notes.3 search-notes-3 3/search-notes + man3/search-notes search_notes man3/search-notes(3).md + + — plus percent-encoded variants of the above. The section is optional. Tool + names with underscores map to the hyphenated page name. + + Raises ValueError for a reference that cannot name a page at all (empty, or + a path whose directory is not a section). + """ + ref = unquote(text).strip().strip("/").removesuffix(".md") + section: int | None = None + + if "/" in ref: + directory, _, ref = ref.rpartition("/") + match = _SECTION_DIR_RE.fullmatch(directory) + if match is None: + raise ValueError(f"{text!r} is not a manual page reference") + section = int(match.group(1)) + + match = _NAME_WITH_SECTION_RE.fullmatch(ref) + if match is not None: + ref = match.group("name") + suffix = match.group("paren") or match.group("dot") or match.group("dash") + section = int(suffix) + + name = ref.lower().replace("_", "-") + if not name: + raise ValueError(f"{text!r} is not a manual page reference") + return PageRef(name=name, section=section) + + +@dataclass(frozen=True) +class ManPage: + """One bundled page and the frontmatter fields the manual schema guarantees.""" + + section: int + name: str + summary: str + tool: str | None + path: Path + + @property + def title(self) -> str: + return f"{self.name}({self.section})" + + @property + def uri(self) -> str: + return f"memory://man/{self.title}" + + def read(self) -> str: + """The page as shipped: frontmatter and body.""" + return self.path.read_text(encoding="utf-8") + + def body(self) -> str: + """The page without its frontmatter, for rendering.""" + return remove_frontmatter(self.read()) + + +@cache +def bundled_pages() -> tuple[ManPage, ...]: + """Every page in the package, ordered by section then name.""" + pages: list[ManPage] = [] + for path in sorted(MAN_DIR.glob("man[1-9]/*.md")): + frontmatter = parse_frontmatter(path.read_text(encoding="utf-8")) + tool = frontmatter.get("tool") + pages.append( + ManPage( + section=int(frontmatter["section"]), + name=str(frontmatter["name"]), + summary=str(frontmatter["summary"]), + tool=str(tool) if tool is not None else None, + path=path, + ) + ) + return tuple(sorted(pages, key=lambda page: (page.section, page.name))) + + +def find_page(ref: PageRef) -> ManPage | None: + """Resolve a reference the way man(1) does: the named section, else the lowest.""" + for page in bundled_pages(): + if page.name == ref.name and (ref.section is None or page.section == ref.section): + return page + return None + + +def render_index(pages: tuple[ManPage, ...]) -> str: + """The apropos view: every page, grouped by section, one line each.""" + section_titles = {1: "User commands", 3: "MCP tools", 5: "File formats", 7: "Concepts"} + lines = [ + "# Basic Memory manual", + "", + "Read a page with its `memory://man/...` URI, or `bm man ` in a shell.", + ] + current_section: int | None = None + for page in pages: + if page.section != current_section: + current_section = page.section + heading = section_titles.get(page.section, f"Section {page.section}") + lines.extend(["", f"## Section {page.section} — {heading}", ""]) + lines.append(f"- [{page.title}]({page.uri}) — {page.summary}") + return "\n".join(lines) + "\n" diff --git a/src/basic_memory/man/man3/build-context(3).md b/src/basic_memory/man/man3/build-context(3).md new file mode 100644 index 000000000..2f4e38112 --- /dev/null +++ b/src/basic_memory/man/man3/build-context(3).md @@ -0,0 +1,81 @@ +--- +title: build-context(3) +type: manpage +section: 3 +name: build-context +summary: traverse the knowledge graph outward from a memory:// URL +generated: hand +tool: build_context +verified: 0.21.6 mcp +--- + +# build-context(3) + +## NAME + +**build-context** — traverse the knowledge graph outward from a memory:// URL + +## SYNOPSIS + +MCP: + +``` +build_context(url, depth=1, timeframe="7d", max_related=10, + project=None, project_id=None, + page=1, page_size=10, output_format="json") +``` + +CLI: + +``` +bm tool build-context URL [--project NAME] [--depth N] [--timeframe SPEC] + [--max-related N] +``` + +## DESCRIPTION + +The conversation-continuity tool: given a note (or pattern of notes), return +it together with its graph neighborhood — observations, typed relations, and +related entities up to `depth` hops out. This is how an agent rebuilds +working context from a cold start: follow a `memory://` URL captured in an +earlier conversation and the relevant subgraph comes back in one call. + +URL forms: `"folder/note"`, `"memory://folder/note"`, and patterns +(`"folder/*"` — but see GOTCHAS for cloud projects). Each traversal step +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) + +## MCP USAGE + +Verified against this manual: + +``` +build_context("man3/write-note-3", project="manual", depth=1, + output_format="text") +# → "# Context: write-note(3)" with the page content, its observations, +# its relations, and related pages like bm-note(5) +``` + +## GOTCHAS + +- [bug] Unresolved forward references render as [[None]] instead of the stored target name, in both text and json output — see basicmachines-co/basic-memory#955 (fixed in #981, pending release) #rendering +- [bug] Pattern URLs (folder/*) match nothing on cloud workspace projects: the pattern is workspace-qualified but index permalinks are project-relative — see basicmachines-co/basic-memory#957 (fixed in #981 — client patterns follow the workspace contextvar, server falls back past the prefix for legacy rows; pending release) #patterns +- [gotcha] Default output_format is json here, unlike most sibling tools that default to text #output +- [gotcha] depth is measured in graph steps where one hop consumes two levels (relation + entity) — depth=1 returns direct neighbors only #traversal +- [pattern] Capture memory:// URLs in conversation summaries and handoffs; build_context on that URL is the cheapest way to restore working state #workflow + +## SEE ALSO + +- see_also [[read-note(3)]] +- see_also [[search-notes(3)]] +- see_also [[recent-activity(3)]] +- see_also [[bm-relation(5)]] diff --git a/src/basic_memory/man/man3/canvas(3).md b/src/basic_memory/man/man3/canvas(3).md new file mode 100644 index 000000000..e94b04464 --- /dev/null +++ b/src/basic_memory/man/man3/canvas(3).md @@ -0,0 +1,58 @@ +--- +title: canvas(3) +type: manpage +section: 3 +name: canvas +summary: generate an Obsidian canvas visualization +generated: hand +tool: canvas +verified: 0.21.6 mcp +--- + +# canvas(3) + +## NAME + +**canvas** — generate an Obsidian canvas visualization + +## SYNOPSIS + +``` +canvas(nodes, edges, title, directory, + project=None, project_id=None) +``` + +## DESCRIPTION + +Writes a `.canvas` file following the JSON Canvas 1.0 spec, openable +in Obsidian. Nodes are dicts (`type: "file"` referencing project notes by +file path, or `type: "text"` for free-standing labels) with explicit +x/y/width/height geometry; edges connect node ids and may carry labels. + +Because file nodes reference real notes, a canvas stays live: opening it in +Obsidian shows the current content of each page. + +## MCP USAGE + +Verified — this manual's own graph diagram: + +``` +canvas(title="manual-graph", directory="diagrams", project="manual", + nodes=[{"id": "n1", "type": "file", + "file": "man7/basic-memory(7).md", + "x": 0, "y": 0, "width": 360, "height": 120}, ...], + edges=[{"id": "e1", "fromNode": "n1", "toNode": "n2", + "label": "see_also"}, ...]) +# → "Created: diagrams/manual-graph.canvas" +``` + +## GOTCHAS + +- [gotcha] file nodes use file paths with extension ("man7/basic-memory(7).md"), not permalinks #identifiers +- [gotcha] All geometry is manual — nothing auto-layouts; compute x/y yourself #layout +- [gotcha] Canvas files are not notes: they don't enter the knowledge graph and search won't find their contents #indexing + +## SEE ALSO + +- see_also [[read-content(3)]] +- see_also [[build-context(3)]] diff --git a/src/basic_memory/man/man3/chatgpt-fetch(3).md b/src/basic_memory/man/man3/chatgpt-fetch(3).md new file mode 100644 index 000000000..5f2e5e3bc --- /dev/null +++ b/src/basic_memory/man/man3/chatgpt-fetch(3).md @@ -0,0 +1,50 @@ +--- +title: chatgpt-fetch(3) +type: manpage +section: 3 +name: chatgpt-fetch +summary: OpenAI-actions-compatible document fetch adapter +generated: hand +tool: fetch +verified: 0.21.6 mcp +--- + +# chatgpt-fetch(3) + +## NAME + +**chatgpt-fetch** (tool name: `fetch`) — OpenAI-actions-compatible document fetch adapter + +## SYNOPSIS + +``` +fetch(id) +``` + +## DESCRIPTION + +Companion to [[chatgpt-search(3)]]: takes an `id` from a search result +(permalink, title, or memory URL) and returns the full document as a +JSON-in-text payload with `id`, `title`, `text` (the full markdown including +frontmatter), `url`, and `metadata.format`. + +## MCP USAGE + +Verified: + +``` +fetch("manual/man3/delete-project-3") +# → [{"type": "text", "text": "{\"id\": ..., \"title\": \"Delete Project 3\", +# \"text\": \"---\\ntitle: delete-project(3)...\", \"url\": ..., +# \"metadata\": {\"format\": \"markdown\"}}"}] +``` + +## GOTCHAS + +- [gotcha] The returned title is title-cased from the permalink ("Delete Project 3"), not the note's actual title ("delete-project(3)") #fidelity +- [gotcha] Same session-active-project routing as chatgpt-search — no project parameter #routing + +## SEE ALSO + +- see_also [[chatgpt-search(3)]] +- see_also [[read-note(3)]] diff --git a/src/basic_memory/man/man3/chatgpt-search(3).md b/src/basic_memory/man/man3/chatgpt-search(3).md new file mode 100644 index 000000000..dce2a6abe --- /dev/null +++ b/src/basic_memory/man/man3/chatgpt-search(3).md @@ -0,0 +1,52 @@ +--- +title: chatgpt-search(3) +type: manpage +section: 3 +name: chatgpt-search +summary: OpenAI-actions-compatible search adapter +generated: hand +tool: search +verified: 0.21.6 mcp +--- + +# chatgpt-search(3) + +## NAME + +**chatgpt-search** (tool name: `search`) — OpenAI-actions-compatible search adapter + +## SYNOPSIS + +``` +search(query) +``` + +## DESCRIPTION + +A minimal adapter for clients that expect the OpenAI actions search shape +(ChatGPT connectors). Delegates to [[search-notes(3)]] with defaults +(page 1, size 10) and re-encodes the response as a single text content item +whose body is a JSON string with `results` (id/title/url), `total_count`, +and the echoed `query`. + +## MCP USAGE + +Verified: + +``` +search("overwrite conflict") +# → [{"type": "text", "text": "{\"results\": [{\"id\": +# \"manual/man3/write-note-3\", \"title\": \"write-note(3)\", +# \"url\": \"manual/man3/write-note-3\"}, ...], +# \"total_count\": 10, \"query\": \"overwrite conflict\"}"}] +``` + +## GOTCHAS + +- [gotcha] No project parameter exists — the search runs against the session's active project (the last one used), not necessarily the configured default #routing +- [gotcha] The payload is JSON-inside-text by design (OpenAI compatibility); normal MCP clients should prefer search_notes #encoding + +## SEE ALSO + +- see_also [[search-notes(3)]] +- see_also [[chatgpt-fetch(3)]] diff --git a/src/basic_memory/man/man3/cloud-info(3).md b/src/basic_memory/man/man3/cloud-info(3).md new file mode 100644 index 000000000..7ec614608 --- /dev/null +++ b/src/basic_memory/man/man3/cloud-info(3).md @@ -0,0 +1,47 @@ +--- +title: cloud-info(3) +type: manpage +section: 3 +name: cloud-info +summary: return Basic Memory Cloud overview and setup guidance +generated: hand +tool: cloud_info +verified: 0.21.6 mcp +--- + +# cloud-info(3) + +## NAME + +**cloud-info** — return Basic Memory Cloud overview and setup guidance + +## SYNOPSIS + +``` +cloud_info() +``` + +## DESCRIPTION + +Returns a static markdown blurb describing the optional cloud add-on +(hosted access, cross-device sync, multi-client workflows) and the +`bm cloud login` entry point. Exists so agents can answer "what is Basic +Memory Cloud?" without leaving MCP. No parameters, read-only. + +## MCP USAGE + +Verified: + +``` +cloud_info() +# → "# Basic Memory Cloud (optional) ..." markdown +``` + +## GOTCHAS + +- [bug] The OSS discount line currently renders a literal {{OSS_DISCOUNT_CODE}} placeholder instead of the code — see basicmachines-co/basic-memory#958 (fixed in #971, pending release) #templating + +## SEE ALSO + +- see_also [[release-notes(3)]] +- see_also [[list-workspaces(3)]] diff --git a/src/basic_memory/man/man3/create-memory-project(3).md b/src/basic_memory/man/man3/create-memory-project(3).md new file mode 100644 index 000000000..9fb93456b --- /dev/null +++ b/src/basic_memory/man/man3/create-memory-project(3).md @@ -0,0 +1,75 @@ +--- +title: create-memory-project(3) +type: manpage +section: 3 +name: create-memory-project +summary: create a new project, locally or in a cloud workspace +generated: hand +tool: create_memory_project +verified: 0.21.6 mcp+cli +--- + +# create-memory-project(3) + +## NAME + +**create-memory-project** — create a new project, locally or in a cloud workspace + +## SYNOPSIS + +MCP: + +``` +create_memory_project(project_name, project_path, + set_default=False, workspace=None, + output_format="text") +``` + +CLI: + +``` +bm project add NAME [PATH] [--cloud] [--workspace SELECTOR] + [--visibility shared|private] [--local-path PATH] +``` + +## DESCRIPTION + +Creates and registers a project. Local projects take a filesystem path; +cloud projects take a cloud-relative path (`"/manual"`) and an optional +`workspace` selector (slug, name, or tenant id — discover via +[[list-workspaces(3)]]). Creating an already-existing project name returns +the existing project rather than erroring. + +## MCP USAGE + +Verified (local project): + +``` +create_memory_project("manual-scratch-952", "/tmp/bm-manual-scratch-952", + output_format="json") +# → {"name": "manual-scratch-952", "external_id": "6a3fb50d-...", +# "created": true, "already_exists": false} +``` + +## CLI EQUIVALENT + +Verified (cloud team-workspace project — this manual's own project): + +``` +bm project add manual --cloud \ + --workspace "basic-memory-7020de4e..." --visibility shared +# → "Project 'manual' added successfully" +``` + +## GOTCHAS + +- [bug] On a local MCP server with OAuth-only credentials, the workspace parameter is silently dropped: the create routes to the local API instead of the cloud workspace — either failing on the cloud-style path or silently creating a local project. Fixed in #981 (pending release): selectors now route to the cloud proxy, or fail fast without credentials — see basicmachines-co/basic-memory#954 #routing +- [gotcha] Cloud project paths are tenant-relative ("/manual"); passing one to a local create attempts a literal filesystem mkdir #paths +- [gotcha] Re-creating an existing name is not an error — check already_exists in the json response #semantics +- [gotcha] Projects created out-of-band are invisible to running MCP sessions until restart — see basicmachines-co/basic-memory#956 (fixed in #981, pending release) #caching + +## SEE ALSO + +- see_also [[list-memory-projects(3)]] +- see_also [[delete-project(3)]] +- see_also [[list-workspaces(3)]] diff --git a/src/basic_memory/man/man3/delete-note(3).md b/src/basic_memory/man/man3/delete-note(3).md new file mode 100644 index 000000000..430edcf7c --- /dev/null +++ b/src/basic_memory/man/man3/delete-note(3).md @@ -0,0 +1,63 @@ +--- +title: delete-note(3) +type: manpage +section: 3 +name: delete-note +summary: delete a note or directory from the knowledge base +generated: hand +tool: delete_note +verified: 0.21.6 mcp+cli +--- + +# delete-note(3) + +## NAME + +**delete-note** — delete a note or directory from the knowledge base + +## SYNOPSIS + +MCP: + +``` +delete_note(identifier, is_directory=False, + project=None, project_id=None, output_format="text") +``` + +CLI: + +``` +bm tool delete-note IDENTIFIER [--is-directory] [--project NAME] +``` + +## DESCRIPTION + +Removes a note (or, with `is_directory=True`, an entire directory and its +contents) from both the filesystem and the index. The file is gone — for +local projects an external backup or git history is the only undo; cloud +projects can fall back to snapshots (see bm-cloud(1) snapshots). + +For directories the identifier is the directory path without file +extension (`"docs"`, `"projects/2025"`). + +## MCP USAGE + +Verified against playground/ (create-then-delete): + +``` +delete_note("playground/demo-doomed-note", project="manual") +# → {"deleted": true, "title": "Demo - Doomed Note", +# "permalink": "manual/playground/demo-doomed-note"} +``` + +## GOTCHAS + +- [gotcha] is_directory=True deletes recursively with no confirmation step — list_directory first and check what you are about to remove #safety +- [gotcha] Identifier accepts title or permalink; with same-titled notes in different folders, prefer the permalink #identifiers +- [pattern] For notes that might be referenced elsewhere, prefer moving to an archive/ folder over deletion — relations to deleted notes become permanently unresolved #workflow + +## SEE ALSO + +- see_also [[move-note(3)]] +- see_also [[write-note(3)]] +- see_also [[list-directory(3)]] diff --git a/src/basic_memory/man/man3/delete-project(3).md b/src/basic_memory/man/man3/delete-project(3).md new file mode 100644 index 000000000..a895604b2 --- /dev/null +++ b/src/basic_memory/man/man3/delete-project(3).md @@ -0,0 +1,61 @@ +--- +title: delete-project(3) +type: manpage +section: 3 +name: delete-project +summary: remove a project from configuration and index (files survive) +generated: hand +tool: delete_project +verified: 0.21.6 mcp +--- + +# delete-project(3) + +## NAME + +**delete-project** — remove a project from configuration and index (files survive) + +## SYNOPSIS + +MCP: + +``` +delete_project(project_name, workspace=None) +``` + +CLI: + +``` +bm project remove NAME +``` + +## DESCRIPTION + +Unregisters a project from Basic Memory's configuration and database. The +markdown files are **not** deleted — the project simply stops being tracked, +and re-adding it restores access to all content. This makes delete-project +far less dangerous than [[delete-note(3)]], which does remove files. + +`workspace` targets a project in a specific cloud workspace (added for +cross-workspace disambiguation). + +## MCP USAGE + +Verified (create-then-delete of a scratch local project): + +``` +delete_project("manual-scratch-952") +# → "✓ Project 'manual-scratch-952' removed successfully ... +# Files remain on disk but project is no longer tracked." +``` + +## GOTCHAS + +- [gotcha] Unlike every sibling tool, delete_project takes no project_id and no output_format — name + workspace is the only addressing mode, and output is text only #parity +- [gotcha] Files remain on disk; this is unregistration, not deletion — but the search index rows for the project are dropped and rebuilt on re-add #semantics + +## SEE ALSO + +- see_also [[create-memory-project(3)]] +- see_also [[list-memory-projects(3)]] +- see_also [[delete-note(3)]] diff --git a/src/basic_memory/man/man3/edit-note(3).md b/src/basic_memory/man/man3/edit-note(3).md new file mode 100644 index 000000000..e4f8cd1a5 --- /dev/null +++ b/src/basic_memory/man/man3/edit-note(3).md @@ -0,0 +1,119 @@ +--- +title: edit-note(3) +type: manpage +section: 3 +name: edit-note +summary: 'edit a note in place: append, prepend, find/replace, or section surgery' +generated: hand +tool: edit_note +verified: 0.21.6 mcp+cli +--- + +# edit-note(3) + +## NAME + +**edit-note** — edit a note in place: append, prepend, find/replace, or section surgery + +## SYNOPSIS + +MCP: + +``` +edit_note(identifier, operation, content, + section=None, find_text=None, expected_replacements=None, + project=None, workspace=None, project_id=None, + output_format="text") +``` + +CLI: + +``` +bm tool edit-note IDENTIFIER --operation OP --content TEXT + [--find-text TEXT] [--section "## Heading"] + [--expected-replacements N] [--project NAME] +``` + +## DESCRIPTION + +Modifies an existing note without rewriting the whole file. Six operations: + +- **append** / **prepend** — add content at the end or start; both create + the note if it does not exist +- **find_replace** — replace occurrences of `find_text` with `content`; + optionally validated by `expected_replacements` +- **replace_section** — replace everything under a markdown heading +- **insert_before_section** / **insert_after_section** — insert content + around a heading without consuming it + +`replace_section` is the mechanism this manual uses for regeneration: +generator-owned sections can be rewritten while curated sections survive +(see [[Manpage]]). + +Unlike [[read-note(3)]], the identifier must be an **exact** title, +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 +- **project** / **project_id** / **workspace** — routing; same semantics as + [[write-note(3)]] + +## MCP USAGE + +All verified against playground/ notes: + +``` +edit_note("playground/demo-cli-stdin", "append", + "\n## Appended Section\n\n- [example] ... #edit", + project="manual") +# → operation: "append", fileCreated: false + +edit_note("playground/demo-pour-over-method", "replace_section", + "- [method] ...\n- [example] regenerated #edit\n", + section="## Observations", project="manual") +# → operation: "replace_section" + +edit_note("playground/demo-pour-over-method", "find_replace", "96°C", + find_text="205°F", expected_replacements=1, project="manual") +# → operation: "find_replace" +``` + +## CLI EQUIVALENT + +``` +bm tool edit-note playground/demo-cli-stdin \ + --operation append --content "more" --project manual +``` + +## EXAMPLES + +Replacement-count validation fails fast and changes nothing: + +``` +edit_note("playground/demo-cli-stdin", "find_replace", "standard input", + find_text="stdin", expected_replacements=99, project="manual") +# → error: "Expected 99 occurrences of 'stdin', but found 4" +``` + +## GOTCHAS +- [gotcha] On the current cloud deployment (0.21.6-era), content added via edit_note is not searchable until a reindex — write_note indexes immediately but edits leave the FTS index stale; verified fixed at HEAD (local edit→search round-trips instantly) — see basicmachines-co/basic-memory-cloud#1173 #version-skew #indexing + +- [gotcha] find_replace searches the whole file including YAML frontmatter — title and permalink fields can be silently rewritten if your find_text matches them; count occurrences with expected_replacements to guard #frontmatter +- [gotcha] The CLI defaults --expected-replacements to 1, but the MCP tool defaults to no validation at all — the same edit can fail via CLI and succeed via MCP #cli-parity +- [gotcha] append and prepend create missing notes instead of erroring; the other four operations require the note to exist #semantics +- [gotcha] edit_note accepts a workspace parameter that most sibling tools lack — prefer project_id for unambiguous cross-workspace routing #routing +- [pattern] Use expected_replacements on every scripted find_replace; it converts silent over-replacement into a loud failure #safety + +## SEE ALSO + +- see_also [[write-note(3)]] +- see_also [[read-note(3)]] +- see_also [[move-note(3)]] +- see_also [[delete-note(3)]] diff --git a/src/basic_memory/man/man3/list-directory(3).md b/src/basic_memory/man/man3/list-directory(3).md new file mode 100644 index 000000000..2d6ae8ad4 --- /dev/null +++ b/src/basic_memory/man/man3/list-directory(3).md @@ -0,0 +1,57 @@ +--- +title: list-directory(3) +type: manpage +section: 3 +name: list-directory +summary: browse project folders with depth and glob filtering +generated: hand +tool: list_directory +verified: 0.21.6 mcp +--- + +# list-directory(3) + +## NAME + +**list-directory** — browse project folders with depth and glob filtering + +## SYNOPSIS + +MCP: + +``` +list_directory(dir_name="/", depth=1, file_name_glob=None, + project=None, project_id=None) +``` + +## DESCRIPTION + +Returns a tree-style listing of a project directory: subfolders with paths, +files with their entity titles and modification dates, and a summary count. +`depth` (1–10) controls recursion; `file_name_glob` filters filenames +(`"*.md"`, `"*meeting*"`). + +This is the orientation tool — the equivalent of `ls` before surgical +operations like [[move-note(3)]] and [[delete-note(3)]]. + +## MCP USAGE + +Verified against this manual: + +``` +list_directory(dir_name="/", depth=2, project="manual") +# → folders (man3, man5, man7, playground, schemas, playground/archive) +# + files with titles and dates +# + "Total: 16 items (6 directories, 10 files)" +``` + +## GOTCHAS + +- [gotcha] Output is text only — there is no structured json output_format on this tool, unlike most siblings #output +- [gotcha] There is no bm tool list-directory CLI wrapper; use bm project ls for local listing #cli-parity + +## SEE ALSO + +- see_also [[move-note(3)]] +- see_also [[delete-note(3)]] +- see_also [[search-notes(3)]] diff --git a/src/basic_memory/man/man3/list-memory-projects(3).md b/src/basic_memory/man/man3/list-memory-projects(3).md new file mode 100644 index 000000000..a4ccdc5d5 --- /dev/null +++ b/src/basic_memory/man/man3/list-memory-projects(3).md @@ -0,0 +1,72 @@ +--- +title: list-memory-projects(3) +type: manpage +section: 3 +name: list-memory-projects +summary: list all projects across local config and cloud workspaces +generated: hand +tool: list_memory_projects +verified: 0.21.6 mcp+cli +--- + +# list-memory-projects(3) + +## NAME + +**list-memory-projects** — list all projects across local config and cloud workspaces + +## SYNOPSIS + +MCP: + +``` +list_memory_projects(output_format="text") +``` + +CLI: + +``` +bm tool list-projects +bm project list # richer table, includes routing and sync columns +``` + +## DESCRIPTION + +Returns a unified view of every reachable project: local projects from +config, plus cloud projects from every workspace the authenticated user can +see, merged by permalink. Each entry carries an `external_id` (UUID) — the +unambiguous handle to pass as `project_id` to other tools when the same +project name exists in more than one workspace. + +JSON entries include `qualified_name` (`workspace-slug/project`), `source` +(`local`, `cloud`, `local+cloud`), `cloud_path`, `local_path`, workspace +metadata, and sync capability flags. + +## MCP USAGE + +``` +list_memory_projects(output_format="json") +# → {"projects": [{"name": "manual", +# "external_id": "0e6a327b-...", +# "qualified_name": "<workspace-slug>/manual", +# "source": "cloud", ...}, ...], +# "default_project": "main"} +``` + +## CLI EQUIVALENT + +``` +bm tool list-projects # same JSON payload +``` + +## GOTCHAS + +- [bug] The cloud project list is cached per session and never refreshed on miss — projects created out-of-band (CLI, teammates in a shared workspace) stay invisible until the session restarts, and project_id routing to them fails — see basicmachines-co/basic-memory#956 (fixed in #981, pending release) #caching +- [gotcha] A bare project name that exists in multiple workspaces resolves to the default workspace; use qualified_name or external_id to disambiguate #routing +- [pattern] Discover once, then route by project_id — names are for humans, UUIDs are for tools #routing + +## SEE ALSO + +- see_also [[create-memory-project(3)]] +- see_also [[delete-project(3)]] +- see_also [[list-workspaces(3)]] diff --git a/src/basic_memory/man/man3/list-workspaces(3).md b/src/basic_memory/man/man3/list-workspaces(3).md new file mode 100644 index 000000000..b7405b697 --- /dev/null +++ b/src/basic_memory/man/man3/list-workspaces(3).md @@ -0,0 +1,64 @@ +--- +title: list-workspaces(3) +type: manpage +section: 3 +name: list-workspaces +summary: list cloud workspaces available to the authenticated user +generated: hand +tool: list_workspaces +verified: 0.21.6 mcp+cli +--- + +# list-workspaces(3) + +## NAME + +**list-workspaces** — list cloud workspaces available to the authenticated user + +## SYNOPSIS + +MCP: + +``` +list_workspaces(output_format="text") +``` + +CLI: + +``` +bm tool list-workspaces +``` + +## DESCRIPTION + +Returns the cloud tenants the current user belongs to: `tenant_id`, `slug`, +`name`, `workspace_type`, `role`, default flag, and subscription status. The +`slug` is the preferred selector to pass as `workspace` to +[[create-memory-project(3)]] and friends; `tenant_id` is the routing +authority underneath. + +For local-only users with no cloud discovery, a display-only "Personal" +workspace is synthesized so the response is never empty. + +## MCP USAGE + +Verified: + +``` +list_workspaces(output_format="json") +# → {"workspaces": [{"tenant_id": "5ccbae40-...", +# "slug": "basic-memory-7020de4e...", +# "name": "Basic Memory", "role": "owner", +# "is_default": true, ...}, ...], +# "count": 2, "default_workspace_id": "5ccbae40-..."} +``` + +## GOTCHAS + +- [gotcha] The synthesized "personal" workspace for local-only users is display-only — it is not valid as a routing selector #routing +- [gotcha] Workspace discovery is read-path only: historically, seeing a workspace here did not mean create_memory_project could route to it from a local MCP server — fixed in #981 (pending release); see basicmachines-co/basic-memory#954 #routing + +## SEE ALSO + +- see_also [[list-memory-projects(3)]] +- see_also [[create-memory-project(3)]] diff --git a/src/basic_memory/man/man3/move-note(3).md b/src/basic_memory/man/man3/move-note(3).md new file mode 100644 index 000000000..d3f03b4c9 --- /dev/null +++ b/src/basic_memory/man/man3/move-note(3).md @@ -0,0 +1,65 @@ +--- +title: move-note(3) +type: manpage +section: 3 +name: move-note +summary: move a note or directory, keeping the database consistent +generated: hand +tool: move_note +verified: 0.21.6 mcp +--- + +# move-note(3) + +## NAME + +**move-note** — move a note or directory, keeping the database consistent + +## SYNOPSIS + +MCP: + +``` +move_note(identifier, + destination_path="" | destination_folder=None, + is_directory=False, + project=None, project_id=None, output_format="text") +``` + +## DESCRIPTION + +Relocates a note (or, with `is_directory=True`, a whole directory tree) and +updates the index. Two mutually exclusive destination forms: + +- **destination_folder** — move into a folder, keeping the filename + (single-file moves only) +- **destination_path** — full new path including filename + (`"work/meetings/note.md"`), or the new directory path for directory moves + +Like [[edit-note(3)]], the identifier must be exact — no fuzzy matching for +destructive operations. + +## MCP USAGE + +Verified against playground/: + +``` +move_note("playground/demo-cli-stdin", + destination_folder="playground/archive", project="manual") +# → {"moved": true, +# "source": "playground/demo-cli-stdin", +# "destination": "playground/archive/Demo - CLI stdin.md", +# "permalink": "manual/playground/demo-cli-stdin"} +``` + +## GOTCHAS + +- [gotcha] A permalink pinned in frontmatter survives the move unchanged — links keep working, but the permalink no longer mirrors the file path (note the example above: file in archive/, permalink still playground/) #permalinks +- [gotcha] destination_folder and destination_path are mutually exclusive, and destination_folder cannot be used for directory moves #parameters +- [gotcha] There is no bm tool move-note CLI wrapper — moves are MCP-only (or plain mv + re-sync for local projects) #cli-parity + +## SEE ALSO + +- see_also [[edit-note(3)]] +- see_also [[delete-note(3)]] +- see_also [[list-directory(3)]] diff --git a/src/basic_memory/man/man3/read-content(3).md b/src/basic_memory/man/man3/read-content(3).md new file mode 100644 index 000000000..8ef3a8852 --- /dev/null +++ b/src/basic_memory/man/man3/read-content(3).md @@ -0,0 +1,51 @@ +--- +title: read-content(3) +type: manpage +section: 3 +name: read-content +summary: read raw file bytes without knowledge-graph processing +generated: hand +tool: read_content +verified: 0.21.6 mcp +--- + +# read-content(3) + +## NAME + +**read-content** — read raw file bytes without knowledge-graph processing + +## SYNOPSIS + +``` +read_content(path, project=None, project_id=None) +``` + +## DESCRIPTION + +Returns a file's raw content with `content_type` and `encoding` metadata — +no identifier cascade, no miss suggestions, no graph awareness. This is the +tool for non-note files (images, canvas files, binaries) and for reading a +note exactly as it sits on disk. Accepts a file path, permalink, or +memory:// URL. + +## MCP USAGE + +Verified: + +``` +read_content("man5/bm-note(5).md", project="manual") +# → {"type": "text", "text": "---\ntitle: bm-note(5)...", +# "content_type": "text/markdown", "encoding": "utf-8"} +``` + +## GOTCHAS + +- [gotcha] File paths include the extension ("man5/bm-note(5).md"); permalinks do not ("manual/man5/bm-note-5") — both are accepted but they are different namespaces #identifiers +- [gotcha] No CLI wrapper exists; for local projects plain cat is the equivalent #cli-parity + +## SEE ALSO + +- see_also [[read-note(3)]] +- see_also [[view-note(3)]] +- see_also [[canvas(3)]] diff --git a/src/basic_memory/man/man3/read-note(3).md b/src/basic_memory/man/man3/read-note(3).md new file mode 100644 index 000000000..0938be169 --- /dev/null +++ b/src/basic_memory/man/man3/read-note(3).md @@ -0,0 +1,110 @@ +--- +title: read-note(3) +type: manpage +section: 3 +name: read-note +summary: read a note by title, permalink, or memory:// URL +generated: hand +tool: read_note +verified: 0.21.6 mcp+cli +--- + +# read-note(3) + +## NAME + +**read-note** — read a note by title, permalink, or memory:// URL + +## SYNOPSIS + +MCP: + +``` +read_note(identifier, + project=None, project_id=None, page=1, page_size=10, + output_format="text", include_frontmatter=False) +``` + +CLI: + +``` +bm tool read-note IDENTIFIER [--project NAME | --project-id UUID] + [--page N] [--page-size N] [--frontmatter] + [--local | --cloud] +``` + +## DESCRIPTION + +Returns the raw markdown of a note. The identifier is resolved through a +cascade: direct permalink lookup, then exact title match, then full-text +search. If nothing matches exactly, read-note returns guidance text instead +of an error: a ranked list of related notes, each with a copy-pasteable +`read_note()` call, plus suggested `search_notes()` and `write_note()` next +steps. A miss is a navigable dead end, not an exception. + +Accepted identifier forms (all verified): + +- exact title — `"Demo - CLI stdin"` +- permalink — `"playground/demo-cli-stdin"` +- memory URL — `"memory://playground/demo-cli-stdin"` +- workspace-qualified permalink — `"<workspace>/manual/playground/demo-cli-stdin"` + +## 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) + +## MCP USAGE + +``` +read_note("Demo - CLI stdin", project="manual") +# → raw markdown, frontmatter included + +read_note("memory://playground/demo-cli-stdin", project="manual") +# → same note via memory URL +``` + +## CLI EQUIVALENT + +``` +bm tool read-note "playground/demo-cli-stdin" --project manual +# → JSON: {"title": ..., "content": "<body without frontmatter>", +# "frontmatter": {...}} +``` + +## EXAMPLES + +A miss returns suggestions, not an error (run against the dev project): + +``` +read_note("xyzzy definitely missing note", project="dev") +# → "# Note Not Found in dev ..." with 3 ranked related notes, +# each with a ready-to-run read_note() call, plus search_notes() +# and write_note() suggestions +``` + +## GOTCHAS + +- [gotcha] Text mode always includes frontmatter; include_frontmatter only controls the json content field #output +- [gotcha] page/page_size never chunk the note — an exact match returns the full note regardless; they only page the miss-suggestion listing #pagination +- [gotcha] The CLI identifier is a positional argument, unlike write-note where everything is a flag #cli-parity +- [gotcha] Exact-title lookup walks its own fixed-size internal pages, so a tiny page_size cannot displace an exact match out of the lookup window #pagination +- [bug] The fuzzy-fallback path re-resolves the project by project_id against the session's cached workspace index; for projects created after session start it errors instead of returning suggestions — see basicmachines-co/basic-memory#956 (fixed in #981, pending release) #routing + +## SEE ALSO + +- see_also [[write-note(3)]] +- see_also [[view-note(3)]] +- see_also [[read-content(3)]] +- see_also [[search-notes(3)]] +- see_also [[build-context(3)]] diff --git a/src/basic_memory/man/man3/recent-activity(3).md b/src/basic_memory/man/man3/recent-activity(3).md new file mode 100644 index 000000000..fed751d5b --- /dev/null +++ b/src/basic_memory/man/man3/recent-activity(3).md @@ -0,0 +1,82 @@ +--- +title: recent-activity(3) +type: manpage +section: 3 +name: recent-activity +summary: list recently changed notes, observations, and relations +generated: hand +tool: recent_activity +verified: 0.21.6 mcp+cli +--- + +# recent-activity(3) + +## NAME + +**recent-activity** — list recently changed notes, observations, and relations + +## SYNOPSIS + +MCP: + +``` +recent_activity(type="", depth=1, timeframe="7d", + project=None, project_id=None, + page=1, page_size=10, output_format="text") +``` + +CLI: + +``` +bm tool recent-activity [--project NAME] [--timeframe SPEC] + [--type TYPE] [--depth N] [--page N] [--page-size N] +``` + +## DESCRIPTION + +Returns what changed in a project within a timeframe — the episodic side of +the knowledge graph (see [[episodic-memory(7)]]). Timeframes accept natural +language (`"today"`, `"2 days ago"`, `"last week"`) or compact forms +(`"7d"`, `"24h"`). + +When no project is given and none resolves, the tool switches to +**cross-project discovery mode**, summarizing activity across every project +so an agent can find where recent work happened before drilling in. + +## 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; omit for discovery mode +- **output_format** — `text` (human summary grouped by kind) or `json` + (flat item list) + +## MCP USAGE + +``` +recent_activity(project="manual", timeframe="today", page_size=5) +# → "## Recent Activity: manual (today)" with grouped recent notes +# and a pagination hint +``` + +## CLI EQUIVALENT + +``` +bm tool recent-activity --project manual --timeframe 1d +# → JSON: flat list of items, entity type, newest first +``` + +## GOTCHAS + +- [gotcha] Default type filter is entity-only — observations and relations are excluded unless requested explicitly #filtering +- [gotcha] Text mode returns a grouped summary; json mode returns a flat list — the shapes are not interconvertible #output +- [pattern] Start a session with discovery mode (no project) to find where recent work happened, then drill into that project #workflow + +## SEE ALSO + +- see_also [[search-notes(3)]] +- see_also [[build-context(3)]] +- see_also [[episodic-memory(7)]] diff --git a/src/basic_memory/man/man3/release-notes(3).md b/src/basic_memory/man/man3/release-notes(3).md new file mode 100644 index 000000000..0b9b7c914 --- /dev/null +++ b/src/basic_memory/man/man3/release-notes(3).md @@ -0,0 +1,47 @@ +--- +title: release-notes(3) +type: manpage +section: 3 +name: release-notes +summary: return the latest product release notes +generated: hand +tool: release_notes +verified: 0.21.6 mcp +--- + +# release-notes(3) + +## NAME + +**release-notes** — return the latest product release notes + +## SYNOPSIS + +``` +release_notes() +``` + +## DESCRIPTION + +Returns the bundled release-notes markdown so agents can summarize what +changed without a web fetch. Static content shipped with the package — it +reflects the installed version's snapshot, not a live feed. No parameters, +read-only. + +## MCP USAGE + +Verified: + +``` +release_notes() +# → "# Release Notes ..." markdown for the installed version +``` + +## GOTCHAS + +- [gotcha] Content is frozen at package build time — for current news check the repository releases page #freshness +- [bug] Shares the {{OSS_DISCOUNT_CODE}} placeholder bug with cloud-info — see basicmachines-co/basic-memory#958 (fixed in #971, pending release) #templating + +## SEE ALSO + +- see_also [[cloud-info(3)]] diff --git a/src/basic_memory/man/man3/schema-diff(3).md b/src/basic_memory/man/man3/schema-diff(3).md new file mode 100644 index 000000000..ed8d94e19 --- /dev/null +++ b/src/basic_memory/man/man3/schema-diff(3).md @@ -0,0 +1,59 @@ +--- +title: schema-diff(3) +type: manpage +section: 3 +name: schema-diff +summary: detect drift between a schema and actual note usage +generated: hand +tool: schema_diff +verified: 0.21.6 mcp +--- + +# schema-diff(3) + +## NAME + +**schema-diff** — detect drift between a schema and actual note usage + +## SYNOPSIS + +``` +schema_diff(note_type, project=None, project_id=None, output_format="text") +``` + +## DESCRIPTION + +Compares the declared schema for a type against how notes of that type are +actually written, reporting three kinds of drift: + +- **New fields** — used in notes but absent from the schema +- **Dropped fields** — declared but rarely or never used +- **Cardinality changes** — declared array but used single-value, or vice versa + +Run it periodically: schemas describe intent, notes describe reality, and +the gap between them is editorial work waiting to be done. + +## MCP USAGE + +Verified — this manual's own drift report: + +``` +schema_diff(note_type="manpage", project="manual") +# → New: links_to (relation, 86%) — inline wikilinks, unschematized +# Dropped: example (observation, 0%) — declared but unused so far +# Cardinality: pattern, bug declared array but typically single-value +``` + +That report did real editorial work: it caught that this manual's pages +put examples in EXAMPLES sections rather than [example] observations. + +## GOTCHAS + +- [gotcha] Like schema-infer, the diff covers observations and relations only — frontmatter drift is not detected #scope +- [pattern] Treat "dropped fields" as a prompt, not an order: a declared-but-unused field may be aspirational rather than dead #workflow + +## SEE ALSO + +- see_also [[schema-validate(3)]] +- see_also [[schema-infer(3)]] +- see_also [[bm-schema(5)]] diff --git a/src/basic_memory/man/man3/schema-infer(3).md b/src/basic_memory/man/man3/schema-infer(3).md new file mode 100644 index 000000000..933c5fadb --- /dev/null +++ b/src/basic_memory/man/man3/schema-infer(3).md @@ -0,0 +1,59 @@ +--- +title: schema-infer(3) +type: manpage +section: 3 +name: schema-infer +summary: derive a Picoschema suggestion from existing notes +generated: hand +tool: schema_infer +verified: 0.21.6 mcp +--- + +# schema-infer(3) + +## NAME + +**schema-infer** — derive a Picoschema suggestion from existing notes + +## SYNOPSIS + +``` +schema_infer(note_type, threshold=0.25, + project=None, project_id=None, output_format="text") +``` + +## DESCRIPTION + +Analyzes every note of a type and proposes a schema from observed usage: +observation categories and relation types with their frequencies. Fields +above 95% frequency are suggested as required; fields above `threshold` +(default 25%) as optional; the rest are listed as excluded. Relation targets +are typed by what they actually point at. + +The workflow this enables: write notes freely first, infer a schema once +patterns stabilize, then [[schema-validate(3)]] keeps new notes consistent. + +## MCP USAGE + +Verified — inferring this manual's schema back from its own pages: + +``` +schema_infer(note_type="manpage", project="manual") +# → analyzed 14 notes; suggested: +# gotcha?(array): string (86%) +# pattern?: string (36%) +# bug?: string (36%) +# see_also(array): Manpage (100% → required, typed!) +# links_to?: Manpage (86%) +``` + +## GOTCHAS + +- [gotcha] Inference sees only observations and relations — frontmatter fields (the settings.frontmatter half of a schema) are not inferred #scope +- [gotcha] A relation present in 100% of notes is promoted to required, which will warn on every future note that lacks it — review before adopting verbatim #thresholds + +## SEE ALSO + +- see_also [[schema-validate(3)]] +- see_also [[schema-diff(3)]] +- see_also [[bm-schema(5)]] diff --git a/src/basic_memory/man/man3/schema-validate(3).md b/src/basic_memory/man/man3/schema-validate(3).md new file mode 100644 index 000000000..e045dc946 --- /dev/null +++ b/src/basic_memory/man/man3/schema-validate(3).md @@ -0,0 +1,76 @@ +--- +title: schema-validate(3) +type: manpage +section: 3 +name: schema-validate +summary: validate notes against their Picoschema definitions +generated: hand +tool: schema_validate +verified: 0.21.6 mcp+cli +--- + +# schema-validate(3) + +## NAME + +**schema-validate** — validate notes against their Picoschema definitions + +## SYNOPSIS + +MCP: + +``` +schema_validate(note_type=None, identifier=None, + project=None, project_id=None, output_format="text") +``` + +CLI: + +``` +bm tool schema-validate [TARGET] [--project NAME] +# TARGET: a note type ("manpage"), a note path, or omitted for everything +``` + +## DESCRIPTION + +Checks notes against the schema resolved for their type (see +[[bm-schema(5)]] for resolution rules) and reports per-field results: +required fields present or missing, enum values in range, observation +categories matched, relations typed correctly — plus `unmatched_observations` +and `unmatched_relations` for content the schema doesn't cover. Severity +follows the schema's `settings.validation` (`warn` by default; `strict` or +`off`). + +This manual validates itself with this tool: every page is checked against +the [[Manpage]] schema before shipping. + +## MCP USAGE + +Verified against this manual: + +``` +schema_validate(note_type="manpage", project="manual", + output_format="json") +# → {"total_notes": 18, "valid_count": 18, +# "warning_count": 0, "error_count": 0, +# "results": [per-note field-by-field reports]} +``` + +## CLI EQUIVALENT + +``` +bm tool schema-validate manpage --project manual +# → same JSON report; TARGET dispatches on type vs path automatically +``` + +## GOTCHAS + +- [gotcha] Validation runs only when you call it — write_note does not validate on save, so a CI or pre-publish validate pass is on you #workflow +- [gotcha] The CLI takes one positional TARGET (type or path, auto-detected); the MCP tool splits the same idea into note_type and identifier parameters #cli-parity +- [gotcha] Inline links create links_to relations that show up in unmatched_relations unless your schema declares them #validation + +## SEE ALSO + +- see_also [[schema-infer(3)]] +- see_also [[schema-diff(3)]] +- see_also [[bm-schema(5)]] diff --git a/src/basic_memory/man/man3/search-notes(3).md b/src/basic_memory/man/man3/search-notes(3).md new file mode 100644 index 000000000..a78e0e731 --- /dev/null +++ b/src/basic_memory/man/man3/search-notes(3).md @@ -0,0 +1,111 @@ +--- +title: search-notes(3) +type: manpage +section: 3 +name: search-notes +summary: search the knowledge base by text, similarity, or metadata +generated: hand +tool: search_notes +verified: 0.21.6 mcp+cli +--- + +# search-notes(3) + +## NAME + +**search-notes** — search the knowledge base by text, similarity, or metadata + +## SYNOPSIS + +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") +``` + +CLI: + +``` +bm tool search-notes [QUERY] [--project NAME] [--search-type TYPE] + [--page N] [--page-size N] [--local | --cloud] ... +``` + +## DESCRIPTION + +One tool, three retrieval modes, and a structured-metadata filter layer that +composes with all of them. + +**Retrieval modes** (`search_type`): `hybrid` (default when semantic search +is enabled — full-text and vector results fused), `text` (SQLite FTS with +boolean operators, phrases, and prefix patterns), `title`, `permalink`, and +`vector`/`semantic` (similarity only, tunable via `min_similarity`). + +**Filters** compose with any mode, or stand alone with no query at all: +`note_types` (frontmatter `type:`), `entity_types` (entity vs observation +rows), `categories` (observation categories, paired with +`entity_types=["observation"]`), `tags`, `status`, `after_date`, and +`metadata_filters` — equality matches against arbitrary frontmatter fields, +which is how the manual implements apropos (see [[Manpage]]). + +## 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) +- **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 +- **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`) + +## MCP USAGE + +All verified against this project: + +``` +search_notes(project="manual", query="frontmatter AND metadata", + search_type="text") +# → 2 results, total: 2, has_more: false (FTS gives exact totals) + +search_notes(project="manual", query="write-note", search_type="title") +# → 1 result + +search_notes(project="manual", tags="manpage-example", note_types=["note"]) +# → filter-only search, no query needed + +search_notes(project="manual", + metadata_filters={"type": "manpage", "section": 3}) +# → apropos: every section-3 page of this manual +``` + +## CLI EQUIVALENT + +``` +bm tool search-notes "conflict error" --project manual --page-size 2 +# → hybrid results as JSON (QUERY is positional) +``` + +## GOTCHAS +- [gotcha] The categories filter is accepted and documented on v0.21.6 but silently ignored — the implementation (#908) is on main, unreleased; the API drops unknown filter fields instead of rejecting them, so there is no error when filtering doesn't happen #version-skew + +- [gotcha] Hybrid and vector searches return total: 0 even with results — counting would cost a second semantic pass, so only has_more is meaningful there; exact totals exist only in text/title/permalink modes #pagination +- [gotcha] Score semantics differ by mode: FTS rank scores in text mode, similarity scores in hybrid/vector — don't compare across modes #scoring +- [gotcha] The CLI takes QUERY positionally; there is no --query flag #cli-parity +- [gotcha] search_all_projects is silently ignored when a project is specified #routing +- [bug] Routing by project_id fails for projects created after the session's workspace index was built — see basicmachines-co/basic-memory#956 (fixed in #981, pending release) #routing + +## SEE ALSO + +- see_also [[read-note(3)]] +- see_also [[build-context(3)]] +- see_also [[recent-activity(3)]] +- see_also [[bm-note(5)]] diff --git a/src/basic_memory/man/man3/view-note(3).md b/src/basic_memory/man/man3/view-note(3).md new file mode 100644 index 000000000..a1068f9db --- /dev/null +++ b/src/basic_memory/man/man3/view-note(3).md @@ -0,0 +1,49 @@ +--- +title: view-note(3) +type: manpage +section: 3 +name: view-note +summary: retrieve a note formatted for artifact display +generated: hand +tool: view_note +verified: 0.21.6 mcp +--- + +# view-note(3) + +## NAME + +**view-note** — retrieve a note formatted for artifact display + +## SYNOPSIS + +``` +view_note(identifier, project=None, project_id=None) +``` + +## DESCRIPTION + +A thin presentational wrapper over [[read-note(3)]]: returns the note's full +content (frontmatter included) wrapped in an instruction telling the client +to render it as a markdown artifact. Use it in chat clients that support +artifacts; use read-note everywhere else. + +## MCP USAGE + +Verified: + +``` +view_note("man3/recent-activity-3", project="manual") +# → 'Note retrieved: ... Display this note as a markdown artifact ...' +# followed by the full note content +``` + +## GOTCHAS + +- [gotcha] No output_format or pagination parameters — this is read_note minus the options, plus a rendering instruction #parameters +- [gotcha] The artifact wrapper separator collides visually with the note's own frontmatter fences (--- followed by ---) #output + +## SEE ALSO + +- see_also [[read-note(3)]] +- see_also [[read-content(3)]] diff --git a/src/basic_memory/man/man3/write-note(3).md b/src/basic_memory/man/man3/write-note(3).md new file mode 100644 index 000000000..8c9e0a806 --- /dev/null +++ b/src/basic_memory/man/man3/write-note(3).md @@ -0,0 +1,125 @@ +--- +title: write-note(3) +type: manpage +section: 3 +name: write-note +summary: create or overwrite a markdown note in the knowledge base +generated: hand +tool: write_note +verified: 0.21.6 mcp+cli +--- + +# write-note(3) + +## NAME + +**write-note** — create or overwrite a markdown note in the knowledge base + +## SYNOPSIS + +MCP: + +``` +write_note(title, content, directory, + project=None, project_id=None, tags=None, note_type="note", + metadata=None, overwrite=None, output_format="text") +``` + +CLI: + +``` +bm tool write-note --title TITLE --folder FOLDER [--content TEXT | < stdin] + [--tags TAG] [--type TYPE] [--project NAME | --project-id UUID] + [--overwrite] [--local | --cloud] +``` + +## DESCRIPTION + +Creates a markdown note and indexes it into the knowledge graph. The content +is parsed for semantic **observations** (`- [category] text #tag`) and +**relations** (`- relation_type [[Target]]`, plus inline `[[wikilinks]]`); +both become queryable graph edges. See [[bm-note(5)]] for the full format. + +If a note with the same title and folder already exists, write-note returns a +conflict error by default. Pass `overwrite=True` (CLI: `--overwrite`) to +replace it. For incremental changes prefer [[edit-note(3)]], which appends, +prepends, or edits sections in place without rewriting the file. + +## PARAMETERS + +- **title** — note title; becomes the H1 and drives the permalink +- **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 uses the default + project. Qualified names (`workspace/project`) route across workspaces +- **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) + +## MCP USAGE + +``` +write_note( + title="Demo - Pour Over Method", + directory="playground", + project="manual", + tags=["demo", "manpage-example"], + content="...markdown with observations and [[relations]]...", +) +# → {"action": "created", "permalink": "<workspace>/manual/playground/demo-pour-over-method", ...} +``` + +## CLI EQUIVALENT + +``` +echo "# CLI Demo Note ..." | bm tool write-note \ + --title "Demo - CLI stdin" --folder playground --project manual +# → {"action": "created", "permalink": "manual/playground/demo-cli-stdin", ...} +``` + +## EXAMPLES + +Create, collide, replace (all run against this project's playground/): + +``` +write_note(title="Demo - Pour Over Method", directory="playground", ...) +# → action: "created" + +write_note(title="Demo - Pour Over Method", directory="playground", ...) +# → action: "conflict", error: "NOTE_ALREADY_EXISTS" + +write_note(title="Demo - Pour Over Method", directory="playground", + overwrite=True, ...) +# → action: "updated" +``` + +## GOTCHAS + +- [gotcha] MCP returns workspace-qualified permalinks for cloud projects while the CLI returns project-relative ones — same write, two canonical forms #permalinks +- [gotcha] The json-mode conflict response permalink is project-relative even though success responses are workspace-qualified #permalinks +- [gotcha] Nested frontmatter (schema:, settings:) must go through the metadata parameter, not content frontmatter — some clients mangle nested YAML in content #frontmatter +- [gotcha] A type: key inside content frontmatter silently overrides the note_type parameter #frontmatter +- [gotcha] CLI flag names diverge from MCP parameter names: --folder vs directory, --type vs note_type #cli-parity +- [gotcha] The CLI has no --metadata flag, so schema notes and custom frontmatter can only be written via MCP or by hand #cli-parity + +## SEE ALSO + +- see_also [[edit-note(3)]] +- see_also [[read-note(3)]] +- see_also [[delete-note(3)]] +- see_also [[bm-note(5)]] +- see_also [[bm-observation(5)]] +- see_also [[bm-relation(5)]] diff --git a/src/basic_memory/mcp/resources/__init__.py b/src/basic_memory/mcp/resources/__init__.py index 53d4713f8..ab5cbc0a9 100644 --- a/src/basic_memory/mcp/resources/__init__.py +++ b/src/basic_memory/mcp/resources/__init__.py @@ -1,5 +1,6 @@ """Bundled MCP resources for Basic Memory.""" +from basic_memory.mcp.resources.man import manual_index, manual_page from basic_memory.mcp.resources.project_info import project_info -__all__ = ["project_info"] +__all__ = ["manual_index", "manual_page", "project_info"] diff --git a/src/basic_memory/mcp/resources/man.py b/src/basic_memory/mcp/resources/man.py new file mode 100644 index 000000000..eceb65bf6 --- /dev/null +++ b/src/basic_memory/mcp/resources/man.py @@ -0,0 +1,64 @@ +"""The manual as MCP resources. + +``memory://man`` is the index (apropos); ``memory://man/<page>`` is one page. Every +bundled page is also registered as a concrete resource so clients that browse +``resources/list`` see each page with its summary, while the template underneath +accepts any spelling of a page reference — ``search-notes(3)``, ``3/search-notes``, +``search_notes`` — so an agent's first guess resolves. +""" + +from fastmcp.exceptions import ResourceError +from fastmcp.resources import FileResource +from pydantic import AnyUrl + +from basic_memory.man import bundled_pages, find_page, parse_page_ref, render_index +from basic_memory.mcp.server import mcp + +MANUAL_INDEX_URI = "memory://man" +MANUAL_PAGE_TEMPLATE = "memory://man/{ref*}" + + +@mcp.resource( + uri=MANUAL_INDEX_URI, + name="manual", + description="Index of the Basic Memory manual: every page with a one-line summary.", + mime_type="text/markdown", +) +def manual_index() -> str: + return render_index(bundled_pages()) + + +@mcp.resource( + uri=MANUAL_PAGE_TEMPLATE, + name="manual page", + description=( + "One manual page, e.g. memory://man/search-notes(3). Section-3 pages document " + "each MCP tool with parameters, verified examples, and gotchas. Any common " + "spelling of the page name resolves: search-notes(3), 3/search-notes, search_notes." + ), + mime_type="text/markdown", +) +def manual_page(ref: str) -> str: + try: + page_ref = parse_page_ref(ref) + except ValueError as error: + raise ResourceError(f"{error}; read {MANUAL_INDEX_URI} for the index") from error + page = find_page(page_ref) + if page is None: + raise ResourceError( + f"No manual entry for {page_ref.display}; read {MANUAL_INDEX_URI} for the index" + ) + return page.read() + + +# Concrete resources are what clients list; the template only answers reads. +for _page in bundled_pages(): + mcp.add_resource( + FileResource( + uri=AnyUrl(_page.uri), + name=_page.title, + description=_page.summary, + mime_type="text/markdown", + path=_page.path, + ) + ) diff --git a/src/basic_memory/mcp/server.py b/src/basic_memory/mcp/server.py index 85773177c..8ce9beda9 100644 --- a/src/basic_memory/mcp/server.py +++ b/src/basic_memory/mcp/server.py @@ -221,9 +221,12 @@ async def lifespan(app: FastMCP): "Memory gives them persistent notes shared between the user and their AI, and offer to save " "something useful from this conversation as their first note with `write_note` — then wait " "for them to agree before writing anything. Do not create notes unprompted.\n\n" - "For a fuller guide, read the `memory://ai_assistant_guide` resource. If you have a web or " - "fetch tool and need current documentation, fetch `https://docs.basicmemory.com/llms.txt` " - "first, then fetch only the relevant linked `/raw/...md` page." + "For a fuller guide, read the `memory://ai_assistant_guide` resource. Every tool has a " + "manual page with verified examples and gotchas: read `memory://man` for the index, or " + "`memory://man/<tool>(3)` (for example `memory://man/search-notes(3)`) before using a tool " + "you have not used before. If you have a web or fetch tool and need current " + "documentation, fetch `https://docs.basicmemory.com/llms.txt` first, then fetch only the " + "relevant linked `/raw/...md` page." ) mcp = FastMCP( diff --git a/tests/cli/test_man_command.py b/tests/cli/test_man_command.py index c6882e9a8..a393abc29 100644 --- a/tests/cli/test_man_command.py +++ b/tests/cli/test_man_command.py @@ -1,10 +1,12 @@ -"""Tests for `bm man install` (#952 / #610: make `man bm` work).""" +"""Tests for `bm man` (#952 / #610): reading bundled pages and making `man bm` work.""" import subprocess +import pytest from typer.testing import CliRunner from basic_memory.cli.app import app +from basic_memory.man import bundled_pages # Importing the module registers the man command group on the top-level app. import basic_memory.cli.commands.man as man_command # noqa: F401 @@ -19,6 +21,52 @@ def _flattened(output: str) -> str: return " ".join(output.split()) +@pytest.mark.parametrize( + "argv", + [ + ["man", "search-notes"], + ["man", "search-notes(3)"], + ["man", "search_notes"], + ["man", "3/search-notes"], + ["man", "show", "search-notes"], + ], +) +def test_man_topic_prints_the_page_as_markdown(argv): + """`bm man <topic>` reads like man(1): the topic needs no subcommand and any spelling works.""" + result = runner.invoke(app, argv) + + assert result.exit_code == 0, result.output + assert result.output.startswith("# search-notes(3)\n") + assert "## GOTCHAS" in result.output + assert "title: search-notes(3)" not in result.output # frontmatter is not rendered + + +def test_man_unknown_topic_fails_and_points_at_list(): + result = runner.invoke(app, ["man", "no-such-page"]) + + assert result.exit_code == 1 + assert "No manual entry for no-such-page" in _flattened(result.output) + assert "bm man list" in _flattened(result.output) + + +def test_man_list_is_apropos(): + result = runner.invoke(app, ["man", "list"]) + + assert result.exit_code == 0, result.output + for page in bundled_pages(): + assert page.title in result.output + assert page.summary in result.output + + +def test_man_help_still_lists_subcommands(): + """A leading option is not a topic: `--help` reaches the group, not `show`.""" + result = runner.invoke(app, ["man", "--help"]) + + assert result.exit_code == 0, result.output + for command in ("install", "list", "show"): + assert command in result.output + + def test_man_install_writes_pages_to_target(tmp_path): """Install copies every bundled page into <root>/man1 as valid groff.""" result = runner.invoke(app, ["man", "install", "--dir", str(tmp_path)]) diff --git a/tests/mcp/test_man_resources.py b/tests/mcp/test_man_resources.py new file mode 100644 index 000000000..8d8b7517f --- /dev/null +++ b/tests/mcp/test_man_resources.py @@ -0,0 +1,70 @@ +"""Tests for the manual as MCP resources (memory://man and memory://man/<page>).""" + +from __future__ import annotations + +import pytest +from fastmcp.exceptions import ResourceError + +from basic_memory.man import bundled_pages +from basic_memory.mcp.resources.man import ( + MANUAL_INDEX_URI, + MANUAL_PAGE_TEMPLATE, + manual_index, + manual_page, +) +from basic_memory.mcp.server import mcp + + +async def _read(uri: str) -> str: + result = await mcp.read_resource(uri) + content = result.contents[0].content + assert isinstance(content, str) + return content + + +@pytest.mark.asyncio +async def test_every_page_is_a_listed_resource_and_the_template_is_registered() -> None: + listed = {str(resource.uri): resource for resource in await mcp.list_resources()} + templates = {str(template.uri_template) for template in await mcp.list_resource_templates()} + + assert MANUAL_INDEX_URI in listed + assert MANUAL_PAGE_TEMPLATE in templates + for page in bundled_pages(): + assert page.uri in listed + assert listed[page.uri].description == page.summary + assert listed[page.uri].mime_type == "text/markdown" + + +@pytest.mark.asyncio +async def test_index_resource_links_every_page() -> None: + index = await _read(MANUAL_INDEX_URI) + + assert index == manual_index() + for page in bundled_pages(): + assert page.uri in index + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "uri", + [ + "memory://man/search-notes(3)", + "memory://man/search-notes%283%29", + "memory://man/search-notes.3", + "memory://man/3/search-notes", + "memory://man/man3/search-notes", + "memory://man/search_notes", + ], +) +async def test_any_spelling_of_a_page_reads_the_same_page(uri: str) -> None: + page = await _read(uri) + + assert page.startswith("---\ntitle: search-notes(3)\n") + assert "## GOTCHAS" in page + + +def test_unknown_pages_point_at_the_index() -> None: + with pytest.raises(ResourceError, match="No manual entry for nope; read memory://man"): + manual_page("nope") + with pytest.raises(ResourceError, match="not a manual page reference; read memory://man"): + manual_page("docs/nope") diff --git a/tests/test_man_pages.py b/tests/test_man_pages.py new file mode 100644 index 000000000..461ed141e --- /dev/null +++ b/tests/test_man_pages.py @@ -0,0 +1,94 @@ +"""Tests for the bundled manual: page references, resolution, and the shipped corpus.""" + +from __future__ import annotations + +import pytest + +from basic_memory.man import ( + MAN_DIR, + PageRef, + bundled_pages, + find_page, + parse_page_ref, + render_index, +) +from basic_memory.mcp.tools import __all__ as registered_tools + + +@pytest.mark.parametrize( + ("text", "expected"), + [ + ("search-notes(3)", PageRef("search-notes", 3)), + ("search-notes.3", PageRef("search-notes", 3)), + ("search-notes-3", PageRef("search-notes", 3)), + ("3/search-notes", PageRef("search-notes", 3)), + ("man3/search-notes", PageRef("search-notes", 3)), + ("man3/search-notes(3).md", PageRef("search-notes", 3)), + ("search-notes%283%29", PageRef("search-notes", 3)), + ("search_notes", PageRef("search-notes", None)), + ("SEARCH_NOTES", PageRef("search-notes", None)), + ("/write-note/", PageRef("write-note", None)), + ("bm(1)", PageRef("bm", 1)), + ], +) +def test_parse_page_ref_accepts_every_common_spelling(text: str, expected: PageRef) -> None: + assert parse_page_ref(text) == expected + + +@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"): + parse_page_ref(text) + + +@pytest.mark.parametrize("text", ["man3", "(3)", "nope"]) +def test_parse_page_ref_leaves_unknown_names_to_resolution(text: str) -> None: + # Parse, don't validate: an odd name is still a name. It simply resolves to + # nothing, which is the caller's "No manual entry" case, not a parse error. + assert find_page(parse_page_ref(text)) is None + + +def test_find_page_uses_named_section_or_lowest() -> None: + assert find_page(PageRef("search-notes", 3)) is not None + assert find_page(PageRef("search-notes", None)) is not None + assert find_page(PageRef("search-notes", 5)) is None + assert find_page(PageRef("no-such-page", None)) is None + + +def test_bundled_pages_are_well_formed_and_sorted() -> None: + pages = bundled_pages() + + assert len(pages) == len(list(MAN_DIR.glob("man[1-9]/*.md"))) + assert [(page.section, page.name) for page in pages] == sorted( + (page.section, page.name) for page in pages + ) + for page in pages: + assert page.path.name == f"{page.title}.md" + assert page.summary + assert page.body().startswith(f"# {page.title}") + # Pages are portable notes: the cloud manual's permalink must not ship. + assert "permalink:" not in page.read().split("---", 2)[1] + assert all(page.tool for page in pages if page.section == 3) + + +# 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"} +PAGES_WITHOUT_LOCAL_TOOLS = {"canvas", "cloud_info", "release_notes"} + + +def test_section_3_matches_the_tool_registry_except_known_gaps() -> None: + documented = {page.tool for page in bundled_pages() if page.section == 3} + + assert set(registered_tools) - documented == TOOLS_WITHOUT_PAGES + assert documented - set(registered_tools) == PAGES_WITHOUT_LOCAL_TOOLS + + +def test_render_index_lists_every_page_with_uri_and_summary() -> None: + index = render_index(bundled_pages()) + + assert index.startswith("# Basic Memory manual") + assert "## Section 3 — MCP tools" in index + for page in bundled_pages(): + assert f"- [{page.title}]({page.uri}) — {page.summary}" in index From 2d8b1c9e04397d65a373e0c9a24f0342cb06e158 Mon Sep 17 00:00:00 2001 From: phernandez <paul@basicmachines.co> Date: Sun, 30 Aug 2026 12:57:34 -0500 Subject: [PATCH 2/9] fix(mcp): refresh drifted manual pages and gate SYNOPSIS against the tool schema Codex review of #1389 caught pages whose SYNOPSIS no longer matched the shipped tool: delete-project(3) omitted the destructive delete_notes flag and promised file retention unconditionally; list-directory(3) claimed there was no structured output while output_format, sort, page, and page_size exist. A scan found the same drift in edit-note(3) (replace_subsections, metadata) and write-note(3) (workspace). All four pages now document the current parameters, and a test asserts that every section-3 SYNOPSIS names exactly the parameters in the live tool schema, so the next added parameter fails CI instead of waiting for a reader. The server instruction no longer promises a page for every tool (basic_memory_diagnostics has none yet); it points at the index. The verified: stamps are unchanged -- the examples on those pages ran on 0.21.6 and were not re-run here; the SYNOPSIS is now checked by test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pmKq6bqCi6Zp6BTHuZjrp Signed-off-by: phernandez <paul@basicmachines.co> --- .../man/man3/delete-project(3).md | 19 ++++++---- src/basic_memory/man/man3/edit-note(3).md | 8 ++++ .../man/man3/list-directory(3).md | 10 +++-- src/basic_memory/man/man3/write-note(3).md | 7 +++- src/basic_memory/mcp/server.py | 9 +++-- tests/test_man_pages.py | 37 +++++++++++++++++++ 6 files changed, 73 insertions(+), 17 deletions(-) diff --git a/src/basic_memory/man/man3/delete-project(3).md b/src/basic_memory/man/man3/delete-project(3).md index a895604b2..2cfc4bac5 100644 --- a/src/basic_memory/man/man3/delete-project(3).md +++ b/src/basic_memory/man/man3/delete-project(3).md @@ -3,7 +3,7 @@ title: delete-project(3) type: manpage section: 3 name: delete-project -summary: remove a project from configuration and index (files survive) +summary: remove a project from configuration and index (files survive by default) generated: hand tool: delete_project verified: 0.21.6 mcp @@ -13,14 +13,14 @@ verified: 0.21.6 mcp ## NAME -**delete-project** — remove a project from configuration and index (files survive) +**delete-project** — remove a project from configuration and index (files survive by default) ## SYNOPSIS MCP: ``` -delete_project(project_name, workspace=None) +delete_project(project_name, delete_notes=False, workspace=None) ``` CLI: @@ -31,10 +31,12 @@ bm project remove NAME ## DESCRIPTION -Unregisters a project from Basic Memory's configuration and database. The -markdown files are **not** deleted — the project simply stops being tracked, -and re-adding it restores access to all content. This makes delete-project -far less dangerous than [[delete-note(3)]], which does remove files. +Unregisters a project from Basic Memory's configuration and database. By +default the markdown files are **not** deleted — the project simply stops +being tracked, and re-adding it restores access to all content. +`delete_notes=True` also deletes the note files themselves (from local disk +for local projects, from cloud storage for cloud projects); with it, this +call is as destructive as [[delete-note(3)]] applied to every note. `workspace` targets a project in a specific cloud workspace (added for cross-workspace disambiguation). @@ -52,7 +54,8 @@ delete_project("manual-scratch-952") ## GOTCHAS - [gotcha] Unlike every sibling tool, delete_project takes no project_id and no output_format — name + workspace is the only addressing mode, and output is text only #parity -- [gotcha] Files remain on disk; this is unregistration, not deletion — but the search index rows for the project are dropped and rebuilt on re-add #semantics +- [gotcha] Files remain on disk by default; this is unregistration, not deletion — the search index rows for the project are dropped and rebuilt on re-add #semantics +- [gotcha] delete_notes=True removes the note files too, and nothing asks twice — there is no confirmation step and no undo #destructive ## SEE ALSO diff --git a/src/basic_memory/man/man3/edit-note(3).md b/src/basic_memory/man/man3/edit-note(3).md index e4f8cd1a5..db5c48d75 100644 --- a/src/basic_memory/man/man3/edit-note(3).md +++ b/src/basic_memory/man/man3/edit-note(3).md @@ -22,6 +22,7 @@ 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") ``` @@ -62,6 +63,13 @@ permalink, or memory:// URL — there is no fuzzy fallback for edits. - **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`, `type`, and `permalink` are ignored; keys cannot be deleted - **project** / **project_id** / **workspace** — routing; same semantics as [[write-note(3)]] diff --git a/src/basic_memory/man/man3/list-directory(3).md b/src/basic_memory/man/man3/list-directory(3).md index 2d6ae8ad4..95d2b543b 100644 --- a/src/basic_memory/man/man3/list-directory(3).md +++ b/src/basic_memory/man/man3/list-directory(3).md @@ -21,7 +21,8 @@ MCP: ``` list_directory(dir_name="/", depth=1, file_name_glob=None, - project=None, project_id=None) + sort=None, page=1, page_size=10, + project=None, project_id=None, output_format="text") ``` ## DESCRIPTION @@ -29,7 +30,11 @@ list_directory(dir_name="/", depth=1, file_name_glob=None, Returns a tree-style listing of a project directory: subfolders with paths, files with their entity titles and modification dates, and a summary count. `depth` (1–10) controls recursion; `file_name_glob` filters filenames -(`"*.md"`, `"*meeting*"`). +(`"*.md"`, `"*meeting*"`). `sort` orders files (`title_asc`, `title_desc`, +`updated_asc`, `updated_desc`; directories always come first), `page` and +`page_size` paginate (10 per page by default, 200 at most), and +`output_format="json"` returns the listing plus pagination data as +structured JSON. This is the orientation tool — the equivalent of `ls` before surgical operations like [[move-note(3)]] and [[delete-note(3)]]. @@ -47,7 +52,6 @@ list_directory(dir_name="/", depth=2, project="manual") ## GOTCHAS -- [gotcha] Output is text only — there is no structured json output_format on this tool, unlike most siblings #output - [gotcha] There is no bm tool list-directory CLI wrapper; use bm project ls for local listing #cli-parity ## SEE ALSO diff --git a/src/basic_memory/man/man3/write-note(3).md b/src/basic_memory/man/man3/write-note(3).md index 8c9e0a806..722dc01c0 100644 --- a/src/basic_memory/man/man3/write-note(3).md +++ b/src/basic_memory/man/man3/write-note(3).md @@ -21,8 +21,9 @@ MCP: ``` write_note(title, content, directory, - project=None, project_id=None, tags=None, note_type="note", - metadata=None, overwrite=None, output_format="text") + project=None, workspace=None, project_id=None, + tags=None, note_type="note", metadata=None, overwrite=None, + output_format="text") ``` CLI: @@ -57,6 +58,8 @@ prepends, or edits sections in place without rewriting the file. - **project** / **project_id** — target project by name or UUID; `project_id` wins and is unambiguous across workspaces. Omitting both uses the default project. 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 diff --git a/src/basic_memory/mcp/server.py b/src/basic_memory/mcp/server.py index 8ce9beda9..62780d6db 100644 --- a/src/basic_memory/mcp/server.py +++ b/src/basic_memory/mcp/server.py @@ -221,10 +221,11 @@ async def lifespan(app: FastMCP): "Memory gives them persistent notes shared between the user and their AI, and offer to save " "something useful from this conversation as their first note with `write_note` — then wait " "for them to agree before writing anything. Do not create notes unprompted.\n\n" - "For a fuller guide, read the `memory://ai_assistant_guide` resource. Every tool has a " - "manual page with verified examples and gotchas: read `memory://man` for the index, or " - "`memory://man/<tool>(3)` (for example `memory://man/search-notes(3)`) before using a tool " - "you have not used before. If you have a web or fetch tool and need current " + "For a fuller guide, read the `memory://ai_assistant_guide` resource. The manual has a " + "page for nearly every tool, with verified examples and gotchas: `memory://man` lists " + "them, and `memory://man/<tool>(3)` (for example `memory://man/search-notes(3)`) is one " + "page — read it before using a tool for the first time. If you have a web or fetch tool " + "and need current " "documentation, fetch `https://docs.basicmemory.com/llms.txt` first, then fetch only the " "relevant linked `/raw/...md` page." ) diff --git a/tests/test_man_pages.py b/tests/test_man_pages.py index 461ed141e..d475fc07e 100644 --- a/tests/test_man_pages.py +++ b/tests/test_man_pages.py @@ -2,16 +2,20 @@ from __future__ import annotations +import re + import pytest from basic_memory.man import ( MAN_DIR, + ManPage, PageRef, bundled_pages, find_page, parse_page_ref, render_index, ) +from basic_memory.mcp.server import mcp from basic_memory.mcp.tools import __all__ as registered_tools @@ -85,6 +89,39 @@ 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", + } + + +@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. + 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" + + def test_render_index_lists_every_page_with_uri_and_summary() -> None: index = render_index(bundled_pages()) From 4db7044990294f634bf5dc6315eb7ea9e0c845d3 Mon Sep 17 00:00:00 2001 From: phernandez <paul@basicmachines.co> Date: Sun, 30 Aug 2026 13:03:29 -0500 Subject: [PATCH 3/9] fix(mcp): describe when recent-activity(3) actually enters discovery mode The page told readers to omit project for cross-project discovery. In the normal case a session's active project or the configured default resolves first, so omitting project returns that project's activity and discovery only runs when nothing resolves. Document the resolution order, fix the parameter line, and add a gotcha with the ways to survey every project. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pmKq6bqCi6Zp6BTHuZjrp Signed-off-by: phernandez <paul@basicmachines.co> --- src/basic_memory/man/man3/recent-activity(3).md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/basic_memory/man/man3/recent-activity(3).md b/src/basic_memory/man/man3/recent-activity(3).md index fed751d5b..fef4611ff 100644 --- a/src/basic_memory/man/man3/recent-activity(3).md +++ b/src/basic_memory/man/man3/recent-activity(3).md @@ -39,9 +39,13 @@ the knowledge graph (see [[episodic-memory(7)]]). Timeframes accept natural language (`"today"`, `"2 days ago"`, `"last week"`) or compact forms (`"7d"`, `"24h"`). -When no project is given and none resolves, the tool switches to +Project resolution follows the usual order: an explicit `project` / +`project_id`, else the session's active project, else the configured +default project. Only when none of those resolves does the tool switch to **cross-project discovery mode**, summarizing activity across every project -so an agent can find where recent work happened before drilling in. +so an agent can find where recent work happened before drilling in. On a +normal install with a default project, omitting `project` therefore returns +that project's activity — not a cross-project view. ## PARAMETERS @@ -50,7 +54,8 @@ so an agent can find where recent work happened before drilling in. - **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; omit for discovery mode +- **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) @@ -70,10 +75,11 @@ bm tool recent-activity --project manual --timeframe 1d ``` ## GOTCHAS +- [gotcha] Discovery mode is rare in practice — with a default project configured, omitting project returns that project's activity; to survey every project, call list_memory_projects and query each, or search with search_all_projects=True #routing - [gotcha] Default type filter is entity-only — observations and relations are excluded unless requested explicitly #filtering - [gotcha] Text mode returns a grouped summary; json mode returns a flat list — the shapes are not interconvertible #output -- [pattern] Start a session with discovery mode (no project) to find where recent work happened, then drill into that project #workflow +- [pattern] Start a session with recent_activity on the default project to orient; when work may have landed elsewhere, list_memory_projects then query the likely ones #workflow ## SEE ALSO From b6c380b6b73c04f5797e22a4cb2a04748cf16fcb Mon Sep 17 00:00:00 2001 From: phernandez <paul@basicmachines.co> Date: Sun, 30 Aug 2026 13:10:47 -0500 Subject: [PATCH 4/9] fix(mcp): drop retired-tool pages, mark hosted-only tools, and state read_content fidelity Codex review of #1389, third pass: - canvas(3) and release-notes(3) documented tools removed in #1111 and #1145 that exist on neither the local nor the hosted server; dropped. - cloud-info(3) documents a tool that only the hosted server registers; the page now says so in its summary and description, and the memory://man index marks any page whose tool this server does not register, computed from the live registry, so the same corpus stays honest on both servers. - read-content(3) promised raw bytes for everything. Text is byte-exact; images are resized and re-encoded as JPEG; other binaries are returned base64 up to 350,000 bytes and error above. The page now says which. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pmKq6bqCi6Zp6BTHuZjrp Signed-off-by: phernandez <paul@basicmachines.co> --- CHANGELOG.md | 2 +- src/basic_memory/man/__init__.py | 18 +++++- src/basic_memory/man/man3/canvas(3).md | 58 ------------------- src/basic_memory/man/man3/cloud-info(3).md | 8 ++- src/basic_memory/man/man3/read-content(3).md | 20 ++++--- src/basic_memory/man/man3/release-notes(3).md | 47 --------------- src/basic_memory/mcp/resources/man.py | 7 ++- tests/mcp/test_man_resources.py | 5 +- tests/test_man_pages.py | 12 +++- 9 files changed, 55 insertions(+), 122 deletions(-) delete mode 100644 src/basic_memory/man/man3/canvas(3).md delete mode 100644 src/basic_memory/man/man3/release-notes(3).md diff --git a/CHANGELOG.md b/CHANGELOG.md index edc3cddad..32364b7e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Features -- **#610**: The manual ships in the package and is served over MCP. The 23 section-3 +- **#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 them as resources: `memory://man` is the index and `memory://man/<page>` is a page, diff --git a/src/basic_memory/man/__init__.py b/src/basic_memory/man/__init__.py index ac50493f3..ab9b5ed93 100644 --- a/src/basic_memory/man/__init__.py +++ b/src/basic_memory/man/__init__.py @@ -132,8 +132,13 @@ def find_page(ref: PageRef) -> ManPage | None: return None -def render_index(pages: tuple[ManPage, ...]) -> str: - """The apropos view: every page, grouped by section, one line each.""" +def render_index(pages: tuple[ManPage, ...], registered_tools: frozenset[str] | None = None) -> str: + """The apropos view: every page, grouped by section, one line each. + + The same corpus serves the local and the hosted server, whose tool sets differ, + so when the caller knows which tools this server registers, pages for the + others are marked rather than presented as callable. + """ section_titles = {1: "User commands", 3: "MCP tools", 5: "File formats", 7: "Concepts"} lines = [ "# Basic Memory manual", @@ -146,5 +151,12 @@ def render_index(pages: tuple[ManPage, ...]) -> str: current_section = page.section heading = section_titles.get(page.section, f"Section {page.section}") lines.extend(["", f"## Section {page.section} — {heading}", ""]) - lines.append(f"- [{page.title}]({page.uri}) — {page.summary}") + line = f"- [{page.title}]({page.uri}) — {page.summary}" + if ( + registered_tools is not None + and page.tool is not None + and page.tool not in registered_tools + ): + line += " *(tool not registered on this server)*" + lines.append(line) return "\n".join(lines) + "\n" diff --git a/src/basic_memory/man/man3/canvas(3).md b/src/basic_memory/man/man3/canvas(3).md deleted file mode 100644 index e94b04464..000000000 --- a/src/basic_memory/man/man3/canvas(3).md +++ /dev/null @@ -1,58 +0,0 @@ ---- -title: canvas(3) -type: manpage -section: 3 -name: canvas -summary: generate an Obsidian canvas visualization -generated: hand -tool: canvas -verified: 0.21.6 mcp ---- - -# canvas(3) - -## NAME - -**canvas** — generate an Obsidian canvas visualization - -## SYNOPSIS - -``` -canvas(nodes, edges, title, directory, - project=None, project_id=None) -``` - -## DESCRIPTION - -Writes a `<title>.canvas` file following the JSON Canvas 1.0 spec, openable -in Obsidian. Nodes are dicts (`type: "file"` referencing project notes by -file path, or `type: "text"` for free-standing labels) with explicit -x/y/width/height geometry; edges connect node ids and may carry labels. - -Because file nodes reference real notes, a canvas stays live: opening it in -Obsidian shows the current content of each page. - -## MCP USAGE - -Verified — this manual's own graph diagram: - -``` -canvas(title="manual-graph", directory="diagrams", project="manual", - nodes=[{"id": "n1", "type": "file", - "file": "man7/basic-memory(7).md", - "x": 0, "y": 0, "width": 360, "height": 120}, ...], - edges=[{"id": "e1", "fromNode": "n1", "toNode": "n2", - "label": "see_also"}, ...]) -# → "Created: diagrams/manual-graph.canvas" -``` - -## GOTCHAS - -- [gotcha] file nodes use file paths with extension ("man7/basic-memory(7).md"), not permalinks #identifiers -- [gotcha] All geometry is manual — nothing auto-layouts; compute x/y yourself #layout -- [gotcha] Canvas files are not notes: they don't enter the knowledge graph and search won't find their contents #indexing - -## SEE ALSO - -- see_also [[read-content(3)]] -- see_also [[build-context(3)]] diff --git a/src/basic_memory/man/man3/cloud-info(3).md b/src/basic_memory/man/man3/cloud-info(3).md index 7ec614608..2da7203d1 100644 --- a/src/basic_memory/man/man3/cloud-info(3).md +++ b/src/basic_memory/man/man3/cloud-info(3).md @@ -3,7 +3,7 @@ title: cloud-info(3) type: manpage section: 3 name: cloud-info -summary: return Basic Memory Cloud overview and setup guidance +summary: return Basic Memory Cloud overview and setup guidance (hosted server only) generated: hand tool: cloud_info verified: 0.21.6 mcp @@ -13,7 +13,7 @@ verified: 0.21.6 mcp ## NAME -**cloud-info** — return Basic Memory Cloud overview and setup guidance +**cloud-info** — return Basic Memory Cloud overview and setup guidance (hosted server only) ## SYNOPSIS @@ -23,6 +23,10 @@ cloud_info() ## DESCRIPTION +**Availability:** registered only on the hosted Basic Memory Cloud MCP +server. It was removed from the local server in v0.22 (#1145), so a local +client will not find this tool. + Returns a static markdown blurb describing the optional cloud add-on (hosted access, cross-device sync, multi-client workflows) and the `bm cloud login` entry point. Exists so agents can answer "what is Basic diff --git a/src/basic_memory/man/man3/read-content(3).md b/src/basic_memory/man/man3/read-content(3).md index 8ef3a8852..384e558a1 100644 --- a/src/basic_memory/man/man3/read-content(3).md +++ b/src/basic_memory/man/man3/read-content(3).md @@ -3,7 +3,7 @@ title: read-content(3) type: manpage section: 3 name: read-content -summary: read raw file bytes without knowledge-graph processing +summary: read a file's content without knowledge-graph processing generated: hand tool: read_content verified: 0.21.6 mcp @@ -13,7 +13,7 @@ verified: 0.21.6 mcp ## NAME -**read-content** — read raw file bytes without knowledge-graph processing +**read-content** — read a file's content without knowledge-graph processing ## SYNOPSIS @@ -23,11 +23,16 @@ read_content(path, project=None, project_id=None) ## DESCRIPTION -Returns a file's raw content with `content_type` and `encoding` metadata — -no identifier cascade, no miss suggestions, no graph awareness. This is the -tool for non-note files (images, canvas files, binaries) and for reading a -note exactly as it sits on disk. Accepts a file path, permalink, or -memory:// URL. +Returns a file's content with no identifier cascade, no miss suggestions, +and no graph awareness. Accepts a file path, permalink, or memory:// URL. +What comes back depends on the file type: + +- **text** — returned exactly as it sits on disk, with `content_type` and + `encoding` metadata; this is the way to read a note byte for byte +- **images** — resized and re-encoded as JPEG (base64) to fit a response, so + the bytes are *not* the original file +- **other binaries** — returned base64-encoded as a document up to 350,000 + bytes; larger files return an error instead of content ## MCP USAGE @@ -42,6 +47,7 @@ read_content("man5/bm-note(5).md", project="manual") ## GOTCHAS - [gotcha] File paths include the extension ("man5/bm-note(5).md"); permalinks do not ("manual/man5/bm-note-5") — both are accepted but they are different namespaces #identifiers +- [gotcha] Only text is byte-exact — images come back as a re-encoded JPEG and binaries over 350,000 bytes return an error; for original bytes read the file from disk #fidelity - [gotcha] No CLI wrapper exists; for local projects plain cat is the equivalent #cli-parity ## SEE ALSO diff --git a/src/basic_memory/man/man3/release-notes(3).md b/src/basic_memory/man/man3/release-notes(3).md deleted file mode 100644 index 0b9b7c914..000000000 --- a/src/basic_memory/man/man3/release-notes(3).md +++ /dev/null @@ -1,47 +0,0 @@ ---- -title: release-notes(3) -type: manpage -section: 3 -name: release-notes -summary: return the latest product release notes -generated: hand -tool: release_notes -verified: 0.21.6 mcp ---- - -# release-notes(3) - -## NAME - -**release-notes** — return the latest product release notes - -## SYNOPSIS - -``` -release_notes() -``` - -## DESCRIPTION - -Returns the bundled release-notes markdown so agents can summarize what -changed without a web fetch. Static content shipped with the package — it -reflects the installed version's snapshot, not a live feed. No parameters, -read-only. - -## MCP USAGE - -Verified: - -``` -release_notes() -# → "# Release Notes ..." markdown for the installed version -``` - -## GOTCHAS - -- [gotcha] Content is frozen at package build time — for current news check the repository releases page #freshness -- [bug] Shares the {{OSS_DISCOUNT_CODE}} placeholder bug with cloud-info — see basicmachines-co/basic-memory#958 (fixed in #971, pending release) #templating - -## SEE ALSO - -- see_also [[cloud-info(3)]] diff --git a/src/basic_memory/mcp/resources/man.py b/src/basic_memory/mcp/resources/man.py index eceb65bf6..434eca620 100644 --- a/src/basic_memory/mcp/resources/man.py +++ b/src/basic_memory/mcp/resources/man.py @@ -24,8 +24,11 @@ description="Index of the Basic Memory manual: every page with a one-line summary.", mime_type="text/markdown", ) -def manual_index() -> str: - return render_index(bundled_pages()) +async def manual_index() -> str: + # Mark pages whose tool this server does not register (hosted-only tools on a + # local server, and vice versa) so an agent does not call a tool that is not there. + tools = await mcp.list_tools(run_middleware=False) + return render_index(bundled_pages(), frozenset(tool.name for tool in tools)) @mcp.resource( diff --git a/tests/mcp/test_man_resources.py b/tests/mcp/test_man_resources.py index 8d8b7517f..dff2b0622 100644 --- a/tests/mcp/test_man_resources.py +++ b/tests/mcp/test_man_resources.py @@ -39,9 +39,12 @@ async def test_every_page_is_a_listed_resource_and_the_template_is_registered() async def test_index_resource_links_every_page() -> None: index = await _read(MANUAL_INDEX_URI) - assert index == manual_index() + assert index == await manual_index() for page in bundled_pages(): assert page.uri in index + # Served from a local server, the hosted-only page is marked, not offered. + assert "cloud-info(3)](memory://man/cloud-info(3)) — " in index + assert "(hosted server only) *(tool not registered on this server)*" in index @pytest.mark.asyncio diff --git a/tests/test_man_pages.py b/tests/test_man_pages.py index d475fc07e..ef9305928 100644 --- a/tests/test_man_pages.py +++ b/tests/test_man_pages.py @@ -79,7 +79,7 @@ def test_bundled_pages_are_well_formed_and_sorted() -> None: # 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"} -PAGES_WITHOUT_LOCAL_TOOLS = {"canvas", "cloud_info", "release_notes"} +PAGES_WITHOUT_LOCAL_TOOLS = {"cloud_info"} # hosted-only; see cloud-info(3) def test_section_3_matches_the_tool_registry_except_known_gaps() -> None: @@ -122,6 +122,16 @@ async def test_section_3_synopsis_names_every_tool_parameter() -> None: assert documented - schema == set(), f"{page.title} SYNOPSIS names unknown parameters" +def test_render_index_marks_pages_whose_tool_this_server_lacks() -> None: + index = render_index(bundled_pages(), registered_tools=frozenset(registered_tools)) + hosted_only = find_page(PageRef("cloud-info", 3)) + local = find_page(PageRef("search-notes", 3)) + assert hosted_only is not None and local is not None + + assert f"({hosted_only.uri}) — {hosted_only.summary} *(tool not registered" in index + assert f"({local.uri}) — {local.summary}\n" in index + + def test_render_index_lists_every_page_with_uri_and_summary() -> None: index = render_index(bundled_pages()) From 2911605f37ef4fda25d84aa095708762156df7d0 Mon Sep 17 00:00:00 2001 From: phernandez <paul@basicmachines.co> Date: Sun, 30 Aug 2026 13:16:51 -0500 Subject: [PATCH 5/9] fix(mcp): write-note(3) no longer claims the title becomes an H1 write_note stores the title in frontmatter and derives the permalink from it; the body is saved as given, so a note whose content has no heading ends up with no H1. The page now says so and tells callers to include the heading themselves. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pmKq6bqCi6Zp6BTHuZjrp Signed-off-by: phernandez <paul@basicmachines.co> --- src/basic_memory/man/man3/write-note(3).md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/basic_memory/man/man3/write-note(3).md b/src/basic_memory/man/man3/write-note(3).md index 722dc01c0..b311a8d3f 100644 --- a/src/basic_memory/man/man3/write-note(3).md +++ b/src/basic_memory/man/man3/write-note(3).md @@ -48,7 +48,9 @@ prepends, or edits sections in place without rewriting the file. ## PARAMETERS -- **title** — note title; becomes the H1 and drives the permalink +- **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) From a0f2b978ac24dcef5a5a8417b7a283c2542dc9fe Mon Sep 17 00:00:00 2001 From: phernandez <paul@basicmachines.co> Date: Sun, 30 Aug 2026 13:22:22 -0500 Subject: [PATCH 6/9] fix(mcp): drop dangling section-3 links and correct delete-note(3) on relations Codex review of #1389, fifth pass: - cloud-info(3) and read-content(3) still linked to release-notes(3) and canvas(3) after those pages were dropped. Removed, and a test now asserts every [[name(3)]] link in the bundle lands on a bundled page. - delete-note(3) said relations to a deleted note become permanently unresolved. Since #1344 the target id is cleared and the link text kept, and the forward-reference pass relinks when a note with that name is written again. The page now describes that recovery and keeps the archive-over-delete pattern for the right reason. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pmKq6bqCi6Zp6BTHuZjrp Signed-off-by: phernandez <paul@basicmachines.co> --- src/basic_memory/man/man3/cloud-info(3).md | 1 - src/basic_memory/man/man3/delete-note(3).md | 3 ++- src/basic_memory/man/man3/read-content(3).md | 1 - tests/test_man_pages.py | 10 ++++++++++ 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/basic_memory/man/man3/cloud-info(3).md b/src/basic_memory/man/man3/cloud-info(3).md index 2da7203d1..2c3d665b0 100644 --- a/src/basic_memory/man/man3/cloud-info(3).md +++ b/src/basic_memory/man/man3/cloud-info(3).md @@ -47,5 +47,4 @@ cloud_info() ## SEE ALSO -- see_also [[release-notes(3)]] - see_also [[list-workspaces(3)]] diff --git a/src/basic_memory/man/man3/delete-note(3).md b/src/basic_memory/man/man3/delete-note(3).md index 430edcf7c..3029905ad 100644 --- a/src/basic_memory/man/man3/delete-note(3).md +++ b/src/basic_memory/man/man3/delete-note(3).md @@ -54,7 +54,8 @@ delete_note("playground/demo-doomed-note", project="manual") - [gotcha] is_directory=True deletes recursively with no confirmation step — list_directory first and check what you are about to remove #safety - [gotcha] Identifier accepts title or permalink; with same-titled notes in different folders, prefer the permalink #identifiers -- [pattern] For notes that might be referenced elsewhere, prefer moving to an archive/ folder over deletion — relations to deleted notes become permanently unresolved #workflow +- [gotcha] Relations pointing at a deleted note are kept as unresolved rows (the target id is cleared, the link text stays) and relink on their own when a note with that name is written again, so deletion is recoverable for the graph — but the note's own outgoing relations and observations are gone with the file #graph +- [pattern] For notes that might be referenced elsewhere, prefer moving to an archive/ folder over deletion — move-note keeps every relation resolved instead of leaving them unresolved until a recreate #workflow ## SEE ALSO diff --git a/src/basic_memory/man/man3/read-content(3).md b/src/basic_memory/man/man3/read-content(3).md index 384e558a1..18bff6df6 100644 --- a/src/basic_memory/man/man3/read-content(3).md +++ b/src/basic_memory/man/man3/read-content(3).md @@ -54,4 +54,3 @@ read_content("man5/bm-note(5).md", project="manual") - see_also [[read-note(3)]] - see_also [[view-note(3)]] -- see_also [[canvas(3)]] diff --git a/tests/test_man_pages.py b/tests/test_man_pages.py index ef9305928..f4804c65c 100644 --- a/tests/test_man_pages.py +++ b/tests/test_man_pages.py @@ -122,6 +122,16 @@ async def test_section_3_synopsis_names_every_tool_parameter() -> None: assert documented - schema == set(), f"{page.title} SYNOPSIS names unknown parameters" +def test_section_3_links_resolve_to_bundled_pages() -> None: + # Section 3 ships in full, so a [[name(3)]] link with no page behind it is a + # dangling SEE ALSO: a retired tool's page was dropped but not its references. + for page in bundled_pages(): + for name in re.findall(r"\[\[([^\]]+)\(3\)\]\]", page.body()): + assert find_page(PageRef(name, 3)) is not None, ( + f"{page.title} links to {name}(3), which is not bundled" + ) + + def test_render_index_marks_pages_whose_tool_this_server_lacks() -> None: index = render_index(bundled_pages(), registered_tools=frozenset(registered_tools)) hosted_only = find_page(PageRef("cloud-info", 3)) From cb30369348bd0959b157e46616de6defec4e3191 Mon Sep 17 00:00:00 2001 From: phernandez <paul@basicmachines.co> Date: Sun, 30 Aug 2026 13:30:18 -0500 Subject: [PATCH 7/9] fix(mcp): resolve manual pages by tool name and correct four page claims Codex review of #1389, sixth pass: - find_page now accepts the tool name as an alias for the page name, so memory://man/search(3) and memory://man/fetch(3) reach chatgpt-search(3) and chatgpt-fetch(3) as the server instruction promises. An exact page name still wins over an alias. - chatgpt-search(3) and chatgpt-fetch(3) state that the tools answer only OpenAI clients; everyone else gets 'Unsupported MCP client'. - delete-note(3), move-note(3), and write-note(3) examples showed a structured response without asking for it; they now pass output_format="json" so the shown shape is what a copy returns. - move-note(3) qualifies permalink preservation: it is the default, and update_permalinks_on_move=True (or a note with no permalink) rewrites the permalink from the destination path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pmKq6bqCi6Zp6BTHuZjrp Signed-off-by: phernandez <paul@basicmachines.co> --- src/basic_memory/man/__init__.py | 17 ++++++++++++++--- src/basic_memory/man/man3/chatgpt-fetch(3).md | 4 ++++ src/basic_memory/man/man3/chatgpt-search(3).md | 4 ++++ src/basic_memory/man/man3/delete-note(3).md | 3 ++- src/basic_memory/man/man3/move-note(3).md | 5 +++-- src/basic_memory/man/man3/write-note(3).md | 1 + tests/mcp/test_man_resources.py | 7 +++++++ tests/test_man_pages.py | 10 ++++++++++ 8 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/basic_memory/man/__init__.py b/src/basic_memory/man/__init__.py index ab9b5ed93..8b059288e 100644 --- a/src/basic_memory/man/__init__.py +++ b/src/basic_memory/man/__init__.py @@ -125,9 +125,20 @@ def bundled_pages() -> tuple[ManPage, ...]: def find_page(ref: PageRef) -> ManPage | None: - """Resolve a reference the way man(1) does: the named section, else the lowest.""" - for page in bundled_pages(): - if page.name == ref.name and (ref.section is None or page.section == ref.section): + """Resolve a reference the way man(1) does: the named section, else the lowest. + + A page may be named differently from the tool it documents (chatgpt-search(3) + documents `search`), so the tool name is an alias for the page name. An exact + page name wins over an alias. + """ + candidates = [ + page for page in bundled_pages() if ref.section is None or page.section == ref.section + ] + for page in candidates: + if page.name == ref.name: + return page + for page in candidates: + if page.tool is not None and page.tool.replace("_", "-") == ref.name: return page return None diff --git a/src/basic_memory/man/man3/chatgpt-fetch(3).md b/src/basic_memory/man/man3/chatgpt-fetch(3).md index 5f2e5e3bc..04bfaf87e 100644 --- a/src/basic_memory/man/man3/chatgpt-fetch(3).md +++ b/src/basic_memory/man/man3/chatgpt-fetch(3).md @@ -23,6 +23,10 @@ fetch(id) ## DESCRIPTION +**Availability:** `fetch` answers only OpenAI clients (ChatGPT connectors). +Any other MCP client gets the error `Unsupported MCP client` without a +search being run — use [[search-notes(3)]] and [[read-note(3)]] instead. + Companion to [[chatgpt-search(3)]]: takes an `id` from a search result (permalink, title, or memory URL) and returns the full document as a JSON-in-text payload with `id`, `title`, `text` (the full markdown including diff --git a/src/basic_memory/man/man3/chatgpt-search(3).md b/src/basic_memory/man/man3/chatgpt-search(3).md index dce2a6abe..696b83483 100644 --- a/src/basic_memory/man/man3/chatgpt-search(3).md +++ b/src/basic_memory/man/man3/chatgpt-search(3).md @@ -23,6 +23,10 @@ search(query) ## DESCRIPTION +**Availability:** `search` answers only OpenAI clients (ChatGPT connectors). +Any other MCP client gets the error `Unsupported MCP client` without a +search being run — use [[search-notes(3)]] and [[read-note(3)]] instead. + A minimal adapter for clients that expect the OpenAI actions search shape (ChatGPT connectors). Delegates to [[search-notes(3)]] with defaults (page 1, size 10) and re-encodes the response as a single text content item diff --git a/src/basic_memory/man/man3/delete-note(3).md b/src/basic_memory/man/man3/delete-note(3).md index 3029905ad..42d8aa396 100644 --- a/src/basic_memory/man/man3/delete-note(3).md +++ b/src/basic_memory/man/man3/delete-note(3).md @@ -45,7 +45,8 @@ extension (`"docs"`, `"projects/2025"`). Verified against playground/ (create-then-delete): ``` -delete_note("playground/demo-doomed-note", project="manual") +delete_note("playground/demo-doomed-note", project="manual", + output_format="json") # → {"deleted": true, "title": "Demo - Doomed Note", # "permalink": "manual/playground/demo-doomed-note"} ``` diff --git a/src/basic_memory/man/man3/move-note(3).md b/src/basic_memory/man/man3/move-note(3).md index d3f03b4c9..0816a21bf 100644 --- a/src/basic_memory/man/man3/move-note(3).md +++ b/src/basic_memory/man/man3/move-note(3).md @@ -45,7 +45,8 @@ Verified against playground/: ``` move_note("playground/demo-cli-stdin", - destination_folder="playground/archive", project="manual") + destination_folder="playground/archive", project="manual", + output_format="json") # → {"moved": true, # "source": "playground/demo-cli-stdin", # "destination": "playground/archive/Demo - CLI stdin.md", @@ -54,7 +55,7 @@ move_note("playground/demo-cli-stdin", ## GOTCHAS -- [gotcha] A permalink pinned in frontmatter survives the move unchanged — links keep working, but the permalink no longer mirrors the file path (note the example above: file in archive/, permalink still playground/) #permalinks +- [gotcha] By default a permalink pinned in frontmatter survives the move unchanged — links keep working, but the permalink no longer mirrors the file path (note the example above: file in archive/, permalink still playground/). With update_permalinks_on_move=True in the project config, or when the note had no permalink, the permalink is rewritten from the destination path and old memory:// links stop resolving #permalinks - [gotcha] destination_folder and destination_path are mutually exclusive, and destination_folder cannot be used for directory moves #parameters - [gotcha] There is no bm tool move-note CLI wrapper — moves are MCP-only (or plain mv + re-sync for local projects) #cli-parity diff --git a/src/basic_memory/man/man3/write-note(3).md b/src/basic_memory/man/man3/write-note(3).md index b311a8d3f..c68907fcb 100644 --- a/src/basic_memory/man/man3/write-note(3).md +++ b/src/basic_memory/man/man3/write-note(3).md @@ -83,6 +83,7 @@ write_note( project="manual", tags=["demo", "manpage-example"], content="...markdown with observations and [[relations]]...", + output_format="json", ) # → {"action": "created", "permalink": "<workspace>/manual/playground/demo-pour-over-method", ...} ``` diff --git a/tests/mcp/test_man_resources.py b/tests/mcp/test_man_resources.py index dff2b0622..a331feadb 100644 --- a/tests/mcp/test_man_resources.py +++ b/tests/mcp/test_man_resources.py @@ -66,6 +66,13 @@ async def test_any_spelling_of_a_page_reads_the_same_page(uri: str) -> None: assert "## GOTCHAS" in page +@pytest.mark.asyncio +async def test_tool_name_reaches_the_page_that_documents_it() -> None: + page = await _read("memory://man/fetch(3)") + + assert page.startswith("---\ntitle: chatgpt-fetch(3)\n") + + def test_unknown_pages_point_at_the_index() -> None: with pytest.raises(ResourceError, match="No manual entry for nope; read memory://man"): manual_page("nope") diff --git a/tests/test_man_pages.py b/tests/test_man_pages.py index f4804c65c..7d3a37c39 100644 --- a/tests/test_man_pages.py +++ b/tests/test_man_pages.py @@ -52,6 +52,16 @@ def test_parse_page_ref_leaves_unknown_names_to_resolution(text: str) -> None: assert find_page(parse_page_ref(text)) is None +def test_find_page_accepts_the_tool_name_as_an_alias() -> None: + # chatgpt-search(3) documents the `search` tool; memory://man/search(3) must land there. + by_alias = find_page(PageRef("search", 3)) + without_section = find_page(PageRef("fetch", None)) + exact = find_page(PageRef("search-notes", 3)) + assert by_alias is not None and by_alias.name == "chatgpt-search" + assert without_section is not None and without_section.name == "chatgpt-fetch" + assert exact is not None and exact.name == "search-notes" + + def test_find_page_uses_named_section_or_lowest() -> None: assert find_page(PageRef("search-notes", 3)) is not None assert find_page(PageRef("search-notes", None)) is not None From c8fc6aaa475f648296d5920fbcc0620d4565c437 Mon Sep 17 00:00:00 2001 From: phernandez <paul@basicmachines.co> Date: Sun, 30 Aug 2026 13:37:19 -0500 Subject: [PATCH 8/9] fix(mcp): drop fixed-and-shipped bugs from the bundled manual pages Codex review of #1389, seventh pass, generalized: eleven GOTCHAS entries across nine pages described defects as 'fixed in #N, pending release', 'on main, unreleased', or 'verified fixed at HEAD'. Those fixes (#908, #971, #981, cloud#1173) are in this tree, and the pages now ship with the tree, so each entry told readers about a bug they do not have. Removed, and a test asserts no bundled page carries such a stamp again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pmKq6bqCi6Zp6BTHuZjrp Signed-off-by: phernandez <paul@basicmachines.co> --- src/basic_memory/man/man3/build-context(3).md | 2 -- src/basic_memory/man/man3/cloud-info(3).md | 4 ---- src/basic_memory/man/man3/create-memory-project(3).md | 2 -- src/basic_memory/man/man3/edit-note(3).md | 1 - src/basic_memory/man/man3/list-memory-projects(3).md | 1 - src/basic_memory/man/man3/list-workspaces(3).md | 1 - src/basic_memory/man/man3/read-note(3).md | 1 - src/basic_memory/man/man3/search-notes(3).md | 2 -- tests/test_man_pages.py | 8 ++++++++ 9 files changed, 8 insertions(+), 14 deletions(-) diff --git a/src/basic_memory/man/man3/build-context(3).md b/src/basic_memory/man/man3/build-context(3).md index 2f4e38112..9b74b9652 100644 --- a/src/basic_memory/man/man3/build-context(3).md +++ b/src/basic_memory/man/man3/build-context(3).md @@ -67,8 +67,6 @@ build_context("man3/write-note-3", project="manual", depth=1, ## GOTCHAS -- [bug] Unresolved forward references render as [[None]] instead of the stored target name, in both text and json output — see basicmachines-co/basic-memory#955 (fixed in #981, pending release) #rendering -- [bug] Pattern URLs (folder/*) match nothing on cloud workspace projects: the pattern is workspace-qualified but index permalinks are project-relative — see basicmachines-co/basic-memory#957 (fixed in #981 — client patterns follow the workspace contextvar, server falls back past the prefix for legacy rows; pending release) #patterns - [gotcha] Default output_format is json here, unlike most sibling tools that default to text #output - [gotcha] depth is measured in graph steps where one hop consumes two levels (relation + entity) — depth=1 returns direct neighbors only #traversal - [pattern] Capture memory:// URLs in conversation summaries and handoffs; build_context on that URL is the cheapest way to restore working state #workflow diff --git a/src/basic_memory/man/man3/cloud-info(3).md b/src/basic_memory/man/man3/cloud-info(3).md index 2c3d665b0..9fbb04fd0 100644 --- a/src/basic_memory/man/man3/cloud-info(3).md +++ b/src/basic_memory/man/man3/cloud-info(3).md @@ -41,10 +41,6 @@ cloud_info() # → "# Basic Memory Cloud (optional) ..." markdown ``` -## GOTCHAS - -- [bug] The OSS discount line currently renders a literal {{OSS_DISCOUNT_CODE}} placeholder instead of the code — see basicmachines-co/basic-memory#958 (fixed in #971, pending release) #templating - ## SEE ALSO - see_also [[list-workspaces(3)]] 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 9fb93456b..e92e90648 100644 --- a/src/basic_memory/man/man3/create-memory-project(3).md +++ b/src/basic_memory/man/man3/create-memory-project(3).md @@ -63,10 +63,8 @@ bm project add manual --cloud \ ## GOTCHAS -- [bug] On a local MCP server with OAuth-only credentials, the workspace parameter is silently dropped: the create routes to the local API instead of the cloud workspace — either failing on the cloud-style path or silently creating a local project. Fixed in #981 (pending release): selectors now route to the cloud proxy, or fail fast without credentials — see basicmachines-co/basic-memory#954 #routing - [gotcha] Cloud project paths are tenant-relative ("/manual"); passing one to a local create attempts a literal filesystem mkdir #paths - [gotcha] Re-creating an existing name is not an error — check already_exists in the json response #semantics -- [gotcha] Projects created out-of-band are invisible to running MCP sessions until restart — see basicmachines-co/basic-memory#956 (fixed in #981, pending release) #caching ## SEE ALSO diff --git a/src/basic_memory/man/man3/edit-note(3).md b/src/basic_memory/man/man3/edit-note(3).md index db5c48d75..9ac4053bd 100644 --- a/src/basic_memory/man/man3/edit-note(3).md +++ b/src/basic_memory/man/man3/edit-note(3).md @@ -111,7 +111,6 @@ edit_note("playground/demo-cli-stdin", "find_replace", "standard input", ``` ## GOTCHAS -- [gotcha] On the current cloud deployment (0.21.6-era), content added via edit_note is not searchable until a reindex — write_note indexes immediately but edits leave the FTS index stale; verified fixed at HEAD (local edit→search round-trips instantly) — see basicmachines-co/basic-memory-cloud#1173 #version-skew #indexing - [gotcha] find_replace searches the whole file including YAML frontmatter — title and permalink fields can be silently rewritten if your find_text matches them; count occurrences with expected_replacements to guard #frontmatter - [gotcha] The CLI defaults --expected-replacements to 1, but the MCP tool defaults to no validation at all — the same edit can fail via CLI and succeed via MCP #cli-parity 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 a4ccdc5d5..928bfcc19 100644 --- a/src/basic_memory/man/man3/list-memory-projects(3).md +++ b/src/basic_memory/man/man3/list-memory-projects(3).md @@ -61,7 +61,6 @@ bm tool list-projects # same JSON payload ## GOTCHAS -- [bug] The cloud project list is cached per session and never refreshed on miss — projects created out-of-band (CLI, teammates in a shared workspace) stay invisible until the session restarts, and project_id routing to them fails — see basicmachines-co/basic-memory#956 (fixed in #981, pending release) #caching - [gotcha] A bare project name that exists in multiple workspaces resolves to the default workspace; use qualified_name or external_id to disambiguate #routing - [pattern] Discover once, then route by project_id — names are for humans, UUIDs are for tools #routing diff --git a/src/basic_memory/man/man3/list-workspaces(3).md b/src/basic_memory/man/man3/list-workspaces(3).md index b7405b697..8fa3d8653 100644 --- a/src/basic_memory/man/man3/list-workspaces(3).md +++ b/src/basic_memory/man/man3/list-workspaces(3).md @@ -56,7 +56,6 @@ list_workspaces(output_format="json") ## GOTCHAS - [gotcha] The synthesized "personal" workspace for local-only users is display-only — it is not valid as a routing selector #routing -- [gotcha] Workspace discovery is read-path only: historically, seeing a workspace here did not mean create_memory_project could route to it from a local MCP server — fixed in #981 (pending release); see basicmachines-co/basic-memory#954 #routing ## SEE ALSO diff --git a/src/basic_memory/man/man3/read-note(3).md b/src/basic_memory/man/man3/read-note(3).md index 0938be169..4e93bf15a 100644 --- a/src/basic_memory/man/man3/read-note(3).md +++ b/src/basic_memory/man/man3/read-note(3).md @@ -99,7 +99,6 @@ read_note("xyzzy definitely missing note", project="dev") - [gotcha] page/page_size never chunk the note — an exact match returns the full note regardless; they only page the miss-suggestion listing #pagination - [gotcha] The CLI identifier is a positional argument, unlike write-note where everything is a flag #cli-parity - [gotcha] Exact-title lookup walks its own fixed-size internal pages, so a tiny page_size cannot displace an exact match out of the lookup window #pagination -- [bug] The fuzzy-fallback path re-resolves the project by project_id against the session's cached workspace index; for projects created after session start it errors instead of returning suggestions — see basicmachines-co/basic-memory#956 (fixed in #981, pending release) #routing ## SEE ALSO diff --git a/src/basic_memory/man/man3/search-notes(3).md b/src/basic_memory/man/man3/search-notes(3).md index a78e0e731..65106f9c9 100644 --- a/src/basic_memory/man/man3/search-notes(3).md +++ b/src/basic_memory/man/man3/search-notes(3).md @@ -95,13 +95,11 @@ bm tool search-notes "conflict error" --project manual --page-size 2 ``` ## GOTCHAS -- [gotcha] The categories filter is accepted and documented on v0.21.6 but silently ignored — the implementation (#908) is on main, unreleased; the API drops unknown filter fields instead of rejecting them, so there is no error when filtering doesn't happen #version-skew - [gotcha] Hybrid and vector searches return total: 0 even with results — counting would cost a second semantic pass, so only has_more is meaningful there; exact totals exist only in text/title/permalink modes #pagination - [gotcha] Score semantics differ by mode: FTS rank scores in text mode, similarity scores in hybrid/vector — don't compare across modes #scoring - [gotcha] The CLI takes QUERY positionally; there is no --query flag #cli-parity - [gotcha] search_all_projects is silently ignored when a project is specified #routing -- [bug] Routing by project_id fails for projects created after the session's workspace index was built — see basicmachines-co/basic-memory#956 (fixed in #981, pending release) #routing ## SEE ALSO diff --git a/tests/test_man_pages.py b/tests/test_man_pages.py index 7d3a37c39..8b7465c94 100644 --- a/tests/test_man_pages.py +++ b/tests/test_man_pages.py @@ -142,6 +142,14 @@ def test_section_3_links_resolve_to_bundled_pages() -> None: ) +@pytest.mark.parametrize("stale", ["pending release", "unreleased", "fixed at HEAD"]) +def test_pages_carry_no_release_pending_claims(stale: str) -> None: + # The pages ship with the code, so a fix described as pending or unreleased is + # already in every package that carries the page; such a note is always stale. + for page in bundled_pages(): + assert stale not in page.body().lower(), f"{page.title} still says '{stale}'" + + def test_render_index_marks_pages_whose_tool_this_server_lacks() -> None: index = render_index(bundled_pages(), registered_tools=frozenset(registered_tools)) hosted_only = find_page(PageRef("cloud-info", 3)) From 98f23d7132f3a940721dfa719376558e478551e1 Mon Sep 17 00:00:00 2001 From: phernandez <paul@basicmachines.co> Date: Sun, 30 Aug 2026 13:45:36 -0500 Subject: [PATCH 9/9] fix(mcp): write-note(3) states active-project precedence for omitted project resolve_project_parameter promotes the session's cached active project over the configured default, so omitting project after touching another project writes there, not to the default. The page said the opposite. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pmKq6bqCi6Zp6BTHuZjrp Signed-off-by: phernandez <paul@basicmachines.co> --- src/basic_memory/man/man3/write-note(3).md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/basic_memory/man/man3/write-note(3).md b/src/basic_memory/man/man3/write-note(3).md index c68907fcb..f5a74f47e 100644 --- a/src/basic_memory/man/man3/write-note(3).md +++ b/src/basic_memory/man/man3/write-note(3).md @@ -58,8 +58,11 @@ prepends, or edits sections in place without rewriting the file. 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 uses the default - project. Qualified names (`workspace/project`) route across workspaces + 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