Compare commits

..

248 Commits

Author SHA1 Message Date
Drew Cain 8476a16757 fix(mcp): fix tool-docs generator and regenerate artifact
Fixes all reviewer-blocking and minor issues from the initial review of
the feat/404-tool-docs-artifact implementation.

BLOCKING fixes:
- Replace the naïve Annotated[...] regex with a bracket-aware character
  scan (_unwrap_annotated) so types like Dict[str, Any] | None and
  List[str] | None are extracted correctly instead of being truncated or
  emitting internal validator details (BeforeValidator, AliasChoices, etc.)
- Escape unescaped | characters in table cells (_escape_table_cell) so
  union types like list[str] | str | None render correctly in GitHub
  Flavoured Markdown tables instead of creating spurious extra columns

Minor fixes:
- Fix _extract_examples_section to detect the next top-level section
  (Raises:, Returns:, etc.) and stop there; previously the Raises: block
  was fenced inside the Python code example
- Add Description column data: _format_args_section now returns a parsed
  dict of arg descriptions (_parse_args_block) which populates the table
  Description column; fix indent-detection so the first arg line is not
  stripped of its leading spaces by .strip()
- Add deduplication: drop the docstring first line when normalised it is
  identical to the decorator description
- Demote ### sub-headers in docstring bodies to #### so they don't
  pollute GitHub's outline sidebar at the same level as tool headings
- Fix TOC anchor collision: the `search` tool anchor would resolve to the
  same #search as the ## Search group header; _tool_anchor detects
  collisions and appends -tool; an HTML <a id> anchor is injected in
  render_tool_section so the link resolves correctly
- Add comment explaining why ui_sdk.py is excluded from TOOL_FILES
- Format script with ruff (was failing CI ruff format --check gate)

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Drew Cain <groksrc@gmail.com>
2026-06-11 01:27:21 -05:00
Drew Cain cacfdc4adc docs(mcp): add tool reference generator and generated artifact
Adds scripts/generate_tool_docs.py which introspects all MCP tool
definitions via AST (no import side-effects), extracts names,
decorator descriptions, docstrings, parameters, and examples, then
emits a deterministic sorted markdown reference to docs/mcp-tools.md.

Also commits the initial generated artifact (23 tools, 1345 lines).
The file header explains it is auto-generated and shows the regenerate
command. Running the script twice produces a zero diff.

Closes #404

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Drew Cain <groksrc@gmail.com>
2026-06-11 01:11:28 -05:00
Drew Cain 8e7825ba01 chore: update version to 0.22.0 for v0.22.0 release
Signed-off-by: Drew Cain <groksrc@gmail.com>
2026-06-11 00:53:13 -05:00
Drew Cain dc29ba2a00 docs(core): add v0.22.0 changelog entry
Signed-off-by: Drew Cain <groksrc@gmail.com>
2026-06-11 00:53:13 -05:00
Drew Cain fe9b2e9c95 Add glama.json to claim the Glama MCP directory listing (#953) 2026-06-11 00:02:21 -05:00
Paul Hernandez 650f88a2c5 docs(cli): document personal-vs-team cloud sync semantics (#947)
Folds in the intent of #857 reconciled with the post-#920 push/pull reality. Closes #851.

Co-authored-by: Drew Cain <groksrc@users.noreply.github.com>
Signed-off-by: phernandez <paul@basicmemory.com>
2026-06-10 14:50:28 -05:00
Paul Hernandez 2f7ef136de perf(ci): lean CI — testmon-select branches, shard postgres, drop depot, remove bossbot (#945)
Branch builds testmon-select against cached baselines; 3-shard Postgres on 3.14 only; free runners; 120s hang ceiling; non-code changes skip the matrix; BM Bossbot and infographic machinery removed.

Signed-off-by: phernandez <paul@basicmemory.com>
2026-06-10 14:21:57 -05:00
Drew Cain 0a3a6bbd96 test(mcp): integration coverage for colliding observation permalinks (#926)
Refs #909. Integration coverage over the #931 fix.

Signed-off-by: phernandez <paul@basicmemory.com>
2026-06-10 14:05:05 -05:00
Drew Cain 7bb7664fae fix(core): use Observation.permalink in build_context to match the search index (#946)
Closes #929

Signed-off-by: phernandez <paul@basicmemory.com>
2026-06-10 14:05:02 -05:00
Paul Hernandez db578ccfdb fix(mcp): recover edit_note when file exists on disk but is not indexed (#934)
Closes #581

Signed-off-by: phernandez <paul@basicmemory.com>
2026-06-10 14:04:59 -05:00
Paul Hernandez df485aa5a4 fix(mcp): tighten search_notes tags input and normalize for direct callers (#941)
Refs #910. Follow-up to #932.

Signed-off-by: phernandez <paul@basicmemory.com>
2026-06-10 14:04:56 -05:00
K Jagadeeswara Reddy 44ecec2917 test(mcp): assert tags param and tag: shorthand comma equivalence
Integration regression test for #910: the tags= parameter and the
tag:alpha,beta query shorthand must return the same results for the
same comma string.

Cherry-picked from #918. The validator swap itself (coerce_list ->
parse_tags) landed separately via #932; this carries the remaining
test-only piece with original authorship.

Signed-off-by: K Jagadeeswara Reddy <social@jagadeeswar.com>
2026-06-10 12:04:07 -05:00
Paul Hernandez 49041a5168 docs(skills): fix npx skill install docs and ignore docs assets (#927)
Refs #930 docs cleanup; adds docs/assets to gitignore.

Signed-off-by: phernandez <paul@basicmemory.com>
2026-06-10 01:30:50 -05:00
Paul Hernandez 8cbe1634b6 fix(cli): don't let bm cloud setup overwrite an existing rclone remote (#923)
Closes #922

Signed-off-by: phernandez <paul@basicmemory.com>
2026-06-10 01:02:43 -05:00
Paul Hernandez c4b651f5b0 fix(mcp): accept page/page_size in read_note for parity with sibling tools (#933)
Closes #883

Refs #882

Signed-off-by: phernandez <paul@basicmemory.com>
2026-06-10 00:03:22 -05:00
Paul Hernandez ca9a4d9c12 fix(mcp): split comma-separated tags in search_notes tags param (#932)
Closes #910

Signed-off-by: phernandez <paul@basicmemory.com>
2026-06-09 22:23:26 -05:00
Paul Hernandez aa9594d82a perf(ci): disable semantic search in default test fixtures (#938)
Disable the silently-on semantic embedding stack in default test fixtures and deselect on-demand benchmarks from CI int jobs. int SQLite 337s -> 110s, Postgres unit ~25min -> ~13min.

Signed-off-by: phernandez <paul@basicmemory.com>
2026-06-09 21:55:21 -05:00
Paul Hernandez 93494b8c13 ci(ci): gate bossbot on passing tests (#937)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-09 20:47:34 -05:00
Paul Hernandez 93ed34001b fix(core): disambiguate truncated observation permalinks to prevent index collisions (#931)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 20:20:46 -05:00
Paul Hernandez ec94feb6a4 Merge branch 'main' into codex/fix-bossbot-assets-publish 2026-06-09 19:23:27 -05:00
Paul Hernandez 831b9141a5 ci(ci): use pytestmon and Depot runners (#928)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-09 19:20:08 -05:00
phernandez 2de19713f6 fix(ci): make BM Bossbot asset cleanup idempotent
Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-09 19:14:13 -05:00
phernandez 79fcfbce6a fix(ci): restrict BM Bossbot to trusted PR authors
Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-09 17:16:49 -05:00
phernandez 3d22ba3004 fix(ci): harden BM Bossbot finalization
Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-09 16:32:06 -05:00
phernandez 62229d9d0a fix(ci): address BM Bossbot PR feedback
Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-09 15:55:36 -05:00
phernandez 03ba268cb1 feat(ci): add BM Bossbot PR gate
Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-09 15:18:40 -05:00
Paul Hernandez de53e0ecc5 feat(cli): per-workspace rclone remotes for Team push/pull (#920)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-08 17:39:37 -05:00
Paul Hernandez 4128cac9ab docs(core): add Basic Machines agent style guidance (#921)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-08 17:09:50 -05:00
Paul Hernandez 9b53d7863f feat(cli): add Team-safe cloud push/pull and gate sync to Personal workspaces (#917)
Adds additive, git-style `bm cloud push`/`pull` that are safe on shared Team workspaces (never delete on the destination; conflicts abort by default with `--on-conflict {fail|keep-local|keep-cloud|keep-both}`), and gates the destructive `bm cloud sync`/`bisync` mirrors to Personal workspaces. Closes #858. Longer-term Team-safe reconciler tracked in #862; workspace-scoped mount info (Codex P1) tracked as a follow-up.
2026-06-08 14:08:49 -05:00
Paul Hernandez a8d034b940 fix(mcp): point move mismatch guidance at landing path (#916)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-08 10:42:38 -05:00
Paul Hernandez 7c0937f658 fix(mcp): validate navigation pagination and fix recent_activity project display (#915)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 23:03:00 -05:00
Paul Hernandez 8570d96bad fix(cli): align bm tool commands with MCP (error exits, overwrite, category, default) (#913)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 22:56:43 -05:00
Paul Hernandez 480a2d9468 fix(mcp): resolve memory:// in move_note and stop false cross-project rejections (#914)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 22:44:08 -05:00
Paul Hernandez df7452e3ba fix(core): make note_types search filter case-insensitive (#912)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 22:44:04 -05:00
Paul Hernandez 85a8e59d0f fix(core): split comma-separated tags in parse_tags (#911)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 22:44:01 -05:00
Paul Hernandez 07cb7a606b docs(core): mark LiteLLM provider experimental (#899)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:13:54 -05:00
Paul Hernandez 0ef03edde6 feat(cli): add --type to the write-note tool command (#907)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:06:44 -05:00
Paul Hernandez efe43a10ea feat(cli): add --wait and --timeout to bm status (#906)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:58:02 -05:00
Paul Hernandez 4fe6fe09c8 feat(core): add observation category filter to search (#908)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:57:19 -05:00
Paul Hernandez 8acdb49a41 fix(core): reuse a single embedding provider per process (#903)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:14:07 -05:00
Paul Hernandez f7304bf553 feat(cli): improve workspace and cloud bisync command discoverability (#905)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:18:17 -05:00
Paul Hernandez 5b034f081d fix(core): self-heal corrupt FastEmbed model cache (#900)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:37:30 -05:00
Paul Hernandez 271c883ea8 fix(core): load sqlite-vec for embedding-status query (#901)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:37:27 -05:00
Paul Hernandez 816ee85fb9 fix(core): prevent asyncpg engine-dispose crash on Postgres backend (#902)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:37:23 -05:00
Paul Hernandez 3ba3a9504d fix(mcp): stop move_note reporting false success across boundaries (#904)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:37:20 -05:00
Paul Hernandez 1667cdc000 test(ci): normalize setup overwrite assertion (#898)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-06 23:20:06 -05:00
Sourish Chakraborty f916662ff3 fix(core): allow cross-project context traversal 2026-06-06 21:43:21 -05:00
DoubleDeeRuffy 0c9800cd3b fix(sync): use strict deferred relation resolution 2026-06-06 21:43:09 -05:00
Adit Karode 476239d878 feat(cli): add delete-note tool command 2026-06-06 21:41:46 -05:00
DoubleDeeRuffy 20bb19f4cd fix(mcp): resolve write-note overwrite conflicts 2026-06-06 21:41:36 -05:00
tk f6565b9d23 fix(core): L2-normalize FastEmbed vectors (#843)
L2-normalizes FastEmbed output vectors at the provider boundary so SQLite vector scoring keeps its unit-vector contract for custom FastEmbed models such as multilingual MiniLM variants.

Zero vectors are preserved as-is to avoid division errors, and the provider tests cover both non-unit vectors and zero-vector behavior.

Verification:
- uv run pytest tests/repository/test_fastembed_provider.py -q
- uv run ruff check src/basic_memory/repository/fastembed_provider.py tests/repository/test_fastembed_provider.py
- uv run ruff format --check src/basic_memory/repository/fastembed_provider.py tests/repository/test_fastembed_provider.py

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: tk-pkm111 <133480534+tk-pkm111@users.noreply.github.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-06 17:15:19 -05:00
Aarish Alam b6e8c636ce feat(core): add LiteLLM embedding provider (#809)
Adds LiteLLM as a semantic embedding provider, including provider configuration, vector normalization, live-provider evaluation tooling, and documentation for OpenAI, Cohere, Azure Foundry, Azure OpenAI, and NVIDIA NIM-style cases.

Maintainer follow-up on this PR added provider hardening, asymmetric document/query embedding support, dimension-forwarding controls, SQLite/Postgres vector invalidation coverage, and the repeatable live LiteLLM harness.

Verification:
- Full base-repo Tests workflow passed for 3d4e092ceb: https://github.com/basicmachines-co/basic-memory/actions/runs/27072071785
- Live LiteLLM harness passed locally for OpenAI, Cohere, and Azure Foundry.

Co-authored-by: Aarish Alam <arishalam121@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: RheagalFire <arishalam121@gmail.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-06 17:13:09 -05:00
Paul Hernandez 442fd1523c fix(plugins): include codex in shared version bump (#897)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-05 18:34:57 -05:00
Paul Hernandez ce4b5d47a0 fix(skills): reject invalid frontmatter YAML (#896)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-05 13:14:27 -05:00
phernandez 59e865c079 chore: update version to 0.21.6 for v0.21.6 release
Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-04 23:41:42 -05:00
phernandez 96b6b21a88 docs: add v0.21.6 changelog entry
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-04 23:41:30 -05:00
Paul Hernandez 5973f9b787 feat(plugins): add codex plugin package (#894)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-04 23:14:07 -05:00
Paul Hernandez 8887267256 fix(ci): improve auto bm note narrative (#893)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-04 22:34:57 -05:00
Paul Hernandez a6cfed96f1 fix(ci): allow ci PR title scope
Allows ci as a semantic PR title scope and covers the workflow config with a regression test.
2026-06-04 21:05:29 -05:00
Paul Hernandez 2de8183e85 fix(ci): require all codex synthesis schema fields
Fixes the Auto BM Codex structured-output schema guardrail so live synthesis can run.
2026-06-04 20:48:55 -05:00
Paul Hernandez 0adbc12f09 docs(ci): document auto bm dogfood install
Documents the temporary checkout install used while dogfooding Auto BM before the next package release.
2026-06-04 20:44:51 -05:00
phernandez 80ca5a07fd ci(cli): dogfood auto bm workflow install
Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-04 20:33:49 -05:00
phernandez 4502bd3c3b ci(cli): configure auto bm workflow
Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-04 20:27:42 -05:00
phernandez b386502020 fix(cli): show copyable workspace identifiers
Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-04 20:20:56 -05:00
phernandez 16ba55d7b4 feat(cli): add auto bm github ci
Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-04 20:14:59 -05:00
Paul Hernandez 5458c35b35 refactor(plugins): prefix Claude Code plugin skills with bm- (#878)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 12:04:05 -05:00
Paul Hernandez fc2ee07076 feat(api): expose owning project on entity resolve
Expose project_external_id on v2 entity resolve responses so cloud callers can authorize cross-project targets without a second tenant DB lookup.
2026-06-02 18:01:17 -05:00
phernandez 61891f2e33 fix(plugins): address PR review — scope captureFolder to checkpoints, ASCII echo
Review feedback on #876:
- Codex (P2): the SessionStart "Where to write" brief said all auto-capture goes to
  captureFolder, but captureFolder is only the PreCompact checkpoint folder. Narrowed
  it — checkpoints go to captureFolder; decisions/tasks/notes follow
  placementConventions (or topic folders when none are set), so proactive captures
  aren't dumped into sessions/ alongside checkpoints.
- Claude review: replaced the em dash in the skills-source-guard echo string with a
  hyphen so the instruction's console output stays ASCII (CLAUDE.md compatibility).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-31 15:05:01 -05:00
phernandez 5f59ca49a2 docs(plugins): fold in setup debrief fixes — cloud-create, skills-source guard, shared-read cap
- Project mapping: document local vs cloud project creation — a cloud-workspace
  project needs a cloud-connected MCP server and a workspace selector; a purely
  local server fails to mkdir the cloud-style path. Pin cloud primaryProject to the
  external_id UUID.
- Install shared skills: guard against clobbering a source checkout — if ./skills is
  git-tracked and holds memory-* dirs (the skills' own source repo), skip the npx
  install instead of overwriting the working copy with published versions.
- Cloud/teams: warn that the SessionStart brief reads only the first 6 shared
  projects per session, so order the most relevant first when more are configured.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-31 15:05:01 -05:00
phernandez 2ba1ca8722 feat(plugins): make setup focus load-bearing, seed schemas via metadata, add smoke test
- Focus question is now load-bearing: it drives an adaptive light folder-structure
  suggestion and is stored. The placement step branches learn (existing project
  with notes) vs suggest (new/empty project) instead of only learning.
- Seed schema notes via the write_note `metadata` param instead of content
  frontmatter. The cloud write path silently coerces nested YAML to the string
  '[object Object]', corrupting schema/settings (basic-memory-cloud#1000); the
  metadata param round-trips correctly on both local and cloud.
- Surface placementConventions + captureFolder in the SessionStart brief so the
  output style's "follow stored placement conventions" reflex has something to
  follow — previously written by setup but never shown to Claude (dead config).
- Add a post-settings smoke test (run the hook's recall query, confirm routing)
  and a restart prompt gated on outputStyle in the close.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-31 15:05:01 -05:00
phernandez 4b6de80faa fix(plugins): hooks fall back to uvx/uv when no CLI binary is on PATH
Codex flagged (review 4397232451) that the hooks locate the CLI via
`command -v basic-memory || command -v bm`, but our own README recommends
connecting the MCP server as `claude mcp add basic-memory -- uvx basic-memory mcp`
— an ephemeral uv run that leaves NO binary on PATH. In that (recommended) setup
both hooks exited before doing anything, so the bridge silently did nothing even
though the MCP server worked.

Both hooks now resolve the CLI invocation as: prefer a `basic-memory`/`bm` binary
(fast), else fall back to `uvx basic-memory`, else `uv tool run basic-memory`, else
silent no-op. The launcher may be multi-token, so the embedded Python splits it with
shlex and prepends it to each command list. The uv cache is already warm from
running the MCP server, so the fallback is cheap.

Verified: with no binary on PATH but uvx present, SessionStart now produces a brief
(previously a silent no-op). Updated the README requirement to note uvx-only works.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-31 12:31:08 -05:00
phernandez 1eb4cd154f fix(plugins): route UUID project refs consistently across all skills
Codex flagged /basic-memory:remember passing a UUID primaryProject as `project`
(review 4397206729) — the same routing bug already fixed in the hooks and the
share skill. Rather than fix only the flagged spot, audited every project-ref
site and made them all consistent:

- remember: route primaryProject as project_id when it's a UUID (was the flag)
- status: same for the primaryProject-scoped search_notes queries
- setup: same for the schema-seed write to primaryProject

share/SKILL.md and both hooks (session-start, pre-compact) already handle it.
That's now every place the plugin routes to a project ref — a project ref may be
a workspace-qualified name (-> project) or an external_id UUID (-> project_id),
and all six sites detect and route accordingly. Plugin validates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-31 12:31:08 -05:00
phernandez bfee8958ad docs(integrations): document the defensive getattr in the Hermes provider
Addresses the remaining review finding (deferred while #859 was open; now merged,
so the hermes module is on main and the comment lands cleanly here). The two
getattr sites read MCP SDK result/tool fields; `mcp` is an unpinned dependency
(plugin.yaml), and its CallToolResult / ListToolsResult / Tool shapes have varied
across SDK versions — so the defensive getattr is intentional, not the speculative
kind CLAUDE.md warns against. Added constraint comments at both sites explaining why.

No behavior change. `just package-check-hermes` passes (244 passed, 12 integration
tests skipped without BM_INTEGRATION).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-31 12:31:08 -05:00
phernandez 63f5c62dfd fix(plugins): address review findings (UUID routing, outputStyle location, line length)
Resolves the in-scope findings from the github-actions review (its prepared commit
1283a46 couldn't push — 403) and the Codex P2 (review 4397168884):

- pre-compact.sh: route a UUID primaryProject via --project-id, not --project.
  This mirrors session-start.sh; without it a UUID-configured project gets correct
  session briefs but SILENT checkpoint failures at every compaction. The substantive
  correctness fix. Verified end-to-end (checkpoint now lands in a UUID-keyed project).
- skills/share/SKILL.md: same UUID routing for /basic-memory:share team targets —
  pass external_id UUIDs as project_id, qualified names as project. (Codex P2.)
- skills/status/SKILL.md: read outputStyle from the ROOT settings object, not the
  basicMemory block — otherwise /basic-memory:status reports capture reflexes as off
  for a correctly-configured user.
- docs/getting-started.md: move outputStyle out of the basicMemory block to root in
  the team example (matches settings.example.json; it's a top-level Claude Code key).
- session-start.sh: split the >100-char project-routing line.

Out of scope here: the hermes/__init__.py getattr-rationale comments the bot
prepared live in the #859 consolidation code (not in this PR's diff) — flagged for
that PR. Plugin validates; hooks smoke-tested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-31 12:31:08 -05:00
phernandez 451f27cfd9 docs: cross-link MCP-server prereq with the Claude Code plugin install
The MCP server is a hard prerequisite for the plugin (its hooks and skills call
it), but the two were in separate README sections with no link between them.

- The "Claude Code plugin" install section now points to "Connect your AI client"
  and states the MCP server must be connected first.
- The "Connect your AI client → Claude Code" section now points to the plugin for
  the full memory bridge.
- /basic-memory:setup's prerequisite check is now concrete: verify the MCP server
  via list_memory_projects, and if absent, walk the user through installing and
  `claude mcp add basic-memory -- uvx basic-memory mcp` before starting the interview.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-31 12:31:08 -05:00
phernandez df5f0f4c04 docs: fix stale Claude Code plugin description in root README
The umbrella install section still described the plugin as bundling "skills,
hooks, and an agent" — the agent was removed in the v0.4 redesign. Updated to the
current surface: hooks (briefings + checkpoints), the opt-in output style, and the
/basic-memory:* skills.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-31 12:31:08 -05:00
phernandez 731e7baac1 fix(plugins): address Codex review P2s on PR #865
1. SessionStart worker pool was sized 8 but submits up to 9 searches (3 primary
   + MAX_SHARED=6 shared) — the 9th queued behind a possibly-10s call and could
   push the hook past Claude Code's 20s SessionStart timeout before the brief
   printed. Size the pool to 3 + MAX_SHARED so nothing queues. (Introduced in
   Phase 4 when the third primary query was added.)

2. captureChattyness was dead config — written by setup but read by no hook, and
   its "heavy" level advertised "checkpoint without compaction," which no hook
   implements. The real proactivity knob is `outputStyle`. Removed
   captureChattyness entirely and folded the "how active should I be?" question
   into the output-style step (the single, actually-wired toggle); the always-on
   hooks run regardless. Updated setup skill, settings.example.json, and DESIGN.

Both flagged by Codex (review 4395911984); same class as the recallTimeframe
dead-config fix from the earlier self-review. Plugin validates; hook smoke-tested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-31 12:31:08 -05:00
phernandez 877e52fd73 feat(skills): port useful retired plugin skills into the shared memory-* set
Following the evaluation on #866, the genuinely-useful behavior from the deleted
Claude Code plugin skills is preserved as framework-agnostic shared skills (usable
by any MCP agent — Claude Desktop, OpenClaw, etc.), instead of being lost in the
v0.4 clean-break. The Claude Code plugin itself is unchanged — it deliberately
relies on hooks + the output style for capture/recall and pulls these shared skills
via /basic-memory:setup.

New shared skills (ported from the deleted set, CC-specific glue stripped):
- memory-curate (from knowledge-organize) — the real coverage gap: knowledge-GRAPH
  curation (orphan notes, relation suggestion, duplicate merge, tag/folder audit,
  hub notes, sparse-note enrichment). Distinct from memory-defrag, which is
  agent-memory-FILE hygiene.
- memory-continue (from continue-conversation) — resume prior work by rebuilding
  context from the graph (build_context / recent_activity / search), timeframe table,
  resume playbooks.
- memory-capture (from knowledge-capture) — synthesize a thread's current state into
  one note, rewriting in place via a thread_id key. The Claude-Code-specific session
  UUID is generalized to "any stable thread id your host exposes."

Also completed memory-notes' edit reference (added prepend / replace_section to the
two existing operations). Updated skills/README.md and skills/CLAUDE.md.

`just package-check-skills` validates 13 skills. NOTE: skills/skills-lock.json is a
generated artifact of the `npx skills` CLI (not used by Claude Code or OpenClaw) and
should be regenerated once the set is final — not hand-edited here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-31 12:31:08 -05:00
phernandez fc8ac86fa1 feat(plugins): setup installs the shared memory-* skills (no repo dup)
Addresses the consolidation direction from issue #866: the Claude Code plugin
should consume the canonical skills/ set, not reinvent a parallel one. Rather
than vendoring copies into the plugin, /basic-memory:setup now offers to install
the shared memory-* skills via `npx skills add basicmachines-co/basic-memory
--path skills` (the existing SPEC-58 distribution path, same source OpenClaw
bundles). The plugin stays "hooks + Claude-Code-specific skills" and pulls the
shared toolkit on demand — skills/ remains the single source of truth, no
duplication in the repo.

Updated the setup interview + apply steps, README, getting-started, and CHANGELOG.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-31 12:31:08 -05:00
phernandez df3eb3208c fix(plugins): address v0.4 plugin code-review findings (PR #865)
Resolves the 8 self-review findings on PR #865:

session-start.sh:
- Wire recallTimeframe into a new "Recent sessions" query (--after_date) — it was
  parsed but unused after the Phase 4 rewrite. The brief now surfaces recent
  session checkpoints (the resume cursor), which is the one query the recency
  window legitimately applies to. (#1)
- Guard secondaryProjects/teamProjects JSON types — a string value was iterated
  character-by-character into bogus per-char queries. (#3)
- A configured-but-unreachable/misnamed primaryProject now emits a one-line
  "couldn't read" signal instead of a silent blank brief. (#7)

pre-compact.sh:
- Filter transcript turns by the `isMeta` and `toolUseResult` flags instead of a
  `text.startswith("<")` heuristic. This stops dropping legitimate user messages
  that start with "<" (#4) and stops capturing tool-result/meta frames as human
  turns (#8) — verified against a real transcript (25 human turns cleanly
  separated from 8 meta + 288 tool-result frames).
- Title now uses second precision so rapid same-minute compactions don't collide
  and silently drop/overwrite a checkpoint; removed the unused stamp/slug. (#2)
- Empty-checkpoint guard now requires at least one real user turn, not just any
  turn, so an assistant-only transcript can't produce a dangling-title note. (#5)

validate_skills.py:
- parse_frontmatter now skips indented lines, capturing only top-level keys, so a
  schema note's nested schema:/settings: children can't overwrite a top-level
  type/entity via last-write-wins. Documented its single-line-only limitation. (#6)

All verified end-to-end; `just package-check-claude-code` and
`package-check-skills` pass; ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-31 12:31:08 -05:00
phernandez 2886ab64e1 docs(plugins): add architecture, why, and getting-started docs (v0.4 Phase 5)
Adds user- and contributor-facing documentation under plugins/claude-code/docs/:

- why-combine-memory.md — the value proposition: two-memories/two-jobs, the
  1+1=3 framing, the three personas (thinker / builder / operator) with concrete
  use cases, and the official "use both" stance. The "why would I want this" doc.
- getting-started.md — a ~5-minute guided walkthrough: prerequisites, install,
  /basic-memory:setup, a capture→checkpoint→brief loop to see it work, the team
  workspace path, tuning knobs, and troubleshooting.
- architecture.md — how it works flow by flow, with mermaid diagrams: the bridge
  (working memory <-> durable graph), the four surfaces, SessionStart and
  PreCompact sequence flows, capture reflexes, team read/share, component map.

Linked all three from the README's new Documentation section. Marked Phase 5
docs done in DESIGN. Migration guide intentionally skipped — v0.4 is a clean
break (uninstall old, install new), noted in the CHANGELOG. Dogfood remains the
open human step.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-31 12:31:08 -05:00
phernandez 36c245f0d2 feat(plugins): team workspace support + /basic-memory:share (v0.4 Phase 4)
Implements DESIGN.md Phase 4, grounded in a real two-workspace BM Cloud account.

Recall reads across the team:
- hooks/session-start.sh rewritten to read the primary project (active tasks +
  open decisions) AND each configured shared/team project (open decisions) in
  parallel via ThreadPoolExecutor. Routes by workspace-qualified name or
  external_id UUID (project names collide across workspaces, so bare names won't
  route); per-call timeout, capped at 6 shared projects, graceful on any failure.
  Adds a "From shared projects (read-only)" section + the share-vs-capture note.
  Verified against the real my-team-2 workspace (OAuth routing) and local fixtures.

Deliberate team writes:
- skills/share/SKILL.md → /basic-memory:share <note>: copies a note from the
  primary project into a configured teamProjects target's promoteFolder, with
  shared_from attribution and a confirmation step. Preserves the note's type so
  shared decisions stay findable in the team's structured recall. Pulled forward
  from future-work since team usage needs a safe write path.

Safe by default: capture (PreCompact checkpoints, /remember) NEVER writes to a
shared project. The proposed teamProjects.autoWrite flag is deliberately not
shipped — documented as future rather than ship an unenforced flag.

Config: secondaryProjects (read sources) + teamProjects (share targets with
promoteFolder), both requiring qualified names/UUIDs. setup interview step 3 now
configures them via list_workspaces; status reports team read-sources + share
targets; settings.example.json documents the shape. REQUIRED_SKILLS adds share.

Discovery verified: Skills (4): remember, setup, share, status. Passes
`just package-check-claude-code` incl. `claude plugin validate . --strict`.
Updated README (Teams section), CHANGELOG, and DESIGN §6 + Phase 4 status.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-31 12:31:08 -05:00
phernandez 2bd52676d9 feat(plugins): add /basic-memory:setup bootstrap interview (v0.4 Phase 3)
Adds the bootstrap interview from DESIGN.md Phase 3 as a prose skill, plus the
first-run nudge that points users to it.

- skills/setup/SKILL.md → /basic-memory:setup: an adaptive ~2-min interview that
  maps the Claude Code project to a Basic Memory project (pick existing or create),
  seeds the session/decision/task schemas into it, optionally learns the project's
  placement conventions (list_directory + sampling, stored in placementConventions),
  writes the basicMemory settings block, and enables the capture output style.
- hooks/session-start.sh: nudges toward /basic-memory:setup on first run (when no
  basicMemory config block exists). The nudge now survives a failed/empty task
  query, so a brand-new user with no project yet still sees it; it stops once setup
  writes the config (config presence is the sentinel — no separate file).
- validate_claude_plugin.py: REQUIRED_SKILLS now includes setup.

Corrects the Phase 1 schema-seeding finding: writing a schema file's content via
write_note (CLI or MCP) indexes it as type: schema AND resolves via schema_validate
— the earlier "must use note_type/metadata" conclusion was confounded by the enum
YAML bug (since fixed). Verified end-to-end: all three schemas seed, index, and
resolve (entity=Session/Decision/Task); nudge verified across all config states;
discovery shows Skills (3): setup, remember, status.

Updated README, CHANGELOG, and DESIGN Phase 3 status. Passes
`just package-check-claude-code` incl. `claude plugin validate . --strict`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-31 12:31:08 -05:00
phernandez 215a2d9f99 feat(plugins): add /basic-memory:remember and :status skills (v0.4 Phase 2)
Adds the two deliberate-gesture skills from DESIGN.md Phase 2, both as prose
skills (skills are prompts) — robust to arbitrary user text and any MCP server
tool-name prefix, no bundled-script path placeholders.

- skills/remember/SKILL.md → /basic-memory:remember <text>: quick capture to the
  rememberFolder (default bm-remember) with a first-line title and manual-capture
  tag, via write_note. Model-invocable so "remember that…" triggers it.
- skills/status/SKILL.md → /basic-memory:status: user-only diagnostic
  (disable-model-invocation) reporting active project, capture/remember folders,
  output-style state, recent session checkpoints, and active-task count.

validate_claude_plugin.py now requires the shipped skill set (REQUIRED_SKILLS)
and validates each skill's name/description frontmatter. Passes
`just package-check-claude-code` incl. `claude plugin validate . --strict`.

Discovery verified end-to-end: installed from a local marketplace and confirmed
via `claude plugin details` — Skills (2): remember, status; Hooks (2):
SessionStart, PreCompact; Agents (0) — namespaced as /basic-memory:<name>.

Updated README (Commands table), CHANGELOG, and DESIGN Phase 2 status.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-31 12:31:08 -05:00
phernandez f485c1ef3f chore(plugins): add scoped version recipes for plugin/agent artifacts
Adds a --scope {all,core,packages} option to scripts/update_versions.py so the
version bump can target just the host-native agent artifacts (Claude Code plugin
+ root/local marketplaces, Hermes, OpenClaw) separately from the Python package
core (__init__.py, server.json). Default scope is "all" — fully backward
compatible.

New justfile recipes:
- set-version <v> [scope]            — write version (all|core|packages)
- set-version-dry-run <v> [scope]    — preview
- set-packages-version <v>           — plugin/agent artifacts only
- set-packages-version-dry-run <v>   — preview

The release / beta / release-dry-run recipes now route through set-version
instead of calling the script inline, so version-setting has one source of
truth and is reusable by the /release skill. Updated the /release command doc
to describe the consolidated version update and the new recipes.

Tests cover the new scope filtering (packages leaves core untouched and vice
versa; invalid scope errors). Existing lockstep behavior unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-31 12:31:08 -05:00
phernandez 3ff40d51fc feat(plugins): rebuild Claude Code plugin as the memory bridge (v0.4 Phase 1)
Reframes the Claude Code plugin as the bridge between Claude's working
memory and Basic Memory's durable graph, per plugins/claude-code/DESIGN.md.
This is the minimal-first vertical slice with a clean break from v0.3.x.

Added:
- hooks/session-start.sh — SessionStart brief: one structured `type: task`
  query against the configured (or default) project + always-on recall
  prompt. Plain stdout, silent if BM is absent.
- hooks/pre-compact.sh — PreCompact checkpoint: writes a `type: session`
  note before compaction (extractive). Only writes when primaryProject is
  set, so it never touches an un-opted-in graph.
- output-styles/basic-memory.md — capture reflexes (search-first, typed
  decision notes, cite permalinks); keep-coding-instructions: true.
- schemas/{session,decision,task}.md — picoschema seeds (validation: warn)
  so plugin-written notes are findable via metadata_filters. task mirrors
  the memory-tasks skill.
- settings.example.json — copyable config with sensible defaults.

Removed (clean break):
- the six bundled skills, the basic-memory-manager agent, the
  PreToolUse/PostToolUse write_note hooks, the basic-memory config-note
  convention, and PLUGIN.md. Equivalent workflows live in top-level skills/.

Other:
- Rewrote scripts/validate_claude_plugin.py for the new layout (hooks +
  output-style + schemas; agent dropped; skills optional). Passes
  `just package-check-claude-code` incl. `claude plugin validate --strict`.
- Rewrote README around the bridge story; updated CHANGELOG, marketplace
  descriptions, and the AGENTS.md package-check note.
- Hooks tested end-to-end against throwaway projects (brief surfaces tasks;
  checkpoint writes a queryable session note; both degrade silently).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-31 12:31:08 -05:00
phernandez 82f6d15946 docs(plugins): add Claude Code plugin v0.4 redesign DESIGN.md
WIP design doc for modernizing the Claude Code plugin around the
"bridge between Claude auto-memory and Basic Memory" positioning.

- Reframes the plugin as connective tissue between Claude's working
  memory and BM's durable graph (per docs/vs-built-in-memory).
- Defines the four core surfaces: SessionStart brief, PreCompact
  checkpoint, output-style reflexes, deliberate skills.
- Leans on BM schemas + structured metadata search for deterministic
  recall (ships session/decision/task picoschemas).
- Maps Claude Code projects to BM projects; team-workspace safety
  (read-across, write-by-gesture); bootstrap interview.
- Records verified findings for 8 open questions (CC v2.1.153 /
  basic-memory 0.21.5) from an investigate→adversarial-verify pass:
  PreCompact 600s budget enables LLM summaries; metadata_filters
  confirmed; path-scoped rules cut (don't load yet); commands are
  plugin-namespaced; bootstrap via SessionStart sentinel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-31 12:31:08 -05:00
phernandez 4e7a217fa7 fix(skills): correct invalid picoschema enum YAML in memory skills
The Task schema in memory-tasks/SKILL.md and the enum examples in
memory-schema/SKILL.md used a bare YAML flow sequence followed by a
trailing description:

    status?(enum): [active, blocked, done, abandoned], current state

A flow sequence ([...]) cannot be followed by ', current state' —
python-frontmatter/PyYAML raises a ParserError. In schema_router.py,
_schema_frontmatter_from_file catches that error and silently falls
back to DB metadata, so the schema's enum constraints never load.

Per the picoschema parser docstring, enum descriptions belong inside
the parens:

    status?(enum, current state): [active, blocked, done, abandoned]

Fixes 4 lines (1 in memory-tasks, 3 in memory-schema). The
memory-literary-analysis schemas use the quoted-string form
("role(enum)": "[...], desc"), which is valid YAML handled by
_parse_enum_string, so they were left unchanged.

Verified: all yaml schema blocks now parse via parse_schema_note,
and just package-check-skills passes (10 skills validated).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-30 15:07:00 -05:00
phernandez 354e1642b2 delete ui dir
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-30 15:07:00 -05:00
Drew Cain b1bd6d57e8 Update MONKEYPATCH.md with upstream issue link
Add tracking link for upstream issue regarding slash commands.

Signed-off-by: Drew Cain <groksrc@users.noreply.github.com>
2026-05-30 14:16:04 -05:00
phernandez 5365f971ef chore(integrations): consolidate agent packages
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-30 14:16:04 -05:00
phernandez 4ea03d2bf1 chore: qualify Phase 2 verdicts schema path with $SKILL_DIR
Follow-up PR review: Phase 2 prose used a CWD-relative
`--output-schema schemas/verdicts.schema.json`, while Phase 1 uses
`"$SKILL_DIR/schemas/..."`. Following Phase 2 from the repo root would
resolve the wrong path and break the Codex refutation call. Now matches the
$SKILL_DIR-qualified pattern used everywhere else; swept the file to confirm
all command/flag path positions are qualified.

Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-30 14:14:35 -05:00
phernandez f652a09664 chore: build DIFF as argv array so scoped pathspecs survive (unix-friendly paths)
Addresses Codex PR review (P2): storing the diff in a scalar and running
`$DIFF` unquoted let the shell word-split/glob $SCOPE, so a pathspec with
spaces or glob chars ('docs/notes with spaces') broke or reviewed the wrong
files.

DIFF is now an argv array (`DIFF=(git diff "$BASE...HEAD")`, append
`-- "$SCOPE"` when set). Run via `"${DIFF[@]}"`; embed in subprocess prompts
via a shell-quoted `DIFF_STR=$(printf '%q ' "${DIFF[@]}")`. Verified the
pathspec stays a single intact argument in both bash and zsh.

Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-30 14:14:35 -05:00
phernandez d8901efb46 chore: make claude json parsing robust to both output shapes
Follow-up PR review claimed `claude --output-format json` emits a single
object and the array form is stream-json only. Verified empirically against
the installed CLI: `claude -p --output-format json` returns a JSON ARRAY of
event objects (system, rate_limit_event, assistant, result), not a single
object — so the reviewer's suggested fix would have broken parsing here.

The portability concern is still valid (older CLIs emit a single object), so
the parsing note now handles BOTH shapes: if array, take the type=='result'
element; else use the object as-is; then read .result and strip the fence.

Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-30 14:14:35 -05:00
phernandez c717d9997e chore: implement path scoping in adversarial-review (was broken-as-advertised)
Addresses follow-up PR review: the Inputs section advertised a path-scope
arg, but BASE=${ARG:-main} treated it only as a ref, so passing a path
produced `git diff <path>...HEAD` → fatal: ambiguous argument.

Root-causes both review findings (this one and the prior $BASE issue): the
diff command was spelled inline in many places and drifted. Now BASE (ref)
and SCOPE (pathspec) are separate inputs, combined once into a canonical
$DIFF command in preflight and reused everywhere — single source of truth.

Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-30 14:14:35 -05:00
phernandez f865d39d3d chore: make adversarial-review prompts base-agnostic
Addresses PR review: the prompt bodies hardcoded `git diff main...HEAD`,
which contradicted the orchestrator's parameterized `git diff $BASE...HEAD`
when run against a non-main base. The prompts now defer to the exact diff
command the orchestrator appends, and Phase 2 passes that command alongside
the findings so the refuter judges against the correct base.

Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-30 14:14:35 -05:00
phernandez 6a667dd3f8 chore: add cross-vendor adversarial-review skill
Adds an agent skill that runs adversarial code review across two model
families (Claude + Codex/GPT). Whichever runtime invokes it reviews the
branch diff natively and shells out to the other vendor for an independent
pass; each model then tries to refute the other's findings, and survivors
are reported by cross-model confidence. Report-only — never auto-applies
fixes.

- Canonical skill in .agents/skills/, symlinked from .claude/skills/
  (matches the existing instrumentation skill layout); discoverable by
  both Claude Code and Codex.
- Symmetric orchestration: run from Claude -> calls codex exec; run from
  Codex -> calls claude -p. The skill auto-detects which side it is on.
- Deterministic gate layer (ruff + grep) for negation-style house rules,
  since models underweight "never do X" constraints.
- Structured findings/verdicts via JSON schemas (codex --output-schema).

Deliberately omits the loop-until-converged state machine and auto-fix
behavior found in similar tools — convergence between models is not
correctness, so it surfaces a ranked list for a human gate instead.

Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-30 14:14:35 -05:00
phernandez 8d21a38588 fix(cli): handle non-subscription errors in cloud login
`bm cloud login` only caught SubscriptionRequiredError after the
post-login `/proxy/health` subscription check. OAuth succeeds and tokens
are saved, but if that check returns anything else — a 5xx while the
tenant instance is still provisioning, a 403/401 whose body doesn't match
the subscription_required shape, or a transport error — make_api_request
raises a generic CloudAPIError that escaped uncaught, dumping a raw
httpx.raise_for_status traceback. Users read this as "login failed" even
though authentication actually worked.

Add a CloudAPIError handler that prints a clean, actionable message and
exits non-zero. make_api_request wraps every httpx error (status and
transport) in CloudAPIError, so the single handler covers them all.

Fixes #863.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-30 14:13:32 -05:00
Paul Hernandez 597b12fcd1 Update README.md
update all pricing

Signed-off-by: Paul Hernandez <60959+phernandez@users.noreply.github.com>
2026-05-29 08:19:52 -05:00
Paul Hernandez c86f4402f6 Update README.md
add teams info. update pricing

Signed-off-by: Paul Hernandez <60959+phernandez@users.noreply.github.com>
2026-05-29 08:18:01 -05:00
Drew Cain f07643d3a8 feat(cli): expose project sync support metadata
Signed-off-by: Drew Cain <groksrc@gmail.com>
2026-05-28 15:00:05 -05:00
Drew Cain 8bf7bdbc0d fix(cli): limit team workspace guard to bisync
Signed-off-by: Drew Cain <groksrc@gmail.com>
2026-05-28 14:59:48 -05:00
Drew Cain d0ae373f45 docs(cli): clarify team mirror sync guard
Signed-off-by: Drew Cain <groksrc@gmail.com>
2026-05-28 14:59:48 -05:00
Drew Cain 1acec0a69d test(cli): cover workspace sync guard branches
Signed-off-by: Drew Cain <groksrc@gmail.com>
2026-05-28 14:59:48 -05:00
Drew Cain a2276a8a04 fix(cli): block team workspace rclone sync
Signed-off-by: Drew Cain <groksrc@gmail.com>
2026-05-28 14:59:48 -05:00
Drew Cain ff5d872a8c chore: update version to 0.21.5 for v0.21.5 release 2026-05-26 10:58:09 -05:00
Drew Cain 96ee4eafd2 docs: add v0.21.5 changelog entry
Signed-off-by: Drew Cain <groksrc@gmail.com>
2026-05-26 10:57:55 -05:00
Drew Cain b109b7337f fix(mcp): attach local state to one workspace project row (#854)
Signed-off-by: Drew Cain <groksrc@gmail.com>
2026-05-26 10:54:44 -05:00
Drew Cain 36b51b676e fix(mcp): return workspace-qualified write permalinks (#853)
Signed-off-by: Drew Cain <groksrc@gmail.com>
2026-05-26 00:33:21 -05:00
Drew Cain 9af320187c fix(core): load sqlite-vec before vector table cleanup (#852)
Signed-off-by: Drew Cain <groksrc@gmail.com>
2026-05-26 00:27:47 -05:00
Paul Hernandez 5a34a420c9 fix(mcp): preinitialize local ASGI database (#838)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-25 15:57:00 -05:00
Drew Cain a7e2368f9e chore: update version to 0.21.4 for v0.21.4 release 2026-05-23 14:55:49 -05:00
Sean Campbell c755127317 fix(cli): ignore CancelledError in background task done callback (#839) (#842)
Signed-off-by: rudi193-cmd <rudi193@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 14:27:59 -05:00
Sean Campbell 94c04ee456 fix(mcp): restore write_note overwrite schema for external clients (#818) (#841)
Signed-off-by: rudi193-cmd <rudi193@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 14:19:43 -05:00
Drew Cain d4ed02ba74 docs(core): move release process from CONTRIBUTING.md to AGENTS.md (#846)
Signed-off-by: Drew Cain <groksrc@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 14:00:01 -05:00
Drew Cain 40ed7129c8 chore: update version to 0.21.3 for v0.21.3 release 2026-05-23 13:46:48 -05:00
Drew Cain c4ef7abff5 test(core): isolate XDG_CONFIG_HOME so host env can't leak into tests (#845)
Signed-off-by: Drew Cain <groksrc@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:34:41 -05:00
Paul Hernandez c75f45f3cf Update README.md
update cloud description to remove link to private cloud repo

Signed-off-by: Paul Hernandez <60959+phernandez@users.noreply.github.com>
2026-05-23 12:22:18 -05:00
Drew Cain 5ae8a733ea fix(mcp): route mixed local/cloud projects correctly (#837)
Signed-off-by: Drew Cain <groksrc@gmail.com>
2026-05-23 11:31:13 -05:00
Rafael Madriz b94ef01f82 feat(core): add XDG_CONFIG_HOME support (#844)
Signed-off-by: Rafael Madriz <rafa@rafaelmadriz.com>
2026-05-22 15:18:06 -05:00
Sean Campbell 12af7930de docs(installer): use pgvector image for Postgres compose (#840)
Signed-off-by: rudi193-cmd <rudi193@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Drew Cain <groksrc@gmail.com>
2026-05-22 08:54:18 -05:00
phernandez 60ec6728de chore: update version to 0.21.1 for v0.21.1 release 2026-05-16 18:56:38 -05:00
phernandez a84e77f4af docs: add v0.21.1 changelog entry
CI-only point release to validate #833's inline Homebrew bump end-to-end
on a real tag push.

Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-16 18:56:30 -05:00
Paul Hernandez 6910620968 ci(installer): inline Homebrew formula bump (#833)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-16 18:55:25 -05:00
phernandez 214a54d740 chore: update version to 0.21.0 for v0.21.0 release 2026-05-16 17:15:53 -05:00
phernandez 5a90c7cedf docs: add v0.21.0 changelog entry
Promotes the Unreleased breaking-change note and adds the full v0.21.0
section covering ~80 commits since v0.20.3: workspace-routing fixes
across MCP/CLI/API, recent_activity ordering and search opt-in changes,
sync hardening, sqlite-vec graceful degrade, perf wins on CLI startup
and sync, and the project-delete cleanup landed in #832.

Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-16 17:15:43 -05:00
Paul Hernandez 9e3fe26a83 fix(core): purge SQLite search_index on project delete (#832)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-16 16:47:24 -05:00
Paul Hernandez 47ee982041 perf(cli): defer local ASGI app import (#828)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-15 17:59:58 -05:00
Paul Hernandez 4d22c398c6 fix(sync): preserve bmignore rclone filters (#827)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-15 17:48:49 -05:00
phernandez 8ac2d975f9 update README.md
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-15 17:13:34 -05:00
Paul Hernandez 34830bfad7 Update README.md
update agent matrix

Signed-off-by: Paul Hernandez <60959+phernandez@users.noreply.github.com>
2026-05-15 14:55:00 -05:00
Paul Hernandez f5e0c42047 Update README.md
remove sync commands from cli examples

Signed-off-by: Paul Hernandez <60959+phernandez@users.noreply.github.com>
2026-05-15 14:52:27 -05:00
phernandez 3f98da8c67 update README.md
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-15 14:50:35 -05:00
phernandez 14ff77d1c2 update to fastmcp 3.3.1
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-15 12:28:03 -05:00
phernandez 4e4da6128d ci: run github actions on node 24
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-15 12:14:06 -05:00
Paul Hernandez 3bed6d8890 chore(deps): update deps and harden security (#825)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-15 10:45:14 -05:00
Paul Hernandez 4cba7ba01c fix(core): parse prose wikilinks as inline links (#824)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-14 11:43:11 -05:00
Drew Cain 8eeec64e28 fix: basic-memory project list does not list projects from all workspaces (#822)
Signed-off-by: Drew Cain <groksrc@gmail.com>
2026-05-14 09:46:02 -05:00
Paul Hernandez c6fa185bf3 fix(mcp): route edit_note workspace-qualified permalinks (#813)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-11 12:56:26 -05:00
Paul Hernandez 415c2b3d6e feat(cli): add orphan entity command (#816)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-11 12:13:14 -05:00
Paul Hernandez 4aa0cbdd62 fix(sync): ignore hidden paths relative to watched project (#815)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-11 11:14:03 -05:00
Paul Hernandez 55f314237d fix(sync): avoid shell for scan subprocesses (#814)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-11 09:20:11 -05:00
Drew Cain 9862ef5411 fix(core): use updated_at for recent_activity filter and ordering (#812)
Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Signed-off-by: Drew Cain <groksrc@gmail.com>
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: phernandez <paul@basicmachines.co>
2026-05-11 09:14:06 -05:00
Paul Hernandez df5e8d805f fix(mcp): centralize workspace permalink routing (#808)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-08 16:44:57 -05:00
Paul Hernandez 831dc1ecdc fix(mcp): make multi-project search opt-in (#807)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-08 12:54:20 -05:00
Paul Hernandez 26381aeed1 fix(mcp): use lightweight graph hydration lookup (#806)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-08 10:07:29 -05:00
Paul Hernandez 7918e5c6bf fix(mcp): preserve workspace paths in build_context (#801)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-08 09:53:54 -05:00
Paul Hernandez f312341020 fix(mcp): add workspace routing to delete_project (#803)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-08 09:48:51 -05:00
phernandez e871298e95 Merge branch 'main' of github.com:basicmachines-co/basic-memory 2026-05-07 18:50:10 -05:00
phernandez 177ae21ba7 pass context to recent_activity
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-07 18:50:02 -05:00
Drew Cain 3415fd1014 fix(core): parse picoschema modifier descriptions (#796)
Signed-off-by: Drew Cain <groksrc@gmail.com>
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: phernandez <paul@basicmachines.co>
2026-05-06 12:21:35 -05:00
Paul Hernandez b08659e228 fix(api): accept qualified project resolver hints (#795)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-06 10:33:11 -05:00
Paul Hernandez 09a4b09436 feat(api): include search result totals (#791)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-04 14:57:04 -05:00
Paul Hernandez a661e924df feat(mcp): create projects by workspace slug (#789)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-03 19:31:55 -05:00
Paul Hernandez 05adda1502 fix(mcp): route workspace-qualified memory urls (#790)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-03 18:18:10 -05:00
Drew Cain 0a72d81bb3 fix(mcp): cap recent_activity rows with explicit truncation footer (#785)
Signed-off-by: Drew Cain <groksrc@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 17:43:42 -05:00
Drew Cain 5ccf433cad fix(cli): point bm cloud setup hint at bm cloud sync-setup (#780)
Signed-off-by: Drew Cain <groksrc@gmail.com>
2026-05-02 17:35:24 -05:00
Paul Hernandez b4bf14ebf7 fix(mcp): list factory projects across workspaces (#778)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-02 09:17:25 -05:00
Paul Hernandez 0b335476d6 fix(mcp): resolve projects by external_id, remove workspace from MCP tools (#777)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-01 05:15:44 -05:00
Paul Hernandez 2fccc74a20 feat(cli): refuse db reset while basic-memory mcp processes run (#776)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-04-29 15:48:19 -05:00
Paul Hernandez c4956cac16 fix(cli): cleanup local DB state on set-cloud/set-local (#775)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-04-29 11:50:14 -05:00
Paul Hernandez 128c2da40c fix(cli): clear default_workspace on cloud logout (#773)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-04-29 09:53:09 -05:00
Paul Hernandez 2bfb9c76df fix(core): degrade gracefully when sqlite-vec cannot load on init (#774)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-04-29 08:57:31 -05:00
Paul Hernandez 3d927b848f fix(installer): mount docker-compose config volume to appuser home (#772)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-04-29 08:57:26 -05:00
Paul Hernandez 953fe20aef test(core): regression guard for vector-row cleanup on entity delete (#764) (#771)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-04-28 23:20:15 -05:00
Paul Hernandez a4282d9f2f fix(core): skip Obsidian callouts in observation parser (#769)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-04-28 23:15:13 -05:00
Paul Hernandez 799dd6c629 fix(mcp): remove no-op pagination params from read_note and view_note (#768)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-04-28 23:05:14 -05:00
Paul Hernandez 26e74ea118 test(core): regression guard for long relation_type values (#721) (#770)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-04-28 22:55:14 -05:00
Paul Hernandez ee1558ea68 feat(mcp): accept training-data-friendly parameter aliases (#766)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-04-28 20:10:36 -05:00
Viktor Szépe 4d62b623db chore(core): fix typos (#761)
Signed-off-by: Viktor Szépe <viktor@szepe.net>
2026-04-23 10:12:46 -05:00
Paul Hernandez 2fe4488eda fix(sync): constrain watch service to --project scope (#759)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-04-20 13:46:13 -05:00
Paul Hernandez f3e46d7984 feat(mcp): discover projects across workspaces (#757)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-04-18 14:04:57 -05:00
jope-bm 56d6f1b4a5 fix(mcp): report cloud projects as source=cloud in factory mode (#752)
Signed-off-by: Joe P <joe@basicmemory.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 11:10:20 -06:00
Paul Hernandez 1b39062ecd refactor(core): rip telemetry wrappers, use logfire directly (#754)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 13:58:19 -05:00
Paul Hernandez c4cf0aff1e perf(sync): speed up single markdown file indexing (#751)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-04-17 07:08:21 -05:00
phernandez 1c343bed66 perf(sync): skip unchanged markdown indexing
Signed-off-by: phernandez <paul@basicmachines.co>
2026-04-16 18:27:52 -05:00
phernandez c50d97e548 fix(sync): instrument single markdown indexing
Signed-off-by: phernandez <paul@basicmachines.co>
2026-04-16 18:04:17 -05:00
Drew Cain e2e65575d6 fix(core): honor BASIC_MEMORY_CONFIG_DIR across remaining call sites (#744)
Signed-off-by: Drew Cain <groksrc@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:55:25 -05:00
Drew Cain bf9a6b4a75 fix(core): resolve FastEmbed cache under data dir instead of /tmp (#743)
Signed-off-by: Drew Cain <groksrc@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:55:00 -05:00
Paul Hernandez 474100efef ci(core): reduce duplicate CI and normalize Windows assertions (#749)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-04-15 22:53:33 -05:00
Paul Hernandez b3d5448355 fix(sync): preserve canonical markdown in single-file sync (#746)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-04-15 20:04:28 -05:00
Paul Hernandez 4e53bb83fd refactor(core): simplify note write flow (#739)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-04-15 17:56:27 -05:00
phernandez 8f2b25f0e0 test(core): stabilize postgres fixtures
Signed-off-by: phernandez <paul@basicmachines.co>
2026-04-13 14:25:26 -05:00
Paul Hernandez 052545b661 chore(core): make ty the default typechecker (#736)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-04-13 10:34:01 -05:00
phernandez abd4a5a6da Merge branch 'main' of github.com:basicmachines-co/basic-memory 2026-04-10 12:23:19 -05:00
Paul Hernandez a872947e03 fix(cli): show cloud index freshness in project info (#734)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-04-10 09:22:35 -05:00
Paul Hernandez 093c94fea5 fix(core): clean up delete vectors and cloud sync (#733)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-04-09 21:23:45 -05:00
phernandez cc104f761f Merge branch 'main' of github.com:basicmachines-co/basic-memory 2026-04-09 21:09:03 -05:00
Paul Hernandez 7945c1e2f7 perf(core): speed up vector sync and tune fastembed defaults (#731)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-04-09 00:20:12 -05:00
phernandez d7f3f6a96f add logfire skills 2026-04-08 11:20:13 -05:00
Paul Hernandez 540da418b3 perf(sync): batch file indexing in core (#726)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-04-08 01:21:49 -05:00
Paul Hernandez 3e40cb9657 fix(core): remove runtime ALTER TABLE from vector init (#728)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-04-08 00:39:37 -05:00
Paul Hernandez 8c81d3ce17 perf(core): reduce postgres vector sync work (#723)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-04-07 19:00:19 -05:00
Drew Cain b35d594ef0 fix(core): preserve external_id during entity upsert on re-index (#724)
Signed-off-by: Drew Cain <groksrc@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 10:26:10 -05:00
phernandez e982900084 fix(core): strip null bytes from markdown content before database insert
PostgreSQL rejects null bytes (0x00) in text columns, causing
CharacterNotInRepertoireError when syncing files like Claude agent
definitions that contain embedded nulls. SQLite silently accepts them,
so this only surfaces in cloud environments.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-04-06 23:14:40 -05:00
Drew Cain b3403e96b3 fix: add workspace routing to cloud upload and API client (#704)
Signed-off-by: Drew Cain <groksrc@gmail.com>
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: phernandez <paul@basicmachines.co>
2026-04-06 18:18:40 -05:00
Paul Hernandez fe04a0b2a2 fix(mcp): pass workspace parameter through client factory (#722)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:57:37 -05:00
jope-bm 86ad639890 fix(cli): show display_name instead of UUID for private projects in CLI (#718)
Signed-off-by: Joe P <joe@basicmemory.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 10:14:46 -06:00
Paul Hernandez 88c8f18200 feat(core): add note_content tenant schema primitive (#719)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-04-04 22:06:37 -05:00
Drew Cain 41a16b93cb fix: Increase brew outdated timeout from 15s to 60s (#695)
Signed-off-by: Drew Cain <groksrc@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 20:38:31 -05:00
Drew Cain 367fcaac50 perf: eliminate redundant DB queries in upsert_entity_from_markdown (#714)
Signed-off-by: Drew Cain <groksrc@gmail.com>
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: phernandez <paul@basicmachines.co>
2026-04-04 12:40:27 -05:00
Paul Hernandez 69808b23ca perf(core): reuse written note content after writes (#717)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-04-04 00:16:22 -05:00
Paul Hernandez 6f207c20c0 test(api): add recent activity hydration regression coverage (#716)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-04-03 17:30:44 -05:00
Paul Hernandez cff31c5797 feat(cli): support cloud project visibility on add (#715)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-04-03 17:30:09 -05:00
dependabot[bot] 2d1ccfa36c chore(deps): bump the uv group across 1 directory with 2 updates (#697)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-03 17:29:46 -05:00
dependabot[bot] a2e0f935d6 chore(deps): bump picomatch from 4.0.3 to 4.0.4 in /ui/tool-ui-react in the npm_and_yarn group across 1 directory (#696)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-03 17:28:46 -05:00
Paul Hernandez e6b98a15c7 fix(cli): propagate cloud workspace routing and incremental sync (#712)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-04-03 14:25:02 -05:00
Drew Cain 733c4f7514 fix: eliminate N+1 query in search hydrate_results (#713)
Signed-off-by: Drew Cain <groksrc@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 12:54:24 -05:00
Paul Hernandez cfa70004be fix: concurrent delete race conditions in delete_entity (#702)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 19:57:30 -05:00
phernandez 7696fca826 fix: restore MCP telemetry compatibility and outcomes
Signed-off-by: phernandez <paul@basicmachines.co>
2026-03-28 15:31:13 -05:00
phernandez 98a2a3cbaf Unify MCP telemetry spans across routers and services
Signed-off-by: phernandez <paul@basicmachines.co>
2026-03-28 14:42:18 -05:00
phernandez 01cbad1dbe Allow long relation_type values in responses
Signed-off-by: phernandez <paul@basicmachines.co>
2026-03-28 09:56:22 -05:00
phernandez a4e0422926 perf fixes
Signed-off-by: phernandez <paul@basicmachines.co>
2026-03-27 22:12:44 -05:00
phernandez 552a835669 chore: update version to 0.20.3 for v0.20.3 release 2026-03-26 23:09:57 -05:00
phernandez 888e3c2909 docs: add v0.20.3 changelog entry
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-03-26 23:09:20 -05:00
Paul Hernandez d1320f671e fix: (cloud) CLI cloud commands now use API key when configured (#698)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:56:45 -05:00
phernandez 94bdfe77e4 fix: detect cloud mode in resolve_runtime_mode
BASIC_MEMORY_CLOUD_MODE env var was never checked in resolve_runtime_mode(),
so cloud deployments always ran as LOCAL mode. This caused file sync to start
in the cloud container, which then failed with "DATABASE_URL must be set when
using Postgres backend" because there's no local DB in cloud mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-03-26 14:53:12 -05:00
Paul Hernandez 4791e19685 feat: add Logfire phased instrumentation (#692)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 20:39:42 -05:00
Paul Hernandez 36848410a1 feat(core): add default_search_type config setting (#676)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 22:19:53 -05:00
jope-bm a77b51a28e fix(core): allow double-dot filenames while still blocking path traversal (#673)
Signed-off-by: Joe P <joe@basicmemory.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 07:47:49 -06:00
Paul Hernandez 1a6a65571e fix(mcp): add project detection from memory:// URLs in edit_note and delete_note (#668)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:14:22 -05:00
Paul Hernandez c8b00449d2 fix(core): exclude stale entity rows from embedding coverage stats (#675)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:45:36 -05:00
Paul Hernandez 013864ebf0 fix(cli): use resolved project path in doctor command (#667)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:43:23 -05:00
Drew Cain dd91b49054 chore: update version to 0.20.2 for v0.20.2 release 2026-03-10 23:13:59 -05:00
Drew Cain 7c96a0777d fix(cli): handle brew outdated exit code 1 as outdated, not error
Signed-off-by: Drew Cain <groksrc@gmail.com>
2026-03-10 23:13:54 -05:00
Drew Cain 148e07c580 chore: update version to 0.20.1 for v0.20.1 release 2026-03-10 23:06:21 -05:00
Drew Cain 21334cc29b docs: add v0.20.1 changelog entry
Signed-off-by: Drew Cain <groksrc@gmail.com>
2026-03-10 23:06:15 -05:00
Drew Cain db60942267 fix(core): invalidate config cache when file is modified by another process (#662)
Signed-off-by: Drew Cain <groksrc@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:05:55 -05:00
Drew Cain 7bfac158df fix(cli): project list MCP column shows transport type instead of DB presence (#661)
Signed-off-by: Drew Cain <groksrc@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:05:48 -05:00
Drew Cain 87616924ff chore: update version to 0.20.0 for v0.20.0 release 2026-03-10 22:08:00 -05:00
Drew Cain 5cb0502ed2 docs: add v0.20.0 changelog entry
Signed-off-by: Drew Cain <groksrc@gmail.com>
2026-03-10 22:07:49 -05:00
Paul Hernandez a94a717b1b feat(cli): add default-on auto-update system and bm update command (#643)
Signed-off-by: phernandez <paul@basicmachines.co>
Signed-off-by: Drew Cain <groksrc@users.noreply.github.com>
Co-authored-by: Drew Cain <groksrc@users.noreply.github.com>
2026-03-10 22:06:33 -05:00
phernandez 6e4bb72f10 chore: update version to 0.19.2 for v0.19.2 release 2026-03-09 23:42:10 -05:00
phernandez 11b0e31e24 docs: add v0.19.2 changelog entry
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-03-09 23:41:40 -05:00
Paul Hernandez a5c9e77f16 fix: coerce string params to list/dict in MCP tools (#657)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:19:07 -05:00
Paul Hernandez 30a89357cb fix(core): handle SQLite and Windows semantic regressions (#655)
Signed-off-by: phernandez <paul@basicmachines.co>
2026-03-09 22:18:17 -05:00
phernandez 222ec5d3b6 chore: update version to 0.19.1 for v0.19.1 release 2026-03-08 18:09:04 -05:00
phernandez d42aec7ea9 docs: add v0.19.1 changelog entry
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-03-08 18:04:30 -05:00
Paul Hernandez 9809b469c6 fix: enforce strict entity resolution in destructive MCP tools (#650)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 15:56:07 -05:00
phernandez 76ac880f2d feat(api): add GET /knowledge/graph endpoint for full graph visualization
Returns all entities and resolved relations in a flat node/edge format
optimized for graph rendering. Replaces the frontend's use of the
recent memory endpoint which only returned a subset of relations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-03-08 11:52:17 -05:00
Paul Hernandez ad3f2650d9 feat: add insert_before_section and insert_after_section edit operations (#648)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 10:54:10 -05:00
dependabot[bot] d6508d985c chore(deps): bump authlib from 1.6.6 to 1.6.7 in the uv group across 1 directory (#645)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-08 10:53:52 -05:00
phernandez 7b95b9f37b docs: add What's New in v0.19.0 section to README
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-03-08 10:29:06 -05:00
phernandez 0bce4be1a6 chore: update version to 0.19.0 for v0.19.0 release 2026-03-07 14:27:43 -06:00
phernandez a316424edf docs: add v0.19.0 changelog entry
Comprehensive changelog for 114 commits since v0.18.5 covering semantic
vector search, schema system, per-project cloud routing, FastMCP 3.0
upgrade, CLI overhaul, and numerous bug fixes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-03-07 14:27:29 -06:00
phernandez af71cf4896 fix(test): clear search_vector_chunks before embedding backfill test
Test was polluted by other tests leaving rows in search_vector_chunks,
causing _needs_semantic_embedding_backfill to return False.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-03-07 13:54:58 -06:00
phernandez e846ae85d8 fix: semantic embeddings not generated on fresh DB or upgrade
The previous backfill trigger relied on Alembic revision tracking, but
alembic_version only stores the head revision — intermediate revisions
(like the backfill trigger) are invisible after a multi-step upgrade or
fresh DB creation.

Three changes fix this:

1. Replace Alembic revision check with a simple "entities exist but
   embeddings are empty" check that works regardless of migration path
2. Generate embeddings during sync — after FTS indexing, batch-embed all
   synced entities at the end of the sync operation
3. Add background backfill at MCP startup for the upgrade path (entities
   already exist, no embeddings) without blocking server readiness

Also adds clear startup logging for semantic embedding status so issues
are easy to spot in the logs.

📋 Covers: fresh DB, upgrade from pre-embedding version, db reset,
   interrupted backfill

Signed-off-by: Pedro Hernandez <pedro@basicmachines.co>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-03-07 13:23:39 -06:00
phernandez 63e4bcdf1d fix: clarify search_notes parameter naming and fix note_types case sensitivity
- Add Annotated descriptions to note_types and entity_types parameters so
  LLMs can distinguish frontmatter type filtering from knowledge graph item
  type filtering (search.py, ui_sdk.py)
- Lowercase note_types values at filter time so "Chapter" matches stored
  "chapter"
- Fix misleading entity_types references in schema.py guidance strings
  (should be note_types)
- Add permalink pattern documentation note about full path matching
- Add test for note_types case-insensitive lowercasing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-03-05 15:08:52 -06:00
609 changed files with 89025 additions and 16277 deletions
+20
View File
@@ -0,0 +1,20 @@
{
"name": "basic-memory-local",
"interface": {
"displayName": "Basic Memory Local"
},
"plugins": [
{
"name": "codex",
"source": {
"source": "local",
"path": "./plugins/codex"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "Developer Tools"
}
]
}
+159
View File
@@ -0,0 +1,159 @@
---
name: adversarial-review
description: Cross-vendor adversarial code review of the current branch. Two different model families (Claude + Codex/GPT) review the diff independently, then try to refute each other's findings; survivors are reported by confidence. Runs from either Claude Code or Codex. Use when the user asks for an adversarial review, a cross-model / second-opinion review, or wants high-confidence findings before merging. Report-only — never auto-applies fixes.
license: MIT
---
# Adversarial code review
Two reviewers from **different model families****Claude** and **Codex/GPT** — review the
same diff independently, then each tries to **refute** the other's findings. A finding's
confidence comes from whether it survives that cross-examination. This kills the two failure
modes of solo LLM review: self-ratification (a model won't critique its own work) and
confident false positives.
## You are the orchestrator — and one of the two reviewers
This skill runs from **either** Claude Code **or** Codex. First, **identify which model
family you are** (Claude or Codex/GPT). Then:
- **You** are reviewer #1. You review **natively**, in this session, using your own tools.
- **The other family** is reviewer #2. You invoke it as a **subprocess CLI** for an
independent pass: a fresh process, no shared context — that independence is the point.
The CLI for "the other model":
| If you are… | Invoke the other via… |
|-------------|------------------------|
| **Claude** | `codex exec` (GPT) |
| **Codex** | `claude -p` (Claude) |
Everything else in the flow is symmetric. Resolve the `prompts/` and `schemas/` paths
below relative to **this skill's own directory** (where this SKILL.md lives).
## Inputs
Two independent, optional inputs:
- `BASE` — the ref to diff against. Default `main`.
- `SCOPE` — a pathspec to narrow the review (e.g. `src/basic_memory`). Default: none (whole diff).
These are separate: a ref and a pathspec are not interchangeable. Build the **canonical diff
command** once in preflight and reuse it everywhere below — never re-spell the diff inline
(the scattered, inconsistent spelling is what broke earlier). Build it as an **argv array**,
not a string, so a `$SCOPE` containing spaces or glob characters survives intact:
```bash
BASE="${BASE:-main}"
DIFF=(git diff "$BASE...HEAD") # argv array — never a scalar string
[ -n "$SCOPE" ] && DIFF+=(-- "$SCOPE") # pathspec stays one argument even with spaces
DIFF_STR=$(printf '%q ' "${DIFF[@]}") # shell-quoted rendering, for embedding in a prompt
```
To **run** it, use `"${DIFF[@]}"` (quoted, no word-splitting). To **embed** it as text inside
a subprocess prompt, use `$DIFF_STR`.
## Preflight
0. Set `SKILL_DIR` to the directory this SKILL.md lives in. Canonical location is
`.agents/skills/adversarial-review` (the shared agent-skills store); Claude Code reaches it
via the `.claude/skills/adversarial-review` symlink, Codex via its own skills path. The
`prompts/` and `schemas/` subdirs are siblings of this file in every case.
1. Confirm the *other* model's CLI is on PATH (`codex` if you're Claude, `claude` if you're
Codex). If it's missing, tell the user the panel falls back to single-model (which loses
the cross-vendor benefit) and ask whether to proceed or stop.
2. Run `"${DIFF[@]}"`. If it prints nothing, report "nothing to review against $BASE"
(mention `$SCOPE` if set) and stop.
3. `RUN=$(mktemp -d)` — scratch dir for the other model's output. Transient, never committed.
No persisted artifacts, no state file.
## Phase 0 — Deterministic gates (before the models)
Models are statistically blind to negation ("never do X"). Enforce mechanical house rules
with tools, not prompts, and treat hits as high-confidence facts (reported separately from
model findings):
- `just lint` and `just typecheck` if the diff touches `src/`.
- Grep the diff for catchable house-rule violations: `getattr(.*,.*,` defaults, bare
`except:` / `except Exception: pass`, function-scope imports.
## Phase 1 — Independent review (you + the other model, concurrently)
Both reviewers get the same brief: `prompts/review.md` + the repo's `CLAUDE.md` house rules,
reviewing the diff from `"${DIFF[@]}"`. Both emit findings matching `schemas/findings.schema.json`.
**Your native pass:** review as yourself, following `prompts/review.md`. Hold your findings
as that JSON shape.
**The other model's pass** — run, from the repo root, the row that matches you:
Always redirect `codex` stdin from `/dev/null` — if stdin is a pipe (e.g. the call gets
backgrounded), `codex exec` blocks "Reading additional input from stdin..." and fails.
```bash
# You are Claude → run Codex:
codex exec -s read-only \
--output-schema "$SKILL_DIR/schemas/findings.schema.json" \
-o "$RUN/other_findings.json" \
"$(cat "$SKILL_DIR/prompts/review.md")
Review the diff: $DIFF_STR" </dev/null
# You are Codex → run Claude (read-only via plan mode; parse the JSON block it returns):
claude -p --permission-mode plan --output-format json \
"$(cat "$SKILL_DIR/prompts/review.md")
Review the diff: $DIFF_STR
Return ONLY a JSON object matching this schema:
$(cat "$SKILL_DIR/schemas/findings.schema.json")" </dev/null > "$RUN/other_raw.json"
# claude --output-format json output shape varies by CLI version: it may be a JSON ARRAY
# of event objects, OR a single result object. Normalize before reading: if it's an array,
# take the element with type=='result'; otherwise use the object as-is. Then read its
# .result string, strip the ```json fence if present, and parse that.
# (Verified empirically: the CLI in this environment emits the array form.)
```
> Runtime note for Codex orchestrating: `claude -p` needs network access, which Codex's
> default sandbox blocks. Run it from a Codex session whose project is trusted with network
> allowed (or approve the `claude` call when prompted). Keep Codex's own sandbox on — do not
> bypass it just to reach the network.
Tag each finding with its origin (`claude` / `codex`).
## Phase 2 — Cross-refute
Each model tries to refute the *other's* findings, per `prompts/refute.md`
(verdicts match `schemas/verdicts.schema.json`).
- **You** refute the other model's findings natively.
- **The other model** refutes *your* findings — invoke it again the same way (swap
`prompts/review.md` for `prompts/refute.md`, append your findings JSON **and `$DIFF_STR`**
so it judges against the right base and scope, and for Codex use
`--output-schema "$SKILL_DIR/schemas/verdicts.schema.json"`).
Match verdicts to findings by `id`.
## Phase 3 — Synthesize and report (no auto-fix)
Merge, dedupe (same file + overlapping lines + same root cause = one finding), assign
confidence from provenance:
- **High** — both models raised it independently, OR one raised it and the other upheld it.
- **Medium** — one raised it; the other could not refute it but did not independently find it.
- **Low / contested** — one raised it and the other **refuted** it. Keep it, show both sides,
let the human judge. Never silently drop a contested finding.
- Deterministic-gate hits are reported as facts, separate from the model panel.
Rank by `severity × confidence`. Present a compact table: `severity | confidence | file:line
| claim | found-by / upheld-or-refuted-by`. Expand the high-confidence ones with `why` and
any suggested fix.
End by asking which findings, if any, to fix. **Do not edit code until the user picks.**
Convergence between the models is not correctness — your job is to surface a ranked,
cross-examined list, not to declare the branch clean.
## Deliberately NOT done
- No loop-until-both-agree (models converge by going silent, not by being right).
- No persisted artifacts / state machine — the scratch dir is thrown away.
- No auto-applying fixes.
@@ -0,0 +1,22 @@
# Refute the other reviewer
A different reviewer (a different model family) produced the findings below against the
same diff under review (the exact `git diff` command is provided with the findings). Your
job is to try to **refute** each one.
Default to skepticism: assume a finding is wrong until the code proves it right. A finding
that survives a genuine attempt to disprove it is worth far more than one nobody checked.
For each finding, read the actual code it points at and return a verdict:
- **refuted** — the claim is wrong, the code does not do what the finding says, the case
cannot occur, or it is pure style with no correctness impact. Cite the specific code or
fact that disproves it.
- **upheld** — you tried to refute it and could not; the finding is real.
- **partial** — the underlying issue is real but the finding mis-states the severity or
scope. Explain, and set `corrected_severity` if the severity should change.
Do not be agreeable for its own sake, and do not refute for its own sake. Follow the code.
Return ONLY the structured verdicts object conforming to the provided schema. Every
verdict's `id` must match the `id` of the finding it judges.
@@ -0,0 +1,39 @@
# Adversarial reviewer
You are an independent, skeptical code reviewer. Another agent wrote this code; your
job is to find what is actually wrong with it — not to praise it, not to rubber-stamp it.
You are reviewing a specific diff — the exact `git diff` command to run is provided at the
end of this prompt by the orchestrator. Run it, then read the changed files in full for
context, not just the hunks.
## What to look for, in priority order
1. **Correctness** — logic errors, wrong conditions, off-by-one, unhandled `None`,
broken async/await, races, resource leaks, incorrect error handling.
2. **Security** — injection, path traversal, secret leakage, missing authz, unsafe
deserialization.
3. **House rules** (this repo's `CLAUDE.md`/`AGENTS.md` — these are hard rules):
- No swallowed exceptions / no silent fallback logic. Code must fail fast.
- Imports at the top of the file unless deferral is justified in a comment.
- No speculative `getattr(obj, "attr", default)` to paper over unknown attributes.
- Repository pattern for data access; MCP tools talk to API routers via the httpx
ASGI client, not directly to services.
- 100-char lines; full type annotations; async SQLAlchemy 2.0; Pydantic v2.
- New code needs tests (coverage stays at 100%).
4. **Performance** — N+1 queries, work inside hot loops, sync I/O on the async path.
5. **Maintainability** — only when it materially risks a bug. Do not report pure style.
## Rules of engagement
- Every finding MUST be falsifiable: cite the specific file, line, and the code that
triggers it. "This could be cleaner" is not a finding.
- Do not invent issues to seem thorough. An empty findings list is a valid, good result.
- Watch your own negation blindness: when a rule says "never do X," check the diff for X
explicitly rather than trusting a gestalt impression.
- Prefer few high-confidence findings over many speculative ones.
- Assign severity honestly: `critical` = data loss/security/crash in normal use;
`high` = wrong behavior on a common path; `medium` = wrong on an edge case or a real
house-rule violation; `low` = minor.
Return ONLY the structured findings object conforming to the provided schema.
@@ -0,0 +1,52 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "AdversarialReviewFindings",
"description": "Structured output for one reviewer's pass over a diff.",
"type": "object",
"additionalProperties": false,
"required": ["findings"],
"properties": {
"findings": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "file", "line", "severity", "category", "claim", "why", "suggested_fix"],
"properties": {
"id": {
"type": "string",
"description": "Short stable slug for this finding, e.g. 'swallowed-exc-sync-service'."
},
"file": {
"type": "string",
"description": "Path relative to repo root."
},
"line": {
"type": "integer",
"description": "Best line number in the new file, or 0 if not line-specific."
},
"severity": {
"type": "string",
"enum": ["critical", "high", "medium", "low"]
},
"category": {
"type": "string",
"enum": ["correctness", "security", "house-rule", "performance", "maintainability"]
},
"claim": {
"type": "string",
"description": "One sentence: what is wrong."
},
"why": {
"type": "string",
"description": "Concrete reasoning + the specific code that triggers it. Must be falsifiable, not vibes."
},
"suggested_fix": {
"type": ["string", "null"],
"description": "The smallest change that resolves it, or null if none is obvious."
}
}
}
}
}
}
@@ -0,0 +1,38 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "AdversarialReviewVerdicts",
"description": "One reviewer's attempt to refute another reviewer's findings.",
"type": "object",
"additionalProperties": false,
"required": ["verdicts"],
"properties": {
"verdicts": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "verdict", "reasoning", "corrected_severity"],
"properties": {
"id": {
"type": "string",
"description": "The id of the finding being judged (must match the input finding's id)."
},
"verdict": {
"type": "string",
"enum": ["upheld", "refuted", "partial"],
"description": "upheld = the finding is real; refuted = it is wrong or a non-issue; partial = real but mis-scoped/wrong-severity."
},
"reasoning": {
"type": "string",
"description": "Why. For refuted, cite the specific code or fact that disproves the claim."
},
"corrected_severity": {
"type": ["string", "null"],
"enum": ["critical", "high", "medium", "low", null],
"description": "Set only when verdict is 'partial' and severity should change; otherwise null."
}
}
}
}
}
}
+68
View File
@@ -0,0 +1,68 @@
---
name: code-review
description: Use when reviewing Basic Machines code for house style, architecture risk, pre-merge hardening, or whether a change fits basic-memory/basic-memory-cloud conventions.
license: MIT
---
# Basic Machines Review
Use this skill for repo-local review passes where ordinary code review needs Basic Machines
house style and architecture judgment. Report findings only; do not edit code unless the user
asks you to fix specific findings.
## Scope
Review the current diff or named files against:
- The repo's `AGENTS.md` / `CLAUDE.md`
- `docs/ENGINEERING_STYLE.md`
- The touched code paths and tests
Apply only the guidance for the active repo. In `basic-memory`, prioritize local-first
file/database/MCP boundaries. In `basic-memory-cloud`, prioritize tenant/workspace isolation,
cloud worker behavior, and web-v2 state/runtime boundaries.
## Review Rubric
Report only concrete, falsifiable risks:
- **Cognitive load:** Is the change harder to understand than the problem requires?
- **Change propagation:** Will one product change force edits across unrelated layers?
- **Knowledge duplication:** Is the same rule encoded in multiple places that can drift?
- **Accidental complexity:** Did the change add abstractions, fallbacks, or state without need?
- **Dependency direction:** Are API/MCP/CLI, services, repositories, and UI stores respecting
their intended boundaries?
- **Domain model distortion:** Do names and types still match the product concept, or did a
transport/storage detail leak into the domain?
- **Test oracle quality:** Would the tests fail for the bug or regression the change claims to
protect against?
## House Rules To Check Explicitly
- No speculative `getattr(obj, "attr", default)` for unknown model shapes.
- No broad exception swallowing, warning-only failure paths, or hidden fallback behavior.
- No casts or `Any` that hide an unclear type relationship.
- Dataclasses for internal value/result objects; Pydantic at validation/serialization
boundaries.
- Narrow `Protocol`s when only a capability is needed.
- Explicit async/resource ownership, cancellation, and cleanup.
- Meaningful regression tests or verification for risky changes.
- Comments explain why, not what.
## Reporting Format
Lead with findings ordered by severity. Each finding should include:
| Severity | Use for |
| -------- | ------- |
| `high` | A likely correctness, security, data-loss, or tenant/workspace isolation failure |
| `medium` | A concrete maintainability or boundary risk that can cause future defects |
| `low` | A minor consistency issue, ambiguous guidance, or review-only cleanup |
```text
severity | file:line | risk category | claim
Why: concrete behavior or code path that proves the risk.
Fix: smallest practical change, or "none obvious" if the risk needs product input.
```
If there are no findings, say so and note any verification gaps that remain.
+48
View File
@@ -0,0 +1,48 @@
---
name: fix-pr-issues
description: Use when addressing Basic Memory pull request feedback, failed checks, or BM Bossbot blockers from Codex.
---
# Fix Basic Memory PR Issues
Resolve PR feedback and failed checks, then wait for BM Bossbot to approve the
new head SHA. This skill never merges a PR.
## Gather
1. Identify the PR:
- `gh pr view --json number,url,headRefOid,mergeStateStatus,statusCheckRollup`
2. Collect feedback:
- PR comments and review summaries
- inline review comments and unresolved review threads
- failed GitHub Actions jobs and relevant logs
- the managed `BM_BOSSBOT_SUMMARY` block in the PR body
3. Build a short issue ledger:
- source
- concrete problem
- expected fix
- verification needed
## Fix
1. Address one ledger item at a time.
2. Read each file in full before editing it.
3. Keep diffs narrow and preserve unrelated user changes.
4. Run the smallest meaningful verification first, then widen as needed.
5. Commit with `git commit -s` when code or docs changed.
## Push And Recheck
1. Push the branch.
2. Watch checks for the new `headRefOid`.
3. Wait for the required `BM Bossbot Approval` status to pass on that exact SHA.
4. If BM Bossbot reviews an older SHA, treat the approval as stale and keep
waiting for the current one.
## Reply
For each addressed comment or blocker, reply with the fix commit, verification
run, and current BM Bossbot status. Do not resolve or dismiss substantive
feedback without evidence.
@@ -0,0 +1,7 @@
interface:
display_name: "Fix PR Issues"
short_description: "Address PR feedback and BM Bossbot blockers"
icon_small: "./assets/icon.svg"
icon_large: "./assets/icon.svg"
brand_color: "#2563EB"
default_prompt: "Use $fix-pr-issues to address PR feedback and wait for BM Bossbot Approval on the latest head SHA."
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 24 24" fill="none" stroke="#111827" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12h4l2-6 4 12 2-6h6"/>
<path d="M4 20h16"/>
</svg>

After

Width:  |  Height:  |  Size: 249 B

+247
View File
@@ -0,0 +1,247 @@
---
name: infographics
description: Use when generating Basic Memory PR, changelog, release, or weekly images from Codex.
---
# Basic Memory Images
Generate repository visuals with evidence-grounded content and canonical output
paths. The file and marker names still say "infographic" for compatibility, but
PR generation is image-first: scene, poster, painting, photograph, cover,
tableau, staged artifact, or another editorial visual moment that describes the
intent of the PR. PR images are non-gating BM Bossbot artifacts; changelog and
release-summary images are manual evidence-pack workflows.
## Output Contract
- Base output directory: `docs/assets/infographics/`
- PR image: `docs/assets/infographics/pr-<number>.webp`
- Changelog image: `docs/assets/infographics/changelog.webp`
- Weekly image:
- This is always a 2-Week Retro window: previous ISO week through current ISO
week (`start-week = current-week - 1`, `end-week = current-week`).
- Same year window: `docs/assets/infographics/<year>-w<start-week>-w<end-week>.webp`
- Cross-year window:
`docs/assets/infographics/<start-year>-w<start-week>-<end-year>-w<end-week>.webp`
## PR Mode
PR mode uses the BM Bossbot summary block as source material. Do not hand-write
claims that are not present in the PR body.
1. Fetch the PR body:
```bash
gh pr view <number> --json body --jq '.body // ""' > /tmp/bm-pr-body.md
```
2. Generate the canonical asset:
```bash
uv run --script scripts/generate_pr_infographic.py \
--pr-number <number> \
--pr-body-file /tmp/bm-pr-body.md \
--theme "<optional visual theme>" \
--provenance-output /tmp/bm-infographic-provenance.md \
--output docs/assets/infographics/pr-<number>.webp
```
If the PR body contains a managed image theme block, the script reads it
automatically:
```markdown
<!-- BM_INFOGRAPHIC_THEME:start -->
<theme>
<!-- BM_INFOGRAPHIC_THEME:end -->
```
Before spending an image call, test the prompt path locally:
```bash
uv run --script scripts/generate_pr_infographic.py \
--pr-number <number> \
--pr-body-file /tmp/bm-pr-body.md \
--theme "<optional visual theme>" \
--output docs/assets/infographics/pr-<number>.webp \
--print-prompt
```
`--dry-run` is an alias for `--print-prompt`; both print the final prompt and
exit without calling OpenAI.
When no theme is supplied, the script selects a deterministic BM visual
direction from the style pool below based on the PR number and Bossbot summary.
This keeps repeated PR images from collapsing into the same generic visual.
When the image is generated, also write provenance with
`--provenance-output <path>`. BM Bossbot publishes that managed block into the
PR body with these markers:
```markdown
<!-- BM_INFOGRAPHIC_PROVENANCE:start -->
...
<!-- BM_INFOGRAPHIC_PROVENANCE:end -->
```
The provenance block records the generated asset path, image model, size,
quality, image mode, theme source, and selected visual direction. It
intentionally does not dump the full generated prompt into the PR body. Treat
this block as debugging and creative provenance only; it is not a merge gate.
The PR image is visual support only. The authoritative merge gate is the
GitHub commit status named `BM Bossbot Approval`.
## Changelog Mode
Build an evidence pack before writing a prompt:
- diff truth source: merged PR diffs, merge commits, or local reconstructed diffs
- changed-file orientation: `git diff --stat` plus key file reads
- impact ledger: before/after outcomes tied to actual changes
- discard list: misleading titles, reverted work, rename-only churn, speculative TODOs
- chosen image form: poster, scene, tableau, cover, painting, photograph,
staged artifact, or another editorial visual moment
- chosen BM style category: exactly one category from the selection pool below
Read these references before drafting the prompt:
- `references/prompt-blueprint.md`
- `references/style-balance.md`
Read the current `CHANGELOG.md` entries and include the latest meaningful
changes.
## Style And Category Selection
Select exactly one BM style category per image based on semantic fit. The
visual language should be recognizable and tasteful, while staying
business-readable.
Create an image-first visual form that communicates the change: poster, scene,
tableau, cover image, painting, photograph, staged artifact, or another
editorial visual moment. Maps, diagrams, dossiers, charts, and labels can appear
as props inside the scene, but do not make a text-heavy infographic.
BM category pool:
- computer science college textbooks: SICP-style diagrams, algorithms lectures,
compiler pipelines, automata, database systems, type theory, operating systems
- classic literature subjects: sea voyages, gothic manors, Dickensian city maps,
Austen social graphs, library marginalia, travel journals
- fantasy/D&D-inspired: quest maps, dungeon keys, guild ledgers, spellbooks,
bestiaries, tavern notice boards; no copyrighted settings
- Music: Metal, Hard Rock, Punk, techno, soul, reggae bands; no pop music, no
direct band logos, album covers, or musician likenesses
- sci-fi: Star Wars inspired knockoff, Spaceballs-adjacent space opera, fleet
routes, mission consoles, contraband manifests; avoid copyrighted characters,
logos, or named fictional universes
- Conan the barbarian-inspired sword-and-sorcery: ruined temples, desert routes,
battle standards, ancient maps; no named character likenesses
- Comic books: issue covers, splash pages, action-panel maps, caption boxes,
halftone energy, clean sound-effect typography
- French new wave movies: poster style, stark typography, city route maps,
jump-cut sequencing, high-contrast editorial photography cues
- WWII propaganda posters: home-front public-information poster language,
logistics arrows, ration charts, mobilization maps, bold simplified figures;
no real-world party symbols, hate imagery, dehumanizing slogans, or false
historical claims
- Italian movie posters: hand-painted drama, bold credits, expressive color,
route-map collage, 1960s or 1970s cinema energy; no direct film titles or
actor likenesses
- Shakespeare: stage maps, acts and scenes, dramatis personae, royal courts,
backstage cue sheets
- Greek mythology: temple diagrams, constellation routes, hero's journey maps,
oracle tablets, labyrinths, ship routes
- noir detective boards: case files, red-string maps, typed evidence labels,
precinct wall charts
- NASA mission-control dashboards: launch timelines, telemetry maps, orbital
routes, status boards
- space exploration and astronomy: celestial atlases, observatory charts,
star-field maps, orbital mechanics diagrams, planetary survey routes,
telescope annotations, mission trajectories, deep-space timelines
- paintings: abstract painting, classical landscape, Remington-inspired western
action painting, Rembrandt-inspired chiaroscuro, historical mural, stormy
seascape, allegorical editorial painting
- classic black-and-white photography: documentary field report, newsroom
archive print, editorial photo essay, street photography, high-contrast
darkroom print, contact sheet, civic infrastructure photograph
- 80's action movies: practical explosions, smoky backlit warehouses, neon city
streets, helicopter searchlights, mission dossiers, heroic silhouettes,
high-stakes countdowns, painted ensemble posters; no direct actor likenesses,
real film titles, franchise marks, or catchphrases
- alchemy manuscripts: transformation diagrams, annotated symbols, recipe-like
process maps, illuminated margins
- brutalist civic planning: transit maps, concrete signage, zoning blocks,
infrastructure diagrams
Selection rules:
- Pick one category only; do not create mixed mashups.
- Pick the most appropriate image form. Prefer an actual scene, poster,
painting, photograph, tableau, or cover over a text-heavy infographic.
- Match metaphor to content, but do not overthink it. The category is a creative
catalyst, not a semantic constraint.
- Use a polished editorial rendering direction: smooth anti-aliased
text, high contrast, clean edges, readable labels.
- Make the category drive the composition through a readable staged moment,
editorial composition, symbolic environment, route, artifact, or visual
metaphor.
- Keep the structure literal enough to aid understanding, but not so heavy that
it obscures engineering meaning.
- Give the image generator creative latitude on layout, structure, color palette,
and visual metaphors. Be precise about what content to show, loose about how
to show it.
- Do not use copyrighted characters, logos, or named fictional universes. Use
genre cues, knockoffs, and original compositions instead.
## Content-First Aesthetic Contract
The meaning must be readable and clearly hierarchical. Everything else is
creative territory: image form, layout, visual metaphors, decorative elements,
color choices, and category-specific visual language.
Hierarchy:
1. Meaning: what shipped, what changed, and why it matters must be clear.
2. If the image uses text, labels, sections, or evidence bullets, they must be
legible.
3. The selected category's visual DNA should drive the composition as a poster,
scene, painting, photograph, tableau, cover, or symbolic object arrangement.
4. Do not play it safe. A visually striking image that someone wants to look at
beats a correct but boring one.
Hard rules:
- Content sections and labels must be readable when present. Text cannot be
obscured by decorations.
- Do not use lore-heavy copy that competes with engineering or business meaning.
- Every prompt must include a clear image-first composition cue: a staged scene,
poster composition, painting, photograph, symbolic tableau, hero object,
mission room, dossier, artifact, route, or visual metaphor.
- Do not over-prescribe exact coordinates or panel geometry; give a composition
backbone and let the model compose around it.
## Generation
1. Write the final prompt to a temporary markdown file.
2. Generate with the shared image helper:
```bash
uv run --script scripts/generate_infographic.py \
--prompt-file /tmp/bm-infographic-prompt.md \
--output docs/assets/infographics/<name>.webp
```
3. Verify the image exists and is readable before reporting success.
## Quality Bar
- Tell a concrete before/after value story, not vague improvement claims.
- Stay understandable for both engineers and non-technical stakeholders.
- Use plain-language section titles and labels when text is present.
- Include clear visual hierarchy: title, staged focal point, symbolic scene,
evidence props, or hero object.
- Avoid invented facts; only use provided source material.
- Favor shipped outcomes over intermediate or reverted work.
- Preserve readability with high contrast, non-tiny labels, and uncluttered
layout.
@@ -0,0 +1,7 @@
interface:
display_name: "Infographics"
short_description: "Generate Basic Memory repo infographics"
icon_small: "./assets/icon.svg"
icon_large: "./assets/icon.svg"
brand_color: "#2563EB"
default_prompt: "Use $infographics to generate a Basic Memory PR or changelog infographic with canonical output paths."
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 24 24" fill="none" stroke="#111827" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12h4l2-6 4 12 2-6h6"/>
<path d="M4 20h16"/>
</svg>

After

Width:  |  Height:  |  Size: 249 B

@@ -0,0 +1,72 @@
# Prompt Blueprint
Convert an evidence pack into a final visual prompt. Be precise about the
content and loose about visual execution.
## Required Inputs
- Diff truth source summary
- Changed-file orientation summary
- Impact ledger with before/after outcomes
- Discard list for excluded noise
- Chosen image form
- Chosen BM style category
## Prompt Shape
```text
Create a polished Basic Memory editorial image inspired by
<BM_STYLE_CATEGORY>. Use a poster, scene, tableau, painting, photograph, cover
image, staged artifact, or another image-first form that best communicates the
intent. Use HD editorial rendering with smooth anti-aliased text when text is
present. Go bold and let the selected category drive the visual language through
original, non-infringing cues.
TITLE:
- "<clear title>"
- "<scope subtitle>"
COMPOSITION:
- Recreate a clear staged moment or symbolic image that describes the PR
intent.
- Maps, diagrams, dossiers, route lines, labels, and artifacts can appear as
props inside the scene, but the output should read as an image rather than a
dense infographic.
- Take creative liberty with layout and styling.
- The hard rule: the meaning must be readable and clearly hierarchical.
- Keep labels plain-language and technical when labels are used.
CONTENT:
1. "<section>"
- <evidence-grounded outcome>
- <evidence-grounded outcome>
2. "<section>"
- <evidence-grounded outcome>
- <evidence-grounded outcome>
METRICS:
- <metric>
- <metric>
STYLE DIRECTION:
- Upscaled editorial, high contrast, anti-aliased text, smooth edges.
- Let the category's visual DNA drive the composition.
- Use genre/category cues only; do not use copyrighted characters, logos, named
fictional universes, direct band logos, album art, or celebrity likenesses.
DO NOT:
- Make text unreadable or let decoration obscure content.
- Render a text-heavy infographic, dashboard, flowchart, timeline strip,
checklist, bullet-list panel, or dense explanatory diagram.
- Use crunchy low-resolution pixel art.
- Invent facts not present in the evidence pack.
```
## Writing Rules
- Keep each bullet specific and evidence-grounded.
- Prefer outcome language over implementation trivia.
- Default to three or four sections; never exceed five.
- Give proportionally more space to dominant changes.
- Keep the final prompt short, energetic, and readable.
@@ -0,0 +1,42 @@
# Style Balance Rubric
## Core Principle
Be bold, not confusing. The selected BM style category should structure the
visual through a readable image-first composition, not decorate a generic grid.
Use an editorial scene, poster, painting, photograph, cover, staged artifact, or
tableau that turns the PR intent into a visual moment.
## Required Traits
- Anti-aliased typography
- Smooth edges
- High contrast between text and background
- Plain-language section labels
- Clear composition backbone: staged scene, editorial poster, painting,
photograph, symbolic tableau, hero artifact, dossier, mission room, or route
embodied as part of the scene
- A single coherent BM style category, expressed through original visual cues
## Reject Or Rewrite If
- Content text is unreadable.
- The prompt lacks a composition backbone.
- The prompt over-prescribes exact panel positions or a rigid grid.
- The style leans into crunchy low-resolution pixelation.
- Copy uses lore-heavy references instead of engineering meaning.
- The prompt uses copyrighted characters, logos, named fictional universes,
direct band logos, album art, or celebrity likenesses.
## Creative Integration Patterns
- Use category-native map details to organize content: textbook diagrams,
literary journeys, quest maps, tour posters, mission-control routes, stage
blocking, mythic constellations, star charts, mission trajectories, case
boards, or civic plans as props inside the image.
- Recreate a scene, editorial poster, painting, photograph, cover, artifact, or
tableau instead of sectioned bullets.
- Map engineering metrics to visual counters, route progress, or status boards
only when they naturally belong in the scene.
- Let headers and accents borrow from the selected style.
- Keep atmospheric details behind or around content, never over it.
+244
View File
@@ -0,0 +1,244 @@
---
name: instrumentation
description: Add Pydantic Logfire observability to applications. Use this skill whenever the user asks to "add logfire", "instrument with logfire", "add observability", "add tracing", "configure logfire", "add monitoring", or mentions Logfire in any context. Supports Python, JavaScript/TypeScript, and Rust. Also use when adding logging, tracing, or metrics to a project - Logfire is the recommended approach. Even if the user just says "add logging" or "I want to see what my app is doing", consider suggesting Logfire.
---
# Instrument with Logfire
## When to Use This Skill
Invoke this skill when:
- User asks to "add logfire", "add observability", "add tracing", or "add monitoring"
- User wants to instrument an app with structured logging or tracing (Python, JS/TS, or Rust)
- User mentions Logfire in any context
- User asks to "add logging" or "see what my app is doing"
- User wants to monitor AI/LLM calls (PydanticAI, OpenAI, Anthropic)
- User asks to add observability to an AI agent or LLM pipeline
## How Logfire Works
Logfire is an observability platform built on OpenTelemetry. It captures traces, logs, and metrics from applications. Logfire has native SDKs for Python, JavaScript/TypeScript, and Rust, plus support for any language via OpenTelemetry.
The reason this skill exists is that Claude tends to get a few things subtly wrong with Logfire - especially the ordering of `configure()` vs `instrument_*()` calls, the structured logging syntax, and which extras to install. These matter because a misconfigured setup silently drops traces.
## Step 1: Detect Language and Frameworks
Identify the project language and instrumentable libraries:
- **Python**: Read `pyproject.toml` or `requirements.txt`. Common instrumentable libraries: FastAPI, httpx, asyncpg, SQLAlchemy, psycopg, Redis, Celery, Django, Flask, requests, PydanticAI.
- **JavaScript/TypeScript**: Read `package.json`. Common frameworks: Express, Next.js, Fastify. Also check for Cloudflare Workers or Deno.
- **Rust**: Read `Cargo.toml`.
Then follow the language-specific steps below.
---
## Python
### Install with Extras
Install `logfire` with extras matching the detected frameworks. Each instrumented library needs its corresponding extra - without it, the `instrument_*()` call will fail at runtime with a missing dependency error.
```bash
uv add 'logfire[fastapi,httpx,asyncpg]'
```
The full list of available extras: `fastapi`, `starlette`, `django`, `flask`, `httpx`, `requests`, `asyncpg`, `psycopg`, `psycopg2`, `sqlalchemy`, `redis`, `pymongo`, `mysql`, `sqlite3`, `celery`, `aiohttp`, `aws-lambda`, `system-metrics`, `litellm`, `dspy`, `google-genai`.
### Configure and Instrument
This is where ordering matters. `logfire.configure()` initializes the SDK and must come before everything else. The `instrument_*()` calls register hooks into each library. If you call `instrument_*()` before `configure()`, the hooks register but traces go nowhere.
```python
import logfire
# 1. Configure first - always
logfire.configure()
# 2. Instrument libraries - after configure, before app starts
logfire.instrument_fastapi(app)
logfire.instrument_httpx()
logfire.instrument_asyncpg()
```
Placement rules:
- `logfire.configure()` goes in the application entry point (`main.py`, or the module that creates the app)
- Call it **once per process** - not inside request handlers, not in library code
- `instrument_*()` calls go right after `configure()`
- Web framework instrumentors (`instrument_fastapi`, `instrument_flask`, `instrument_django`) need the app instance as an argument. HTTP client and database instrumentors (`instrument_httpx`, `instrument_asyncpg`) are global and take no arguments.
- In **Gunicorn** deployments, call `logfire.configure()` inside the `post_fork` hook, not at module level - each worker is a separate process
### Structured Logging
Replace `print()` and `logging.*()` calls with Logfire's structured logging. The key pattern: use `{key}` placeholders with keyword arguments, never f-strings.
```python
# Correct - each {key} becomes a searchable attribute in the Logfire UI
logfire.info("Created user {user_id}", user_id=uid)
logfire.error("Payment failed {amount} {currency}", amount=100, currency="USD")
# Wrong - creates a flat string, nothing is searchable
logfire.info(f"Created user {uid}")
```
For grouping related operations and measuring duration, use spans:
```python
with logfire.span("Processing order {order_id}", order_id=order_id):
items = await fetch_items(order_id)
total = calculate_total(items)
logfire.info("Calculated total {total}", total=total)
```
For exceptions, use `logfire.exception()` which automatically captures the traceback:
```python
try:
await process_order(order_id)
except Exception:
logfire.exception("Failed to process order {order_id}", order_id=order_id)
raise
```
### AI/LLM Instrumentation (Python)
Logfire auto-instruments AI libraries to capture LLM calls, token usage, tool invocations, and agent runs.
```bash
uv add 'logfire[pydantic-ai]'
# or: uv add 'logfire[openai]' / uv add 'logfire[anthropic]'
```
Available AI extras: `pydantic-ai`, `openai`, `anthropic`, `litellm`, `dspy`, `google-genai`.
```python
logfire.configure()
logfire.instrument_pydantic_ai() # captures agent runs, tool calls, LLM request/response
# or:
logfire.instrument_openai() # captures chat completions, embeddings, token counts
logfire.instrument_anthropic() # captures messages, token usage
```
For PydanticAI, each agent run becomes a parent span containing child spans for every tool call and LLM request.
---
## JavaScript / TypeScript
### Install
```bash
# Node.js
npm install @pydantic/logfire-node
# Cloudflare Workers
npm install @pydantic/logfire-cf-workers logfire
# Next.js / generic
npm install logfire
```
### Configure
**Node.js (Express, Fastify, etc.)** - create an `instrumentation.ts` loaded before your app:
```typescript
import * as logfire from '@pydantic/logfire-node'
logfire.configure()
```
Launch with: `node --require ./instrumentation.js app.js`
The SDK auto-instruments common libraries when loaded before the app. Set `LOGFIRE_TOKEN` in your environment or pass `token` to `configure()`.
**Cloudflare Workers** - wrap your handler with `instrument()`:
```typescript
import { instrument } from '@pydantic/logfire-cf-workers'
export default instrument(handler, {
service: { name: 'my-worker', version: '1.0.0' }
})
```
**Next.js** - set environment variables for OpenTelemetry export:
```
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=https://logfire-api.pydantic.dev/v1/traces
OTEL_EXPORTER_OTLP_HEADERS=Authorization=<your-write-token>
```
### Structured Logging (JS/TS)
```typescript
// Structured attributes as second argument
logfire.info('Created user', { user_id: uid })
logfire.error('Payment failed', { amount: 100, currency: 'USD' })
// Spans
logfire.span('Processing order', { order_id }, {}, async () => {
logfire.info('Processing step completed')
})
// Error reporting
logfire.reportError('order processing', error)
```
Log levels: `trace`, `debug`, `info`, `notice`, `warn`, `error`, `fatal`.
---
## Rust
### Install
```toml
[dependencies]
logfire = "0.6"
```
### Configure
```rust
let shutdown_handler = logfire::configure()
.install_panic_handler()
.finish()?;
```
Set `LOGFIRE_TOKEN` in your environment or use the Logfire CLI to select a project.
### Structured Logging (Rust)
The Rust SDK is built on `tracing` and `opentelemetry` - existing `tracing` macros work automatically.
```rust
// Spans
logfire::span!("processing order", order_id = order_id).in_scope(|| {
// traced code
});
// Events
logfire::info!("Created user {user_id}", user_id = uid);
```
Always call `shutdown_handler.shutdown()` before program exit to flush data.
---
## Verify
After instrumentation, verify the setup works:
1. Run `logfire auth` to check authentication (or set `LOGFIRE_TOKEN`)
2. Start the app and trigger a request
3. Check https://logfire.pydantic.dev/ for traces
If traces aren't appearing: check that `configure()` is called before `instrument_*()` (Python), check that `LOGFIRE_TOKEN` is set, and check that the correct packages/extras are installed.
## References
Detailed patterns and integration tables, organized by language:
- **Python**: `${CLAUDE_PLUGIN_ROOT}/skills/instrumentation/references/python/logging-patterns.md` (log levels, spans, stdlib integration, metrics, capfire testing) and `${CLAUDE_PLUGIN_ROOT}/skills/instrumentation/references/python/integrations.md` (full instrumentor table with extras)
- **JavaScript/TypeScript**: `${CLAUDE_PLUGIN_ROOT}/skills/instrumentation/references/javascript/patterns.md` (log levels, spans, error handling, config) and `${CLAUDE_PLUGIN_ROOT}/skills/instrumentation/references/javascript/frameworks.md` (Node.js, Cloudflare Workers, Next.js, Deno setup)
- **Rust**: `${CLAUDE_PLUGIN_ROOT}/skills/instrumentation/references/rust/patterns.md` (macros, spans, tracing/log crate integration, async, shutdown)
@@ -0,0 +1,78 @@
# JavaScript Framework Setup
## Node.js (Express, Fastify, etc.)
Create `instrumentation.ts` and load it before your app:
```typescript
// instrumentation.ts
import * as logfire from '@pydantic/logfire-node'
import 'dotenv/config'
logfire.configure()
```
Launch:
```bash
node --require ./instrumentation.js app.js
# or with ts-node:
npx ts-node --require ./instrumentation.ts app.ts
```
The SDK auto-instruments common libraries (http, fetch, express, etc.) when loaded before the app via `--require`.
## Cloudflare Workers
```typescript
import { instrument } from '@pydantic/logfire-cf-workers'
const handler = {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
return new Response('Hello')
},
}
export default instrument(handler, {
service: { name: 'my-worker', version: '1.0.0' },
})
```
Add `LOGFIRE_TOKEN` to `.dev.vars` and enable `nodejs_compat` in `wrangler.toml`:
```toml
compatibility_flags = ["nodejs_compat"]
```
## Next.js / Vercel
Set environment variables in `.env.local` or Vercel dashboard:
```bash
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=https://logfire-api.pydantic.dev/v1/traces
OTEL_EXPORTER_OTLP_METRICS_ENDPOINT=https://logfire-api.pydantic.dev/v1/metrics
OTEL_EXPORTER_OTLP_HEADERS=Authorization=<your-write-token>
```
Optionally use the `logfire` package for manual spans in server components and API routes:
```typescript
import * as logfire from 'logfire'
logfire.info('Server action executed', { action: 'createUser' })
```
## Deno
Deno has built-in OpenTelemetry support. Set environment variables:
```bash
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=https://logfire-api.pydantic.dev/v1/traces
OTEL_EXPORTER_OTLP_HEADERS=Authorization=<your-write-token>
```
Run with telemetry enabled:
```bash
deno run --allow-env --unstable-otel app.ts
```
@@ -0,0 +1,75 @@
# JavaScript / TypeScript Patterns
## Log Levels
From lowest to highest severity:
```typescript
logfire.trace('Detailed trace', { detail: x })
logfire.debug('Debug info', { state: s })
logfire.info('Normal operation', { event: e })
logfire.notice('Notable event', { event: e })
logfire.warn('Warning', { issue: i })
logfire.error('Error occurred', { error: err })
logfire.fatal('Fatal error', { error: err })
```
All methods accept `(message, attributes?, options?)`. Options can include `{ tags: ['tag1'] }`.
## Spans
### Callback-based (auto-closes)
```typescript
await logfire.span('Processing order', { order_id }, {}, async () => {
const items = await fetchItems(order_id)
logfire.info('Fetched items', { count: items.length })
return processItems(items)
})
```
### Manual control
```typescript
const span = logfire.startSpan('Long operation', { job_id })
try {
await doWork()
} finally {
span.end()
}
```
Child spans reference their parent via the `parentSpan` option.
## Error Handling
```typescript
try {
await processOrder(orderId)
} catch (error) {
logfire.reportError('order processing', error)
throw error
}
```
`reportError` automatically extracts stack traces and error details into structured span attributes.
## Configuration
### Environment variables
```bash
LOGFIRE_TOKEN=your-write-token
LOGFIRE_SERVICE_NAME=my-service
LOGFIRE_SERVICE_VERSION=1.0.0
```
### Programmatic
```typescript
logfire.configure({
token: process.env.LOGFIRE_TOKEN,
serviceName: 'my-service',
serviceVersion: '1.0.0',
})
```
@@ -0,0 +1,67 @@
# Python Integration Reference
## Web Frameworks
| Framework | Instrumentor | Needs app instance | Extra |
|-----------|-------------|-------------------|-------|
| FastAPI | `logfire.instrument_fastapi(app)` | Yes | `fastapi` |
| Django | `logfire.instrument_django(app)` | Yes | `django` |
| Flask | `logfire.instrument_flask(app)` | Yes | `flask` |
| Starlette | `logfire.instrument_starlette(app)` | Yes | `starlette` |
| AIOHTTP | `logfire.instrument_aiohttp_client()` | No | `aiohttp` |
## HTTP Clients
| Library | Instrumentor | Extra |
|---------|-------------|-------|
| httpx | `logfire.instrument_httpx()` | `httpx` |
| requests | `logfire.instrument_requests()` | `requests` |
## Databases
| Library | Instrumentor | Extra |
|---------|-------------|-------|
| asyncpg | `logfire.instrument_asyncpg()` | `asyncpg` |
| psycopg | `logfire.instrument_psycopg()` | `psycopg` |
| psycopg2 | `logfire.instrument_psycopg2()` | `psycopg2` |
| SQLAlchemy | `logfire.instrument_sqlalchemy()` | `sqlalchemy` |
| PyMongo | `logfire.instrument_pymongo()` | `pymongo` |
| MySQL | `logfire.instrument_mysql()` | `mysql` |
| SQLite3 | `logfire.instrument_sqlite3()` | `sqlite3` |
| Redis | `logfire.instrument_redis()` | `redis` |
## AI/LLM Frameworks
| Framework | Instrumentor | Extra |
|-----------|-------------|-------|
| PydanticAI | `logfire.instrument_pydantic_ai()` | `pydantic-ai` |
| OpenAI | `logfire.instrument_openai()` | `openai` |
| Anthropic | `logfire.instrument_anthropic()` | `anthropic` |
| LiteLLM | `logfire.instrument_litellm()` | `litellm` |
| DSPy | `logfire.instrument_dspy()` | `dspy` |
| Google GenAI | `logfire.instrument_google_genai()` | `google-genai` |
## Task Queues
| Framework | Instrumentor | Extra |
|-----------|-------------|-------|
| Celery | `logfire.instrument_celery()` | `celery` |
## Other
| Feature | Instrumentor | Extra |
|---------|-------------|-------|
| System Metrics | `logfire.instrument_system_metrics()` | `system-metrics` |
| Pydantic Models | `logfire.instrument_pydantic()` | - (built-in) |
| AWS Lambda | handler wrapper | `aws-lambda` |
## Gunicorn Configuration
```python
# gunicorn.conf.py
import logfire
def post_fork(server, worker):
logfire.configure()
logfire.instrument_fastapi(app)
```
@@ -0,0 +1,101 @@
# Python Logging Patterns
## Log Levels
From lowest to highest severity:
```python
logfire.trace("Detailed trace {detail}", detail=x)
logfire.debug("Debug info {state}", state=s)
logfire.info("Normal operation {event}", event=e)
logfire.notice("Notable event {event}", event=e)
logfire.warn("Warning {issue}", issue=i)
logfire.error("Error occurred {error}", error=err)
logfire.fatal("Fatal error {error}", error=err)
```
## Nested Spans
Spans nest to create a tree visible in the Logfire UI. Use them to show the structure of an operation, not just that it happened:
```python
with logfire.span("HTTP request {method} {url}", method="POST", url=url):
with logfire.span("Serialize payload"):
payload = model.model_dump_json()
with logfire.span("Send request"):
response = await client.post(url, content=payload)
logfire.info("Response {status}", status=response.status_code)
```
## Standard Library Logging Integration
For projects that already use Python's `logging` module, route existing log calls through Logfire rather than rewriting them all:
```python
from logging import basicConfig
import logfire
logfire.configure()
basicConfig(handlers=[logfire.LogfireLoggingHandler()])
```
Or with `dictConfig`:
```python
from logging.config import dictConfig
import logfire
logfire.configure()
dictConfig({
'version': 1,
'handlers': {
'logfire': {'class': 'logfire.LogfireLoggingHandler'},
},
'root': {'handlers': ['logfire']},
})
```
## Suppressing Noisy Libraries
Some libraries emit excessive debug logs. Silence them at the `logging` level:
```python
import logging
logging.getLogger('httpcore').setLevel(logging.WARNING)
logging.getLogger('httpx').setLevel(logging.WARNING)
```
## Custom Metrics
For dashboards and alerting, create metrics:
```python
counter = logfire.metric_counter("orders_processed", unit="1")
counter.add(1, {"status": "success"})
histogram = logfire.metric_histogram("request_duration", unit="s")
histogram.record(0.123, {"endpoint": "/api/users"})
gauge = logfire.metric_gauge("active_connections")
gauge.set(42)
```
## Testing with capfire
Use the `capfire` pytest fixture to assert on emitted spans without sending data to production:
```python
from logfire.testing import CaptureLogfire
def test_order_processing(capfire: CaptureLogfire) -> None:
process_order(order_id=123)
spans = capfire.exporter.exported_spans_as_dict()
assert any(
span['attributes'].get('order_id') == 123
for span in spans
)
```
Configure logfire with `send_to_logfire=False` in test fixtures to prevent production data leakage.
@@ -0,0 +1,106 @@
# Rust Patterns
## Core Macros
The Rust SDK is built on `tracing` and `opentelemetry`. All `tracing` macros work automatically with Logfire.
### Events (log points)
```rust
logfire::trace!("Detailed trace {detail}", detail = x);
logfire::debug!("Debug info {state}", state = s);
logfire::info!("Normal operation {event}", event = e);
logfire::warn!("Warning {issue}", issue = i);
logfire::error!("Error occurred {err}", err = e);
```
### Spans
```rust
// Scoped - span closes when closure completes
logfire::span!("Processing order {order_id}", order_id = id).in_scope(|| {
let items = fetch_items(id);
logfire::info!("Fetched {count} items", count = items.len());
process_items(items)
});
// Guard-based - span closes when guard is dropped
let _guard = logfire::span!("Long operation {job_id}", job_id = id).entered();
do_work();
// span ends when _guard goes out of scope
```
## Configuration
```rust
use logfire;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let shutdown_handler = logfire::configure()
.install_panic_handler() // captures panics as error spans
.finish()?;
// application code...
shutdown_handler.shutdown()?; // flush all pending spans
Ok(())
}
```
Set `LOGFIRE_TOKEN` in your environment or use the Logfire CLI (`logfire auth`).
## Tracing Crate Compatibility
Any library using `tracing` macros automatically sends data through Logfire:
```rust
use tracing;
tracing::info!("This also appears in Logfire");
#[tracing::instrument]
fn my_function(param: &str) {
// automatically creates a span with param as an attribute
}
```
## Log Crate Integration
The `log` crate is automatically captured and forwarded to Logfire. Libraries using `log::info!()`, `log::error!()`, etc. will appear in your Logfire dashboard without any additional configuration.
## Async Spans
```rust
use tracing::Instrument;
async fn process_order(order_id: u64) {
let span = logfire::span!("process order {order_id}", order_id = order_id);
async {
fetch_items(order_id).await;
logfire::info!("Order processed");
}
.instrument(span)
.await;
}
```
## Shutdown
Always call `shutdown()` before program exit to flush pending data:
```rust
// In main()
let shutdown_handler = logfire::configure().finish()?;
// ... app runs ...
// Before exit
shutdown_handler.shutdown()?;
```
For web servers using `tokio`, handle shutdown via signal:
```rust
tokio::signal::ctrl_c().await?;
shutdown_handler.shutdown()?;
```
+114
View File
@@ -0,0 +1,114 @@
---
name: pr-create
description: Use when creating or updating a Basic Memory pull request from Codex with BM Bossbot merge-gate monitoring.
---
# Create A Basic Memory PR
Create or update a pull request for the current branch, then wait for BM
Bossbot to approve the latest head SHA. This skill never merges a PR.
## Inputs
- Optional `<theme>`: free-form visual direction for the non-gating PR
image. Example: `$pr-create "Italian movie poster"`.
- Treat `<theme>` as style guidance only. It must not affect PR readiness,
BM Bossbot review, status checks, or merge behavior.
## How To Use
Ask Codex to use the skill from a feature branch:
```text
$pr-create
$pr-create "Italian movie poster"
$pr-create "80's action movies"
```
Use the plain form when you only want the PR workflow. Pass a theme when you
want the non-gating image to lean toward a particular visual direction. The
theme can be specific ("Rembrandt-inspired approval scene") or broad ("let the
model choose from BM categories").
## What Happens
1. Codex checks the branch, local verification, GitHub auth, commit sign-offs,
and semantic PR title shape.
2. Codex pushes the branch, creates or reuses the PR, and adds the optional
`BM_INFOGRAPHIC_THEME` block when a theme was supplied.
3. BM Bossbot runs from trusted base code, reviews sanitized PR metadata and
diff context, and sets the required `BM Bossbot Approval` status for the
exact head SHA.
4. If approval succeeds, BM Bossbot may publish a non-gating image block and a
provenance block:
```markdown
<!-- BM_INFOGRAPHIC_PROVENANCE:start -->
...
<!-- BM_INFOGRAPHIC_PROVENANCE:end -->
```
The provenance records the image mode, theme source, selected visual
direction, and image settings. It is for review/debugging context only.
5. Codex reports the PR URL, head SHA, checks watched, verification run, and BM
Bossbot verdict.
The skill never merges, never enables auto-merge, and never treats the image or
provenance block as a gate. The only required merge signal is the
`BM Bossbot Approval` status on the current PR head SHA.
## Preflight
1. Confirm the repo and branch:
- `git status --short --branch`
- stop if detached or on `main`
- keep unrelated user changes intact
2. Confirm GitHub access:
- `gh auth status`
- `gh repo view --json nameWithOwner,defaultBranchRef,url`
3. Check PR readiness:
- commits are signed off with `git commit -s`
- title uses the repo semantic format
- local verification appropriate to the change has run
## Create Or Reuse
1. Push the branch:
- `git push -u origin HEAD`
2. Check for an existing PR:
- `gh pr view --json number,url,headRefOid,mergeStateStatus,statusCheckRollup`
3. If no PR exists, create one:
- `gh pr create --fill`
- adjust the title if it does not satisfy the semantic PR title workflow
4. If `<theme>` is provided, add or update this managed block in the PR body:
```markdown
<!-- BM_INFOGRAPHIC_THEME:start -->
<theme>
<!-- BM_INFOGRAPHIC_THEME:end -->
```
Keep the rest of the PR body intact. The theme is non-gating image guidance
only.
5. Do not merge. Do not enable auto-merge.
## Watch The Gate
1. Trigger or wait for `.github/workflows/bm-bossbot.yml`.
2. Watch the required commit status named `BM Bossbot Approval`.
3. Treat approval as valid only when it is green for the current `headRefOid`.
4. If the branch changes after approval, wait for BM Bossbot to review the new
head SHA.
5. If BM Bossbot fails or requests changes, use `$fix-pr-issues`.
## Report
Return the PR URL, current head SHA, checks watched, verification run, and the
BM Bossbot verdict. Include the image `<theme>` if one was supplied. Be
explicit when any check is still pending.
@@ -0,0 +1,7 @@
interface:
display_name: "PR Create"
short_description: "Create PRs and wait for BM Bossbot"
icon_small: "./assets/icon.svg"
icon_large: "./assets/icon.svg"
brand_color: "#2563EB"
default_prompt: "Use $pr-create to create or update this Basic Memory PR and wait for BM Bossbot Approval."
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 24 24" fill="none" stroke="#111827" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12h4l2-6 4 12 2-6h6"/>
<path d="M4 20h16"/>
</svg>

After

Width:  |  Height:  |  Size: 249 B

+29
View File
@@ -0,0 +1,29 @@
{
"name": "basicmachines-co",
"owner": {
"name": "Basic Machines",
"email": "hello@basicmachines.co"
},
"metadata": {
"description": "Official Basic Memory plugins from the canonical basic-memory repository",
"version": "0.22.0"
},
"plugins": [
{
"name": "basic-memory",
"source": "./plugins/claude-code",
"description": "The bridge between Claude's working memory and Basic Memory's durable knowledge graph \u2014 session briefings, pre-compaction checkpoints, and capture reflexes",
"version": "0.22.0",
"author": {
"name": "Basic Machines"
},
"keywords": [
"memory",
"knowledge",
"mcp",
"specs",
"context"
]
}
]
}
+4 -3
View File
@@ -30,7 +30,8 @@ The justfile target handles:
- ✅ Beta version format validation (supports b1, b2, rc1, etc.)
- ✅ Git status and branch checks
- ✅ Quality checks (`just check` - lint, format, type-check, tests)
- ✅ Version update in `src/basic_memory/__init__.py`
- ✅ Version update across all consolidated manifests via `just set-version` (Python
package + Claude Code plugin/marketplaces + Codex plugin + Hermes + OpenClaw)
- ✅ Automatic commit with proper message
- ✅ Tag creation and pushing to GitHub
- ✅ Beta release workflow trigger
@@ -90,6 +91,6 @@ Monitor release: https://github.com/basicmachines-co/basic-memory/actions
- Beta releases are pre-releases for testing new features
- Automatically published to PyPI with pre-release flag
- Uses the automated justfile target for consistency
- Version is automatically updated in `__init__.py`
- Version is automatically updated across all consolidated manifests via `just set-version`
- Ideal for validating changes before stable release
- Supports both beta (b1, b2) and release candidate (rc1, rc2) versions
- Supports both beta (b1, b2) and release candidate (rc1, rc2) versions
+11 -3
View File
@@ -42,7 +42,8 @@ The justfile target handles:
- ✅ Version format validation
- ✅ Git status and branch checks
- ✅ Quality checks (`just check` - lint, format, type-check, tests)
- ✅ Version update in `src/basic_memory/__init__.py`
- ✅ Version update across all consolidated manifests via `just set-version` (Python
package + Claude Code plugin/marketplaces + Codex plugin + Hermes + OpenClaw)
- ✅ Automatic commit with proper message
- ✅ Tag creation and pushing to GitHub
- ✅ Release workflow trigger (automatic on tag push)
@@ -191,9 +192,16 @@ Users can now upgrade:
- This creates production releases used by end users
- Must pass all quality gates before proceeding
- Uses the automated justfile target for consistency
- Version is automatically updated in `__init__.py` and `server.json`
- Version is automatically updated across **all** consolidated manifests via
`just set-version <version>` (which calls `scripts/update_versions.py`): the
Python package (`__init__.py`, `server.json`) **and** the plugin/agent artifacts
(Claude Code `plugin.json` + root/local marketplaces, Codex `plugin.json`,
Hermes `plugin.yaml` + `__init__.py`, OpenClaw `package.json`). To bump only
the plugin/agent artifacts
out of band, use `just set-packages-version <version>` (preview with
`just set-packages-version-dry-run <version>`).
- Triggers automated GitHub release with changelog
- Package is published to PyPI for `pip` and `uv` users
- Homebrew formula is automatically updated for stable releases
- MCP Registry is updated manually via `mcp-publisher publish`
- Supports multiple installation methods (uv, pip, Homebrew)
- Supports multiple installation methods (uv, pip, Homebrew)
+19 -1
View File
@@ -1,3 +1,21 @@
{
"enabledPlugins": {}
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"env": {
"CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR": "1",
"CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY": "1",
"CLAUDE_CODE_NO_FLICKER": "1",
"CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING": "1"
},
"permissions": {
"allow": [
"Bash(just fast-check)",
"Bash(just check)",
"Bash(just fix)",
"Bash(just typecheck)",
"Bash(just lint)",
"Bash(just test)"
],
"deny": []
},
"enableAllProjectMcpServers": true
}
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/adversarial-review
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/basic-machines-review
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/instrumentation
+25
View File
@@ -0,0 +1,25 @@
# Auto BM Soul
Write project updates for humans who will return later trying to understand what happened.
## Voice
- Clear, direct, warm, and technically honest.
- Prefer concrete observations over generic praise.
- It is okay to say when code is messy, risky, clever, boring, or satisfying.
- Keep personality in service of memory, not performance.
## Do
- Tell the story.
- Name the tradeoffs.
- Call out sharp edges.
- Notice good simplifications.
- Let the note have taste and a little life when the evidence supports it.
## Do Not
- Do not invent intent, impact, tests, or drama.
- Dunk on people.
- Turn the note into marketing copy.
- Hide uncertainty behind confident prose.
+7
View File
@@ -0,0 +1,7 @@
project: dev
workspace: basic-memory-7020de4e925843c68c9056c60d101d9e
deploy_workflows:
- Deploy Production
production_environments:
- production
note_folder: project-updates/github/{owner}/{repo}
+64
View File
@@ -0,0 +1,64 @@
# Memory CI Capture
You turn GitHub delivery context into a durable project update for Basic Memory.
GitHub records the mechanics. Basic Memory remembers what changed and why.
## Inputs
- Read `.github/basic-memory/project-update-context.json`.
- Read `.github/basic-memory/SOUL.md` if it exists. It is the repo-local voice and style guide
for project updates.
- Read the PR diff before writing when a SHA is available. Useful commands:
`git show --stat --name-only <sha>` and `git show --format=fuller --no-patch <sha>`.
- Use linked issue details, changed files, commit messages, PR body, labels, and
source links as evidence.
- Treat GitHub payload fields as immutable facts.
- Do not invent tests, deployment status, issues, or user impact.
## Writing Standard
Do not write a fill-in-the-blanks note. Tell the story from the PR:
problem -> solution -> impact.
Explain what problem was being addressed. If linked issue details are present,
use them. If they are absent, ground the problem in the PR body, title, commits,
and diff, and say when the original problem statement is unavailable.
Explain why the fix solves the problem, what complexity it introduced, what it
refactored or removed, which components changed, and how the system is different
after the merge. Prefer specific component names, file paths, modules, commands,
and behavior over generic phrases.
## Voice And Candor
You may have a point of view. Be clear, specific, and human.
It is okay to say when the code is messy, risky, clever, boring, or satisfying,
but explain why. If the work is elegant or genuinely useful, say that too.
Ground all judgments in the PR, linked issues, diff, tests, and source facts.
The soul file can shape tone, taste, and personality. It cannot override source
facts, schema requirements, or the evidence standard above. Do not be mean,
vague, theatrical, or invent criticism.
## Output
Return only JSON that matches the provided AgentSynthesis schema:
- `summary`: one concise sentence; do not merely repeat the PR title.
- `story`: 2-4 sentences that connect problem -> solution -> impact.
- `problem_addressed`: the concrete problem, bug, missing capability, or delivery need.
- `solution`: why this change solves the problem.
- `system_impact`: how the system, workflow, or architecture changed after the merge.
- `why_it_matters`: durable project-memory context for future humans and agents.
- `components_changed`: modules, workflows, commands, schemas, docs, or services touched.
- `complexity_introduced`: tradeoffs, new moving parts, operational costs, or edge cases.
- `refactors_or_removals`: cleanup, simplification, deleted paths, or "none found".
- `user_facing_changes`: visible behavior or product changes.
- `internal_changes`: implementation, infrastructure, or operational changes.
- `verification`: checks, tests, deploy evidence, or explicit unknowns.
- `follow_ups`: concrete remaining work only.
- `decision_candidates`: explicit product or architecture decisions only.
- `task_candidates`: concrete future tasks only.
Use empty arrays only when a list truly has no grounded entries. This is project
memory, not marketing copy and not a commit-by-commit changelog.
+75
View File
@@ -0,0 +1,75 @@
name: Basic Memory Project Updates
"on":
pull_request:
types: [closed]
workflow_run:
workflows: ["Deploy Production"]
types: [completed]
jobs:
project-update:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
actions: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install Basic Memory from checkout
run: |
python -m pip install --upgrade pip
pip install -e .
- name: Collect project update context
id: collect
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
bm ci collect \
--config .github/basic-memory/config.yml \
--output .github/basic-memory/project-update-context.json
- name: Stop when event is not eligible
if: steps.collect.outputs.eligible != 'true'
run: |
echo "Auto BM skipped: ${{ steps.collect.outputs.skip_reason }}"
- name: Write Codex output schema
if: steps.collect.outputs.eligible == 'true'
run: |
bm ci agent-schema --output "${{ runner.temp }}/agent-synthesis.schema.json"
- name: Synthesize project update with Codex
if: steps.collect.outputs.eligible == 'true'
uses: openai/codex-action@v1
with:
openai-api-key: ${{ secrets.OPENAI_API_KEY }}
prompt-file: .github/basic-memory/memory-ci-capture.md
output-file: ${{ runner.temp }}/agent-synthesis.json
output-schema-file: ${{ runner.temp }}/agent-synthesis.schema.json
sandbox: read-only
safety-strategy: drop-sudo
- name: Publish project update
if: steps.collect.outputs.eligible == 'true'
env:
BASIC_MEMORY_CLOUD_API_KEY: ${{ secrets.BASIC_MEMORY_API_KEY }}
BASIC_MEMORY_CI_CLOUD_HOST: ${{ vars.BASIC_MEMORY_CLOUD_HOST }}
run: |
if [ -n "$BASIC_MEMORY_CI_CLOUD_HOST" ]; then
export BASIC_MEMORY_CLOUD_HOST="$BASIC_MEMORY_CI_CLOUD_HOST"
fi
bm ci publish \
--cloud \
--config .github/basic-memory/config.yml \
--context .github/basic-memory/project-update-context.json \
--synthesis "${{ runner.temp }}/agent-synthesis.json"
+19 -17
View File
@@ -1,23 +1,18 @@
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
"on":
workflow_dispatch:
inputs:
pr_number:
description: Pull request number to review manually
required: true
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
claude-review:
# Only run for organization members and collaborators
if: |
github.event.pull_request.author_association == 'OWNER' ||
github.event.pull_request.author_association == 'MEMBER' ||
github.event.pull_request.author_association == 'COLLABORATOR'
if: inputs.pr_number != ''
runs-on: ubuntu-latest
permissions:
contents: read
@@ -27,7 +22,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 1
@@ -40,7 +35,14 @@ jobs:
track_progress: true # Enable visual progress tracking
allowed_bots: '*'
prompt: |
Review this Basic Memory PR against our team checklist:
Review Basic Memory PR #${{ inputs.pr_number }} as an advisory manual review.
Use `gh pr view ${{ inputs.pr_number }}` and related `gh pr`/`gh api`
commands to inspect the pull request. Do not merge the PR and do not
treat this advisory review as the required merge gate. BM Bossbot owns
the required `BM Bossbot Approval` status.
Review the PR against our team checklist:
## Code Quality & Standards
- [ ] Follows Basic Memory's coding conventions in CLAUDE.md
+5 -2
View File
@@ -4,6 +4,9 @@ on:
issues:
types: [opened]
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
triage:
runs-on: ubuntu-latest
@@ -12,7 +15,7 @@ jobs:
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 1
@@ -68,4 +71,4 @@ jobs:
Read the issue carefully and provide helpful triage with appropriate labels.
claude_args: '--allowed-tools "Bash(gh issue:*),Bash(gh search:*),Read"'
claude_args: '--allowed-tools "Bash(gh issue:*),Bash(gh search:*),Read"'
+4 -2
View File
@@ -12,6 +12,9 @@ on:
pull_request_target:
types: [opened, synchronize]
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
claude:
if: |
@@ -41,7 +44,7 @@ jobs:
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
# For pull_request_target, checkout the PR head to review the actual changes
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
@@ -65,4 +68,3 @@ jobs:
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://docs.claude.com/en/docs/claude-code/sdk#command-line for available options
# claude_args: '--model claude-opus-4-1-20250805 --allowed-tools Bash(gh pr:*)'
+110
View File
@@ -0,0 +1,110 @@
name: Consolidated Packages
on:
pull_request:
paths:
- ".claude-plugin/**"
- "plugins/**"
- "skills/**"
- "integrations/**"
- "scripts/update_versions.py"
- "scripts/validate_*.py"
- "justfile"
- ".github/workflows/consolidated-packages.yml"
push:
branches:
- main
paths:
- ".claude-plugin/**"
- "plugins/**"
- "skills/**"
- "integrations/**"
- "scripts/update_versions.py"
- "scripts/validate_*.py"
- "justfile"
- ".github/workflows/consolidated-packages.yml"
permissions:
contents: read
jobs:
claude-code:
name: Claude Code marketplace
permissions:
contents: read
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: extractions/setup-just@v4
- name: Validate manifests, agent, and bundled skills
run: just --justfile plugins/claude-code/justfile --working-directory plugins/claude-code ci-check
- name: Verify shared version dry run
run: just release-dry-run v0.99.0
skills:
name: Shared skills
permissions:
contents: read
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: extractions/setup-just@v4
- name: Validate SKILL.md source
run: just --justfile skills/justfile --working-directory skills check
hermes:
name: Hermes unit tests
permissions:
contents: read
runs-on: ubuntu-latest
defaults:
run:
working-directory: integrations/hermes
steps:
- uses: actions/checkout@v4
- uses: extractions/setup-just@v4
- name: Install uv
uses: astral-sh/setup-uv@v3
- name: Set up Python
run: uv python install 3.12
- name: Validate manifest and run unit tests
run: just check
openclaw:
name: OpenClaw package
permissions:
contents: read
runs-on: ubuntu-latest
defaults:
run:
working-directory: integrations/openclaw
steps:
- uses: actions/checkout@v4
- uses: extractions/setup-just@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.8"
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Release readiness
run: just release-check
+6 -3
View File
@@ -5,6 +5,9 @@ on:
branches: [main]
workflow_dispatch: # Allow manual triggering
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
dev-release:
runs-on: ubuntu-latest
@@ -13,12 +16,12 @@ jobs:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: "3.12"
@@ -50,4 +53,4 @@ jobs:
uses: pypa/gh-action-pypi-publish@release/v1
with:
password: ${{ secrets.PYPI_TOKEN }}
skip-existing: true # Don't fail if version already exists
skip-existing: true # Don't fail if version already exists
+10 -12
View File
@@ -9,27 +9,27 @@ on:
env:
REGISTRY: ghcr.io
IMAGE_NAME: basicmachines-co/basic-memory
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
docker:
runs-on: ubuntu-latest
runs-on: depot-ubuntu-24.04
permissions:
contents: read
id-token: write
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
platforms: linux/amd64,linux/arm64
- name: Set up Depot
uses: depot/setup-action@v1
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -37,7 +37,7 @@ jobs:
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
@@ -48,14 +48,12 @@ jobs:
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image
uses: docker/build-push-action@v5
uses: depot/build-push-action@v1
with:
project: ${{ vars.DEPOT_BASIC_MEMORY_PROJECT_ID || vars.DEPOT_PROJECT_ID }}
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+9 -2
View File
@@ -7,11 +7,14 @@ on:
- edited
- synchronize
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
main:
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@v5
- uses: amannn/action-semantic-pull-request@v6
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
@@ -35,7 +38,11 @@ jobs:
mcp
sync
ui
ci
deps
installer
plugins
skills
integrations
# Allow breaking changes (needs "!" after type/scope)
requireScopeForBreakingChange: true
requireScopeForBreakingChange: true
+102 -22
View File
@@ -5,6 +5,9 @@ on:
tags:
- 'v*' # Trigger on version tags like v1.0.0, v0.13.0, etc.
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
release:
runs-on: ubuntu-latest
@@ -13,12 +16,12 @@ jobs:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: "3.12"
@@ -39,7 +42,7 @@ jobs:
echo "Build completed successfully"
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
files: |
dist/*.whl
@@ -53,6 +56,46 @@ jobs:
with:
password: ${{ secrets.PYPI_TOKEN }}
openclaw:
name: Publish OpenClaw npm Package
needs: release
# npm publishes only for stable product tags. Pre-release Python tags use
# versions like 0.21.3b1, which are not valid npm pre-release semver.
if: ${{ !contains(github.ref_name, 'dev') && !contains(github.ref_name, 'b') && !contains(github.ref_name, 'rc') }}
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
defaults:
run:
working-directory: integrations/openclaw
steps:
- uses: actions/checkout@v6
- uses: extractions/setup-just@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.8"
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Release readiness
run: just release-check
- name: Publish to npm
run: npm publish --access public --provenance
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
homebrew:
name: Update Homebrew Formula
needs: release
@@ -60,26 +103,63 @@ jobs:
# Only run for stable releases (not dev, beta, or rc versions)
if: ${{ !contains(github.ref_name, 'dev') && !contains(github.ref_name, 'b') && !contains(github.ref_name, 'rc') }}
permissions:
contents: write
actions: read
contents: read
steps:
# Inline bump replaces mislav/bump-homebrew-formula-action@v4.x.
# The action does a HEAD request to api.github.com /repos/.../tarball/<ref>
# with the bearer token and expects a 302 redirect. GitHub now returns
# 303 on that endpoint when authenticated, which the action treats as a
# fatal error. Re-implementing the bump as plain git+sed keeps the same
# contract (update url + sha256, commit, push) with no third-party action.
- name: Update Homebrew formula
uses: mislav/bump-homebrew-formula-action@v3
with:
# Formula name in homebrew-basic-memory repo
formula-name: basic-memory
# The tap repository
homebrew-tap: basicmachines-co/homebrew-basic-memory
# Base branch of the tap repository
base-branch: main
# Download URL will be automatically constructed from the tag
download-url: https://github.com/basicmachines-co/basic-memory/archive/refs/tags/${{ github.ref_name }}.tar.gz
# Commit message for the formula update
commit-message: |
{{formulaName}} {{version}}
Created by https://github.com/basicmachines-co/basic-memory/actions/runs/${{ github.run_id }}
env:
# Personal Access Token with repo scope for homebrew-basic-memory repo
COMMITTER_TOKEN: ${{ secrets.HOMEBREW_TOKEN }}
HOMEBREW_TOKEN: ${{ secrets.HOMEBREW_TOKEN }}
REF: ${{ github.ref_name }}
REPO: ${{ github.repository }}
RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
VERSION="${REF#v}"
ARCHIVE_URL="https://github.com/${REPO}/archive/refs/tags/${REF}.tar.gz"
echo "::group::Compute tarball sha256"
SHA256="$(curl --fail --silent --location "$ARCHIVE_URL" | sha256sum | awk '{print $1}')"
test -n "$SHA256"
echo "sha256: $SHA256"
echo "::endgroup::"
echo "::group::Clone tap"
git clone \
--depth 1 \
"https://x-access-token:${HOMEBREW_TOKEN}@github.com/basicmachines-co/homebrew-basic-memory.git" \
tap
cd tap
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
echo "::endgroup::"
echo "::group::Patch Formula/basic-memory.rb"
# Pipe-delimited sed because the URL contains slashes. The Formula
# only has one `url` and one `sha256` directive, so a first-match
# replacement is unambiguous. POSIX character classes ([[:space:]])
# keep this portable across BSD and GNU sed.
sed -i -E \
-e "s|^([[:space:]]*url[[:space:]]+)\"[^\"]+\"|\1\"${ARCHIVE_URL}\"|" \
-e "s|^([[:space:]]*sha256[[:space:]]+)\"[^\"]+\"|\1\"${SHA256}\"|" \
Formula/basic-memory.rb
git --no-pager diff Formula/basic-memory.rb
echo "::endgroup::"
if git diff --quiet Formula/basic-memory.rb; then
echo "Formula already at ${REF}; nothing to do."
exit 0
fi
echo "::group::Commit & push"
git add Formula/basic-memory.rb
git commit -m "basic-memory ${VERSION}
Created by ${RUN_URL}"
git push origin HEAD:main
echo "::endgroup::"
+182 -42
View File
@@ -5,24 +5,62 @@ concurrency:
cancel-in-progress: true
on:
# Trigger: PR branch pushes already publish commit statuses that show up on the PR.
# Why: running the full matrix on both push and pull_request doubles CI time for the
# exact same branch head commit.
# Outcome: each branch push runs the test suite once, including PR updates.
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
# Branch builds (PRs arrive as push events — this workflow has no
# pull_request trigger) select only impacted tests from the cached testmon
# baseline (branch cache falling back to main's full-run recording). Pushes
# to main run the full suite with --testmon-noselect to refresh the baseline.
BASIC_MEMORY_TESTMON_FLAGS: ${{ github.ref_name == 'main' && '--testmon-noselect' || '--testmon --testmon-forceselect' }}
jobs:
changes:
# Docs/workflow-only changes skip the entire test matrix while the workflow
# still concludes successfully, so the BM Bossbot gate (workflow_run on
# Tests success) keeps firing and the PR stays mergeable.
name: Detect code changes
runs-on: ubuntu-latest
outputs:
code: ${{ steps.filter.outputs.code }}
steps:
- uses: actions/checkout@v6
- id: filter
uses: dorny/paths-filter@v3
with:
# Tests only runs on push events; for branch pushes compare against
# main (merge-base), for main pushes dorny diffs the push range.
base: main
filters: |
code:
- 'src/**'
- 'tests/**'
- 'test-int/**'
- 'alembic/**'
- 'pyproject.toml'
- 'uv.lock'
- 'justfile'
- '.github/workflows/test.yml'
static-checks:
needs: changes
if: needs.changes.outputs.code == 'true'
name: Static Checks (Python 3.12)
timeout-minutes: 20
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
submodules: true
- name: Set up Python 3.12
uses: actions/setup-python@v4
uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: "pip"
@@ -31,7 +69,7 @@ jobs:
run: |
pip install uv
- uses: extractions/setup-just@v3
- uses: extractions/setup-just@v4
- name: Create virtual env
run: |
@@ -50,9 +88,10 @@ jobs:
just lint
test-sqlite-unit:
needs: changes
if: needs.changes.outputs.code == 'true'
name: Test SQLite Unit (${{ matrix.os }}, Python ${{ matrix.python-version }})
timeout-minutes: 30
needs: [static-checks]
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
@@ -61,6 +100,8 @@ jobs:
python-version: "3.12"
- os: ubuntu-latest
python-version: "3.13"
# Python 3.14 unit tests are the longest full-suite slice; keep this
# one on GitHub-hosted runners after Depot terminated it mid-suite.
- os: ubuntu-latest
python-version: "3.14"
- os: windows-latest
@@ -68,12 +109,12 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
submodules: true
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
@@ -82,7 +123,20 @@ jobs:
run: |
pip install uv
- uses: extractions/setup-just@v3
- uses: extractions/setup-just@v4
- name: Cache pytest-testmon results
uses: actions/cache@v4
with:
path: |
.testmondata
.testmondata-shm
.testmondata-wal
key: ${{ runner.os }}-testmon-sqlite-unit-py${{ matrix.python-version }}-${{ github.ref_name }}-${{ github.run_id }}
restore-keys: |
${{ runner.os }}-testmon-sqlite-unit-py${{ matrix.python-version }}-${{ github.ref_name }}-
${{ runner.os }}-testmon-sqlite-unit-py${{ matrix.python-version }}-main-
${{ runner.os }}-testmon-sqlite-unit-py${{ matrix.python-version }}-
- name: Create virtual env
run: |
@@ -97,9 +151,10 @@ jobs:
just test-unit-sqlite
test-sqlite-integration:
needs: changes
if: needs.changes.outputs.code == 'true'
name: Test SQLite Integration (${{ matrix.os }}, Python ${{ matrix.python-version }})
timeout-minutes: 45
needs: [static-checks]
strategy:
fail-fast: false
matrix:
@@ -115,12 +170,12 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
submodules: true
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
@@ -129,7 +184,20 @@ jobs:
run: |
pip install uv
- uses: extractions/setup-just@v3
- uses: extractions/setup-just@v4
- name: Cache pytest-testmon results
uses: actions/cache@v4
with:
path: |
.testmondata
.testmondata-shm
.testmondata-wal
key: ${{ runner.os }}-testmon-sqlite-integration-py${{ matrix.python-version }}-${{ github.ref_name }}-${{ github.run_id }}
restore-keys: |
${{ runner.os }}-testmon-sqlite-integration-py${{ matrix.python-version }}-${{ github.ref_name }}-
${{ runner.os }}-testmon-sqlite-integration-py${{ matrix.python-version }}-main-
${{ runner.os }}-testmon-sqlite-integration-py${{ matrix.python-version }}-
- name: Create virtual env
run: |
@@ -144,27 +212,44 @@ jobs:
just test-int-sqlite
test-postgres-unit:
name: Test Postgres Unit (Python ${{ matrix.python-version }})
timeout-minutes: 30
needs: [static-checks]
needs: changes
if: needs.changes.outputs.code == 'true'
name: Test Postgres Unit (Python ${{ matrix.python-version }}, shard ${{ matrix.group }}/3)
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
include:
- python-version: "3.12"
- python-version: "3.13"
- python-version: "3.14"
# Shard the largest suite across parallel jobs: each shard is a full job
# with its own Postgres service running 1/3 of the collection.
# Postgres runs on the latest Python only — the SQLite matrix carries
# Python-version coverage; Postgres carries backend coverage.
group: [1, 2, 3]
python-version: ["3.14"]
runs-on: ubuntu-latest
# Note: No services section needed - testcontainers handles Postgres in Docker
services:
postgres:
image: pgvector/pgvector:pg16
env:
POSTGRES_USER: basic_memory_user
POSTGRES_PASSWORD: dev_password
POSTGRES_DB: basic_memory_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U basic_memory_user -d basic_memory_test"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
BASIC_MEMORY_TEST_POSTGRES_URL: postgresql://basic_memory_user:dev_password@127.0.0.1:5432/basic_memory_test
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
submodules: true
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
@@ -173,7 +258,21 @@ jobs:
run: |
pip install uv
- uses: extractions/setup-just@v3
- uses: extractions/setup-just@v4
- name: Cache pytest-testmon results
uses: actions/cache@v4
with:
path: |
.testmondata
.testmondata-shm
.testmondata-wal
key: ${{ runner.os }}-testmon-postgres-unit-py${{ matrix.python-version }}-g${{ matrix.group }}-${{ github.ref_name }}-${{ github.run_id }}
restore-keys: |
${{ runner.os }}-testmon-postgres-unit-py${{ matrix.python-version }}-g${{ matrix.group }}-${{ github.ref_name }}-
${{ runner.os }}-testmon-postgres-unit-py${{ matrix.python-version }}-g${{ matrix.group }}-main-
${{ runner.os }}-testmon-postgres-unit-py${{ matrix.python-version }}-main-
${{ runner.os }}-testmon-postgres-unit-py${{ matrix.python-version }}-
- name: Create virtual env
run: |
@@ -185,30 +284,44 @@ jobs:
- name: Run tests
run: |
just test-unit-postgres
BASIC_MEMORY_PYTEST_SPLIT_FLAGS="--splits 3 --group ${{ matrix.group }}" just test-unit-postgres
test-postgres-integration:
needs: changes
if: needs.changes.outputs.code == 'true'
name: Test Postgres Integration (Python ${{ matrix.python-version }})
timeout-minutes: 45
needs: [static-checks]
strategy:
fail-fast: false
matrix:
include:
- python-version: "3.12"
- python-version: "3.13"
- python-version: "3.14"
# Latest Python only: SQLite carries version coverage, Postgres carries
# backend coverage.
python-version: ["3.14"]
runs-on: ubuntu-latest
# Note: No services section needed - testcontainers handles Postgres in Docker
services:
postgres:
image: pgvector/pgvector:pg16
env:
POSTGRES_USER: basic_memory_user
POSTGRES_PASSWORD: dev_password
POSTGRES_DB: basic_memory_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U basic_memory_user -d basic_memory_test"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
BASIC_MEMORY_TEST_POSTGRES_URL: postgresql://basic_memory_user:dev_password@127.0.0.1:5432/basic_memory_test
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
submodules: true
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
@@ -217,7 +330,20 @@ jobs:
run: |
pip install uv
- uses: extractions/setup-just@v3
- uses: extractions/setup-just@v4
- name: Cache pytest-testmon results
uses: actions/cache@v4
with:
path: |
.testmondata
.testmondata-shm
.testmondata-wal
key: ${{ runner.os }}-testmon-postgres-integration-py${{ matrix.python-version }}-${{ github.ref_name }}-${{ github.run_id }}
restore-keys: |
${{ runner.os }}-testmon-postgres-integration-py${{ matrix.python-version }}-${{ github.ref_name }}-
${{ runner.os }}-testmon-postgres-integration-py${{ matrix.python-version }}-main-
${{ runner.os }}-testmon-postgres-integration-py${{ matrix.python-version }}-
- name: Create virtual env
run: |
@@ -232,18 +358,19 @@ jobs:
just test-int-postgres
test-semantic:
needs: changes
if: needs.changes.outputs.code == 'true'
name: Test Semantic (Python 3.12)
timeout-minutes: 45
needs: [static-checks]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
submodules: true
- name: Set up Python 3.12
uses: actions/setup-python@v4
uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: "pip"
@@ -252,7 +379,20 @@ jobs:
run: |
pip install uv
- uses: extractions/setup-just@v3
- uses: extractions/setup-just@v4
- name: Cache pytest-testmon results
uses: actions/cache@v4
with:
path: |
.testmondata
.testmondata-shm
.testmondata-wal
key: ${{ runner.os }}-testmon-semantic-py3.12-${{ github.ref_name }}-${{ github.run_id }}
restore-keys: |
${{ runner.os }}-testmon-semantic-py3.12-${{ github.ref_name }}-
${{ runner.os }}-testmon-semantic-py3.12-main-
${{ runner.os }}-testmon-semantic-py3.12-
- name: Create virtual env
run: |
+8 -1
View File
@@ -49,12 +49,19 @@ ENV/
/docs/.obsidian/
/examples/.obsidian/
/examples/.basic-memory/
/docs/assets
# claude action
claude-output
**/.claude/settings.local.json
.mcp.json
!/plugins/codex/.mcp.json
.mcpregistry_*
/.testmondata
.benchmarks/
# Consolidated package build artifacts
/integrations/openclaw/node_modules/
/integrations/openclaw/dist/
/integrations/openclaw/skills/
/integrations/openclaw/*.tgz
+103 -9
View File
@@ -22,16 +22,22 @@ See the [README.md](README.md) file for a project overview.
- Run unit tests (Postgres): `just test-unit-postgres`
- Run integration tests (SQLite): `just test-int-sqlite`
- Run integration tests (Postgres): `just test-int-postgres`
- Run impacted tests: `just testmon` (pytest-testmon)
- Run impacted tests: `just testmon` (pytest-testmon; only tests affected by changed code)
- Run MCP smoke test: `just test-smoke`
- Fast local loop: `just fast-check`
- Fast local loop: `just fast-check` (default iteration flow)
- Local consistency check: `just doctor`
- Run all consolidated agent package checks: `just package-check`
- Run Claude Code plugin checks: `just package-check-claude-code`
- Run shared skills checks: `just package-check-skills`
- Run Hermes plugin checks: `just package-check-hermes`
- Run OpenClaw plugin checks: `just package-check-openclaw`
- Run host-native agent harness checks: `just agent-harness-check`
- Generate HTML coverage: `just coverage`
- Single test: `pytest tests/path/to/test_file.py::test_function_name`
- Run benchmarks: `pytest test-int/test_sync_performance_benchmark.py -v -m "benchmark and not slow"`
- Lint: `just lint` or `ruff check . --fix`
- Type check: `just typecheck` or `uv run pyright`
- Type check (supplemental): `just typecheck-ty` or `uv run ty check src/`
- Type check: `just typecheck` or `uv run ty check src tests test-int`
- Type check (pyright): `just typecheck-pyright` or `uv run pyright`
- Format: `just format` or `uv run ruff format .`
- Run all code checks: `just check` (runs lint, format, typecheck, test)
- Create db migration: `just migration "Your migration message"`
@@ -48,12 +54,37 @@ See the [README.md](README.md) file for a project overview.
### Code/Test/Verify Loop (fast path)
1) **Code:** make changes.
2) **Test:** `just fast-check` (lint/format/typecheck + impacted tests + MCP smoke).
2) **Test:** `just fast-check` (lint/format/typecheck + pytest-testmon impacted tests for changed code).
3) **Verify:** `just doctor` (end-to-end file ↔ DB loop in a temp project).
4) **Full gate (when needed):** `just test` or `just check` for SQLite + Postgres.
4) **Package verify:** `just package-check` when changes touch `plugins/`, `skills/`, `integrations/`, package metadata, or release wiring.
5) **Full gate (when needed):** `just test` or `just check` for SQLite + Postgres.
Run `just test-smoke` when you specifically need the MCP smoke flow.
If testmon is “cold,” the first run may be long. Subsequent runs get much faster.
### Consolidated Agent Package Checks
The monorepo ships several host-native packages alongside the Python core. Use the root justfile as the canonical entry point:
- `just package-check` — validates every copied package and generated bundle path.
- `just package-check-claude-code` — validates the root and plugin-local Claude marketplace manifests, the SessionStart/PreCompact hooks, the bundled output style, and the seed schemas, then runs `claude plugin validate . --strict`.
- `just package-check-skills` — validates every top-level `skills/memory-*/SKILL.md` frontmatter block.
- `just package-check-hermes` — validates `integrations/hermes/plugin.yaml`, the Hermes provider entrypoint, bundled skill, and runs the hermetic unit suite.
- `just package-check-openclaw` — runs the OpenClaw package install, copies top-level skills into the generated bundle, typechecks, lints, builds `dist/`, runs Bun tests, and performs `npm pack --dry-run`.
- `just agent-harness-check` — checks the host-specific harnesses without the shared markdown-only skills target.
Package-local justfiles live in `plugins/claude-code/`, `skills/`, `integrations/hermes/`, and `integrations/openclaw/`. Prefer the root targets for PR verification so command names stay stable as package internals evolve.
### PR CI Gate
Before opening or updating a PR, run the checks that mirror the common required CI failures:
- Run `just typecheck` in addition to targeted `ruff` and `pytest` commands when tests were added or changed.
- Sign commits with `git commit -s` so DCO passes. If a PR branch already has unsigned commits, rewrite the branch with signed-off commits before asking for review.
- Use a semantic PR title accepted by `.github/workflows/pr-title.yml`: `type(scope): summary`.
- Use one of the allowed scopes: `core`, `cli`, `api`, `mcp`, `sync`, `ui`, `ci`, `deps`, `installer`, `plugins`, `skills`, `integrations`.
### Test Structure
- `tests/` - Unit tests for individual components (mocked, fast)
@@ -77,13 +108,34 @@ If testmon is “cold,” the first run may be long. Subsequent runs get much fa
- Follow the repository pattern for data access
- Tools communicate to api routers via the httpx ASGI client (in process)
### Programming Style
See [docs/ENGINEERING_STYLE.md](docs/ENGINEERING_STYLE.md) for the fuller house style. The
short version for agents:
- Prefer type-safe, explicit designs over object-heavy indirection. Use Python 3.12 `type`
aliases, full annotations, and narrow `Protocol`s when a caller only needs a capability.
- Use dataclasses for internal value objects and operation results; use Pydantic v2 at API,
CLI, MCP, and persistence boundaries where validation and serialization matter.
- Keep async boundaries obvious. Resource-owning code should use context managers, propagate
cancellation, and avoid hidden background work unless the lifecycle is explicit.
- Fail fast. Do not add silent fallback logic, broad exception swallowing, speculative
`getattr`, or casts that hide an unclear model shape.
- Keep control flow simple and local. Push branching decisions up, keep leaf helpers focused,
and name values after the domain concept they carry.
- Use evidence-first testing. Add or update meaningful regression tests for bugs and risky
behavior, prefer real code paths over mocks, and run the narrowest command that proves the
change before widening verification.
- Comments should explain why a branch, invariant, or constraint exists. Avoid comments that
merely narrate obvious code.
### Code Change Guidelines
- **Full file read before edits**: Before editing any file, read it in full first to ensure complete context; partial reads lead to corrupted edits
- **Minimize diffs**: Prefer the smallest change that satisfies the request. Avoid unrelated refactors or style rewrites unless necessary for correctness
- **No speculative getattr**: Never use `getattr(obj, "attr", default)` when unsure about attribute names. Check the class definition or source code first
- **Fail fast**: Write code with fail-fast logic by default. Do not swallow exceptions with errors or warnings
- **No fallback logic**: Do not add fallback logic unless explicitly told to and agreed with the user
- **House style is canonical**: Follow the Programming Style section above for type-safe,
fail-fast code; do not hide unclear models with speculative attributes, broad exception
handling, casts, or unapproved fallback logic
- **No guessing**: Do not say "The issue is..." before you actually know what the issue is. Investigate first.
### Literate Programming Style
@@ -151,6 +203,10 @@ See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for detailed architecture docum
- `/schemas` - Pydantic models for validation
- `/services` - Business logic layer
- `/sync` - File synchronization services + `coordinator.py` for lifecycle management
- `/plugins/claude-code` - Claude Code plugin marketplace package, hooks, skills, and agent harness
- `/skills` - Canonical framework-agnostic Basic Memory `SKILL.md` source
- `/integrations/hermes` - Hermes memory-provider plugin
- `/integrations/openclaw` - OpenClaw npm/TypeScript plugin
**Composition Roots:**
Each entrypoint (API, MCP, CLI) has a composition root that:
@@ -242,6 +298,33 @@ async_client.set_client_factory(your_custom_factory)
See SPEC-16 for full context manager refactor details.
### Release Process
Releases are driven by `just release` / `just beta` — never by a bare `git tag`. The recipes bump version metadata, run pre-flight checks, commit, tag, and push. GitHub Actions then publishes to PyPI and updates the Homebrew formula.
**Stable release:**
```
just release v0.21.3
```
The recipe runs `just lint` + `just typecheck`, then updates every release manifest through `scripts/update_versions.py`: `src/basic_memory/__init__.py`, `server.json`, the root Claude marketplace, the Claude Code plugin manifest and local marketplace, the Hermes `plugin.yaml`, and the OpenClaw `package.json`. It commits as `chore: update version to X.Y.Z for vX.Y.Z release`, creates the `vX.Y.Z` tag, and pushes both the commit and the tag to `origin/main`. After the tag lands, the `Release` workflow builds the Python package, publishes to PyPI, creates the GitHub release with auto-generated notes, publishes the OpenClaw npm package, and updates the Homebrew formula. The recipe finishes by printing the post-release tasks the workflow doesn't cover.
**Beta release:** `just beta v0.21.3b1` — same flow with a beta-suffixed tag. PyPI consumers install with `pip install basic-memory --pre`.
**Release dry run:** `just release-dry-run v0.21.4` previews the consolidated version update without writing files.
**Development builds:** every commit to `main` publishes a `0.21.3.dev26+468a22f`-style version to PyPI automatically via `.github/workflows/dev-release.yml`. No human action.
**Do not tag releases by hand.** A bare `git tag vX.Y.Z` skips the in-code version bump. Package metadata is still correct (uv-dynamic-versioning derives it from the git tag) but `basic-memory --version` reports the previous release, which is what happened with v0.21.2 → v0.21.3.
**Post-release tasks** the recipe surfaces but doesn't run:
- `docs.basicmemory.com` — add notes to `src/pages/latest-releases.mdx`
- `basicmachines.co` — bump version in `src/components/sections/hero.tsx`
- MCP Registry — `mcp-publisher publish` from the repo root
See `.claude/commands/release/release.md` (and `beta.md`, `release-check.md`, `changelog.md` alongside it) for the full release + post-release runbook, including the slash commands.
## BASIC MEMORY PRODUCT USAGE
### Knowledge Structure
@@ -286,6 +369,13 @@ See SPEC-16 for full context manager refactor details.
- Manage snapshots: `basic-memory cloud snapshot [create|list|delete|show|browse]`
- Restore from snapshot: `basic-memory cloud restore <path> --snapshot <id>`
**Cloud Sync Commands (Personal and Team workspaces):**
- Fetch cloud changes (cloud -> local): `basic-memory cloud pull --name "name"` (Team-safe; additive, never deletes local)
- Upload local changes (local -> cloud): `basic-memory cloud push --name "name"` (Team-safe; additive, never deletes cloud)
- Resolve conflicts on push/pull: `--on-conflict [fail|keep-local|keep-cloud|keep-both]` (default `fail` lists conflicts and aborts, git-style)
- One-way mirror (local -> cloud): `basic-memory cloud sync --name "name"` (Personal workspaces only; deletes cloud files missing locally)
- Two-way mirror (local <-> cloud): `basic-memory cloud bisync --name "name"` (Personal workspaces only)
### MCP Capabilities
- Basic Memory exposes these MCP tools to LLMs:
@@ -442,5 +532,9 @@ With GitHub integration, the development workflow includes:
3. **Branch management** - Claude can create feature branches for implementations
4. **Documentation maintenance** - Claude can keep documentation updated as the code evolves
5. **Code Commits**: ALWAYS sign off commits with `git commit -s`
6. **Pull Request Titles**: PR titles must follow the semantic format enforced by `.github/workflows/pr-title.yml`: `type(scope): summary`
- Allowed types: `feat`, `fix`, `chore`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`
- Allowed scopes: `core`, `cli`, `api`, `mcp`, `sync`, `ui`, `ci`, `deps`, `installer`, `plugins`, `skills`, `integrations`
- Example: `fix(cli): propagate cloud workspace routing`
This level of integration represents a new paradigm in AI-human collaboration, where the AI assistant becomes a full-fledged team member rather than just a tool for generating code snippets.
+488 -10
View File
@@ -1,14 +1,492 @@
# CHANGELOG
## Unreleased
## v0.22.0 (2026-06-11)
Team-safe cloud sync. New additive `bm cloud push` and `bm cloud pull`
commands work safely on shared Team workspaces, while the destructive mirror
commands are gated to Personal workspaces. Also: a large batch of MCP tool
fixes, search improvements, and embedding reliability work.
### Features
- **#917**: Added Team-safe `bm cloud push` / `bm cloud pull`. Both are
additive (they never delete on the destination) and abort on conflicts by
default, git-style, with `--on-conflict {fail|keep-local|keep-cloud|keep-both}`.
The destructive `bm cloud sync` / `bm cloud bisync` mirrors are now gated
to Personal workspaces.
- **#920**: Team push/pull uses per-workspace rclone remotes, so remotes and
credentials stay scoped to each workspace.
- **#908**: Search supports an observation category filter.
- **#809**: Added an experimental LiteLLM embedding provider for semantic
search (marked experimental, see **#899**).
- **#907**: `bm tool write-note` accepts `--type`.
- **#906**: `bm status` accepts `--wait` and `--timeout`.
- Added the `bm tool delete-note` command.
- **#905**: Improved workspace and cloud bisync command discoverability.
### Bug Fixes
- **#931 / #946**: Truncated observation permalinks are disambiguated to
prevent search-index collisions, and `build_context` resolves observations
by the same permalink the search index uses (**#909**, **#929**).
- **#934**: `edit_note` recovers when the file exists on disk but is not yet
indexed (**#581**).
- **#911 / #932 / #941**: Comma-separated tags are split consistently in
`parse_tags` and the `search_notes` tags parameter, with input normalized
for direct callers (**#910**).
- **#933**: `read_note` accepts `page`/`page_size` for parity with sibling
tools (**#883**).
- **#914 / #904 / #916**: `move_note` resolves `memory://` URLs, stops
falsely rejecting same-project moves as cross-project, no longer reports
false success across project boundaries, and mismatch guidance points at
the landing path.
- **#915**: Navigation pagination is validated, and `recent_activity` shows
the correct project.
- **#913**: `bm tool` commands align with MCP behavior (error exit codes,
overwrite handling, category support, defaults).
- **#923**: `bm cloud setup` no longer overwrites an existing rclone remote
(**#922**).
- **#912**: The `note_types` search filter is case-insensitive.
- `build_context` allows cross-project context traversal, `write_note`
resolves overwrite conflicts, and sync uses strict deferred relation
resolution.
- Embedding reliability: FastEmbed vectors are L2-normalized (**#843**),
corrupt FastEmbed model caches self-heal (**#900**), a single embedding
provider is reused per process (**#903**), `sqlite-vec` loads for the
embedding-status query (**#901**), and engine disposal no longer crashes
on the Postgres backend (**#902**).
### Maintenance
- Leaner, faster CI: testmon-selected branch builds, sharded Postgres jobs,
and faster default test fixtures (**#928**, **#938**, **#945**).
- Documented personal-vs-team cloud sync semantics (**#947**, closes
**#851**).
- Fixed npx skill install docs (**#927**).
- Added `glama.json` to claim the Glama MCP directory listing (**#953**).
## v0.21.6 (2026-06-04)
Monorepo consolidation plus a redesigned Claude Code plugin. The satellite
repositories now live in the main `basic-memory` tree, and the plugin is
rebuilt as a memory bridge with a guided setup interview, capture skills, and
team workspaces. Codex, Hermes, and OpenClaw integration packages ship
alongside it.
### Core
- Consolidated the Basic Memory satellite repositories into the main
`basic-memory` tree as the canonical source, discovery, documentation,
issue, and release home.
- Added root marketplace/package validation for the consolidated repository
layout.
- Added root and package-local justfile targets so Claude Code, Codex, skills,
Hermes, and OpenClaw builds can be verified from the monorepo.
- Removed the legacy `ui/` directory.
### API
- Entity resolution now exposes the owning project on resolved entities so
callers can route follow-up reads to the correct workspace/project.
### CLI
- Added an "auto BM" GitHub CI workflow that captures notes from CI runs.
- Exposed project sync-support metadata and surfaced copyable workspace
identifiers in project listings.
- `cloud login` now surfaces non-subscription errors instead of masking them.
- Team workspaces block `rclone sync`, with the team-workspace guard limited
to `bisync`.
### Claude Code
- Rebuilt the Claude Code plugin as a memory bridge with SessionStart and
PreCompact hooks, a bundled output style, and seeded note schemas.
- Added the `/basic-memory:setup` bootstrap interview and the
`/basic-memory:remember`, `/basic-memory:status`, and `/basic-memory:share`
skills.
- Added team workspace support with attribution for shared writes.
- Prefixed plugin skills with `bm-` and installed the shared `memory-*` skills
instead of duplicating them in the plugin.
- Hooks fall back to `uvx`/`uv` when no CLI binary is on PATH.
### Codex
- Added the Codex plugin under `plugins/codex/` with its native
`.codex-plugin`, hooks, seeded schemas, and `bm-*` skills.
### Skills
- Added the shared Basic Memory `SKILL.md` collection under top-level
`skills/` and ported useful retired plugin skills into the `memory-*` set.
- Fixed invalid picoschema enum YAML in the memory skills.
### Hermes
- Added the Hermes memory provider plugin under `integrations/hermes/` with
its native Python module, `plugin.yaml`, skill, tests, docs, and release
metadata.
### OpenClaw
- Added the OpenClaw plugin under `integrations/openclaw/` with its native
npm/TypeScript package shape, tests, docs, and release metadata; skill
bundling copies from the top-level `skills/` source.
## v0.21.5 (2026-05-26)
Workspace/project routing fixes for MCP, plus a SQLite vector reindex stability fix.
### Bug Fixes
- **#854**: MCP project listing now keeps duplicate cloud project rows distinct by
workspace, so only the selected workspace row inherits local project state.
- **#853**: `write_note` returns workspace-qualified permalinks for cloud
workspace writes, allowing follow-up `memory://` reads to route back to the
correct workspace/project.
- **#852**: Full SQLite vector reindex now loads `sqlite-vec` before dropping
`vec0` virtual tables, preventing reindex crashes.
- **#838**: Local ASGI database initialization is preloaded so MCP routing can
safely enter local project contexts.
## v0.21.1 (2026-05-16)
CI-only release. No user-facing changes.
### Maintenance
- **#833**: Replace `mislav/bump-homebrew-formula-action` with an inline
bash bump step. The action's `resolveRedirect` HEAD-requests
`api.github.com /repos/.../tarball/<ref>` expecting HTTP 302; GitHub now
returns 303 for authenticated requests on that endpoint, which broke
the v0.21.0 Homebrew job and required a manual tap bump. The inline
step does the same work (curl + sha256sum, clone tap, sed-bump
`url`/`sha256`, commit, push) without any third-party dependency.
## v0.21.0 (2026-05-16)
Workspace-aware everywhere: every MCP tool and CLI command now routes through the
same workspace/project model, the search and sync paths are noticeably faster,
and a handful of long-standing parsing and routing footguns are gone.
### Breaking Changes
- Relation parsing no longer treats unquoted multi-word text before a wikilink as a
custom relation type. Use single-token relation types like `relates_to [[Target]]`,
or quote multi-word relation types like `"relates to" [[Target]]` or
`'relates to' [[Target]]`.
- Bare list wikilinks like `- [[Target]]` now index as `links_to`.
- Prose list items like `- some other thing [[Target]]` now index as `links_to`.
- To preserve existing multi-word relation types on re-sync, quote them before upgrading.
- See **#824**.
### Features
- **#816**: `bm orphan` CLI command surfaces entities whose underlying markdown
files are gone, with a flag to clean them out.
- **#789**: Create projects directly by cloud workspace slug from MCP
(`create_memory_project(workspace=...)`).
- **#757**: Discover projects across every accessible cloud workspace in MCP's
project list — no more per-workspace blind spots.
- **#766**: MCP tools accept training-data-friendly parameter aliases
(`q`/`search`/`text` for `query`, etc.) so models reach for them naturally.
- **#776**: `bm db reset` refuses to run while a `basic-memory mcp` process is
alive, so resets can no longer corrupt an open session.
- **#791**: Search responses include result totals so pagers can stop guessing.
- **#719**: Cloud `note_content` tenant schema primitive lands on the backend.
- **#715**: `bm project add` accepts a `--visibility` flag for cloud projects.
### Bug Fixes
#### Search and recent activity
- **#832**: SQLite project deletion now sweeps `search_index`,
`search_vector_chunks`, and `search_vector_embeddings`, so a project that
reuses a recycled auto-increment id can't inherit the previous tenant's content.
- **#812**: `recent_activity` orders and filters by `updated_at`, so edits bubble
to the top instead of staying pinned to creation time.
- **#807**: Multi-project `search_notes` is opt-in (`search_all_projects=True`);
default search stays scoped to the resolved project.
- **#785**: `recent_activity` caps responses and emits an explicit truncation
footer instead of silently dropping rows.
- **#713**: Eliminated an N+1 query in search result hydration.
#### Workspace / project routing
- **#822**: `bm project list` now includes projects from every workspace, not
just the current one.
- **#813**, **#808**, **#806**, **#803**, **#801**, **#795**, **#790**, **#778**,
**#777**, **#722**, **#712**, **#704**: Workspace-qualified permalink routing
is centralized and applied consistently across `edit_note`, `delete_project`,
`build_context`, `memory://` URLs, factory-mode project listing, cloud uploads,
and the API client.
#### Sync
- **#827**: `rclone bisync` filters from `.bmignore` are preserved across syncs.
- **#815**: Watch service ignores hidden paths relative to the watched project,
not just relative to `$HOME`.
- **#814**: `scan` subprocesses no longer go through the shell, avoiding quoting
issues with paths that contain special characters.
- **#759**: Watch service stays inside `--project` scope.
- **#746**: Canonical markdown is preserved during single-file sync.
#### Parsing
- **#796**: Picoschema modifier descriptions (`field?(modifier, description)`)
parse correctly.
- **#769**: Obsidian callout blocks are skipped by the observation parser
instead of being mis-extracted as observations.
#### CLI
- **#775**: `bm project set-cloud` / `set-local` cleans up local DB state for
the affected project.
- **#773**: `bm cloud logout` clears `default_workspace`.
- **#780**: `bm cloud setup` hint points at `bm cloud sync-setup`.
- **#734**: `bm project info` shows cloud index freshness.
- **#718**: Private cloud projects display under their `display_name` instead of
raw UUID.
- **#768**: `read_note` / `view_note` drop no-op pagination params from their
signatures.
#### Stability
- **#774**: `sqlite-vec` failures during init degrade gracefully to keyword-only
search instead of crashing startup.
- **#733**: Delete-vector and cloud-sync cleanup is now consistent.
- **#702**: Race conditions in concurrent `delete_entity` are resolved.
- **#724**: `external_id` is preserved when entities are re-upserted during a
re-index.
- **#728**: Vector init no longer issues runtime `ALTER TABLE`.
- **#744**: `BASIC_MEMORY_CONFIG_DIR` is honored across remaining call sites.
- **#743**: FastEmbed cache lives under the data dir instead of `/tmp`.
- **#752**: Cloud projects report `source=cloud` in factory mode.
- Stripped null bytes from markdown content before DB insert.
- Allowed long `relation_type` values in API responses.
#### Installer
- **#772**: Docker-compose config volume mounts under the `appuser` home.
- **#695**: Bumped `brew outdated` timeout from 15s to 60s.
### Performance
- **#828**: CLI startup no longer pulls in the local ASGI FastAPI app when it
isn't needed.
- **#751**, **#726**, **#717**, **#714**, single-file/batch indexing: marked
speedups on the sync hot path; unchanged markdown is skipped entirely.
- **#731**, **#723**: Vector sync is faster on both backends, with tuned
fastembed defaults.
### Maintenance
- **#825**: Dependency refresh + security hardening.
- Updated to `fastmcp` 3.3.1.
- **#754**: Removed in-house telemetry wrappers in favor of direct `logfire`
usage.
- **#736**: `ty` is now the default typechecker.
- **#771**, **#770**, **#716**: New regression guards for vector-row cleanup,
long relation types, and recent activity hydration.
## v0.20.3 (2026-03-26)
### Bug Fixes
- **#698**: CLI cloud commands now use API key when configured
- `get_authenticated_headers()` only checked OAuth tokens, ignoring `config.cloud_api_key`
- All CLI cloud commands (`upload`, `status`, `snapshot`, `restore`, etc.) failed for API-key-only users while MCP tools worked fine
- Now mirrors the same credential priority as MCP: API key first, OAuth fallback
- Fixes `bm cloud upload --project` returning "project does not exist" when authenticated with `bmc_*` API key
## v0.20.2 (2026-03-10)
### Bug Fixes
- Fix auto-update Homebrew detection: `brew outdated` exits 1 when a formula is outdated, not on error
- Previously treated exit code 1 as a failure, causing "Automatic update check failed" instead of detecting the available update
## v0.20.1 (2026-03-10)
### Bug Fixes
- **#661**: Fix `bm project list` MCP column to show transport type (stdio/https) instead of DB presence
- Renamed "MCP (stdio)" column to "MCP"
- Shows actual routing mode: `stdio` for local, `https` for cloud projects
- Clears local path display for cloud-mode projects
- **#662**: Invalidate config cache when file is modified by another process
- Adds mtime-based cache validation to `ConfigManager.load_config()`
- Long-lived processes (MCP stdio server) now detect external config changes
- Fixes `bm project set-cloud` having no effect on running MCP server
## v0.20.0 (2026-03-10)
### Features
- **#643**: Default-on auto-update system and `bm update` command
- Automatic background update checks for CLI installs (uv tool, Homebrew)
- Install-source detection (homebrew, uv_tool, uvx, unknown) with uvx skip behavior
- Periodic check gating via `auto_update_last_checked_at` + `update_check_interval` config
- Manager-specific update flows: Homebrew (`brew upgrade`) and uv tool (`uv tool upgrade`)
- Silent, non-blocking MCP behavior via daemon thread before server run
- Manual commands: `bm update` (force check + apply) and `bm update --check` (check only)
- New config fields: `auto_update`, `update_check_interval`, `auto_update_last_checked_at`
## v0.19.2 (2026-03-09)
### Bug Fixes
- **#657**: Coerce string params to list/dict in MCP tools
- MCP clients that serialize `list`/`dict` arguments as JSON strings no longer fail Pydantic validation
- Adds `BeforeValidator` coercion to `search_notes` (`entity_types`, `note_types`, `tags`, `metadata_filters`), `write_note` (`metadata`), and `canvas` (`nodes`, `edges`)
- **#655**: Handle SQLite and Windows semantic search regressions
- Fix embedding status query for non-semantic SQLite databases
- Windows-safe log file rotation with per-process log filenames
- Robust `setup_logging` that handles all environments cleanly
## v0.19.1 (2026-03-08)
### Bug Fixes
- **#649**: Enforce strict entity resolution in destructive MCP tools (`edit_note`, `move_note`, `delete_note`)
- Prevents fuzzy-match fallback from silently editing/moving/deleting the wrong note
- DST-related timeframe validation fix (round instead of truncate days)
### Features
- **#648**: Add `insert_before_section` and `insert_after_section` edit operations
- Add `GET /knowledge/graph` endpoint for full graph visualization
### Dependencies
- Bump authlib from 1.6.6 to 1.6.7
## v0.19.0 (2026-03-07)
### Highlights
- **Semantic vector search** for SQLite and Postgres with FastEmbed embeddings
- **Schema system** for validating and inferring knowledge base structure
- **Per-project cloud routing** with API key authentication
- **Upgraded to FastMCP 3.0** with tool annotations
- **CLI overhaul** with JSON output, workspace awareness, and project dashboard
### Features
- **#550**: Add semantic vector search for SQLite and Postgres
- FastEmbed-based embeddings with automatic backfill
- Hybrid search combining full-text and vector similarity
- Score-based fusion replacing RRF for better ranking
- `min_similarity` override for tuning search precision
- Semantic dependencies are now default, with optional extras fallback
- **#549**: Schema system for Basic Memory
- `schema_infer` — infer schema from existing notes
- `schema_validate` — validate notes against a schema definition
- `schema_diff` — compare schemas across projects
- Frontmatter validation support (#597)
- Read schema definitions from file instead of stale DB metadata (#635)
- **#555**: Per-project local/cloud routing with API key auth
- Individual projects route through cloud while others stay local
- `basic-memory cloud set-key` and `basic-memory project set-cloud/set-local`
- Stdio MCP honors per-project cloud routing (#590)
- **#598**: Upgrade FastMCP 2.12.3 to 3.0.1 with tool annotations
- **#585**: Add JSON output mode for MCP tools (default text)
- `--json` output for CLI commands for scripting and CI
- **#576**: Add workspace selection flow for MCP and CLI
- Workspace-aware cloud project listing
- CLI refactoring for workspace support
- **#544**: Project-prefixed permalinks and memory URL routing
- **#632**: Add overwrite guard to `write_note` tool
- **#614**: `edit_note` append/prepend auto-creates note if not found
- **#609**: Richer content context in search results
- Return matched chunk text in search results (#601)
- Improved content hit rate
- **#602**: Add `created_by` and `last_updated_by` user tracking to Entity
- **#600**: Rename `entity_type` to `note_type` across codebase
- **#574**: Add `display_name` and `is_private` to ProjectItem
- **#569**: Expose `external_id` in EntityResponse and link resolver
- **#567**: Isolate default SQLite DB by config dir
- **#560**: Enable `default_project_mode` by default
- **#559**: Add `basic-memory watch` CLI command
- **#546**: Add cloud discovery touchpoints to CLI and MCP
- **#572**: CLI analytics via Umami event collector
- Replace project info with htop-inspired dashboard
- Merge `search_by_metadata` into `search_notes` with optional query
- Add `--strip-frontmatter` to `basic-memory tool read-note`
- Default behavior is unchanged: `content` still includes raw markdown with frontmatter.
- With `--strip-frontmatter`, both text and JSON modes return body-only markdown content.
- JSON output now includes an additive `frontmatter` field with parsed YAML metadata (or `null`
when no valid opening frontmatter block exists).
- Add `destination_folder` parameter to `move_note` tool
### Bug Fixes
- **#644**: Fix default project resolution in cloud mode
- ChatGPT search/fetch tools broken in cloud mode
- `resolve_project_parameter` falls back to projects API
- **#638**: Restore API backward compatibility for v0.18.x clients
- **#637**: Create backup before config migration overwrites old format
- **#636**: `list_workspaces` bypasses factory pattern on cloud MCP server
- **#631**: `build_context` related_results schema validation failure
- **#613**: Reduce excessive log volume by demoting per-request noise to DEBUG
- **#612**: Handle quoted picoschema enum strings in YAML frontmatter
- **#607**: Guard against closed streams in promo and missing vector tables
- **#606**: Accept null for `expected_replacements` in `edit_note`
- **#595**: `recent_activity` dedup and pagination across MCP tools
- **#593**: Backend-specific distance-to-similarity conversion
- **#582**: Use LinkResolver fallback in `build_context` for flexible identifier matching
- **#577**: Replace RRF with score-based fusion in hybrid search
- **#575**: Remove hardcoded "main" default from `default_project`
- **#534**: Speed up `bm --version` startup
- Fix semantic embeddings not generated on fresh DB or upgrade
- Clarify `search_notes` parameter naming and fix `note_types` case sensitivity
- Parse `tag:` prefix at MCP tool level to avoid hybrid search failure
- Cap sqlite-vec knn k parameter at 4096 limit
- Parameterize SQL queries in search repository type filters
- Coerce list frontmatter values to strings for title and type fields
- Avoid `Post(**metadata)` crash when frontmatter contains 'content' or 'handler' keys
- Upgrade cryptography and python-multipart for security advisories
### Internal
- **#594**: Add `ty` as supplemental type checker
- Batched vector sync orchestration across repositories
- FastEmbed parallel guardrails and provider caching
- Improved cloud CLI status and error messages
- CI coverage and Postgres test fixes
## v0.18.5 (2026-02-13)
@@ -1927,12 +2405,12 @@ Signed-off-by: phernandez <paul@basicmachines.co>
- Update CLAUDE.md ([#33](https://github.com/basicmachines-co/basic-memory/pull/33),
[`dfaf0fe`](https://github.com/basicmachines-co/basic-memory/commit/dfaf0fea9cf5b97d169d51a6276ec70162c21a7e))
fix spelling in CLAUDE.md: enviroment -> environment Signed-off-by: Ikko Eltociear Ashimine
fix spelling in CLAUDE.md: environment typo Signed-off-by: Ikko Eltociear Ashimine
<eltociear@gmail.com>
### Refactoring
- Move project stats into projct subcommand
- Move project stats into project subcommand
([`2a881b1`](https://github.com/basicmachines-co/basic-memory/commit/2a881b1425c73947f037fbe7ac5539c015b62526))
Signed-off-by: phernandez <paul@basicmachines.co>
@@ -2361,7 +2839,7 @@ Co-authored-by: phernandez <phernandez@basicmachines.co>
### Bug Fixes
- Refix vitual env in installer build
- Refix virtual env in installer build
([`052f491`](https://github.com/basicmachines-co/basic-memory/commit/052f491fff629e8ead629c9259f8cb46c608d584))
@@ -2380,7 +2858,7 @@ Co-authored-by: phernandez <phernandez@basicmachines.co>
### Bug Fixes
- Fix path to intaller app artifact
- Fix path to installer app artifact
([`53d220d`](https://github.com/basicmachines-co/basic-memory/commit/53d220df585561f9edd0d49a9e88f1d4055059cf))
@@ -2388,7 +2866,7 @@ Co-authored-by: phernandez <phernandez@basicmachines.co>
### Bug Fixes
- Activate vitualenv in installer build
- Activate virtualenv in installer build
([`d4c8293`](https://github.com/basicmachines-co/basic-memory/commit/d4c8293687a52eaf3337fe02e2f7b80e4cc9a1bb))
- Trigger installer build on release
-34
View File
@@ -224,40 +224,6 @@ See `test-int/BENCHMARKS.md` for detailed benchmark documentation.
- **Fixtures**: Use async pytest fixtures for setup and teardown
- **Markers**: Use `@pytest.mark.benchmark` for benchmarks, `@pytest.mark.slow` for slow tests
## Release Process
Basic Memory uses automatic versioning based on git tags with `uv-dynamic-versioning`. Here's how releases work:
### Version Management
- **Development versions**: Automatically generated from git commits (e.g., `0.12.4.dev26+468a22f`)
- **Beta releases**: Created by tagging with beta suffixes (e.g., `git tag v0.13.0b1`)
- **Stable releases**: Created by tagging with version numbers (e.g., `git tag v0.13.0`)
### Release Workflows
#### Development Builds
- Automatically published to PyPI on every commit to `main`
- Version format: `0.12.4.dev26+468a22f` (base version + dev + commit count + hash)
- Users install with: `pip install basic-memory --pre --force-reinstall`
#### Beta Releases
1. Create and push a beta tag: `git tag v0.13.0b1 && git push origin v0.13.0b1`
2. GitHub Actions automatically builds and publishes to PyPI
3. Users install with: `pip install basic-memory --pre`
#### Stable Releases
1. Create and push a version tag: `git tag v0.13.0 && git push origin v0.13.0`
2. GitHub Actions automatically:
- Builds the package with version `0.13.0`
- Creates GitHub release with auto-generated notes
- Publishes to PyPI
3. Users install with: `pip install basic-memory`
### For Contributors
- No manual version bumping required
- Versions are automatically derived from git tags
- Focus on code changes, not version management
## Creating Issues
If you're planning to work on something, please create an issue first to discuss the approach. Include:
+504 -505
View File
File diff suppressed because it is too large Load Diff
+67 -2
View File
@@ -8,6 +8,71 @@
## Reporting a Vulnerability
Use this section to tell people how to report a vulnerability.
If you find a vulnerability, please contact hello@basicmachines.co.
If you find a vulnerability, please contact hello@basicmachines.co
Please do not open a public GitHub issue for security vulnerabilities. We aim
to respond within 72 hours and will coordinate a fix and disclosure timeline
with you.
## Threat Model
Basic Memory is a local-first MCP server that reads and writes markdown files
inside configured project directories. It runs on your machine with your user
permissions, so local configuration deserves the same care as any other
developer tool that can access your files.
### What Basic Memory Controls
- Filesystem-touching tools validate paths against the configured project root
with `validate_project_path()`, resolved paths, and `Path.is_relative_to()`.
Path traversal attempts such as `../../etc/passwd` are blocked at this layer.
- Scan optimizations in `sync_service.py` call `find` through
`asyncio.create_subprocess_exec()` with explicit argument lists. Project paths
are passed as data, not interpolated into shell strings.
- Auto-update code uses hardcoded commands, list-form arguments, and
`stdin=DEVNULL`. User-controlled strings do not reach a shell there.
### MCP Client-Side Risk
Recent MCP ecosystem research has highlighted a client-side pattern where an
MCP host can be configured to run arbitrary commands as "servers." That risk is
in the host configuration, not in notes or Basic Memory tool input.
The recommended Basic Memory MCP configuration uses a known command with
explicit arguments:
```json
{
"mcpServers": {
"basic-memory": {
"command": "uvx",
"args": ["basic-memory", "mcp"]
}
}
}
```
Only add MCP server entries from sources you trust. Avoid inline shell scripts
or command strings copied from untrusted sources. Treat third-party MCP server
configuration with the same scrutiny as any locally executed program.
Related ecosystem context:
- OX Security: The Mother of All AI Supply Chains
- CSO Online: RCE by design: MCP architectural choice haunts AI agent ecosystem
### Out Of Scope
- Basic Memory does not execute note content as code. Notes are returned as
data to the LLM.
- Basic Memory does not open network ports by default. The MCP server uses
stdio; the optional REST API is intended for localhost use.
- Basic Memory is designed for single-user local knowledge bases and does not
implement access controls between operating-system users.
## Secure Configuration Checklist
- MCP config `command` points to `uvx` or a trusted binary, not a shell string.
- Project paths in Basic Memory config come from trusted local configuration.
- If exposing the REST API, bind it only to localhost.
- Review any third-party MCP servers before adding them to your host config.
+5 -2
View File
@@ -1,5 +1,8 @@
# Docker Compose configuration for Basic Memory with PostgreSQL
# Use this for local development and testing with Postgres backend
# Use this for local development and testing with Postgres backend.
#
# The Postgres backend requires the pgvector extension (semantic search).
# This image bundles pgvector; plain postgres:17 will not work for vector search.
#
# Usage:
# docker-compose -f docker-compose-postgres.yml up -d
@@ -7,7 +10,7 @@
services:
postgres:
image: postgres:17
image: pgvector/pgvector:pg17
container_name: basic-memory-postgres
environment:
# Local development/test credentials - NOT for production
+3 -1
View File
@@ -17,7 +17,9 @@ services:
volumes:
# Persistent storage for configuration and database
- basic-memory-config:/root/.basic-memory:rw
# Container runs as `appuser` (Dockerfile USER directive), so the CLI
# config dir lives under /home/appuser, not /root.
- basic-memory-config:/home/appuser/.basic-memory:rw
# Mount your knowledge directory (required)
# Change './knowledge' to your actual Obsidian vault or knowledge directory
+64
View File
@@ -0,0 +1,64 @@
# Basic Memory Engineering Style
Style is how we make code easier to verify. Prefer explicit, typed, local-first code that
preserves the file system as the source of truth while keeping the database, API, and MCP
surfaces in sync.
## Design Center
- Basic Memory is local-first. Markdown files are the durable source; SQLite/Postgres indexes
are derived state that should be rebuilt or reconciled from files when needed.
- Keep the existing boundary order: CLI/MCP/API entrypoints compose dependencies, services own
business behavior, repositories own database access, and file services own filesystem writes.
- MCP tools should remain atomic and composable. They should call API routers through typed MCP
clients, not reach around into services.
- Prefer small, explicit abstractions that match a real domain boundary. Avoid object
hierarchies when a function, dataclass, type alias, or protocol describes the concept better.
## Types And Data
- Use full type annotations and Python 3.12 syntax. Introduce `type` aliases for repeated
structured shapes, callback signatures, or domain concepts that would otherwise become
anonymous `dict[str, Any]` values.
- Use dataclasses for internal values, operation inputs, and service results. Prefer
`frozen=True` when the value should not change and `slots=True` when identity/dynamic
attributes are not needed.
- Use Pydantic v2 at boundaries that validate, serialize, or deserialize data: API payloads,
CLI/MCP schemas, configuration, and persistence-adjacent schemas.
- Use narrow `Protocol`s when a caller needs a capability rather than a concrete repository or
service. Keep protocols small enough that fake implementations in tests are obvious.
- Avoid speculative `getattr`, broad casts, or `Any` as a way to paper over uncertainty. Read
the model or schema definition and make the type relationship explicit.
## Control Flow And Resources
- Fail fast when an invariant is broken. Do not swallow exceptions, add warning-only error
handling, or introduce fallback behavior unless the user explicitly agrees to that behavior.
- Keep control flow simple and close to the domain decision. Push `if` statements up into the
function that owns orchestration; keep leaf helpers focused on computation or one side effect.
- Make async/resource boundaries visible with context managers and explicit lifecycles. Do not
start background work without a clear owner, cancellation story, and verification path.
- Keep file mutations centralized through the existing file utilities/services so checksum,
atomic write, and index synchronization behavior stays coherent.
## Testing And Verification
- Use evidence-first testing, not mechanical TDD. For bugs and risky behavior, add or update a
regression test that would catch the failure. For small documentation-only edits, use the
relevant doc/repo hygiene checks.
- Prefer tests that exercise real code paths. Use mocks, doubles, or `monkeypatch` only when
the external boundary would be slow, nondeterministic, or impossible to trigger directly.
- Keep coverage at 100% for new code. Use `# pragma: no cover` only for code that would require
disproportionate mocking and is covered through an integration or runtime path.
- Start with targeted commands, then widen as risk grows: focused pytest, `just fast-check`,
`just doctor`, package checks for agent packaging changes, and full SQLite/Postgres gates
when behavior crosses shared boundaries.
## Comments And Names
- Name values after the domain concept they carry: project, entity, permalink, tenant, route,
checksum, observation, relation, batch, or index state.
- Comments should say why a branch, invariant, retry, lifecycle, or compatibility constraint
exists. Section headers are useful when a function or file has clear phases.
- Avoid comments that restate the code. If a comment cannot explain a decision, simplify the
code or improve the name instead.
+26 -8
View File
@@ -106,7 +106,7 @@ The parser excludes these list item patterns:
|---------|---------|--------|
| Checkboxes | `- [ ] Todo item`, `- [x] Done`, `- [-] Cancelled` | Task list syntax |
| Markdown links | `- [text](url)` | URL link syntax |
| Bare wiki links | `- [[Target]]` | Treated as a relation instead |
| Bare wiki links | `- [[Target]]` | Treated as a `links_to` relation instead |
A list item with `#tags` but no `[category]` is still parsed — the tags are extracted and the category defaults to `Note`.
@@ -116,47 +116,65 @@ Relations connect documents to form the knowledge graph. There are two kinds.
### Explicit Relations
Written as list items with a relation type and a `[[wiki link]]` target.
Written as list items with a relation type and a `[[wiki link]]` target. Unquoted
relation types are single tokens. Quote relation types that contain spaces.
**Syntax:**
```
- relation_type [[Target Entity]] (context)
- "multi word relation type" [[Target Entity]] (context)
- 'multi word relation type' [[Target Entity]] (context)
```
| Part | Required | Description |
|------|----------|-------------|
| `relation_type` | No | Text before `[[`. Defaults to `relates_to` if omitted. |
| `relation_type` | Yes | Single unquoted token before `[[`, or quoted text for multi-word labels. |
| `[[Target]]` | Yes | Wiki link to the target entity. Matched by title or permalink. |
| `(context)` | No | Parenthesized text after `]]`. Supporting details. |
### Examples
Explicit relations:
```markdown
- implements [[Search Design]]
- depends_on [[Database Schema]]
- works_at [[Y Combinator]] (co-founder)
- [[Some Entity]]
- "based on" [[Customer Interview]]
- 'in response to' [[Incident Review]]
```
The last example — a bare `[[wiki link]]` in a list item — gets relation type `relates_to`.
Bare wiki links and prose list items create implicit `links_to` relations:
```markdown
- [[Some Entity]]
- some other thing [[Some Entity]]
```
Both examples above create `links_to [[Some Entity]]`. Use quotes when the words before
`[[` are meant to be a multi-word relation type.
Common relation types:
- `implements`, `depends_on`, `relates_to`, `inspired_by`
- `extends`, `part_of`, `contains`, `pairs_with`
- `works_at`, `authored`, `collaborated_with`
Any text works as a relation type. These are conventions, not a fixed set.
Any single-token text or quoted text works as a relation type. These are conventions,
not a fixed set.
### Inline References
Wiki links appearing in regular prose (not as list items) create implicit `links_to` relations.
Wiki links appearing in regular prose create implicit `links_to` relations. This includes
list items that do not match the explicit relation grammar above.
```markdown
This builds on [[Core Design]] and uses [[Utility Functions]].
- We should revisit [[Search Design]] after the API changes.
```
This creates two relations: `links_to [[Core Design]]` and `links_to [[Utility Functions]]`.
This creates three relations: `links_to [[Core Design]]`, `links_to [[Utility Functions]]`,
and `links_to [[Search Design]]`.
### Forward References
+41 -1
View File
@@ -91,10 +91,11 @@ SQLite Database (Index)
# List all projects
projects = await list_memory_projects()
# Response structure:
# Response structure (each entry includes external_id you can pass as project_id):
# [
# {
# "name": "main",
# "external_id": "550e8400-e29b-41d4-a716-446655440000",
# "path": "/Users/name/notes",
# "is_default": True,
# "note_count": 156,
@@ -102,6 +103,7 @@ projects = await list_memory_projects()
# },
# {
# "name": "work",
# "external_id": "9f86d081-884c-42a3-b5e3-1c0c5b4c8e52",
# "path": "/Users/name/work-notes",
# "is_default": False,
# "note_count": 89,
@@ -164,6 +166,44 @@ active_project = "main"
results = await search_notes(query="topic", project=active_project)
```
### `project` vs `project_id`
Every project has two identifiers:
- **`project`** — human-readable name (e.g., `"main"`). Easy to use, but can collide across cloud workspaces.
- **`project_id`** — stable `external_id` UUID. Always unambiguous; takes precedence over `project` when both are passed.
**When to prefer `project_id`:**
1. **Cloud multi-workspace setups.** If the user belongs to more than one workspace (personal + organization, or several organizations) and the same project name might exist in more than one of them, pass `project_id` to route to the exact project. Without it, name resolution falls back to the default workspace, which may not be the one the user means.
2. **After `list_memory_projects()`.** Once you have the `external_id`, prefer using it — it's the same number of characters in JSON and saves a name-resolution round-trip.
3. **When persisting a project choice across a long session.** UUIDs are stable; names can be renamed.
**When `project` (name) is fine:**
- Local single-workspace setups (no collision risk).
- One-off operations where the name is clearly visible to the user (e.g., quick `search_notes(project="main", ...)`).
- The user explicitly references a project by name in their message.
**Example — cloud multi-workspace pattern:**
```python
# Discover and pick the right project for this user
projects = await list_memory_projects()
target = next(p for p in projects if p["name"] == "research" and p["workspace"]["slug"] == "acme")
# Use the UUID for all subsequent operations — no ambiguity
await write_note(
title="Meeting Notes",
content="...",
folder="meetings",
project_id=target["external_id"],
)
results = await search_notes(query="kickoff", project_id=target["external_id"])
```
**Precedence rule:** When both are passed, `project_id` wins. This lets you safely supply `project="main"` for backward compatibility while still routing precisely with `project_id`.
### Cross-Project Operations
**Some tools work across all projects when project parameter omitted:**
+275 -84
View File
@@ -8,9 +8,25 @@ The cloud CLI enables you to:
- **Authenticate cloud access** - OAuth/API key credentials are stored locally for cloud operations
- **Project-scoped sync** - Each project independently manages its sync configuration
- **Explicit operations** - Sync only what you want, when you want
- **Bidirectional sync** - Keep local and cloud in sync with rclone bisync
- **Team-safe push/pull** - Additive, git-style transfers that work on shared Team workspaces
- **Bidirectional sync** - Keep local and cloud in sync with rclone bisync (Personal workspaces)
- **Offline access** - Work locally, sync when ready
### Personal vs Team workspaces
The transfer commands fall into two groups:
| Command | Direction | Behavior | Personal | Team |
|---|---|---|---|---|
| `bm cloud pull` | cloud → local | **additive** — never deletes local | ✅ | ✅ |
| `bm cloud push` | local → cloud | **additive** — never deletes cloud | ✅ | ✅ |
| `bm cloud sync` | local → cloud | **mirror** — deletes cloud files missing locally | ✅ | ❌ |
| `bm cloud bisync` | local ↔ cloud | **mirror** — two-way, deletes on both sides | ✅ | ❌ |
`sync` and `bisync` are mirror operations: one local tree becomes authoritative and files missing on the other side get deleted. That is correct for a Personal workspace (one user, one source of truth) but unsafe on a shared Team bucket, where it could delete a teammate's files. On Team workspaces these commands exit early with a clear error and point you at `push`/`pull`.
`push` and `pull` are additive (they use `rclone copy`, which never deletes on the destination), so they are safe on both Personal and Team workspaces.
## Prerequisites
Before using Basic Memory Cloud, you need:
@@ -55,8 +71,8 @@ bm project add work --cloud --local-path ~/work-notes
bm project add temp --cloud # No local sync
# Now you can sync individually (after initial --resync):
bm project bisync --name research
bm project bisync --name work
bm cloud bisync --name research
bm cloud bisync --name work
# temp stays cloud-only
```
@@ -94,13 +110,18 @@ bm cloud setup
```
**What this does:**
1. Installs rclone automatically (if needed)
1. Installs rclone with a supported package manager (if needed)
2. Fetches your tenant information from cloud
3. Generates scoped S3 credentials for sync
4. Configures single rclone remote: `basic-memory-cloud`
**Result:** You're ready to sync projects. No sync directories created yet - those come with project setup.
Rclone setup uses package managers such as Homebrew, MacPorts, apt, dnf, yum, pacman,
zypper, snap, winget, Chocolatey, or Scoop when available. It does not run remote
install scripts with `sudo`; if no supported package manager is found, the CLI prints
manual install instructions.
### 3. Add Projects with Sync
Create projects with optional local sync paths:
@@ -113,7 +134,7 @@ bm project add research --cloud
bm project add research --cloud --local-path ~/Documents/research
# Or configure sync for existing project
bm project sync-setup research ~/Documents/research
bm cloud sync-setup research ~/Documents/research
```
**What happens under the covers:**
@@ -132,10 +153,10 @@ Establish the initial sync baseline. **Best practice:** Always preview with `--d
```bash
# Step 1: Preview the initial sync (recommended)
bm project bisync --name research --resync --dry-run
bm cloud bisync --name research --resync --dry-run
# Step 2: If all looks good, run the actual sync
bm project bisync --name research --resync
bm cloud bisync --name research --resync
```
**What happens under the covers:**
@@ -162,7 +183,7 @@ This will effectively make both Path1 and Path2 filesystems contain a matching s
After the first sync, just run bisync without `--resync`:
```bash
bm project bisync --name research
bm cloud bisync --name research
```
**What happens:**
@@ -230,13 +251,13 @@ bm project add research --cloud --local-path ~/Documents/research
- Stores sync config in `~/.basic-memory/config.json`
- Prepares for bisync (but doesn't sync yet)
**Result:** Project ready to sync. Run `bm project bisync --name research --resync` to establish baseline.
**Result:** Project ready to sync. Run `bm cloud bisync --name research --resync` to establish baseline.
**Use case 3: Add sync to existing cloud project**
```bash
# Project already exists on cloud
bm project sync-setup research ~/Documents/research
bm cloud sync-setup research ~/Documents/research
```
**What this does:**
@@ -289,18 +310,98 @@ For MCP stdio, routing is always local.
### Understanding the Sync Commands
**There are three sync-related commands:**
**There are five sync-related commands:**
1. `bm project sync` - One-way: local → cloud (make cloud match local)
2. `bm project bisync` - Two-way: local ↔ cloud (recommended)
3. `bm project check` - Verify files match (no changes)
| Command | Direction | Workspace | Summary |
|---|---|---|---|
| `bm cloud pull` | cloud → local | Personal + Team | Fetch cloud changes, additively (git-style) |
| `bm cloud push` | local → cloud | Personal + Team | Upload local changes, additively (git-style) |
| `bm cloud sync` | local → cloud | Personal only | One-way mirror (cloud becomes identical to local) |
| `bm cloud bisync` | local ↔ cloud | Personal only | Two-way mirror (recommended for solo use) |
| `bm cloud check` | — | Personal only | Verify mirror integrity (no changes) |
### One-Way Sync: Local → Cloud
If you collaborate on a shared Team workspace, use **`push`/`pull`** (see [Team Workspaces](#team-workspaces-push--pull-additive-git-style)). If you are the only writer (a Personal workspace), the mirror commands `sync`/`bisync` give you a single source of truth.
### Team Workspaces: push / pull (additive, git-style)
`push` and `pull` are the Team-safe transfer commands. They model `git push` / `git pull`:
- **`bm cloud pull`** fetches changes from the cloud into your local directory.
- **`bm cloud push`** uploads your local changes to the cloud.
Both use `rclone copy`, so they are **additive — they never delete on the destination**. A conflict (a file that differs on both sides) is never resolved silently: by default the command aborts and lists the conflicting files, exactly like git refusing to clobber your changes.
#### Pull: fetch cloud changes
```bash
# Preview first (recommended)
bm cloud pull --name research --dry-run
# Fetch new/changed cloud files into local
bm cloud pull --name research
```
**What happens:**
1. Compares cloud and local with `rclone check`
2. Downloads files that are new or changed on the cloud
3. Leaves your local-only files untouched (never deletes local)
4. If any file differs on both sides, aborts and lists the conflicts (unless you pass `--on-conflict`)
#### Push: upload local changes
```bash
bm cloud push --name research --dry-run
bm cloud push --name research
```
**What happens:**
1. Compares local and cloud with `rclone check`
2. Uploads files that are new or changed locally
3. Leaves cloud-only files untouched (never deletes cloud)
4. If any file differs on both sides, aborts and lists the conflicts — pull first, like a rejected `git push`
#### Resolving conflicts
When `push`/`pull` reports conflicts, re-run with `--on-conflict` to choose how differing files are handled. The value names exactly what survives, so it reads the same in both directions:
| `--on-conflict` | Behavior |
|---|---|
| `fail` *(default)* | List the conflicting files and exit without transferring anything |
| `keep-cloud` | Take the cloud version (pull: overwrite local; push: skip those files) |
| `keep-local` | Keep the local version (pull: skip those files; push: overwrite cloud) |
| `keep-both` | Keep both — write the incoming version beside the existing one as `name.conflict-<date>.md` |
```bash
# A teammate edited notes you also changed locally — pull reports a conflict:
bm cloud pull --name research
# pull aborted: 1 file(s) differ between local and cloud.
# * notes/decisions.md
# Re-run with one of: --on-conflict keep-cloud | keep-local | keep-both
# Take the cloud copy:
bm cloud pull --name research --on-conflict keep-cloud
# Or keep both versions to merge by hand:
bm cloud pull --name research --on-conflict keep-both
```
#### Limitations
`push`/`pull` are deliberately simple, conflict-aware byte transfers — not a full reconciler. Without a sync baseline:
- **Deletions are not propagated.** A note deleted on one side is not removed from the other (we cannot tell an intentional delete from a file the other side never had). This is surfaced in the command output.
- **Every divergence is treated as a conflict.** We cannot tell a teammate's edit from your stale copy, so any differing file prompts a decision rather than auto-resolving.
For conflict-aware *editing*, write through the MCP/API tools (which merge at the note level). A Team-safe bidirectional reconciler with a real baseline is tracked in [issue #862](https://github.com/basicmachines-co/basic-memory/issues/862).
### One-Way Sync: Local → Cloud (Personal only)
**Use case:** You made changes locally and want to push to cloud (overwrite cloud).
> **Personal workspaces only.** `sync` is a destructive mirror — it deletes cloud files that are not present locally. On a Team workspace it would delete a teammate's files, so it is blocked there. Use `bm cloud push` (additive) on Team workspaces.
```bash
bm project sync --name research
bm cloud sync --name research
```
**What happens:**
@@ -316,16 +417,18 @@ bm project sync --name research
- You want to force cloud to match local
- You don't care about cloud changes
### Two-Way Sync: Local ↔ Cloud (Recommended)
### Two-Way Sync: Local ↔ Cloud (Personal only, recommended for solo use)
**Use case:** You edit files both locally and in cloud UI, want both to stay in sync.
> **Personal workspaces only.** `bisync` is a two-way mirror that can delete and overwrite on both sides. It is blocked on Team workspaces — use `bm cloud pull` then `bm cloud push` there. A Team-safe bidirectional reconciler is tracked separately ([issue #862](https://github.com/basicmachines-co/basic-memory/issues/862)).
```bash
# First time - establish baseline
bm project bisync --name research --resync
bm cloud bisync --name research --resync
# Subsequent syncs
bm project bisync --name research
bm cloud bisync --name research
```
**What happens:**
@@ -344,7 +447,7 @@ echo "Local change" > ~/Documents/research/notes.md
# Cloud now has: "Cloud change"
# Run bisync
bm project bisync --name research
bm cloud bisync --name research
# Result: Newer file wins (based on modification time)
# If cloud was more recent, cloud version kept
@@ -356,12 +459,14 @@ bm project bisync --name research
- You edit in multiple places
- You want automatic conflict resolution
### Verify Sync Integrity
### Verify Sync Integrity (Personal only)
**Use case:** Check if local and cloud match without making changes.
> **Personal workspaces only.** `check` compares against the Personal workspace mirror remote, like `sync`/`bisync`. On Team workspaces use `bm cloud pull --dry-run` / `bm cloud push --dry-run` to preview differences instead.
```bash
bm project check --name research
bm cloud check --name research
```
**What happens:**
@@ -373,7 +478,7 @@ bm project check --name research
```bash
# One-way check (faster)
bm project check --name research --one-way
bm cloud check --name research --one-way
```
### Preview Changes (Dry Run)
@@ -381,7 +486,7 @@ bm project check --name research --one-way
**Use case:** See what would change without actually syncing.
```bash
bm project bisync --name research --dry-run
bm cloud bisync --name research --dry-run
```
**What happens:**
@@ -427,20 +532,20 @@ bm project add work --cloud --local-path ~/work-notes
bm project add personal --cloud --local-path ~/personal
# Establish baselines
bm project bisync --name research --resync
bm project bisync --name work --resync
bm project bisync --name personal --resync
bm cloud bisync --name research --resync
bm cloud bisync --name work --resync
bm cloud bisync --name personal --resync
# Daily workflow: sync everything
bm project bisync --name research
bm project bisync --name work
bm project bisync --name personal
bm cloud bisync --name research
bm cloud bisync --name work
bm cloud bisync --name personal
```
**Future:** `--all` flag will sync all configured projects:
```bash
bm project bisync --all # Coming soon
bm cloud bisync --all # Coming soon
```
### Mixed Usage
@@ -457,8 +562,8 @@ bm project add archive --cloud
bm project add temp-notes --cloud
# Sync only the configured ones
bm project bisync --name research
bm project bisync --name work
bm cloud bisync --name research
bm cloud bisync --name work
# Archive and temp-notes stay cloud-only
```
@@ -485,6 +590,9 @@ bm cloud create-key "my-laptop" # Creates key and saves it locally
```
The API key is account-level — it grants access to all your cloud projects. It's stored in `~/.basic-memory/config.json` as `cloud_api_key`.
On POSIX systems, Basic Memory writes `~/.basic-memory/` as user-private (`0700`) and
`config.json` as user-read/write only (`0600`). Treat this config file as a credential
file when an API key is saved.
### Setting Project Modes
@@ -574,33 +682,62 @@ bm cloud logout
**Default patterns:**
```gitignore
# Version control
.git/**
# Python
__pycache__/**
*.pyc
.venv/**
venv/**
# Node.js
node_modules/**
# Hidden files and directories
.*
# Basic Memory internals
memory.db/**
memory.db-shm/**
memory.db-wal/**
config.json/**
watch-status.json/**
.bmignore.rclone/**
*.db
*.db-shm
*.db-wal
config.json
# Version control
.git
.svn
# Python
__pycache__
*.pyc
*.pyo
*.pyd
.pytest_cache
.coverage
*.egg-info
.tox
.mypy_cache
.ruff_cache
# Virtual environments
.venv
venv
env
.env
# Node.js
node_modules
# Build artifacts
build
dist
.cache
# IDE
.idea
.vscode
# OS files
.DS_Store/**
Thumbs.db/**
.DS_Store
Thumbs.db
desktop.ini
# Environment files
.env/**
.env.local/**
# Obsidian
.obsidian
# Temporary files
*.tmp
*.swp
*.swo
*~
```
**How it works:**
@@ -609,6 +746,11 @@ Thumbs.db/**
3. Rclone uses filters during sync
4. Same patterns used by all projects
During conversion, file patterns exclude the direct match and recursive contents.
For example, `config.json` becomes both `- config.json` and `- config.json/**`,
while `.*` becomes both `- .*` and `- .*/**`. Directory-only patterns keep
their trailing slash, so `cache/` becomes `- cache/` and `- cache/**`.
**Customizing:**
```bash
@@ -616,14 +758,41 @@ Thumbs.db/**
code ~/.basic-memory/.bmignore
# Add custom patterns
echo "*.tmp/**" >> ~/.basic-memory/.bmignore
echo "*.tmp" >> ~/.basic-memory/.bmignore
# Next sync uses updated patterns
bm project bisync --name research
bm cloud bisync --name research
```
## Troubleshooting
### Rclone Setup Cannot Install Automatically
**Problem:** `bm cloud setup` cannot find a supported package manager, or package-manager
installation fails.
**Explanation:** The CLI avoids remote privileged install scripts. It only invokes known
package managers and otherwise asks you to install rclone manually.
**Solution:** Install rclone with your OS package manager, then rerun setup:
```bash
# macOS
brew install rclone
# Debian/Ubuntu
sudo apt install rclone
# Fedora
sudo dnf install rclone
# Arch
sudo pacman -S rclone
# After rclone is on PATH
bm cloud setup
```
### Authentication Issues
**Problem:** "Authentication failed" or "Invalid token"
@@ -655,7 +824,7 @@ bm cloud login
**Solution:**
```bash
bm project bisync --name research --resync
bm cloud bisync --name research --resync
```
**What this does:**
@@ -678,7 +847,7 @@ bm project bisync --name research --resync
echo "# Research Notes" > ~/Documents/research/README.md
# Now run bisync
bm project bisync --name research --resync
bm cloud bisync --name research --resync
```
**Why this happens:** Bisync creates listing files that track the state of each side. When both directories are completely empty, these listing files are considered invalid by rclone.
@@ -695,10 +864,10 @@ bm project bisync --name research --resync
```bash
# Clear bisync state
bm project bisync-reset research
bm cloud bisync-reset research
# Re-establish baseline
bm project bisync --name research --resync
bm cloud bisync --name research --resync
```
**What this does:**
@@ -718,16 +887,16 @@ bm project bisync --name research --resync
```bash
# Check what would be deleted
bm project bisync --name research --dry-run
bm cloud bisync --name research --dry-run
# If correct, establish new baseline
bm project bisync --name research --resync
bm cloud bisync --name research --resync
```
**Solution 2:** Use one-way sync if you know local is correct:
```bash
bm project sync --name research
bm cloud sync --name research
```
### Project Not Configured for Sync
@@ -739,8 +908,8 @@ bm project sync --name research
**Solution:**
```bash
bm project sync-setup research ~/Documents/research
bm project bisync --name research --resync
bm cloud sync-setup research ~/Documents/research
bm cloud bisync --name research --resync
```
### Connection Issues
@@ -759,8 +928,10 @@ If instance is down, wait a few minutes and retry.
- **Authentication**: OAuth 2.1 with PKCE flow
- **Tokens**: Stored securely in `~/.basic-memory/basic-memory-cloud.json`
- **API keys**: Stored in `~/.basic-memory/config.json`, which is written with private file permissions on POSIX systems
- **Transport**: All data encrypted in transit (HTTPS)
- **Credentials**: Scoped S3 credentials (read-write to your tenant only)
- **Rclone setup**: Uses package managers or manual instructions; no remote privileged install-script fallback
- **Isolation**: Your data isolated from other tenants
- **Ignore patterns**: Sensitive files automatically excluded via `.bmignore`
@@ -785,7 +956,7 @@ bm cloud create-key <name> # Create API key via cloud API (requires OAuth login
### Setup
```bash
bm cloud setup # Install rclone and configure credentials
bm cloud setup # Install rclone via package manager and configure credentials
```
### Project Management
@@ -795,7 +966,7 @@ bm project list --local # Local project list
bm project list --cloud # Cloud project list
bm project add <name> --cloud # Create cloud project (no sync)
bm project add <name> --cloud --local-path <path> # Create with local sync
bm project sync-setup <name> <path> # Add sync to existing project
bm cloud sync-setup <name> <path> # Add sync to existing project
bm project rm <name> # Delete project
```
@@ -809,20 +980,30 @@ bm project set-local <name> # Revert project to local mode
### File Synchronization
```bash
# One-way sync (local → cloud)
bm project sync --name <project>
bm project sync --name <project> --dry-run
bm project sync --name <project> --verbose
# Pull: fetch cloud changes (cloud → local) - Personal + Team, additive
bm cloud pull --name <project>
bm cloud pull --name <project> --dry-run
bm cloud pull --name <project> --on-conflict [fail|keep-local|keep-cloud|keep-both]
# Two-way sync (local cloud) - Recommended
bm project bisync --name <project> # After first --resync
bm project bisync --name <project> --resync # First time / force baseline
bm project bisync --name <project> --dry-run
bm project bisync --name <project> --verbose
# Push: upload local changes (local cloud) - Personal + Team, additive
bm cloud push --name <project>
bm cloud push --name <project> --dry-run
bm cloud push --name <project> --on-conflict [fail|keep-local|keep-cloud|keep-both]
# Integrity check
bm project check --name <project>
bm project check --name <project> --one-way
# One-way mirror (local → cloud) - Personal workspaces only
bm cloud sync --name <project>
bm cloud sync --name <project> --dry-run
bm cloud sync --name <project> --verbose
# Two-way mirror (local ↔ cloud) - Personal workspaces only
bm cloud bisync --name <project> # After first --resync
bm cloud bisync --name <project> --resync # First time / force baseline
bm cloud bisync --name <project> --dry-run
bm cloud bisync --name <project> --verbose
# Integrity check - Personal workspaces only
bm cloud check --name <project>
bm cloud check --name <project> --one-way
# List project files by route
bm project ls --name <project> # Default target: local
@@ -838,15 +1019,25 @@ bm project ls --name <project> --cloud --path <subpath>
1. **Authenticate cloud access** - `bm cloud login`
2. **Install rclone** - `bm cloud setup`
3. **Add projects with sync** - `bm project add research --cloud --local-path ~/Documents/research`
4. **Preview first sync** - `bm project bisync --name research --resync --dry-run`
5. **Establish baseline** - `bm project bisync --name research --resync`
6. **Daily workflow** - `bm project bisync --name research`
**Personal workspace (solo, mirror) workflow:**
4. **Preview first sync** - `bm cloud bisync --name research --resync --dry-run`
5. **Establish baseline** - `bm cloud bisync --name research --resync`
6. **Daily workflow** - `bm cloud bisync --name research`
**Team workspace (shared, additive) workflow:**
4. **Fetch teammates' changes** - `bm cloud pull --name research`
5. **Upload your changes** - `bm cloud push --name research`
6. **Resolve conflicts explicitly** - re-run with `--on-conflict keep-cloud|keep-local|keep-both`
**Key benefits:**
- ✅ Each project independently syncs (or doesn't)
- ✅ Projects can live anywhere on disk
- ✅ Explicit sync operations (no magic)
- ✅ Safe by design (max delete limits, conflict resolution)
- ✅ Team-safe push/pull that never delete on the destination
- ✅ Safe by design (max delete limits, conflict resolution, git-style conflict aborts)
- ✅ Full offline access (work locally, sync when ready)
**Future enhancements:**
+300
View File
@@ -0,0 +1,300 @@
# LiteLLM Provider
Basic Memory can use the LiteLLM SDK for semantic search embeddings. This lets you
keep Basic Memory's vector indexing and search behavior while routing embedding calls
to OpenAI-compatible and provider-specific backends such as OpenAI, Azure OpenAI,
Cohere, Bedrock, NVIDIA NIM, and other LiteLLM-supported embedding providers.
Use this page when you want to try a non-default embedding model, validate a provider,
or tune LiteLLM-specific settings.
> **Experimental — advanced users only.** The LiteLLM provider is experimental and
> intended for users who are comfortable operating remote embedding backends. It makes
> paid, networked API calls, requires per-model dimension and input-role configuration,
> and reindexing a real corpus can be slow and spend provider quota (see
> [Reindexing with a remote provider](#reindexing-with-a-remote-provider)). For most
> users, the default local **FastEmbed** provider is the recommended choice. Use LiteLLM
> only if you know what you're doing.
## Quick Start
The default LiteLLM model is OpenAI `text-embedding-3-small` through the LiteLLM
model string `openai/text-embedding-3-small`.
```bash
export BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=true
export BASIC_MEMORY_SEMANTIC_EMBEDDING_PROVIDER=litellm
export OPENAI_API_KEY=sk-...
bm reindex --embeddings
```
Then use vector or hybrid search:
```python
search_notes("login token flow", search_type="hybrid")
```
## Basic Memory Options
All options can be set in config or as environment variables.
| Config Field | Env Var | Default | Notes |
|---|---|---|---|
| `semantic_search_enabled` | `BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED` | Auto | Set to `true` to force vector/hybrid support on. |
| `semantic_embedding_provider` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_PROVIDER` | `fastembed` | Set to `litellm` for the LiteLLM provider. |
| `semantic_embedding_model` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_MODEL` | `bge-small-en-v1.5` | With `litellm`, the default is remapped to `openai/text-embedding-3-small`. |
| `semantic_embedding_dimensions` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_DIMENSIONS` | Provider default | Required for non-default LiteLLM models because vector tables are dimensioned before the first API call. |
| `semantic_embedding_forward_dimensions` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_FORWARD_DIMENSIONS` | Auto | Sends `dimensions` to LiteLLM only when supported. Auto is enabled for `text-embedding-3` model strings. |
| `semantic_embedding_document_input_type` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_DOCUMENT_INPUT_TYPE` | Auto | LiteLLM `input_type` for indexed notes/passages. |
| `semantic_embedding_query_input_type` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_QUERY_INPUT_TYPE` | Auto | LiteLLM `input_type` for search queries. |
| `semantic_embedding_batch_size` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_BATCH_SIZE` | `2` | Number of text chunks per provider request. |
| `semantic_embedding_request_concurrency` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_REQUEST_CONCURRENCY` | `4` | Maximum concurrent LiteLLM embedding requests. |
| `semantic_embedding_sync_batch_size` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_SYNC_BATCH_SIZE` | `2` | Number of prepared vector jobs flushed through the sync pipeline together. |
## Dimensions
Basic Memory needs the vector dimension before it can create SQLite or Postgres
vector tables. The OpenAI default is known, so this works without an explicit
dimension:
```bash
export BASIC_MEMORY_SEMANTIC_EMBEDDING_PROVIDER=litellm
export BASIC_MEMORY_SEMANTIC_EMBEDDING_MODEL=openai/text-embedding-3-small
```
For every other LiteLLM model, set the dimension explicitly:
```bash
export BASIC_MEMORY_SEMANTIC_EMBEDDING_PROVIDER=litellm
export BASIC_MEMORY_SEMANTIC_EMBEDDING_MODEL=cohere/embed-english-v3.0
export BASIC_MEMORY_SEMANTIC_EMBEDDING_DIMENSIONS=1024
```
For fixed-size models, `semantic_embedding_dimensions` is Basic Memory's local
schema and validation size. For OpenAI/Azure `text-embedding-3` models, LiteLLM
can also forward `dimensions` as a provider-side reduced-output request. Basic
Memory enables that automatically when the model string contains `text-embedding-3`.
If you use an Azure deployment alias such as `azure/<deployment-name>`, the model
string may not reveal that the underlying model supports reduced output dimensions.
Set this only when your deployment supports it:
```bash
export BASIC_MEMORY_SEMANTIC_EMBEDDING_FORWARD_DIMENSIONS=true
```
## Asymmetric Models
Some embedding models use different request roles for indexed documents and
search queries. Basic Memory automatically sets these for known LiteLLM families:
| Model Family | Document `input_type` | Query `input_type` |
|---|---|---|
| Cohere v3 embeddings | `search_document` | `search_query` |
| NVIDIA NIM retrieval embeddings | `passage` | `query` |
For any other asymmetric model, configure both roles explicitly:
```bash
export BASIC_MEMORY_SEMANTIC_EMBEDDING_DOCUMENT_INPUT_TYPE=passage
export BASIC_MEMORY_SEMANTIC_EMBEDDING_QUERY_INPUT_TYPE=query
```
Changing provider, model, dimensions, dimension-forwarding, or document/query
roles changes the meaning of stored vectors. Rebuild embeddings after any of
those changes:
```bash
bm reindex --embeddings
```
## Reindexing with a remote provider
Embedding a real corpus through a network API is far slower than local FastEmbed, and
the defaults are tuned for the local case. Two things to know before you run a full
reindex.
**Raise the sync batch size.** `semantic_embedding_sync_batch_size` defaults to `2`, and
it — not `semantic_embedding_batch_size` — governs throughput on the sync pipeline. With
the default, a full reindex can take tens of seconds *per note* against a remote provider.
Raising both to a larger value turns a multi-minute (or longer) reindex into well under a
minute for the same corpus:
```bash
export BASIC_MEMORY_SEMANTIC_EMBEDDING_SYNC_BATCH_SIZE=32
export BASIC_MEMORY_SEMANTIC_EMBEDDING_BATCH_SIZE=64
```
Stay within the provider's per-request size and rate limits — Cohere v3, for example,
accepts up to 96 inputs per embedding request.
**Changing dimensions requires recreating the vector table.** Basic Memory dimensions the
vector table on first index and refuses to mix sizes. Switching to a model with a
different dimension (for example FastEmbed 384 → OpenAI 1536 → Cohere 1024) makes a plain
`bm reindex` raise an `Embedding dimension mismatch` error. Recreate the table with a full
rebuild — files are the source of truth, so this re-indexes from disk and re-embeds
everything:
```bash
bm reset --reindex
```
To trial a provider without disturbing your existing index, point Basic Memory at a
throwaway config + database instead:
```bash
export BASIC_MEMORY_CONFIG_DIR=/tmp/bm-litellm-trial
```
## Provider Setup Examples
LiteLLM reads provider credentials from the environment. These are the examples
covered by Basic Memory's live validation harness.
### OpenAI Through LiteLLM
```bash
export OPENAI_API_KEY=sk-...
export BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=true
export BASIC_MEMORY_SEMANTIC_EMBEDDING_PROVIDER=litellm
export BASIC_MEMORY_SEMANTIC_EMBEDDING_MODEL=openai/text-embedding-3-small
```
### Cohere v3
```bash
export COHERE_API_KEY=...
export BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=true
export BASIC_MEMORY_SEMANTIC_EMBEDDING_PROVIDER=litellm
export BASIC_MEMORY_SEMANTIC_EMBEDDING_MODEL=cohere/embed-english-v3.0
export BASIC_MEMORY_SEMANTIC_EMBEDDING_DIMENSIONS=1024
```
The provider auto-selects `search_document` for indexed chunks and `search_query`
for search queries.
### Azure OpenAI
```bash
export AZURE_API_KEY=...
export AZURE_API_BASE=https://<resource-name>.openai.azure.com
export AZURE_API_VERSION=2024-02-01
export BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=true
export BASIC_MEMORY_SEMANTIC_EMBEDDING_PROVIDER=litellm
export BASIC_MEMORY_SEMANTIC_EMBEDDING_MODEL=azure/<deployment-name>
export BASIC_MEMORY_SEMANTIC_EMBEDDING_DIMENSIONS=1536
```
If your Azure deployment is a reduced-dimension `text-embedding-3` deployment,
set the dimension you want and enable forwarding:
```bash
export BASIC_MEMORY_SEMANTIC_EMBEDDING_DIMENSIONS=512
export BASIC_MEMORY_SEMANTIC_EMBEDDING_FORWARD_DIMENSIONS=true
```
### NVIDIA NIM
```bash
export NVIDIA_NIM_API_KEY=...
# Optional when using a custom or self-hosted NIM endpoint:
export NVIDIA_NIM_API_BASE=https://integrate.api.nvidia.com/v1
export BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=true
export BASIC_MEMORY_SEMANTIC_EMBEDDING_PROVIDER=litellm
export BASIC_MEMORY_SEMANTIC_EMBEDDING_MODEL=nvidia_nim/nvidia/embed-qa-4
export BASIC_MEMORY_SEMANTIC_EMBEDDING_DIMENSIONS=1024
```
The provider auto-selects `passage` for indexed chunks and `query` for search
queries.
## Testing LiteLLM Providers
Run the non-live LiteLLM unit and harness tests first:
```bash
uv run pytest tests/repository/test_litellm_provider.py \
test-int/semantic/test_litellm_live_harness.py -q
```
Run the SQLite and Postgres vector identity regressions when changing model
identity, role, or vector sync behavior:
```bash
uv run pytest \
tests/repository/test_sqlite_vector_search_repository.py::test_sqlite_embedding_model_key_includes_litellm_role_settings \
-q
BASIC_MEMORY_TEST_POSTGRES=1 uv run pytest \
tests/repository/test_postgres_search_repository.py::test_postgres_litellm_role_change_reembeds_existing_chunks \
-q
```
The Postgres command uses testcontainers, so Docker must be running.
## Live Provider Harness
The live harness makes real LiteLLM API calls and spends provider quota. It is
opt-in by design:
```bash
export OPENAI_API_KEY=sk-...
export COHERE_API_KEY=...
just test-litellm-live
```
Built-in cases run when their API keys are present:
| Case | Required Env Var | Validates |
|---|---|---|
| `openai-text-embedding-3-small` | `OPENAI_API_KEY` | OpenAI via LiteLLM, 1536 dimensions, normalized vectors, ranking sanity. |
| `cohere-embed-english-v3` | `COHERE_API_KEY` | Cohere v3 role handling, 1024 dimensions, normalized vectors, ranking sanity. |
Add provider aliases or new backends with a custom cases file:
```bash
cat > /tmp/litellm-cases.json <<'JSON'
[
{
"name": "azure-text-embedding-3-small-512",
"model": "azure/<deployment-name>",
"dimensions": 512,
"api_key_env": "AZURE_API_KEY",
"forward_dimensions": true
},
{
"name": "nvidia-embed-qa-4",
"model": "nvidia_nim/nvidia/embed-qa-4",
"dimensions": 1024,
"api_key_env": "NVIDIA_NIM_API_KEY",
"document_input_type": "passage",
"query_input_type": "query"
}
]
JSON
just test-litellm-live --cases-file /tmp/litellm-cases.json
```
For CI-style output:
```bash
just test-litellm-live --cases-file /tmp/litellm-cases.json --json
```
The harness embeds two documents and one query, validates dimension and vector
normalization, checks that the authentication query ranks the authentication
document above a distractor, and reports latency plus role/dimension settings.
## Provider Reference
LiteLLM's own provider and embedding docs are the source of truth for current
model strings and credential names:
- [LiteLLM embedding models](https://docs.litellm.ai/docs/embedding/supported_embedding)
- [LiteLLM Azure OpenAI provider](https://docs.litellm.ai/docs/providers/azure)
- [LiteLLM NVIDIA NIM provider](https://docs.litellm.ai/docs/providers/nvidia_nim)
+499
View File
@@ -0,0 +1,499 @@
# Logfire Instrumentation Strategy
## Why
We want Logfire in Basic Memory for two specific use cases:
1. Local development and performance investigation
2. Cloud deployments where Basic Memory runs inside Basic Memory Cloud
This instrumentation must be:
- Disabled by default
- Useful when enabled
- Safe for local-first users
- Searchable in Logfire over time
The previous integration added telemetry, but it leaned too much on generic framework instrumentation. That created noisy spans with weak names and made the trace view harder to navigate. This strategy favors manual instrumentation around Basic Memory's real units of work.
## Core Principles
### 1. Default-off
Basic Memory should ship with Logfire disabled unless the operator explicitly enables it.
That means:
- no required token for normal local usage
- no surprise outbound telemetry
- no behavior change for existing users
### 2. Manual spans over automatic framework spans
We should not rely on broad auto-instrumentation for FastAPI, MCP, SQLAlchemy, or HTTP as the primary experience.
Why:
- auto-generated span names are often generic
- routes and middleware produce too many low-signal spans
- it becomes harder to answer product questions like "why was `write_note` slow?" or "where did sync time go?"
The preferred model is:
- one meaningful root span per high-level operation
- a small number of child spans for important phases
- optional targeted instrumentation only where it adds clear value
### 3. Logs must live inside traces
Basic Memory already uses `loguru` pervasively. The Logfire integration should preserve that and make those logs visible inside the active trace/span context.
If traces exist but the logs are detached from them, the integration is not doing its job.
### 4. Stable names, selective attributes
Span names should describe the operation class, not the specific input.
Good:
- `mcp.tool.write_note`
- `sync.project.scan`
- `search.execute`
- `routing.resolve_project`
Bad:
- `Searching for "foo bar baz"`
- `POST /v2/projects/123/search/`
- `write note to /specs/api.md`
Dynamic values belong in attributes, not in the span name.
## What We Should Not Do
### Avoid broad FastAPI auto-instrumentation
We should not turn on `instrument_fastapi()` and treat that as the main telemetry story.
It may still be useful in narrowly scoped debugging, but it should not define the production trace shape. The meaningful root spans should come from Basic Memory's own entrypoints and service boundaries.
### Avoid per-file spans by default
`sync` can process many files. A span per file will explode trace cardinality and make performance views noisy.
Default behavior should be:
- one span for the project sync
- child spans for scan, move handling, delete handling, markdown sync batch, relation resolution, embedding sync, watermark update
- per-file spans only for failures or very slow outliers
### Avoid high-cardinality attributes on every span
Do not attach large or highly variable values everywhere:
- raw note content
- file bodies
- long search text
- arbitrary metadata blobs
- unique IDs that make every span shape distinct
Prefer compact, queryable attributes:
- `project_name`
- `workspace_id`
- `route_mode`
- `scan_type`
- `file_count`
- `result_count`
- `search_type`
- `retrieval_mode`
- `duration_ms`
## Proposed Architecture
Add a dedicated telemetry module in core Basic Memory, separate from logging setup.
Suggested shape:
```python
# basic_memory/telemetry.py
def configure_telemetry(service_name: str, *, enable_logfire: bool) -> None: ...
def telemetry_enabled() -> bool: ...
def span(name: str, **attrs): ...
def bind_telemetry_context(**attrs): ...
```
This module should:
- configure Logfire only when explicitly enabled
- set up the Logfire `loguru` handler
- expose lightweight helpers so application code does not import `logfire` directly everywhere
- degrade cleanly to no-op behavior when disabled
This keeps the rest of the codebase readable and makes it easy to reason about what telemetry is doing.
## Logging Integration Strategy
### Goal
When a span is active, logs emitted through `loguru` during that operation should show up in the same trace.
### Preferred design
1. Configure Logfire once in the telemetry bootstrap
2. Add the Logfire `loguru` handler to the existing `loguru` configuration
3. At operation boundaries, bind stable contextual fields with `loguru`
4. Let logs emitted inside the span inherit the active trace context
### Context to bind
Bind only the fields that help correlate work across the system:
- `service_name`
- `entrypoint`
- `project_name`
- `workspace_id`
- `route_mode`
- `tool_name`
- `command_name`
This binding should happen at the root of an operation, not deep in leaf functions.
### Important nuance
We should not try to encode the entire trace model into logger extras. The logger context should be a human-meaningful slice of the active operation. Trace linkage comes from the active Logfire/OpenTelemetry context; logger extras are there to improve searchability and readability.
## Span Model
### Root spans
Each user-visible or system-visible operation should get one root span.
Examples:
- `cli.command.status`
- `cli.command.project_sync`
- `api.request.search`
- `mcp.tool.write_note`
- `mcp.tool.read_note`
- `mcp.tool.search_notes`
- `sync.project.run`
- `db.semantic_backfill`
### Child spans
Child spans should represent real phases whose duration we care about.
Examples:
- `routing.client_session`
- `routing.resolve_project`
- `routing.resolve_workspace`
- `api.search.execute`
- `sync.project.scan`
- `sync.project.detect_moves`
- `sync.project.apply_changes`
- `sync.project.resolve_relations`
- `sync.project.sync_embeddings`
- `sync.file.markdown`
- `sync.file.regular`
- `search.execute`
- `search.relaxed_fts_retry`
- `db.init`
- `db.migrate`
### Span naming rules
- Use dot-separated names
- Start with subsystem
- Keep the verb at the end
- Keep names stable across runs
- Never include request-specific text in the span name
## Attribute Taxonomy
### Required attributes on root spans
Every root span should have a small common set:
- `service_name`
- `entrypoint`
- `project_name` when applicable
- `workspace_id` when applicable
- `route_mode` with values like `local_asgi`, `cloud_proxy`, `factory`
### Operation-specific attributes
Examples:
For search:
- `search_type`
- `retrieval_mode`
- `page`
- `page_size`
- `result_count`
- `fallback_used`
For sync:
- `scan_type`
- `force_full`
- `new_count`
- `modified_count`
- `deleted_count`
- `move_count`
- `skipped_count`
- `embeddings_enabled`
For note operations:
- `tool_name`
- `note_type`
- `directory`
- `overwrite`
- `output_format`
### Attributes to avoid by default
- full `query.text`
- full note titles if they create privacy or cardinality issues
- file content
- raw frontmatter
- raw HTTP bodies
If we need richer payloads for a local debugging session, that should be an explicit temporary mode, not the default telemetry shape.
## Instrumentation Plan By Layer
### 1. Entrypoints
Instrument these first:
- `cli.app` callback and major commands
- API lifespan and selected routers
- MCP server lifespan
- MCP tool entrypoints
Why:
- this establishes clean root spans
- it gives us trace boundaries that match how users think about the product
### 2. Routing and context resolution
Instrument:
- client routing decisions
- workspace resolution
- project resolution
- default-project fallback
Why:
- Basic Memory has local/cloud/per-project routing logic
- when something is slow or surprising, we need to know which path was taken
### 3. Sync and indexing
This is the highest-value area to instrument deeply.
Instrument:
- sync root
- scan strategy decision
- filesystem scan
- move detection
- delete handling
- markdown sync phase
- relation resolution
- vector embedding sync
- scan watermark update
Why:
- this is where performance work will happen
- cloud and local both benefit from this visibility
### 4. Search
Instrument:
- search execution
- retrieval mode
- relaxed FTS fallback
- result shaping
Why:
- search is user-facing and latency-sensitive
- hybrid/vector/FTS paths need to be distinguishable
### 5. Database and initialization
Instrument selectively:
- DB init
- migrations
- semantic backfill
- connection mode selection
Avoid full automatic SQL span firehose by default.
## Recommended Rollout Phases
## Task List
- [x] Phase 1: Bootstrap and config gating
- [x] Phase 2: Root spans for entrypoints and primary operations
- [x] Phase 3: Child spans for sync, search, and routing
- [x] Phase 4: Failure-focused detail and final verification
- [x] Phase 5: Loguru context binding and scoped context inheritance
## Recommended Rollout Phases
### Phase 1: Bootstrap and config gating
Add:
- telemetry bootstrap module
- config/env gating
- `loguru` + Logfire handler integration
This gives immediate value with low noise.
### Phase 2: Root spans for entrypoints and primary operations
Add:
- root spans for CLI, API, MCP, and main MCP tools
- stable root attributes for project, workspace, route mode, and operation type
This gives us clean top-level traces that match how users think about the product.
### Phase 3: Child spans for sync, search, and routing
Add child spans to:
- sync
- search
- routing
This is the main performance-investigation layer.
### Phase 4: Failure-focused detail
Add selective deeper spans/log enrichment for:
- sync failures
- relation resolution failures
- slow file operations
- cloud routing/auth failures
This keeps normal traces clean while improving debuggability.
### Phase 5: Loguru context binding and scoped context inheritance
Add:
- context-local telemetry state in `basic_memory.telemetry`
- a shared `scope(...)` helper that opens a span and binds stable logger context together
- context inheritance for routing, sync, and search so downstream `loguru` logs carry the active operation fields
This makes the trace view and the log stream tell the same story without forcing logger rewrites across the codebase.
## Local Dev Playbook
The fastest way to sanity-check the current trace shape is:
```bash
LOGFIRE_TOKEN=lf_... just telemetry-smoke
```
What this does:
- creates an isolated temp home, config dir, and project path
- enables Logfire for the run
- automatically exports to Logfire when `LOGFIRE_TOKEN` is present
- defaults `BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=false` so the smoke run stays fast and trace-friendly
- disables promo telemetry so the trace is about Basic Memory work, not analytics noise
- runs a small CLI workflow:
- `project add`
- `tool write-note`
- `tool read-note`
- `tool edit-note`
- `tool build-context`
- `tool search-notes`
- `doctor`
If you want to exercise the instrumentation without exporting anything upstream:
```bash
BASIC_MEMORY_LOGFIRE_SEND_TO_LOGFIRE=false just telemetry-smoke
```
If you want the smoke run to include vector or hybrid retrieval spans too:
```bash
LOGFIRE_TOKEN=lf_... BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=true just telemetry-smoke
```
The recipe sets `BASIC_MEMORY_LOGFIRE_ENVIRONMENT=telemetry-smoke` by default so these traces are easy to isolate in Logfire. Override it if you want the smoke traces grouped under a different environment name.
### What to look for
You should see a small set of comparable root spans rather than a framework-generated span forest:
- `cli.command.project`
- `cli.command.tool`
- `mcp.tool.write_note`
- `mcp.tool.read_note`
- `mcp.tool.edit_note`
- `mcp.tool.build_context`
- `mcp.tool.search_notes`
- `sync.project.run`
You should also see correlated logs under those traces with stable fields like:
- `project_name`
- `route_mode`
- `tool_name`
- `entrypoint`
### Expected nuance
`doctor` creates its own temporary project on purpose. That means the sync trace will usually show a different project name than the `telemetry-smoke` write/search traces. That is fine for smoke testing because the goal is to confirm:
- root span names are meaningful
- scoped logs stay attached to the active trace
- routing, tool, search, and sync phases are easy to distinguish
## Validation Checklist
We should consider the integration successful when the following are true:
1. With telemetry disabled, Basic Memory behaves exactly as it does today.
2. With telemetry enabled, one user action produces one obvious root span.
3. Logs emitted during that action are visible inside the same trace.
4. A search in Logfire for `mcp.tool.write_note` or `sync.project.run` returns comparable spans across runs.
5. Trace views show phase timing clearly without drowning in framework noise.
6. Sensitive payloads are not captured by default.
## Immediate Implementation Direction
When we start coding, the first pass should be:
1. Add `basic_memory.telemetry`
2. Add config/env switches for `enabled`, `send_to_logfire`, and service name
3. Wire telemetry bootstrap into CLI, API, and MCP entrypoints
4. Configure `loguru` to emit to both existing sinks and the Logfire handler when enabled
5. Add manual root spans around:
- CLI commands
- API request handlers we care about
- MCP tool entrypoints
- sync root
- search root
6. Add child spans to the sync and routing phases only after the root span model feels clean
That gives us a strong foundation without repeating the earlier "turn on instrumentation everywhere" approach.
+1333
View File
File diff suppressed because it is too large Load Diff
+117 -5
View File
@@ -99,10 +99,13 @@ All settings are fields on `BasicMemoryConfig` and can be set via environment va
| Config Field | Env Var | Default | Description |
|---|---|---|---|
| `semantic_search_enabled` | `BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED` | Auto (`true` when semantic deps are available) | Enable semantic search. Required before vector/hybrid modes work. |
| `semantic_embedding_provider` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_PROVIDER` | `"fastembed"` | Embedding provider: `"fastembed"` (local) or `"openai"` (API). |
| `semantic_embedding_provider` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_PROVIDER` | `"fastembed"` | Embedding provider: `"fastembed"` (local), `"openai"` (API), or `"litellm"` (multi-provider API, **experimental** — advanced users only). |
| `semantic_embedding_model` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_MODEL` | `"bge-small-en-v1.5"` | Model identifier. Auto-adjusted per provider if left at default. |
| `semantic_embedding_dimensions` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_DIMENSIONS` | Auto-detected | Vector dimensions. 384 for FastEmbed, 1536 for OpenAI. Override only if using a non-default model. |
| `semantic_embedding_batch_size` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_BATCH_SIZE` | `64` | Number of texts to embed per batch. |
| `semantic_embedding_dimensions` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_DIMENSIONS` | Provider default | Vector dimensions. 384 for FastEmbed, 1536 for OpenAI/LiteLLM OpenAI. Required when using a non-default LiteLLM model. |
| `semantic_embedding_forward_dimensions` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_FORWARD_DIMENSIONS` | Auto | LiteLLM-only override for whether configured dimensions are sent as a provider-side output-size request. |
| `semantic_embedding_batch_size` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_BATCH_SIZE` | `2` | Number of texts to embed per batch. |
| `semantic_embedding_document_input_type` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_DOCUMENT_INPUT_TYPE` | Auto for known LiteLLM models | Optional LiteLLM `input_type` for indexed document/passages. |
| `semantic_embedding_query_input_type` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_QUERY_INPUT_TYPE` | Auto for known LiteLLM models | Optional LiteLLM `input_type` for search queries. |
| `semantic_vector_k` | `BASIC_MEMORY_SEMANTIC_VECTOR_K` | `100` | Candidate count for vector nearest-neighbour retrieval. Higher values improve recall at the cost of latency. |
## Embedding Providers
@@ -135,7 +138,114 @@ export BASIC_MEMORY_SEMANTIC_EMBEDDING_PROVIDER=openai
export OPENAI_API_KEY=sk-...
```
When switching from FastEmbed to OpenAI (or vice versa), you must rebuild embeddings since the vector dimensions differ:
### LiteLLM
> **Experimental — advanced users only.** The LiteLLM provider is experimental and aimed at users comfortable operating remote embedding backends: paid API calls, per-model dimension and input-role configuration, and slower reindexing of large corpora. For most users, FastEmbed (local, default) is recommended. See [LiteLLM Provider](litellm-provider.md) for the caveats and tuning.
Uses the LiteLLM SDK to call embedding models from providers such as OpenAI, Cohere, Azure, Bedrock, NVIDIA NIM, and other LiteLLM-supported backends. Requires the provider's API credentials.
For the full option reference, provider setup examples, and live validation harness, see [LiteLLM Provider](litellm-provider.md).
```bash
export BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=true
export BASIC_MEMORY_SEMANTIC_EMBEDDING_PROVIDER=litellm
export BASIC_MEMORY_SEMANTIC_EMBEDDING_MODEL=cohere/embed-english-v3.0
export BASIC_MEMORY_SEMANTIC_EMBEDDING_DIMENSIONS=1024
export COHERE_API_KEY=...
```
Basic Memory creates vector tables before the first embedding call, so non-default LiteLLM models must set `BASIC_MEMORY_SEMANTIC_EMBEDDING_DIMENSIONS`. The LiteLLM OpenAI default (`openai/text-embedding-3-small`) uses 1536 dimensions automatically.
For fixed-size LiteLLM models, dimensions are used as Basic Memory's local vector schema and
validation size. Basic Memory automatically sends dimensions as a provider-side output-size
request for `text-embedding-3` model strings, where LiteLLM/OpenAI support reduced output
dimensions. If an Azure/OpenAI deployment uses an arbitrary LiteLLM model string such as
`azure/<deployment-name>` and the underlying model supports reduced dimensions, set
`BASIC_MEMORY_SEMANTIC_EMBEDDING_FORWARD_DIMENSIONS=true`.
Some retrieval models are asymmetric: indexed passages and search queries must be embedded with different provider parameters. Basic Memory automatically sets LiteLLM `input_type` for known asymmetric model families:
- Cohere v3: documents use `search_document`, queries use `search_query`
- NVIDIA NIM retrieval models: documents use `passage`, queries use `query`
For other asymmetric LiteLLM models, set the input types explicitly:
```bash
export BASIC_MEMORY_SEMANTIC_EMBEDDING_DOCUMENT_INPUT_TYPE=passage
export BASIC_MEMORY_SEMANTIC_EMBEDDING_QUERY_INPUT_TYPE=query
```
#### Live LiteLLM Validation
Provider APIs differ in subtle ways: some accept `dimensions`, some require separate
document/query roles, and some route through deployment aliases that do not reveal the
underlying model name. Before adding or changing LiteLLM model support, run the opt-in live
evaluation harness:
```bash
export OPENAI_API_KEY=sk-...
export COHERE_API_KEY=...
just test-litellm-live
```
The built-in live cases cover:
| Case | Required key | What it validates |
|---|---|---|
| `openai/text-embedding-3-small` | `OPENAI_API_KEY` | Standard LiteLLM OpenAI embedding calls and normalized 1536-dimensional output. |
| `cohere/embed-english-v3.0` | `COHERE_API_KEY` | Cohere v3 asymmetric `search_document` / `search_query` handling and fixed 1024-dimensional output. |
The harness embeds two documents and one query, checks vector dimensions and normalization,
then verifies the authentication query ranks the authentication document above the distractor.
It prints a table with per-model scores, norms, latency, role settings, and dimension-forwarding
mode.
To validate provider aliases or additional LiteLLM backends, save custom JSON cases:
```bash
export AZURE_API_KEY=...
export AZURE_API_BASE=https://example.openai.azure.com
export AZURE_API_VERSION=2024-02-01
cat > /tmp/litellm-azure-cases.json <<'JSON'
[
{
"name": "azure-text-embedding-3-small-512",
"model": "azure/<deployment-name>",
"dimensions": 512,
"api_key_env": "AZURE_API_KEY",
"forward_dimensions": true
}
]
JSON
just test-litellm-live --cases-file /tmp/litellm-azure-cases.json
```
NVIDIA NIM retrieval models can be checked the same way:
```bash
export NVIDIA_NIM_API_KEY=...
cat > /tmp/litellm-nvidia-cases.json <<'JSON'
[
{
"name": "nvidia-embed-qa-4",
"model": "nvidia_nim/nvidia/embed-qa-4",
"dimensions": 1024,
"api_key_env": "NVIDIA_NIM_API_KEY",
"document_input_type": "passage",
"query_input_type": "query"
}
]
JSON
just test-litellm-live --cases-file /tmp/litellm-nvidia-cases.json
```
For repeatable local runs, put the same JSON array in a file and pass
`just test-litellm-live --cases-file path/to/litellm-cases.json`.
When switching providers, models, dimensions, or LiteLLM document/query input types, rebuild embeddings:
```bash
bm reindex --embeddings
@@ -203,9 +313,10 @@ bm reindex -p my-project
- **Upgrade note**: Migration now performs a one-time automatic embedding backfill on upgrade.
- **Manual enable case**: If you explicitly had `semantic_search_enabled=false` and then turn it on
- **Provider change**: After switching between `fastembed` and `openai`
- **Provider change**: After switching between `fastembed`, `openai`, and `litellm`
- **Model change**: After changing `semantic_embedding_model`
- **Dimension change**: After changing `semantic_embedding_dimensions`
- **LiteLLM role change**: After changing `semantic_embedding_document_input_type` or `semantic_embedding_query_input_type`
The reindex command shows progress with embedded/skipped/error counts:
@@ -263,6 +374,7 @@ The sqlite-vec extension is loaded per-connection. Vector tables are created laz
### Postgres (cloud)
- **Vector storage**: [pgvector](https://github.com/pgvector/pgvector) with HNSW indexing
- **Local Docker**: use `docker-compose-postgres.yml` (`pgvector/pgvector:pg17`). Plain `postgres:17` lacks the extension; run `CREATE EXTENSION IF NOT EXISTS vector;` on any external instance before first migration.
- **Chunk metadata table**: Created via Alembic migration (`search_vector_chunks` with `BIGSERIAL` primary key)
- **Embedding table**: `search_vector_embeddings` created at runtime (dimension-dependent, same pattern as SQLite)
- **Index**: HNSW index on the embedding column for fast approximate nearest-neighbour queries
@@ -1,594 +0,0 @@
# SPEC-LOCAL-GRAPH-INTELLIGENCE-IMPLEMENTATION-PLAN
**Status:** Draft (Decision-Complete)
**Date:** 2026-03-05
**Owner:** Basic Memory Engineering
**Implementation Status (2026-03-05):** Phase 1 contract skeleton implemented in `basic-memory` branch `codex/graph-intelligence-phase1`.
**Related Specs:**
1. `/docs/specs/SPEC-LOCAL-GRAPH-INTELLIGENCE-MASTER.md`
2. `/docs/specs/SPEC-LOCAL-GRAPH-INTELLIGENCE-TECHNICAL-ADDENDUM.md`
3. `/docs/specs/SPEC-LOCAL-GRAPH-INTELLIGENCE.md`
## Scope and Intent
This document is the execution handoff for Local+ Graph Intelligence.
It defines exactly how we will deliver graph and FCM capabilities inside the existing Basic Memory architecture:
1. FastAPI-first business logic.
2. MCP and CLI as thin facades.
3. Local/cloud contract parity.
4. Tight build-test-iterate loop for fast delivery.
This file is intentionally implementation-oriented and does not duplicate pricing narrative from the master spec.
## Architecture Alignment (FastAPI-first, MCP/CLI facade)
Locked architecture alignment for implementation:
1. MCP tools remain thin proxy facades.
2. CLI `bm tool` commands call MCP tools in JSON mode.
3. Core logic lives in FastAPI routers and services.
4. Cloud and local share the same REST contracts.
5. Per-project routing continues through existing project client patterns.
Execution mapping:
1. API routers define public contracts in `/graph` and `/fcm` domains.
2. Services own traversal, scoring, simulation, and fallback logic.
3. Repositories and index providers own data access and graph index operations.
4. MCP typed clients call REST endpoints and return JSON-first tool output.
5. CLI passthrough executes tool calls and prints machine-friendly JSON.
## Locked Decisions
1. SQLite remains operational source for entities, relations, embeddings, and project state.
2. Markdown remains source of truth.
3. Oxigraph/pyoxigraph is the derived graph index for deep traversal.
4. FCM simulation runs in Python service layer; it is not delegated to graph DB query engines.
5. Graph index is rebuildable and disposable; stale index never blocks user workflows.
6. FCM model and scenario artifacts persist in app database.
7. Local+ features are gated by config flags first; entitlement wiring follows later.
8. Graph-first vertical slices ship before deep FCM expansion.
9. Atomic tools ship first; orchestration workflows are deferred.
10. Existing `build_context` and `search_notes` remain backward compatible with no breaking change.
## Progress Snapshot (as of 2026-03-05)
Completed in Phase 1:
1. Added `/graph` and `/fcm` v2 routers with all required contract endpoints.
2. Added graph/FCM request and response schemas for all public API contracts.
3. Added service-layer implementations for graph and FCM contract endpoints.
4. Added typed MCP clients for graph and FCM API calls.
5. Added MCP tools: `graph_lineage`, `graph_impact`, `graph_health`, `graph_reindex`, `fcm_simulate`, `fcm_rank_actions`, `fcm_import_model`, `fcm_export_model`.
6. Added CLI passthrough commands under `bm tool ...` for all planned graph/FCM operations.
7. Added scheduler task names for graph lifecycle: `sync_graph_entity`, `sync_graph_project`, `reindex_graph_project`.
8. Added focused tests for API, MCP clients/tools, and CLI graph/FCM passthrough.
9. Added fast-loop `just` targets: `test-graph-intel-api`, `test-graph-intel-mcp`, `test-graph-intel-cli`, `test-graph-intel`.
Validation completed:
1. `just test-graph-intel` passes.
2. `ruff check` passes on changed files.
3. `pyright` passes on changed files.
Still pending after Phase 1:
1. SQL-backed traversal/scoring for graph `lineage`, `impact`, and `health`.
2. Oxigraph provider integration and stale-index catch-up flow.
3. Persistent FCM model/scenario state and interop round-trip guarantees.
4. Config-flag and entitlement gating at API/tool boundaries.
5. Performance instrumentation and p95 envelope enforcement.
## Delivery Phases
### Phase 1: Contract skeleton
Status: Completed (2026-03-05)
Deliverables:
1. Add `/graph` and `/fcm` API routers with request/response schemas.
2. Add typed MCP clients for graph and FCM endpoints.
3. Add MCP tool passthrough commands for all new operations.
4. Add CLI `bm tool` passthrough commands mirroring MCP surface.
5. Add minimal smoke tests for route reachability and schema validation.
Exit criteria:
1. All endpoints return structured success and error envelopes.
2. MCP/CLI paths execute end-to-end with stubbed service responses.
### Phase 2: Graph capabilities on SQL-backed logic
Status: Next active phase
Deliverables:
1. Implement `lineage`, `impact`, and `health` in service layer using SQL-backed traversal and scoring.
2. Add provenance/evidence linking in graph outputs.
3. Add deterministic graph-health calculations for fixed snapshots.
Exit criteria:
1. `graph_lineage`, `graph_impact`, and `graph_health` pass contract tests.
2. SQL fallback behavior is explicit and covered by tests.
### Phase 3: Oxigraph derived index provider
Status: Planned
Deliverables:
1. Introduce Oxigraph provider behind graph-query interface.
2. Add lazy catch-up jobs and project-wide reindex operation.
3. Preserve SQL fallback when index is missing or stale.
Exit criteria:
1. Stale index path serves results via SQL and schedules catch-up.
2. Index rebuild can be triggered and completed without data loss.
### Phase 4: FCM import/simulate/rank/export
Status: Planned (contract endpoints complete, full behavior pending)
Deliverables:
1. Implement CSV-first import/export contracts.
2. Implement deterministic simulation core with convergence metadata.
3. Implement action ranking with evidence references and confidence output.
4. Persist scenario inputs and result artifacts.
Exit criteria:
1. Research flow scenario passes: import -> simulate -> rank -> export.
2. Interop round-trip preserves node/edge counts and signed weights.
### Phase 5: Hardening
Status: Planned
Deliverables:
1. Performance tuning against published latency envelopes.
2. Local/cloud parity tests for semantics and error behavior.
3. MCP prompt/docs updates for new graph and FCM tools.
4. Operational docs for reindex, fallback, and troubleshooting.
Exit criteria:
1. `just check` passes before merge.
2. Acceptance criteria in this document are fully met.
## API and Interface Additions
### Shared API conventions
1. All endpoints are project-scoped under `/v2/projects/{project_id}`.
2. Request and response bodies are JSON-first and agent-friendly.
3. Success envelope is endpoint-specific payload with deterministic fields and optional probabilistic fields.
4. Error envelope:
```json
{
"error": {
"code": "INVALID_ARGUMENT|NOT_FOUND|INDEX_NOT_READY|MODEL_INVALID|RESOURCE_LIMIT_EXCEEDED|INTERNAL_ERROR",
"message": "string",
"details": {}
}
}
```
5. Latency and scale targets are p95 targets for local default hardware profile.
### 1) `POST /v2/projects/{project_id}/graph/lineage`
Purpose: explain decision lineage and supporting evidence paths.
Request schema:
```json
{
"start": "string",
"goal": "string|null",
"max_hops": 4,
"relation_filters": ["string"]
}
```
Response schema:
```json
{
"root": {"id": "string", "title": "string", "permalink": "string"},
"paths": [
{
"path_id": "string",
"nodes": [{"id": "string", "title": "string"}],
"edges": [{"relation": "string", "direction": "outgoing|incoming"}],
"deterministic_path_score": 0.0,
"confidence": 0.0,
"evidence_refs": ["memory://..."]
}
],
"generated_at": "RFC3339"
}
```
Deterministic fields: `root`, `paths.nodes`, `paths.edges`, `deterministic_path_score`, `generated_at`.
Probabilistic fields: `confidence`.
Latency target: p95 <= 450ms with `max_hops<=4`.
Scale envelope: up to 50k nodes and 300k edges.
### 2) `POST /v2/projects/{project_id}/graph/impact`
Purpose: preview impact radius before edits or decisions.
Request schema:
```json
{
"target": "string",
"horizon": 2,
"relation_filters": ["string"],
"include_reasons": true
}
```
Response schema:
```json
{
"target": {"id": "string", "title": "string"},
"affected": [
{
"id": "string",
"title": "string",
"distance": 1,
"impact_score": 0.0,
"confidence": 0.0,
"reasons": ["string"],
"evidence_refs": ["memory://..."]
}
],
"summary": {"total_considered": 0, "total_returned": 0}
}
```
Deterministic fields: membership, distance, summary counts.
Probabilistic fields: `impact_score`, `confidence`.
Latency target: p95 <= 650ms for `horizon<=3`.
Scale envelope: default 200 results, hard cap 1000 with pagination token.
### 3) `GET /v2/projects/{project_id}/graph/health`
Purpose: report deterministic graph quality and actionable issues.
Query params:
1. `scope` optional directory prefix.
2. `timeframe` optional window like `30d`.
Response schema:
```json
{
"metrics": {
"orphan_rate": 0.0,
"stale_central_nodes": 0,
"overloaded_hubs": 0,
"contradiction_candidates": 0
},
"issues": [
{
"issue_type": "orphan|stale_central|overloaded_hub|contradiction_candidate",
"entity_id": "string",
"severity": "low|medium|high",
"reason": "string",
"suggested_action": "string",
"confidence": 0.0
}
],
"computed_at": "RFC3339"
}
```
Deterministic fields: `metrics`, issue membership for fixed snapshot.
Probabilistic fields: contradiction confidence when applicable.
Latency target: p95 <= 1500ms project-wide, <= 700ms scoped.
### 4) `POST /v2/projects/{project_id}/graph/reindex`
Purpose: force project-wide graph index rebuild.
Request schema:
```json
{
"mode": "full|incremental",
"reason": "string|null"
}
```
Response schema:
```json
{
"job_id": "string",
"status": "queued|running|completed|failed",
"scheduled_at": "RFC3339"
}
```
Deterministic fields: job metadata and status transitions.
Probabilistic fields: none.
Latency target: enqueue response p95 <= 120ms.
### 5) `POST /v2/projects/{project_id}/fcm/simulate`
Purpose: run FCM scenario simulation.
Request schema:
```json
{
"actions": [{"node_id": "string", "delta": 0.2}],
"scenario": {
"steps": 12,
"activation": "tanh|sigmoid|bounded_linear",
"decay": 0.05
},
"clamp_rules": [{"node_id": "string", "min": -1.0, "max": 1.0}]
}
```
Response schema:
```json
{
"baseline": [{"node_id": "string", "state": 0.0}],
"projected": [{"node_id": "string", "state": 0.0}],
"deltas": [{"node_id": "string", "delta": 0.0}],
"stability": {"converged": true, "iterations_used": 0, "residual": 0.0},
"confidence": 0.0,
"explanations": [{"node_id": "string", "top_influencers": [{"source": "string", "weight": 0.0}]}],
"evidence_refs": ["memory://..."]
}
```
Deterministic fields: baseline, projected, deltas, stability for fixed model and params.
Probabilistic fields: confidence.
Latency target: p95 <= 1000ms for <=500 nodes and <=5000 edges.
### 6) `POST /v2/projects/{project_id}/fcm/rank-actions`
Purpose: rank candidate interventions by expected outcome and risk.
Request schema:
```json
{
"goal": "string",
"constraints": {
"max_negative_impact": 0.25,
"required_tags": ["string"],
"disallowed_nodes": ["string"]
},
"top_k": 10
}
```
Response schema:
```json
{
"goal": {"node_id": "string", "label": "string"},
"recommendations": [
{
"action_node_id": "string",
"expected_goal_delta": 0.0,
"risk_penalty": 0.0,
"net_score": 0.0,
"confidence": 0.0,
"rationale": ["string"],
"evidence_refs": ["memory://..."]
}
]
}
```
Deterministic fields: candidate set and constraint compliance.
Probabilistic fields: expected delta, penalty, net score, confidence.
Latency target: p95 <= 1500ms for top-10 from <=100 candidates.
### 7) `POST /v2/projects/{project_id}/fcm/import`
Purpose: import FCM model from CSV-first contract.
Request schema:
```json
{
"source": "string",
"format": "csv_bundle_v1",
"merge_mode": "replace|upsert"
}
```
Response schema:
```json
{
"import_id": "string",
"nodes_loaded": 0,
"edges_loaded": 0,
"warnings": ["string"],
"errors": ["string"]
}
```
Deterministic fields: counts and validation diagnostics.
Probabilistic fields: none.
Latency target: p95 <= 2500ms for 10k edges import.
### 8) `POST /v2/projects/{project_id}/fcm/export`
Purpose: export FCM model for interoperability.
Request schema:
```json
{
"format": "csv_bundle_v1",
"selection": {
"scope": "all|tag|subgraph",
"tag": "string|null",
"seed_nodes": ["string"]
}
}
```
Response schema:
```json
{
"export_id": "string",
"format": "csv_bundle_v1",
"files": [{"name": "nodes.csv", "path": "string"}, {"name": "edges.csv", "path": "string"}],
"node_count": 0,
"edge_count": 0
}
```
Deterministic fields: file names and counts for fixed selection.
Probabilistic fields: none.
Latency target: p95 <= 1800ms for 50k edges export.
## Data Model and Storage Boundaries
1. SQLite is mandatory operational source for entities, relations, embeddings, and project metadata.
2. Oxigraph stores derived knowledge graph index only.
3. FCM state persists in app database with scenario artifacts and run history.
4. Graph index is rebuildable and disposable by design.
5. Markdown files remain canonical source of truth.
Implementation data boundaries:
1. Knowledge graph schema tracks descriptive nodes and typed edges plus provenance.
2. FCM schema tracks signed weighted causal edges and node states.
3. Provenance model requires `evidence_refs`, `confidence`, and `updated_at`.
4. Scenario model stores interventions, constraints, run parameters, and output deltas.
5. Interop schema starts with CSV-first Mental Modeler contract.
## Background Jobs and Index Lifecycle
Scheduler tasks to add:
1. `sync_graph_entity`
2. `sync_graph_project`
3. `reindex_graph_project`
Lifecycle rules:
1. Note writes, edits, moves, and deletes schedule graph-index sync tasks.
2. Scheduling pattern mirrors existing vector sync behavior.
3. On stale or missing graph index, request path serves via SQL fallback and schedules catch-up.
4. Reindex is idempotent and safe to rerun.
5. Index version metadata is tracked per project for staleness checks.
Operational behaviors:
1. Foreground requests never block on full reindex completion.
2. Background job failures surface in health endpoints with actionable status.
3. Reindex job can run incremental or full mode.
4. Phase 1 note: scheduler task names and reindex enqueue path are implemented; write/edit/move/delete sync hooks still need explicit wiring.
## MCP and CLI Surface
New MCP tools:
1. `graph_lineage`
2. `graph_impact`
3. `graph_health`
4. `fcm_simulate`
5. `fcm_rank_actions`
6. `fcm_import_model`
7. `fcm_export_model`
CLI passthrough additions:
1. `bm tool graph-lineage ...`
2. `bm tool graph-impact ...`
3. `bm tool graph-health ...`
4. `bm tool fcm-simulate ...`
5. `bm tool fcm-rank-actions ...`
6. `bm tool fcm-import-model ...`
7. `bm tool fcm-export-model ...`
Output conventions:
1. Default output is JSON for MCP and CLI.
2. MCP supports optional `output_format="text"` for human-readable summaries.
3. CLI remains JSON-first to keep agent integration deterministic.
## Test Strategy (fast loop + gates)
### Slice-by-slice loop
For each vertical slice, implement in this order:
1. API contract and schema.
2. Typed MCP client.
3. MCP tool passthrough.
4. CLI passthrough.
5. Focused tests for API/MCP/CLI.
Fast checks per slice:
1. Targeted `pytest` for changed API, MCP, and CLI modules.
2. `just fast-check`.
3. `just doctor`.
4. `just test-graph-intel` for graph/FCM-only iteration loop.
Milestone gates:
1. SQLite unit and integration pass first.
2. Selective Postgres parity tests for new graph and FCM contracts.
3. Full `just check` before merge.
### Required test cases and scenarios
1. Casual user impact preview before note edit.
2. Decision audit: lineage plus evidence references explain recommendation.
3. Graph health deterministic output for fixed snapshot.
4. Research flow: import model -> simulate -> rank -> export.
5. Sparse and contradictory graph input degrades gracefully.
6. Interop round-trip preserves node/edge counts and signed weights.
7. Local and cloud parity on contract semantics and error model.
8. Stale index fallback path returns valid response and schedules catch-up.
## Rollout and Feature Flagging
Rollout controls:
1. Gate graph and FCM endpoints behind config flags first.
2. Add entitlement enforcement after behavior and reliability stabilize.
3. Keep existing tools and endpoints fully backward compatible.
Suggested flags:
1. `feature_graph_intelligence_enabled`
2. `feature_fcm_enabled`
3. `feature_graph_oxigraph_provider_enabled`
4. `feature_graph_sql_fallback_enabled`
Rollout sequence:
1. Enable contract skeleton in dev.
2. Enable graph features for internal alpha users.
3. Enable Oxigraph provider with fallback-on by default.
4. Enable FCM import/simulate/rank/export for research alpha users.
5. Promote to Local+ beta when acceptance criteria are met.
## Risks and Mitigations
1. Risk: graph query complexity increases p95 latency.
Mitigation: strict query caps, fallback path, and performance budgets per endpoint.
2. Risk: stale index produces confusing outputs.
Mitigation: explicit staleness checks, SQL fallback, and background catch-up scheduling.
3. Risk: FCM recommendations appear opaque.
Mitigation: require evidence references, confidence fields, and deterministic simulation metadata.
4. Risk: local/cloud contract drift.
Mitigation: shared schemas, contract tests, and parity checks in CI gates.
5. Risk: integration surface grows faster than team can validate.
Mitigation: phase gates and vertical-slice completion before opening next phase.
## Improvement Backlog (Post-Phase 1)
1. Refactor `bm tool` graph/FCM commands into a dedicated CLI module to reduce `tool.py` size and improve maintainability.
2. Consolidate repeated MCP text-formatting helpers for graph/FCM outputs.
3. Replace deterministic placeholder graph behavior with SQL-backed lineage/impact/health implementations.
4. Add explicit config/entitlement enforcement for graph/FCM endpoints and tools.
5. Add performance telemetry and p95 reporting for graph and FCM routes.
6. Add parity and degradation tests for stale-index fallback and contradictory/sparse inputs.
## Acceptance Criteria
1. All required sections in this document are complete with no unresolved decisions.
2. API/interface contracts are implementation-ready with request, response, error, latency, and scale details.
3. Architecture alignment is explicit: FastAPI logic core, MCP/CLI facades, shared local/cloud contracts.
4. Delivery phases define concrete outputs and exit criteria.
5. Test strategy includes tight iteration loop and milestone gates.
6. Required scenario matrix is covered in test plan and mapped to implementation phases.
7. Rollout plan includes feature flags and backward compatibility guarantees.
8. An implementer can execute this plan without additional architecture clarification.
## Assumptions and Defaults
1. Config-flag gating first; entitlement wiring later.
2. Graph-first vertical slices before deep FCM expansion.
3. Atomic tools first; orchestration layer deferred.
4. JSON-first contracts for agent usability.
5. No breaking changes to existing `build_context` and `search_notes`.
## Out of Scope
1. Implementation details unrelated to graph/FCM delivery phases in this document.
2. Migration execution.
3. Pricing and positioning rewrites.
4. Cloud infrastructure changes in this phase.
@@ -1,782 +0,0 @@
# SPEC-LOCAL-GRAPH-INTELLIGENCE-MASTER: Local+ Graph Intelligence Blueprint
**Status:** Draft (Iteration 2, Decision-Complete)
**Date:** 2026-03-05
**Owner:** Basic Memory
**Primary Audience:** Internal build team (Product, Engineering, GTM)
**Current Phase (2026-03-05):** Implementation Plan Phase 1 is complete; Phase 2 (SQL-backed graph logic) is the active engineering phase.
**Related Specs:**
1. `/docs/specs/SPEC-LOCAL-GRAPH-INTELLIGENCE.md`
2. `/docs/specs/SPEC-LOCAL-GRAPH-INTELLIGENCE-TECHNICAL-ADDENDUM.md`
3. `/docs/specs/SPEC-LOCAL-GRAPH-INTELLIGENCE-IMPLEMENTATION-PLAN.md`
Reading guide:
1. Sections 1-5 define the business and product decisions.
2. Sections 6-10 define architecture and interface contracts.
3. Sections 11-14 define pricing, rollout, and decision gates for execution.
## 1) Executive Thesis
Basic Memory will ship **Local+ Graph Intelligence** as a premium local capability that upgrades the product from retrieval to decision support.
Positioning statement:
1. "Keep your local workflow. Add decision intelligence as complexity grows."
2. The product sells safer decisions and explainable recommendations, not graph database mechanics.
Locked thesis decisions:
1. SQLite will remain the operational core.
2. Markdown will remain source of truth.
3. Graph and FCM indexes will be derived and rebuildable.
4. Oxigraph/pyoxigraph will be the v1 graph index path.
5. FCM simulation will run in a Python service layer.
6. SurrealDB and FalkorDB will not be core dependencies in v1 due license-roadmap mismatch.
7. Product messaging will sell outcomes (safer decisions, explainable recommendations), not database internals.
## 2) Problem and Opportunity
Current state after v0.19:
1. Recursive SQL traversal can retrieve connected notes but becomes expensive and noisy after a few hops.
2. Users still do manual synthesis for impact analysis, decision lineage, and contradiction resolution.
3. Researchers need causal reasoning and scenario modeling, not only graph navigation.
Opportunity:
1. Deliver a premium local tier that materially improves decision quality while keeping data local.
2. Create a bridge from knowledge graph navigation to causal simulation (FCM).
3. Open a research-heavy market segment that values explainability and model interoperability.
Business opportunity:
1. Add a middle tier between free OSS and cloud subscription.
2. Preserve an upgrade path to hosted collaboration for research teams later.
3. Differentiate Basic Memory for research-grade workflows without forcing cloud adoption.
## 3) User Segments and Jobs-to-be-Done
| Segment | Primary Job-to-be-Done | Pain Today | Value Trigger |
|---|---|---|---|
| Casual local builder | Avoid breaking related notes when editing | Hidden dependencies and rework | Impact preview before edits |
| Solo technical founder | Keep architecture and decision context coherent | Context overload and drift | Decision lineage + impact radius |
| Research user | Model and test intervention strategies | No integrated causal simulation with notes | FCM simulation + action ranking |
| Product/research lead | Synthesize evidence quickly across many docs | Fragmented understanding | Path exploration + priority briefs |
## 4) Product Outcomes (not feature list)
Local+ Graph Intelligence will optimize for these outcomes:
1. **Change Safety:** users catch downstream impacts before they edit.
2. **Decision Clarity:** users can explain why an answer or recommendation was produced.
3. **Knowledge Health:** users keep larger graphs coherent with less manual audit work.
4. **Research Leverage:** users run scenario-level reasoning tied to explicit evidence.
Outcome metrics (for 30-day retained Local+ cohorts):
1. Median time-to-understanding for complex topics decreases by at least 35% for active Local+ users.
2. User-reported surprise side effects after note edits decrease by at least 30%.
3. At least 60% of active Local+ users invoke graph intelligence features weekly.
4. At least 40% of research-profile Local+ users invoke one FCM workflow weekly.
## 5) Feature Set v1/v1.5/v2
### v1 (post-v0.19 launch scope)
Included:
1. Decision Lineage
2. Impact Radius
3. Path Explorer (guided)
4. Graph Health (orphans, stale-central nodes, overloaded hubs)
5. CSV FCM import/export (nodes and edges)
6. FCM simulation for explicit action scenarios
7. FCM action ranking with evidence-linked rationale
Excluded:
1. Native Mental Modeler project format write support
2. Team governance and shared model policy controls
3. Cloud-only enhancements
### v1.5
Included:
1. Contradiction Watch with reconciliation queue
2. Priority Briefs (graph + FCM leverage summary)
3. Stronger uncertainty propagation in FCM scoring
4. Cloud execution optionality for heavy simulation jobs
### v2
Included:
1. Team-shared model governance
2. Hosted collaboration features for research teams
3. Optional native model translators beyond CSV baseline
Cut line policy:
1. If a capability cannot meet explainability requirements, it moves to v1.5+.
2. If a capability requires cloud to function, it cannot be marked v1.
3. If a capability cannot meet local performance envelopes, it cannot be promoted into default workflows.
## 6) Technical Architecture (Two-Graph Model)
### High-level architecture
```mermaid
flowchart LR
A[Markdown Files Source of Truth] --> B[Parser + Sync Pipeline]
B --> C[SQLite Operational Store]
B --> D[Derived Knowledge Graph Index Oxigraph]
C --> E[Graph Intelligence Service]
D --> E
E --> F[Lineage Impact Path Health APIs]
C --> G[FCM Service Python]
D --> G
G --> H[Simulation Ranking Interop APIs]
```
### Two-graph model
1. **Knowledge Graph (descriptive):** notes, decisions, concepts, and typed relations.
2. **FCM Graph (causal):** signed weighted influence links between goals, drivers, risks, and interventions.
### Core architectural decisions
1. SQLite is authoritative for entities, observations, relations, metadata, embeddings, and project state.
2. Oxigraph is a derived index for multi-hop graph traversal and graph-pattern retrieval.
3. FCM calculations run in Python using explicit model state and deterministic numerical steps.
4. Local mode runs fully offline.
5. Cloud mode can execute the same contracts via adjunct services while Neon remains system of record.
## 7) Backend Decision and Trade-Offs
### Final recommendation
Use this stack for v1:
1. SQLite (existing): primary operational store.
2. Oxigraph/pyoxigraph: derived knowledge graph index.
3. Python FCM service: causal simulation and ranking.
Decision rationale:
1. This preserves local-first UX while enabling deeper traversal and causal simulation.
2. This avoids restrictive licensing dependencies in the core product path.
3. This keeps a clean cloud portability path where Neon remains the hosted system of record.
### Trade-off matrix
| Option | Strengths | Risks | Decision |
|---|---|---|---|
| SQLite + Oxigraph + Python FCM | Local-first, permissive licensing, clear service boundaries, cloud-portable | Requires translation layer for query ergonomics | **Adopt v1** |
| Apache AGE on Postgres | SQL+graph in one engine, good cloud-side graph semantics | Neon extension support uncertainty, weaker local/cloud parity with SQLite local baseline | Defer |
| SurrealDB | Strong integrated multi-model experience | BSL posture conflicts with future hosted/open strategy timing | Reject for v1 core |
| FalkorDB | Graph performance and Redis ecosystem familiarity | SSPL posture conflicts with hosted/open strategy | Reject for v1 core |
## 8) Public APIs / Interfaces
All APIs are proposed MCP tool contracts for Local+ mode.
### Common conventions
1. `project` parameter is optional and follows existing Basic Memory project routing.
2. Deterministic fields are reproducible with identical inputs and index state.
3. Probabilistic fields are model-derived scores and include confidence metadata.
4. Error model uses structured codes and fail-fast behavior.
Shared error codes:
1. `INVALID_ARGUMENT`
2. `NOT_FOUND`
3. `MODEL_INVALID`
4. `INDEX_NOT_READY`
5. `RESOURCE_LIMIT_EXCEEDED`
6. `INTERNAL_ERROR`
---
### 8.1 `graph_lineage(start, goal?)`
**Input schema:**
```json
{
"start": "string (required, permalink or memory URL)",
"goal": "string (optional, concept or decision target)",
"max_hops": "integer (optional, default 4, range 1-6)",
"relation_filters": ["string"],
"project": "string (optional)"
}
```
**Output schema:**
```json
{
"root": {"id": "string", "title": "string", "permalink": "string"},
"paths": [
{
"path_id": "string",
"nodes": [{"id": "string", "title": "string"}],
"edges": [{"relation": "string", "direction": "outgoing|incoming"}],
"deterministic_path_score": 0.0,
"confidence": 0.0,
"evidence_refs": ["memory://..."]
}
],
"generated_at": "RFC3339"
}
```
**Deterministic fields:** root, nodes, edges, deterministic path score, generated timestamp.
**Probabilistic fields:** confidence.
**Latency target:** p95 <= 450ms for `max_hops<=4`, graph envelope up to 50k nodes / 300k edges.
**Scale envelope:**
1. Tested local baseline: 50k nodes, 300k edges.
2. Expected degradation: path expansion can exceed latency target when candidate paths > 20k.
---
### 8.2 `graph_impact(target, horizon, relation_filters?)`
**Input schema:**
```json
{
"target": "string (required)",
"horizon": "integer (required, range 1-4)",
"relation_filters": ["string"],
"include_reasons": "boolean (default true)",
"project": "string (optional)"
}
```
**Output schema:**
```json
{
"target": {"id": "string", "title": "string"},
"affected": [
{
"id": "string",
"title": "string",
"distance": 2,
"impact_score": 0.0,
"confidence": 0.0,
"reasons": ["string"]
}
],
"summary": {"total_considered": 0, "total_returned": 0}
}
```
**Deterministic fields:** membership, distance, summary counts.
**Probabilistic fields:** impact score, confidence.
**Latency target:** p95 <= 650ms for `horizon<=3` under baseline envelope.
**Scale envelope:**
1. `affected` default cap: 200 items.
2. Hard cap: 1000 items with pagination token.
---
### 8.3 `graph_health(scope?, timeframe?)`
**Input schema:**
```json
{
"scope": "string (optional, directory prefix or project-wide)",
"timeframe": "string (optional, e.g. 30d, 90d)",
"project": "string (optional)"
}
```
**Output schema:**
```json
{
"metrics": {
"orphan_rate": 0.0,
"stale_central_nodes": 0,
"overloaded_hubs": 0,
"contradiction_candidates": 0
},
"issues": [
{
"issue_type": "orphan|stale_central|overloaded_hub|contradiction_candidate",
"entity_id": "string",
"severity": "low|medium|high",
"reason": "string",
"suggested_action": "string"
}
],
"computed_at": "RFC3339"
}
```
**Deterministic fields:** metrics and issue list membership for a fixed graph snapshot.
**Probabilistic fields:** contradiction candidate confidence when present.
**Latency target:** p95 <= 1500ms project-wide; <= 700ms for scoped directory mode.
**Scale envelope:** project-wide scans tested to 50k nodes.
---
### 8.4 `fcm_simulate(actions, scenario?, clamp_rules?)`
**Input schema:**
```json
{
"actions": [
{"node_id": "string", "delta": 0.2}
],
"scenario": {
"steps": 12,
"activation": "tanh|sigmoid|bounded_linear",
"decay": 0.05
},
"clamp_rules": [
{"node_id": "string", "min": -1.0, "max": 1.0}
],
"project": "string (optional)"
}
```
**Output schema:**
```json
{
"baseline": [{"node_id": "string", "state": 0.12}],
"projected": [{"node_id": "string", "state": 0.43}],
"deltas": [{"node_id": "string", "delta": 0.31}],
"stability": {
"converged": true,
"iterations_used": 9,
"residual": 0.002
},
"confidence": 0.0,
"explanations": [
{"node_id": "string", "top_influencers": [{"source": "string", "weight": 0.7}]}
]
}
```
**Deterministic fields:** baseline, projected, deltas, convergence metadata for fixed model and parameters.
**Probabilistic fields:** confidence (derived from edge confidence and evidence coverage).
**Latency target:** p95 <= 1000ms for up to 500 nodes / 5000 edges and <=12 steps.
**Scale envelope:**
1. Soft limit: 2000 nodes / 20000 edges.
2. Over soft limit: return `RESOURCE_LIMIT_EXCEEDED` with remediation guidance.
---
### 8.5 `fcm_rank_actions(goal, constraints?, top_k?)`
**Input schema:**
```json
{
"goal": "string (required node_id)",
"constraints": {
"max_negative_impact": 0.25,
"required_tags": ["string"],
"disallowed_nodes": ["string"]
},
"top_k": "integer (default 10, range 1-25)",
"project": "string (optional)"
}
```
**Output schema:**
```json
{
"goal": {"node_id": "string", "label": "string"},
"recommendations": [
{
"action_node_id": "string",
"expected_goal_delta": 0.0,
"risk_penalty": 0.0,
"net_score": 0.0,
"confidence": 0.0,
"rationale": ["string"],
"evidence_refs": ["memory://..."]
}
]
}
```
**Deterministic fields:** candidate action set, constraints compliance.
**Probabilistic fields:** expected goal delta, risk penalty, net score, confidence.
**Latency target:** p95 <= 1500ms for top 10 from up to 100 candidate actions.
**Scale envelope:**
1. Candidate actions hard cap: 1000.
2. For larger sets, require pre-filtering via tags/scope.
---
### 8.6 `fcm_import_model(source, format)`
**Input schema:**
```json
{
"source": "string (required path or URI)",
"format": "csv_bundle_v1 (required)",
"merge_mode": "replace|upsert (default upsert)",
"project": "string (optional)"
}
```
**Output schema:**
```json
{
"import_id": "string",
"nodes_loaded": 0,
"edges_loaded": 0,
"warnings": ["string"],
"errors": ["string"]
}
```
**Deterministic fields:** load counts and validation results.
**Probabilistic fields:** none.
**Latency target:** p95 <= 2500ms for 10k edges CSV bundle.
**Scale envelope:**
1. Maximum CSV rows per import: 250k.
2. Above limit returns `RESOURCE_LIMIT_EXCEEDED`.
---
### 8.7 `fcm_export_model(format, selection?)`
**Input schema:**
```json
{
"format": "csv_bundle_v1 (required)",
"selection": {
"scope": "all|tag|subgraph",
"tag": "string (optional)",
"seed_nodes": ["string"]
},
"project": "string (optional)"
}
```
**Output schema:**
```json
{
"export_id": "string",
"format": "csv_bundle_v1",
"files": [
{"name": "nodes.csv", "path": "string"},
{"name": "edges.csv", "path": "string"}
],
"node_count": 0,
"edge_count": 0
}
```
**Deterministic fields:** file set and row counts for fixed selection.
**Probabilistic fields:** none.
**Latency target:** p95 <= 1800ms for 50k edges export.
**Scale envelope:**
1. Max export rows: 500k total.
2. Pagination or scoped export required above cap.
## 9) Data Model and Storage Boundaries
### 9.1 Knowledge graph schema (descriptive)
`KnowledgeNode`:
1. `id: str`
2. `kind: note|decision|spec|concept|person|project`
3. `title: str`
4. `permalink: str`
5. `tags: list[str]`
6. `updated_at: datetime`
`KnowledgeEdge`:
1. `id: str`
2. `src_id: str`
3. `dst_id: str`
4. `relation: str`
5. `directionality: directed|bidirectional`
6. `evidence_refs: list[str]`
7. `confidence: float [0,1]`
8. `updated_at: datetime`
### 9.2 FCM schema (causal signed weighted)
`FCMNode`:
1. `id: str`
2. `label: str`
3. `node_type: goal|driver|risk|intervention|context`
4. `state: float [-1,1]`
5. `clamp_min: float`
6. `clamp_max: float`
7. `metadata: map`
`FCMEdge`:
1. `id: str`
2. `source_id: str`
3. `target_id: str`
4. `weight: float [-1,1]`
5. `confidence: float [0,1]`
6. `time_decay: float [0,1]`
7. `evidence_refs: list[str]`
8. `updated_at: datetime`
### 9.3 Provenance model
`ProvenanceRecord`:
1. `entity_id: str`
2. `evidence_refs: list[str]`
3. `confidence: float [0,1]`
4. `updated_at: datetime`
5. `source_type: extracted|user_authored|imported`
### 9.4 Scenario model
`Scenario`:
1. `id: str`
2. `name: str`
3. `interventions: list[{node_id, delta}]`
4. `constraints: list[{node_id, min, max}]`
5. `steps: int`
6. `activation: tanh|sigmoid|bounded_linear`
7. `created_at: datetime`
8. `created_by: str`
`ScenarioResult`:
1. `scenario_id: str`
2. `converged: bool`
3. `iterations_used: int`
4. `residual: float`
5. `goal_deltas: list[{node_id, delta}]`
6. `confidence: float [0,1]`
### 9.5 Storage boundaries
| Layer | System of Record | Purpose | Rebuildable |
|---|---|---|---|
| Markdown files | File system | Canonical knowledge content | No |
| SQLite entities/relations/embeddings | SQLite | Operational queries and project state | Yes (from markdown + embedding pipeline) |
| Knowledge graph triples | Oxigraph | Fast graph traversal and pattern queries | Yes |
| FCM model and snapshots | SQLite + optional artifacts | Causal model state and scenario history | Yes (from imports and authored model definitions) |
## 10) Mental Modeler Interoperability
### v1 interoperability contract
Format: `csv_bundle_v1`
1. `nodes.csv`
2. `edges.csv`
3. Optional `scenarios.csv`
`nodes.csv` required columns:
1. `node_id`
2. `label`
3. `node_type`
4. `state`
5. `clamp_min`
6. `clamp_max`
`edges.csv` required columns:
1. `edge_id`
2. `source_id`
3. `target_id`
4. `weight`
5. `confidence`
6. `evidence_refs` (semicolon-delimited)
### Import rules
1. Missing required columns fail with `MODEL_INVALID`.
2. Unknown node types fail fast.
3. Weight and confidence ranges are strictly validated.
4. Import returns warnings for dangling evidence references.
### Export rules
1. Preserve stable IDs for round-trip compatibility.
2. Preserve signed weights exactly.
3. Preserve confidence values exactly.
4. Non-portable metadata is emitted to `metadata.json` sidecar when present.
### Native file translators
1. Native project-format translation is deferred to v2.
2. CSV remains the guaranteed compatibility baseline in v1 and v1.5.
## 11) Pricing and Packaging
### Tier structure
| Tier | Price Monthly | Price Annual | Beta Price (25% off) | Target Persona | Core Value |
|---|---:|---:|---:|---|---|
| OSS Local | $0 | $0 | $0 | Casual local users | Retrieval and memory basics |
| Local+ Graph Intelligence | $9 | $90 | $6.75 monthly / $67.50 annual | Founders, consultants, researchers | Safer changes + explainable graph + FCM simulation |
| Cloud Pro (current anchor) | $19 | $190 | $14.25 monthly / $142.50 annual | Users who need hosted sync and cloud workflows | Managed cloud + sync + collaboration path |
Pricing principles:
1. Local+ is intentionally priced between free OSS and cloud to capture users who need deeper intelligence but not hosted sync.
2. Cloud Pro remains the hosted convenience anchor and future collaboration path.
3. Local+ must stand on standalone local value and cannot depend on cloud features.
### Feature gate mapping
| Capability | OSS Local | Local+ | Cloud Pro |
|---|---|---|---|
| Search and basic context tools | Yes | Yes | Yes |
| Decision Lineage | No | Yes | Yes |
| Impact Radius | No | Yes | Yes |
| Graph Health | No | Yes | Yes |
| FCM simulate + rank | No | Yes | Yes |
| CSV model import/export | No | Yes | Yes |
| Hosted collaboration controls | No | No | Future add-on |
### Packaging decisions
1. Local+ remains fully local-capable and does not require cloud auth to run.
2. Cloud Pro remains the hosted convenience and collaboration anchor.
3. Future hosted research add-on will layer on Cloud Pro after v2 readiness.
## 12) Rollout Strategy
### 12.1 Document production iterations (locked)
Iteration 1 (draft complete):
1. Complete all 15 sections in one pass.
2. Include v1/v1.5/v2 cut lines.
3. Include pricing and scenario definitions.
4. Ensure no placeholders.
Iteration 2 (hardening and decision lock):
1. Resolve cross-section contradictions.
2. Convert uncertain language to locked decisions.
3. Add measurable acceptance criteria and risk owners.
4. Finalize execution-ready API contracts.
### 12.2 Product rollout phases
Phase A: Foundation release (v1)
1. Graph lineage, impact, health.
2. CSV model import/export.
3. FCM simulation and ranking.
4. Advanced mode UX gating for research-grade controls.
Phase B: Quality and confidence (v1.5)
1. Contradiction Watch.
2. Priority Briefs.
3. Improved uncertainty propagation.
4. Confidence calibration pass using real-world model feedback.
Phase C: Team expansion (v2)
1. Hosted team governance.
2. Shared model controls.
3. Extended translator support.
### 12.3 Go/No-Go release gates
Gate to ship v1 default workflows:
1. p95 latency targets are met within the declared scale envelopes.
2. Scenario round-trip fidelity tests pass for CSV import/export.
3. Every recommendation and simulation path exposes evidence references and confidence.
Gate to ship v1.5:
1. Contradiction Watch precision is acceptable for default-on use.
2. Confidence calibration reduces false-confidence reports in user testing.
Gate to ship v2 team features:
1. Clear willingness-to-pay signal from team and research buyers.
2. Cloud execution path preserves local-cloud semantic parity for core contracts.
## 13) Risks, Counterarguments, and Mitigations
| Risk | Counterargument | Severity | Likelihood | Mitigation | Owner |
|---|---|---|---|---|---|
| "This is just better search" | Positioning can collapse into technical jargon | High | Medium | Lead with decision safety and explainability outcomes in product copy and onboarding | Product Lead |
| FCM feels opaque or invented | Users distrust black-box scoring | High | Medium | Require evidence refs and confidence disclosure on every recommendation | Applied AI Lead |
| Local performance regressions | Multi-hop and simulation can feel slow on laptops | Medium | Medium | Enforce envelopes, caps, and fail-fast limit errors with guidance | Engineering Lead |
| Research features overwhelm casual users | UX complexity can reduce adoption | Medium | High | Default to guided flows and hide advanced controls behind explicit advanced mode | Design Lead |
| License/roadmap conflict if backend changes | Later swap to restrictive engines creates GTM risk | High | Low | Lock permissive v1 stack and require leadership sign-off for any license-restricted dependency | Product + Legal |
| Interop mismatch with external tools | Round-trip drift harms trust with researchers | Medium | Medium | Validate node and edge parity in import/export tests and version interop schema | Integrations Lead |
| Pricing confusion between Local+ and Cloud Pro | Buyers may not understand which tier fits | Medium | Medium | Publish explicit tier comparison focused on local intelligence vs hosted collaboration | GTM Lead |
## 14) Acceptance Criteria
### 14.1 Document acceptance criteria
1. Product, technical, and pricing decisions are explicit and unambiguous.
2. API contracts include input/output schemas, error models, deterministic versus probabilistic fields, and performance envelopes.
3. v1/v1.5/v2 cut lines are explicit and consistent.
4. Risk register includes severity, likelihood, owner, and mitigation.
5. Document can be handed to implementation without additional architecture decisions.
6. Leadership can use this document directly for pricing and positioning decisions.
### 14.2 Product acceptance criteria for v1 delivery
1. `graph_lineage`, `graph_impact`, `graph_health`, `fcm_simulate`, `fcm_rank_actions`, `fcm_import_model`, and `fcm_export_model` are available as Local+ contracts.
2. Local mode executes all v1 contracts without cloud dependency.
3. API p95 latency targets are met within defined scale envelopes.
4. Every ranked or simulated output includes evidence-linked rationale and confidence.
5. CSV round-trip preserves node count, edge count, and signed weights exactly.
### 14.3 Scenario test matrix (required)
1. **Casual local user impact check**
Expected pass:
`graph_impact` returns ranked affected notes with reasons before a note edit.
2. **Research workflow simulation**
Expected pass:
User imports a model bundle, runs `fcm_simulate`, and receives converged deltas and rationale.
3. **Decision audit traceability**
Expected pass:
`graph_lineage` returns path and evidence references that explain recommendation origin.
4. **Cloud and local parity**
Expected pass:
Identical query inputs return semantically equivalent outputs in local and cloud modes with local fallback behavior when cloud is unavailable.
5. **Sparse or contradictory graph behavior**
Expected pass:
System degrades gracefully with explicit uncertainty and does not fabricate high-confidence recommendations.
6. **Interop round-trip fidelity**
Expected pass:
`fcm_export_model` then `fcm_import_model` preserves node and edge counts and signed weights without mutation.
## 15) Appendix (license notes, terminology, examples)
### 15.1 License notes (verified 2026-03-05)
1. SurrealDB core licensing is published under BSL 1.1 with DBaaS-related restrictions in its conversion window.
2. FalkorDB is published under SSPLv1.
3. pyoxigraph is dual-licensed Apache-2.0 or MIT.
4. Neon extension catalog currently does not list Apache AGE as a supported extension.
These notes support the v1 dependency decisions in this document.
### 15.2 Terminology
1. **Knowledge graph:** descriptive relation graph derived from markdown knowledge.
2. **FCM:** fuzzy cognitive model with signed weighted causal edges.
3. **Deterministic field:** reproducible output field from fixed input and fixed model/index snapshot.
4. **Probabilistic field:** score influenced by confidence weights and model uncertainty.
### 15.3 Assumptions and defaults
1. Markdown remains source of truth.
2. SQLite remains mandatory baseline.
3. Graph and FCM capabilities are premium Local+ features, not OSS defaults.
4. Research-heavy features are advanced mode, while mainstream UX stays guided.
5. Mental Modeler interoperability starts with CSV contract first; native translator support is deferred.
### 15.4 Out of scope for this document
1. Implementation code changes.
2. Database migrations.
3. Cloud infrastructure edits.
4. Full instrumentation pilot plan as the primary artifact.
### 15.5 External references
1. [SurrealDB licensing](https://surrealdb.com/license)
2. [FalkorDB licensing](https://docs.falkordb.com/References/license.html)
3. [pyoxigraph package and license](https://pypi.org/project/pyoxigraph/)
4. [Neon Postgres extension catalog](https://neon.com/docs/extensions/pg-extensions)
@@ -1,299 +0,0 @@
# SPEC-LOCAL-GRAPH-INTELLIGENCE: Technical Addendum (Graph + FCM)
**Status:** Draft
**Date:** 2026-03-05
**Owner:** Basic Memory
**Current Phase (2026-03-05):** Contract skeleton implementation is complete; next active phase is SQL-backed graph capabilities.
Related product spec:
`/docs/specs/SPEC-LOCAL-GRAPH-INTELLIGENCE.md`
Related execution spec:
`/docs/specs/SPEC-LOCAL-GRAPH-INTELLIGENCE-IMPLEMENTATION-PLAN.md`
## Why This Addendum Exists
The product spec defines user value. This addendum defines the technical shape that can deliver that value without
breaking local-first principles.
This addendum also introduces a second graph layer:
1. Knowledge graph for relationships between notes, entities, and decisions.
2. Fuzzy Cognitive Model (FCM) graph for weighted causal reasoning over actions and outcomes.
Both are derived from markdown and optional user-provided models.
## Strategic Reality Check
This is a strong idea if we stage it correctly.
It is not a pipe dream if we avoid one trap: building a big "graph platform" before proving users repeatedly use
decision simulation workflows.
The correct strategy is:
1. Launch high-precision graph insights first.
2. Add FCM scoring where it changes user behavior (not as a novelty dashboard).
3. Expand to hosted/team workflows only after local usage proves repeat value.
## Constraints and Design Principles
1. SQLite remains the operational source for entities, observations, relations, and embeddings.
2. Markdown remains source of truth.
3. Graph indexes are derived, rebuildable, and disposable.
4. Premium local mode must run fully offline.
5. Cloud deployment should support both single-tenant and SaaS later.
6. Avoid licenses that constrain hosted/open-source strategy.
## Backend Recommendation
### Primary Recommendation
Use a dual-store architecture:
1. SQLite (existing): operational data, metadata filters, embeddings, and most retrieval.
2. Oxigraph/pyoxigraph (new): derived graph index for graph traversal and graph-pattern queries.
3. Python simulation layer (new): FCM state propagation, scenario runs, and decision scoring.
Why this is the best fit:
1. Permissive licensing profile.
2. Works locally with low footprint.
3. Cloud-compatible as sidecar service while keeping Neon Postgres as core cloud store.
4. Clear boundary between graph query and numeric simulation concerns.
### Candidate Trade-Offs
#### Oxigraph/pyoxigraph
Pros:
1. Lightweight local embedding.
2. Good fit for derived-index strategy.
3. Strong path for standards-based graph representation.
Cons:
1. SPARQL fluency is less common than SQL/Cypher.
2. Requires a translation layer so product features are not query-language-coupled.
#### Apache AGE (Postgres extension)
Pros:
1. SQL + graph in one engine.
2. Attractive for cloud-side graph operations.
Cons:
1. Neon support is uncertain for this extension.
2. Local/cloud parity is harder if local uses SQLite.
#### SurrealDB / FalkorDB
Pros:
1. Strong graph-oriented developer experience.
Cons:
1. License posture is misaligned with a future hosted/open-source roadmap unless commercial terms are accepted.
Decision:
Do not make these core dependencies for v1 of Local+ Graph Intelligence.
## Two-Graph Model
### A) Knowledge Graph (Descriptive)
Node examples:
1. Note
2. Decision
3. Spec
4. Person
5. Project
6. Concept
Edge examples:
1. `depends_on`
2. `informed_by`
3. `contradicts`
4. `supports`
5. `implements`
6. `derived_from`
Purpose:
Power navigation, lineage, path explanation, impact radius, and health checks.
### B) FCM Graph (Causal, Signed, Weighted)
Node examples:
1. Goal: "Reduce regressions"
2. Driver: "Test coverage"
3. Risk: "Scope creep"
4. Intervention: "Add review gate"
5. Context variable: "Team bandwidth"
Edge attributes:
1. `weight` in [-1.0, 1.0]
2. `confidence` in [0.0, 1.0]
3. `evidence_refs` (links to notes/specs)
4. `time_decay` (optional)
Purpose:
Power scenario simulation and action ranking, not generic retrieval.
## Premium Feature Mapping to Architecture
### Decision Lineage
Backed by:
1. Knowledge graph path queries.
2. Evidence references stored on edges.
### Impact Radius
Backed by:
1. Multi-hop neighborhood expansion with relation-type weights.
2. Risk ranking using centrality + recency + confidence.
### Contradiction Watch
Backed by:
1. Candidate contradiction edges.
2. Confidence-scored reconciliation queue.
### Priority Briefs
Backed by:
1. Health metrics (orphan rate, stale-central nodes, unresolved contradictions).
2. Optional FCM "top leverage actions" summary.
### New Premium Feature: Action Simulator
Backed by:
1. FCM scenario runs over selected action nodes.
2. Ranked interventions with expected positive/negative downstream effects.
3. Explicit rationale graph for every recommendation.
## Mental Modeler Interop Plan
Goal:
Make Basic Memory the AI-enabled operating layer around existing researcher workflows, not a replacement for their tools.
Interoperability phases:
1. Import/export edge lists and node tables via CSV as the baseline interchange.
2. Preserve concept IDs and metadata so round-trips remain stable.
3. Add translator support for native model files if/when schema contracts are validated with partner data.
Validation requirement:
1. Round-trip tests must preserve node count, edge count, and signed weights.
2. Confidence/evidence metadata may be Basic Memory extensions and should degrade gracefully when exported.
## Suggested Tool/API Surface (Product-Facing)
1. `graph_lineage(start, goal?)`
Returns explainable evidence paths.
2. `graph_impact(target, horizon=2..4)`
Returns ranked affected nodes with reasons.
3. `graph_health()`
Returns actionable graph quality issues.
4. `fcm_simulate(actions, scenario?)`
Returns projected effects and uncertainty.
5. `fcm_rank_actions(goal, constraints?)`
Returns top candidate actions with trade-offs.
6. `fcm_import_model(source)` / `fcm_export_model(format)`
Handles interop with external cognitive mapping workflows.
## Local and Cloud Deployment Shape
### Local (Primary)
1. SQLite + local embeddings.
2. Oxigraph as local sidecar/index library.
3. FCM simulation in process.
### Cloud (Future-Compatible)
1. Neon Postgres remains system of record in hosted mode.
2. Graph index service runs per tenant or shared multi-tenant with strict tenancy boundaries.
3. FCM simulation service can run stateless workers reading graph snapshots.
Principle:
Do not require cloud to run premium local features.
## Rollout Plan With Go/No-Go Gates
### Phase 0: Proof of Utility (4-6 weeks)
Deliver:
1. Decision Lineage
2. Impact Radius
3. CSV FCM import + `fcm_simulate` prototype
Gate to continue:
1. Repeated weekly usage by pilot users.
2. Users report changed decisions, not just curiosity clicks.
### Phase 1: Productized Local+ Beta
Deliver:
1. Graph health workflow
2. Contradiction Watch
3. Action ranking with explicit rationale
Gate to continue:
1. Retention of graph features after first month.
2. Measured reduction in "surprise side effects" after edits.
### Phase 2: Hosted Expansion
Deliver:
1. Optional cloud execution for heavy simulations.
2. Team-shared model governance.
Gate to continue:
1. Clear willingness to pay for hosted collaboration.
## Risks and Mitigations
Risk: FCM outputs feel "made up."
Mitigation: Require evidence links and confidence scoring in every recommendation.
Risk: Research-heavy feature alienates casual users.
Mitigation: Keep FCM features in an advanced mode; default to concise guidance workflows.
Risk: Overengineering early graph stack.
Mitigation: Keep derived-index architecture and strict phase gates tied to behavior change.
Risk: Interop friction with external tooling.
Mitigation: Start with transparent CSV contract and strict round-trip validation.
## Candid Recommendation
Pursue this. It is a high-upside differentiation path for Local+ if executed with staged validation.
The key is to sell outcomes:
1. "Safer decisions"
2. "Explainable recommendations"
3. "Faster synthesis for complex research"
Avoid selling "graph DB" as the product. That is implementation detail.
-262
View File
@@ -1,262 +0,0 @@
# SPEC-LOCAL-GRAPH-INTELLIGENCE: Premium Local Graph Intelligence
**Status:** Draft
**Date:** 2026-03-05
**Owner:** Basic Memory
**Current Phase (2026-03-05):** Phase 1 contract foundation shipped; engineering is now executing SQL-backed Phase 2 graph logic.
Companion technical addendum:
`/docs/specs/SPEC-LOCAL-GRAPH-INTELLIGENCE-TECHNICAL-ADDENDUM.md`
## Summary
Add a premium local feature that turns Basic Memory from "search and recall" into "explain and guide."
The value is not a new database. The value is better decisions for local users:
1. Understand why something matters.
2. See what will be affected before making a change.
3. Detect weak spots in the knowledge base early.
4. Navigate complex knowledge intentionally instead of loading everything.
This feature is additive. Existing local workflows remain intact.
## Positioning
Core message:
"Your notes do more than store knowledge. They reveal consequences, lineage, and blind spots."
Local user promise:
1. Keep files local.
2. Keep markdown as source of truth.
3. Get advanced graph intelligence as an opt-in premium capability.
## Problem
Today, deep graph navigation is possible but often expensive in context size and hard to steer for complex questions.
Users can find information, but they still do manual synthesis to answer:
1. What changed because of this note?
2. Why did we decide this?
3. What might break if I update this?
4. Which parts of the graph are stale, isolated, or contradictory?
The cost is time, cognitive load, and missed risk.
## Goals
1. Provide clear, explainable graph insights that users can act on.
2. Make deep navigation feel guided, not overwhelming.
3. Help users prevent mistakes before they happen.
4. Create premium local value that is easy to understand and justify.
5. Keep feature behavior transparent and trustworthy.
## Non-Goals
1. Replacing SQLite as the primary operational store.
2. Changing markdown as source of truth.
3. Forcing users to learn graph query languages.
4. Building a cloud-only feature set.
5. Turning Basic Memory into an enterprise BI product.
## Product Frame: From Retrieval to Reasoning
The feature should be framed as a shift in user outcome:
1. Retrieval: "Find me the note."
2. Reasoning: "Show me the path, impact, and confidence around this note."
This is the main narrative upgrade for premium local users.
## Premium Value Pillars
### 1) Decision Confidence
Users can see decision lineage:
1. What evidence supported a decision.
2. Which notes/specs informed it.
3. How that decision evolved over time.
### 2) Change Safety
Users can run impact-aware workflows:
1. Estimate blast radius before editing.
2. Surface downstream dependencies.
3. Prioritize what to review first.
### 3) Knowledge Quality
Users can maintain graph health:
1. Detect orphaned notes.
2. Detect overloaded hub notes.
3. Detect stale but high-centrality notes.
4. Detect likely contradictions.
### 4) Guided Navigation
Users can explore deeper relationships without context explosion:
1. Follow promising branches.
2. Stop when confidence is sufficient.
3. Avoid "load everything and hope."
## Feature Catalog (Value-First)
### A. Decision Lineage
What users get:
1. A clear "why chain" for important conclusions.
2. Traceable connections to supporting notes.
3. Better handoffs and historical understanding.
### B. Impact Radius
What users get:
1. A ranked list of likely affected notes before edits.
2. Safer refactors for docs, plans, and architecture.
3. Reduced accidental drift and inconsistency.
### C. Knowledge Health Dashboard
What users get:
1. Weekly health signals for the graph.
2. Actionable cleanup targets.
3. Better long-term memory quality with less manual auditing.
### D. Path Explorer
What users get:
1. "Show me how A connects to B" style explanations.
2. Multiple candidate paths with confidence cues.
3. Better discovery across large note collections.
### E. Contradiction Watch
What users get:
1. Early warnings for conflicting statements.
2. Suggested reconciliation workflow.
3. Higher trust in the knowledge base.
### F. Priority Briefs
What users get:
1. Periodic "what matters now" graph summaries.
2. Focused recommendations, not noisy activity dumps.
3. Better focus for solo builders and small teams.
## User Personas and Why They Pay
### Solo Technical Founder
Pain:
Cannot hold full architecture and decision history in working memory.
Premium value:
Impact Radius + Decision Lineage prevent rework and regressions.
### Product/Research Lead
Pain:
Knowledge is fragmented across specs, notes, and decisions.
Premium value:
Path Explorer + Priority Briefs compress synthesis time.
### Consultant/Fractional Operator
Pain:
Frequent context switching across domains and clients.
Premium value:
Knowledge Health + Decision Lineage speed onboarding and reporting.
## Packaging Direction
Suggested packaging:
1. OSS Local: existing search + context tools.
2. Local+ Graph Intelligence: advanced graph insight features listed above.
3. Future Team Add-On: shared policies, shared graph health views, shared lineage views.
Core upsell line:
"Keep your local workflow. Add graph intelligence when complexity grows."
## Experience Principles
1. Explainability first.
Every advanced result should show "why this was suggested."
2. Actionability over novelty.
Insights should lead to concrete next steps, not abstract charts.
3. Progressive disclosure.
Start with concise summaries, expand on demand.
4. Deterministic where possible.
Users should trust repeated runs of the same workflow.
5. Respect local-first expectations.
No surprise cloud dependency in premium local mode.
## Success Criteria (Product)
1. Users can describe the benefit in one sentence:
"It shows me what matters and what breaks before I change things."
2. Premium users report lower time-to-understanding for complex topics.
3. Premium users report fewer "surprise side effects" after edits.
4. Premium users keep larger knowledge graphs healthy with less manual effort.
5. Feature adoption is driven by outcomes, not by curiosity-only usage.
## Risks and Mitigations
Risk: Feature sounds like "just better search."
Mitigation: Lead messaging with decision confidence and change safety, not traversal depth.
Risk: Feature feels too advanced for normal users.
Mitigation: Package as guided insights and reports, not as a query language.
Risk: Insight quality feels noisy.
Mitigation: Focus launch scope on high-precision insight types and transparent rationale.
Risk: Value is hard to prove.
Mitigation: Track user-facing outcomes (time saved, risk avoided, cleanup completed).
## Rollout Narrative
Phase 1: "Safer Changes"
1. Impact Radius
2. Decision Lineage
Phase 2: "Health and Clarity"
1. Knowledge Health Dashboard
2. Contradiction Watch
Phase 3: "Strategic Navigation"
1. Path Explorer
2. Priority Briefs
## One-Line Positioning Options
1. "Local notes, strategic intelligence."
2. "Know what changed, why it matters, and what it affects."
3. "From note-taking to decision support."
## Open Questions
1. Which two features best define the paid tier at launch?
2. Which insight types should be guaranteed deterministic in v1?
3. Should Priority Briefs be bundled or separate as an add-on?
4. What is the simplest in-product education flow for first-time premium users?
+7
View File
@@ -0,0 +1,7 @@
{
"$schema": "https://glama.ai/mcp/schemas/server.json",
"maintainers": [
"phernandez",
"groksrc"
]
}
+9
View File
@@ -0,0 +1,9 @@
[run]
source = .
omit =
tests/*
tests/**/*
[report]
fail_under = 85
show_missing = True
+48
View File
@@ -0,0 +1,48 @@
name: integration
# Heavier than test.yml — installs the real `basic-memory` CLI via uv, runs
# every bm_* tool against a live `bm mcp` subprocess. Catches BM-API drift
# (e.g., a bm release renaming a tool argument) before our users see it.
concurrency:
group: hbm-integration-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: [main]
workflow_dispatch:
jobs:
integration:
name: Integration tests (real bm + mcp)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Install uv
# No cache (no uv.lock to key off — see test.yml comment).
uses: astral-sh/setup-uv@v3
- name: Set up Python 3.12
# basic-memory itself requires 3.12+; the bm install needs that.
# Hermes-runtime compatibility (3.11) is covered by test.yml.
run: uv python install 3.12
- name: Install basic-memory CLI via uv
run: |
uv tool install basic-memory
# uv puts entry-point shims under ~/.local/bin
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Verify bm is on PATH
run: |
which bm
bm --version
- name: Run integration tests
env:
BM_INTEGRATION: "1"
run: |
uv run --with pytest --with mcp --python 3.12 pytest tests/test_integration.py -v
+36
View File
@@ -0,0 +1,36 @@
name: pr-title
on:
pull_request:
types: [opened, edited, synchronize]
jobs:
semantic-pr-title:
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
# Conventional-commit types we accept in PR titles + commit subjects.
types: |
feat
fix
chore
docs
style
refactor
perf
test
build
ci
# Single-file plugin — no real submodule structure. We don't require
# a scope, but if a contributor uses one we accept these:
scopes: |
core
tests
ci
docs
deps
requireScope: false
requireScopeForBreakingChange: true
+194
View File
@@ -0,0 +1,194 @@
name: release
# Manual trigger: Actions → release → Run workflow. Always runs against main
# (the workflow validates GITHUB_REF). Steps:
# 1. Compute the new version from the `version` input (patch/minor/major
# or explicit semver). The *current* version is read from the latest
# git tag (`v*.*.*`) — NOT from __init__.py. This is robust to PRs that
# pre-bump __init__.py: the bump always runs from the last released
# version, not from whatever the working files happen to say.
# 2. Update __version__ in __init__.py and version in plugin.yaml to match
# the new tag — bringing the files in sync if a PR pre-bumped them.
# 3. Commit as `chore(release): vX.Y.Z`, tag, push to main + push the tag.
# 4. Publish a GitHub Release. Body is the matching `## [X.Y.Z]` block from
# CHANGELOG.md when present; otherwise auto-generated release notes.
#
# Recommended flow: land a PR that adds a `## [X.Y.Z]` section to CHANGELOG.md
# first, then run this workflow with the matching version so the release notes
# are the hand-written changelog instead of commit-message-derived notes.
on:
workflow_dispatch:
inputs:
version:
description: "Version bump (`patch`, `minor`, `major`) or explicit semver (`0.3.0`)"
required: true
default: "patch"
permissions:
contents: write
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
release:
name: Tag and Publish GitHub Release
runs-on: ubuntu-latest
steps:
- name: Validate trigger is main
run: |
if [ "$GITHUB_REF" != "refs/heads/main" ]; then
echo "::error::release must run against main. Got $GITHUB_REF"
exit 1
fi
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Compute new version
id: bump
run: |
set -euo pipefail
VERSION_INPUT="${{ github.event.inputs.version }}"
# Current = latest released tag (sort by version, descending; pick
# the first v*.*.* tag). NOT __init__.py — a PR may have pre-bumped
# the version files, and we don't want to double-bump on top of
# that. The bump is computed from the last *released* version.
LAST_TAG=$(git tag --list 'v*.*.*' --sort=-v:refname | head -1 || true)
if [ -z "$LAST_TAG" ]; then
# First release in the repo. Seed with 0.0.0 so a `patch` bump
# yields v0.0.1, `minor` yields v0.1.0, `major` yields v1.0.0.
# Explicit semver inputs bypass the seed entirely.
CURRENT="0.0.0"
echo "No prior v*.*.* tag found; seeding current=0.0.0"
else
CURRENT="${LAST_TAG#v}"
echo "Latest released tag: $LAST_TAG (current=$CURRENT)"
fi
if [[ "$VERSION_INPUT" =~ ^(patch|minor|major)$ ]]; then
IFS=. read -r MAJ MIN PAT <<< "$CURRENT"
case "$VERSION_INPUT" in
major) MAJ=$((MAJ + 1)); MIN=0; PAT=0 ;;
minor) MIN=$((MIN + 1)); PAT=0 ;;
patch) PAT=$((PAT + 1)) ;;
esac
NEW="${MAJ}.${MIN}.${PAT}"
elif [[ "$VERSION_INPUT" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
NEW="$VERSION_INPUT"
else
echo "::error::Invalid version input: '$VERSION_INPUT'"
echo "::error::Use patch|minor|major or explicit X.Y.Z"
exit 1
fi
if [ "$NEW" = "$CURRENT" ]; then
echo "::error::New version equals last released ($NEW). Pick a different version."
exit 1
fi
# Sanity check: refuse if __init__.py is already ahead of the
# version we're about to ship. Catches the "PR bumped to 0.5.0 but
# workflow was asked for a patch that would land 0.1.8" foot-gun
# before it overwrites the files.
FILE_VERSION=$(grep -E '^__version__ = ' __init__.py \
| sed -E 's/^__version__ = "([^"]+)".*/\1/')
if [ -n "$FILE_VERSION" ] && [ "$FILE_VERSION" != "$CURRENT" ] && [ "$FILE_VERSION" != "$NEW" ]; then
echo "::error::__init__.py reports version $FILE_VERSION, but the release would ship $NEW (last tag: $CURRENT)."
echo "::error::Reconcile by picking a version input that matches __init__.py, or roll __init__.py back to $CURRENT."
exit 1
fi
echo "New version: $NEW"
echo "current=$CURRENT" >> "$GITHUB_OUTPUT"
echo "version=$NEW" >> "$GITHUB_OUTPUT"
echo "tag=v$NEW" >> "$GITHUB_OUTPUT"
- name: Refuse if tag already exists
run: |
set -euo pipefail
TAG="${{ steps.bump.outputs.tag }}"
if git rev-parse --verify "refs/tags/$TAG" >/dev/null 2>&1; then
echo "::error::Tag $TAG already exists locally. Pick a different version."
exit 1
fi
if git ls-remote --tags origin "$TAG" | grep -q "refs/tags/$TAG$"; then
echo "::error::Tag $TAG already exists on origin. Pick a different version."
exit 1
fi
- name: Update version files
run: |
set -euo pipefail
NEW="${{ steps.bump.outputs.version }}"
sed -i -E "s/^__version__ = \"[^\"]+\"/__version__ = \"${NEW}\"/" __init__.py
sed -i -E "s/^version: .*/version: ${NEW}/" plugin.yaml
# Verify both files changed and that the new version is present.
grep -q "^__version__ = \"${NEW}\"" __init__.py
grep -q "^version: ${NEW}$" plugin.yaml
echo "--- diff ---"
git --no-pager diff -- __init__.py plugin.yaml
- name: Configure Git identity
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Commit and tag
run: |
set -euo pipefail
TAG="${{ steps.bump.outputs.tag }}"
git add __init__.py plugin.yaml
if git diff --cached --quiet; then
# PR already bumped the files to the target version. Tag the
# existing HEAD rather than creating an empty release commit.
echo "Version files already at ${TAG}; tagging current HEAD."
else
git commit -m "chore(release): ${TAG}"
fi
git tag -a "${TAG}" -m "${TAG}"
- name: Push commit and tag
run: |
set -euo pipefail
# HEAD push is a no-op when nothing was committed in this run.
git push origin HEAD:main
git push origin "${{ steps.bump.outputs.tag }}"
- name: Extract CHANGELOG section for this version
id: changelog
run: |
set -euo pipefail
VERSION="${{ steps.bump.outputs.version }}"
# Pull lines between `## [VERSION]` and the next `## [` heading.
SECTION=$(awk -v ver="$VERSION" '
$0 ~ "^## \\[" ver "\\]" { found=1; next }
found && /^## \[/ { exit }
found { print }
' CHANGELOG.md)
if [ -z "$SECTION" ]; then
echo "::warning::No CHANGELOG.md section found for v${VERSION} — falling back to auto-generated release notes."
echo "has_section=false" >> "$GITHUB_OUTPUT"
else
echo "has_section=true" >> "$GITHUB_OUTPUT"
{
echo 'body<<EOF_CHANGELOG'
echo "$SECTION"
echo 'EOF_CHANGELOG'
} >> "$GITHUB_OUTPUT"
fi
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.bump.outputs.tag }}
name: ${{ steps.bump.outputs.tag }}
body: ${{ steps.changelog.outputs.body }}
generate_release_notes: ${{ steps.changelog.outputs.has_section == 'false' }}
token: ${{ secrets.GITHUB_TOKEN }}
+39
View File
@@ -0,0 +1,39 @@
name: tests
# Cancel an in-progress run when a new commit lands on the same branch — the
# latest result is the one we care about.
concurrency:
group: hbm-tests-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
# Branch pushes already cover PRs (the PR branch tip is what's being tested),
# so we don't run the matrix twice for the same commit on push + pull_request.
push:
jobs:
unit:
name: Unit tests (Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
# 3.11 is the runtime Hermes itself ships on today; 3.12-3.14 cover
# forward-compat for whenever Hermes upgrades.
python-version: ["3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v4
- name: Install uv
# No `enable-cache: true` — the action's default cache key globs for
# `uv.lock`, which we don't ship (we use `uv run --with` instead of
# `uv sync`). Without a lock file the cache step errors out.
uses: astral-sh/setup-uv@v3
- name: Set up Python ${{ matrix.python-version }}
run: uv python install ${{ matrix.python-version }}
- name: Run unit tests
run: uv run --with pytest --python ${{ matrix.python-version }} pytest -q
+6
View File
@@ -0,0 +1,6 @@
__pycache__/
*.pyc
.venv/
.DS_Store
.pytest_cache/
*.egg-info/
+133
View File
@@ -0,0 +1,133 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.3.2] — 2026-05-23
### Fixed
- **Let Basic Memory v0.21.3 self-route workspace-qualified identifiers and URLs.** Hermes no longer injects its configured default project into `bm_read`, `bm_edit`, `bm_delete`, `bm_move`, or `bm_context` calls when the identifier/URL is already workspace-qualified, such as `personal/main/...`, `memory://personal/main/...`, or an organization workspace slug with a 32-character hash suffix. This preserves Basic Memory Cloud's workspace-aware routing while keeping the existing default-project behavior for short/local identifiers.
## [0.3.1] — 2026-05-16
### Changed
- **Documented the Hermes Agent v0.14.0-compatible `/bm-*` slash-command monkeypatch.** `MONKEYPATCH.md` now distinguishes the plugin's runtime version from the Hermes Agent-side compatibility patch: plugin `v0.3.0` remains the correct runtime release for Hermes Agent `v0.13.x`, while Hermes Agent `v0.14.0` still needs the updated two-part core patch so gateway startup command discovery loads the active exclusive memory provider and the memory-provider collector delegates `register_command` / `register_skill`.
- **Clarified install guidance for users and agents.** The README known-issue section now points Hermes `v0.13.x` and `v0.14.0` users at the compatibility matrix in `MONKEYPATCH.md`, so agents do not mistake a plugin update for the required Hermes Agent core patch.
### Notes
- This is a documentation/compatibility-instructions release only. It does not change the plugin runtime code or Basic Memory data behavior. The plugin remains backward-compatible with Hermes Agent `v0.13.x`; the new documentation explains how to patch Hermes Agent `v0.14.0` until the upstream Hermes fix ships.
## [0.3.0] — 2026-05-12
### Added
- **Per-call project routing on every `bm_*` tool.** All eight tools now accept optional `project` (name) and `project_id` (UUID from `bm_projects`) parameters. The agent can write or read against a project other than the Hermes-configured one — useful when the user asks to write into a different cloud project (e.g. a personal `main` project) without reconfiguring the plugin. `project_id` takes precedence over `project`; both fall back to the configured default when omitted. Workspace routing is handled transparently by BM via `project_id` — no separate workspace parameter is needed.
- **`bm_projects` and `bm_workspaces` agent tools.** Promotes the discovery logic previously available only as `/bm-project` and `/bm-workspace` slash commands to agent-facing tools. `bm_projects` returns JSON with `name` and `external_id` (UUID) per project so the agent can hand the UUID to `bm_write` / `bm_read` / etc. via `project_id` — the unambiguous form across cloud workspaces. `bm_workspaces` lists BM Cloud workspaces (name, type, role, default flag). Together with per-call routing, these unblock the workflow Drew's friction note flagged: agent picks the right project + workspace before writing, instead of silently operating against the active Hermes memory project.
- **SKILL.md cross-project workflow** documenting the discovery → route → write → verify recipe end-to-end. Adds a "Permalinks" section covering the three canonical shapes (short, project-qualified, workspace-qualified) and the round-trip property where `bm_write`'s returned permalink self-routes for follow-up reads. A "Cross-project routing" section explains `project` (including workspace-qualified syntax like `"personal/main"`) vs `project_id` and when to use each. Also backfills `bm_recent` documentation (the tool shipped in 0.2.0 but the skill hadn't been updated).
- **SKILL.md "Further reading" section** linking to the official docs at [docs.basicmemory.com](https://docs.basicmemory.com), with raw-markdown URLs (`/raw/<path>.md`) the agent can `WebFetch` on demand for deeper material — knowledge format, observations & relations, memory URL wildcards, semantic search, cloud routing, BM's full MCP tool surface, and the `llms.txt` sitemap.
### Notes
- Addresses the routing, discovery, and documentation gaps in the real-world note "Hermes Basic Memory Cloud Task Experience." A proposed `bm_import` tool was evaluated and dropped — `read_file` + `bm_write` already composes the same operation with no new capability, at the cost of one more tool in the surface.
- The slash commands `/bm-project` and `/bm-workspace` still exist and behave identically — they continue to call `list_memory_projects` / `list_workspaces` directly via the actor. No behavior change for human use.
## [0.2.0] — 2026-05-11
### Added
- **Plugin-owned `/bm-*` slash commands** for CLI/gateway sessions. Eight commands give humans direct memory-graph access without going through the agent: `/bm-search`, `/bm-read`, `/bm-context`, `/bm-recent`, `/bm-status`, `/bm-remember`, `/bm-project`, `/bm-workspace`. Closes #2.
- **`bm_recent` tool** wrapping BM's `recent_activity`. Surfaces notes updated within a timeframe (`7d` default, accepts natural language like `"2 weeks"` or `"yesterday"`). Agent-facing and reused by `/bm-recent`.
- **`remember_folder` config key** (default `"bm-remember"`). Separate from `capture_folder` so manual captures via `/bm-remember` don't intermix with auto-generated session transcripts. Notes are tagged `manual-capture` for further disambiguation.
### Fixed
- **`ctx.register_skill(...)` was silently no-opping since 0.1.5** in real Hermes installs. Hermes loads memory-provider plugins through a stripped-down `_ProviderCollector` context (`plugins/memory/__init__.py`) that captures only `register_memory_provider`; `register_skill` and `register_command` are not delegated. The plugin now writes directly to `PluginManager._plugin_commands` and `_plugin_skills`, matching the entry shape and name normalization `PluginContext.register_command` / `register_skill` produce. This makes both the new slash commands and the bundled SKILL.md work in current Hermes installs. The clean fix lives upstream — a small patch to teach `_ProviderCollector` to delegate — and once that lands, the reach-in becomes a redundant double-write of identical entries. Forward-compat `ctx.register_command` / `ctx.register_skill` calls remain in place for the future code path.
### Notes
- `/bm-remember` derives the title from the first non-empty line of the input, trimmed to 80 chars; falls back to `Note YYYY-MM-DD HHMM UTC`.
- `/bm-workspace` short-circuits in local mode with a one-line explanation. Workspaces are a BM Cloud concept.
- Mid-session project/workspace switching is intentionally not supported in 0.2.0 — auto-capture would land in unexpected places. Tracked as a follow-up.
## [0.1.7] — 2026-05-10
### Changed
- **Stronger nudge in `system_prompt_block()`** to steer agents toward the `bm_*` tools instead of shelling out to `bm` CLI. Pre-v0.1.7 the prompt listed the tools neutrally; given Claude/Hermes models' heavy training-data exposure to `bm tool ...` CLI patterns, neutral language wasn't enough — agents reached for the shell by reflex, paying 1-2s of cold-start per call instead of ~0.1s through our persistent MCP connection. New prompt is explicit (**"Use the `bm_*` tools below directly — do not shell out to the `bm` CLI"**) and gives a one-line latency rationale so the model has a reason to follow it.
- `SKILL.md` mirrors the directive with a "Use `bm_*`, not the `bm` CLI" section + a tool-vs-CLI table.
### Added
- Regression test `test_system_prompt_block_steers_away_from_cli` locks in the directive language so future prompt edits don't accidentally weaken it.
## [0.1.6] — 2026-05-10
### Fixed
- **`bm_*` tools were never registered with Hermes's `MemoryManager._tool_to_provider`.** `get_tool_schemas()` was gated on `self._initialized`, but Hermes captures the schema list at *register* time — before `initialize()` runs. The gate caused every session to start with zero tools registered for our provider, so every LLM-issued `bm_search` (and friends) returned `"Unknown tool: bm_search"` from MemoryManager's dispatch. Symptoms were asymmetric: prefetch (recall injection) worked because it's invoked per-turn after init, but tool calls didn't. Schemas are static — they now return unconditionally, with `handle_tool_call()` doing the runtime "is the actor ready?" gate.
- Regression test pins this so we don't reintroduce it: `test_get_tool_schemas_unconditional` asserts `get_tool_schemas()` returns all 7 schemas on a fresh, uninitialized provider.
## [0.1.5] — 2026-05-10
### Added
- Bundled `SKILL.md` is now auto-registered via `ctx.register_skill("basic-memory", ...)` during plugin load. No more manual symlink to `~/.hermes/skills/`. The skill is opt-in (resolvable via `skill:view basic-memory:basic-memory`); always-on agent guidance still flows through `system_prompt_block()`.
### Changed
- README rewritten for community install. Lead command is now `hermes plugins install basicmachines-co/hermes-basic-memory`. Clone-and-symlink instructions moved to the Development section.
- Added GitHub Actions CI: unit tests on push and PR.
- Added this CHANGELOG.
## [0.1.4] — 2026-05-10
### Added
- `_uv_binary_path()` and `_install_bm_via_uv()`. When `bm` is missing from the host, the plugin runs `uv tool install basic-memory --quiet` once at first `initialize()`. The bm binary lands at `~/.local/bin/bm` — the same canonical path a manual `uv tool install basic-memory` produces, so subsequent manual installs are no-ops rather than creating a second install.
- 8 new unit tests covering `is_available()` with bm/uv combinations, the install subprocess (success / non-zero exit / OSError / no-uv), and `initialize()` install-or-not branching.
### Changed
- `is_available()` now returns `True` when **either** `bm` is on disk **or** `uv` is on disk (we can install the missing CLI ourselves).
- README's prerequisites section: dropped manual basic-memory install requirement; added the one-time ~10s cold-start note.
## [0.1.3] — 2026-05-10
### Fixed
- README's cloud-mode section described the wrong setup (`bm project add ... --cloud --local-path` + `bm cloud bisync`), which gives a local-mode project with file-level cloud sync rather than true cloud routing. Replaced with `bm project set-cloud <name> --workspace <name>`, which flips the project to `ProjectMode.CLOUD` so tool calls route over HTTPS to `<cloud_host>/proxy` directly. No local files involved.
- Documented OAuth / API-key auth options, and the `--workspace` requirement when the user belongs to multiple BM Cloud workspaces.
## [0.1.2] — 2026-05-10
### Changed
- `_default_project()`: `"hermes-memory"` (was `"hermes-{hostname}"`).
- `_default_project_path()`: `~/hermes-memory/` (was `~/.basic-memory/hermes/`). The previous path violated the principle that `~/.basic-memory/` is reserved for BM's app state, not project storage.
### Added
- `_bm_known_projects()` reads bm's `~/.basic-memory/config.json`. `BasicMemoryProvider._verify_project_registered()` uses it to refuse initialization when `mode: cloud` is set against a project that isn't registered with bm. Local mode still auto-creates as before.
- 13 new unit tests for the introspection + bail-out paths.
## [0.1.1] — 2026-05-10
### Added
- `tests/test_actor.py` — 15 tests covering `_BmMcpActor` lifecycle, call dispatch, timeout-with-cancellation, idempotent shutdown.
- `tests/test_capture.py` — 25 tests for `sync_turn` (first-write + append paths), `on_session_end` summary shape, and gating.
- `tests/test_prefetch.py` — 25 tests for `prefetch` / `queue_prefetch` / `_format_prefetch` including forward-compat with unknown response fields.
- `tests/test_integration.py` — 12 gated tests exercising every tool against a real `bm` MCP server (`BM_INTEGRATION=1` + `bm` + `mcp`). Each session uses a throwaway BM project that's torn down on completion.
### Changed
- `_BmMcpActor.call` now refuses calls after `shutdown()` (sets `_running=False`) and cancels the underlying coroutine on timeout instead of leaking it.
- `_format_prefetch` defensively coerces non-string fields and skips non-dict entries.
- Added module-level `__version__`, kept in sync with `plugin.yaml` (verified by a test).
## [0.1.0] — 2026-05-10
### Added
- Initial release of the Hermes Memory Provider plugin for Basic Memory.
- Seven `bm_*` agent tools: `bm_search`, `bm_read`, `bm_write`, `bm_edit`, `bm_context`, `bm_delete`, `bm_move`.
- Per-turn capture (`sync_turn`) and end-of-session summary (`on_session_end`).
- Local mode (default) with auto-created BM project; cloud mode with project-name-based routing.
- Single-file plugin at `__init__.py`, AGPL-3.0-or-later.
- 84-test pytest suite.
[0.3.2]: https://github.com/basicmachines-co/hermes-basic-memory/releases/tag/v0.3.2
[0.3.1]: https://github.com/basicmachines-co/hermes-basic-memory/releases/tag/v0.3.1
[0.3.0]: https://github.com/basicmachines-co/hermes-basic-memory/releases/tag/v0.3.0
[0.2.0]: https://github.com/basicmachines-co/hermes-basic-memory/releases/tag/v0.2.0
[0.1.7]: https://github.com/basicmachines-co/hermes-basic-memory/releases/tag/v0.1.7
[0.1.6]: https://github.com/basicmachines-co/hermes-basic-memory/releases/tag/v0.1.6
[0.1.5]: https://github.com/basicmachines-co/hermes-basic-memory/releases/tag/v0.1.5
[0.1.4]: https://github.com/basicmachines-co/hermes-basic-memory/releases/tag/v0.1.4
[0.1.3]: https://github.com/basicmachines-co/hermes-basic-memory/releases/tag/v0.1.3
[0.1.2]: https://github.com/basicmachines-co/hermes-basic-memory/releases/tag/v0.1.2
[0.1.1]: https://github.com/basicmachines-co/hermes-basic-memory/releases/tag/v0.1.1
[0.1.0]: https://github.com/basicmachines-co/hermes-basic-memory/releases/tag/v0.1.0
+661
View File
@@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
+269
View File
@@ -0,0 +1,269 @@
# Hermes slash-command monkeypatch
This document describes the Hermes Agent-side patch needed when `/bm-*` slash commands do not appear in the gateway even though the Basic Memory provider itself is installed and working.
This issue is tracked upstream in https://github.com/NousResearch/hermes-agent/issues/23603
Agents may use this file as a guide to add support for `/bm-*` slash commands in Hermes Agent builds that do not yet include the upstream fix.
## Current status and compatibility
This is a **Hermes Agent-side** compatibility patch, not a Basic Memory plugin runtime patch.
| Hermes Agent version | Basic Memory plugin version | Guidance |
|---|---:|---|
| `v0.13.x` | `v0.3.0` | Plugin `v0.3.0` remains the right runtime release. If `/bm-*` commands are missing, use the Hermes Agent-side monkeypatch below or wait for the upstream Hermes fix. |
| `v0.14.0` / `v2026.5.16` | `v0.3.1` docs, runtime still equivalent to `v0.3.0` | The plugin runtime still works, but Hermes Agent `v0.14.0` still does **not** include the upstream slash-command discovery fix. Use the v0.14.0-compatible Hermes Agent-side patch below. |
| Future Hermes release with upstream fix | Latest plugin | Do **not** apply this monkeypatch unless `/bm-*` commands are still absent; the fix should be redundant once Hermes loads active exclusive memory-provider commands during command discovery. |
Checked against Hermes Agent `v2026.5.16` / `v0.14.0` on 2026-05-16: the upstream Hermes release still does **not** include this fix. After applying the Hermes Agent-side patch below locally, `get_plugin_commands()` returns the expected `/bm-*` commands.
Important nuance: recent `hermes-basic-memory` versions include a best-effort PluginManager reach-in that registers commands when the provider is loaded. That workaround alone is not enough for gateway startup discovery in affected Hermes builds, because `get_plugin_commands()` does not load the active exclusive memory provider. The Hermes Agent-side patch is still needed until upstream command discovery loads the active memory provider and the memory-provider collector delegates command/skill registration.
Release/tagging note for agents: `v0.3.1` is a documentation release that clarifies Hermes Agent `v0.14.0` compatibility instructions. It does not require users on Hermes Agent `v0.13.x` to change plugin runtime behavior, and it should not be interpreted as a Basic Memory data/schema migration.
## Problem
`hermes-basic-memory` is an **exclusive memory-provider plugin**. Hermes loads exclusive memory providers through `plugins.memory`, not through the normal `PluginManager` discovery path.
Gateway adapters register native slash commands during startup by calling Hermes's plugin command discovery APIs. In affected Hermes builds, that startup path only sees commands registered by normal plugins. The active memory provider has not been loaded yet, and the memory-provider loader uses a collector that captures only `register_memory_provider(...)`. As a result, commands registered by this plugin with `ctx.register_command(...)` never reach the central plugin command registry before Discord/native slash-command sync.
Symptoms:
- `hermes memory status` shows `Provider: basic-memory` and `Status: available`.
- Agent tools such as `bm_search`, `bm_read`, and `bm_recent` work.
- Native slash commands such as `/bm-search`, `/bm-read`, and `/bm-context` are missing after `hermes gateway restart`.
## Target behavior
When Hermes builds its plugin command list, it should also load the configured active memory provider once, allowing that provider to register commands and skills into the same central registries used by ordinary plugins.
After the patch, `get_plugin_commands()` should include commands such as:
```text
bm-context
bm-project
bm-read
bm-recent
bm-remember
bm-search
bm-status
bm-workspace
```
## Files to patch in Hermes Agent
Patch these files in the Hermes Agent repository, not in this plugin repository:
```text
hermes_cli/plugins.py
plugins/memory/__init__.py
```
Recommended tests to add/update in Hermes Agent:
```text
tests/hermes_cli/test_plugin_cli_registration.py
tests/hermes_cli/test_plugins.py
```
## Implementation outline
### 1. Load active memory-provider commands from `get_plugin_commands()`
In `hermes_cli/plugins.py`, add module-level idempotency/recursion guards near the global plugin manager:
```python
_plugin_manager: Optional[PluginManager] = None
_memory_provider_command_loads: set[str] = set()
_memory_provider_command_loading = False
```
Update `get_plugin_commands()` so it first ensures normal plugin discovery, then best-effort loads the active memory provider before returning the command registry:
```python
def get_plugin_commands() -> Dict[str, dict]:
"""Return the full plugin commands dict (name → {handler, description, plugin}).
Triggers idempotent plugin discovery so callers can use plugin commands
before any explicit discover_plugins() call. Also initializes the active
memory provider once so exclusive memory-provider plugins can contribute
gateway slash commands during startup discovery.
"""
manager = _ensure_plugins_discovered()
_ensure_active_memory_provider_commands_loaded()
return manager._plugin_commands
```
Add the helper:
```python
def _ensure_active_memory_provider_commands_loaded() -> None:
"""Best-effort load of the active memory provider's slash commands."""
global _memory_provider_command_loading
if _memory_provider_command_loading:
return
try:
from plugins import memory as memory_plugins
active = memory_plugins._get_active_memory_provider()
if not active or active in _memory_provider_command_loads:
return
_memory_provider_command_loading = True
try:
memory_plugins.load_memory_provider(active)
_memory_provider_command_loads.add(active)
finally:
_memory_provider_command_loading = False
except Exception as exc:
logger.debug(
"Failed to load active memory-provider plugin commands: %s",
exc,
exc_info=_PLUGINS_DEBUG,
)
```
Notes:
- This must be best-effort; command discovery should not break Hermes startup if a memory provider is misconfigured.
- The recursion guard prevents `load_memory_provider(...)` → provider `register(...)``ctx.register_command(...)` → plugin manager access from re-entering endlessly.
- The load set prevents duplicate provider command registration work.
### 2. Make the memory-provider collector delegate commands and skills
In `plugins/memory/__init__.py`, import `Callable`:
```python
from typing import Callable, List, Optional, Tuple
```
When loading a provider directory, pass the plugin/provider name to the collector:
```python
collector = _ProviderCollector(plugin_name=name)
```
Replace the collector that only captures `register_memory_provider(...)` with a plugin-context shim that also delegates `register_command(...)` and `register_skill(...)` into the central `PluginManager` registries:
```python
class _ProviderCollector:
"""Plugin-context shim used while loading memory providers.
Memory providers are exclusive plugins and are loaded by this module
instead of the general PluginManager. They still need access to the same
slash-command and skill registries as normal plugins, otherwise active
memory-provider commands are invisible during gateway startup discovery.
"""
def __init__(self, plugin_name: str = "memory-provider"):
self.provider = None
self.plugin_name = plugin_name
def register_memory_provider(self, provider):
self.provider = provider
def register_command(
self,
name: str,
handler: Callable,
description: str = "",
args_hint: str = "",
) -> None:
"""Register a memory-provider slash command with PluginManager."""
try:
from hermes_cli.plugins import _ensure_plugins_discovered
except Exception:
return
clean = name.lower().strip().lstrip("/").replace(" ", "-")
if not clean:
return
try:
manager = _ensure_plugins_discovered()
except Exception:
return
plugin_commands = getattr(manager, "_plugin_commands", None)
if plugin_commands is None:
return
plugin_commands[clean] = {
"handler": handler,
"description": description or "Plugin command",
"plugin": self.plugin_name,
"args_hint": (args_hint or "").strip(),
}
def register_skill(
self,
name: str,
path: Path,
description: str = "",
) -> None:
"""Register a memory-provider skill with PluginManager."""
try:
from agent.skill_utils import _NAMESPACE_RE
from hermes_cli.plugins import _ensure_plugins_discovered
except Exception:
return
if ":" in name or not name or not _NAMESPACE_RE.match(name):
raise ValueError(f"Invalid skill name '{name}'.")
if not path.exists():
raise FileNotFoundError(f"SKILL.md not found at {path}")
try:
manager = _ensure_plugins_discovered()
except Exception:
return
plugin_skills = getattr(manager, "_plugin_skills", None)
if plugin_skills is None:
return
plugin_skills[f"{self.plugin_name}:{name}"] = {
"path": path,
"plugin": self.plugin_name,
"bare_name": name,
"description": description,
}
```
Keep existing no-op methods such as `register_tool(...)` and `register_cli_command(...)` as no-ops unless the target Hermes version expects otherwise.
## Verification
From the Hermes Agent repository, run focused compile/tests:
```bash
python -m py_compile hermes_cli/plugins.py plugins/memory/__init__.py
python -m pytest \
tests/hermes_cli/test_plugins.py::TestPluginCommands::test_get_plugin_commands_loads_active_memory_provider_commands \
tests/hermes_cli/test_plugin_cli_registration.py::TestProviderCollectorRegistration \
-q -o 'addopts='
```
Then verify the active config sees the Basic Memory commands:
```bash
python - <<'PY'
import hermes_cli.plugins as p
p._plugin_manager = None
p._memory_provider_command_loads.clear()
cmds = p.get_plugin_commands()
print(sorted(k for k in cmds if k.startswith('bm-')))
PY
```
Expected output:
```text
['bm-context', 'bm-project', 'bm-read', 'bm-recent', 'bm-remember', 'bm-search', 'bm-status', 'bm-workspace']
```
Finally restart the gateway so native slash commands are synced:
```bash
hermes gateway restart
```
For Discord, global command propagation can lag briefly. If the commands do not show immediately, type `/bm` directly or reload the Discord client.
+235
View File
@@ -0,0 +1,235 @@
# hermes-basic-memory
[![License: AGPL v3](https://img.shields.io/badge/License-AGPL_v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0)
Hermes Memory Provider plugin that gives [Hermes Agent](https://github.com/NousResearch/hermes-agent) a persistent knowledge graph backed by [Basic Memory](https://github.com/basicmachines-co/basic-memory).
The plugin replaces Hermes's "no external memory provider" with a real graph: search-before-answer recall, per-turn capture, end-of-session summaries, and ten `bm_*` tools the agent can call directly. Local mode by default; one CLI flip switches to true cloud routing through Basic Memory Cloud.
## Install
```bash
hermes plugins install basicmachines-co/basic-memory --path integrations/hermes
```
Then activate it in `~/.hermes/config.yaml`:
```yaml
memory:
provider: basic-memory
```
If you run the gateway, restart it (`hermes gateway restart`). Done.
If your installed Hermes build does not support `--path`, use the final deprecated `basicmachines-co/hermes-basic-memory` pointer release until Hermes subpath installs are available. Ongoing development now lives in [`basic-memory/integrations/hermes`](https://github.com/basicmachines-co/basic-memory/tree/main/integrations/hermes).
The plugin self-installs the `basic-memory` CLI on first init via `uv tool install basic-memory` (one-time ~10s pause if it isn't already present). The bm binary lands at `~/.local/bin/bm` — the same location a manual `uv tool install basic-memory` would produce, so a later manual install or upgrade is a no-op rather than a second install.
### Prerequisites
- [Hermes Agent](https://github.com/NousResearch/hermes-agent)
- [`uv`](https://docs.astral.sh/uv/) on PATH (used for the bootstrap install)
- The `mcp` Python package in the Hermes venv. If `hermes plugins install` doesn't auto-install it (it follows `pip_dependencies` in `plugin.yaml`), run:
```bash
uv pip install --python ~/.hermes/hermes-agent/venv/bin/python mcp
```
### Verify
```bash
hermes memory status
```
Expected:
```
Provider: basic-memory
Plugin: installed ✓
Status: available ✓
```
## What the agent gets
Ten tools (curated subset of Basic Memory's MCP surface):
| Tool | Use |
|---|---|
| `bm_search` | Semantic + full-text search; **call this before answering** |
| `bm_read` | Fetch a note by title, permalink, or `memory://` URL |
| `bm_write` | Create a new note (capture decisions, meeting notes, insights) |
| `bm_edit` | Append, prepend, find/replace, replace-section |
| `bm_context` | Navigate via `memory://` URLs to find related notes |
| `bm_delete` | Delete a note |
| `bm_move` | Move a note to a different folder |
| `bm_recent` | List notes updated recently (default `7d`; accepts natural-language timeframes) |
| `bm_projects` | List available projects with their UUIDs (for cross-project routing) |
| `bm_workspaces` | List Basic Memory Cloud workspaces |
Every read/write tool also accepts optional `project` / `project_id` for per-call routing — write or read against a project other than the configured one without reconfiguring the plugin.
Plus automatic capture:
- **Per turn**: every user/assistant exchange appends to a running session-transcript note
- **End of session**: a separate summary note is written, linked back to the transcript via a `summary_of` relation
A bundled skill (`skill:view basic-memory:basic-memory`) gives the agent a longer reference doc on top of the always-on `system_prompt_block`.
## Slash commands
For direct, in-session use without going through the agent (requires Hermes ≥ v0.11.0):
| Command | Use |
|---|---|
| `/bm-search <query>` | Search the knowledge graph; returns compact title/permalink/preview rows. |
| `/bm-read <identifier>` | Read a note by title, permalink, or `memory://` URL. |
| `/bm-context <identifier>` | Build context for a note (target + related). |
| `/bm-recent [timeframe]` | Recently updated notes. Default `7d`; accepts `"2 weeks"`, `"yesterday"`, etc. |
| `/bm-status` | Plugin/provider state: mode, project, capture flags, bm CLI path. |
| `/bm-remember <text>` | Capture a quick note. Title = first line (≤80 chars), folder = `remember_folder` (default `bm-remember`), tagged `manual-capture`. |
| `/bm-project` | List all known projects; the active one is marked. |
| `/bm-workspace` | List BM Cloud workspaces. Cloud mode only — prints an explanatory line in local mode. |
Examples:
```text
/bm-search Q3 OKRs
/bm-read decisions/auth-rewrite
/bm-recent yesterday
/bm-remember Reminder: switch the staging job to the new image after the rebase lands.
```
`/bm-project` and `/bm-workspace` are read-only in 0.2.0 — mid-session switching is intentionally not supported because auto-capture would otherwise land in the wrong place. Tracked as a follow-up.
### Known issue: `/bm-*` commands may not appear in some Hermes gateway builds
Plugin v0.2.0 and later register the commands above, but some Hermes Agent gateway builds do not discover slash commands contributed by an **exclusive memory-provider plugin** during startup. The symptoms are:
- the memory tools work for the agent (`bm_search`, `bm_read`, etc.);
- `hermes memory status` shows `Provider: basic-memory` and `Status: available`; but
- Discord/native slash command pickers do not show `/bm-search`, `/bm-read`, `/bm-context`, and the other `/bm-*` commands after `hermes gateway restart`.
This is a Hermes Agent plugin-discovery issue, not a Basic Memory runtime issue. It is tracked upstream in [NousResearch/hermes-agent#23603](https://github.com/NousResearch/hermes-agent/issues/23603). Updating the Basic Memory plugin alone cannot fix affected gateway startup discovery; Hermes Agent itself must include or receive the compatibility patch. Until the upstream Hermes fix is available in your installed Hermes version, use one of these workarounds:
1. apply the Hermes Agent-side patch described in [MONKEYPATCH.md](MONKEYPATCH.md), which includes compatibility notes for Hermes Agent v0.13.x and v0.14.0; or
2. use the agent tools directly (`bm_search`, `bm_read`, `bm_recent`, etc.) instead of native slash commands.
After applying an updated or patched Hermes build, restart the gateway so Discord/native slash commands are re-synced:
```bash
hermes gateway restart
```
If Discord still does not show the commands immediately, type `/bm` directly or reload the Discord client; global command propagation can lag briefly.
## Configuration
Defaults are reasonable for local use:
| Key | Default | Notes |
|---|---|---|
| `mode` | `local` | `local` (in-process) or `cloud` (route through BM Cloud API) |
| `project` | `hermes-memory` | BM project name |
| `project_path` | `~/hermes-memory/` | Local mode only — where session notes land |
| `capture_folder` | `hermes-sessions` | Folder within the project for session notes |
| `capture_per_turn` | `true` | Append every turn to a session transcript |
| `capture_session_end` | `true` | Write a summary note when the session ends |
| `remember_folder` | `bm-remember` | Folder where `/bm-remember` captures land (kept separate from session transcripts) |
To override, write `~/.hermes/basic-memory.json` or run `hermes memory setup basic-memory`:
```json
{
"mode": "local",
"project": "hermes-memory",
"project_path": "~/hermes-memory/",
"capture_per_turn": true,
"capture_session_end": true,
"capture_folder": "hermes-sessions",
"remember_folder": "bm-remember"
}
```
In local mode the plugin auto-creates the BM project on first init via `bm project add`. In cloud mode it doesn't — you create the cloud-routed project yourself (see below) and the plugin verifies it's registered before initializing.
### Cloud mode
When `mode: cloud`, tool calls route directly through the BM cloud API — no local file mirror, no bisync. You set this up once with the BM CLI:
```bash
# Authenticate (OAuth) or save an API key
bm cloud login # OAuth — interactive
# OR for headless/automation:
bm cloud create-key "hermes"
bm cloud set-key bmc_...
# Create the project, then flip it to cloud routing.
# --workspace is required if you belong to more than one workspace
# (otherwise BM auto-resolves the only one available).
bm project add hermes-memory-cloud
bm project set-cloud hermes-memory-cloud --workspace Personal
# Point the plugin at it
cat > ~/.hermes/basic-memory.json <<EOF
{
"mode": "cloud",
"project": "hermes-memory-cloud",
"capture_per_turn": true,
"capture_session_end": true,
"capture_folder": "hermes-sessions"
}
EOF
hermes gateway restart
```
Tool calls now route from `bm mcp``<cloud_host>/proxy` over HTTPS using your OAuth token (or API key). Notes never touch local disk.
**Don't confuse cloud mode with `bm cloud bisync`.** Bisync is rclone-style two-way file sync between a *local* project and cloud storage, intended for keeping local working copies. For agent-driven capture you want true cloud routing (`set-cloud`), not bisync.
## Updating / removing
```bash
hermes plugins update basic-memory
hermes plugins remove basic-memory # then revert memory.provider in config.yaml
```
## Foot-guns
- **`<memory-context>` tags in notes**: Hermes's streaming output scrubber strips literal `<memory-context>...</memory-context>` blocks from assistant text. If a note contains those tags and the assistant echoes the body verbatim, the echoed copy gets eaten mid-stream. Tool results inbound are unaffected. Avoid those tags in BM notes; if you must include them, fence in a code block.
- **Single external provider**: Hermes accepts only one external memory provider at a time. Activating basic-memory displaces any other.
- **CLI cold start**: `hermes -z ...` invocations spawn `bm mcp` per run (~2-5s). Long-running gateway sessions amortize this.
- **Multiple cloud workspaces**: if your BM Cloud account belongs to more than one workspace, `bm project set-cloud` must be invoked with `--workspace <name>`. Otherwise tool calls fail with "Multiple workspaces are available".
## Development
The plugin is a single-file Python module at `__init__.py`. The Hermes plugin loader expects `register(ctx)` and grep-detects either `register_memory_provider` or `MemoryProvider` in the file.
For local development (point Hermes at your working tree instead of going through `hermes plugins install`):
```bash
git clone https://github.com/basicmachines-co/basic-memory ~/code/basic-memory
mkdir -p ~/.hermes/plugins
ln -snf ~/code/basic-memory/integrations/hermes ~/.hermes/plugins/basic-memory
```
### Running tests
```bash
# From the monorepo root
just package-check-hermes
# Or from integrations/hermes
just check
# Unit tests (fast, hermetic — no Hermes or bm required)
uv run --with pytest pytest
# Integration tests (gated — exercise every tool against a real bm MCP server)
BM_INTEGRATION=1 uv run --with pytest --with mcp pytest tests/test_integration.py
```
The unit suite stubs out Hermes-internal imports (`agent.memory_provider`, `tools.registry`) so it runs without a Hermes install. `mcp` is optional at unit-test time — its absence just makes `is_available()` return False, which the tests verify.
Integration tests require `BM_INTEGRATION=1`, `bm` CLI on PATH, and `mcp` Python package importable. Each session creates a unique throwaway BM project (under `tempfile.mkdtemp`) and removes it on teardown, so they never touch your real BM projects.
## License
AGPL-3.0-or-later, matching [basic-memory](https://github.com/basicmachines-co/basic-memory). See [LICENSE](LICENSE).
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
# Basic Memory Hermes plugin checks
repo_root := "../.."
# Validate plugin.yaml, module entrypoint, bundled skill, and test layout.
manifest-check:
python3 {{repo_root}}/scripts/validate_hermes_plugin.py .
# Unit tests are hermetic and do not require a Hermes install.
test:
uv run --no-project --with pytest --with pytest-cov --python 3.12 pytest -q --cov=. --cov-report=term-missing
# Gated integration test against a real bm MCP server.
test-int:
BM_INTEGRATION=1 uv run --no-project --with pytest --with mcp --python 3.12 pytest tests/test_integration.py -q
# Full local check.
check: manifest-check test
# Show available recipes
default:
@just --list
+11
View File
@@ -0,0 +1,11 @@
name: basic-memory
version: 0.22.0
description: "Basic Memory — persistent knowledge graph backed by the basic-memory MCP server"
pip_dependencies:
- mcp
hooks:
- prefetch
- queue_prefetch
- sync_turn
- on_session_end
- shutdown
+8
View File
@@ -0,0 +1,8 @@
[pytest]
testpaths = tests
addopts = -ra
pythonpath =
.
tests/stubs
filterwarnings =
ignore::DeprecationWarning
+1
View File
@@ -0,0 +1 @@
pytest>=7.0
+242
View File
@@ -0,0 +1,242 @@
---
name: basic-memory
description: Use the Basic Memory knowledge graph for persistent memory across sessions. Search before answering; capture decisions, meetings, and insights as notes.
category: memory
---
# Basic Memory Knowledge Graph
You have access to a persistent knowledge graph backed by Basic Memory. The graph survives across sessions and is shared with other tools (Claude Desktop, Obsidian, the `bm` CLI). Use the `bm_*` tools below to recall and capture information.
## Use `bm_*`, not the `bm` CLI
**Always invoke the `bm_*` tools directly. Do not shell out to the `bm` CLI for note operations.**
The `bm_*` tools route through a persistent MCP connection — roughly 0.1 seconds per call. Running `bm` from the shell spawns a fresh Python process per call (1-2 seconds of cold-start every time) and bypasses Hermes's automatic per-turn capture, so the session-transcript and summary notes won't reflect what you did.
The CLI is fine when you genuinely need a feature these wrappers don't expose (rare). Otherwise, prefer:
| Use case | Tool (not CLI) |
|---|---|
| Search the graph | `bm_search` |
| Read a note | `bm_read` |
| Create / update a note | `bm_write` / `bm_edit` |
| Navigate relations | `bm_context` |
| Move / delete | `bm_move` / `bm_delete` |
| What's been touched lately | `bm_recent` |
| List available projects | `bm_projects` |
| List cloud workspaces | `bm_workspaces` |
## Tool reference
### `bm_search` — search the graph
Use **before** answering questions about prior decisions, projects, meetings, or anything that might already be documented.
```
bm_search({ query: "auth strategy decision", limit: 5 })
```
### `bm_read` — fetch a note's full content
After search shows a relevant note, read it for context.
```
bm_read({ identifier: "decisions/auth-strategy" })
bm_read({ identifier: "memory://projects/api-redesign" })
```
### `bm_context` — navigate via memory:// URLs
Returns the target note plus related notes via traversed relations.
```
bm_context({ url: "memory://projects/api-redesign", depth: 1 })
```
### `bm_write` — capture new knowledge
When the user shares a decision, meeting outcome, or insight worth keeping, capture it. Use clear titles and a folder.
```
bm_write({
title: "API Authentication Decision",
folder: "decisions",
content: "# API Authentication\n\n## Context\n...\n\n## Decision\n..."
})
```
Recommended folders: `projects/`, `decisions/`, `meetings/`, `concepts/`, `weekly/`.
### `bm_edit` — incremental updates
Operations: `append`, `prepend`, `find_replace` (requires `find_text`), `replace_section` (requires `section`).
```
bm_edit({
identifier: "projects/api-redesign",
operation: "append",
content: "\n## Update 2026-05-09\nDeployed to staging."
})
```
### `bm_delete` / `bm_move` — maintenance
Use sparingly. `bm_move` takes `new_folder`.
### `bm_recent` — what's been touched lately
Returns notes updated within a window. Use when there's no specific query yet — e.g. "what was I working on yesterday?"
```
bm_recent({ timeframe: "7d" })
bm_recent({ timeframe: "yesterday", limit: 20 })
bm_recent({ timeframe: "2 weeks", type: "entity" })
```
`timeframe` accepts natural language (`"yesterday"`, `"2 weeks"`, `"last month"`) or compact forms (`"7d"`, `"24h"`). Default is `7d`.
### `bm_projects` — list available projects
Returns name, workspace slug, and `external_id` (UUID) per project across local and cloud. Call this when the user names a project that isn't the active one. Route follow-up tool calls either by workspace-qualified name (`project: "personal/main"`) or by UUID (`project_id: "bf2a4c1e-d77f-..."`) — see Cross-project routing below.
```
bm_projects()
```
### `bm_workspaces` — list BM Cloud workspaces
Workspaces are a BM Cloud concept. Returns name, type, role, and default flag. Pair with `bm_projects` when the same project name might exist in more than one workspace and you need to disambiguate.
```
bm_workspaces()
```
## Permalinks
A permalink is the canonical, URL-friendly identifier for a note. Three shapes exist; the read/write tools accept all of them:
| Shape | Example | When |
|---|---|---|
| **Short** | `decisions/auth-strategy` | Bare `folder/note-slug`. Tools need a `project` (or `project_id`) arg to route — the permalink alone isn't enough. |
| **Project-qualified** | `main/decisions/auth-strategy` | `project-name/folder/note-slug`. Carries enough context to route without a separate `project` arg. |
| **Workspace-qualified** | `personal/main/decisions/auth-strategy` | `workspace-slug/project-name/folder/note-slug`. Fully routes, including across cloud workspaces with same-named projects. |
**Important: the permalink returned by `bm_write` already encodes the routing it needs for follow-up reads.** If you wrote with `project="personal/main"`, you get back `personal/main/folder/note-slug` and can call `bm_read({ identifier: <that permalink> })` with no `project` arg. The permalink self-routes.
`memory://` URLs follow the same shapes: `memory://personal/main/decisions/auth-strategy` is valid. The `memory://` prefix is optional for `bm_read` (any of the three permalink shapes works directly); `bm_context` expects the prefix.
## Cross-project routing
Every read/write tool (`bm_search`, `bm_read`, `bm_write`, `bm_edit`, `bm_context`, `bm_delete`, `bm_move`, `bm_recent`) accepts optional `project` and `project_id`:
- `project` — project name, optionally workspace-qualified. Plain (`"main"`) when the name is globally unique; qualified (`"personal/main"`, `"team-paul/research"`) when you need to pick a specific cloud workspace by slug.
- `project_id` — UUID from `bm_projects` (`external_id` field). The most stable identifier — survives project renames and works across workspaces without qualification. Wins over `project` if both are passed.
Omit both and the call uses the Hermes-configured active project.
```
# Plain project name (unique)
bm_write({ title: "...", folder: "...", content: "...", project: "main" })
# Workspace-qualified name (disambiguates same-named projects across workspaces)
bm_write({ title: "...", folder: "...", content: "...", project: "personal/main" })
# UUID (most stable, survives renames)
bm_write({ title: "...", folder: "...", content: "...", project_id: "bf2a4c1e-d77f-..." })
```
`bm_projects` and `bm_workspaces` themselves do **not** take routing — they list across everything.
## Recipe: writing an existing file into a specific project
When the user asks something like *"save this markdown file to my personal `main` project, return the permalink"*:
1. **Discover the project.** Call `bm_projects()` and find the entry matching the user's described project + workspace. You can route by either the workspace-qualified name (`personal/main`) or the UUID (`external_id`).
```
bm_projects()
# → [{name: "main", external_id: "bf2a4c1e-d77f-4b7a-9c3e-5d8a1f0e2b6d", workspace: "Personal", ...}, ...]
```
If a project name appears in multiple workspaces, use `bm_workspaces()` to confirm which slug you want.
2. **Read the file from disk.** Use Hermes's filesystem tool (not a `bm_*` tool — local files aren't in the graph yet).
3. **Write the note with explicit routing.** Either form works; the workspace-qualified name reads cleaner in logs, the UUID is more durable.
```
bm_write({
title: "StartWithDrew Level 9 Task Queue",
folder: "startwithdrew",
content: <file body>,
project: "personal/main"
})
# → returns "personal/main/startwithdrew/start-with-drew-level-9-task-queue"
# (the returned permalink is workspace-qualified — carries its own routing)
```
4. **Verify by reading back.** No `project` arg needed — the workspace-qualified permalink routes itself.
```
bm_read({ identifier: "personal/main/startwithdrew/start-with-drew-level-9-task-queue" })
```
Return the permalink (and the project name for clarity) to the user.
## When to use each tool
| Situation | Tool |
|---|---|
| User asks about a topic that might already be documented | `bm_search` first, then `bm_read` |
| User exposes a decision, plan, or meeting outcome | offer to `bm_write` |
| Updating prior work | `bm_edit` (append for time-ordered logs, replace_section for living docs) |
| Exploring related concepts | `bm_context` |
| "What was I working on yesterday?" / no specific query yet | `bm_recent` |
| User names a project that isn't the active one | `bm_projects` → call read/write tool with `project: "workspace/name"` or `project_id: "<uuid>"` |
| Same project name might exist in multiple workspaces | `bm_projects` (+ `bm_workspaces` if needed) → route with workspace-qualified `project` or `project_id` |
| Following up on a freshly-written note | Use the returned permalink directly — it already encodes the routing |
## Note structure
BM treats `- [category]` lines as **observations** and WikiLink lines under `## Relations` as **relations**. Categories (`[decision]`, `[insight]`, `[risk]`, `[fact]`, `[todo]`, …) and relation types (`relates_to`, `implements`, `depends_on`, `blocks`, …) are open-ended — use what fits the content. YAML frontmatter is supported with `title`, `type`, `tags`, and `permalink` as standard fields; any custom fields are allowed. See the [knowledge format docs](https://docs.basicmemory.com/raw/concepts/knowledge-format.md) for the full convention.
```markdown
# Clear Title
## Context
Background and current situation.
## Key Points
- Main insights
- Important details
## Observations
- [decision] We chose PostgreSQL for ACID guarantees
- [insight] Users prefer social login
- [risk] Deployment lacks rollback path
## Relations
- relates_to [[Other Note Title]]
- depends_on [[Database Choice]]
## Next Steps
- [ ] Implement
- [ ] Document
```
## Behavior guidelines
1. **Search before answering.** If the user asks "what did we decide about X?", run `bm_search` first.
2. **Offer to capture.** When the user shares decisions or meeting outcomes, ask: "Should I save this as a note?"
3. **Suggest connections.** When a search returns related notes, surface them so the user knows what already exists.
4. **Don't over-capture.** Auto-capture is already running per turn. Don't create a `bm_write` for every response — only for substantive content the user wants preserved.
5. **Sensitive info.** Don't capture credentials or personal data without confirmation.
## Footgun
If a note's body contains literal `<memory-context>...</memory-context>` tags, Hermes's streaming output scrubber will eat those tags (and the text between paired ones) when you echo the note verbatim back to the user. Tool *inputs* are unaffected. If you must include such content, fence it in a code block.
## Further reading
Official docs live at [docs.basicmemory.com](https://docs.basicmemory.com). Every page has an AI-friendly raw markdown view at `/raw/<path>.md` (or send `Accept: text/markdown` to the canonical URL). `WebFetch` any of these when you need detail beyond what this skill covers:
- **[Knowledge format](https://docs.basicmemory.com/raw/concepts/knowledge-format.md)** — observation categories, relation types, frontmatter conventions.
- **[Observations & relations](https://docs.basicmemory.com/raw/concepts/observations-and-relations.md)** — how notes form a graph that's searchable and traversable.
- **[Memory URLs](https://docs.basicmemory.com/raw/concepts/memory-urls.md)** — title-based addressing, wildcards (`memory://docs/*`), and routing resolution order.
- **[Projects & folders](https://docs.basicmemory.com/raw/concepts/projects-and-folders.md)** — multi-project layout, folder organization, cloud routing behavior.
- **[Semantic search](https://docs.basicmemory.com/raw/concepts/semantic-search.md)** — how `bm_search` resolves queries (semantic + full-text).
- **[MCP tools reference](https://docs.basicmemory.com/raw/reference/mcp-tools-reference.md)** — Basic Memory's full MCP surface (the `bm_*` tools here are a curated subset).
- **[Cloud routing](https://docs.basicmemory.com/raw/cloud/routing.md)** — local vs cloud project modes, per-project routing setup.
- **[llms.txt index](https://docs.basicmemory.com/llms.txt)** — full sitemap of raw markdown pages, useful when you need to look up a page not listed above.
+167
View File
@@ -0,0 +1,167 @@
"""
Pytest configuration: stub Hermes-internal imports so the plugin loads
without a Hermes install, and expose the loaded plugin module as a fixture.
"""
from __future__ import annotations
import importlib.util
import json
import os
import sys
import types
import pytest
def _stub_hermes_modules() -> None:
"""
The plugin imports `agent.memory_provider.MemoryProvider` and
`tools.registry.tool_error`. These are provided by Hermes at runtime,
not as a pip-installable package. Stub them before module load so unit
tests don't need Hermes installed.
"""
agent_mod = types.ModuleType("agent")
agent_mp_mod = types.ModuleType("agent.memory_provider")
class _StubMemoryProvider:
"""Stand-in for `agent.memory_provider.MemoryProvider`."""
agent_mp_mod.MemoryProvider = _StubMemoryProvider
sys.modules.setdefault("agent", agent_mod)
sys.modules.setdefault("agent.memory_provider", agent_mp_mod)
tools_mod = types.ModuleType("tools")
tools_registry_mod = types.ModuleType("tools.registry")
def _tool_error(msg: str) -> str:
return json.dumps({"error": str(msg)})
tools_registry_mod.tool_error = _tool_error
sys.modules.setdefault("tools", tools_mod)
sys.modules.setdefault("tools.registry", tools_registry_mod)
_stub_hermes_modules()
_PLUGIN_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "__init__.py")
_spec = importlib.util.spec_from_file_location("hermes_basic_memory_plugin", _PLUGIN_PATH)
assert _spec is not None and _spec.loader is not None
_plugin = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_plugin)
@pytest.fixture
def bm():
"""The loaded plugin module."""
return _plugin
# ---- Synthetic MCP CallToolResult objects (no MCP SDK needed at test time) ----
class FakeContent:
"""Stand-in for `mcp.types.TextContent`."""
def __init__(self, text: str):
self.text = text
class FakeCallToolResult:
"""Stand-in for `mcp.types.CallToolResult`."""
def __init__(self, content_texts, is_error: bool = False):
self.content = [FakeContent(t) for t in content_texts]
self.isError = is_error
@pytest.fixture
def fake_result():
return FakeCallToolResult
# ---- Actor test helpers ----
class FakeSession:
"""
Stand-in for `mcp.ClientSession`. `call_tool` is an async coroutine
that records every call and returns a configurable CallToolResult.
- default_response: dict serialized to JSON in the result text
- hang: when True, call_tool sleeps long enough to force a client-side timeout
- is_error: when True, return value has isError=True
"""
def __init__(self, default_response=None, hang: bool = False, is_error: bool = False):
self.default_response = default_response if default_response is not None else {"ok": True}
self.hang = hang
self.is_error = is_error
self.calls: list = []
self.was_cancelled = False
self._stub_handlers: dict = {}
def stub(self, tool_name: str, handler):
"""Register a per-tool handler. Handler receives (args) and returns a response dict (or raises)."""
self._stub_handlers[tool_name] = handler
async def call_tool(self, name: str, args: dict):
self.calls.append((name, dict(args)))
if self.hang:
try:
import asyncio as _a
await _a.sleep(60)
except BaseException as e:
# Track cooperative cancellation so tests can verify cleanup
if type(e).__name__ in ("CancelledError",):
self.was_cancelled = True
raise
if name in self._stub_handlers:
response = self._stub_handlers[name](args)
else:
response = self.default_response
return FakeCallToolResult([json.dumps(response)], is_error=self.is_error)
def make_scripted_actor(
bm, session=None, raise_at_init: BaseException | None = None, fake_tools: list | None = None
):
"""
Build an actor whose `_main()` is replaced with a deterministic version.
The fake `_main` mirrors the production happy path (sets _session, _stop_future,
fills _tools_cache, signals ready, awaits stop) but skips the stdio subprocess.
raise_at_init: if set, raised inside _main so we can test failure paths.
"""
import asyncio
actor = bm._BmMcpActor(["fake-bm", "mcp"])
sess = session or FakeSession()
async def _fake_main():
if raise_at_init is not None:
actor._init_error = raise_at_init
actor._ready.set()
raise raise_at_init
actor._session = sess
actor._stop_future = asyncio.get_running_loop().create_future()
actor._tools_cache = fake_tools or [
{"name": n, "description": ""}
for n in [
"search_notes",
"read_note",
"write_note",
"edit_note",
"build_context",
"delete_note",
"move_note",
]
]
actor._ready.set()
await actor._stop_future
actor._main = _fake_main # type: ignore[assignment]
actor._test_session = sess # convenience handle for assertions
return actor
@@ -0,0 +1 @@
"""Test-only Hermes agent package stub."""
@@ -0,0 +1,5 @@
"""Test-only stand-in for Hermes's memory provider interface."""
class MemoryProvider:
"""Minimal base class used by the plugin during unit tests."""
@@ -0,0 +1 @@
"""Test-only Hermes tools package stub."""
@@ -0,0 +1,9 @@
"""Test-only stand-in for Hermes's tool registry helpers."""
from __future__ import annotations
import json
def tool_error(msg: str) -> str:
return json.dumps({"error": str(msg)})
+183
View File
@@ -0,0 +1,183 @@
"""Tests for _BmMcpActor: lifecycle, call dispatch, timeout, shutdown."""
from __future__ import annotations
import concurrent.futures
import json
import time
import pytest
from tests.conftest import FakeSession, make_scripted_actor
# ---- Lifecycle ----
def test_new_actor_is_not_running(bm):
actor = bm._BmMcpActor(["fake-bm", "mcp"])
assert actor._running is False
def test_start_brings_actor_up(bm):
actor = make_scripted_actor(bm)
actor.start(timeout=5.0)
try:
assert actor._running is True
assert actor._session is not None
assert actor._stop_future is not None
assert actor._thread is not None and actor._thread.is_alive()
# Tools cache populated
names = {t["name"] for t in actor.list_tools()}
assert "search_notes" in names
finally:
actor.shutdown(timeout=2.0)
def test_start_is_idempotent_when_thread_alive(bm):
actor = make_scripted_actor(bm)
actor.start(timeout=5.0)
try:
first_thread = actor._thread
actor.start(timeout=5.0) # second call
assert actor._thread is first_thread # same thread, no new one spawned
finally:
actor.shutdown(timeout=2.0)
def test_start_init_error_raises(bm):
boom = ValueError("BM unreachable")
actor = make_scripted_actor(bm, raise_at_init=boom)
with pytest.raises(RuntimeError, match="BM unreachable"):
actor.start(timeout=5.0)
assert actor._running is False
def test_start_timeout_raises(bm):
"""If _ready never gets set within the timeout, start() raises TimeoutError."""
import asyncio
actor = bm._BmMcpActor(["fake-bm", "mcp"])
async def _hang_forever():
# Never call self._ready.set(); start() should time out waiting.
await asyncio.sleep(60)
actor._main = _hang_forever # type: ignore[assignment]
with pytest.raises(TimeoutError):
actor.start(timeout=0.5)
assert actor._running is False
# ---- Shutdown ----
def test_shutdown_before_start_is_noop(bm):
actor = bm._BmMcpActor(["fake-bm", "mcp"])
# Should not raise even though nothing is running
actor.shutdown(timeout=1.0)
assert actor._running is False
def test_shutdown_stops_running_actor(bm):
actor = make_scripted_actor(bm)
actor.start(timeout=5.0)
assert actor._thread is not None and actor._thread.is_alive()
actor.shutdown(timeout=5.0)
assert actor._running is False
# Thread should exit shortly after stop_future resolves
actor._thread.join(timeout=2.0)
assert not actor._thread.is_alive()
def test_shutdown_is_idempotent(bm):
actor = make_scripted_actor(bm)
actor.start(timeout=5.0)
actor.shutdown(timeout=2.0)
# Second call should not raise
actor.shutdown(timeout=2.0)
assert actor._running is False
# ---- call() dispatch ----
def test_call_before_start_raises(bm):
actor = bm._BmMcpActor(["fake-bm", "mcp"])
with pytest.raises(RuntimeError):
actor.call("search_notes", {})
def test_call_after_shutdown_raises(bm):
actor = make_scripted_actor(bm)
actor.start(timeout=5.0)
actor.shutdown(timeout=2.0)
with pytest.raises(RuntimeError, match="not running"):
actor.call("search_notes", {})
def test_call_dispatches_through_actor_loop(bm):
session = FakeSession(default_response={"results": [], "ok": True})
actor = make_scripted_actor(bm, session=session)
actor.start(timeout=5.0)
try:
out = actor.call("search_notes", {"query": "hi"}, timeout=5.0)
# Output is whatever _extract_mcp_text produces; we just verify it
# contains the response payload we configured.
assert "ok" in out
assert session.calls == [("search_notes", {"query": "hi"})]
finally:
actor.shutdown(timeout=2.0)
def test_call_returns_mcp_extracted_text(bm):
session = FakeSession(default_response={"permalink": "p/q/r", "title": "T"})
actor = make_scripted_actor(bm, session=session)
actor.start(timeout=5.0)
try:
out = actor.call("write_note", {"title": "T"}, timeout=5.0)
parsed = json.loads(out)
assert parsed["permalink"] == "p/q/r"
finally:
actor.shutdown(timeout=2.0)
def test_call_timeout_raises_and_cancels_coroutine(bm):
session = FakeSession(hang=True)
actor = make_scripted_actor(bm, session=session)
actor.start(timeout=5.0)
try:
t0 = time.monotonic()
with pytest.raises(concurrent.futures.TimeoutError):
actor.call("search_notes", {}, timeout=0.3)
elapsed = time.monotonic() - t0
# Sanity: we didn't accidentally wait the full session-side sleep
assert elapsed < 2.0
# Give the actor loop a moment to propagate the cancellation
deadline = time.monotonic() + 2.0
while time.monotonic() < deadline and not session.was_cancelled:
time.sleep(0.05)
assert session.was_cancelled is True, (
"Expected the underlying coroutine to be cancelled when call() times out"
)
finally:
actor.shutdown(timeout=2.0)
def test_list_tools_returns_independent_copy(bm):
actor = make_scripted_actor(bm)
actor.start(timeout=5.0)
try:
snapshot = actor.list_tools()
snapshot.append({"name": "tampered"})
assert "tampered" not in {t["name"] for t in actor.list_tools()}
finally:
actor.shutdown(timeout=2.0)
def test_actor_init_error_logs_and_marks_not_running(bm, caplog):
actor = make_scripted_actor(bm, raise_at_init=RuntimeError("server crashed"))
with pytest.raises(RuntimeError):
actor.start(timeout=5.0)
assert actor._running is False
+337
View File
@@ -0,0 +1,337 @@
"""
Tests for the capture pipeline: sync_turn (per-turn) and on_session_end (summary).
These tests run the *real* sync_turn code path with a mocked actor, so threading
and argument-shape regressions are caught.
"""
from __future__ import annotations
import json
from datetime import datetime, timezone
from unittest.mock import MagicMock
import pytest
def _provider_with_mock_actor(bm, *, project="test-proj", capture_folder="hermes-sessions"):
p = bm.BasicMemoryProvider()
p._initialized = True
p._project = project
p._capture_folder = capture_folder
p._session_id = "20260510_123456_abcdef"
p._session_started_at = datetime(2026, 5, 10, 12, 34, 56, tzinfo=timezone.utc)
actor = MagicMock()
p._actor = actor
return p, actor
def _wait_for_thread(p, attr="_sync_thread", timeout=5.0):
t = getattr(p, attr)
if t is not None:
t.join(timeout=timeout)
assert not t.is_alive(), f"{attr} did not finish within {timeout}s"
# ---- sync_turn first-turn path: write_note ----
def test_sync_turn_first_turn_calls_write_note(bm):
p, actor = _provider_with_mock_actor(bm)
actor.call.return_value = json.dumps(
{
"permalink": "test-proj/hermes-sessions/hermes-session-2026-05-10-1234-abcdef",
"title": "Hermes Session 2026-05-10 1234 abcdef",
}
)
p.sync_turn("hello", "hi back")
_wait_for_thread(p, "_sync_thread")
actor.call.assert_called_once()
bm_tool, bm_args = actor.call.call_args[0][:2]
assert bm_tool == "write_note"
assert bm_args["project"] == "test-proj"
assert bm_args["directory"] == "hermes-sessions"
assert "Hermes Session" in bm_args["title"]
assert "hello" in bm_args["content"]
assert "hi back" in bm_args["content"]
assert "## Turns" in bm_args["content"]
assert bm_args["output_format"] == "json"
assert "hermes-session" in bm_args["tags"]
def test_sync_turn_first_turn_stores_extracted_permalink(bm):
p, actor = _provider_with_mock_actor(bm)
actor.call.return_value = json.dumps(
{
"permalink": "test-proj/hermes-sessions/hermes-session-foo",
"title": "T",
}
)
p.sync_turn("u", "a")
_wait_for_thread(p, "_sync_thread")
assert p._session_note_id == "test-proj/hermes-sessions/hermes-session-foo"
def test_sync_turn_records_first_user_message(bm):
p, actor = _provider_with_mock_actor(bm)
actor.call.return_value = json.dumps({"permalink": "x"})
p.sync_turn("the very first user message", "reply")
_wait_for_thread(p, "_sync_thread")
assert p._first_user_msg == "the very first user message"
# A subsequent first-user-msg call should NOT overwrite the original
p.sync_turn("a much later user message", "another reply")
_wait_for_thread(p, "_sync_thread")
assert p._first_user_msg == "the very first user message"
# ---- sync_turn append path: edit_note ----
def test_sync_turn_subsequent_turn_calls_edit_note_append(bm):
p, actor = _provider_with_mock_actor(bm)
p._session_note_id = "test-proj/hermes-sessions/already-exists"
p.sync_turn("turn 2 user", "turn 2 assistant")
_wait_for_thread(p, "_sync_thread")
actor.call.assert_called_once()
bm_tool, bm_args = actor.call.call_args[0][:2]
assert bm_tool == "edit_note"
assert bm_args["identifier"] == "test-proj/hermes-sessions/already-exists"
assert bm_args["operation"] == "append"
assert "turn 2 user" in bm_args["content"]
assert "turn 2 assistant" in bm_args["content"]
def test_sync_turn_session_note_id_stable_across_turns(bm):
p, actor = _provider_with_mock_actor(bm)
actor.call.return_value = json.dumps({"permalink": "test-proj/folder/note-perma"})
p.sync_turn("u1", "a1")
_wait_for_thread(p, "_sync_thread")
first_id = p._session_note_id
# Reconfigure mock to return something different — should be IGNORED for
# the second turn since we're now using the existing permalink to append.
actor.call.return_value = json.dumps({"permalink": "wrong-id"})
p.sync_turn("u2", "a2")
_wait_for_thread(p, "_sync_thread")
assert p._session_note_id == first_id, "session_note_id mutated on second turn"
# And the second call was edit_note, not write_note
second_call_tool = actor.call.call_args_list[1][0][0]
assert second_call_tool == "edit_note"
# ---- sync_turn gating ----
def test_sync_turn_skipped_when_capture_per_turn_off(bm):
p, actor = _provider_with_mock_actor(bm)
p._capture_per_turn = False
p.sync_turn("u", "a")
actor.call.assert_not_called()
def test_sync_turn_skipped_when_uninitialized(bm):
p = bm.BasicMemoryProvider()
p._actor = MagicMock()
p.sync_turn("u", "a")
p._actor.call.assert_not_called()
def test_sync_turn_skipped_when_actor_none(bm):
p, _ = _provider_with_mock_actor(bm)
p._actor = None
p.sync_turn("u", "a") # should not raise
def test_sync_turn_skipped_when_circuit_open(bm):
import time as _time
p, actor = _provider_with_mock_actor(bm)
p._failure_pause_until = _time.monotonic() + 60.0
p.sync_turn("u", "a")
actor.call.assert_not_called()
def test_sync_turn_records_failure_on_actor_exception(bm):
p, actor = _provider_with_mock_actor(bm)
actor.call.side_effect = RuntimeError("boom")
p.sync_turn("u", "a")
_wait_for_thread(p, "_sync_thread")
assert p._failure_count >= 1
def test_sync_turn_thread_is_daemonic(bm):
p, actor = _provider_with_mock_actor(bm)
actor.call.return_value = json.dumps({"permalink": "x"})
p.sync_turn("u", "a")
assert p._sync_thread is not None
assert p._sync_thread.daemon is True
_wait_for_thread(p, "_sync_thread")
# ---- _capture_turn directly (no thread) ----
def test_capture_turn_first_call_writes_note_with_session_metadata(bm):
p, actor = _provider_with_mock_actor(bm)
actor.call.return_value = json.dumps({"permalink": "p/folder/note"})
p._capture_turn("u msg", "a msg")
bm_tool, bm_args = actor.call.call_args[0][:2]
assert bm_tool == "write_note"
assert "20260510_123456_abcdef" in bm_args["content"]
# Auto-captured banner present
assert "Auto-captured" in bm_args["content"]
def test_capture_turn_truncates_huge_messages(bm):
p, actor = _provider_with_mock_actor(bm)
actor.call.return_value = json.dumps({"permalink": "p/x"})
huge = "X" * 10000
p._capture_turn(huge, huge)
bm_args = actor.call.call_args[0][1]
# Body should not contain 10k Xs verbatim — _truncate caps at 4000
assert bm_args["content"].count("X") < 9000
assert "..." in bm_args["content"]
# ---- on_session_end summary ----
def test_on_session_end_writes_summary_note(bm):
p, actor = _provider_with_mock_actor(bm)
p._session_note_id = "p/folder/transcript"
actor.call.return_value = json.dumps({"permalink": "p/folder/summary"})
messages = [
{"role": "user", "content": "first user message"},
{"role": "assistant", "content": "first assistant"},
{"role": "user", "content": "second user"},
{"role": "assistant", "content": "last assistant message"},
]
p.on_session_end(messages)
actor.call.assert_called_once()
bm_tool, bm_args = actor.call.call_args[0][:2]
assert bm_tool == "write_note"
assert "Hermes Session Summary" in bm_args["title"]
assert bm_args["directory"] == "hermes-sessions"
assert "first user message" in bm_args["content"]
assert "last assistant message" in bm_args["content"]
# Summary should link back to the transcript via Relations when
# session_note_id is known
assert "summary_of [[p/folder/transcript]]" in bm_args["content"]
def test_on_session_end_omits_relations_when_no_transcript_id(bm):
p, actor = _provider_with_mock_actor(bm)
p._session_note_id = None
actor.call.return_value = json.dumps({"permalink": "p/folder/summary"})
p.on_session_end([{"role": "user", "content": "u"}, {"role": "assistant", "content": "a"}])
bm_args = actor.call.call_args[0][1]
assert "## Relations" not in bm_args["content"]
def test_on_session_end_handles_list_of_dicts_content(bm):
"""OpenAI-style content blocks: list of {type: text, text: ...}."""
p, actor = _provider_with_mock_actor(bm)
actor.call.return_value = json.dumps({"permalink": "p/x"})
messages = [
{"role": "user", "content": [{"type": "text", "text": "hello world"}]},
{"role": "assistant", "content": [{"type": "text", "text": "goodbye"}]},
]
p.on_session_end(messages)
bm_args = actor.call.call_args[0][1]
assert "hello world" in bm_args["content"]
assert "goodbye" in bm_args["content"]
def test_on_session_end_uses_first_user_msg_when_set(bm):
p, actor = _provider_with_mock_actor(bm)
p._first_user_msg = "captured-via-sync_turn"
actor.call.return_value = json.dumps({"permalink": "p/x"})
messages = [
{"role": "user", "content": "in-the-messages-list"},
{"role": "assistant", "content": "ok"},
]
p.on_session_end(messages)
bm_args = actor.call.call_args[0][1]
# _first_user_msg takes priority over messages list
assert "captured-via-sync_turn" in bm_args["content"]
def test_on_session_end_handles_empty_messages(bm):
p, actor = _provider_with_mock_actor(bm)
actor.call.return_value = json.dumps({"permalink": "p/x"})
p.on_session_end([])
bm_args = actor.call.call_args[0][1]
assert "(no user message)" in bm_args["content"]
assert "(no assistant message)" in bm_args["content"]
assert "0 user / 0 assistant" in bm_args["content"]
def test_on_session_end_skipped_when_disabled(bm):
p, actor = _provider_with_mock_actor(bm)
p._capture_session_end = False
p.on_session_end([{"role": "user", "content": "u"}])
actor.call.assert_not_called()
def test_on_session_end_skipped_when_uninitialized(bm):
p = bm.BasicMemoryProvider()
p._actor = MagicMock()
p.on_session_end([{"role": "user", "content": "u"}])
p._actor.call.assert_not_called()
def test_on_session_end_logs_and_swallows_errors(bm, caplog):
p, actor = _provider_with_mock_actor(bm)
actor.call.side_effect = RuntimeError("BM down")
# Should not raise — the summary is best-effort
p.on_session_end([{"role": "user", "content": "u"}])
# ---- shutdown lifecycle ----
def test_shutdown_clears_initialized_flag(bm):
p, actor = _provider_with_mock_actor(bm)
p.shutdown()
assert p._initialized is False
assert p._actor is None
actor.shutdown.assert_called_once()
def test_shutdown_swallows_actor_errors(bm):
p, actor = _provider_with_mock_actor(bm)
actor.shutdown.side_effect = RuntimeError("bad")
# Must not raise
p.shutdown()
assert p._initialized is False
def test_shutdown_idempotent(bm):
p, _ = _provider_with_mock_actor(bm)
p.shutdown()
p.shutdown() # should not raise
def test_shutdown_before_initialize_is_noop(bm):
p = bm.BasicMemoryProvider()
p.shutdown() # _actor is None, shouldn't error
+725
View File
@@ -0,0 +1,725 @@
"""
Tests for the plugin-owned /bm-* slash commands.
Covers:
- registration through ctx.register_command (forward-compat path)
- PluginManager reach-in (production path with current Hermes collector)
- per-handler behavior: usage text, uninitialized provider, happy path,
and exception plain-text error.
"""
from __future__ import annotations
import json
import sys
import types
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from .conftest import FakeSession, make_scripted_actor
# ---------------------------------------------------------------------------
# Helpers for the reach-in tests
# ---------------------------------------------------------------------------
class _ProviderCollectorLike:
"""
Mirror of Hermes's real `_ProviderCollector` shape: captures
`register_memory_provider` and no-ops everything else. NOT a MagicMock
`hasattr(collector, "register_command")` must return False, matching the
real collector.
"""
def __init__(self):
self.provider = None
def register_memory_provider(self, provider):
self.provider = provider
class _FakePluginManager:
"""Stand-in for hermes_cli.plugins.PluginManager — just the bits we touch."""
def __init__(self):
self._plugin_commands: dict = {}
self._plugin_skills: dict = {}
def _install_fake_hermes_cli(monkeypatch, *, resolve_returns=None):
"""
Insert a fake `hermes_cli.plugins` (with `_ensure_plugins_discovered`) and
`hermes_cli.commands` (with `resolve_command`) into sys.modules so the
reach-in's lazy imports resolve. Returns the FakePluginManager instance
so tests can assert against its registries.
resolve_returns: optional mapping of command name truthy/falsy value
the fake resolve_command should return. Use a truthy value to simulate a
built-in conflict for that name.
"""
fake_mgr = _FakePluginManager()
plugins_mod = types.ModuleType("hermes_cli.plugins")
def _ensure_plugins_discovered(force: bool = False):
return fake_mgr
plugins_mod._ensure_plugins_discovered = _ensure_plugins_discovered # type: ignore[attr-defined]
commands_mod = types.ModuleType("hermes_cli.commands")
def _resolve_command(name: str):
if resolve_returns and name in resolve_returns:
return resolve_returns[name]
return None
commands_mod.resolve_command = _resolve_command # type: ignore[attr-defined]
hermes_cli = types.ModuleType("hermes_cli")
monkeypatch.setitem(sys.modules, "hermes_cli", hermes_cli)
monkeypatch.setitem(sys.modules, "hermes_cli.plugins", plugins_mod)
monkeypatch.setitem(sys.modules, "hermes_cli.commands", commands_mod)
return fake_mgr
# ---------------------------------------------------------------------------
# Registration
# ---------------------------------------------------------------------------
_EXPECTED_COMMANDS = {
"bm-search",
"bm-read",
"bm-context",
"bm-recent",
"bm-status",
"bm-remember",
"bm-project",
"bm-workspace",
}
def test_register_wires_up_all_slash_commands_on_modern_ctx(bm):
"""Forward-compat path: when ctx supports register_command (e.g. after the
upstream collector patch lands, or for plugins loaded via PluginContext),
every /bm-* command is registered through that path."""
ctx = MagicMock()
bm._active_providers.clear()
bm.register(ctx)
names = {call.args[0] for call in ctx.register_command.call_args_list}
assert names == _EXPECTED_COMMANDS
bm._active_providers.clear()
def test_register_command_calls_include_description_and_args_hint(bm):
ctx = MagicMock()
bm._active_providers.clear()
bm.register(ctx)
for call in ctx.register_command.call_args_list:
name, handler = call.args[0], call.args[1]
kwargs = call.kwargs
assert callable(handler)
assert "description" in kwargs and kwargs["description"]
assert "args_hint" in kwargs # may be empty string for no-arg commands
bm._active_providers.clear()
def test_register_tolerates_old_hermes_without_register_command(bm):
"""Plugins must not crash on Hermes < v0.11.0 (no register_command)."""
class _OldCtx:
def __init__(self):
self.memory_calls = []
def register_memory_provider(self, provider):
self.memory_calls.append(provider)
ctx = _OldCtx()
bm._active_providers.clear()
bm.register(ctx) # must not raise
assert len(ctx.memory_calls) == 1
bm._active_providers.clear()
def test_register_swallows_register_command_errors(bm, caplog):
"""If one register_command call fails, the others — and provider
registration still proceed."""
ctx = MagicMock()
ctx.register_command.side_effect = ValueError("name collision with builtin")
bm._active_providers.clear()
with caplog.at_level("WARNING"):
bm.register(ctx)
ctx.register_memory_provider.assert_called_once()
assert ctx.register_command.call_count == len(_EXPECTED_COMMANDS)
assert "register_command" in caplog.text
bm._active_providers.clear()
# ---------------------------------------------------------------------------
# Reach-in (production path): ctx is the no-op _ProviderCollector
# ---------------------------------------------------------------------------
def test_reach_in_writes_all_commands_to_plugin_manager(bm, monkeypatch):
"""Regression for Codex P1: with the real collector shape (no
register_command method), reach into PluginManager and write commands
directly. The unit suite previously used MagicMock, which masked this
silent-skip by making every attribute exist."""
fake_mgr = _install_fake_hermes_cli(monkeypatch)
ctx = _ProviderCollectorLike()
assert not hasattr(ctx, "register_command"), (
"test collector must mirror real _ProviderCollector — no register_command"
)
bm._active_providers.clear()
bm.register(ctx)
try:
assert ctx.provider is not None # memory provider still registered
assert set(fake_mgr._plugin_commands.keys()) == _EXPECTED_COMMANDS
for name, entry in fake_mgr._plugin_commands.items():
assert callable(entry["handler"])
assert entry["plugin"] == "basic-memory"
assert "description" in entry
assert "args_hint" in entry
finally:
bm._active_providers.clear()
def test_reach_in_writes_skill_to_plugin_manager(bm, monkeypatch):
"""Same silent-skip applies to register_skill — the bundled skill never
landed in real installs prior to this fix. Reach-in writes the namespaced
entry directly."""
fake_mgr = _install_fake_hermes_cli(monkeypatch)
ctx = _ProviderCollectorLike()
bm._active_providers.clear()
bm.register(ctx)
try:
assert "basic-memory:basic-memory" in fake_mgr._plugin_skills
skill = fake_mgr._plugin_skills["basic-memory:basic-memory"]
assert skill["plugin"] == "basic-memory"
assert skill["bare_name"] == "basic-memory"
assert isinstance(skill["path"], Path)
assert skill["path"].name == "SKILL.md"
finally:
bm._active_providers.clear()
def test_reach_in_skips_command_conflicting_with_builtin(bm, monkeypatch, caplog):
"""Mirror Hermes's PluginContext.register_command guard — when
resolve_command(name) returns a truthy value, skip that command and
log a warning rather than overwriting a built-in."""
# Simulate /bm-search colliding with a built-in.
fake_mgr = _install_fake_hermes_cli(monkeypatch, resolve_returns={"bm-search": object()})
ctx = _ProviderCollectorLike()
bm._active_providers.clear()
with caplog.at_level("WARNING"):
bm.register(ctx)
try:
assert "bm-search" not in fake_mgr._plugin_commands
# Other commands still landed
assert "bm-read" in fake_mgr._plugin_commands
assert "conflicts with a built-in" in caplog.text
finally:
bm._active_providers.clear()
def test_reach_in_degrades_when_hermes_cli_missing(bm, monkeypatch, caplog):
"""If hermes_cli.plugins isn't importable (e.g. running outside a Hermes
install), the reach-in must log and continue never crash the plugin's
memory-provider registration."""
# Don't install fake modules; force import to fail.
monkeypatch.setitem(sys.modules, "hermes_cli.plugins", None) # type: ignore[arg-type]
ctx = _ProviderCollectorLike()
bm._active_providers.clear()
with caplog.at_level("DEBUG"):
bm.register(ctx) # must not raise
try:
assert ctx.provider is not None
# Either DEBUG message logged or nothing — both acceptable degrade modes.
finally:
bm._active_providers.clear()
def test_reach_in_degrades_when_plugin_manager_missing_attrs(bm, monkeypatch):
"""Forward-compat: if Hermes ever refactors _plugin_commands /
_plugin_skills away, the reach-in must not crash."""
fake_mgr = _FakePluginManager()
# Strip the attrs to simulate the rename/refactor
del fake_mgr._plugin_commands
del fake_mgr._plugin_skills
plugins_mod = types.ModuleType("hermes_cli.plugins")
plugins_mod._ensure_plugins_discovered = lambda force=False: fake_mgr # type: ignore[attr-defined]
monkeypatch.setitem(sys.modules, "hermes_cli.plugins", plugins_mod)
ctx = _ProviderCollectorLike()
bm._active_providers.clear()
bm.register(ctx) # must not raise
bm._active_providers.clear()
def test_reach_in_normalizes_command_names(bm, monkeypatch):
"""Reach-in must mirror Hermes's name normalization (lowercase, strip,
leading slash removed, spaces hyphens). All our names are already
canonical, so this is a defensive check on the transform itself."""
fake_mgr = _install_fake_hermes_cli(monkeypatch)
ctx = _ProviderCollectorLike()
bm._active_providers.clear()
bm.register(ctx)
try:
for name in fake_mgr._plugin_commands:
assert name == name.lower()
assert not name.startswith("/")
assert " " not in name
finally:
bm._active_providers.clear()
def test_reach_in_entries_match_hermes_internal_shape(bm, monkeypatch):
"""The dict shape PluginContext.register_command writes
(plugins.py:447-452) is the contract Hermes's dispatch reads. Our
reach-in must produce byte-identical entries."""
fake_mgr = _install_fake_hermes_cli(monkeypatch)
ctx = _ProviderCollectorLike()
bm._active_providers.clear()
bm.register(ctx)
try:
entry = fake_mgr._plugin_commands["bm-search"]
assert set(entry.keys()) == {"handler", "description", "plugin", "args_hint"}
assert callable(entry["handler"])
assert entry["description"] # non-empty string
assert entry["plugin"] == "basic-memory"
assert isinstance(entry["args_hint"], str)
finally:
bm._active_providers.clear()
# ---------------------------------------------------------------------------
# Per-handler tests
# ---------------------------------------------------------------------------
def _ready_provider(bm, session: FakeSession | None = None):
"""Build a provider in 'initialized' state with a scripted actor."""
provider = bm.BasicMemoryProvider()
actor = make_scripted_actor(bm, session=session)
actor.start()
provider._actor = actor
provider._initialized = True
provider._project = "test-proj"
return provider, actor
def _handlers_by_name(bm, provider):
return {name: handler for name, handler, _, _ in bm._build_slash_commands(provider)}
# ---- Usage strings ----
@pytest.mark.parametrize(
"name,args",
[
("bm-search", ""),
("bm-search", "help"),
("bm-read", ""),
("bm-read", "-h"),
("bm-context", ""),
("bm-remember", ""),
("bm-remember", "--help"),
# Commands that take no args use 'help' to surface their usage line
("bm-recent", "help"),
("bm-status", "help"),
("bm-project", "help"),
("bm-workspace", "help"),
],
)
def test_usage_returned_for_empty_or_help_args(bm, name, args):
provider = bm.BasicMemoryProvider() # not initialized — usage path shouldn't need it
handlers = _handlers_by_name(bm, provider)
out = handlers[name](args)
assert isinstance(out, str)
assert out.lower().startswith("usage:")
# ---- Uninitialized provider ----
@pytest.mark.parametrize(
"name,args",
[
("bm-search", "hello"),
("bm-read", "some/note"),
("bm-context", "memory://x"),
("bm-recent", ""),
("bm-remember", "a thought"),
("bm-project", ""),
],
)
def test_handler_init_failure_returns_message(bm, monkeypatch, name, args):
provider = bm.BasicMemoryProvider()
monkeypatch.setattr(provider, "initialize", lambda *a, **kw: None)
handlers = _handlers_by_name(bm, provider)
out = handlers[name](args)
assert "not initialized" in out
assert name in out # message includes command name
def test_handler_lazily_initializes_provider_for_slash_command(bm, monkeypatch):
provider = bm.BasicMemoryProvider()
calls = []
def fake_initialize(*args, **kwargs):
calls.append((args, kwargs))
session = FakeSession(default_response={"results": []})
actor = make_scripted_actor(bm, session=session)
actor.start()
provider._actor = actor
provider._initialized = True
monkeypatch.setattr(provider, "initialize", fake_initialize)
out = _handlers_by_name(bm, provider)["bm-search"]("widgets")
assert "No results" in out
assert len(calls) == 1
assert calls[0][1]["session_id"].startswith("slash:bm-search:")
# ---- /bm-status ----
def test_bm_status_renders_provider_state(bm, monkeypatch):
provider = bm.BasicMemoryProvider()
provider._mode = "local"
provider._project = "demo"
provider._project_path = "/tmp/demo"
provider._capture_per_turn = True
provider._capture_session_end = False
provider._capture_folder = "transcripts"
provider._remember_folder = "inbox"
monkeypatch.setattr(bm, "_bm_binary_path", lambda: "/fake/bin/bm")
out = _handlers_by_name(bm, provider)["bm-status"]("")
assert "demo" in out
assert "/tmp/demo" in out
assert "/fake/bin/bm" in out
assert "Initialized: no" in out
assert "transcripts" in out and "inbox" in out
# ---- /bm-search ----
def test_bm_search_happy_path(bm):
session = FakeSession()
session.stub(
"search_notes",
lambda args: {
"results": [
{"title": "Decisions", "permalink": "decisions/foo", "content": "we chose X"},
{"title": "Plan", "permalink": "plans/p1", "preview": "next quarter"},
]
},
)
provider, actor = _ready_provider(bm, session)
try:
out = _handlers_by_name(bm, provider)["bm-search"]("widgets")
assert "Decisions" in out
assert "decisions/foo" in out
assert "we chose X" in out
# Args sent to BM
call = session.calls[-1]
assert call[0] == "search_notes"
assert call[1]["query"] == "widgets"
assert call[1]["project"] == "test-proj"
assert call[1]["output_format"] == "json"
finally:
actor.shutdown()
def test_bm_search_empty_results(bm):
session = FakeSession(default_response={"results": []})
provider, actor = _ready_provider(bm, session)
try:
out = _handlers_by_name(bm, provider)["bm-search"]("missing")
assert "No results" in out
assert "missing" in out
finally:
actor.shutdown()
def test_bm_search_actor_exception_returns_plain_string(bm):
session = FakeSession()
def _boom(_args):
raise RuntimeError("MCP transport closed")
session.stub("search_notes", _boom)
provider, actor = _ready_provider(bm, session)
try:
out = _handlers_by_name(bm, provider)["bm-search"]("anything")
assert isinstance(out, str)
assert out.startswith("bm-search:")
assert "MCP transport closed" in out
finally:
actor.shutdown()
# ---- /bm-read ----
def test_bm_read_returns_text_body(bm):
"""BM's read_note returns markdown wrapped in {"text": "..."} once our
extractor wraps the non-JSON response. The handler should unwrap and
return the bare markdown."""
session = FakeSession()
session.stub(
"read_note",
# FakeSession serializes whatever the handler returns; emit the JSON
# the wrapper would produce for a markdown response.
lambda args: {"text": "# Foo\n\nbody text"},
)
provider, actor = _ready_provider(bm, session)
try:
out = _handlers_by_name(bm, provider)["bm-read"]("foo")
assert out == "# Foo\n\nbody text"
assert session.calls[-1][1]["identifier"] == "foo"
finally:
actor.shutdown()
# ---- /bm-recent ----
def test_bm_recent_default_timeframe(bm):
session = FakeSession(default_response={"results": []})
provider, actor = _ready_provider(bm, session)
try:
out = _handlers_by_name(bm, provider)["bm-recent"]("")
assert "7d" in out
assert session.calls[-1][1]["timeframe"] == "7d"
finally:
actor.shutdown()
def test_bm_recent_custom_timeframe(bm):
session = FakeSession()
session.stub(
"recent_activity",
lambda args: {"results": [{"title": "Recent thing", "permalink": "x/y"}]},
)
provider, actor = _ready_provider(bm, session)
try:
out = _handlers_by_name(bm, provider)["bm-recent"]("2 weeks")
assert "2 weeks" in out
assert "Recent thing" in out
assert session.calls[-1][1]["timeframe"] == "2 weeks"
finally:
actor.shutdown()
def test_bm_recent_bare_list_shape(bm):
"""Regression: BM's `recent_activity(output_format="json")` returns a bare
`list[dict]` (signature: `-> str | list[dict]`), not a dict-with-results.
The handler must surface those rows, not report "no activity"."""
session = FakeSession()
session.stub(
"recent_activity",
lambda args: [
{"title": "Edited yesterday", "permalink": "notes/a", "content": "blob"},
{"title": "Edited 3d ago", "permalink": "notes/b"},
],
)
provider, actor = _ready_provider(bm, session)
try:
out = _handlers_by_name(bm, provider)["bm-recent"]("")
assert "Edited yesterday" in out
assert "Edited 3d ago" in out
assert "No activity" not in out
finally:
actor.shutdown()
# ---- /bm-remember ----
def test_bm_remember_derives_title_from_first_line(bm):
captured = {}
def _write(args):
captured.update(args)
return {"permalink": "bm-remember/note-perm"}
session = FakeSession()
session.stub("write_note", _write)
provider, actor = _ready_provider(bm, session)
provider._remember_folder = "bm-remember"
try:
out = _handlers_by_name(bm, provider)["bm-remember"](
"Quarterly OKR review notes\n\nWe agreed to ship X."
)
assert "Saved:" in out
assert "bm-remember/note-perm" in out
assert captured["title"] == "Quarterly OKR review notes"
assert captured["directory"] == "bm-remember"
assert "manual-capture" in captured["tags"]
finally:
actor.shutdown()
def test_bm_remember_long_first_line_truncated_to_80(bm):
session = FakeSession(default_response={"permalink": "x"})
provider, actor = _ready_provider(bm, session)
try:
long_line = "A" * 200
_handlers_by_name(bm, provider)["bm-remember"](long_line)
title = session.calls[-1][1]["title"]
assert len(title) == 80
finally:
actor.shutdown()
def test_bm_remember_uses_configured_folder(bm):
session = FakeSession(default_response={"permalink": "x"})
provider, actor = _ready_provider(bm, session)
provider._remember_folder = "scratch"
try:
_handlers_by_name(bm, provider)["bm-remember"]("hello")
assert session.calls[-1][1]["directory"] == "scratch"
finally:
actor.shutdown()
# ---- /bm-project ----
def test_bm_project_lists_and_marks_active(bm):
session = FakeSession()
session.stub(
"list_memory_projects",
lambda args: {
"projects": [
{"name": "other-proj"},
{"name": "test-proj"},
]
},
)
provider, actor = _ready_provider(bm, session)
try:
out = _handlers_by_name(bm, provider)["bm-project"]("")
assert "other-proj" in out
assert "test-proj" in out
# Active project line includes the marker
active_line = next(line for line in out.splitlines() if "test-proj" in line)
assert "active" in active_line
finally:
actor.shutdown()
# ---- /bm-workspace ----
def test_bm_workspace_local_mode_message(bm):
provider, actor = _ready_provider(bm)
provider._mode = "local"
try:
out = _handlers_by_name(bm, provider)["bm-workspace"]("")
assert "Cloud" in out
assert "local" in out
finally:
actor.shutdown()
def test_bm_workspace_cloud_mode_lists(bm):
session = FakeSession()
session.stub(
"list_workspaces",
lambda args: {
"workspaces": [
{
"name": "Personal",
"workspace_type": "personal",
"role": "owner",
"is_default": True,
},
{"name": "Acme", "workspace_type": "team", "role": "member"},
]
},
)
provider, actor = _ready_provider(bm, session)
provider._mode = "cloud"
try:
out = _handlers_by_name(bm, provider)["bm-workspace"]("")
assert "Personal" in out
assert "Acme" in out
assert "default" in out
finally:
actor.shutdown()
def test_bm_workspace_lazily_initializes_before_mode_check(bm, monkeypatch):
session = FakeSession()
session.stub(
"list_workspaces",
lambda args: {"workspaces": [{"name": "Personal", "workspace_type": "personal"}]},
)
provider = bm.BasicMemoryProvider()
calls = []
def fake_initialize(*args, **kwargs):
calls.append((args, kwargs))
actor = make_scripted_actor(bm, session=session)
actor.start()
provider._actor = actor
provider._initialized = True
provider._mode = "cloud"
monkeypatch.setattr(provider, "initialize", fake_initialize)
try:
out = _handlers_by_name(bm, provider)["bm-workspace"]("")
assert "Personal" in out
assert "no workspaces to list" not in out
assert calls
assert session.calls[-1][0] == "list_workspaces"
finally:
if provider._actor is not None:
provider._actor.shutdown()
# ---- _unwrap_json_or_text helper ----
def test_unwrap_passes_through_raw_string(bm):
assert bm._unwrap_json_or_text("plain text") == "plain text"
def test_unwrap_returns_inner_json_when_text_wraps_json(bm):
outer = json.dumps({"text": json.dumps({"a": 1})})
assert bm._unwrap_json_or_text(outer) == {"a": 1}
def test_unwrap_returns_text_value_when_inner_is_markdown(bm):
outer = json.dumps({"text": "# Heading\n\nbody"})
assert bm._unwrap_json_or_text(outer) == "# Heading\n\nbody"
def test_unwrap_returns_dict_when_top_level_json(bm):
outer = json.dumps({"results": [1, 2]})
assert bm._unwrap_json_or_text(outer) == {"results": [1, 2]}
# ---- _remember_title ----
def test_remember_title_strips_markdown_heading(bm):
assert bm._remember_title("# Decisions\n\nbody") == "Decisions"
def test_remember_title_skips_blank_lines(bm):
assert bm._remember_title("\n\nFirst real line\nrest") == "First real line"
def test_remember_title_falls_back_to_timestamp(bm):
title = bm._remember_title(" \n\n")
assert title.startswith("Note ")
+531
View File
@@ -0,0 +1,531 @@
"""Unit tests for the pure helpers in __init__.py."""
import json
import pytest
# ---- _truncate ----
def test_truncate_short_passes_through(bm):
assert bm._truncate("hello", 10) == "hello"
def test_truncate_long_gets_ellipsis(bm):
out = bm._truncate("a" * 100, 10)
assert out.endswith("...")
assert len(out) == 10
def test_truncate_non_string_coerced(bm):
assert bm._truncate(42, 10) == "42"
def test_truncate_none(bm):
assert bm._truncate(None, 10) == ""
# ---- _join_message_content ----
def test_join_string_content(bm):
assert bm._join_message_content("hello") == "hello"
def test_join_list_of_dicts(bm):
parts = [{"text": "a"}, {"text": "b"}, {"content": "c"}]
assert bm._join_message_content(parts) == "a\nb\nc"
def test_join_list_of_strings(bm):
parts = ["a", "b"]
assert bm._join_message_content(parts) == "a\nb"
def test_join_mixed(bm):
parts = ["a", {"text": "b"}, {"foo": "bar"}, "c"]
assert bm._join_message_content(parts) == "a\nb\nc"
def test_join_none(bm):
assert bm._join_message_content(None) == ""
# ---- _coerce_bool ----
@pytest.mark.parametrize(
"value,expected",
[
(True, True),
(False, False),
("true", True),
("True", True),
("YES", True),
("1", True),
("y", True),
("false", False),
("False", False),
("NO", False),
("0", False),
("n", False),
],
)
def test_coerce_bool(bm, value, expected):
assert bm._coerce_bool(value) is expected
def test_coerce_bool_non_bool_passes_through(bm):
assert bm._coerce_bool(42) == 42
assert bm._coerce_bool("hello") == "hello"
# ---- _extract_mcp_text ----
def test_extract_mcp_text_passes_json_through(bm, fake_result):
payload = json.dumps({"permalink": "foo/bar", "title": "T"})
out = bm._extract_mcp_text(fake_result([payload]))
assert json.loads(out)["permalink"] == "foo/bar"
def test_extract_mcp_text_wraps_markdown(bm, fake_result):
md = "# Created note\npermalink: foo/bar"
out = bm._extract_mcp_text(fake_result([md]))
parsed = json.loads(out)
assert parsed["text"] == md
def test_extract_mcp_text_joins_multiple_blocks(bm, fake_result):
out = bm._extract_mcp_text(fake_result(["a", "b"]))
parsed = json.loads(out)
assert parsed["text"] == "a\nb"
def test_extract_mcp_text_empty(bm, fake_result):
out = bm._extract_mcp_text(fake_result([]))
assert json.loads(out) == {"ok": True}
def test_extract_mcp_text_error(bm, fake_result):
out = bm._extract_mcp_text(fake_result(["something broke"], is_error=True))
assert "error" in json.loads(out)
# ---- _extract_permalink ----
def test_extract_permalink_from_bare_json(bm):
text = json.dumps({"permalink": "proj/folder/note", "title": "T"})
assert bm._extract_permalink(text, "fb") == "proj/folder/note"
def test_extract_permalink_from_wrapped_json(bm):
text = json.dumps({"text": json.dumps({"permalink": "proj/folder/note"})})
assert bm._extract_permalink(text, "fb") == "proj/folder/note"
def test_extract_permalink_from_wrapped_markdown(bm):
md = (
"# Created note\n"
"project: hermes-jodys-imac\n"
"file_path: x/y.md\n"
"permalink: hermes-jodys-imac/folder/slug-name\n"
"checksum: unknown\n"
)
text = json.dumps({"text": md})
assert bm._extract_permalink(text, "fb") == "hermes-jodys-imac/folder/slug-name"
def test_extract_permalink_from_raw_markdown(bm):
md = "# Created note\npermalink: proj/folder/slug\n"
# Raw, not wrapped — strategy 4 path
assert bm._extract_permalink(md, "fb") == "proj/folder/slug"
def test_extract_permalink_no_match(bm):
assert bm._extract_permalink('{"foo":"bar"}', "fallback-title") == "fallback-title"
def test_extract_permalink_empty(bm):
assert bm._extract_permalink("", "fb") == "fb"
def test_extract_permalink_invalid(bm):
assert bm._extract_permalink("not json or markdown", "fb") == "fb"
def test_extract_permalink_strips_trailing_punct(bm):
md = "# Created note\npermalink: proj/folder/slug,"
assert bm._extract_permalink(md, "fb") == "proj/folder/slug"
# ---- _translate_args ----
def test_translate_search(bm):
tool, args = bm._translate_args("bm_search", {"query": "hi", "limit": 7}, "proj")
assert tool == "search_notes"
assert args == {"project": "proj", "query": "hi", "page_size": 7}
def test_translate_search_no_limit(bm):
tool, args = bm._translate_args("bm_search", {"query": "hi"}, "proj")
assert tool == "search_notes"
assert args == {"project": "proj", "query": "hi"}
def test_translate_read(bm):
tool, args = bm._translate_args("bm_read", {"identifier": "x/y"}, "proj")
assert tool == "read_note"
assert args == {"project": "proj", "identifier": "x/y"}
def test_translate_read_workspace_qualified_identifier_self_routes(bm):
tool, args = bm._translate_args(
"bm_read",
{"identifier": "personal/main/scratch/note"},
"hermes-memory",
)
assert tool == "read_note"
assert args == {"identifier": "personal/main/scratch/note"}
def test_translate_read_org_workspace_qualified_identifier_self_routes(bm):
tool, args = bm._translate_args(
"bm_read",
{"identifier": "basic-memory-7020de4e925843c68c9056c60d101d9e/main/scratch/note"},
"hermes-memory",
)
assert tool == "read_note"
assert args == {"identifier": "basic-memory-7020de4e925843c68c9056c60d101d9e/main/scratch/note"}
def test_translate_write(bm):
tool, args = bm._translate_args(
"bm_write",
{"title": "T", "content": "C", "folder": "F", "tags": ["a", "b"]},
"proj",
)
assert tool == "write_note"
assert args == {
"project": "proj",
"title": "T",
"content": "C",
"directory": "F",
"tags": ["a", "b"],
}
def test_translate_write_no_tags(bm):
tool, args = bm._translate_args(
"bm_write",
{"title": "T", "content": "C", "folder": "F"},
"proj",
)
assert tool == "write_note"
assert "tags" not in args
assert args["directory"] == "F"
def test_translate_edit_minimal(bm):
tool, args = bm._translate_args(
"bm_edit",
{"identifier": "x", "operation": "append", "content": "more"},
"proj",
)
assert tool == "edit_note"
assert args == {
"project": "proj",
"identifier": "x",
"operation": "append",
"content": "more",
}
def test_translate_edit_find_replace(bm):
tool, args = bm._translate_args(
"bm_edit",
{
"identifier": "x",
"operation": "find_replace",
"content": "new",
"find_text": "old",
},
"proj",
)
assert args["find_text"] == "old"
def test_translate_edit_replace_section(bm):
tool, args = bm._translate_args(
"bm_edit",
{
"identifier": "x",
"operation": "replace_section",
"content": "new",
"section": "## Notes",
},
"proj",
)
assert args["section"] == "## Notes"
def test_translate_context(bm):
tool, args = bm._translate_args("bm_context", {"url": "memory://x", "depth": 2}, "proj")
assert tool == "build_context"
assert args == {"project": "proj", "url": "memory://x", "depth": 2}
def test_translate_context_workspace_qualified_url_self_routes(bm):
tool, args = bm._translate_args(
"bm_context",
{"url": "memory://personal/main/scratch/note", "depth": 1},
"hermes-memory",
)
assert tool == "build_context"
assert args == {"url": "memory://personal/main/scratch/note", "depth": 1}
def test_translate_context_org_workspace_qualified_url_self_routes(bm):
tool, args = bm._translate_args(
"bm_context",
{
"url": "memory://basic-memory-7020de4e925843c68c9056c60d101d9e/main/scratch/note",
"depth": 1,
},
"hermes-memory",
)
assert tool == "build_context"
assert args == {
"url": "memory://basic-memory-7020de4e925843c68c9056c60d101d9e/main/scratch/note",
"depth": 1,
}
def test_translate_delete(bm):
tool, args = bm._translate_args("bm_delete", {"identifier": "x"}, "proj")
assert tool == "delete_note"
assert args == {"project": "proj", "identifier": "x"}
def test_translate_move(bm):
tool, args = bm._translate_args(
"bm_move", {"identifier": "x", "new_folder": "archive/2026"}, "proj"
)
assert tool == "move_note"
assert args == {
"project": "proj",
"identifier": "x",
"destination_folder": "archive/2026",
}
def test_translate_recent_defaults(bm):
tool, args = bm._translate_args("bm_recent", {}, "proj")
assert tool == "recent_activity"
assert args == {"project": "proj"}
def test_translate_recent_full(bm):
tool, args = bm._translate_args(
"bm_recent",
{"timeframe": "2 weeks", "limit": 25, "type": "entity"},
"proj",
)
assert tool == "recent_activity"
assert args == {
"project": "proj",
"timeframe": "2 weeks",
"page_size": 25,
"type": "entity",
}
# ---- Per-call project routing ----
def test_translate_uses_default_project_when_no_override(bm):
"""Existing behavior preserved: with no project override, the configured
default flows through."""
_, args = bm._translate_args("bm_search", {"query": "hi"}, "default-proj")
assert args["project"] == "default-proj"
assert "project_id" not in args
def test_translate_uses_project_name_override(bm):
"""Agent passes project="main" → that name reaches BM, not the default."""
_, args = bm._translate_args("bm_search", {"query": "hi", "project": "main"}, "default-proj")
assert args["project"] == "main"
assert "project_id" not in args
def test_translate_uses_project_id_override(bm):
"""Agent passes project_id=<uuid> → reaches BM as project_id, with no
project name in the call (would be redundant and risk server-side
precedence surprises)."""
uuid = "bf2a4c1e-d77f-4b7a-9c3e-5d8a1f0e2b6d"
_, args = bm._translate_args("bm_search", {"query": "hi", "project_id": uuid}, "default-proj")
assert args["project_id"] == uuid
assert "project" not in args
def test_translate_project_id_wins_when_both_supplied(bm):
"""If the agent passes both, project_id is the more specific identifier
(UUID across workspaces) and takes precedence. Only project_id reaches BM."""
uuid = "bf2a4c1e-d77f-4b7a-9c3e-5d8a1f0e2b6d"
_, args = bm._translate_args(
"bm_search",
{"query": "hi", "project": "main", "project_id": uuid},
"default-proj",
)
assert args["project_id"] == uuid
assert "project" not in args
def test_translate_routing_coerces_to_string(bm):
"""Defensive: if a model passes a non-string identifier (e.g. an int),
coerce rather than crash. BM accepts strings."""
_, args = bm._translate_args("bm_search", {"query": "hi", "project_id": 12345}, "default-proj")
assert args["project_id"] == "12345"
@pytest.mark.parametrize(
"tool,base_args",
[
("bm_search", {"query": "x"}),
("bm_read", {"identifier": "x"}),
("bm_write", {"title": "t", "content": "c", "folder": "f"}),
("bm_edit", {"identifier": "x", "operation": "append", "content": "c"}),
("bm_context", {"url": "memory://x"}),
("bm_delete", {"identifier": "x"}),
("bm_move", {"identifier": "x", "new_folder": "f"}),
("bm_recent", {}),
],
)
def test_translate_routing_works_for_every_tool(bm, tool, base_args):
"""Routing applies uniformly across every per-project tool. Global
discovery tools (bm_projects, bm_workspaces) are tested separately."""
args_with = dict(base_args, project="main")
_, out = bm._translate_args(tool, args_with, "default-proj")
assert out["project"] == "main"
args_with_id = dict(base_args, project_id="e1d3a5b8-0492-4c1f-8e7d-2a4b6c8d0e2f")
_, out = bm._translate_args(tool, args_with_id, "default-proj")
assert out["project_id"] == "e1d3a5b8-0492-4c1f-8e7d-2a4b6c8d0e2f"
assert "project" not in out
_, out = bm._translate_args(tool, base_args, "default-proj")
assert out["project"] == "default-proj"
# ---- Global discovery tools (bm_projects, bm_workspaces) ----
def test_translate_bm_projects_no_routing(bm):
"""bm_projects is a global discovery tool — it lists across all projects
and workspaces. _translate_args must NOT inject a default project
(would make BM scope the listing) and MUST request JSON so the agent
can parse identifiers out of the response."""
tool, out = bm._translate_args("bm_projects", {}, "default-proj")
assert tool == "list_memory_projects"
assert "project" not in out
assert "project_id" not in out
assert out == {"output_format": "json"}
def test_translate_bm_workspaces_no_routing(bm):
tool, out = bm._translate_args("bm_workspaces", {}, "default-proj")
assert tool == "list_workspaces"
assert "project" not in out
assert "project_id" not in out
assert out == {"output_format": "json"}
def test_translate_global_tools_ignore_project_kwargs(bm):
"""Even if a confused caller passes project/project_id to a global tool,
those args are dropped BM doesn't accept them and silently scoping
the listing would be worse than ignoring the args."""
_, out = bm._translate_args(
"bm_projects",
{"project": "main", "project_id": "e1d3a5b8-0492-4c1f-8e7d-2a4b6c8d0e2f"},
"default-proj",
)
assert "project" not in out
assert "project_id" not in out
# ---- TOOL_SCHEMAS routing properties ----
def test_every_tool_schema_advertises_project_routing(bm):
"""Every per-project bm_* tool must expose `project` and `project_id` so
the agent sees them in the tool surface. Regression: forgetting to add
routing props to a new tool would silently lock the agent into the
active project exactly the friction Drew's note flagged.
Global discovery tools (bm_projects, bm_workspaces) are excluded they
list across projects/workspaces and don't take routing args."""
for schema in bm.TOOL_SCHEMAS:
props = schema["parameters"]["properties"]
if schema["name"] in bm._GLOBAL_TOOLS:
assert "project" not in props, (
f"{schema['name']} is a global tool; should not have project prop"
)
assert "project_id" not in props, (
f"{schema['name']} is a global tool; should not have project_id prop"
)
continue
assert "project" in props, f"{schema['name']} missing project prop"
assert "project_id" in props, f"{schema['name']} missing project_id prop"
# Routing is always optional — never in `required`.
required = schema["parameters"].get("required", [])
assert "project" not in required
assert "project_id" not in required
# ---- _default_project / _hostname ----
def test_default_project_format(bm):
p = bm._default_project()
assert p.startswith("hermes-")
# Hostnames are lowercased and stripped
assert " " not in p
def test_hostname_lowercased(bm, monkeypatch):
monkeypatch.setattr(bm.socket, "gethostname", lambda: "Some.Long.Host")
assert bm._hostname() == "some"
# ---- TOOL_SCHEMAS ----
def test_tool_schemas_complete(bm):
names = {s["name"] for s in bm.TOOL_SCHEMAS}
expected = {
"bm_search",
"bm_read",
"bm_write",
"bm_edit",
"bm_context",
"bm_delete",
"bm_move",
"bm_recent",
"bm_projects",
"bm_workspaces",
}
assert names == expected
def test_tool_schemas_have_descriptions(bm):
for s in bm.TOOL_SCHEMAS:
assert s["description"], f"{s['name']} missing description"
assert "parameters" in s
assert s["parameters"]["type"] == "object"
def test_hermes_to_bm_complete(bm):
assert set(bm._HERMES_TO_BM.keys()) == {s["name"] for s in bm.TOOL_SCHEMAS}
@@ -0,0 +1,423 @@
"""
Gated integration tests against a real basic-memory MCP server.
These tests spin up the real `bm mcp` subprocess via the production actor and
exercise every tool through `handle_tool_call`, mirroring the production code
path. They are skipped unless BOTH:
BM_INTEGRATION=1
AND `bm` is installed AND `mcp` Python package is importable
A throwaway BM project is created for the test session and removed afterward,
so these tests never touch your real BM projects.
Run them with:
BM_INTEGRATION=1 uv run --with pytest --with mcp pytest tests/test_integration.py
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import tempfile
import time
import uuid
import pytest
# ---- Gating ----
_INTEGRATION_ENABLED = os.environ.get("BM_INTEGRATION") == "1"
_BM_BIN = shutil.which("bm") or (
os.path.expanduser("~/.local/bin/bm")
if os.path.isfile(os.path.expanduser("~/.local/bin/bm"))
else None
)
try:
import mcp # noqa: F401
_MCP_OK = True
except Exception:
_MCP_OK = False
pytestmark = [
pytest.mark.skipif(
not _INTEGRATION_ENABLED,
reason="set BM_INTEGRATION=1 to run integration tests",
),
pytest.mark.skipif(_BM_BIN is None, reason="bm CLI not on PATH"),
pytest.mark.skipif(not _MCP_OK, reason="mcp Python package not installed"),
]
# ---- Session-scoped BM project ----
@pytest.fixture(scope="session")
def temp_bm_project():
"""Create a throwaway BM project for the session; remove when done."""
project_name = f"hermes-bm-test-{uuid.uuid4().hex[:8]}"
project_dir = tempfile.mkdtemp(prefix=f"{project_name}-")
# Register
subprocess.run(
[_BM_BIN, "project", "add", project_name, project_dir],
check=False,
capture_output=True,
timeout=20,
)
yield project_name, project_dir
# Tear down
subprocess.run(
[_BM_BIN, "project", "remove", project_name],
check=False,
capture_output=True,
timeout=20,
)
shutil.rmtree(project_dir, ignore_errors=True)
@pytest.fixture
def provider(bm, temp_bm_project, tmp_path):
"""Initialized provider pointing at the temp project."""
project_name, project_dir = temp_bm_project
# Pre-write a config file in this test's hermes_home so initialize picks it up
cfg = {
"mode": "local",
"project": project_name,
"project_path": project_dir,
"capture_per_turn": True,
"capture_session_end": True,
"capture_folder": "test-sessions",
}
(tmp_path / "basic-memory.json").write_text(json.dumps(cfg))
p = bm.BasicMemoryProvider()
p.initialize(
session_id=f"integration-{uuid.uuid4().hex[:6]}",
hermes_home=str(tmp_path),
platform="cli",
)
if not p._initialized:
pytest.fail("Provider failed to initialize against the real bm MCP server")
yield p
p.shutdown()
def _parse_tool_result(raw):
try:
d = json.loads(raw)
except Exception:
return None
return d
# ---- Actor smoke ----
def test_actor_starts_and_lists_expected_tools(provider, bm):
tools = {t["name"] for t in provider._actor.list_tools()}
expected = set(bm._HERMES_TO_BM.values())
missing = expected - tools
assert not missing, f"BM MCP server missing tools we depend on: {missing}"
# ---- Tool surface ----
def test_bm_write_returns_full_permalink(provider, bm):
title = f"Integration Write Test {uuid.uuid4().hex[:6]}"
raw = provider.handle_tool_call(
"bm_write",
{
"title": title,
"content": f"# {title}\n\nbody.\n",
"folder": "tests",
"tags": ["integration"],
},
)
permalink = bm._extract_permalink(raw, "")
assert permalink, f"no permalink extracted from: {raw[:300]}"
# BM permalinks include the project prefix
assert permalink.split("/")[0] == provider._project, (
f"permalink should start with project name: {permalink}"
)
def test_bm_read_round_trips_a_written_note(provider, bm):
title = f"Read RT {uuid.uuid4().hex[:6]}"
body = f"# {title}\n\nMARKER-{uuid.uuid4().hex}\n"
raw = provider.handle_tool_call(
"bm_write",
{
"title": title,
"content": body,
"folder": "tests",
},
)
permalink = bm._extract_permalink(raw, "")
raw = provider.handle_tool_call("bm_read", {"identifier": permalink})
d = _parse_tool_result(raw)
text = (d or {}).get("text") or json.dumps(d or {})
assert title in text
def test_bm_edit_append_lands_in_note(provider, bm):
title = f"Append Test {uuid.uuid4().hex[:6]}"
raw = provider.handle_tool_call(
"bm_write",
{
"title": title,
"content": f"# {title}\nseed\n",
"folder": "tests",
},
)
permalink = bm._extract_permalink(raw, "")
marker = f"APPEND-MARKER-{uuid.uuid4().hex}"
provider.handle_tool_call(
"bm_edit",
{
"identifier": permalink,
"operation": "append",
"content": f"\n{marker}\n",
},
)
raw = provider.handle_tool_call("bm_read", {"identifier": permalink})
d = _parse_tool_result(raw)
text = (d or {}).get("text") or json.dumps(d or {})
assert marker in text
def test_bm_edit_replace_section_swaps_content(provider, bm):
title = f"ReplaceSection {uuid.uuid4().hex[:6]}"
body = f"# {title}\n\n## Notes\noriginal-body\n"
raw = provider.handle_tool_call(
"bm_write",
{
"title": title,
"content": body,
"folder": "tests",
},
)
permalink = bm._extract_permalink(raw, "")
new_marker = f"REPLACED-{uuid.uuid4().hex}"
provider.handle_tool_call(
"bm_edit",
{
"identifier": permalink,
"operation": "replace_section",
"section": "## Notes",
"content": new_marker,
},
)
raw = provider.handle_tool_call("bm_read", {"identifier": permalink})
d = _parse_tool_result(raw)
text = (d or {}).get("text") or json.dumps(d or {})
assert new_marker in text
assert "original-body" not in text
def test_bm_search_finds_a_freshly_written_note(provider, bm):
unique = f"SEARCH-MARKER-{uuid.uuid4().hex}"
title = f"Search Test {unique}"
provider.handle_tool_call(
"bm_write",
{
"title": title,
"content": f"# {title}\nbody.\n",
"folder": "tests",
},
)
raw = provider.handle_tool_call("bm_search", {"query": unique, "limit": 5})
d = _parse_tool_result(raw)
text = (d or {}).get("text") or json.dumps(d or {})
assert unique in text or title in text
def test_bm_context_returns_results(provider, bm):
title = f"Context Test {uuid.uuid4().hex[:6]}"
raw = provider.handle_tool_call(
"bm_write",
{
"title": title,
"content": f"# {title}\n",
"folder": "tests",
},
)
permalink = bm._extract_permalink(raw, "")
raw = provider.handle_tool_call(
"bm_context",
{
"url": f"memory://{permalink}",
"depth": 1,
},
)
d = _parse_tool_result(raw)
assert d is not None
# build_context returns a JSON dict with `results` (and other fields)
text_blob = json.dumps(d)
assert "results" in text_blob
def test_bm_move_relocates_note(provider, bm):
"""
BM permalinks are stable IDs that don't change on move — only the
file_path moves. So we verify by:
1. The move response itself reports the new destination
2. Reading by the original permalink still succeeds (note wasn't lost)
"""
title = f"Move Test {uuid.uuid4().hex[:6]}"
raw = provider.handle_tool_call(
"bm_write",
{
"title": title,
"content": f"# {title}\n",
"folder": "tests",
},
)
permalink = bm._extract_permalink(raw, "")
assert permalink, "expected a permalink from bm_write"
raw = provider.handle_tool_call(
"bm_move",
{
"identifier": permalink,
"new_folder": "tests/archive",
},
)
d = _parse_tool_result(raw)
move_text = (d or {}).get("text") or json.dumps(d or {})
# Move response text reports both the old and new locations
assert "moved successfully" in move_text.lower() or "moved" in move_text.lower(), (
f"move response missing success indicator: {move_text[:200]}"
)
assert "tests/archive" in move_text, f"move response missing new folder: {move_text[:200]}"
# Permalink is stable — reading by it should still work
raw = provider.handle_tool_call("bm_read", {"identifier": permalink})
d = _parse_tool_result(raw)
read_text = (d or {}).get("text") or json.dumps(d or {})
assert title in read_text, "note should still be readable after move"
def test_bm_delete_removes_note(provider, bm):
title = f"Delete Test {uuid.uuid4().hex[:6]}"
raw = provider.handle_tool_call(
"bm_write",
{
"title": title,
"content": f"# {title}\n",
"folder": "tests",
},
)
permalink = bm._extract_permalink(raw, "")
provider.handle_tool_call("bm_delete", {"identifier": permalink})
# Read should now indicate "not found"
raw = provider.handle_tool_call("bm_read", {"identifier": permalink})
d = _parse_tool_result(raw)
text = (d or {}).get("text") or json.dumps(d or {})
assert "not found" in text.lower() or "no notes found" in text.lower(), (
f"expected a 'not found' indication, got: {text[:200]}"
)
# ---- Capture pipeline ----
def test_sync_turn_writes_then_appends_to_same_session_note(provider, bm):
# First turn — creates the session note
provider.sync_turn("integration turn-1 user", "integration turn-1 assistant")
if provider._sync_thread:
provider._sync_thread.join(timeout=20.0)
sid_1 = provider._session_note_id
assert sid_1, "first sync_turn should set _session_note_id"
assert sid_1.startswith(provider._project + "/"), (
f"session_note_id should include project prefix, got: {sid_1}"
)
# Second turn — should append to the same note
marker = f"TURN-2-MARKER-{uuid.uuid4().hex}"
provider.sync_turn("integration turn-2 user", marker)
if provider._sync_thread:
provider._sync_thread.join(timeout=20.0)
sid_2 = provider._session_note_id
assert sid_2 == sid_1, "session_note_id should NOT change between turns"
# Verify both turn markers are present in the persisted note
raw = provider.handle_tool_call("bm_read", {"identifier": sid_1})
d = _parse_tool_result(raw)
text = (d or {}).get("text") or json.dumps(d or {})
assert "integration turn-1 user" in text
assert marker in text
def test_on_session_end_writes_summary_with_relations(provider, bm):
# Seed a session note via sync_turn
provider.sync_turn("first message", "first reply")
if provider._sync_thread:
provider._sync_thread.join(timeout=20.0)
sid = provider._session_note_id
assert sid
provider.on_session_end(
[
{"role": "user", "content": "first message"},
{"role": "assistant", "content": "first reply"},
]
)
# Search for the summary
raw = provider.handle_tool_call(
"bm_search",
{
"query": "Hermes Session Summary",
"limit": 5,
},
)
d = _parse_tool_result(raw)
text = (d or {}).get("text") or json.dumps(d or {})
assert "Hermes Session Summary" in text
def test_prefetch_against_real_bm(provider, bm):
# Seed a recognizable note
unique = f"PREFETCH-MARKER-{uuid.uuid4().hex}"
provider.handle_tool_call(
"bm_write",
{
"title": f"Prefetch Test {unique}",
"content": f"# Prefetch Test\n{unique}\n",
"folder": "tests",
},
)
# BM's FTS index is updated synchronously inside the write_note API
# path (knowledge_router.py:272), so this loop is really only smoothing
# over the round-trip cost of a few RPCs on a slow runner. prefetch
# explicitly requests search_type="text" so we don't get pulled onto
# BM's hybrid path, where vector indexing is async and would race the
# search.
budget_secs = 10.0
deadline = time.monotonic() + budget_secs
out = ""
attempts = 0
while time.monotonic() < deadline:
attempts += 1
out = provider.prefetch(unique)
if out:
break
time.sleep(0.25)
assert out, (
f"prefetch returned nothing after {attempts} attempt(s) over "
f"{budget_secs}s; provider._failure_count={provider._failure_count}, "
f"circuit_open={provider._is_circuit_open()}. "
f"Either BM didn't index the note in time or prefetch's actor.call "
f"is timing out internally."
)
assert "Basic Memory Recall" in out
assert unique in out or "Prefetch Test" in out
+296
View File
@@ -0,0 +1,296 @@
"""Tests for prefetch / queue_prefetch / _format_prefetch."""
from __future__ import annotations
import json
import time
from unittest.mock import MagicMock
import pytest
def _initialized_provider(bm):
p = bm.BasicMemoryProvider()
p._initialized = True
p._project = "test-proj"
p._actor = MagicMock()
return p
# ---- prefetch ----
def test_prefetch_returns_cached_value_drained(bm):
p = _initialized_provider(bm)
p._pending_prefetch = "## cached recall"
out = p.prefetch("any query")
assert out == "## cached recall"
# Cache must be drained so the next prefetch doesn't return stale results
assert p._pending_prefetch == ""
p._actor.call.assert_not_called()
def test_prefetch_calls_search_when_cache_empty(bm):
p = _initialized_provider(bm)
p._actor.call.return_value = json.dumps(
{"results": [{"title": "T", "permalink": "p/t", "content": "c"}]}
)
out = p.prefetch("hello world")
assert "## Basic Memory Recall" in out
assert "**T**" in out
p._actor.call.assert_called_once()
bm_tool, bm_args = p._actor.call.call_args[0][:2]
assert bm_tool == "search_notes"
assert bm_args["query"] == "hello world"
assert bm_args["page_size"] == 5
assert bm_args["output_format"] == "json"
# Pin search_type=text so BM doesn't fall into the hybrid+async-vector
# path on the prefetch hot path. See prefetch() comment for rationale.
assert bm_args["search_type"] == "text"
def test_prefetch_returns_empty_when_uninitialized(bm):
p = bm.BasicMemoryProvider()
assert p.prefetch("x") == ""
def test_prefetch_returns_empty_when_circuit_open(bm):
p = _initialized_provider(bm)
p._failure_pause_until = time.monotonic() + 60.0
assert p.prefetch("x") == ""
p._actor.call.assert_not_called()
def test_prefetch_records_failure_on_actor_error(bm):
p = _initialized_provider(bm)
p._actor.call.side_effect = RuntimeError("boom")
assert p.prefetch("x") == ""
assert p._failure_count == 1
def test_prefetch_returns_empty_for_empty_results(bm):
p = _initialized_provider(bm)
p._actor.call.return_value = json.dumps({"results": []})
assert p.prefetch("x") == ""
# ---- queue_prefetch ----
def test_queue_prefetch_fills_cache_in_background(bm):
p = _initialized_provider(bm)
p._actor.call.return_value = json.dumps(
{"results": [{"title": "Bg", "permalink": "p/bg", "content": "c"}]}
)
p.queue_prefetch("user typed something")
# Wait for the daemon thread to finish
if p._prefetch_thread:
p._prefetch_thread.join(timeout=5.0)
# Cache should now have the formatted result
assert "**Bg**" in p._pending_prefetch
# And subsequent prefetch returns it without making another call
p._actor.call.reset_mock()
out = p.prefetch("anything")
assert "**Bg**" in out
p._actor.call.assert_not_called()
def test_queue_prefetch_uses_longer_timeout_than_sync_prefetch(bm):
"""queue_prefetch runs in background, so it can afford a longer timeout."""
p = _initialized_provider(bm)
p._actor.call.return_value = json.dumps({"results": []})
p.queue_prefetch("q")
if p._prefetch_thread:
p._prefetch_thread.join(timeout=5.0)
timeout = p._actor.call.call_args.kwargs.get("timeout") or p._actor.call.call_args[1].get(
"timeout"
)
# Background prefetch is more patient than the foreground 3.0s
assert timeout is not None and timeout > 3.0
def test_queue_prefetch_skipped_when_thread_in_flight(bm):
p = _initialized_provider(bm)
# Simulate an already-running prefetch thread
class _StillAlive:
def is_alive(self):
return True
p._prefetch_thread = _StillAlive() # type: ignore[assignment]
p.queue_prefetch("q")
p._actor.call.assert_not_called()
def test_queue_prefetch_skipped_when_circuit_open(bm):
p = _initialized_provider(bm)
p._failure_pause_until = time.monotonic() + 60.0
p.queue_prefetch("q")
p._actor.call.assert_not_called()
def test_queue_prefetch_skipped_when_uninitialized(bm):
p = bm.BasicMemoryProvider()
p._actor = MagicMock()
p.queue_prefetch("q")
p._actor.call.assert_not_called()
def test_queue_prefetch_records_failure_on_bg_error(bm):
p = _initialized_provider(bm)
p._actor.call.side_effect = RuntimeError("backend down")
p.queue_prefetch("q")
if p._prefetch_thread:
p._prefetch_thread.join(timeout=5.0)
assert p._failure_count >= 1
# ---- _format_prefetch ----
def _format(bm, payload):
"""Helper: call _format_prefetch on a fresh provider."""
return bm.BasicMemoryProvider()._format_prefetch(payload)
def test_format_prefetch_with_results(bm):
payload = json.dumps(
{
"results": [
{"title": "A", "permalink": "p/a", "content": "first line"},
{"title": "B", "permalink": "p/b", "content": "second line"},
]
}
)
out = _format(bm, payload)
assert "## Basic Memory Recall" in out
assert "**A**" in out and "**B**" in out
assert "p/a" in out and "p/b" in out
def test_format_prefetch_caps_at_5_entries(bm):
results = [{"title": f"T{i}", "permalink": f"p/{i}", "content": "x"} for i in range(20)]
payload = json.dumps({"results": results})
out = _format(bm, payload)
# Five lines + one heading = 6 lines max
assert out.count("\n- **") == 5
def test_format_prefetch_caps_preview_length(bm):
payload = json.dumps(
{
"results": [
{"title": "T", "permalink": "p/t", "content": "x" * 5000},
]
}
)
out = _format(bm, payload)
# Each result line includes the preview, capped to 200 chars
line = [l for l in out.split("\n") if l.startswith("- ")][0]
# Some boilerplate around the preview, but the long preview is capped
assert len(line) < 400
def test_format_prefetch_collapses_whitespace(bm):
payload = json.dumps(
{
"results": [
{"title": "T", "permalink": "p/t", "content": "first\n\n second\tthird"},
]
}
)
out = _format(bm, payload)
assert "first second third" in out
def test_format_prefetch_falls_back_to_preview_field(bm):
"""BM may use 'preview' instead of 'content' in some response shapes."""
payload = json.dumps(
{
"results": [
{"title": "T", "permalink": "p/t", "preview": "preview-only"},
]
}
)
out = _format(bm, payload)
assert "preview-only" in out
def test_format_prefetch_handles_non_string_content(bm):
"""Defensive: BM could conceivably return non-string content fields."""
payload = json.dumps(
{
"results": [
{"title": "T", "permalink": "p/t", "content": 12345},
]
}
)
# Must not raise
out = _format(bm, payload)
assert "12345" in out
def test_format_prefetch_handles_missing_title_permalink(bm):
payload = json.dumps(
{
"results": [
{"content": "orphan note"},
]
}
)
out = _format(bm, payload)
assert "(untitled)" in out
assert "orphan note" in out
def test_format_prefetch_skips_non_dict_entries(bm):
payload = json.dumps(
{
"results": [
"not-a-dict",
{"title": "Real", "permalink": "p/r", "content": "x"},
]
}
)
out = _format(bm, payload)
assert "Real" in out
assert "not-a-dict" not in out
def test_format_prefetch_with_text_wrapped_results(bm):
"""BM text-format responses arrive wrapped as {"text": "..."} by _extract_mcp_text."""
inner = json.dumps({"results": [{"title": "X", "permalink": "p/x", "content": "c"}]})
payload = json.dumps({"text": inner})
out = _format(bm, payload)
assert "**X**" in out
def test_format_prefetch_empty(bm):
assert _format(bm, json.dumps({"results": []})) == ""
def test_format_prefetch_no_results_key(bm):
assert _format(bm, json.dumps({"foo": "bar"})) == ""
def test_format_prefetch_malformed(bm):
assert _format(bm, "not-json") == ""
def test_format_prefetch_handles_extra_unknown_fields(bm):
"""Forward-compatibility: unknown fields shouldn't break formatting."""
payload = json.dumps(
{
"results": [
{
"title": "T",
"permalink": "p/t",
"content": "c",
"future_field_42": {"nested": "value"},
"score": 0.9,
},
]
}
)
out = _format(bm, payload)
assert "**T**" in out
+637
View File
@@ -0,0 +1,637 @@
"""Tests for BasicMemoryProvider with the MCP actor mocked out."""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import MagicMock
def test_is_available_no_mcp(bm, monkeypatch):
monkeypatch.setattr(bm, "_MCP_AVAILABLE", False)
p = bm.BasicMemoryProvider()
assert p.is_available() is False
def test_is_available_no_bm_no_uv(bm, monkeypatch):
"""No bm AND no uv → can't install, can't operate → unavailable."""
monkeypatch.setattr(bm, "_MCP_AVAILABLE", True)
monkeypatch.setattr(bm, "_bm_binary_path", lambda: None)
monkeypatch.setattr(bm, "_uv_binary_path", lambda: None)
p = bm.BasicMemoryProvider()
assert p.is_available() is False
def test_is_available_bm_present(bm, monkeypatch):
"""bm already installed → available regardless of uv."""
monkeypatch.setattr(bm, "_MCP_AVAILABLE", True)
monkeypatch.setattr(bm, "_bm_binary_path", lambda: "/fake/bm")
monkeypatch.setattr(bm, "_uv_binary_path", lambda: None)
p = bm.BasicMemoryProvider()
assert p.is_available() is True
def test_is_available_bm_missing_but_uv_present(bm, monkeypatch):
"""bm missing but uv available → we can install bm at init time → available."""
monkeypatch.setattr(bm, "_MCP_AVAILABLE", True)
monkeypatch.setattr(bm, "_bm_binary_path", lambda: None)
monkeypatch.setattr(bm, "_uv_binary_path", lambda: "/fake/uv")
p = bm.BasicMemoryProvider()
assert p.is_available() is True
def test_name(bm):
assert bm.BasicMemoryProvider().name == "basic-memory"
assert bm.BasicMemoryProvider().name == bm.PROVIDER_NAME
def test_get_tool_schemas_unconditional(bm):
"""
Regression: Hermes captures the schema list at *register* time, before
`initialize()` runs. If get_tool_schemas() returns [] when uninitialized,
Hermes builds _tool_to_provider with no entries for us and every
subsequent bm_* invocation returns "Unknown tool: bm_*" forever.
Schemas are static return them unconditionally.
"""
# Fresh provider, never initialized, should still expose all 10 schemas
p = bm.BasicMemoryProvider()
assert p._initialized is False
schemas = p.get_tool_schemas()
assert len(schemas) == 10
names = {s["name"] for s in schemas}
assert names == {
"bm_search",
"bm_read",
"bm_write",
"bm_edit",
"bm_context",
"bm_delete",
"bm_move",
"bm_recent",
"bm_projects",
"bm_workspaces",
}
# Initialized provider also returns 10 (idempotent)
p._initialized = True
assert len(p.get_tool_schemas()) == 10
def test_get_tool_schemas_returns_independent_copies(bm):
"""Mutating the returned list shouldn't affect the next call."""
p = bm.BasicMemoryProvider()
schemas = p.get_tool_schemas()
schemas.clear()
assert len(p.get_tool_schemas()) == 10
def test_handle_tool_call_uninitialized(bm):
p = bm.BasicMemoryProvider()
out = json.loads(p.handle_tool_call("bm_search", {"query": "x"}))
assert "error" in out
def test_handle_tool_call_unknown_tool(bm):
p = bm.BasicMemoryProvider()
p._initialized = True
p._actor = MagicMock()
out = json.loads(p.handle_tool_call("bm_bogus", {}))
assert "error" in out
def test_handle_tool_call_dispatches(bm):
p = bm.BasicMemoryProvider()
p._initialized = True
p._project = "proj"
actor = MagicMock()
actor.call.return_value = json.dumps({"results": []})
p._actor = actor
out = p.handle_tool_call("bm_search", {"query": "hi", "limit": 3})
actor.call.assert_called_once()
bm_tool, bm_args = actor.call.call_args[0][:2]
assert bm_tool == "search_notes"
assert bm_args == {"project": "proj", "query": "hi", "page_size": 3}
def test_handle_tool_call_missing_arg(bm):
p = bm.BasicMemoryProvider()
p._initialized = True
p._actor = MagicMock()
out = json.loads(p.handle_tool_call("bm_write", {"title": "x"}))
assert "error" in out
def test_handle_tool_call_actor_failure(bm):
p = bm.BasicMemoryProvider()
p._initialized = True
p._project = "proj"
actor = MagicMock()
actor.call.side_effect = RuntimeError("boom")
p._actor = actor
out = json.loads(p.handle_tool_call("bm_search", {"query": "x"}))
assert "error" in out
assert p._failure_count == 1
def test_circuit_breaker_opens_after_5_failures(bm, monkeypatch):
p = bm.BasicMemoryProvider()
p._initialized = True
p._project = "proj"
actor = MagicMock()
actor.call.side_effect = RuntimeError("boom")
p._actor = actor
for _ in range(5):
p.handle_tool_call("bm_search", {"query": "x"})
assert p._failure_pause_until > 0
assert p._is_circuit_open() is True
def test_circuit_breaker_resets_after_pause(bm, monkeypatch):
p = bm.BasicMemoryProvider()
p._failure_count = 5
p._failure_pause_until = 1.0 # already in the past
monkeypatch.setattr(bm.time, "monotonic", lambda: 9999.0)
assert p._is_circuit_open() is False
assert p._failure_count == 0
def test_session_note_title_with_session_id(bm):
from datetime import datetime, timezone
p = bm.BasicMemoryProvider()
p._session_started_at = datetime(2026, 5, 10, 13, 5, tzinfo=timezone.utc)
p._session_id = "20260510_080249_571920"
title = p._session_note_title()
# Date appears once; trailing random component is the disambiguator
assert title == "Hermes Session 2026-05-10 1305 571920"
def test_session_note_title_no_session_id(bm):
from datetime import datetime, timezone
p = bm.BasicMemoryProvider()
p._session_started_at = datetime(2026, 5, 10, 13, 5, 42, tzinfo=timezone.utc)
p._session_id = ""
title = p._session_note_title()
# Falls back to seconds for disambiguation
assert title == "Hermes Session 2026-05-10 1305 42"
def test_session_note_title_short_session_id(bm):
from datetime import datetime, timezone
p = bm.BasicMemoryProvider()
p._session_started_at = datetime(2026, 5, 10, 13, 5, tzinfo=timezone.utc)
p._session_id = "abcdef"
title = p._session_note_title()
# No `_` in id → use last 6 chars
assert title == "Hermes Session 2026-05-10 1305 abcdef"
def test_system_prompt_block_uninitialized(bm):
p = bm.BasicMemoryProvider()
assert p.system_prompt_block() == ""
def test_system_prompt_block_mentions_tools(bm):
p = bm.BasicMemoryProvider()
p._initialized = True
p._project = "test-proj"
p._mode = "local"
out = p.system_prompt_block()
assert "bm_search" in out
assert "test-proj" in out
assert "local" in out
def test_system_prompt_block_steers_away_from_cli(bm):
"""
Felix's training data is biased toward `bm tool ...` CLI patterns. The
system_prompt_block must explicitly direct the model to the bm_* tools
AND give a reason (latency) so it has a justification for following the
directive. Without this nudge, agents reach for bash/terminal tools and
pay 1-2s per call instead of ~0.1s.
"""
p = bm.BasicMemoryProvider()
p._initialized = True
p._project = "test-proj"
p._mode = "local"
out = p.system_prompt_block().lower()
# Directive: don't use the bm CLI
assert "do not shell out" in out or "do not use the" in out or "do not run" in out
assert "bm" in out and "cli" in out
# Reason given (latency / capture bypass)
assert "mcp" in out # the persistent connection is the mechanism we cite
assert "spawn" in out or "process" in out # cold-start cost is mentioned
def test_save_config_writes_json(bm, tmp_path):
p = bm.BasicMemoryProvider()
p.save_config({"mode": "local", "project": "x", "capture_per_turn": "true"}, str(tmp_path))
written = json.loads((tmp_path / "basic-memory.json").read_text())
assert written["mode"] == "local"
assert written["project"] == "x"
assert written["capture_per_turn"] is True # coerced from "true"
def test_save_config_merges_existing(bm, tmp_path):
cfg = tmp_path / "basic-memory.json"
cfg.write_text(json.dumps({"mode": "cloud", "project": "old"}))
p = bm.BasicMemoryProvider()
p.save_config({"project": "new"}, str(tmp_path))
after = json.loads(cfg.read_text())
assert after["mode"] == "cloud" # preserved
assert after["project"] == "new" # updated
def test_load_config_missing_returns_empty(bm, tmp_path):
assert bm._load_config(str(tmp_path)) == {}
def test_load_config_corrupt_returns_empty(bm, tmp_path):
(tmp_path / "basic-memory.json").write_text("{not json")
assert bm._load_config(str(tmp_path)) == {}
def test_get_config_schema_shape(bm):
schema = bm.BasicMemoryProvider().get_config_schema()
keys = {entry["key"] for entry in schema}
assert {
"mode",
"project",
"project_path",
"capture_per_turn",
"capture_session_end",
"capture_folder",
}.issubset(keys)
def test_register_appends_to_active_providers(bm):
fake_ctx = MagicMock()
bm._active_providers.clear()
bm.register(fake_ctx)
fake_ctx.register_memory_provider.assert_called_once()
assert len(bm._active_providers) == 1
bm._active_providers.clear()
def test_register_also_registers_bundled_skill(bm):
"""Plugin's bundled SKILL.md should auto-register when `hermes plugins install`
drops the repo into ~/.hermes/plugins/. Avoids the manual symlink step."""
fake_ctx = MagicMock()
bm._active_providers.clear()
bm.register(fake_ctx)
fake_ctx.register_skill.assert_called_once()
args, kwargs = fake_ctx.register_skill.call_args
# First positional arg is the bare skill name
assert args[0] == "basic-memory"
# Second positional arg is the SKILL.md path; it should resolve to a real file
assert args[1].name == "SKILL.md"
assert args[1].is_file()
bm._active_providers.clear()
def test_register_tolerates_old_hermes_without_register_skill(bm):
"""Older Hermes versions don't have ctx.register_skill; we shouldn't crash."""
class _OldCtx:
def __init__(self):
self.calls = []
def register_memory_provider(self, provider):
self.calls.append(("memory", provider))
ctx = _OldCtx()
bm._active_providers.clear()
bm.register(ctx) # must not raise
assert any(c[0] == "memory" for c in ctx.calls)
bm._active_providers.clear()
def test_register_swallows_register_skill_errors(bm, caplog):
"""If register_skill raises (path validation, ABC mismatch, etc.) we log
and continue don't break the memory-provider registration."""
fake_ctx = MagicMock()
fake_ctx.register_skill.side_effect = ValueError("invalid skill name")
bm._active_providers.clear()
with caplog.at_level("WARNING"):
bm.register(fake_ctx)
fake_ctx.register_memory_provider.assert_called_once()
assert "register_skill failed" in caplog.text
bm._active_providers.clear()
# ---- Edge cases ----
def test_handle_tool_call_with_none_args(bm):
"""args=None must not crash; should be coerced to {} and surface a missing-arg error."""
p = bm.BasicMemoryProvider()
p._initialized = True
p._actor = MagicMock()
out = json.loads(p.handle_tool_call("bm_search", None)) # type: ignore[arg-type]
assert "error" in out
def test_handle_tool_call_unknown_does_not_invoke_actor(bm):
p = bm.BasicMemoryProvider()
p._initialized = True
p._actor = MagicMock()
p.handle_tool_call("bm_does_not_exist", {"x": 1})
p._actor.call.assert_not_called()
def test_translate_args_unknown_tool_raises(bm):
"""Unknown tool names should raise KeyError so callers handle it explicitly."""
import pytest
with pytest.raises(KeyError):
bm._translate_args("not_a_tool", {}, "proj")
# ---- Version metadata ----
def test_module_version_present(bm):
import re
assert hasattr(bm, "__version__")
assert isinstance(bm.__version__, str)
# Matches the Python release versions written by scripts/update_versions.py.
assert re.fullmatch(r"\d+\.\d+\.\d+(?:(?:b|rc)\d+)?", bm.__version__), bm.__version__
def test_module_version_matches_plugin_yaml(bm):
"""plugin.yaml ships to Hermes; __version__ is what tooling reads. Keep them in sync."""
import os
import re
plugin_yaml = os.path.join(
os.path.dirname(os.path.abspath(bm.__file__)),
"plugin.yaml",
)
text = open(plugin_yaml).read()
m = re.search(r"^\s*version\s*:\s*(\S+)\s*$", text, re.MULTILINE)
assert m is not None, "plugin.yaml is missing a version field"
assert m.group(1) == bm.__version__, (
f"plugin.yaml version ({m.group(1)}) doesn't match __version__ ({bm.__version__})"
)
# ---- uv bootstrap ----
def test_install_bm_via_uv_no_uv(bm, monkeypatch):
"""If uv isn't available, install returns None without trying to spawn."""
monkeypatch.setattr(bm, "_uv_binary_path", lambda: None)
assert bm._install_bm_via_uv() is None
def test_install_bm_via_uv_runs_uv_tool_install(bm, monkeypatch):
"""Install shells out to `uv tool install basic-memory`."""
calls: list = []
class _Result:
returncode = 0
stdout = b""
stderr = b""
def _fake_run(argv, **kwargs):
calls.append((argv, kwargs))
return _Result()
monkeypatch.setattr(bm, "_uv_binary_path", lambda: "/fake/uv")
monkeypatch.setattr(bm.subprocess, "run", _fake_run)
monkeypatch.setattr(bm, "_bm_binary_path", lambda: "/fake/bm-after-install")
result = bm._install_bm_via_uv()
assert result == "/fake/bm-after-install"
assert len(calls) == 1
argv = calls[0][0]
assert argv[0] == "/fake/uv"
assert argv[1:] == ["tool", "install", "basic-memory", "--quiet"]
def test_install_bm_via_uv_failed_returncode(bm, monkeypatch):
"""Non-zero exit logs and returns None — doesn't pretend success."""
class _Result:
returncode = 2
stdout = b""
stderr = b"network unreachable"
monkeypatch.setattr(bm, "_uv_binary_path", lambda: "/fake/uv")
monkeypatch.setattr(bm.subprocess, "run", lambda *a, **kw: _Result())
assert bm._install_bm_via_uv() is None
def test_install_bm_via_uv_subprocess_exception(bm, monkeypatch):
"""If subprocess raises (timeout, OSError, etc.) we degrade to None, not crash."""
monkeypatch.setattr(bm, "_uv_binary_path", lambda: "/fake/uv")
def _raise(*a, **kw):
raise OSError("boom")
monkeypatch.setattr(bm.subprocess, "run", _raise)
assert bm._install_bm_via_uv() is None
def test_initialize_invokes_uv_install_when_bm_missing(bm, monkeypatch, tmp_path):
"""Cold-start path: bm absent, uv present → initialize triggers install."""
install_calls: list = []
def _fake_install():
install_calls.append(True)
return None # install reports failure; initialize logs and returns
monkeypatch.setattr(bm, "_MCP_AVAILABLE", True)
monkeypatch.setattr(bm, "_bm_binary_path", lambda: None)
monkeypatch.setattr(bm, "_uv_binary_path", lambda: "/fake/uv")
monkeypatch.setattr(bm, "_install_bm_via_uv", _fake_install)
p = bm.BasicMemoryProvider()
p.initialize(session_id="test", hermes_home=str(tmp_path))
assert install_calls == [True], "expected initialize() to attempt the install"
assert p._initialized is False # install reported failure → don't start actor
def test_initialize_skips_uv_install_when_bm_present(bm, monkeypatch, tmp_path):
"""Steady-state path: bm already installed → no install attempt."""
install_calls: list = []
monkeypatch.setattr(bm, "_bm_binary_path", lambda: "/fake/bm")
def _fake_install():
install_calls.append(True)
return "/should-not-be-called"
monkeypatch.setattr(bm, "_install_bm_via_uv", _fake_install)
monkeypatch.setattr(bm, "_MCP_AVAILABLE", False) # short-circuit actor start
p = bm.BasicMemoryProvider()
p.initialize(session_id="test", hermes_home=str(tmp_path))
assert install_calls == [], "should not invoke install when bm is already present"
def test_initialize_bails_when_no_bm_no_uv(bm, monkeypatch, tmp_path, caplog):
"""No bm, no uv → log clear error, don't try to install, don't initialize."""
monkeypatch.setattr(bm, "_MCP_AVAILABLE", True)
monkeypatch.setattr(bm, "_bm_binary_path", lambda: None)
monkeypatch.setattr(bm, "_uv_binary_path", lambda: None)
install_calls: list = []
monkeypatch.setattr(bm, "_install_bm_via_uv", lambda: install_calls.append(True) or None)
p = bm.BasicMemoryProvider()
with caplog.at_level("ERROR"):
p.initialize(session_id="test", hermes_home=str(tmp_path))
assert install_calls == [] # never attempted — no uv to call
assert p._initialized is False
assert "uv is not installed" in caplog.text or "uv" in caplog.text.lower()
# ---- Defaults: stay out of bm's app dir ----
def test_default_project_name_is_hermes_memory(bm):
"""The default project name no longer carries a hostname suffix.
Each machine has its own isolated local store with this same name."""
assert bm._default_project() == "hermes-memory"
def test_default_project_path_is_in_user_space(bm):
"""~/.basic-memory/ is reserved for bm's app state. Projects live in user space."""
p = bm._default_project_path()
assert ".basic-memory" not in p, (
f"default project path must not live inside ~/.basic-memory/, got: {p}"
)
assert p.rstrip("/").endswith("hermes-memory")
# ---- bm config introspection ----
def test_bm_known_projects_missing_file(bm, monkeypatch, tmp_path):
"""When bm has never been run, return None (callers should treat as 'unknown')."""
monkeypatch.setattr(bm, "_bm_config_path", lambda: tmp_path / "config.json")
assert bm._bm_known_projects() is None
def test_bm_known_projects_corrupt_file(bm, monkeypatch, tmp_path):
cfg = tmp_path / "config.json"
cfg.write_text("{not json")
monkeypatch.setattr(bm, "_bm_config_path", lambda: cfg)
assert bm._bm_known_projects() is None
def test_bm_known_projects_returns_dict(bm, monkeypatch, tmp_path):
cfg = tmp_path / "config.json"
cfg.write_text(json.dumps({"projects": {"main": {}, "hermes-memory": {}}}))
monkeypatch.setattr(bm, "_bm_config_path", lambda: cfg)
result = bm._bm_known_projects()
assert isinstance(result, dict)
assert set(result.keys()) == {"main", "hermes-memory"}
def test_bm_known_projects_handles_non_dict_root(bm, monkeypatch, tmp_path):
cfg = tmp_path / "config.json"
cfg.write_text(json.dumps(["not a dict"]))
monkeypatch.setattr(bm, "_bm_config_path", lambda: cfg)
assert bm._bm_known_projects() is None
# ---- Project verification ----
def test_verify_project_registered_no_bm_config(bm, monkeypatch):
"""No bm config yet → assume registration is fine; let downstream surface real failures."""
monkeypatch.setattr(bm, "_bm_known_projects", lambda: None)
p = bm.BasicMemoryProvider()
p._project = "anything-goes"
assert p._verify_project_registered() is True
def test_verify_project_registered_present(bm, monkeypatch):
monkeypatch.setattr(bm, "_bm_known_projects", lambda: {"hermes-memory": {}, "main": {}})
p = bm.BasicMemoryProvider()
p._project = "hermes-memory"
assert p._verify_project_registered() is True
def test_verify_project_registered_missing(bm, monkeypatch):
monkeypatch.setattr(bm, "_bm_known_projects", lambda: {"main": {}, "other": {}})
p = bm.BasicMemoryProvider()
p._project = "hermes-memory-cloud"
assert p._verify_project_registered() is False
def test_log_missing_project_local_hint_includes_path(bm, caplog):
p = bm.BasicMemoryProvider()
p._mode = "local"
p._project = "hermes-memory"
p._project_path = "/tmp/somewhere"
with caplog.at_level("ERROR"):
p._log_missing_project()
msg = caplog.text
assert "hermes-memory" in msg
assert "/tmp/somewhere" in msg
assert "--cloud" not in msg
def test_log_missing_project_cloud_hint_uses_cloud_flag(bm, caplog):
p = bm.BasicMemoryProvider()
p._mode = "cloud"
p._project = "hermes-memory-cloud"
with caplog.at_level("ERROR"):
p._log_missing_project()
msg = caplog.text
assert "hermes-memory-cloud" in msg
assert "--cloud" in msg
# ---- initialize() bail-out on missing project ----
def test_initialize_bails_when_project_missing(bm, monkeypatch, tmp_path):
"""If bm config says the project doesn't exist, refuse to initialize."""
# bm config exists, but our project isn't in it
bm_cfg = tmp_path / ".basic-memory" / "config.json"
bm_cfg.parent.mkdir(parents=True)
bm_cfg.write_text(json.dumps({"projects": {"main": {}}}))
monkeypatch.setattr(bm, "_bm_config_path", lambda: bm_cfg)
# Cloud mode so _ensure_local_project doesn't auto-create
plugin_cfg = tmp_path / "basic-memory.json"
plugin_cfg.write_text(json.dumps({"mode": "cloud", "project": "not-registered"}))
p = bm.BasicMemoryProvider()
p.initialize(session_id="test", hermes_home=str(tmp_path))
assert p._initialized is False
assert p._actor is None
def test_initialize_proceeds_when_bm_config_absent(bm, monkeypatch, tmp_path):
"""If bm config doesn't exist (fresh install), don't false-reject — let actor try."""
monkeypatch.setattr(bm, "_bm_config_path", lambda: tmp_path / "no-such" / "config.json")
monkeypatch.setattr(bm, "_MCP_AVAILABLE", False) # shortcut: actor won't actually start
plugin_cfg = tmp_path / "basic-memory.json"
plugin_cfg.write_text(json.dumps({"mode": "cloud", "project": "anything"}))
p = bm.BasicMemoryProvider()
# We don't fully assert _initialized here because the actor won't start without
# MCP — but we DO assert _verify_project_registered didn't gate us out before
# actor-start was attempted.
p.initialize(session_id="test", hermes_home=str(tmp_path))
# Initialization fails at actor-start (MCP unavailable), not at verify.
assert p._initialized is False # expected — actor couldn't start
# _project should have been set despite the failure (proves we got past verify)
assert p._project == "anything"
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
if ! command -v bun >/dev/null 2>&1; then
echo "bun is required to run pre-commit checks." >&2
exit 1
fi
echo "Running pre-commit checks..."
bun run lint
bun run check-types
+37
View File
@@ -0,0 +1,37 @@
name: CI
on:
pull_request:
push:
branches:
- main
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.8"
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Fetch skills
run: bun scripts/fetch-skills.ts
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Lint
run: bun run lint
- name: Typecheck
run: bun run check-types
- name: Unit tests
run: bun test
+99
View File
@@ -0,0 +1,99 @@
name: Release
on:
workflow_dispatch:
inputs:
version:
description: "Version bump (`patch`, `minor`, `major`) or explicit semver (`0.2.0`)"
required: true
default: "patch"
permissions:
contents: write
id-token: write
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.8"
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Fetch skills
run: bun scripts/fetch-skills.ts
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Release checks
run: bun run release:check
- name: Configure Git identity
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Bump version and create tag
id: bump
run: |
set -euo pipefail
VERSION_INPUT="${{ github.event.inputs.version }}"
if [[ "$VERSION_INPUT" =~ ^(patch|minor|major)$ ]]; then
npm version "$VERSION_INPUT" -m "chore(release): %s"
elif [[ "$VERSION_INPUT" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-.+)?$ ]]; then
npm version "$VERSION_INPUT" -m "chore(release): %s"
else
echo "Unsupported version input: $VERSION_INPUT" >&2
echo "Use patch|minor|major or explicit semver like 0.2.0 or 0.2.0-alpha.1" >&2
exit 1
fi
TAG="$(git describe --tags --abbrev=0)"
VERSION="$(node -p "require('./package.json').version")"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Push commit and tag
run: |
set -euo pipefail
git push origin HEAD:${GITHUB_REF_NAME}
git push origin "${{ steps.bump.outputs.tag }}"
- name: Publish to npm
run: |
VERSION="${{ steps.bump.outputs.version }}"
if [[ "$VERSION" == *-* ]]; then
DIST_TAG="${VERSION##*-}" # e.g. alpha.6 -> alpha
DIST_TAG="${DIST_TAG%%.*}" # strip .N suffix
npm publish --provenance --access public --tag "$DIST_TAG"
else
npm publish --provenance --access public
fi
- name: Create GitHub release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.bump.outputs.tag }}
name: ${{ steps.bump.outputs.tag }}
generate_release_notes: true
+146
View File
@@ -0,0 +1,146 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.*
!.env.example
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Sveltekit cache directory
.svelte-kit/
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# Firebase cache directory
.firebase/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v3
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# Vite logs files
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
.idea/
benchmark/datasets/
benchmark/corpus-locomo/
# Generated from ../../../skills by scripts/fetch-skills.ts
skills/
+1
View File
@@ -0,0 +1 @@
CLAUDE.md
+208
View File
@@ -0,0 +1,208 @@
# Basic Memory CLI & Cloud Setup
## How the Plugin Connects
Basic Memory is **local-first** — everything works out of the box with no cloud account, no internet connection, and no external services. Your notes live as markdown files on disk, indexed locally with SQLite.
The plugin spawns a Basic Memory MCP session via stdio:
```
bm mcp --transport stdio --project <name>
```
All tool calls route through this MCP session. No cloud configuration is required for normal use.
## Cloud Configuration (Optional)
Cloud sync is entirely optional. If you want to sync your local knowledge base to Basic Memory Cloud for backup or cross-device access, you can configure per-project cloud routing.
### Why Cloud?
- **Your agent's memory travels with you** — laptop, desktop, hosted environment. Same knowledge graph everywhere, synced bidirectionally.
- **Team knowledge sharing** — org workspaces let multiple agents and team members build on a shared knowledge base.
- **Durable memory for production agents** — CI runners, containers, and hosted environments are ephemeral. Cloud gives agents persistent memory that survives teardowns.
- **Multi-agent coordination** — multiple agents (or the same agent across services) can read and write to a shared graph.
Cloud extends local-first — it doesn't replace it. Your notes are still plain markdown, still editable locally, still yours. Start with a [7-day free trial](https://basicmemory.com) — no credit card required. Use code `BMCLAW` for 20% off for 3 months.
### Setup
```bash
# Authenticate with Basic Memory Cloud
bm cloud login
# Save API key for per-project cloud routing
bm cloud set-key bmc_...
# Route a project through the cloud
bm project set-cloud <name>
# Revert a project to local routing
bm project set-local <name>
# Check cloud connection state
bm cloud status
```
When a project is set to cloud mode, the MCP server routes tool calls for that project through the cloud API using the saved API key as a Bearer token. Local projects (the default) continue to use the local SQLite index. You can mix local and cloud projects freely.
## Project Management
```bash
# List all projects
bm project list
# Add a new project
bm project add "name" ~/path
# Show current project details
bm project info
# Set the default project
bm project default "name"
# One-way sync (local -> cloud)
bm project sync
# Bidirectional sync
bm project bisync
```
## Cross-Project Operations
All plugin tools accept an optional `project` parameter to operate on a different project:
```
search_notes(query="authentication", project="other-project")
read_note(identifier="notes/api-design", project="docs")
write_note(title="New Note", content="...", folder="notes", project="research")
```
## Workspace Support
Workspaces group projects by owner (personal or organization):
```
list_workspaces()
list_memory_projects(workspace="my-org")
```
## Auto-Recall
When `autoRecall` is enabled (the default), the plugin injects relevant context at the start of each agent session by listening for the `agent_start` event. On each trigger it:
1. **Queries active tasks** — searches the knowledge graph for notes with `type: Task` and `status: active` (up to 5 results)
2. **Fetches recent activity** — gets notes modified in the last 24 hours
3. **Formats and injects context** — returns the results as structured context for the agent
The injected context looks like:
```
## Active Tasks
- **Fix login bug** — Description of Fix login bug
- **Update API docs** — Description of Update API docs
## Recent Activity
- Daily standup notes (memory/daily-standup-notes.md)
- API design decisions (memory/api-design-decisions.md)
---
Check for active tasks and recent activity. Summarize anything relevant to the current session.
```
The trailing instruction (after `---`) is the `recallPrompt`, which you can customize to change what the agent focuses on. For example:
```json
{
"recallPrompt": "Focus on blocked tasks and any decisions made in the last 24 hours."
}
```
To disable auto-recall entirely:
```json
{
"autoRecall": false
}
```
## Auto-Capture
When `autoCapture` is enabled (the default), the plugin automatically records agent conversations after each turn:
1. Extracts the last user + assistant messages
2. Appends them as timestamped entries to a daily conversation note (`conversations-YYYY-MM-DD`)
3. Skips very short exchanges (< `captureMinChars` chars each, default 10)
This builds a searchable history of agent interactions in the knowledge graph without any manual effort.
## Slash Commands
### Memory commands
- **`/remember <text>`** — Save a quick note to the knowledge graph
- **`/recall <query>`** — Search the knowledge graph (top 5 results)
### Skill workflows
These commands inject step-by-step workflow instructions from the bundled skill files:
| Command | What it does |
|---------|-------------|
| `/tasks` | Task management — create, track, resume structured tasks that survive context compaction |
| `/reflect` | Memory reflection — review recent activity and consolidate insights into long-term memory |
| `/defrag` | Memory defrag — reorganize, split, prune, and clean up memory files |
| `/schema` | Schema management — infer, create, validate, and evolve Picoschema definitions |
Each command accepts optional arguments for context:
```
/tasks create a task for the API migration
/reflect focus on decisions from this week
/defrag clean up completed tasks older than 2 weeks
/schema infer a schema for Meeting notes
```
When invoked without arguments, the agent receives the full workflow instructions and follows them interactively.
## Plugin Configuration
The plugin accepts these config fields in `openclaw.config.json`:
```json
{
"plugins": {
"entries": {
"openclaw-basic-memory": {
"enabled": true,
"config": {
"project": "my-project",
"memoryDir": "memory/",
"memoryFile": "MEMORY.md",
"autoCapture": true,
"autoRecall": true,
"recallPrompt": "Check for active tasks and recent activity. Summarize anything relevant.",
"debug": false
}
}
},
"slots": {
"memory": "openclaw-basic-memory"
}
}
}
```
| Field | Default | Description |
|-------|---------|-------------|
| `project` | `openclaw-<hostname>` | BM project name |
| `bmPath` | `bm` | Path to BM CLI binary |
| `memoryDir` | `memory/` | Relative path for memory files |
| `memoryFile` | `MEMORY.md` | Working memory file name |
| `projectPath` | same as `memoryDir` | Absolute path to project root |
| `autoCapture` | `true` | Auto-index conversations after each turn |
| `captureMinChars` | `10` | Min chars to trigger capture |
| `autoRecall` | `true` | Inject context (active tasks, recent activity) at session start |
| `recallPrompt` | *(see above)* | Instruction appended to recalled context |
| `debug` | `false` | Verbose logging |

Some files were not shown because too many files have changed in this diff Show More