Skip to content

Commit ae65794

Browse files
authored
Merge branch 'main' into codex/bossbot-image-smoke
2 parents 02ddb25 + 49041a5 commit ae65794

15 files changed

Lines changed: 572 additions & 69 deletions

File tree

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ ENV/
4949
/docs/.obsidian/
5050
/examples/.obsidian/
5151
/examples/.basic-memory/
52-
52+
/docs/assets
5353

5454
# claude action
5555
claude-output

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -216,14 +216,14 @@ Source: [`plugins/claude-code`](plugins/claude-code).
216216
### Shared skills
217217

218218
Framework-agnostic `SKILL.md` files live in [`skills/`](skills). If your
219-
Skills CLI supports subpath installs:
219+
Skills CLI supports repository subdirectory sources:
220220

221221
```bash
222-
npx skills add basicmachines-co/basic-memory --path skills
222+
npx skills add basicmachines-co/basic-memory/skills
223223
```
224224

225-
If it does not, copy the `memory-*` directories from `skills/` into your
226-
agent's skills directory as a temporary Phase 1 install path.
225+
If your installed Skills CLI cannot load that source, update the CLI or copy
226+
the `memory-*` directories from `skills/` into your agent's skills directory.
227227

228228
### Hermes
229229

integrations/openclaw/MEMORY_TASK_FLOW.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ This plugin ships with workflow-oriented skills that are automatically loaded wh
150150
No manual installation needed. To update skills or install new ones as they become available:
151151
152152
```bash
153-
npx skills add basicmachines-co/basic-memory --path skills --agent openclaw
153+
npx skills add basicmachines-co/basic-memory/skills --agent openclaw
154154
```
155155

156156
See the canonical source at [`basic-memory/skills`](../../skills).

plugins/claude-code/CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ Memory's durable graph**, rather than a memory layer of its own. See
2424
configures the project for the plugin: maps it to a Basic Memory project (picking
2525
an existing one or creating a new one), seeds the `session`/`decision`/`task`
2626
schemas into the project, installs the shared `memory-*` skills via
27-
`npx skills add basicmachines-co/basic-memory --path skills` (the plugin doesn't
27+
`npx skills add basicmachines-co/basic-memory/skills` (the plugin doesn't
2828
vendor its own copies — `skills/` is the single source of truth, shared with
2929
OpenClaw), optionally learns the project's placement conventions, and enables the
3030
capture reflexes. Writes the `basicMemory` block to

plugins/claude-code/skills/bm-setup/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ git ls-files skills/ | grep -q memory- && echo "source repo - skip install"
156156
Otherwise, run from the project root:
157157

158158
```
159-
npx skills add basicmachines-co/basic-memory --path skills
159+
npx skills add basicmachines-co/basic-memory/skills
160160
```
161161

162162
This installs the canonical `memory-*` skills into the user's skills directory — the

skills/DEVELOPMENT.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,13 @@ Users can install or update skills with the [Skills CLI](https://github.com/verc
5454

5555
```bash
5656
# Install all skills
57-
npx skills add basicmachines-co/basic-memory --path skills
57+
npx skills add basicmachines-co/basic-memory/skills
5858

5959
# Install a specific skill
60-
npx skills add basicmachines-co/basic-memory --path skills --skill memory-tasks
60+
npx skills add basicmachines-co/basic-memory/skills --skill memory-tasks
6161

6262
# Install for a specific agent
63-
npx skills add basicmachines-co/basic-memory --path skills --agent claude
63+
npx skills add basicmachines-co/basic-memory/skills --agent claude
6464
```
6565

6666
## Adding a New Skill

skills/README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,16 +47,16 @@ Install or update skills using the [Skills CLI](https://github.com/vercel-labs/s
4747

4848
```bash
4949
# Install all skills
50-
npx skills add basicmachines-co/basic-memory --path skills
50+
npx skills add basicmachines-co/basic-memory/skills
5151

5252
# Install a specific skill
53-
npx skills add basicmachines-co/basic-memory --path skills --skill memory-tasks
53+
npx skills add basicmachines-co/basic-memory/skills --skill memory-tasks
5454

5555
# Install all skills for a specific agent
56-
npx skills add basicmachines-co/basic-memory --path skills --agent claude
56+
npx skills add basicmachines-co/basic-memory/skills --agent claude
5757

5858
# List available skills without installing
59-
npx skills add basicmachines-co/basic-memory --path skills --list
59+
npx skills add basicmachines-co/basic-memory/skills --list
6060

6161
# Check for updates
6262
npx skills check
@@ -67,7 +67,7 @@ npx skills update
6767

6868
Skills are installed to your agent's skills directory (e.g., `~/.claude/skills/` for Claude Code global, or `.claude/skills/` for project-scoped).
6969

70-
If your installed Skills CLI does not support `--path`, copy the `memory-*` directories manually for now. Phase 2 will add a first-class Codex/package install path.
70+
If your installed Skills CLI cannot load `basicmachines-co/basic-memory/skills`, update the CLI or copy the `memory-*` directories manually.
7171

7272
### Claude Desktop (claude.ai)
7373

src/basic_memory/cli/commands/cloud/core_commands.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
)
2929
from basic_memory.cli.commands.cloud.rclone_config import (
3030
configure_rclone_remote,
31+
rclone_remote_exists,
3132
remote_name_for_workspace,
3233
)
3334
from basic_memory.cli.commands.cloud.rclone_installer import (
@@ -207,6 +208,11 @@ def setup(
207208
help="Set up sync for a specific workspace (slug, name, or tenant_id). "
208209
"Omit for your default workspace.",
209210
),
211+
force: bool = typer.Option(
212+
False,
213+
"--force",
214+
help="Reconfigure an rclone remote that already exists (mints new credentials).",
215+
),
210216
) -> None:
211217
"""Set up cloud sync by installing rclone and configuring credentials.
212218
@@ -239,6 +245,20 @@ def setup(
239245
workspace_id = None # default tenant
240246
remote_name = remote_name_for_workspace(None, is_default=True)
241247

248+
# Trigger: the target rclone remote already exists.
249+
# Why: re-running setup mints new credentials and overwrites the remote,
250+
# which would silently repoint it — e.g. clobbering the shared
251+
# basic-memory-cloud remote that served another tenant. Checked BEFORE
252+
# minting so an abort wastes no credentials.
253+
# Outcome: stop unless the user explicitly opts in with --force.
254+
if rclone_remote_exists(remote_name) and not force:
255+
console.print(f"[red]rclone remote '{remote_name}' is already configured.[/red]")
256+
console.print(
257+
"Re-running setup mints new credentials and overwrites it. "
258+
"Pass --force to reconfigure."
259+
)
260+
raise typer.Exit(1)
261+
242262
# Step 2: Get tenant info (scoped to the target workspace when given)
243263
console.print("\n[blue]Step 2: Getting tenant information...[/blue]")
244264
tenant_info = run_with_cleanup(get_mount_info(workspace_id=workspace_id))

src/basic_memory/mcp/tools/read_note.py

Lines changed: 113 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
"""Read note tool for Basic Memory MCP server."""
22

33
from textwrap import dedent
4-
from typing import Optional, Literal, cast
4+
from typing import Annotated, Optional, Literal, cast
55

66
import logfire
77
import yaml
88

99
from loguru import logger
1010
from fastmcp import Context
11+
from pydantic import AliasChoices, Field
1112

1213
from basic_memory.config import ConfigManager
1314
from basic_memory.mcp.project_context import (
@@ -20,6 +21,17 @@
2021
from basic_memory.schemas.memory import memory_url_path
2122
from basic_memory.utils import validate_project_path
2223

24+
# The title-match fallback exists to find THE note by exact title, so it scans
25+
# fixed-size pages of title results instead of the caller's page/page_size
26+
# (which apply only to the text-search suggestion listing).
27+
_TITLE_LOOKUP_PAGE_SIZE = 10
28+
29+
# Hard safety cap on title-lookup pages. The loop normally stops as soon as an
30+
# exact match is found or results run out (has_more=False); the cap only bounds
31+
# pathological knowledge bases where hundreds of fuzzy titles contain the
32+
# queried phrase. Exhausting the cap falls through to the suggestion behavior.
33+
_TITLE_LOOKUP_MAX_PAGES = 10
34+
2335

2436
def _is_exact_title_match(identifier: str, title: str) -> bool:
2537
"""Return True when identifier exactly matches a title (case-insensitive)."""
@@ -71,6 +83,21 @@ async def read_note(
7183
identifier: str,
7284
project: Optional[str] = None,
7385
project_id: Optional[str] = None,
86+
# Accept common pagination aliases models reach for from training data
87+
# (page_number/limit/per_page), matching the sibling navigation tools
88+
# (search_notes, build_context, recent_activity). The schema advertises
89+
# only the canonical names; aliases are silently mapped at validation time.
90+
# `offset` is intentionally NOT aliased: offset is item-indexed (skip N
91+
# items) while page is a 1-indexed page-number, so direct aliasing would
92+
# return the wrong slice.
93+
page: Annotated[
94+
int,
95+
Field(default=1, validation_alias=AliasChoices("page", "page_number")),
96+
] = 1,
97+
page_size: Annotated[
98+
int,
99+
Field(default=10, validation_alias=AliasChoices("page_size", "limit", "per_page")),
100+
] = 10,
74101
output_format: Literal["text", "json"] = "text",
75102
include_frontmatter: bool = False,
76103
context: Context | None = None,
@@ -99,6 +126,14 @@ async def read_note(
99126
workspaces. Takes precedence over `project`. Get from list_memory_projects().
100127
identifier: The title or permalink of the note to read
101128
Can be a full memory:// URL, a permalink, a title, or search text
129+
page: Page of fallback-search results to use when the identifier does not
130+
resolve to a note directly (default: 1). A direct or exact-title match
131+
always returns the full note content — page/page_size never chunk the
132+
note itself, and the title-match lookup pages through fixed-size pages
133+
of title results until an exact match is found or results are
134+
exhausted, regardless of page or page_size.
135+
page_size: Number of fallback-search results per page (default: 10). When no
136+
match is found, this caps how many related-note suggestions are listed.
102137
output_format: "text" returns markdown content or guidance text.
103138
"json" returns a structured object with title/permalink/file_path/content/frontmatter.
104139
include_frontmatter: When output_format="json", whether content should include the
@@ -122,6 +157,9 @@ async def read_note(
122157
# Read recent meeting notes
123158
read_note("team-docs", "Weekly Standup")
124159
160+
# Page through fallback-search suggestions when nothing matches directly
161+
read_note("unknown topic", page=2, page_size=5)
162+
125163
Raises:
126164
HTTPError: If project doesn't exist or is inaccessible
127165
SecurityError: If identifier attempts path traversal
@@ -130,6 +168,15 @@ async def read_note(
130168
If the exact note isn't found, this tool provides helpful suggestions
131169
including related notes, search commands, and note creation templates.
132170
"""
171+
# Trigger: page < 1 or page_size < 1 (e.g. page_size=0 or negative).
172+
# Why: both flow into the fallback search's server-side slicing, where
173+
# non-positive values produce empty result pages with unreachable
174+
# pagination. Fail fast, matching search_notes/build_context.
175+
if page < 1:
176+
raise ValueError(f"page must be >= 1, got {page}")
177+
if page_size < 1:
178+
raise ValueError(f"page_size must be >= 1, got {page_size}")
179+
133180
# Detect project from a memory URL or permalink prefix before routing.
134181
# project_id routes by external UUID, so it bypasses URL discovery entirely.
135182
if project is None and project_id is None:
@@ -147,6 +194,8 @@ async def read_note(
147194
tool_name="read_note",
148195
requested_project=project,
149196
requested_project_id=project_id,
197+
page=page,
198+
page_size=page_size,
150199
output_format=output_format,
151200
include_frontmatter=include_frontmatter,
152201
):
@@ -245,7 +294,7 @@ def _search_results(payload: object) -> list[dict[str, object]]:
245294
]
246295

247296
async def _search_candidates(
248-
identifier_text: str, *, title_only: bool
297+
identifier_text: str, *, title_only: bool, lookup_page: int = 1
249298
) -> dict[str, object]:
250299
# Trigger: direct entity resolution failed for the caller's identifier.
251300
# Why: search_notes applies the same memory:// normalization and tool-level
@@ -256,11 +305,24 @@ async def _search_candidates(
256305
# Without this, project names that collide across workspaces could re-resolve
257306
# to a different tenant via the default-workspace fallback (CLI/context=None).
258307
search_type = "title" if title_only else "text"
308+
# Trigger: title_only — the title search exists to find THE note by
309+
# exact title, not to page through suggestions.
310+
# Why: paginating it by the caller's page would skip an exact match
311+
# sitting on page 1 (read_note("Exact Title", page=2)), and a
312+
# small caller page_size could let a higher-ranked fuzzy title
313+
# displace the exact match out of the lookup window
314+
# (read_note("Foo Bar", page_size=1) when "Foo Bar Foo Bar"
315+
# ranks first) — both returning suggestions instead of the note.
316+
# Outcome: title lookup uses its own lookup_page with a fixed lookup
317+
# size, walked by the caller below; caller page/page_size
318+
# apply only to the text-search suggestion listing.
259319
response = await search_notes(
260320
project=active_project.name,
261321
project_id=active_project.external_id,
262322
query=identifier_text,
263323
search_type=search_type,
324+
page=lookup_page if title_only else page,
325+
page_size=_TITLE_LOOKUP_PAGE_SIZE if title_only else page_size,
264326
output_format="json",
265327
context=context,
266328
)
@@ -297,12 +359,24 @@ def _result_file_path(item: dict[str, object]) -> Optional[str]:
297359
logger.info(f"Direct lookup failed for '{entity_path}': {e}")
298360
# Continue to fallback methods
299361

300-
# Fallback 1: Try title search via API
362+
# Fallback 1: Try title search via API, walking fixed-size pages of
363+
# title results until an exact match is found or results run out.
364+
# A single page is not enough: when more than _TITLE_LOOKUP_PAGE_SIZE
365+
# higher-ranked fuzzy titles contain the queried phrase, the exact
366+
# title lands on a later page and a one-page lookup would miss it.
301367
logger.info(f"Search title for: {identifier}")
302-
title_results = await _search_candidates(identifier, title_only=True)
303-
304-
title_candidates = _search_results(title_results)
305-
if title_candidates:
368+
result: dict[str, object] | None = None
369+
for lookup_page in range(1, _TITLE_LOOKUP_MAX_PAGES + 1):
370+
title_results = await _search_candidates(
371+
identifier, title_only=True, lookup_page=lookup_page
372+
)
373+
title_candidates = _search_results(title_results)
374+
if not title_candidates:
375+
logger.info(
376+
f"No results in title search for: {identifier} "
377+
f"in project {active_project.name}"
378+
)
379+
break
306380
# Trigger: direct resolution failed and title search returned candidates.
307381
# Why: avoid returning unrelated notes when search yields only fuzzy matches.
308382
# Outcome: fetch content only when a true exact title match exists.
@@ -314,33 +388,37 @@ def _result_file_path(item: dict[str, object]) -> Optional[str]:
314388
),
315389
None,
316390
)
317-
if not result:
391+
if result is not None:
392+
break
393+
# Trigger: this page held only fuzzy titles and the server reports
394+
# no further pages (has_more is False or absent).
395+
# Why: continuing past the last page would issue empty lookups.
396+
# Outcome: give up on the title fallback and try text search below.
397+
if title_results.get("has_more") is not True:
318398
logger.info(f"No exact title match found for: {identifier}")
319-
elif _result_permalink(result):
320-
try:
321-
# Resolve the permalink to entity ID
322-
entity_id = await knowledge_client.resolve_entity(
323-
_result_permalink(result) or "", strict=True
324-
)
399+
break
400+
401+
if result is not None and _result_permalink(result):
402+
try:
403+
# Resolve the permalink to entity ID
404+
entity_id = await knowledge_client.resolve_entity(
405+
_result_permalink(result) or "", strict=True
406+
)
407+
408+
# Fetch content using the entity ID
409+
response = await resource_client.read(entity_id)
325410

326-
# Fetch content using the entity ID
327-
response = await resource_client.read(entity_id)
328-
329-
if response.status_code == 200:
330-
logger.info(
331-
f"Found note by exact title search: {_result_permalink(result)}"
332-
)
333-
if output_format == "json":
334-
return await _read_json_payload(entity_id)
335-
return response.text
336-
except Exception as e: # pragma: no cover
411+
if response.status_code == 200:
337412
logger.info(
338-
f"Failed to fetch content for found title match {_result_permalink(result)}: {e}"
413+
f"Found note by exact title search: {_result_permalink(result)}"
339414
)
340-
else:
341-
logger.info(
342-
f"No results in title search for: {identifier} in project {active_project.name}"
343-
)
415+
if output_format == "json":
416+
return await _read_json_payload(entity_id)
417+
return response.text
418+
except Exception as e: # pragma: no cover
419+
logger.info(
420+
f"Failed to fetch content for found title match {_result_permalink(result)}: {e}"
421+
)
344422

345423
# Fallback 2: Text search as a last resort
346424
logger.info(f"Title search failed, trying text search for: {identifier}")
@@ -352,6 +430,9 @@ def _result_file_path(item: dict[str, object]) -> Optional[str]:
352430
if output_format == "json":
353431
return _empty_json_payload()
354432
return format_not_found_message(active_project.name, identifier)
433+
# The fallback search is paginated server-side to page_size, so list
434+
# the whole returned page instead of a hardcoded cap — otherwise the
435+
# caller's page_size would be silently ignored past the cap.
355436
if output_format == "json":
356437
payload = _empty_json_payload()
357438
payload["related_results"] = [
@@ -360,10 +441,10 @@ def _result_file_path(item: dict[str, object]) -> Optional[str]:
360441
"permalink": _result_permalink(result),
361442
"file_path": _result_file_path(result),
362443
}
363-
for result in text_candidates[:5]
444+
for result in text_candidates
364445
]
365446
return payload
366-
return format_related_results(active_project.name, identifier, text_candidates[:5])
447+
return format_related_results(active_project.name, identifier, text_candidates)
367448

368449

369450
def format_not_found_message(project: str | None, identifier: str) -> str:

0 commit comments

Comments
 (0)