Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/basic_memory/man/man3/bm-context(3).md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ Returns a note, its observations, and the notes its relations reach, up to
pattern such as `plans/*`. `since` limits related results to recent changes and
`max_related` caps them.

A pattern selects notes as the primary results. Their observations and relations
remain grouped under those notes. A pattern that names no note but addresses
relation or observation permalinks still selects those rows directly.

`compact` returns the graph without note bodies or observations; read chosen
notes with bm_read. Output defaults to JSON because the result is a graph; text
output names each note's path and ends with the next-page call when there is
Expand Down
3 changes: 3 additions & 0 deletions src/basic_memory/man/man3/bm-read(3).md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ bm_read(identifier="plans/missing.md", project="demo")
default project is set. Pass `project`, or qualify the path (`demo/plans/...`).
- A section read returns numbered lines, like `start_line`/`end_line`; a whole
note comes back without line numbers.
- The token budget is approximate. The first nonblank line is returned whole
even when it exceeds the budget, so a read does not return only leading blank
lines. Follow the returned line coordinates to continue.

## SEE ALSO

Expand Down
7 changes: 6 additions & 1 deletion src/basic_memory/man/man3/bm-search(3).md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,12 @@ bm_search(query="release", mode="text", page_size=1, output_format="json", proje
refused.
- A search across projects reaches at most result 1000 (`page * page_size`).
- `hybrid` mode also returns notes that are only near in meaning. With `lines`,
use `mode="text"` when you want only notes that contain the words.
candidates without a matching line are omitted in every mode. Searching one
note with `identifier` explicitly says when no lines match.
- Line matching filters one ranked page at a time. An empty page can still have
a next-page call; follow it to check the remaining candidates.
- Line results report `total_is_exact: false`; `total` counts ranked candidates,
so page with `has_more` instead of using it as a result count.

## SEE ALSO

Expand Down
3 changes: 3 additions & 0 deletions src/basic_memory/man/man3/bm-write(3).md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ relations (`- relation_type [[Other Note]]`). Observations and relations are
indexed after the write, so they show up in bm_context and bm_search moments
later.

The result reports the accepted note's path and checksum. Read its indexed
observations and relations with bm_context.

## PARAMETERS

- **content** (string, required) — The note's Markdown. Saved as given; it may start with frontmatter.
Expand Down
19 changes: 11 additions & 8 deletions src/basic_memory/markdown/sections.py
Original file line number Diff line number Diff line change
Expand Up @@ -483,14 +483,17 @@ def _token_budget_line_count(text: str, text_lines: list[str], budget_chars: int

Cut preference: section boundary first, then paragraph (blank-line) boundary,
then the last full line within budget. Never cuts mid-line, and always keeps
at least one line so a continue-loop makes forward progress. A single line
over budget is served whole — there is no boundary to cut at.
the first nonblank line so a small budget cannot return only whitespace. An
over-budget first content line is served whole — there is no boundary to cut at.
"""
if len(text) <= budget_chars:
return None
line_count = len(text_lines)
if line_count <= 1:
return None
minimum = next((index + 1 for index, line in enumerate(text_lines) if line.strip()), line_count)
if minimum == line_count:
return None

# prefix_lengths[k] == len("\n".join(text_lines[:k]))
prefix_lengths = [0]
Expand All @@ -506,24 +509,24 @@ def fits(kept: int) -> bool:
section_cuts = [
section.start_line - 1 for section in scan_sections(text) if section.start_line > 1
]
fitting = [kept for kept in section_cuts if kept >= 1 and fits(kept)]
fitting = [kept for kept in section_cuts if kept >= minimum and fits(kept)]
if fitting:
return max(fitting)

paragraph_cuts = [
index for index, line in enumerate(text_lines) if index >= 1 and not line.strip()
]
fitting = [kept for kept in paragraph_cuts if fits(kept)]
fitting = [kept for kept in paragraph_cuts if kept >= minimum and fits(kept)]
if fitting:
return max(fitting)

# Line backstop: the largest whole-line prefix within budget; when even the
# first line exceeds the budget, keep it anyway — forward progress beats an
# empty slice.
for kept in range(line_count - 1, 0, -1):
# first content line exceeds the budget, keep it and its leading whitespace
# anyway, preserving coordinates and serving actual content.
for kept in range(line_count - 1, minimum - 1, -1):
if fits(kept):
return kept
return 1
return minimum


def slice_note_content(
Expand Down
12 changes: 1 addition & 11 deletions src/basic_memory/mcp/note_writes.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,6 @@ class NoteChange:
# The accepted revision's checksum, which bm_write and bm_edit take back as
# expected_checksum. file_checksum lags behind materialization.
checksum: str | None
observations: int
relations: int
unresolved_relations: int
# The path the caller asked for, when the note landed in an existing folder's
# spelling instead.
requested_file_path: str | None = None
Expand All @@ -56,9 +53,6 @@ def of(
permalink=note.permalink,
file_path=paths.path(note.file_path),
checksum=note.db_checksum,
observations=len(note.observations),
relations=len(note.relations),
unresolved_relations=sum(1 for relation in note.relations if not relation.to_id),
requested_file_path=None if requested is None else paths.path(requested),
previous_file_path=(
None if previous_file_path is None else paths.path(previous_file_path)
Expand All @@ -75,7 +69,7 @@ def as_json(self) -> dict[str, Any]:
}

def text_lines(self, *, detail: str | None = None) -> list[str]:
"""The text result: what changed and where, then identity and graph counts."""
"""Report the accepted revision, whose derived graph may not be indexed yet."""
match self.action:
case "moved":
headline = f"Moved {self.previous_file_path} to {self.file_path}"
Expand All @@ -86,13 +80,9 @@ def text_lines(self, *, detail: str | None = None) -> list[str]:
lines = [headline]
if self.requested_file_path is not None:
lines.append(f"requested: {self.requested_file_path}")
relations = f"relations: {self.relations}"
if self.unresolved_relations:
relations += f" ({self.unresolved_relations} not yet resolved)"
lines += [
f"title: {self.title}",
f"permalink: {self.permalink}",
f"checksum: {self.checksum}",
f"observations: {self.observations}, {relations}",
]
return lines
49 changes: 31 additions & 18 deletions src/basic_memory/mcp/tools/ranked_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -549,26 +549,31 @@ async def _with_matching_lines(
"""Replace each hit's excerpt with the matching lines of its accepted Markdown.

Line numbers count the whole document, frontmatter included, as bm_read's do.
Hits with no accepted Markdown (files that are not notes) have no lines to show.
Only notes with accepted Markdown and matching lines are returned.
"""
note_hits = [hit for hit in response.results if hit.external_id is not None]
contents = await KnowledgeClient(
target.client, target.project.external_id
).accepted_note_contents([hit.external_id for hit in note_hits if hit.external_id])
payload = response.model_dump(mode="json", exclude_none=True)
payload["results"] = [
_line_row(
hit,
scan_lines(
contents[hit.external_id],
terms,
context_lines=context_lines,
max_matches=max_matches,
),
rows: list[dict[str, Any]] = []
for hit in note_hits:
if hit.external_id not in contents:
continue
scan = scan_lines(
contents[hit.external_id],
terms,
context_lines=context_lines,
max_matches=max_matches,
)
for hit in note_hits
if hit.external_id in contents
]
if scan.match_count > 0:
rows.append(_line_row(hit, scan))
payload["results"] = rows
# Trigger: line matching can drop ranked rows from this page.
# Why: `total` counts ranked candidates, not rows with a matching line, so it
# cannot be used as an exact result count once rows are filtered.
# Outcome: JSON callers page with `has_more` instead of treating `total` as exact.
payload["total_is_exact"] = False
return payload


Expand Down Expand Up @@ -633,9 +638,17 @@ def _format_results(
"""
results = payload["results"]
heading = f"Results for '{query}'" if query else "Results"
continuation = (
f"\n\nMore results available. Call {next_call} to continue."
if next_call is not None
else ""
)
if not results:
return f"No results for '{query or ''}' in {scope}." + (
f"\n\n{payload['query_hint']}" if payload.get("query_hint") else ""
page_hint = " on this page" if next_call is not None else ""
return (
f"No results for '{query or ''}'{page_hint} in {scope}."
+ (f"\n\n{payload['query_hint']}" if payload.get("query_hint") else "")
+ continuation
)

lines = [f"{heading} in {scope}:", ""]
Expand All @@ -645,6 +658,8 @@ def _format_results(
header += f" | score {row['score']:.3f}"
lines.append(header)
if "windows" in row:
if row["match_count"] == 0:
lines.append(" No matching lines.")
for window in row["windows"]:
for number, text in enumerate(
window["content"].split("\n"), start=window["start_line"]
Expand All @@ -654,6 +669,4 @@ def _format_results(
lines.append(f" (more matches from line {row['next_match_line']})")
elif row.get("matched_chunk"):
lines.append(f" {row['matched_chunk'][:200]}")
if next_call is not None:
lines.extend(["", f"More results available. Call {next_call} to continue."])
return "\n".join(lines)
return "\n".join(lines) + continuation
63 changes: 32 additions & 31 deletions src/basic_memory/mcp/tools/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,56 +174,57 @@ def _nothing_to_report(output_format: OutputFormat, message: str, guidance: str)
return refusal("NOT_FOUND", text, output_format=output_format)


def _no_notes_guidance(note_type: str, tool_name: str) -> str:
def _no_notes_guidance(note_type: str, command: Literal["validate", "infer", "diff"]) -> str:
"""Build guidance string when no notes of a given type exist.

Used by schema_validate when the project has zero notes of the
requested type — a different situation from "notes exist but no schema".
"""
return (
f"# No Notes Found of Type '{note_type}'\n\n"
f"`{tool_name}` found no notes with type '{note_type}' in the project.\n\n"
f"`bm_schema(command={command!r}, note_type={note_type!r})` found no notes "
f"with type '{note_type}' in the project.\n\n"
f"## Next Steps\n\n"
f"1. **Create notes of this type** — use `write_note` with "
f"1. **Create notes of this type** — use `bm_write` with "
f'`note_type="{note_type}"` to create notes\n'
f"2. **Check existing types** — use `search_notes` with `note_types` "
f"2. **Check existing types** — use `bm_search` with `note_types` "
f"filter to see what types exist\n"
f"3. **Browse content** — use `list_directory` or `recent_activity` to "
f"see what's in the project\n"
f"3. **Browse content** — use `bm_list` to see what's in the project\n"
)


def _no_schemas_defined_guidance(tool_name: str) -> str:
def _no_schemas_defined_guidance() -> str:
"""Build guidance string when validating all types but no schemas exist.

Used by schema_validate when called without arguments in a project that
has no schema notes — there is nothing to validate yet.
"""
return (
f"# No Schemas Defined\n\n"
f"`{tool_name}` was called without `note_type` or `identifier`, which "
f"validates every note type that has a schema — but this project has "
f"no schema notes yet, so there is nothing to validate.\n\n"
f"## Next Steps\n\n"
f'1. **Infer a schema** — run `schema_infer("<note_type>")` to analyze '
f"existing notes and get a suggested schema\n"
f"2. **Create a schema note** — write a note with `type: schema` and an "
f"`entity` field naming the note type it validates\n"
f"3. **Re-run** — call `{tool_name}()` again once a schema exists\n"
"# No Schemas Defined\n\n"
"`bm_schema(command='validate')` was called without `note_type` or `identifier`, which "
"validates every note type that has a schema — but this project has "
"no schema notes yet, so there is nothing to validate.\n\n"
"## Next Steps\n\n"
"1. **Infer a schema** — run `bm_schema(command='infer', note_type='<note_type>')` to analyze "
"existing notes and get a suggested schema\n"
"2. **Create a schema note** — write a note with `type: schema` and an "
"`entity` field naming the note type it validates\n"
"3. **Re-run** — call `bm_schema(command='validate')` again once a schema exists\n"
)


def _no_schema_guidance(note_type: str, tool_name: str) -> str:
def _no_schema_guidance(note_type: str, command: Literal["validate", "diff"]) -> str:
"""Build guidance string when no schema exists for a note type.

Used by schema_validate and schema_diff to explain what happened
and how to create a schema.
"""
return (
f"# No Schema Found for '{note_type}'\n\n"
f"`{tool_name}` requires a schema note to exist for type '{note_type}'.\n\n"
f"`bm_schema(command={command!r}, note_type={note_type!r})` requires a schema "
f"note to exist for type '{note_type}'.\n\n"
f"## How to Create a Schema\n\n"
f'1. **Infer from existing notes** — run `schema_infer("{note_type}")` to '
f"1. **Infer from existing notes** — run `bm_schema(command='infer', note_type={note_type!r})` to "
f"analyze your notes and get a suggested schema\n"
f"2. **Create a schema note** — write a markdown file with this frontmatter:\n\n"
f"```yaml\n"
Expand All @@ -243,9 +244,9 @@ def _no_schema_guidance(note_type: str, tool_name: str) -> str:
f"- `field_name: type, description` — required field\n"
f"- `field_name?: type, description` — optional field\n"
f"- Supported types: `string`, `number`, `boolean`, `string[]`\n\n"
f"3. **Index** — run `basic-memory db reindex --search` or wait for the "
f"3. **Index** — run `bm reindex --search` or wait for the "
f"file watcher to pick up the new schema note\n"
f'4. **Re-run** — call `{tool_name}("{note_type}")` again\n'
f"4. **Re-run** — call `bm_schema(command={command!r}, note_type={note_type!r})` again\n"
)


Expand Down Expand Up @@ -351,7 +352,7 @@ async def schema_validate(
raise _nothing_to_report(
output_format,
"No schemas defined in this project",
_no_schemas_defined_guidance("schema_validate"),
_no_schemas_defined_guidance(),
)
if output_format == "json":
return result.model_dump(mode="json", exclude_none=True)
Expand All @@ -366,7 +367,7 @@ async def schema_validate(
raise _nothing_to_report(
output_format,
f"No notes found of type '{effective_type}'",
_no_notes_guidance(effective_type, "schema_validate"),
_no_notes_guidance(effective_type, "validate"),
)

# --- No schema guard ---
Expand All @@ -377,7 +378,7 @@ async def schema_validate(
raise _nothing_to_report(
output_format,
f"No schema found for type '{effective_type}'",
_no_schema_guidance(effective_type, "schema_validate"),
_no_schema_guidance(effective_type, "validate"),
)

if output_format == "json":
Expand Down Expand Up @@ -476,7 +477,7 @@ async def schema_infer(
raise _nothing_to_report(
output_format,
f"No notes found of type '{note_type}'",
_no_notes_guidance(note_type, "schema_infer"),
_no_notes_guidance(note_type, "infer"),
)

# --- Empty schema guard ---
Expand All @@ -494,12 +495,12 @@ async def schema_infer(
f"This usually means '{note_type}' is too broad — the notes don't "
f"share a consistent structure.\n\n"
f"## Suggestions\n"
f"1. **Use a more specific type** — try `search_notes` with "
f"1. **Use a more specific type** — try `bm_search` with "
f"`note_types` filter to see what types exist\n"
f"2. **Lower the threshold** — "
f'`schema_infer("{note_type}", threshold=0.1)` to include '
f"`bm_schema(command='infer', note_type={note_type!r}, threshold=0.1)` to include "
f"rarer fields\n"
f"3. **Create typed notes** — use `write_note` with a specific "
f"3. **Create typed notes** — use `bm_write` with a specific "
f'`note_type` (e.g., "person", "meeting") to build consistent '
f"structure\n",
)
Expand Down Expand Up @@ -595,7 +596,7 @@ async def schema_diff(
raise _nothing_to_report(
output_format,
f"No schema found for type '{note_type}'",
_no_schema_guidance(note_type, "schema_diff"),
_no_schema_guidance(note_type, "diff"),
)

# --- No notes guard ---
Expand All @@ -606,7 +607,7 @@ async def schema_diff(
raise _nothing_to_report(
output_format,
f"No notes found of type '{note_type}'",
_no_notes_guidance(note_type, "schema_diff"),
_no_notes_guidance(note_type, "diff"),
)

if output_format == "json":
Expand Down
Loading
Loading