Skip to content

Commit 2e26612

Browse files
authored
feat(mcp): add bounded line reads and literal grep context (#1502)
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 583b817 commit 2e26612

13 files changed

Lines changed: 801 additions & 45 deletions

File tree

src/basic_memory/cli/commands/posix.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,31 @@ def _plain_find_fields(result: dict[str, Any]) -> None:
332332
print(f"{row.get('file_path', '')}\t{fields_json}")
333333

334334

335+
def _display_grep_lines(result: dict[str, Any], *, plain: bool) -> None:
336+
"""Print bounded windows without reintroducing the search response's full body."""
337+
output: list[str] = [f"Search candidates: page {result['current_page']}"]
338+
for row in result["results"]:
339+
path = row["file_path"]
340+
output.append(f"{path}: {row['match_count']} matching line(s)")
341+
for window in row["windows"]:
342+
for number, line in zip(
343+
range(window["start_line"], window["end_line"] + 1),
344+
window["content"].split("\n"),
345+
strict=True,
346+
):
347+
marker = ":" if number in window["match_lines"] else "-"
348+
output.append(f"{path}{marker}{number}{marker}{line}")
349+
if row["next_match_line"] is not None:
350+
output.append(f"… more matches; read {path} at line {row['next_match_line']}")
351+
if result["has_more"]:
352+
output.append(f"… more candidates; use --page {result['current_page'] + 1}")
353+
text = "\n".join(output)
354+
if plain:
355+
print(text)
356+
else:
357+
console.print(Text(text))
358+
359+
335360
# --- tail rendering ---
336361
# tail's row shape ({type, title, permalink, file_path, created_at}) differs
337362
# from recent-activity's payload, so it gets its own small renderers rather
@@ -622,6 +647,22 @@ def grep(
622647
"--literal", "-F", help="Literal full-text matching instead of semantic search"
623648
),
624649
] = False,
650+
context_lines: Annotated[
651+
Optional[int],
652+
typer.Option(
653+
"-C",
654+
"--context-lines",
655+
min=0,
656+
max=10,
657+
help="Compact literal line matches with surrounding context (requires -F)",
658+
),
659+
] = None,
660+
max_matches: Annotated[
661+
int,
662+
typer.Option(
663+
"--max-matches", min=1, max=100, help="Matching lines per candidate in context mode"
664+
),
665+
] = 10,
625666
page: Annotated[int, typer.Option("--page", help="Page number (1-indexed)")] = 1,
626667
page_size: Annotated[int, typer.Option("--page-size", help="Results per page")] = 10,
627668
json_output: JsonOption = False,
@@ -635,6 +676,7 @@ def grep(
635676
636677
Examples:
637678
679+
bm grep -F "retry" -C 3 --plain
638680
bm grep "auth token rotation"
639681
bm grep -F "BASIC_MEMORY_FORCE_LOCAL"
640682
bm grep "deploy checklist" --page-size 20 --json
@@ -652,6 +694,8 @@ def grep(
652694
mcp_grep(
653695
pattern,
654696
literal=literal,
697+
context_lines=context_lines,
698+
max_matches=max_matches,
655699
page=page,
656700
page_size=page_size,
657701
project=project,
@@ -662,6 +706,8 @@ def grep(
662706
mode = _resolve_output_mode(json_output, plain)
663707
if mode == "json":
664708
_print_json(result)
709+
elif context_lines is not None:
710+
_display_grep_lines(result, plain=mode == "plain")
665711
elif mode == "plain":
666712
_plain_search_results(result, query=pattern)
667713
else:

src/basic_memory/cli/commands/tool.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -699,6 +699,14 @@ def write_note(
699699
@tool_app.command()
700700
def read_note(
701701
identifier: str,
702+
start_line: Annotated[
703+
Optional[int],
704+
typer.Option(min=1, help="First document line, including frontmatter (1-based)"),
705+
] = None,
706+
end_line: Annotated[
707+
Optional[int],
708+
typer.Option(min=1, help="Last document line, inclusive; omitted reads to EOF"),
709+
] = None,
702710
include_frontmatter: bool = typer.Option(
703711
False,
704712
"--frontmatter",
@@ -740,6 +748,7 @@ def read_note(
740748
bm tool read-note my-note --frontmatter
741749
bm tool read-note my-note --plain
742750
bm tool read-note my-note --json
751+
bm tool read-note my-note --start-line 120 --end-line 180 --plain
743752
"""
744753
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
745754
from basic_memory.mcp.tools import read_note as mcp_read_note
@@ -756,6 +765,8 @@ def read_note(
756765
project_id=project_id,
757766
include_frontmatter=include_frontmatter,
758767
output_format="json",
768+
start_line=start_line,
769+
end_line=end_line,
759770
)
760771
)
761772

@@ -776,6 +787,19 @@ def read_note(
776787
mode = _resolve_output_mode(json_output, plain)
777788
if mode == "json" or isinstance(result, str):
778789
_print_json(result)
790+
elif "start_line" in result:
791+
from basic_memory.markdown.line_scanning import format_line_read
792+
793+
text = format_line_read(
794+
result["content"],
795+
start_line=result["start_line"],
796+
end_line=result["end_line"],
797+
total_lines=result["total_lines"],
798+
)
799+
if mode == "plain":
800+
print(text)
801+
else:
802+
console.print(Text(text))
779803
elif mode == "plain":
780804
_plain_read_note(result, include_frontmatter=include_frontmatter)
781805
else:

src/basic_memory/man/man1/grep(1).md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ generated: hand
1717

1818
```
1919
bm grep PATTERN [-F | --literal] [--page N] [--page-size N]
20+
[-C N | --context-lines N] [--max-matches N]
2021
[--json | --plain] [--project NAME | --project-id UUID]
2122
[--local | --cloud]
2223
```
@@ -29,14 +30,31 @@ matching, like real grep's fixed-strings flag. Results carry title, score,
2930
permalink, and the matched snippet; on a TTY they render as a table, and
3031
`--json` (or piped output) emits the search response with pagination.
3132

33+
With `-F -C N`, return compact literal match windows instead. The full-text index
34+
selects a page of candidate notes, then their current content is checked for
35+
case-insensitive literal substrings. Matching line numbers include frontmatter
36+
and can be passed directly to `read_note(start_line=..., end_line=...)` or
37+
`bm cat ... --lines N-M`. Overlapping context windows merge to avoid repeated text.
38+
The full body and search excerpt are omitted in this mode, including JSON output.
39+
40+
This is not an exhaustive filesystem grep: index tokenization, stemming, or edits
41+
can produce a candidate with zero literal matches, or omit a substring-only match.
42+
Pagination and totals refer to search candidates, not exact matching notes or lines.
43+
Each candidate reports `match_count`, `total_lines`, `windows`, and `next_match_line`
44+
(the first omitted matching line, or null). Use the latter for a targeted note read.
45+
Line positions can change if the note is edited between calls.
46+
3247
## OPTIONS
3348

3449
- **-F, --literal** — literal full-text matching instead of semantic search
50+
- **-C, --context-lines** — opt into line scanning with 0-10 lines around each match; requires -F
51+
- **--max-matches** — matching lines to show per candidate in line mode, 1-100 (default 10)
3552
- **--page, --page-size** — result pagination (defaults 1 and 10)
3653

3754
## EXAMPLES
3855

3956
```
57+
bm grep -F "retry" -C 3 --max-matches 5 --plain
4058
bm grep "auth token rotation"
4159
bm grep -F "BASIC_MEMORY_FORCE_LOCAL"
4260
bm grep "deploy checklist" --json | jq '.results[].permalink'

src/basic_memory/man/man3/read-note(3).md

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,15 @@ MCP:
2121

2222
```
2323
read_note(identifier, project=None, project_id=None, page=1, page_size=10,
24-
output_format="text", include_frontmatter=False)
24+
output_format="text", include_frontmatter=False, start_line=None,
25+
end_line=None)
2526
```
2627

2728
CLI:
2829

2930
```
3031
bm tool read-note IDENTIFIER [--project NAME | --project-id UUID]
31-
[--page N] [--page-size N] [--frontmatter]
32+
[--start-line N] [--end-line N] [--frontmatter]
3233
[--local | --cloud]
3334
```
3435

@@ -53,10 +54,12 @@ Accepted identifier forms (all verified):
5354
- **identifier** (string, required) — The title or permalink of the note to read. Can be a full memory:// URL, a permalink, a title, or search text. From the CLI this is a positional argument, not a flag.
5455
- **project** (string | null, optional, default: None) — Project name to read from. Optional - server will resolve using the hierarchy above. If unknown, use list_memory_projects() to discover available projects.
5556
- **project_id** (string | null, optional, default: None) — Project external_id (UUID). Prefer this over `project` when known — it routes to the exact project regardless of name collisions across cloud workspaces. Takes precedence over `project`. Get from list_memory_projects().
56-
- **page** (integer, optional, default: 1) — Page of fallback-search results to use when the identifier does not resolve to a note directly (default: 1). A direct or exact-title match always returns the full note content — page/page_size never chunk the note itself, and the title-match lookup pages through fixed-size pages of title results until an exact match is found or results are exhausted, regardless of page or page_size. Aliases: page_number.
57+
- **page** (integer, optional, default: 1) — Page of fallback-search results to use when the identifier does not resolve to a note directly (default: 1). A direct or exact-title match returns the note content — page/page_size never chunk the note itself, and the title-match lookup pages through fixed-size pages of title results until an exact match is found or results are exhausted, regardless of page or page_size. Aliases: page_number.
5758
- **page_size** (integer, optional, default: 10) — Number of fallback-search results per page (default: 10). When no match is found, this caps how many related-note suggestions are listed. Aliases: limit, per_page.
5859
- **output_format** (string, optional, default: "text") — "text" returns markdown content or guidance text. "json" returns a structured object with title/permalink/file_path/content/frontmatter.
59-
- **include_frontmatter** (boolean, optional, default: False) — When output_format="json", whether content should include the opening YAML frontmatter block; the parsed frontmatter object is returned either way. The CLI flag is --frontmatter (--include-frontmatter is a deprecated alias).
60+
- **include_frontmatter** (boolean, optional, default: False) — For unsliced JSON reads, include opening YAML in content; parsed frontmatter is returned either way. Explicit line ranges are never stripped. CLI: --frontmatter (--include-frontmatter is a deprecated alias).
61+
- **start_line** (integer | null, optional, default: None) — First document line to read (1-based, inclusive). Defaults to 1 when only end_line is given. Line scans count the full Markdown, including frontmatter, matching cat's default line coordinates.
62+
- **end_line** (integer | null, optional, default: None) — Last document line to read (inclusive); omitted means EOF. Out-of-file ranges return empty content; invalid/reversed ranges fail. With either bound, text output is numbered and JSON carries coordinates, has_more, and next_start_line/next_end_line. include_frontmatter does not strip an explicitly addressed range. Edits between calls may shift lines.
6063

6164
## MCP USAGE
6265

@@ -76,6 +79,27 @@ bm tool read-note "playground/demo-cli-stdin" --project manual
7679
# "frontmatter": {...}}
7780
```
7881

82+
## LINE SCANNING
83+
84+
Pass `start_line` and/or `end_line` to read a 1-based inclusive range. An omitted
85+
start means line 1; an omitted end means EOF. Coordinates count the full Markdown,
86+
including frontmatter, regardless of `include_frontmatter`. They match literal
87+
grep context and `cat` with its default `include_frontmatter=True`. Cat's explicit
88+
`include_frontmatter=False` line reads remain body-relative.
89+
90+
Text scans show numbered lines and the next bounded range. JSON scans return
91+
`content`, `start_line`, `end_line`, `total_lines`, `has_more`, `next_start_line`,
92+
and `next_end_line`; both next bounds are null at EOF. End bounds clamp to EOF;
93+
a start beyond EOF returns empty content with `end_line < start_line`. Empty
94+
notes have zero total lines. Bounds below 1 and reversed ranges are errors.
95+
Line endings normalize to LF, with CR/LF/CRLF counted consistently. A trailing
96+
newline does not create an extra line. Lines may move after an intervening edit.
97+
98+
```
99+
read_note("runbook", start_line=120, end_line=180)
100+
bm tool read-note runbook --start-line 120 --end-line 180 --plain
101+
```
102+
79103
## EXAMPLES
80104

81105
A miss returns suggestions, not an error (run against the dev project):
@@ -89,8 +113,8 @@ read_note("xyzzy definitely missing note", project="dev")
89113

90114
## GOTCHAS
91115

92-
- [gotcha] Text mode always includes frontmatter; include_frontmatter only controls the json content field #output
93-
- [gotcha] page/page_size never chunk the note — an exact match returns the full note regardless; they only page the miss-suggestion listing #pagination
116+
- [gotcha] Unsliced text mode includes frontmatter; include_frontmatter controls unsliced JSON content #output
117+
- [gotcha] page/page_size never chunk the note — use start_line/end_line for within-note scanning; page/page_size only page the miss-suggestion listing #pagination
94118
- [gotcha] The CLI identifier is a positional argument, unlike write-note where everything is a flag #cli-parity
95119
- [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
96120

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"""Bounded literal-match windows over the same document lines as note slices."""
2+
3+
from dataclasses import dataclass
4+
5+
from basic_memory.markdown.sections import document_lines
6+
7+
8+
@dataclass(frozen=True)
9+
class MatchWindow:
10+
start_line: int
11+
end_line: int
12+
match_lines: list[int]
13+
content: str
14+
15+
16+
@dataclass(frozen=True)
17+
class LiteralLineScan:
18+
total_lines: int
19+
match_count: int
20+
windows: list[MatchWindow]
21+
next_match_line: int | None
22+
23+
24+
def scan_literal_lines(
25+
content: str, pattern: str, *, context_lines: int, max_matches: int
26+
) -> LiteralLineScan:
27+
"""Find case-insensitive literal substrings; merge overlapping context windows.
28+
29+
Count all matching lines, but return context for only the first max_matches.
30+
The next omitted match points to a useful follow-up read without copying the
31+
rest of the note into the response. Inputs are validated by the tool boundary.
32+
"""
33+
lines = document_lines(content)
34+
needle = pattern.casefold()
35+
matches = [number for number, line in enumerate(lines, 1) if needle in line.casefold()]
36+
ranges: list[tuple[int, int, list[int]]] = []
37+
for number in matches[:max_matches]:
38+
first = max(1, number - context_lines)
39+
last = min(len(lines), number + context_lines)
40+
if ranges and first <= ranges[-1][1] + 1:
41+
previous_first, previous_last, previous_matches = ranges.pop()
42+
ranges.append((previous_first, max(previous_last, last), [*previous_matches, number]))
43+
else:
44+
ranges.append((first, last, [number]))
45+
return LiteralLineScan(
46+
total_lines=len(lines),
47+
match_count=len(matches),
48+
windows=[
49+
MatchWindow(first, last, numbers, "\n".join(lines[first - 1 : last]))
50+
for first, last, numbers in ranges
51+
],
52+
next_match_line=matches[max_matches] if len(matches) > max_matches else None,
53+
)
54+
55+
56+
def format_line_read(
57+
content: str,
58+
*,
59+
start_line: int,
60+
end_line: int,
61+
total_lines: int,
62+
) -> str:
63+
"""Render a slice with copyable coordinates and a bounded continuation hint."""
64+
header = f"Lines {start_line}-{end_line} of {total_lines} (document, including frontmatter)"
65+
# The slice has no terminal newline. Splitting on '\n' preserves a final
66+
# blank selected line; the coordinate range distinguishes it from EOF.
67+
numbered = (
68+
"\n".join(
69+
f"{number}: {line}"
70+
for number, line in zip(
71+
range(start_line, end_line + 1), content.split("\n"), strict=True
72+
)
73+
)
74+
if end_line >= start_line
75+
else ""
76+
)
77+
if end_line < total_lines:
78+
width = end_line - start_line + 1
79+
header += (
80+
f"; next: start_line={end_line + 1}, end_line={min(total_lines, end_line + width)}"
81+
)
82+
else:
83+
header += "; EOF"
84+
return f"{header}\n{numbered}" if numbered else header

src/basic_memory/markdown/sections.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,18 @@ def _ends_with_terminator(text: str) -> bool:
5050
return text.endswith(("\n", "\r"))
5151

5252

53+
def document_lines(text: str) -> list[str]:
54+
"""Count physical Markdown lines, without a phantom line after a final newline.
55+
56+
Unlike str.splitlines(), Unicode separators inside prose do not move the
57+
coordinates away from those used by the section and line-range reader.
58+
"""
59+
if not text:
60+
return []
61+
lines = _split_lines(text)
62+
return lines[:-1] if _ends_with_terminator(text) else lines
63+
64+
5365
@dataclass(frozen=True, slots=True)
5466
class MarkdownSection:
5567
"""One heading-bounded span of a note body, addressed by its heading path.

0 commit comments

Comments
 (0)