diff --git a/CHANGELOG.md b/CHANGELOG.md index 0683b4a3c..32364b7e6 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 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/` 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..8b059288e --- /dev/null +++ b/src/basic_memory/man/__init__.py @@ -0,0 +1,173 @@ +"""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. + + 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 + + +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", + "", + "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}", ""]) + 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/build-context(3).md b/src/basic_memory/man/man3/build-context(3).md new file mode 100644 index 000000000..9b74b9652 --- /dev/null +++ b/src/basic_memory/man/man3/build-context(3).md @@ -0,0 +1,79 @@ +--- +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 + +- [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/chatgpt-fetch(3).md b/src/basic_memory/man/man3/chatgpt-fetch(3).md new file mode 100644 index 000000000..04bfaf87e --- /dev/null +++ b/src/basic_memory/man/man3/chatgpt-fetch(3).md @@ -0,0 +1,54 @@ +--- +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 + +**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 +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..696b83483 --- /dev/null +++ b/src/basic_memory/man/man3/chatgpt-search(3).md @@ -0,0 +1,56 @@ +--- +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 + +**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 +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..9fbb04fd0 --- /dev/null +++ b/src/basic_memory/man/man3/cloud-info(3).md @@ -0,0 +1,46 @@ +--- +title: cloud-info(3) +type: manpage +section: 3 +name: cloud-info +summary: return Basic Memory Cloud overview and setup guidance (hosted server only) +generated: hand +tool: cloud_info +verified: 0.21.6 mcp +--- + +# cloud-info(3) + +## NAME + +**cloud-info** — return Basic Memory Cloud overview and setup guidance (hosted server only) + +## SYNOPSIS + +``` +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 +Memory Cloud?" without leaving MCP. No parameters, read-only. + +## MCP USAGE + +Verified: + +``` +cloud_info() +# → "# Basic Memory Cloud (optional) ..." markdown +``` + +## 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 new file mode 100644 index 000000000..e92e90648 --- /dev/null +++ b/src/basic_memory/man/man3/create-memory-project(3).md @@ -0,0 +1,73 @@ +--- +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 + +- [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 + +## 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..42d8aa396 --- /dev/null +++ b/src/basic_memory/man/man3/delete-note(3).md @@ -0,0 +1,65 @@ +--- +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", + output_format="json") +# → {"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 +- [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 + +- 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..2cfc4bac5 --- /dev/null +++ b/src/basic_memory/man/man3/delete-project(3).md @@ -0,0 +1,64 @@ +--- +title: delete-project(3) +type: manpage +section: 3 +name: delete-project +summary: remove a project from configuration and index (files survive by default) +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 by default) + +## SYNOPSIS + +MCP: + +``` +delete_project(project_name, delete_notes=False, workspace=None) +``` + +CLI: + +``` +bm project remove NAME +``` + +## DESCRIPTION + +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). + +## 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 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 + +- 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..9ac4053bd --- /dev/null +++ b/src/basic_memory/man/man3/edit-note(3).md @@ -0,0 +1,126 @@ +--- +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, + replace_subsections=None, metadata=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 +- **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)]] + +## 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] 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..95d2b543b --- /dev/null +++ b/src/basic_memory/man/man3/list-directory(3).md @@ -0,0 +1,61 @@ +--- +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, + sort=None, page=1, page_size=10, + project=None, project_id=None, output_format="text") +``` + +## 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*"`). `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)]]. + +## 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] 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..928bfcc19 --- /dev/null +++ b/src/basic_memory/man/man3/list-memory-projects(3).md @@ -0,0 +1,71 @@ +--- +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": "/manual", +# "source": "cloud", ...}, ...], +# "default_project": "main"} +``` + +## CLI EQUIVALENT + +``` +bm tool list-projects # same JSON payload +``` + +## GOTCHAS + +- [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..8fa3d8653 --- /dev/null +++ b/src/basic_memory/man/man3/list-workspaces(3).md @@ -0,0 +1,63 @@ +--- +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 + +## 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..0816a21bf --- /dev/null +++ b/src/basic_memory/man/man3/move-note(3).md @@ -0,0 +1,66 @@ +--- +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", + output_format="json") +# → {"moved": true, +# "source": "playground/demo-cli-stdin", +# "destination": "playground/archive/Demo - CLI stdin.md", +# "permalink": "manual/playground/demo-cli-stdin"} +``` + +## GOTCHAS + +- [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 + +## 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..18bff6df6 --- /dev/null +++ b/src/basic_memory/man/man3/read-content(3).md @@ -0,0 +1,56 @@ +--- +title: read-content(3) +type: manpage +section: 3 +name: read-content +summary: read a file's content without knowledge-graph processing +generated: hand +tool: read_content +verified: 0.21.6 mcp +--- + +# read-content(3) + +## NAME + +**read-content** — read a file's content without knowledge-graph processing + +## SYNOPSIS + +``` +read_content(path, project=None, project_id=None) +``` + +## DESCRIPTION + +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 + +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] 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 + +- see_also [[read-note(3)]] +- see_also [[view-note(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..4e93bf15a --- /dev/null +++ b/src/basic_memory/man/man3/read-note(3).md @@ -0,0 +1,109 @@ +--- +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 — `"/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": "", +# "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 + +## 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..fef4611ff --- /dev/null +++ b/src/basic_memory/man/man3/recent-activity(3).md @@ -0,0 +1,88 @@ +--- +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"`). + +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. On a +normal install with a default project, omitting `project` therefore returns +that project's activity — not a cross-project view. + +## PARAMETERS + +- **type** — filter by item type: `entity` (default), `observation`, + `relation`, or a list combining them; case-insensitive +- **depth** — relation hops to include around recent items (1–3 recommended) +- **timeframe** — how far back to look (aliases: `since`, `time_range`, + `lookback`) +- **project** / **project_id** — target project; omitted, the active or + default project is used, and discovery mode only when neither resolves +- **output_format** — `text` (human summary grouped by kind) or `json` + (flat item list) + +## 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] 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 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 + +- see_also [[search-notes(3)]] +- see_also [[build-context(3)]] +- see_also [[episodic-memory(7)]] 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..65106f9c9 --- /dev/null +++ b/src/basic_memory/man/man3/search-notes(3).md @@ -0,0 +1,109 @@ +--- +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] 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 + +## 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..f5a74f47e --- /dev/null +++ b/src/basic_memory/man/man3/write-note(3).md @@ -0,0 +1,134 @@ +--- +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, workspace=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; written to frontmatter and drives the permalink. + No H1 is added for you: `content` is saved as given, so include + `# Title` yourself if the note should open with a heading +- **content** — markdown body; may include observations, relations, and its own + frontmatter (a `type:` in content frontmatter takes precedence over the + `note_type` parameter) +- **directory** — folder path relative to project root; `/` or empty writes to + root. MCP accepts the aliases `folder`, `dir`, and `path`; the CLI flag is + `--folder` +- **project** / **project_id** — target project by name or UUID; `project_id` + wins and is unambiguous across workspaces. Omitting both writes to the + session's active project — the last one this session touched — and only + falls back to the configured default when there is none, so after working + in another project pass `project` explicitly. Qualified names + (`workspace/project`) route across workspaces +- **workspace** — cloud workspace slug, name, or tenant_id; with `project`, + routes as `workspace/project`. Cannot be combined with `project_id` +- **tags** — list or comma-separated string; external MCP clients should pass + the string form (`"a,b,c"`) +- **note_type** (CLI: `--type`) — frontmatter `type:`, default `note`; this is + what schema validation keys on (see [[bm-schema(5)]]) +- **metadata** — dict merged into frontmatter; the reliable way to write + nested YAML (schema notes, custom fields). Not available from the CLI +- **overwrite** — `True` replaces on conflict; `False` errors; unset consults + the `write_note_overwrite_default` config setting +- **output_format** — `text` (markdown summary) or `json` (machine-readable; + conflicts come back as `action: "conflict"` with an `error` code instead of + raising) + +## MCP USAGE + +``` +write_note( + title="Demo - Pour Over Method", + directory="playground", + project="manual", + tags=["demo", "manpage-example"], + content="...markdown with observations and [[relations]]...", + output_format="json", +) +# → {"action": "created", "permalink": "/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..434eca620 --- /dev/null +++ b/src/basic_memory/mcp/resources/man.py @@ -0,0 +1,67 @@ +"""The manual as MCP resources. + +``memory://man`` is the index (apropos); ``memory://man/`` 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", +) +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( + 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..62780d6db 100644 --- a/src/basic_memory/mcp/server.py +++ b/src/basic_memory/mcp/server.py @@ -221,9 +221,13 @@ 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. The manual has a " + "page for nearly every tool, with verified examples and gotchas: `memory://man` lists " + "them, and `memory://man/(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." ) 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 ` 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 /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..a331feadb --- /dev/null +++ b/tests/mcp/test_man_resources.py @@ -0,0 +1,80 @@ +"""Tests for the manual as MCP resources (memory://man and memory://man/).""" + +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 == 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 +@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 + + +@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") + 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..8b7465c94 --- /dev/null +++ b/tests/test_man_pages.py @@ -0,0 +1,169 @@ +"""Tests for the bundled manual: page references, resolution, and the shipped corpus.""" + +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 + + +@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_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 + 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 = {"cloud_info"} # hosted-only; see cloud-info(3) + + +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 _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_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" + ) + + +@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)) + 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()) + + 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