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
12 changes: 8 additions & 4 deletions benchmarks/src/basic_memory_benchmarks/agent_tasks/driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -390,18 +390,22 @@ def verify_seeded_recency(*, prefix: list[str], env: dict[str, str], project: Ta
result = run_command(
prefix
+ [
"tail",
"tool",
"project",
"recent",
"--project",
project.name,
"--timeframe",
"--since",
SEEDED_RECENCY_WINDOW,
"-n",
"--items",
"note",
"--page-size",
"100",
"--json",
],
env=env,
)
recent = {row["file_path"] for row in json.loads(result.stdout)}
recent = {row["file_path"] for row in json.loads(result.stdout)["results"]}
mismatch = recency_mismatch(recent, set(TIMESTAMPS))
if mismatch is not None:
raise SeedingError(
Expand Down
30 changes: 18 additions & 12 deletions benchmarks/tests/agent_tasks/test_driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,13 +101,13 @@ def _seeded_status(*, indexed: int, files: int, phase: str = "idle") -> str:
def _bm_stdout(command: list[str]) -> str:
"""What the stubbed bm prints for the seed guards.

`status --json` reports a fully indexed project and `tail --json` the aged
gold set, so the guards pass unless a test overrides one of them.
`status --json` reports a fully indexed project and `tool project recent
--json` the aged gold set, so the guards pass unless a test overrides one of them.
"""
if "status" in command:
return _seeded_status(indexed=25, files=25)
if "tail" in command:
return json.dumps([{"file_path": relpath} for relpath in TIMESTAMPS])
if "recent" in command:
return json.dumps({"results": [{"file_path": relpath} for relpath in TIMESTAMPS]})
return ""


Expand Down Expand Up @@ -314,15 +314,19 @@ def test_state_graded_task_reindexes_before_grading(
# seeded project (settle_index is stubbed, so these are the only status calls).
statuses = [cmd[cmd.index("status") :] for cmd in commands if "status" in cmd]
assert statuses == [["status", "--project", project, "--json", "--local"]]
tails = [cmd[cmd.index("tail") :] for cmd in commands if "tail" in cmd]
assert tails == [
recents = [cmd[cmd.index("tool") :] for cmd in commands if "recent" in cmd]
assert recents == [
[
"tail",
"tool",
"project",
"recent",
"--project",
project,
"--timeframe",
"--since",
driver.SEEDED_RECENCY_WINDOW,
"-n",
"--items",
"note",
"--page-size",
"100",
"--json",
]
Expand Down Expand Up @@ -704,7 +708,7 @@ def test_grouped_manifest_run_shares_projects_and_reports_groups(
# Grouped corpora carry no aged-mtime contract, so the recency guard is
# specific to the shipped corpus and must not run here; the index-count
# guard runs for every seeded project, once per persona.
assert not [cmd for cmd in commands if "tail" in cmd]
assert not [cmd for cmd in commands if "recent" in cmd]
# Slice from the verb: the uv prefix carries its own `--project <checkout>`.
statuses = sorted(cmd[cmd.index("status") + 2] for cmd in commands if "status" in cmd)
assert statuses == added_projects
Expand Down Expand Up @@ -1018,12 +1022,14 @@ def test_seeding_records_a_project_that_lost_its_aged_mtimes(
the files outside the gold set, and the run keeps the other projects' spend.
"""
_stub_bm(monkeypatch)
whole_corpus = json.dumps([{"file_path": relpath} for relpath in corpus_files(CORPUS_DIR)])
whole_corpus = json.dumps(
{"results": [{"file_path": relpath} for relpath in corpus_files(CORPUS_DIR)]}
)
monkeypatch.setattr(
driver,
"run_command",
lambda command, **kwargs: SimpleNamespace(
stdout=whole_corpus if "tail" in command else _bm_stdout(command), stderr=""
stdout=whole_corpus if "recent" in command else _bm_stdout(command), stderr=""
),
)
monkeypatch.chdir(tmp_path)
Expand Down
26 changes: 24 additions & 2 deletions docs/1.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,29 @@ pass `--json`. Without `--project`, commands use the configured default project.
| `--plain` | removed; the default output is the tool's text result |
| (new) | `move`, `list`, `find` |

The bundled manual page `tools-migration(7)` (`bm man tools-migration`) carries both tables.
The shell read verbs now run the same tools the same way: `bm cat` and `bm head` call
`bm_read`, `bm grep` calls `bm_search` in text mode, `bm ls` calls `bm_list`, and
`bm find` and `bm tree` call `bm_find`. Their output is the tool's text result, or its
JSON result with `--json`.

| Previous shell verb | 1.0 shell verb |
| --- | --- |
| `bm tail` | removed; use `bm tool project recent` |
| `--plain`, Rich tables, JSON when piped | removed; text result by default, `--json` for JSON |
| `--project-id UUID` | `--project UUID` |
| no `--project` with several projects: refused | the configured default project |
| `<project>/path` routes to that project by itself | pass `--project <project>`; a prefix naming another project is refused |
| `bm ls /` lists the projects | lists the default project's root; `bm project list` lists projects |
| `bm cat --lines` with `--no-frontmatter` counts body lines | `--lines` always counts the whole file, frontmatter included; `--frontmatter` applies to whole-note reads |
| `bm head --frontmatter/--no-frontmatter` | removed; `head` reads lines 1-N of the file |
| `bm grep -C/--context-lines N` | `bm grep -C/--lines N` |
| `bm grep ""` refused; `-C` with `--semantic` refused | follow `bm_search` |
| (new) `bm grep --path DIR` | search one folder |
| (new) `bm ls --glob`, `--sort` | `bm_list`'s file-name glob and order |
| `ls`/`find`/`tree` `--page-size` up to 200 | 1-100; `tree` defaults to 100 |
| `bm find --fields` hit rows rendered as a table | `bm_find`'s text result |

The bundled manual page `tools-migration(7)` (`bm man tools-migration`) carries these tables.

| Previous MCP tool | 1.0 replacement |
| --- | --- |
Expand All @@ -184,7 +206,7 @@ The bundled manual page `tools-migration(7)` (`bm man tools-migration`) carries
| `list_directory`, `ls` | `bm_list` for one level; `bm_find` for recursive inventory |
| `find` | `bm_find` |
| `recent_activity` | `bm_project(command="recent")` |
| `tail` | `bm_project(command="recent")`; map `timeframe` to `since` and `lines` to `page_size`. Recent JSON includes `created_at`, not `updated_at`; shell users needing the last-change timestamp can use `bm tail --json`. |
| `tail` | `bm_project(command="recent")`; map `timeframe` to `since` and `lines` to `page_size`. Recent JSON includes `created_at`, not `updated_at`. The `bm tail` shell command is removed; use `bm tool project recent`. |
| `list_memory_projects` | `bm_project(command="list")` |
| `list_workspaces` | `bm_project(command="list")` groups projects by workspace, so it omits a workspace with no projects; `bm cloud workspace list` in a shell lists every workspace |
| `create_memory_project` | `bm_project(command="create", name=..., path=...)` |
Expand Down
25 changes: 15 additions & 10 deletions plugins/claude-code/skills/bm-setup/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,16 +232,21 @@ nudge — the config's presence is the signal that setup has run.
### 4. Smoke-test the wiring
Before you close, prove recall actually resolves — this catches a misnamed project,
missing cloud credentials, or a ref that doesn't route, while the user is still here
to fix it. Run the same structured query the SessionStart hook runs, via the CLI it
uses (`basic-memory` / `bm` / `uvx --prerelease=allow basic-memory`):

- **Primary:** `… tool search --type schema --page-size 10` against
`primaryProject` — use `--project-id <uuid>` for a UUID, `--project <ref>`
otherwise. It should return the schemas you just seeded. For a coding setup,
confirm the `Coding Session` schema is present.
- **One shared project** (only if `secondaryProjects` is non-empty): a
`--type decision --status open` query against the first ref. It just needs to
return *cleanly* — `0 results` is fine; an **error** means the ref doesn't route.
to fix it. Use the CLI the hook uses (`basic-memory` / `bm` /
`uvx --prerelease=allow basic-memory`). `--project` takes a project name,
`workspace/project`, or a project id.

- **Primary:** run the open-decision query the SessionStart hook runs against
`primaryProject`:
`… tool search --note-types decision --filters '{"status": "open"}' --page-size 5 --project <primaryProject>`.
It just needs to return *cleanly* — `0 results` is fine; an **error** means the
ref doesn't route.
- **Seeded schemas:** `… tool search --note-types schema --page-size 10 --project <primaryProject>`
should return the schemas you just seeded. For a coding setup, confirm the
`Coding Session` schema is present.
- **One shared project** (only if `secondaryProjects` is non-empty): the same
open-decision query with `--project <first ref>`. Again `0 results` is fine; an
**error** means the ref doesn't route.
- **Hook health:** run
`basic-memory hook status --harness claude --project-dir <project-root>` with
the first available `basic-memory`, `bm`, or `uvx --prerelease=allow basic-memory` launcher. It
Expand Down
2 changes: 1 addition & 1 deletion scripts/update_man_pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@
# fast path in cli/main.py skips these imports, so a script that walks the tree
# must import them explicitly or every subcommand comes back empty.
from basic_memory.cli.app import app
import basic_memory.cli.commands.posix # noqa: F401 (registers cat/grep/ls/find/tail/head/tree)
import basic_memory.cli.commands.posix # noqa: F401 (registers cat/grep/ls/find/head/tree)
import basic_memory.cli.commands.okf # noqa: F401 (registers OKF commands)
import basic_memory.cli.commands.man # noqa: F401 (registers `bm man apropos`)

Expand Down
77 changes: 45 additions & 32 deletions skills/memory-literary-analysis/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ answer, exit 0, no warning — so learn it here rather than from a graph you tho
audited:

- **`find` pages, and the default page is 10.** Any query whose answer is "all N chapters"
needs `--page-size 200` (the maximum) — see [Coverage Checks](#coverage-checks).
needs `--page-size 100` (the maximum) and paging — see [Coverage Checks](#coverage-checks).
- **`--name` cannot combine with `--meta`.** The metadata search has no filename glob. Scope a
`--meta` query with the positional path instead: `find /characters --meta
'note_type=character'`. That path is matched on a directory boundary against the *file path*
Expand Down Expand Up @@ -361,14 +361,14 @@ re-reading the whole book into context:

```bash
grep -n '^CHAPTER ' ~/basic-memory/moby-dick/moby-dick.txt # heading -> line number
bm cat moby-dick/moby-dick.txt --lines 4200-4890 --plain # one chapter, not the whole text
bm cat moby-dick.txt --lines 4200-4890 --project moby-dick # one chapter, not the whole text
```

`grep -n` here is the shell's grep on a filesystem path (this is the map-building step, and
it needs the file). `bm cat` then takes the *note identifier* and returns exactly that slice plus a
`lines 4200-4890 of N` footer. Spell the identifier project-qualified
(`<work>/<work>.txt`) so it routes to the right project. `bm head moby-dick/moby-dick.txt -n 40` is the cheap
way to eyeball the heading format before writing the grep pattern.
it needs the file). `bm cat` then takes the *note identifier* and returns exactly that slice,
numbered, under a `Lines 4200-4890 of N` header. Pass `--project <work>` so it reads from the
right project. `bm head moby-dick.txt -n 40 --project moby-dick` is the cheap way to eyeball
the heading format before writing the grep pattern.

Store the map in the project (a note or a small JSON file) so later batches — and a resumed
run after context compaction — do not have to rediscover it. On a long work this is the
Expand Down Expand Up @@ -493,17 +493,17 @@ After all chapters are processed:
Do not re-read every note to decide what is thin. Query for it:

```bash
bm find --meta 'note_type=chapter' --fields chapter_number,pov,setting --page-size 200
bm find --meta 'note_type=character' --fields role,status --page-size 200 # who is still a stub
bm find --meta 'chapter_number>100' --fields pov --page-size 200 # late-book POV drift
bm find --meta 'note_type=chapter' --fields chapter_number,pov,setting --page-size 100
bm find --meta 'note_type=character' --fields role,status --page-size 100 # who is still a stub
bm find --meta 'chapter_number>100' --fields pov --page-size 100 # late-book POV drift
```

A field a note never set comes back as a blank cell (`null` under `--json`), so rows with
A field a note never set comes back as `None` (`null` under `--json`), so rows with
blanks are the work queue. This turns "audit the graph" from a read of every note into one
call per question.

`bm_write` snake-cases `note_type` (`Chapter` is stored as `chapter`), and `--meta`
type predicates are normalized the same way, so either spelling matches. `--page-size 200`
type predicates are normalized the same way, so either spelling matches. `--page-size 100`
is not decoration: without it these return the first 10 rows and the work queue looks ten
items long.

Expand Down Expand Up @@ -582,27 +582,25 @@ Fix issues found — common fixes:
Schema validation proves notes match their shape. These prove the graph is *complete*:

```bash
bm find --meta 'note_type=chapter' --fields chapter_number --page-size 200 # every chapter present?
bm find --meta 'note_type=chapter' --fields pov,setting --page-size 200 # missing context?
bm find /characters --meta 'note_type=character' --fields role --page-size 200 # inventory vs. seed list
bm find --meta 'note_type=chapter' --fields chapter_number --page-size 100 # every chapter present?
bm find --meta 'note_type=chapter' --fields pov,setting --page-size 100 # missing context?
bm find /characters --meta 'note_type=character' --fields role --page-size 100 # inventory vs. seed list
```

**A coverage check that pages is not a coverage check.** `bm find` defaults to
`--page-size 10`, so the un-sized form of the first query "proves" a 138-chapter work has 10
chapters. 200 is the maximum page size; past that, iterate with `--page 2`, `--page 3`, … .
chapters. 100 is the maximum page size; past that, iterate with `--page 2`, `--page 3`, … .
Scope a `--meta` query with the positional path (`/characters`), never `--name` — the two
options are mutually exclusive, because the metadata search has no filename glob. The
positional path scopes by the *file path* a note is indexed under, matched on a directory
boundary: `/characters` admits `characters/major/ahab.md` but never `characters-cut/`. It is
not a permalink match, so a note that pins its own `permalink:` is still found where its file
lives.

Read the count off the footer, not off the rows you can see. In a terminal every `find`
result reports `page 1 • total 138`, and appends `• more available (--page)` when the page
truncated the answer — that suffix appearing is the check *failing*, whatever the visible
rows say. **The footer is a TTY feature.** Piped output without `--plain` is JSON, which
carries `total` and `has_more`; with `--plain` the rows go to stdout and a has-more notice
goes to stderr. For scripts, read `has_more` from `--json`.
Read the count off the result, not off the rows you can see. The text result opens with the
total (`138 notes under '/' match:`) and ends with `More results available.` when the page
truncated the answer — that line appearing is the check *failing*, whatever the visible rows
say. For scripts, read `total` and `has_more` from `--json`.

For the sequence gap — the failure that a count alone cannot catch — take the numbers from
`--json`, which carries `total`, `total_is_exact`, and `has_more`:
Expand All @@ -611,7 +609,7 @@ For the sequence gap — the failure that a count alone cannot catch — take th
expected=138; page=1; rows='[]'
while :; do
resp=$(bm find --meta 'note_type=chapter' --fields chapter_number \
--page-size 200 --page $page --project <work> --json)
--page-size 100 --page $page --project <work> --json)
rows=$(jq -n --argjson acc "$rows" --argjson r "$resp" \
'$acc + [$r.results[] | {title, n: .fields.chapter_number}]')
[ "$(jq -r '.has_more' <<<"$resp")" = "true" ] || break
Expand All @@ -627,9 +625,9 @@ jq -n --argjson rows "$rows" --argjson expected "$expected" '
out_of_range: ($n | map(select(. < 1 or . > $expected)) | unique) }'
```

The loop is not ceremony. `--page-size` caps at 200, so a single call cannot inventory a work
with more than 200 chapters — and rerunning it with `--page 2` *replaces* the numbers rather
than accumulating them, which reports chapters 1-200 as missing on a corpus that is complete.
The loop is not ceremony. `--page-size` caps at 100, so a single call cannot inventory a work
with more than 100 chapters — and rerunning it with `--page 2` *replaces* the numbers rather
than accumulating them, which reports chapters 1-100 as missing on a corpus that is complete.
Walk until `has_more` is false and check the union.

`--fields` returns YAML-native types, so `chapter_number: 63` comes back as `63`. The
Expand Down Expand Up @@ -679,13 +677,27 @@ orphans behind has made the graph worse.
With the graph complete, traverse it to find what the chapter-by-chapter pass could not see:

```bash
bm grep "features [[" --page-size 200 --project <work> --json # every chapter's cast, one call
bm find --meta 'note_type=theme' --fields prevalence --page-size 200 # thematic weight
bm find --meta 'note_type=theme' --fields prevalence --page-size 100 # thematic weight
bm grep "doubloon" --page-size 100 --project <work> # every mention of a symbol
```

"Which characters share the most chapters" is the first line plus a local parse of each
row's `content` for `features [[...]]` — one call instead of a read per chapter. Do not
"Which characters share the most chapters" is a search for `features [[` plus a local parse
of each row's `content` for `features [[...]]` — one call per 100 chapters instead of a read
per chapter. Page until `has_more` is false, as the coverage check does; a 138-chapter work
needs two pages:

```bash
page=1; rows='[]'
while :; do
resp=$(bm grep "features [[" --page-size 100 --page $page --project <work> --json)
rows=$(jq -n --argjson acc "$rows" --argjson r "$resp" '$acc + $r.results')
[ "$(jq -r '.has_more' <<<"$resp")" = "true" ] || break
page=$((page + 1))
done
jq 'length' <<<"$rows" # one row per chapter that lists its cast
```

Do not
reach for `bm tool context 'memory://characters/major/*'` here: a
wildcard context is capped at 100 related rows across all primaries and returns one primary
row per indexed observation, so it neither enumerates the cast nor walks the web.
Expand All @@ -699,14 +711,15 @@ the default page would have dropped. `--semantic` ranks by meaning instead, whic
"what is this about?" rather than "where does this word appear?".

Two more facts about `grep` rows. Matching is case-insensitive and note-level: a hit is a
note, not a line, and there is no `-n` or context. And each row's `content` is capped at
note, not a line, unless you pass `-C N` for matching lines with context. And each row's
`content` is capped at
4000 characters; `content_truncated: true` marks a cut row, so a long note's tail (such as a
final chapter's `follows`/`precedes` relations) may be missing from a grep-driven scan.
When a parse depends on the end of a note, `cat` that note.

`--page-size` raises the ceiling, it does not remove it. A symbol in a long work can exceed
even 100, so check whether the last page was full and walk `--page 2`, `--page 3` until it is
not. A truncated symbol search fails the same silent way as an unpaginated `find`: a plausible
even 100, so walk `--page 2`, `--page 3` until `has_more` is false, as in the cast loop
above. A truncated symbol search fails the same silent way as an unpaginated `find`: a plausible
answer, exit 0, and no sign that the tail is missing.

And `grep` searches **your notes, not the source**. The `<work>.txt` is indexed as an entity,
Expand Down
1 change: 0 additions & 1 deletion src/basic_memory/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ def version_callback(value: bool) -> None:
"grep",
"ls",
"find",
"tail",
"head",
"tree",
}
Expand Down
Loading
Loading