Compare commits

...

44 Commits

Author SHA1 Message Date
phernandez 2c30922e65 docs(core): add Basic Machines agent style guidance
Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-08 15:37:43 -05:00
phernandez e8cda55ddb test(cli): make copyto path assertions Windows-portable
The Windows SQLite unit job failed because two new copyto tests asserted on
raw POSIX dest strings. On Windows `str(Path("/tmp/research"))` renders with
backslashes, so the copyto dest is `\tmp\research/notes/...` (rclone accepts the
mixed separators — the product is fine). Compare the local dest via `Path(...)`
like the other tests, which normalizes separators cross-platform.

Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-08 13:00:48 -05:00
phernandez c850450ddf fix(cli): address push/pull review feedback (fatal-error guard, UX, tests)
From the Codex and claude-review automated reviews on PR #917:

- project_diff: fail fast on fatal `rclone check` errors. A non-zero exit with
  no combined listing (auth/network/missing-remote) previously produced an empty
  plan, so the transfer ran as a no-op and reported success. Now raises
  RcloneError with rclone's stderr. (Codex P2 / claude-review #2)
- sync-setup "Next steps": stop pointing every user at the now-Personal-only
  `bm cloud sync`; lead with the Team-safe `pull`/`push` and note bisync is
  Personal-only. (claude-review #1)
- Cosmetic: capitalize the push/pull abort headlines for consistency; document
  the two ConflictStrategy definitions (Typer enum vs engine Literal). (nits)
- Tests: cover keep-local+pull and keep-cloud+push (preserve-destination cases),
  project_transfer aborting on a mid-loop conflict-copy failure, and project_diff
  raising on a fatal check error vs not raising on a normal differences exit.

Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-08 12:30:20 -05:00
phernandez ad1f8621f1 docs(cli): document cloud push/pull and Personal-only sync/bisync
Update docs/cloud-cli.md for the docs site:
- Add a Team Workspaces push/pull section (additive, git-style transfers,
  the --on-conflict {fail|keep-local|keep-cloud|keep-both} resolution, and the
  no-baseline limitations tracked by #862).
- Mark `sync` and `bisync` as Personal-workspace-only mirrors and point Team
  users at push/pull, with a Personal-vs-Team overview table.
- Fix stale command namespace: `bm project sync|bisync|check|bisync-reset` ->
  `bm cloud ...` (these live under `bm cloud`, not `bm project`).
- Add push/pull to the command reference and the summary workflows.

Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-08 12:25:22 -05:00
phernandez a7937d4066 fix(cli): compare by checksum on overwrite push/pull to match conflict detection
Second code-review finding (PLAUSIBLE): project_diff detects conflicts with
`rclone check` (content/hash), but project_copy used `rclone copy` with its
default size+modtime comparison. On an overwrite strategy (keep-cloud on pull /
keep-local on push), copy could skip a file the diff had flagged as a conflict
when sizes matched and the destination was not older — silently ignoring the
user's explicit choice.

Add `--checksum` to the overwrite copy so the transfer decision uses the same
content basis as detection. New-only mode is unaffected: `--ignore-existing`
skips by existence, so the comparison basis is irrelevant there. project_sync
(the Personal-only mirror) is untouched and keeps its default comparison.

Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-08 11:48:20 -05:00
phernandez 9dbe313193 fix(cli): add --local-no-preallocate to conflict-copy copyto
Code-review finding: project_copy_file built its `rclone copyto` command
inline and omitted `--local-no-preallocate`. On a `pull --on-conflict keep-both`
that copyto writes the conflict copy to the local filesystem, which is exactly
the case the flag guards (NUL byte padding on virtual filesystems such as
Google Drive File Stream). Every other transfer path adds it via
_build_transfer_cmd; bring copyto in line.

Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-08 11:44:20 -05:00
phernandez e23363606a feat(cli): add Team-safe cloud push/pull and gate sync to Personal workspaces
Adds `bm cloud push` and `bm cloud pull` as git-style, fail-safe transfer
primitives that are usable on Team workspaces (issue #858), and restricts the
destructive `bm cloud sync` mirror to Personal workspaces.

Why
- `bm cloud sync` is a destructive local->cloud mirror; on a shared Team bucket
  it can delete a teammate's files. The only pull path was two-way `bisync`,
  which is already Personal-only (#849).
- Teams need a safe way to fetch teammates' notes and add their own without one
  stale local tree becoming authoritative for shared cloud state.

What
- push = `rclone copy` local->cloud, pull = `rclone copy` cloud->local. Both are
  additive (never delete on the destination), so neither can damage shared state.
- Conflicts (a file that differs on both sides) abort by default and list the
  paths, like git refusing to clobber local changes / rejecting a stale push.
  `--on-conflict {fail|keep-local|keep-cloud|keep-both}` lets the user decide;
  no vague --force, no silent winner.
- `sync` now requires a Personal workspace; its guard and the bisync guard point
  Team users at push/pull.

Limitations (surfaced in --help and command output, tracked by #862):
- No sync baseline yet, so deletions are not propagated and every divergence is
  treated as a conflict rather than auto-resolved. Real three-way merge needs
  the per-client manifest + Tigris snapshot baseline designed in #862.

Implementation
- rclone_commands.py: shared `_build_transfer_cmd`/`_transfer_endpoints`;
  refactor `project_sync` onto them (no behavior change); add `project_diff`
  (conflict detection via `rclone check --combined`), `project_copy`,
  `project_copy_file`, and `project_transfer` (strategy dispatch).
- project_sync.py: new `push`/`pull` commands (ungated, Team-safe) with
  `--on-conflict`/`--dry-run`; gate `sync` to Personal via a per-command guard
  message.
- Tests at the rclone-argv and CLI-command levels; existing sync/bisync tests
  updated for the new gating and messages.

Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-08 11:35:13 -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
187 changed files with 14484 additions and 397 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"
}
]
}
@@ -0,0 +1,62 @@
---
name: basic-machines-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
For `basic-memory`, prioritize local-first file/database/MCP boundaries. For
`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:
```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.
+10 -4
View File
@@ -6,18 +6,24 @@
},
"metadata": {
"description": "Official Basic Memory plugins from the canonical basic-memory repository",
"version": "0.3.13"
"version": "0.21.6"
},
"plugins": [
{
"name": "basic-memory",
"source": "./plugins/claude-code",
"description": "The bridge between Claude's working memory and Basic Memory's durable knowledge graph session briefings, pre-compaction checkpoints, and capture reflexes",
"version": "0.3.13",
"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.21.6",
"author": {
"name": "Basic Machines"
},
"keywords": ["memory", "knowledge", "mcp", "specs", "context"]
"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
+6 -4
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 across all consolidated manifests via `just set-version` (Python package + Claude Code plugin/marketplaces + Hermes + OpenClaw)
- ✅ 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)
@@ -194,12 +195,13 @@ Users can now upgrade:
- 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, Hermes `plugin.yaml` +
`__init__.py`, OpenClaw `package.json`). To bump only 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)
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/basic-machines-review
+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"
+1
View File
@@ -38,6 +38,7 @@ jobs:
mcp
sync
ui
ci
deps
installer
plugins
+1
View File
@@ -55,6 +55,7 @@ ENV/
claude-output
**/.claude/settings.local.json
.mcp.json
!/plugins/codex/.mcp.json
.mcpregistry_*
/.testmondata
.benchmarks/
+30 -2
View File
@@ -83,7 +83,7 @@ Before opening or updating a PR, run the checks that mirror the common required
- 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`, `deps`, `installer`, `plugins`, `skills`, `integrations`.
- Use one of the allowed scopes: `core`, `cli`, `api`, `mcp`, `sync`, `ui`, `ci`, `deps`, `installer`, `plugins`, `skills`, `integrations`.
### Test Structure
@@ -108,6 +108,27 @@ Before opening or updating a PR, run the checks that mirror the common required
- 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
@@ -348,6 +369,13 @@ See `.claude/commands/release/release.md` (and `beta.md`, `release-check.md`, `c
- 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:
@@ -506,7 +534,7 @@ With GitHub integration, the development workflow includes:
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`, `deps`, `installer`, `plugins`, `skills`, `integrations`
- 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.
+45 -18
View File
@@ -1,6 +1,12 @@
# CHANGELOG
## Unreleased
## 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
@@ -8,38 +14,59 @@
`basic-memory` tree as the canonical source, discovery, documentation,
issue, and release home.
- Added root marketplace/package validation for the consolidated repository
layout while keeping Phase 1 focused on parity.
- Added root and package-local justfile targets so Claude Code, skills,
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/`.
- Updated skills documentation to point at `basicmachines-co/basic-memory`
and document manual copy as the temporary fallback when subpath installs are
unavailable.
### Claude Code
- Added the Claude Code plugin under `plugins/claude-code/` with its native
`.claude-plugin`, hooks, skills, agent, changelog, and docs.
- Added the root Claude marketplace manifest pointing `basic-memory` to
`./plugins/claude-code`.
`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.
- Updated Hermes install and development docs for monorepo subpath usage.
### OpenClaw
- Added the OpenClaw plugin under `integrations/openclaw/` with its native
npm/TypeScript package shape, tests, docs, and release metadata.
- Updated OpenClaw package metadata to point at the monorepo subdirectory and
changed skill bundling to copy from the top-level `skills/` source.
npm/TypeScript package shape, tests, docs, and release metadata; skill
bundling copies from the top-level `skills/` source.
## v0.21.5 (2026-05-26)
+1 -1
View File
@@ -199,7 +199,7 @@ just package-check-openclaw
The Claude Code plugin is the bridge between Claude's working memory and Basic
Memory — session-start briefings, pre-compaction checkpoints, an opt-in capture
output style, and `/basic-memory:setup` · `:remember` · `:share` · `:status`.
output style, and `/basic-memory:bm-setup` · `:remember` · `:share` · `:status`.
**Connect the Basic Memory MCP server first** — see [Connect your AI
client](#connect-your-ai-client). The plugin's hooks and skills call it, so it's a
+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.
+171 -53
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
```
@@ -137,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:**
@@ -167,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:**
@@ -235,7 +251,7 @@ 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**
@@ -294,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 + Team | Verify files match (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:**
@@ -321,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:**
@@ -349,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
@@ -366,7 +464,7 @@ bm project bisync --name research
**Use case:** Check if local and cloud match without making changes.
```bash
bm project check --name research
bm cloud check --name research
```
**What happens:**
@@ -378,7 +476,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)
@@ -386,7 +484,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:**
@@ -432,20 +530,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
@@ -462,8 +560,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
```
@@ -661,7 +759,7 @@ code ~/.basic-memory/.bmignore
echo "*.tmp" >> ~/.basic-memory/.bmignore
# Next sync uses updated patterns
bm project bisync --name research
bm cloud bisync --name research
```
## Troubleshooting
@@ -724,7 +822,7 @@ bm cloud login
**Solution:**
```bash
bm project bisync --name research --resync
bm cloud bisync --name research --resync
```
**What this does:**
@@ -747,7 +845,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.
@@ -764,10 +862,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:**
@@ -787,16 +885,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
@@ -809,7 +907,7 @@ bm project sync --name research
```bash
bm cloud sync-setup research ~/Documents/research
bm project bisync --name research --resync
bm cloud bisync --name research --resync
```
### Connection Issues
@@ -880,20 +978,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]
# 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
bm project check --name <project>
bm project check --name <project> --one-way
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
@@ -909,15 +1017,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)
+116 -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:
+1 -1
View File
@@ -38,7 +38,7 @@ from typing import Any, Callable
from agent.memory_provider import MemoryProvider
from tools.registry import tool_error
__version__ = "0.3.2"
__version__ = "0.21.6"
logger = logging.getLogger("hermes.memory.basic-memory")
+1 -1
View File
@@ -1,5 +1,5 @@
name: basic-memory
version: 0.3.2
version: 0.21.6
description: "Basic Memory — persistent knowledge graph backed by the basic-memory MCP server"
pip_dependencies:
- mcp
+2 -2
View File
@@ -1,10 +1,10 @@
{
"name": "@basicmemory/openclaw-basic-memory",
"version": "0.2.4",
"version": "0.21.6",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"description": "Basic Memory plugin for OpenClaw local-first knowledge graph for agent memory",
"description": "Basic Memory plugin for OpenClaw \u2014 local-first knowledge graph for agent memory",
"license": "MIT",
"repository": {
"type": "git",
+13 -3
View File
@@ -115,6 +115,10 @@ test-semantic-report:
BASIC_MEMORY_ENV=test BASIC_MEMORY_BENCHMARK_OUTPUT=.benchmarks/semantic-quality.jsonl uv run pytest -p pytest_mock -v -s --no-cov -m semantic test-int/semantic/
uv run python test-int/semantic/report.py .benchmarks/semantic-quality.jsonl
# Run opt-in live LiteLLM provider checks against configured external APIs
test-litellm-live *args:
BASIC_MEMORY_ENV=test BASIC_MEMORY_RUN_LITELLM_INTEGRATION=1 PYTHONPATH=test-int:src uv run python -m semantic.litellm_live_harness {{args}}
# Run semantic benchmarks (Postgres combos only)
test-semantic-postgres:
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov -m semantic -k postgres test-int/semantic/
@@ -265,8 +269,8 @@ check: lint format typecheck test
# Run all code quality checks and all test suites, including semantic benchmarks
check-all: lint format typecheck test test-semantic
# Validate every consolidated agent package (Claude Code, skills, Hermes, OpenClaw)
package-check: package-check-claude-code package-check-skills package-check-hermes package-check-openclaw
# Validate every consolidated agent package (Claude Code, Codex, skills, Hermes, OpenClaw)
package-check: package-check-claude-code package-check-codex package-check-skills package-check-hermes package-check-openclaw
# Alias for plugin/package validation during consolidation work
plugins-check: package-check
@@ -278,6 +282,10 @@ agent-harness-check: package-check-claude-code package-check-hermes package-chec
package-check-claude-code:
just --justfile plugins/claude-code/justfile --working-directory plugins/claude-code check
# Codex plugin: manifest, bundled skills, hooks, MCP config, and schemas
package-check-codex:
just --justfile plugins/codex/justfile --working-directory plugins/codex check
# Shared top-level SKILL.md source
package-check-skills:
just --justfile skills/justfile --working-directory skills check
@@ -303,7 +311,7 @@ set-version version scope="all":
set-version-dry-run version scope="all":
python3 scripts/update_versions.py "{{version}}" --scope "{{scope}}" --dry-run
# Set the version for just the plugin/agent artifacts (plugin, marketplaces, Hermes, OpenClaw)
# Set the version for just the plugin/agent artifacts (plugins, marketplaces, Hermes, OpenClaw)
set-packages-version version:
just set-version "{{version}}" packages
@@ -365,6 +373,7 @@ release version:
.claude-plugin/marketplace.json \
plugins/claude-code/.claude-plugin/plugin.json \
plugins/claude-code/.claude-plugin/marketplace.json \
plugins/codex/.codex-plugin/plugin.json \
integrations/hermes/plugin.yaml \
integrations/hermes/__init__.py \
integrations/openclaw/package.json
@@ -438,6 +447,7 @@ beta version:
.claude-plugin/marketplace.json \
plugins/claude-code/.claude-plugin/plugin.json \
plugins/claude-code/.claude-plugin/marketplace.json \
plugins/codex/.codex-plugin/plugin.json \
integrations/hermes/plugin.yaml \
integrations/hermes/__init__.py \
integrations/openclaw/package.json
@@ -6,18 +6,24 @@
},
"metadata": {
"description": "Official plugins from Basic Machines for knowledge management and AI-assisted development",
"version": "0.3.13"
"version": "0.21.6"
},
"plugins": [
{
"name": "basic-memory",
"source": "./",
"description": "The bridge between Claude's working memory and Basic Memory's durable knowledge graph session briefings, pre-compaction checkpoints, and capture reflexes",
"version": "0.3.13",
"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.21.6",
"author": {
"name": "Basic Machines"
},
"keywords": ["memory", "knowledge", "mcp", "specs", "context"]
"keywords": [
"memory",
"knowledge",
"mcp",
"specs",
"context"
]
}
]
}
@@ -1,7 +1,7 @@
{
"name": "basic-memory",
"description": "The bridge between Claude's working memory and Basic Memory's durable knowledge graph session briefings, pre-compaction checkpoints, and capture reflexes",
"version": "0.3.13",
"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.21.6",
"author": {
"name": "Basic Machines"
},
+6 -6
View File
@@ -15,12 +15,12 @@ Memory's durable graph**, rather than a memory layer of its own. See
(`my-team/notes`) or `external_id` UUIDs, since project names collide across
workspaces. Reads route over the user's OAuth session; capture **never** writes to a
shared project.
- **`/basic-memory:share <note>`** (`skills/share/`) — the deliberate personal→team
- **`/basic-memory:bm-share <note>`** (`skills/bm-share/`) — the deliberate personal→team
write: 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. (Phase 4)
- **`/basic-memory:setup`** (`skills/setup/`) — a short guided interview that
- **`/basic-memory:bm-setup`** (`skills/bm-setup/`) — a short guided interview that
configures the project for the plugin: maps it to a Basic Memory project (picking
an existing one or creating a new one), seeds the `session`/`decision`/`task`
schemas into the project, installs the shared `memory-*` skills via
@@ -30,11 +30,11 @@ Memory's durable graph**, rather than a memory layer of its own. See
capture reflexes. Writes the `basicMemory` block to
`.claude/settings.json` (or `settings.local.json`). The SessionStart hook nudges
toward this on first run; running it (writing the config) stops the nudge. (Phase 3)
- **`/basic-memory:remember <text>`** (`skills/remember/`) — quick deliberate
- **`/basic-memory:bm-remember <text>`** (`skills/bm-remember/`) — quick deliberate
capture. Writes the text verbatim to the `rememberFolder` (default `bm-remember`)
with a first-line title and a `manual-capture` tag, via the connected Basic Memory
MCP server. Also fires when the user says "remember that…". (Phase 2)
- **`/basic-memory:status`** (`skills/status/`) — diagnostic that reports the active
- **`/basic-memory:bm-status`** (`skills/bm-status/`) — diagnostic that reports the active
project, capture/remember folders, output-style state, recent session checkpoints,
and active-task count. User-invoked only (`disable-model-invocation`). (Phase 2)
@@ -62,7 +62,7 @@ Memory's durable graph**, rather than a memory layer of its own. See
### Changed
- **SessionStart hook now nudges toward `/basic-memory:setup` on first run** — when
- **SessionStart hook now nudges toward `/basic-memory:bm-setup` on first run** — when
no `basicMemory` config block is present in either settings file. The nudge
survives a failed/empty task query (so a brand-new user with no project yet still
sees it), and stops once setup writes the config. (Phase 3)
@@ -84,7 +84,7 @@ Memory's durable graph**, rather than a memory layer of its own. See
### Notes
- Slash commands shipped by later phases (`/basic-memory:setup`,
- Slash commands shipped by later phases (`/basic-memory:bm-setup`,
`:remember`, `:status`) will be **plugin-namespaced** — Claude Code namespaces
all plugin skills as `/<plugin>:<skill>`.
- Requires `basic-memory >= 0.19.0` (for `metadata_filters` / structured recall).
+36 -32
View File
@@ -69,9 +69,9 @@ plugins/claude-code/
│ └── basic-memory.md # reflexes: search first, capture decisions
│ # NOTE: rules/ deferred — path-scoped rules don't load yet (Q5)
├── skills/
│ ├── setup/SKILL.md # /basic-memory:setup — bootstrap interview (first-run)
│ ├── remember/SKILL.md # /basic-memory:remember <text> — quick deliberate capture
│ └── status/SKILL.md # /basic-memory:status — show plugin state
│ ├── bm-setup/SKILL.md # /basic-memory:bm-setup — bootstrap interview (first-run)
│ ├── bm-remember/SKILL.md # /basic-memory:bm-remember <text> — quick deliberate capture
│ └── bm-status/SKILL.md # /basic-memory:bm-status — show plugin state
├── schemas/ # picoschema seeds, copied into the user's BM project at bootstrap
│ ├── session.md # type: session — resume checkpoints
│ ├── decision.md # type: decision — durable choices + rationale
@@ -87,7 +87,7 @@ Three layers, matching what Hermes and OpenClaw converged on:
| ----------- | ---------------------------------- | ------------------------------------------ | ------------------------------------- |
| Ambient | `hooks/`, `rules/` | Lifecycle events, file context | Brief Claude, checkpoint, guide placement |
| Background | `output-styles/basic-memory.md` | System prompt, every turn | Reflexes — search first, capture inline |
| Deliberate | `skills/{setup,remember,status}/` | User invokes (`/basic-memory:setup`, `/basic-memory:remember`) | One-shot user gestures |
| Deliberate | `skills/{bm-setup,bm-remember,bm-status}/` | User invokes (`/basic-memory:bm-setup`, `/basic-memory:bm-remember`) | One-shot user gestures |
## 4. The core flows
@@ -191,13 +191,17 @@ If/when path-scoped rules start working *and* support out-of-tree globs, we can
Three skills only, each Claude-Code-specific (everything else lives in top-level `skills/`).
> **Verified (Q3) — slash commands are always plugin-namespaced.** A skill folder `skills/remember/` in a plugin whose `plugin.json` name is `basic-memory` is invoked as **`/basic-memory:remember`**, not `/remember` — namespacing is mandatory and can't be shortened. Consequence: we drop the redundant `bm-` prefix from skill folder names (the namespace already says `basic-memory`). So the folders are `setup/`, `remember/`, `status/`, surfacing as `/basic-memory:setup`, `/basic-memory:remember`, `/basic-memory:status`. Skills are auto-discovered on install no extra registration.
> **Verified (Q3) — slash commands are always plugin-namespaced.** A skill folder `skills/bm-setup/` in a plugin named `basic-memory` is invoked as **`/basic-memory:bm-setup`** — namespacing is mandatory and can't be shortened. Skills are auto-discovered on install, no extra registration.
>
> **Naming decision (revised after dogfood).** The four skills carry a **`bm-` prefix** (`bm-setup`, `bm-remember`, `bm-share`, `bm-status`). Phase 2 originally *dropped* the prefix, reasoning that the `basic-memory:` namespace already disambiguates. Dogfooding the merged plugin proved otherwise: the Claude Code slash **picker shows only the bare skill name** — the plugin name is relegated to the focused-item tooltip — so un-prefixed skills blend into the list with other plugins' similarly-named ones (`spec`, `simplify`, `schedule`, …). The `bm-` prefix makes them group and read as ours in the picker. The cost is a cosmetic stutter in the full form (`/basic-memory:bm-setup`).
>
> **This is a workaround, not the end state.** The real fix is upstream: [anthropics/claude-code#50486](https://github.com/anthropics/claude-code/issues/50486) (open) asks the picker to namespace plugin skills the way it already does for *commands*. If/when that lands, the picker would show `basic-memory:setup` natively — revisit dropping the `bm-` prefix then, to kill the stutter. (Aside: [#22063](https://github.com/anthropics/claude-code/issues/22063) — a `name` frontmatter field stripping the namespace — does **not** affect us; with `name == dir` the namespace is retained, confirmed live in the session skill list.)
**`/basic-memory:setup`** — bootstrap interview (§7). Run after install and any time the user wants to reconfigure.
**`/basic-memory:bm-setup`** — bootstrap interview (§7). Run after install and any time the user wants to reconfigure.
**`/basic-memory:remember <text>`** — quick capture. Writes to a `bm-remember/` folder, separated from auto-captures. First line becomes title (truncated to 80 chars), tagged `manual-capture`. Optional `--project` flag for cross-project.
**`/basic-memory:bm-remember <text>`** — quick capture. Writes to a `bm-remember/` folder, separated from auto-captures. First line becomes title (truncated to 80 chars), tagged `manual-capture`. Optional `--project` flag for cross-project.
**`/basic-memory:status`** — show plugin state: active BM project, capture folders, recent SessionNotes, sync status, last successful BM call. Trust-building UI.
**`/basic-memory:bm-status`** — show plugin state: active BM project, capture folders, recent SessionNotes, sync status, last successful BM call. Trust-building UI.
### 4.5 The schema layer — why our note types are contracts, not conventions
@@ -207,8 +211,8 @@ Basic Memory ships a [schema system](https://docs.basicmemory.com/raw/concepts/s
| Note type | `type:` | Written by | Purpose |
| --------- | ------- | ---------- | ------- |
| Session | `session` | PreCompact hook, `/basic-memory:handoff` (future) | Resume cursor — what we were doing, what's next |
| Decision | `decision` | output-style reflex, `/basic-memory:decide` (future) | Durable record of choices + rationale |
| Session | `session` | PreCompact hook, `/basic-memory:bm-handoff` (future) | Resume cursor — what we were doing, what's next |
| Decision | `decision` | output-style reflex, `/basic-memory:bm-decide` (future) | Durable record of choices + rationale |
| Task | `task` | user + Claude | Active work tracking (aligns with `skills/memory-tasks`) |
These conform to the SessionNote / DecisionNote picoschema shapes defined in SPEC-55, so when the SPEC-55 Writer SDK and async pipeline land, validation Just Works and nothing has to change in user-facing behavior.
@@ -264,7 +268,7 @@ These are orthogonal concepts that the plugin must explicitly map.
### 5.1 Mapping model
Each Claude Code project has:
- **One primary BM project** — destination for SessionStart context + PreCompact checkpoints + `/basic-memory:remember`. Required.
- **One primary BM project** — destination for SessionStart context + PreCompact checkpoints + `/basic-memory:bm-remember`. Required.
- **Zero or more secondary BM projects** — read-only by default for recall (SessionStart can query them); writes require explicit user gesture.
Mapping is configured in `.claude/settings.json`:
@@ -311,11 +315,11 @@ UUID and routes accordingly. Cross-workspace reads route over the user's OAuth s
### 6.1 Defaults — safe by design
- **Auto-capture defaults to personal.** SessionNotes, PreCompact checkpoints, and
`/basic-memory:remember` quick captures **only ever** land in `primaryProject`. The
`/basic-memory:bm-remember` quick captures **only ever** land in `primaryProject`. The
capture hooks never write to a shared project — full stop, no opt-in flag in v0.4.
- **Recall reads across.** SessionStart queries the shared projects in parallel for
open decisions and folds them into the brief (read-only — discloses nothing).
- **Sharing is a deliberate gesture.** `/basic-memory:share` copies a personal note
- **Sharing is a deliberate gesture.** `/basic-memory:bm-share` copies a personal note
into a configured team project with attribution, after explicit confirmation. The
personal→team boundary is always a visible, manual action.
@@ -334,7 +338,7 @@ UUID and routes accordingly. Cross-workspace reads route over the user's OAuth s
```
- `secondaryProjects` — workspace-qualified refs (or UUIDs) read for recall. Read-only.
- `teamProjects` — share targets for `/basic-memory:share`; each carries a
- `teamProjects` — share targets for `/basic-memory:bm-share`; each carries a
`promoteFolder` (default `shared`). Also read for recall (SessionStart reads the
union of `secondaryProjects` and `teamProjects` keys, capped at 6 per session).
@@ -349,15 +353,15 @@ shared session memory; until then we don't ship a flag we don't enforce.
- New team members get instant context — first SessionStart pulls the team graph and they're already oriented.
- Cross-pollination — operator running a strategy session sees recent technical decisions from builders.
## 7. Bootstrap — `/basic-memory:setup` interview
## 7. Bootstrap — `/basic-memory:bm-setup` interview
Users get overwhelmed starting from zero. The plugin opens with a guided interview that establishes opinionated defaults from a short conversation.
> **Verified (Q6) — there is no install hook.** Claude Code has no PostInstall/PreInstall lifecycle event (feature request [#11240](https://github.com/anthropics/claude-code/issues/11240) was closed as duplicate). We can't auto-run setup the moment the plugin installs. The workaround is the **SessionStart hook detecting first-run**: it checks for a sentinel (e.g. `${CLAUDE_PLUGIN_DATA}/.bootstrapped` or the absence of a `basicMemory` config) and, if missing, injects a one-line nudge — *"Basic Memory isn't configured yet. Run `/basic-memory:setup` (≈3 min) to wire it up."* A bonus verified capability: SessionStart can return `{"reloadSkills": true}` to re-scan skills mid-session, useful if setup writes new skills/config that should activate without a restart.
> **Verified (Q6) — there is no install hook.** Claude Code has no PostInstall/PreInstall lifecycle event (feature request [#11240](https://github.com/anthropics/claude-code/issues/11240) was closed as duplicate). We can't auto-run setup the moment the plugin installs. The workaround is the **SessionStart hook detecting first-run**: it checks for a sentinel (e.g. `${CLAUDE_PLUGIN_DATA}/.bootstrapped` or the absence of a `basicMemory` config) and, if missing, injects a one-line nudge — *"Basic Memory isn't configured yet. Run `/basic-memory:bm-setup` (≈3 min) to wire it up."* A bonus verified capability: SessionStart can return `{"reloadSkills": true}` to re-scan skills mid-session, useful if setup writes new skills/config that should activate without a restart.
**Trigger paths:**
1. SessionStart detects no `basicMemory` config and no `basic-memory` note → inject the one-line nudge suggesting `/basic-memory:setup` (we cannot run it automatically)
2. User runs `/basic-memory:setup` explicitly (anytime, including for reconfiguration)
1. SessionStart detects no `basicMemory` config and no `basic-memory` note → inject the one-line nudge suggesting `/basic-memory:bm-setup` (we cannot run it automatically)
2. User runs `/basic-memory:bm-setup` explicitly (anytime, including for reconfiguration)
**Interview script** (Claude executes it; SKILL.md provides the structure):
@@ -368,14 +372,14 @@ Users get overwhelmed starting from zero. The plugin opens with a guided intervi
3. *"Are you using Basic Memory Cloud or local-only?"*
- If cloud + team workspace exists: *"You're on the `<team>` workspace. Want me to also read from team projects for recall? (read-only by default; we can opt-in to writes later)"*
4. *"How chatty should I be?"*
- **Light** (default): SessionStart brief on each session, PreCompact checkpoint, `/basic-memory:remember` on demand
- **Light** (default): SessionStart brief on each session, PreCompact checkpoint, `/basic-memory:bm-remember` on demand
- **Standard**: above + capture decisions inline via output-style
- **Heavy**: above + every-session SessionNote even without compaction
5. *"Should I look at your existing notes and suggest some placement conventions?"* (yes → runs `schema_infer` on the existing notes, summarizes the patterns it found, and stores them in the `basicMemory` settings block — see §4.3, since path-scoped rules don't load yet — from the user's *real* conventions rather than imposed ones)
6. *"I'll set up schemas for session checkpoints and decisions so I can find them precisely later — okay?"* (yes → writes `schemas/session.md`, `schemas/decision.md`, `schemas/task.md` into the primary project, skipping any that already exist; validation mode `warn`)
7. *"Want me to enable the `basic-memory` output style now?"* (yes → adds `outputStyle: basic-memory` to settings)
**Output:** writes `.claude/settings.json` (with prompt to commit or keep local) including any inferred conventions, writes the three schema notes, optionally creates the BM project, and drops the bootstrap sentinel so SessionStart stops nudging. Closes with: *"Done. I'll start using this on the next message. Try `/basic-memory:status` anytime to see what I'm tracking."*
**Output:** writes `.claude/settings.json` (with prompt to commit or keep local) including any inferred conventions, writes the three schema notes, optionally creates the BM project, and drops the bootstrap sentinel so SessionStart stops nudging. Closes with: *"Done. I'll start using this on the next message. Try `/basic-memory:bm-status` anytime to see what I'm tracking."*
**Why an interview, not a config form:** the interview is *adaptive* — it skips questions when context is obvious (e.g., it sees you're on cloud and on a team; doesn't ask about local), suggests reasonable defaults the user can accept with a single word, and produces a meaningful starting point in under 3 minutes. A config form makes the user own every decision.
@@ -431,10 +435,10 @@ Verified 2026-05-28 against Claude Code v2.1.153 and basic-memory 0.21.5, via a
|---|----------|---------|--------|--------------------|
| **Q1** | Does SessionStart fire before/after auto-memory loads? Can the hook use auto-memory as input? | **uncertain** | Firing order vs `MEMORY.md` load is **not documented**. The often-cited "SessionStart fires before CLAUDE.md loads" phrasing isn't in current docs. What *is* certain: the hook can read `MEMORY.md` from disk anytime. | Don't assume auto-memory is in context at SessionStart. If the brief wants it, **read the file from disk** (§4.1). Don't build anything that depends on context-load ordering. |
| **Q2** | Does PreCompact block synchronously? Timeout? Can it do multi-second/LLM work? | **confirmed** | Blocks synchronously; **600s default timeout** (configurable). MCP/LLM calls fit easily. On timeout the hook is killed and compaction proceeds. (To *block* compaction you'd return exit 2 / `decision:block` before the timeout — we don't.) | **Upgraded the design.** `preCompactCapture` default is now `"summarized"` (real LLM pass), not extractive (§4.2, §8). Write the note early, enrich after, in case of kill. |
| **Q3** | Do plugin skills/commands appear in the `/` menu, and as what? | **confirmed** | Auto-discovered on install, but **always namespaced** as `/<plugin-name>:<skill>`. Can't be shortened. No sparse/subdir caveats. | Drop the `bm-` prefix; folders become `setup/`, `remember/`, `status/``/basic-memory:setup` etc. (§4.4). README must show the namespaced form. |
| **Q3** | Do plugin skills/commands appear in the `/` menu, and as what? | **confirmed** | Auto-discovered, **always namespaced** as `/<plugin-name>:<skill>`, but the picker shows only the bare skill name (namespace in the tooltip). | Use a `bm-` prefix (`bm-setup` …) so they're legible in the picker — see §4.4. Workaround for [anthropics/claude-code#50486](https://github.com/anthropics/claude-code/issues/50486); revisit if that lands. |
| **Q4** | How does SessionStart inject context? Size limit? | **confirmed** | Plain stdout (added to context, no JSON needed) **or** JSON `hookSpecificOutput.additionalContext`. **10,000-char cap** per string; overflow spills to a file. Shell-profile echoes corrupt output. | Use plain stdout. Keep the brief well under 10k — cap each section's item count, prefer permalinks over previews. Guard against shell-profile noise (§4.1). |
| **Q5** | Do path-scoped `rules/` with `paths:` load for BM files (incl. outside the git tree)? | **refuted** | Path-scoped rules **don't load automatically at all** — open bug [#16853](https://github.com/anthropics/claude-code/issues/16853) ("never worked"). Even when fixed they're repo-relative (won't match `~/basic-memory/`, [#25562](https://github.com/anthropics/claude-code/issues/25562)). | **Dropped `rules/` from the plugin.** Placement/format conventions move to the `basicMemory` settings block + SessionStart brief + output-style (§4.3). Revisit if the platform bug is fixed *and* out-of-tree globs are supported. |
| **Q6** | Is there a run-on-install hook for bootstrap? | **confirmed** | No PostInstall/PreInstall lifecycle event ([#11240](https://github.com/anthropics/claude-code/issues/11240) closed as dup). Only SessionStart + explicit Setup. Bonus: SessionStart can return `{"reloadSkills": true}`. | Bootstrap can't auto-run. SessionStart detects first-run via a **sentinel file** and nudges the user to run `/basic-memory:setup` (§7). |
| **Q6** | Is there a run-on-install hook for bootstrap? | **confirmed** | No PostInstall/PreInstall lifecycle event ([#11240](https://github.com/anthropics/claude-code/issues/11240) closed as dup). Only SessionStart + explicit Setup. Bonus: SessionStart can return `{"reloadSkills": true}`. | Bootstrap can't auto-run. SessionStart detects first-run via a **sentinel file** and nudges the user to run `/basic-memory:bm-setup` (§7). |
| **Q7** | Does `search_notes` support `metadata_filters` + `after_date` in 0.21.5? Min version? | **confirmed** | Both present and working in 0.21.5. Full operator set: `$in`, `$gt/$gte/$lt/$lte`, `$between`, array-contains, equality, dot-notation (`schema.confidence`). On `search_notes` since **v0.18.1** (verify corrected the first pass's "v0.19.0"). | Structured recall is safe as a **baseline** — no fallback path needed for the 0.21.5 target. Pin a minimum `basic-memory >= 0.19.0` in prerequisites for margin. Use `tags`/`status` shorthands for common cases (§4.1). |
| **Q8** | Is `schema_validate` cheap enough for PreCompact? Are the schema tools in 0.21.5? | **confirmed** | All three (`validate`/`infer`/`diff`) present since v0.19.0. `schema_validate(identifier=…)` = **cheap single-note**. `schema_validate(note_type=…)` = **batch, O(N)** with per-note file I/O. | PreCompact validates only the note it just wrote via the **identifier path** (§4.2). Batch validation + `schema_diff` go to the nightly hygiene routine (§13). |
@@ -495,7 +499,7 @@ eventual default is `"summarized"` — the LLM pass is the first enrich step.
schemas / output-style / settings carry no version)
**Carried into later phases (not part of the minimal cut):** the first-run *sentinel
nudge* moves to Phase 3 (it should point at `/basic-memory:setup`, which doesn't exist
nudge* moves to Phase 3 (it should point at `/basic-memory:bm-setup`, which doesn't exist
yet); the multi-query parallel brief and the LLM-summarized PreCompact are the enrich
steps.
@@ -517,10 +521,10 @@ bash-injection scripts — avoids shell-quoting fragility on arbitrary user text
the `${CLAUDE_SKILL_DIR}` path uncertainty, and works regardless of the MCP server's
tool-name prefix.
- [x] Write `skills/remember/SKILL.md``/basic-memory:remember` — model-invocable
- [x] Write `skills/bm-remember/SKILL.md``/basic-memory:bm-remember` — model-invocable
("remember that…"); writes verbatim to `rememberFolder` with a first-line title and
`manual-capture` tag via `write_note`.
- [x] Write `skills/status/SKILL.md``/basic-memory:status``disable-model-invocation`
- [x] Write `skills/bm-status/SKILL.md``/basic-memory:bm-status``disable-model-invocation`
(user-only diagnostic); reports project, folders, output-style, recent checkpoints,
active-task count.
- [x] Test slash-command discovery end-to-end — installed from a local marketplace and
@@ -534,7 +538,7 @@ tool-name prefix.
Implemented as a **prose skill** (the interview is conversational; Claude runs it
using its MCP tools). Verified the whole loop end-to-end against throwaway projects.
- [x] Write `skills/setup/SKILL.md``/basic-memory:setup` — adaptive interview that
- [x] Write `skills/bm-setup/SKILL.md``/basic-memory:bm-setup` — adaptive interview that
maps the project, seeds schemas, optionally learns conventions, writes settings, and
enables the output style. Model-invocable ("set up basic memory") + user-invocable.
- [x] Wire `schema_infer`/`list_directory` into the bootstrap — the skill inspects the
@@ -546,7 +550,7 @@ using its MCP tools). Verified the whole loop end-to-end against throwaway proje
`schema_validate` (`entity=Session/Decision/Task`). This corrects the earlier Phase 1
finding — schema seeding is a plain content copy; the previous "must use
`note_type`/`metadata`" conclusion was confounded by the enum YAML bug.
- [x] First-run detection in SessionStart — nudges toward `/basic-memory:setup` when no
- [x] First-run detection in SessionStart — nudges toward `/basic-memory:bm-setup` when no
`basicMemory` config block exists (config presence is the sentinel; no separate file).
The nudge survives a failed/empty task query. Verified across all three config states
(no config → nudge; block without project → pin tip; project pinned → silent).
@@ -561,7 +565,7 @@ using its MCP tools). Verified the whole loop end-to-end against throwaway proje
### Phase 4: Team workspace support — ✅ DONE (2026-05-28)
Grounded in a real two-workspace BM Cloud account (verified name-collision routing,
OAuth cross-workspace reads). Pulled `/basic-memory:share` forward from future-work
OAuth cross-workspace reads). Pulled `/basic-memory:bm-share` forward from future-work
since team usage needs a safe write path.
- [x] Extend SessionStart to read primary + shared projects **in parallel**
@@ -569,7 +573,7 @@ since team usage needs a safe write path.
decisions; each shared project: open decisions). Routes by qualified name or UUID,
per-call timeout, capped at 6 shared projects, graceful on any failure. Verified
against the real `my-team-2` workspace and with local fixtures.
- [x] Add `/basic-memory:share` (`skills/share/`) — the deliberate personal→team
- [x] Add `/basic-memory:bm-share` (`skills/bm-share/`) — the deliberate personal→team
write: reads a note from `primaryProject`, confirms, and copies it to a configured
`teamProjects` target's `promoteFolder` with `shared_from` attribution. Preserves
the note's type so shared decisions stay findable in the team's structured recall.
@@ -611,11 +615,11 @@ Docs done 2026-05-28; dogfood is the remaining (human) step.
## 13. Future work (post-v0.4)
- **Routines integration** — three routine templates (nightly hygiene, weekly digest, daily reflection). Separate design doc. The nightly hygiene routine is the natural home for `schema_diff` drift detection and the deferred LLM-summary pass over the day's extractive SessionNotes.
- ~~**`/basic-memory:share`** — promote personal note → team project~~ — shipped in Phase 4.
- ~~**`/basic-memory:bm-share`** — promote personal note → team project~~ — shipped in Phase 4.
- **Team `autoWrite`** — opt-in for auto-capture (PreCompact/remember) to write to a
team project, for teams that want shared session memory. Deferred from Phase 4 (§6.2).
- **`/basic-memory:blame <sha>`** — code archaeology, builder add-on.
- **`/basic-memory:bm-blame <sha>`** — code archaeology, builder add-on.
- **Commit-hook integration** — PostToolUse on `Bash(git commit *)` writes CommitNote linking SHA to session's BM writes.
- **Subagent memory bundling** — explore `memory: project|user` on dedicated BM subagents.
- **Statusline** — small visible presence (active project, last write).
- **`/basic-memory:promote`** — review auto-memory MEMORY.md, graduate observations into BM with proper schema.
- **`/basic-memory:bm-promote`** — review auto-memory MEMORY.md, graduate observations into BM with proper schema.
+8 -8
View File
@@ -38,10 +38,10 @@ Plugin skills are namespaced under the plugin name:
| Command | What it does |
|---------|--------------|
| `/basic-memory:setup` | One-time guided setup — maps the project to a Basic Memory project, seeds the note schemas, installs the shared `memory-*` skills, optionally learns your conventions, and turns on the capture reflexes. Run this first. |
| `/basic-memory:remember <text>` | Quick capture — saves the text to the `bm-remember` folder with a `manual-capture` tag. Also fires when you say "remember that…". |
| `/basic-memory:share <note>` | Promote a personal note to a configured team project, with attribution and confirmation. The deliberate way to write to a shared workspace. |
| `/basic-memory:status` | Diagnostic — shows the active project, team read-sources and share targets, capture folders, output-style state, recent session checkpoints, and active-task count. |
| `/basic-memory:bm-setup` | One-time guided setup — maps the project to a Basic Memory project, seeds the note schemas, installs the shared `memory-*` skills, optionally learns your conventions, and turns on the capture reflexes. Run this first. |
| `/basic-memory:bm-remember <text>` | Quick capture — saves the text to the `bm-remember` folder with a `manual-capture` tag. Also fires when you say "remember that…". |
| `/basic-memory:bm-share <note>` | Promote a personal note to a configured team project, with attribution and confirmation. The deliberate way to write to a shared workspace. |
| `/basic-memory:bm-status` | Diagnostic — shows the active project, team read-sources and share targets, capture folders, output-style state, recent session checkpoints, and active-task count. |
## Requirements
@@ -61,7 +61,7 @@ claude plugin install basic-memory@basicmachines-co
## Configuration
The fastest path is **`/basic-memory:setup`** — a ~2-minute interview that writes
The fastest path is **`/basic-memory:bm-setup`** — a ~2-minute interview that writes
the config, seeds the schemas, and turns on the capture reflexes. The SessionStart
hook nudges you toward it on first run.
@@ -103,14 +103,14 @@ ever auto-writing to the shared graph.**
- **Read across** — add team projects to `secondaryProjects`. SessionStart pulls their
open decisions into your brief (in parallel, read-only), so you start oriented on
what the team has decided.
- **Capture stays personal** — session checkpoints and `/basic-memory:remember` only
- **Capture stays personal** — session checkpoints and `/basic-memory:bm-remember` only
ever write to your `primaryProject`. Nothing lands in a team project automatically.
- **Share deliberately**`/basic-memory:share` copies a chosen note into a
- **Share deliberately**`/basic-memory:bm-share` copies a chosen note into a
`teamProjects` target (with attribution and a confirmation step). That's the only
path to a shared write.
Because project names repeat across workspaces, team refs must be **workspace-qualified**
(`my-team/notes`) or `external_id` UUIDs — `/basic-memory:setup` fills these in for you
(`my-team/notes`) or `external_id` UUIDs — `/basic-memory:bm-setup` fills these in for you
from `list_workspaces`.
## Documentation
+4 -4
View File
@@ -38,7 +38,7 @@ flowchart TB
OS["output-style<br/>→ search-first / capture / cite reflexes"]
end
subgraph Deliberate["Deliberate (slash commands)"]
SK["/basic-memory:setup · remember · share · status"]
SK["/basic-memory:bm-setup · bm-remember · bm-share · bm-status"]
end
Ambient --> MCP["Basic Memory MCP server"]
@@ -85,7 +85,7 @@ Key properties:
is ~one query, not the sum.
- **Best-effort.** No Basic Memory, no config, or a slow cloud read never blocks or
errors the session — the worst case is a missing or partial brief.
- **First-run aware.** With no config it nudges toward `/basic-memory:setup`.
- **First-run aware.** With no config it nudges toward `/basic-memory:bm-setup`.
## PreCompact — the checkpoint
@@ -145,7 +145,7 @@ flowchart TB
T1 -- "read-only<br/>(SessionStart)" --> P
T2 -- "read-only<br/>(SessionStart)" --> P
P -- "/basic-memory:share<br/>(deliberate, confirmed)" --> T2
P -- "/basic-memory:bm-share<br/>(deliberate, confirmed)" --> T2
note["Auto-capture (checkpoints, /remember)<br/>writes ONLY to primaryProject"]
```
@@ -160,7 +160,7 @@ project names collide across workspaces. Reads route over the user's OAuth sessi
| `hooks/session-start.sh`, `hooks/pre-compact.sh` | the ambient bridge (read / write) |
| `hooks/hooks.json` | registers the hooks |
| `output-styles/basic-memory.md` | the capture reflexes |
| `skills/{setup,remember,share,status}/` | the deliberate slash commands |
| `skills/{bm-setup,bm-remember,bm-share,bm-status}/` | the deliberate slash commands |
| `schemas/{session,decision,task}.md` | picoschema seeds (copied into your project at setup) |
| `.claude/settings.json``basicMemory` | per-project configuration |
| your Basic Memory projects | all actual content |
+8 -8
View File
@@ -37,7 +37,7 @@ SessionStart, PreCompact**.
In a project (repo) where you want memory, run:
```
/basic-memory:setup
/basic-memory:bm-setup
```
It's a short interview. It will:
@@ -57,7 +57,7 @@ It's a short interview. It will:
When it finishes, run:
```
/basic-memory:status
/basic-memory:bm-status
```
to see exactly what the plugin is tracking.
@@ -67,7 +67,7 @@ to see exactly what the plugin is tracking.
1. **Capture a decision.** In normal conversation, make a decision — e.g. *"Let's use
Postgres, not SQLite, because we need concurrent writers."* With the output style on,
Claude writes a `type: decision` note and tells you the permalink.
2. **Quick-capture something.** `/basic-memory:remember switch the staging job to the
2. **Quick-capture something.** `/basic-memory:bm-remember switch the staging job to the
new image after the rebase lands` → saved to `bm-remember/`.
3. **Start a fresh session.** Open a new Claude Code session in the same project. The
**SessionStart brief** appears first thing, showing your active tasks and the open
@@ -83,7 +83,7 @@ graph accumulates.
On Basic Memory Cloud with a team workspace, you can read team context into your brief
and publish back deliberately.
Re-run `/basic-memory:setup` (or edit `.claude/settings.json`). Because project names
Re-run `/basic-memory:bm-setup` (or edit `.claude/settings.json`). Because project names
repeat across workspaces, team projects use **workspace-qualified names**
(`my-team/notes`) or `external_id` UUIDs — setup finds these for you via
`list_workspaces`.
@@ -102,7 +102,7 @@ repeat across workspaces, team projects use **workspace-qualified names**
Now:
- SessionStart folds the team's **open decisions** into your brief (read-only).
- Your captures still go **only** to `primaryProject` — never to the team.
- `/basic-memory:share <note>` publishes a chosen note to `my-team/notes/shared`, with
- `/basic-memory:bm-share <note>` publishes a chosen note to `my-team/notes/shared`, with
attribution and a confirmation step.
Tip: a team brief is only as rich as the team's typed notes. Share an existing decision
@@ -116,9 +116,9 @@ Everything is in the `basicMemory` block of `.claude/settings.json`. Common knob
|-----|---------|--------------|
| `primaryProject` | (default project) | where briefs read from and captures write to |
| `secondaryProjects` | `[]` | team/shared projects read for recall (read-only) |
| `teamProjects` | `{}` | share targets for `/basic-memory:share` |
| `teamProjects` | `{}` | share targets for `/basic-memory:bm-share` |
| `captureFolder` | `sessions` | folder for PreCompact checkpoints |
| `rememberFolder` | `bm-remember` | folder for `/basic-memory:remember` |
| `rememberFolder` | `bm-remember` | folder for `/basic-memory:bm-remember` |
| `recallTimeframe` | `3d` | recency window for the brief |
| `preCompactCapture` | `extractive` | how checkpoints are produced |
@@ -126,7 +126,7 @@ See [settings.example.json](../settings.example.json) for the full shape.
## Troubleshooting
- **No brief at session start?** Confirm Basic Memory is connected (`/basic-memory:status`).
- **No brief at session start?** Confirm Basic Memory is connected (`/basic-memory:bm-status`).
The hooks are silent if `basic-memory` isn't on PATH.
- **Checkpoints aren't being written?** A `primaryProject` must be set — the PreCompact
hook never writes to an un-pinned/default project on its own.
@@ -72,18 +72,18 @@ often on a **team**.
> team's recent open decisions. You're oriented before you ask a single question.
**Wins:** no context bleeding between projects; team memory that compounds across
people; sharing a decision to the team in one gesture (`/basic-memory:share`).
people; sharing a decision to the team in one gesture (`/basic-memory:bm-share`).
## What you actually get
Once installed and set up (`/basic-memory:setup`):
Once installed and set up (`/basic-memory:bm-setup`):
- **Session briefings** — start each session knowing your active tasks, open decisions,
and (if on a team) recent team context.
- **Checkpoints that survive compaction** — long sessions don't lose their thread.
- **Capture reflexes** — Claude searches before answering recall questions and writes
down real decisions as it goes, citing permalinks.
- **Quick capture**`/basic-memory:remember` for a fast note without breaking flow.
- **Quick capture**`/basic-memory:bm-remember` for a fast note without breaking flow.
- **Team memory** — read across shared projects; publish back deliberately.
All of it in plain Markdown files you own, in projects you control — local, cloud, or
+3 -3
View File
@@ -177,7 +177,7 @@ with ThreadPoolExecutor(max_workers=3 + MAX_SHARED) as pool:
# The first-run nudge — shown until setup writes a basicMemory config block.
setup_nudge = (
"_Basic Memory isn't set up for this project yet. Run "
"`/basic-memory:setup` (~2 min) to configure session briefings and checkpoints._"
"`/basic-memory:bm-setup` (~2 min) to configure session briefings and checkpoints._"
)
# Trigger: every primary query failed (no default project, misnamed project,
@@ -193,7 +193,7 @@ if primary_tasks is None and primary_decisions is None and primary_sessions is N
print(
"# Basic Memory\n\n"
f"_Couldn't read from `{proj}` — it may be misnamed or unreachable. "
"Run `/basic-memory:status` to check._"
"Run `/basic-memory:bm-status` to check._"
)
sys.exit(0)
@@ -246,7 +246,7 @@ if shared_sections:
lines += [
"",
"_Shared-project context is read-only. Your captures stay in this project; "
"use `/basic-memory:share` to deliberately promote a note to the team._",
"use `/basic-memory:bm-share` to deliberately promote a note to the team._",
]
if shared_capped:
lines += ["", f"_(reading the first {MAX_SHARED} shared projects; more are configured.)_"]
+1 -1
View File
@@ -24,7 +24,7 @@ settings:
A **DecisionNote** is a durable record of a real choice — one with alternatives
and a rationale, not a passing preference. The Basic Memory plugin's output-style
prompts Claude to capture these inline as decisions are made, and the future
`/basic-memory:decide` command captures them explicitly.
`/basic-memory:bm-decide` command captures them explicitly.
Decisions are found by structured recall:
`search_notes(metadata_filters={"type": "decision", "status": "open"})`.
+1 -1
View File
@@ -25,7 +25,7 @@ settings:
# Session
A **SessionNote** is a resume checkpoint written by the Basic Memory plugin's
PreCompact hook (and, later, the `/basic-memory:handoff` command) right before
PreCompact hook (and, later, the `/basic-memory:bm-handoff` command) right before
Claude Code compacts the context window. It records what the session was doing
so the next session can pick up where this one left off.
+1 -1
View File
@@ -1,5 +1,5 @@
{
"$comment": "Example Basic Memory plugin settings. Copy the basicMemory block (and optionally outputStyle) into your project's .claude/settings.json, then set primaryProject. The easiest way to fill this in is /basic-memory:setup. Team projects (secondaryProjects, teamProjects) must use workspace-qualified names like 'my-team/notes' or external_id UUIDs — bare names are ambiguous across workspaces. See DESIGN.md for the full schema.",
"$comment": "Example Basic Memory plugin settings. Copy the basicMemory block (and optionally outputStyle) into your project's .claude/settings.json, then set primaryProject. The easiest way to fill this in is /basic-memory:bm-setup. Team projects (secondaryProjects, teamProjects) must use workspace-qualified names like 'my-team/notes' or external_id UUIDs — bare names are ambiguous across workspaces. See DESIGN.md for the full schema.",
"basicMemory": {
"primaryProject": "my-project",
"secondaryProjects": ["my-team/main", "my-team/notes"],
@@ -1,6 +1,6 @@
---
name: remember
description: Quickly capture a thought, fact, or reminder into Basic Memory as a lightweight note. Use when the user says "remember that…", "note this", "save this to memory", or runs /basic-memory:remember. For quick deliberate capture — not full decision or session records.
name: bm-remember
description: Quickly capture a thought, fact, or reminder into Basic Memory as a lightweight note. Use when the user says "remember that…", "note this", "save this to memory", or runs /basic-memory:bm-remember. For quick deliberate capture — not full decision or session records.
argument-hint: <text to remember>
---
@@ -1,6 +1,6 @@
---
name: setup
description: Set up the Basic Memory plugin for this project — a short guided interview that configures the project mapping, seeds note schemas, learns or suggests placement conventions, and enables capture reflexes. Use when the user runs /basic-memory:setup, says "set up basic memory", or asks to configure/bootstrap the plugin.
name: bm-setup
description: Set up the Basic Memory plugin for this project — a short guided interview that configures the project mapping, seeds note schemas, learns or suggests placement conventions, and enables capture reflexes. Use when the user runs /basic-memory:bm-setup, says "set up basic memory", or asks to configure/bootstrap the plugin.
argument-hint: (no arguments — runs an interactive interview)
---
@@ -71,7 +71,7 @@ Ask only what you can't infer. Cover:
six, order the most relevant first and tell them the rest are configured but
not read each session.
- **Share target** (optional): if the user wants a place to *publish* notes to the
team via `/basic-memory:share`, add it to `teamProjects` as
team via `/basic-memory:bm-share`, add it to `teamProjects` as
`"<qualified-name>": { "promoteFolder": "shared" }`. Sharing is always a manual
gesture — auto-capture never writes to a team project.
@@ -102,7 +102,7 @@ Ask only what you can't infer. Cover:
6. **How active should I be? (output style)** "Want me to proactively capture —
search the graph before recalling, write material decisions as typed notes, and
cite permalinks? Or keep it quiet (just the session brief, the PreCompact
checkpoint, and `/basic-memory:remember` on demand)?" Enabling it sets
checkpoint, and `/basic-memory:bm-remember` on demand)?" Enabling it sets
`outputStyle: "basic-memory"`. Default to enabled; leave it off for a recall-only,
low-noise setup. (This is the single knob for how proactive the assistant is —
the hooks always run regardless.)
@@ -119,7 +119,7 @@ Ask only what you can't infer. Cover:
### 1. Seed the schemas
The plugin ships seed schemas at `<plugin>/schemas/` — that's **two directories up
from this skill's directory, then `schemas/`** (this skill is at
`<plugin>/skills/setup/`). Read `session.md`, `decision.md`, and `task.md` there.
`<plugin>/skills/bm-setup/`). Read `session.md`, `decision.md`, and `task.md` there.
For each one:
- Check whether the chosen project already has a schema for that type
@@ -222,5 +222,5 @@ Then handle activation based on the output style:
proactive-capture reflexes wait for the restart.
- **Output style off** → no restart needed; the hooks already run.
End with: *"Done — I'll use this from the next message. Run `/basic-memory:status`
End with: *"Done — I'll use this from the next message. Run `/basic-memory:bm-status`
anytime to see what I'm tracking."*
@@ -1,6 +1,6 @@
---
name: share
description: Promote a note from your personal Basic Memory project to a shared team project, with attribution. Use when the user says "share this with the team", "publish this decision", or runs /basic-memory:share. This is the deliberate way to write to a team workspace — auto-capture never does.
name: bm-share
description: Promote a note from your personal Basic Memory project to a shared team project, with attribution. Use when the user says "share this with the team", "publish this decision", or runs /basic-memory:bm-share. This is the deliberate way to write to a team workspace — auto-capture never does.
argument-hint: <note title or permalink to share>
---
@@ -8,7 +8,7 @@ argument-hint: <note title or permalink to share>
Copy a note from the personal/primary project into a configured **team project** so
teammates can see it. This is the *only* path by which the plugin writes to a shared
project — session checkpoints and `/basic-memory:remember` always stay personal.
project — session checkpoints and `/basic-memory:bm-remember` always stay personal.
## Steps
@@ -19,7 +19,7 @@ project — session checkpoints and `/basic-memory:remember` always stay persona
- `primaryProject` — the source project notes are read from.
If `teamProjects` is empty, tell the user there's no share target configured and
suggest adding one (or running `/basic-memory:setup`), then stop. Don't invent a
suggest adding one (or running `/basic-memory:bm-setup`), then stop. Don't invent a
target.
2. **Find the source note.** From `$ARGUMENTS` (a title, permalink, or `memory://`
@@ -1,5 +1,5 @@
---
name: status
name: bm-status
description: Show the Basic Memory plugin's current state for this project — active project, capture folders, output style, recent session checkpoints, and whether Basic Memory is reachable.
disable-model-invocation: true
---
@@ -19,7 +19,7 @@ This is a quick diagnostic — gather the facts and lay them out; don't over-inv
if present) and report:
- From the `basicMemory` block: `primaryProject` (or note none is pinned — the
default project is used), `secondaryProjects` (team/shared read sources),
`teamProjects` (share targets for `/basic-memory:share`), `captureFolder`
`teamProjects` (share targets for `/basic-memory:bm-share`), `captureFolder`
(default `sessions`), `rememberFolder` (default `bm-remember`), and
`preCompactCapture` mode (default `extractive`).
- From the **root** settings object (not `basicMemory`): whether `outputStyle` is
+46
View File
@@ -0,0 +1,46 @@
{
"name": "codex",
"version": "0.21.6",
"description": "A Codex-native bridge to Basic Memory for durable engineering context, decisions, and resumable checkpoints.",
"author": {
"name": "Basic Machines",
"email": "hello@basicmachines.co",
"url": "https://basicmemory.com"
},
"homepage": "https://docs.basicmemory.com",
"repository": "https://github.com/basicmachines-co/basic-memory/tree/main/plugins/codex",
"license": "MIT",
"keywords": [
"basic-memory",
"codex",
"memory",
"knowledge-graph",
"mcp",
"checkpoints"
],
"skills": "./skills/",
"mcpServers": "./.mcp.json",
"interface": {
"displayName": "Basic Memory for Codex",
"shortDescription": "Carry decisions, active work, and handoffs across Codex threads",
"longDescription": "Use Basic Memory for Codex to orient from your durable knowledge graph, capture engineering decisions, checkpoint long-running work, and resume with repo-backed context across Codex sessions.",
"developerName": "Basic Machines",
"category": "Developer Tools",
"capabilities": [
"Interactive",
"Read",
"Write"
],
"websiteURL": "https://basicmemory.com",
"privacyPolicyURL": "https://basicmemory.com/privacy",
"termsOfServiceURL": "https://basicmemory.com/terms",
"defaultPrompt": [
"Use Basic Memory to orient before changing this repo.",
"Checkpoint this Codex thread into Basic Memory.",
"Capture the decision we just made."
],
"brandColor": "#2563EB",
"composerIcon": "./assets/app-icon.png",
"logo": "./assets/logo.png"
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"mcpServers": {
"basic-memory": {
"command": "uvx",
"args": [
"basic-memory",
"mcp"
]
}
}
}
+80
View File
@@ -0,0 +1,80 @@
# Basic Memory Codex Plugin Development
This plugin is developed in-place from the Basic Memory repository. Codex installs local plugins
through marketplaces, so local testing uses a repo-local marketplace wrapper rather than publishing
anything external.
## Local Marketplace
The repo marketplace lives at:
```text
.agents/plugins/marketplace.json
```
It exposes this plugin as:
```text
codex@basic-memory-local
```
The marketplace entry points at `./plugins/codex`, resolved relative to the repository root.
## First-Time Setup
From the repository root:
```bash
codex plugin marketplace add "$(git rev-parse --show-toplevel)"
codex plugin add codex@basic-memory-local
```
Start a new Codex thread after installing. New threads are the reliable boundary for picking up
plugin skills, hooks, and MCP configuration.
Plugin installation is user-level in Codex, so one install makes the plugin available across
projects on the same machine. Repo-specific memory routing still comes from each checkout's
`.codex/basic-memory.json`.
## Iteration Loop
After changing files in `plugins/codex`, run the local checks:
```bash
just package-check-codex
```
Then update the manifest cachebuster and reinstall from the local marketplace:
```bash
python3 "$CODEX_PLUGIN_CREATOR_SCRIPTS/update_plugin_cachebuster.py" \
"$(git rev-parse --show-toplevel)/plugins/codex"
codex plugin add codex@basic-memory-local
```
Start a fresh Codex thread to test the updated plugin.
## Useful Checks
List configured marketplaces:
```bash
codex plugin marketplace list
```
List plugins Codex can see:
```bash
codex plugin list
```
Run the full package validation gate when touching plugin packaging, shared skills, or integration
metadata:
```bash
just package-check
```
To also run Codex's scaffold validator during `just package-check-codex`, set
`CODEX_PLUGIN_VALIDATOR` to the local `plugin-creator` validator script before
running the check.
+93
View File
@@ -0,0 +1,93 @@
# Basic Memory for Codex
Basic Memory for Codex is the Codex-native bridge between a working coding thread
and Basic Memory's durable knowledge graph.
It is not a 1:1 copy of the Claude Code plugin. This version leans into Codex
workflows: repo orientation, long-running goals, changed-file evidence, explicit
verification, decision capture, and resumable checkpoints.
## What It Does
- **Orient from memory.** The `bm-orient` skill reads active tasks, open
decisions, and recent Codex checkpoints before substantial work.
- **Checkpoint work.** The `bm-checkpoint` skill and `PreCompact` hook write
`type: codex_session` notes with the current work cursor.
- **Capture decisions.** The `bm-decide` skill records durable engineering
decisions with rationale, alternatives, and consequences.
- **Remember lightly.** The `bm-remember` skill saves small facts without turning
them into a full decision or session note.
- **Share deliberately.** The `bm-share` skill copies personal notes to configured
team projects only after confirmation.
- **Report status.** The `bm-status` skill shows configuration, reachability, and
recent memory state.
## Package Contents
| Path | Role |
| --- | --- |
| `.codex-plugin/plugin.json` | Codex plugin manifest |
| `.mcp.json` | Basic Memory MCP server configuration |
| `hooks/hooks.json` | SessionStart and PreCompact hook registration |
| `hooks/session-start.sh` | Launches the SessionStart uv script |
| `hooks/session-start.py` | Injects a compact memory brief at thread start |
| `hooks/pre-compact.sh` | Launches the PreCompact uv script |
| `hooks/pre-compact.py` | Writes an automatic Codex checkpoint before compaction |
| `skills/` | Codex-native Basic Memory workflows |
| `schemas/` | Seed schemas for Codex sessions, decisions, and tasks |
## Install
Install the plugin once from the Basic Memory repository root:
```bash
codex plugin marketplace add "$(git rev-parse --show-toplevel)"
codex plugin add codex@basic-memory-local
```
Plugin installation is user-level in Codex, so one install makes the plugin
available across projects on the same machine. Start a new Codex thread after
installing so Codex can load the plugin skills, MCP configuration, and hooks.
Each repository still needs its own `.codex/basic-memory.json` so the plugin
knows which Basic Memory project and folders to use for that checkout. Run the
setup skill in each repo, or create the config file shown below.
## Configuration
Run the setup skill, or create `.codex/basic-memory.json` in a repo:
```json
{
"basicMemory": {
"primaryProject": "my-project",
"secondaryProjects": [],
"teamProjects": {},
"focus": "code/dev",
"captureFolder": "codex-sessions",
"rememberFolder": "codex-remember",
"recallTimeframe": "7d",
"placementConventions": "Put decisions in decisions/ and work checkpoints in codex-sessions/."
}
}
```
Codex plugin hooks must be reviewed and trusted before they run. Open `/hooks` in
Codex after enabling the plugin and trust the Basic Memory hook definitions.
## Development
From this directory:
```bash
just check
```
From the repo root:
```bash
just package-check-codex
```
The package intentionally keeps Codex-specific configuration separate from
Claude's `.claude/settings.json`.
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+31
View File
@@ -0,0 +1,31 @@
{
"description": "Basic Memory for Codex hooks - orient from the graph on session start and checkpoint before compaction.",
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume|compact",
"hooks": [
{
"type": "command",
"command": "${PLUGIN_ROOT}/hooks/session-start.sh",
"statusMessage": "Loading Basic Memory context",
"timeout": 30
}
]
}
],
"PreCompact": [
{
"matcher": "manual|auto",
"hooks": [
{
"type": "command",
"command": "${PLUGIN_ROOT}/hooks/pre-compact.sh",
"statusMessage": "Checkpointing Codex work to Basic Memory",
"timeout": 60
}
]
}
]
}
}
+232
View File
@@ -0,0 +1,232 @@
#!/usr/bin/env -S uv run --script
"""Checkpoint Codex work into Basic Memory before compaction."""
import json
import os
import re
import shlex
import shutil
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
UUID_RE = re.compile(
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
re.IGNORECASE,
)
def basic_memory_command() -> list[str] | None:
configured = os.environ.get("BM_BIN")
if configured:
return shlex.split(configured)
if shutil.which("basic-memory"):
return ["basic-memory"]
if shutil.which("bm"):
return ["bm"]
if shutil.which("uvx"):
return ["uvx", "basic-memory"]
if shutil.which("uv"):
return ["uv", "tool", "run", "basic-memory"]
return None
def parse_payload() -> dict:
try:
payload = json.loads(sys.stdin.read() or "{}")
except Exception:
return {}
return payload if isinstance(payload, dict) else {}
def load_config(directory: Path) -> dict:
path = directory / ".codex" / "basic-memory.json"
try:
data = json.loads(path.read_text())
except Exception:
return {}
if not isinstance(data, dict):
return {}
return data.get("basicMemory", data)
def text_of(content):
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
text = block.get("text")
if isinstance(text, str):
parts.append(text)
return "\n".join(parts)
return ""
def transcript_turns(path: str):
collected = []
if not path:
return collected
try:
with open(path) as handle:
for line in handle:
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except Exception:
continue
if obj.get("isMeta") or obj.get("toolUseResult") is not None:
continue
msg = obj.get("message") if isinstance(obj.get("message"), dict) else obj
role = msg.get("role") or obj.get("type")
if role not in ("user", "assistant"):
continue
text = text_of(msg.get("content")).strip()
if text:
collected.append((role, text))
except Exception:
return []
return collected
def git_status(directory: Path) -> list[str]:
try:
out = subprocess.run(
["git", "status", "--short"],
cwd=directory,
capture_output=True,
text=True,
timeout=5,
)
except Exception:
return []
if out.returncode != 0:
return []
return [line for line in out.stdout.splitlines() if line.strip()][:20]
def clip(value: str, limit: int) -> str:
compact = " ".join(value.split())
return compact if len(compact) <= limit else compact[: limit - 1].rstrip() + "..."
def main() -> int:
bm_cmd = basic_memory_command()
if not bm_cmd:
return 0
payload = parse_payload()
cwd = Path(payload.get("cwd") or os.getcwd())
transcript_path = payload.get("transcript_path") or ""
session_id = payload.get("session_id") or ""
turn_id = payload.get("turn_id") or ""
trigger = payload.get("trigger") or ""
model = payload.get("model") or ""
cfg = load_config(cwd)
primary_project = str(cfg.get("primaryProject") or "").strip()
capture_folder = str(cfg.get("captureFolder") or "codex-sessions").strip()
if not primary_project:
return 0
conversation = transcript_turns(transcript_path)
if not conversation or not any(role == "user" for role, _ in conversation):
return 0
user_messages = [text for role, text in conversation if role == "user"]
assistant_messages = [text for role, text in conversation if role == "assistant"]
opening = user_messages[0] if user_messages else ""
recent_user = user_messages[-3:]
recent_assistant = assistant_messages[-2:]
status_lines = git_status(cwd)
now = datetime.now(timezone.utc)
iso = now.isoformat(timespec="seconds")
title = f"Codex session {now.strftime('%Y-%m-%d %H:%M:%S')} - {clip(opening, 40)}"
frontmatter = [
"---",
"type: codex_session",
"status: open",
f"started: {iso}",
f"ended: {iso}",
f"project: {primary_project}",
f"cwd: {cwd}",
]
if session_id:
frontmatter.append(f"codex_session_id: {session_id}")
if turn_id:
frontmatter.append(f"codex_turn_id: {turn_id}")
if trigger:
frontmatter.append(f"trigger: {trigger}")
if model:
frontmatter.append(f"model: {model}")
frontmatter += ["capture: extractive", "---"]
body = [
"",
f"# {title}",
"",
"_Automatic Codex pre-compaction checkpoint. It records the working cursor, "
"not a polished summary._",
"",
"## Summary",
f"Working in `{cwd}`.",
f"- Opening request: {clip(opening, 300)}" if opening else "",
"",
"## Recent User Cursor",
]
body += [f"- {clip(message, 240)}" for message in recent_user]
if recent_assistant:
body += ["", "## Recent Assistant Notes"]
body += [f"- {clip(message, 240)}" for message in recent_assistant]
if status_lines:
body += ["", "## Working Tree"]
body += [f"- `{line}`" for line in status_lines]
body += [
"",
"## Observations",
f"- [context] Codex worked in `{cwd}`",
f"- [context] Session opened with: {clip(opening, 200)}" if opening else "",
"- [next_step] Re-read this checkpoint, inspect the current worktree, and "
"continue from the latest user request",
]
content = "\n".join(frontmatter + body)
project_flag = "--project-id" if UUID_RE.match(primary_project) else "--project"
try:
subprocess.run(
[
*bm_cmd,
"tool",
"write-note",
"--title",
title,
"--folder",
capture_folder,
project_flag,
primary_project,
"--tags",
"codex",
"--tags",
"auto-capture",
],
input=content,
capture_output=True,
text=True,
timeout=60,
)
except Exception:
return 0
return 0
if __name__ == "__main__":
raise SystemExit(main())
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
#
# PreCompact hook - checkpoint Codex work into Basic Memory before compaction.
#
# Contract: best effort. The hook only writes when .codex/basic-memory.json pins a
# primary project, and every failure exits 0 so compaction can continue.
set -u
if ! command -v uv >/dev/null 2>&1; then
exit 0
fi
script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
uv run --script "$script_dir/pre-compact.py" 2>/dev/null || exit 0
+237
View File
@@ -0,0 +1,237 @@
#!/usr/bin/env -S uv run --script
"""Brief Codex from Basic Memory at thread start."""
import json
import os
import re
import shlex
import shutil
import subprocess
import sys
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
UUID_RE = re.compile(
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
re.IGNORECASE,
)
MAX_SHARED = 6
def basic_memory_command() -> list[str] | None:
configured = os.environ.get("BM_BIN")
if configured:
return shlex.split(configured)
if shutil.which("basic-memory"):
return ["basic-memory"]
if shutil.which("bm"):
return ["bm"]
if shutil.which("uvx"):
return ["uvx", "basic-memory"]
if shutil.which("uv"):
return ["uv", "tool", "run", "basic-memory"]
return None
def parse_payload() -> dict:
try:
payload = json.loads(sys.stdin.read() or "{}")
except Exception:
return {}
return payload if isinstance(payload, dict) else {}
def load_config(directory: Path) -> tuple[dict, bool]:
path = directory / ".codex" / "basic-memory.json"
try:
data = json.loads(path.read_text())
except FileNotFoundError:
return {}, False
except Exception:
return {}, True
if not isinstance(data, dict):
return {}, True
return data.get("basicMemory", data), True
def project_args(project_ref: str | None) -> list[str]:
if not project_ref:
return []
flag = "--project-id" if UUID_RE.match(project_ref) else "--project"
return [flag, project_ref]
def search(
bm_cmd: list[str],
filters: list[str],
project_ref: str | None = None,
timeout: int = 10,
):
cmd = [*bm_cmd, "tool", "search-notes", *filters, "--page-size", "5"]
cmd.extend(project_args(project_ref))
try:
out = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
if out.returncode != 0:
return None
return json.loads(out.stdout)
except Exception:
return None
def rows(result):
return (result or {}).get("results") or []
def label(result):
name = result.get("title") or result.get("file_path") or "(untitled)"
ref = result.get("permalink") or result.get("file_path") or ""
return f"- {name}" + (f" - {ref}" if ref else "")
def readable(ref):
return f"{ref[:8]}..." if UUID_RE.match(ref) else ref
def shared_project_refs(cfg: dict, primary_project: str) -> tuple[list[str], bool]:
secondary = cfg.get("secondaryProjects")
secondary = secondary if isinstance(secondary, list) else []
team = cfg.get("teamProjects")
team = team if isinstance(team, dict) else {}
shared_refs: list[str] = []
for ref in list(secondary) + list(team.keys()):
if isinstance(ref, str) and ref.strip() and ref.strip() != primary_project:
clean = ref.strip()
if clean not in shared_refs:
shared_refs.append(clean)
shared_capped = len(shared_refs) > MAX_SHARED
return shared_refs[:MAX_SHARED], shared_capped
def no_context_message(configured: bool, primary_project: str) -> str:
if not configured:
return (
"# Basic Memory for Codex\n\n"
"_This repo is not configured for Basic Memory yet. Run `Use Basic Memory "
"for Codex to set up this repo` to map a project, seed schemas, and turn "
"on Codex checkpoints._"
)
project = primary_project or "the default project"
return (
"# Basic Memory for Codex\n\n"
f"_Could not read from `{project}`. Run `Use bm-status` to check the "
"Basic Memory project mapping._"
)
def main() -> int:
bm_cmd = basic_memory_command()
if not bm_cmd:
return 0
payload = parse_payload()
cwd = Path(payload.get("cwd") or os.getcwd())
source = payload.get("source") or "startup"
cfg, configured = load_config(cwd)
primary_project = str(cfg.get("primaryProject") or "").strip()
recall_timeframe = str(cfg.get("recallTimeframe") or "7d").strip()
capture_folder = str(cfg.get("captureFolder") or "codex-sessions").strip()
placement = str(cfg.get("placementConventions") or "").strip()
focus = str(cfg.get("focus") or "").strip()
shared_refs, shared_capped = shared_project_refs(cfg, primary_project)
active_tasks = ["--type", "task", "--status", "active"]
open_decisions = ["--type", "decision", "--status", "open"]
recent_codex = ["--type", "codex_session", "--after_date", recall_timeframe]
recent_generic = ["--type", "session", "--after_date", recall_timeframe]
with ThreadPoolExecutor(max_workers=4 + MAX_SHARED) as pool:
fut_tasks = pool.submit(search, bm_cmd, active_tasks, primary_project or None)
fut_decisions = pool.submit(search, bm_cmd, open_decisions, primary_project or None)
fut_codex = pool.submit(search, bm_cmd, recent_codex, primary_project or None)
fut_sessions = pool.submit(search, bm_cmd, recent_generic, primary_project or None)
fut_shared = {ref: pool.submit(search, bm_cmd, open_decisions, ref) for ref in shared_refs}
primary_tasks = fut_tasks.result()
primary_decisions = fut_decisions.result()
primary_codex = fut_codex.result()
primary_sessions = fut_sessions.result()
shared_results = {ref: fut.result() for ref, fut in fut_shared.items()}
if primary_tasks is None and primary_decisions is None and primary_codex is None:
print(no_context_message(configured, primary_project))
return 0
lines = ["# Basic Memory for Codex", ""]
header = f"Project: {primary_project or 'default project'}"
if focus:
header += f" | focus: {focus}"
if shared_refs:
header += f" | reading {len(shared_refs)} shared project(s)"
lines.append(header)
lines.append(f"Session source: {source}")
task_rows = rows(primary_tasks)
decision_rows = rows(primary_decisions)
codex_rows = rows(primary_codex)
session_rows = rows(primary_sessions)
if task_rows:
lines += ["", f"## Active Tasks ({len(task_rows)})", *[label(r) for r in task_rows]]
if decision_rows:
lines += [
"",
f"## Open Decisions ({len(decision_rows)})",
*[label(r) for r in decision_rows],
]
if codex_rows:
lines += [
"",
f"## Recent Codex Checkpoints ({len(codex_rows)})",
*[label(r) for r in codex_rows],
]
elif session_rows:
lines += [
"",
f"## Recent Sessions ({len(session_rows)})",
*[label(r) for r in session_rows],
]
shared_sections = [(ref, rows(shared_results.get(ref))) for ref in shared_refs]
shared_sections = [(ref, items) for ref, items in shared_sections if items]
if shared_sections:
lines += ["", "## Shared Context (Read Only)"]
for ref, items in shared_sections:
lines += [f"### {readable(ref)} open decisions", *[label(r) for r in items]]
if shared_capped:
lines += ["", f"Only the first {MAX_SHARED} shared projects are read on session start."]
if not (task_rows or decision_rows or codex_rows or session_rows or shared_sections):
lines += ["", "_No active tasks, open decisions, or recent checkpoints found._"]
lines += [
"",
"## Codex Memory Posture",
"- Search Basic Memory before answering questions about prior decisions or status.",
"- Capture durable engineering decisions as typed decision notes.",
f"- Put automatic Codex checkpoints in `{capture_folder}/`.",
]
if placement:
lines.append(f"- Follow these placement conventions for other notes: {placement}")
else:
lines.append("- Place other notes by topic, not in the checkpoint folder.")
lines += [
"",
"Use Basic Memory as durable context, but keep required repo rules in AGENTS.md "
"or checked-in docs.",
]
print("\n".join(lines))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
#
# SessionStart hook - brief Codex from Basic Memory at thread start.
#
# Contract: best effort only. A missing Basic Memory install, empty project, slow
# cloud read, or bad config must never disrupt a Codex thread.
set -u
if ! command -v uv >/dev/null 2>&1; then
exit 0
fi
script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
uv run --script "$script_dir/session-start.py" 2>/dev/null || exit 0
+19
View File
@@ -0,0 +1,19 @@
# Basic Memory Codex plugin checks
repo_root := "../.."
# Validate the plugin manifest, hooks, skills, schemas, and MCP config.
manifest-check:
python3 {{repo_root}}/scripts/validate_codex_plugin.py .
# Validate against the local Codex plugin scaffold contract.
scaffold-check:
@validator="${CODEX_PLUGIN_VALIDATOR:-}"; \
if [ -n "$validator" ]; then \
cd {{repo_root}} && uv run python "$validator" plugins/codex; \
else \
echo "Skipping optional Codex scaffold validator: set CODEX_PLUGIN_VALIDATOR to enable"; \
fi
# Run every local package check for this plugin.
check: manifest-check scaffold-check
+47
View File
@@ -0,0 +1,47 @@
---
title: Codex Session
type: schema
entity: CodexSession
version: 1
schema:
summary?: string, one-paragraph what happened in this Codex thread
changed_file?(array): string, files created, edited, deleted, or inspected
verification?(array): string, checks run and their result
decision?(array): string, decisions surfaced or created during the thread
blocker?(array): string, unresolved blockers or failed approaches
next_step?(array): string, explicit cursor for the next Codex thread
produced?(array): Entity, notes or artifacts created or updated
settings:
validation: warn
frontmatter:
project: string, the Basic Memory project this session belongs to
started: string, when the session began or checkpoint was created
ended?: string, when the session was checkpointed
status?(enum, lifecycle of the checkpoint): [open, resumed, closed]
cwd?: string, working directory for the Codex thread
codex_session_id?: string, Codex session identifier
codex_turn_id?: string, Codex turn identifier
trigger?: string, compaction trigger or deliberate checkpoint source
model?: string, active Codex model slug when known
capture?(enum, how this checkpoint was produced): [extractive, deliberate, summarized]
---
# Codex Session
A **CodexSession** note is a resumable engineering checkpoint. It captures the
thread cursor: what changed, what was verified, what decisions matter, and what
the next Codex thread should do first.
Codex sessions are found by structured recall:
`search_notes(metadata_filters={"type": "codex_session"}, after_date="7d")`.
## What Goes In A CodexSession
- **summary** - what happened.
- **changed_file** - changed or inspected paths that matter to resume.
- **verification** - commands actually run and their outcome.
- **decision** - choices made or surfaced.
- **blocker** - open failures, constraints, or rejected approaches.
- **next_step** - the next concrete action.
Validation is `warn` so checkpointing never blocks the user's flow.
+30
View File
@@ -0,0 +1,30 @@
---
title: Decision
type: schema
entity: Decision
version: 1
schema:
decision: string, the choice that was made
rationale?: string, why this choice over alternatives
alternative?(array): string, options considered and not taken
consequence?(array): string, what this decision commits the work to
context?: string, the situation that prompted the decision
affects?(array): Entity, work or notes this decision bears on
supersedes?: Entity, a prior decision this one replaces
settings:
validation: warn
frontmatter:
status?(enum, lifecycle of the decision): [open, accepted, superseded, rejected]
decided?: string, when the decision was made
project?: string, the Basic Memory project this decision belongs to
---
# Decision
A **Decision** note records a real choice with rationale and consequences. Codex
uses decisions to avoid relitigating the same tradeoff in later threads.
Decisions are found by structured recall:
`search_notes(metadata_filters={"type": "decision", "status": "open"})`.
Capture decisions sparingly. Use one note per genuine durable choice.
+30
View File
@@ -0,0 +1,30 @@
---
title: Task
type: schema
entity: Task
version: 1
schema:
description: string, what needs to be done
status?(enum, current state): [active, blocked, done, abandoned]
assigned_to?: string, who is working on this
steps?(array): string, ordered steps to complete
current_step?: integer, which step number is current
context?: string, key context needed to resume
started?: string, when work began
completed?: string, when work finished
blockers?(array): string, what prevents progress
parent_task?: Task, parent task if this is a subtask
settings:
validation: warn
---
# Task
A **Task** note tracks work in progress so Codex can find it on the next thread.
It matches the framework-agnostic `memory-tasks` shape.
Tasks are found by structured recall:
`search_notes(metadata_filters={"type": "task", "status": "active"})`.
Put queryable fields such as `status` and `current_step` in frontmatter, and use
observations for human-readable progress notes.
@@ -0,0 +1,62 @@
---
name: bm-checkpoint
description: Save a deliberate Codex work checkpoint to Basic Memory with changed files, verification, decisions, blockers, and the next action.
---
# Checkpoint Codex Work
Create a durable handoff note for current Codex work. Use this when the user asks
to checkpoint, wrap up, hand off, remember the state of the work, or before a long
context transition.
## Gather
Read `.codex/basic-memory.json` if present:
- `primaryProject`, default omitted
- `captureFolder`, default `codex-sessions`
- `placementConventions`, optional
Gather repo evidence:
- `git status --short`
- current branch
- changed files you touched
- tests or checks actually run
- failures or skipped checks
- decisions made in this thread
- unresolved blockers
- next action
Do not claim a test passed unless you ran it or the user supplied the result.
## Write
Write a note to Basic Memory:
- `title`: `Codex checkpoint - <short topic>`
- `directory`: configured `captureFolder`
- `tags`: `["codex", "checkpoint"]`
- frontmatter:
- `type: codex_session`
- `status: open`
- `project: <primaryProject if known>`
- `cwd: <current cwd>`
- `capture: deliberate`
Use sections:
- Summary
- Changed Files
- Verification
- Decisions
- Blockers
- Next Action
- Observations
Observations should include at least one `[next_step]` line. Add relations to
existing tasks, decisions, specs, issues, or PRs when the thread has obvious ones.
## Confirm
Reply with the permalink and the one next action the checkpoint preserves.
@@ -0,0 +1,7 @@
interface:
display_name: "Checkpoint"
short_description: "Save a resumable Codex work handoff"
icon_small: "./assets/icon.svg"
icon_large: "./assets/icon.svg"
brand_color: "#2563EB"
default_prompt: "Use $bm-checkpoint to save the current Codex work state into Basic Memory."
@@ -0,0 +1,6 @@
<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">
<rect x="4" y="3" width="16" height="18" rx="2.5"/>
<path d="M8 3v5h8V3"/>
<path d="M8 21v-7h8v7"/>
<path d="M10 17h4"/>
</svg>

After

Width:  |  Height:  |  Size: 317 B

+35
View File
@@ -0,0 +1,35 @@
---
name: bm-decide
description: Capture a durable engineering decision in Basic Memory with rationale, alternatives, consequences, and affected work.
---
# Capture A Decision
Use this when the user makes or asks to record a durable choice. A decision is a
choice with rationale and consequences, not a casual preference.
## Steps
1. Resolve `.codex/basic-memory.json`:
- write to `primaryProject` when set
- follow `placementConventions` for the directory when they are specific
- otherwise use `decisions`
2. Clarify only if the choice itself is ambiguous. Do not ask for every field if
the conversation already contains the rationale.
3. Write a `type: decision` note:
- `status: open` unless the user says it is accepted, superseded, or rejected
- `decided: <ISO timestamp when known>`
- `project: <primaryProject if known>`
4. Include:
- the decision
- context
- rationale
- alternatives considered
- consequences
- affected files, specs, issues, PRs, or notes
5. Confirm with the permalink. If this supersedes an older decision, update the old
note or link it as `supersedes`.
@@ -0,0 +1,7 @@
interface:
display_name: "Decide"
short_description: "Record durable engineering decisions"
icon_small: "./assets/icon.svg"
icon_large: "./assets/icon.svg"
brand_color: "#2563EB"
default_prompt: "Use $bm-decide to capture this engineering decision in Basic Memory."
@@ -0,0 +1,4 @@
<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">
<circle cx="12" cy="12" r="9"/>
<path d="M8 12.5l2.8 2.8L16.5 9"/>
</svg>

After

Width:  |  Height:  |  Size: 259 B

+36
View File
@@ -0,0 +1,36 @@
---
name: bm-orient
description: Orient Codex from Basic Memory before substantial repo work by reading active tasks, decisions, recent Codex checkpoints, and repo conventions.
---
# Orient From Basic Memory
Use this before substantial work in a repo, before resuming an old thread, or when
the user asks where things stand.
## Steps
1. Read `.codex/basic-memory.json` if present. Use `primaryProject`, `secondaryProjects`,
`recallTimeframe`, and `placementConventions`. If the file is missing, continue
against the default Basic Memory project and mention that setup has not been run.
2. Query the primary project:
- active tasks: `type=task`, `status=active`
- open decisions: `type=decision`, `status=open`
- recent Codex sessions: `type=codex_session`, after `recallTimeframe`
- recent generic sessions only if no Codex sessions are found
3. Query configured `secondaryProjects` read-only for open decisions. Do not write
to shared projects during orientation.
4. Read the highest-signal hits before summarizing. Prefer notes that match the
current repo, named route, issue, branch, or file path.
5. Present a compact orientation:
- active work
- decisions that constrain the next move
- recent checkpoint cursor
- likely next action
- any missing setup or ambiguous project mapping
Keep the summary evidence-backed. Include permalinks for notes you rely on.
@@ -0,0 +1,7 @@
interface:
display_name: "Orient"
short_description: "Load repo context from Basic Memory"
icon_small: "./assets/icon.svg"
icon_large: "./assets/icon.svg"
brand_color: "#2563EB"
default_prompt: "Use $bm-orient to load Basic Memory context before changing this repo."
@@ -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">
<circle cx="12" cy="12" r="9"/>
<path d="M15.6 8.4l-2.4 5.8-5.8 2.4 2.4-5.8 5.8-2.4z"/>
<circle cx="12" cy="12" r="1"/>
</svg>

After

Width:  |  Height:  |  Size: 314 B

+31
View File
@@ -0,0 +1,31 @@
---
name: bm-remember
description: Quickly save a small fact, reminder, or user preference into Basic Memory from Codex without turning it into a full decision or checkpoint.
---
# Remember
Use this for lightweight capture: "remember that", "save this", "note this", or
a small fact that should survive the current thread.
## Steps
1. Read `.codex/basic-memory.json` if present:
- `primaryProject`, default omitted
- `rememberFolder`, default `codex-remember`
2. Identify the exact text to save. If the user supplied text, preserve their
wording. If the user said "remember that" and the referent is unclear, ask one
short question.
3. Write with `write_note`:
- `title`: first line trimmed to 80 characters, or a short descriptive title
- `directory`: `rememberFolder`
- `content`: the text to remember
- `tags`: `["codex", "manual-capture"]`
- route to `primaryProject` if configured
4. Confirm in one line with the permalink.
Do not use this for decisions with alternatives or for work handoffs. Use
`bm-decide` or `bm-checkpoint` for those.
@@ -0,0 +1,7 @@
interface:
display_name: "Remember"
short_description: "Save small facts and preferences"
icon_small: "./assets/icon.svg"
icon_large: "./assets/icon.svg"
brand_color: "#2563EB"
default_prompt: "Use $bm-remember to save this fact or preference into Basic Memory."
@@ -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="M7 4.5A2.5 2.5 0 0 1 9.5 2H17v20l-5-3-5 3V4.5z"/>
<path d="M10 7h4"/>
<path d="M10 11h4"/>
</svg>

After

Width:  |  Height:  |  Size: 294 B

+101
View File
@@ -0,0 +1,101 @@
---
name: bm-setup
description: Set up Basic Memory for Codex in the current repo by mapping a Basic Memory project, seeding schemas, and writing .codex/basic-memory.json.
---
# Basic Memory for Codex Setup
Set up the current repo so Codex can orient from Basic Memory and checkpoint work
back into it. Keep the interview short, but always ask before choosing where data
will be written.
## Preconditions
Confirm Basic Memory is reachable before changing files:
1. Prefer MCP: call `list_memory_projects`.
2. If MCP tools are not available, run `basic-memory --version` or `bm --version`.
3. If neither works, stop and tell the user to install Basic Memory and connect the
MCP server. The plugin bundles an `.mcp.json` that starts `uvx basic-memory mcp`.
4. List available projects before the interview. Include cloud/local source,
workspace, qualified name, and project id when available.
## Interview
Ask the user to choose the project mapping. Do not infer write targets from the
repo, default project, current directory, or previous local state.
- storage mode: cloud, local, or mixed. Prefer the user's stated mode over any
CLI default.
- `focus`: code/dev, research, writing, planning, or mixed.
- `primaryProject`: an existing Basic Memory project or a new one to create.
- `secondaryProjects`: optional read-only projects for session-start context.
- `teamProjects`: optional share targets for `bm-share`.
- `captureFolder`: default `codex-sessions`.
- `rememberFolder`: default `codex-remember`.
- `placementConventions`: a short note about where decisions, tasks, and research
notes should land.
If there are duplicate names, show qualified names and ask the user which one to
use. Prefer qualified project names or project ids for cloud projects. Never pick
between cloud and local variants without confirmation.
For a new or empty project, suggest a light convention instead of creating empty
folders. For an existing project, inspect `list_directory` and a few notes before
summarizing the real convention.
## Apply
After confirming the plan, write `.codex/basic-memory.json` in the repo:
```json
{
"basicMemory": {
"primaryProject": "<project-ref>",
"secondaryProjects": [],
"projectMode": "cloud",
"teamProjects": {},
"focus": "<focus>",
"captureFolder": "codex-sessions",
"rememberFolder": "codex-remember",
"recallTimeframe": "7d",
"placementConventions": "<short convention>"
}
}
```
Preserve unrelated keys if the file already exists. Include `projectMode` when
the user chose cloud, local, or mixed routing. This file is intentionally
Codex-specific; do not write `.claude/settings.json`.
## Seed Schemas
Read the schema files from `<plugin-root>/schemas/`. This skill lives at
`<plugin-root>/skills/bm-setup/SKILL.md`, so the schemas are two directories up.
Seed these schema notes into the chosen `primaryProject` if they do not already
exist:
- `codex-session.md`
- `decision.md`
- `task.md`
Use `write_note` with `directory="schemas"`, `note_type="schema"`, schema
frontmatter as metadata, and the markdown body as content. Do not paste the YAML
frontmatter into content.
Before seeding schemas, restate the exact target project and ask for confirmation
if it differs from the user's selected primary project or if routing is
ambiguous.
## Verify
Before closing, prove the mapping works:
- Search the primary project for `type=schema` with page size 5.
- Search one shared project for open decisions if shared projects were configured.
- If either query errors, fix the project ref before finishing.
Finish with the project mapping, schemas seeded or skipped, and the verification
result. Tell the user that plugin hooks need to be reviewed and trusted in Codex
before they run.
@@ -0,0 +1,7 @@
interface:
display_name: "Setup"
short_description: "Map this repo to Basic Memory"
icon_small: "./assets/icon.svg"
icon_large: "./assets/icon.svg"
brand_color: "#2563EB"
default_prompt: "Use $bm-setup to map this repo to the right Basic Memory project."
@@ -0,0 +1,11 @@
<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="M4 6h7"/>
<path d="M15 6h5"/>
<circle cx="13" cy="6" r="2"/>
<path d="M4 18h5"/>
<path d="M13 18h7"/>
<circle cx="11" cy="18" r="2"/>
<path d="M4 12h3"/>
<path d="M11 12h9"/>
<circle cx="9" cy="12" r="2"/>
</svg>

After

Width:  |  Height:  |  Size: 421 B

+38
View File
@@ -0,0 +1,38 @@
---
name: bm-share
description: Share a personal Basic Memory note to a configured team project from Codex with attribution and explicit confirmation.
---
# Share A Note
Copy a note from the configured primary project to a configured team project. This
is the deliberate shared-write path. Automatic checkpoints and quick remembers
stay personal.
## Steps
1. Read `.codex/basic-memory.json` and resolve:
- `primaryProject`
- `teamProjects`, a map of project ref to settings
2. If no team projects are configured, stop and ask the user to run setup or add a
target. Do not invent a team destination.
3. Read the source note from the user's argument or the current conversation. If
ambiguous, ask which note to share.
4. Pick the target. If there is more than one team project, ask which one.
5. Confirm before writing. The prompt should be specific:
`Share "<title>" to <target>/<promoteFolder>?`
6. Write the copy:
- route to the target project
- `directory`: target `promoteFolder`, default `shared`
- preserve the original content and useful frontmatter
- add `shared_from: <source permalink>` frontmatter when possible
- add `- [context] Shared from <source permalink>` as an observation
7. Confirm with the new team permalink.
Never share secrets, credentials, or private notes without an explicit yes.
@@ -0,0 +1,7 @@
interface:
display_name: "Share"
short_description: "Copy notes to configured team projects"
icon_small: "./assets/icon.svg"
icon_large: "./assets/icon.svg"
brand_color: "#2563EB"
default_prompt: "Use $bm-share to share this Basic Memory note with a configured team project."
@@ -0,0 +1,7 @@
<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">
<circle cx="18" cy="5" r="3"/>
<circle cx="6" cy="12" r="3"/>
<circle cx="18" cy="19" r="3"/>
<path d="M8.6 10.6l5.8-3.2"/>
<path d="M8.6 13.4l5.8 3.2"/>
</svg>

After

Width:  |  Height:  |  Size: 352 B

+50
View File
@@ -0,0 +1,50 @@
---
name: bm-status
description: Report the Basic Memory for Codex configuration, reachability, hook expectations, recent Codex checkpoints, and active tasks.
---
# Basic Memory For Codex Status
Gather a concise diagnostic. Do not over-investigate.
## Gather
1. CLI reachability:
- `basic-memory --version`
- fallback `bm --version`
2. Plugin config:
- read `.codex/basic-memory.json`
- report `primaryProject`, `secondaryProjects`, `teamProjects`,
`captureFolder`, `rememberFolder`, `recallTimeframe`, and `focus`
3. Hook files:
- confirm `plugins/codex/hooks/hooks.json` exists if running from this repo
- remind the user that Codex plugin hooks must be reviewed and trusted before
they run
4. Basic Memory queries:
- recent `type=codex_session`, page size 5
- active `type=task`, `status=active`
- open `type=decision`, `status=open`
## Present
Use this shape:
```text
Basic Memory for Codex
- CLI: <version or missing>
- Project: <primaryProject or default>
- Reads from: <secondaryProjects or none>
- Share targets: <teamProjects or none>
- Capture folder: <captureFolder>
- Remember folder: <rememberFolder>
- Recall timeframe: <recallTimeframe>
- Recent Codex checkpoints: <count>
- Active tasks: <count>
- Open decisions: <count>
- Hooks: installed; trust review required in Codex
```
List recent checkpoints by title and permalink when available.
@@ -0,0 +1,7 @@
interface:
display_name: "Status"
short_description: "Check Basic Memory plugin health"
icon_small: "./assets/icon.svg"
icon_large: "./assets/icon.svg"
brand_color: "#2563EB"
default_prompt: "Use $bm-status to report the Basic Memory for Codex configuration and health."
@@ -0,0 +1,4 @@
<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: 248 B

+6
View File
@@ -48,8 +48,13 @@ dependencies = [
"fastembed>=0.7.4",
"sqlite-vec>=0.1.6",
"openai>=1.100.2",
"litellm>=1.60.0,<2.0.0",
"logfire>=4.19.0",
"psutil>=5.9.0",
# uvloop's C event loop has no self._ready.popleft() codepath, so the
# asyncpg engine-dispose race ("IndexError: pop from an empty deque") that
# crashes the Postgres backend cannot fire under it. Not available on Windows.
"uvloop>=0.21.0; sys_platform != 'win32'",
]
[project.urls]
@@ -84,6 +89,7 @@ markers = [
"windows: Windows-specific tests (deselect with '-m \"not windows\"')",
"smoke: Fast end-to-end smoke tests for MCP flows",
"semantic: Tests requiring semantic dependencies (fastembed, sqlite-vec, openai)",
"live: Tests that call external provider APIs and require explicit opt-in",
]
[tool.ruff]
+7 -2
View File
@@ -99,7 +99,7 @@ def set_package_version(data: dict[str, Any], version: str) -> None:
# Version scopes. The two groups map to the two distribution tracks:
# core — the Python package and its MCP registry manifest
# packages — the host-native agent artifacts (Claude Code plugin + marketplaces,
# Hermes, OpenClaw). These are the "plugin/agent artifacts."
# Codex plugin, Hermes, OpenClaw). These are the "plugin/agent artifacts."
# `all` writes both. Lockstep releases use `all`; targeted fixes can use one group.
SCOPES = ("all", "core", "packages")
@@ -134,6 +134,11 @@ def _update_packages(version: str, *, dry_run: bool) -> None:
lambda data: set_claude_marketplace_version(data, version),
dry_run=dry_run,
)
update_json(
"plugins/codex/.codex-plugin/plugin.json",
lambda data: set_package_version(data, npm_package_version(version)),
dry_run=dry_run,
)
update_text(
"integrations/hermes/plugin.yaml",
r"^version:\s*.*$",
@@ -174,7 +179,7 @@ def main() -> None:
choices=SCOPES,
default="all",
help="Which artifacts to update: all (default), core (Python + server.json), "
"or packages (Claude Code plugin, marketplaces, Hermes, OpenClaw)",
"or packages (Claude Code plugin, Codex plugin, marketplaces, Hermes, OpenClaw)",
)
parser.add_argument("--dry-run", action="store_true", help="Preview changes without writing")
args = parser.parse_args()
+1 -1
View File
@@ -27,7 +27,7 @@ REQUIRED_HOOK_SCRIPTS = ("hooks/session-start.sh", "hooks/pre-compact.sh")
# project at bootstrap). Each must be a parseable schema note.
REQUIRED_SCHEMAS = ("session.md", "decision.md", "task.md")
# Skills the plugin ships as namespaced slash commands (/basic-memory:<name>).
REQUIRED_SKILLS = ("setup", "remember", "status", "share")
REQUIRED_SKILLS = ("bm-setup", "bm-remember", "bm-status", "bm-share")
def read_json(path: Path) -> dict:
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""Validate the Basic Memory Codex plugin layout."""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
from typing import Any
from validate_skills import parse_frontmatter
REQUIRED_SKILLS = (
"bm-setup",
"bm-orient",
"bm-checkpoint",
"bm-decide",
"bm-remember",
"bm-share",
"bm-status",
)
REQUIRED_SCHEMAS = ("codex-session.md", "decision.md", "task.md")
REQUIRED_HOOK_EVENTS = ("SessionStart", "PreCompact")
REQUIRED_HOOK_SCRIPTS = (
"hooks/session-start.sh",
"hooks/session-start.py",
"hooks/pre-compact.sh",
"hooks/pre-compact.py",
)
REQUIRED_SKILL_AGENT_FILES = ("agents/openai.yaml", "assets/icon.svg")
REQUIRED_INTERFACE_ASSETS = {
"composerIcon": "assets/app-icon.png",
"logo": "assets/logo.png",
}
def read_json(path: Path) -> dict[str, Any]:
try:
payload = json.loads(path.read_text())
except FileNotFoundError:
raise SystemExit(f"Missing JSON file: {path}") from None
except json.JSONDecodeError as exc:
raise SystemExit(f"{path}: invalid JSON: {exc}") from None
if not isinstance(payload, dict):
raise SystemExit(f"{path}: expected a JSON object")
return payload
def require_path(path: Path, label: str) -> None:
if not path.exists():
raise SystemExit(f"Missing {label}: {path}")
def validate_plugin(plugin_dir: Path) -> None:
plugin_dir = plugin_dir.resolve()
# --- Manifest ---
manifest_path = plugin_dir / ".codex-plugin" / "plugin.json"
manifest = read_json(manifest_path)
if manifest.get("name") != "codex":
raise SystemExit(f"{manifest_path}: expected name=codex")
if manifest.get("skills") != "./skills/":
raise SystemExit(f"{manifest_path}: expected skills=./skills/")
if manifest.get("mcpServers") != "./.mcp.json":
raise SystemExit(f"{manifest_path}: expected mcpServers=./.mcp.json")
interface = manifest.get("interface")
if not isinstance(interface, dict):
raise SystemExit(f"{manifest_path}: missing interface object")
if interface.get("displayName") != "Basic Memory for Codex":
raise SystemExit(f"{manifest_path}: unexpected interface.displayName")
for field, expected_path in REQUIRED_INTERFACE_ASSETS.items():
if interface.get(field) != f"./{expected_path}":
raise SystemExit(f"{manifest_path}: expected interface.{field}=./{expected_path}")
require_path(plugin_dir / expected_path, f"interface.{field} asset")
# --- MCP ---
mcp = read_json(plugin_dir / ".mcp.json")
servers = mcp.get("mcpServers")
if not isinstance(servers, dict) or "basic-memory" not in servers:
raise SystemExit(".mcp.json: expected mcpServers.basic-memory")
basic_memory = servers["basic-memory"]
if not isinstance(basic_memory, dict):
raise SystemExit(".mcp.json: basic-memory server must be an object")
if basic_memory.get("command") not in {"uvx", "basic-memory", "bm"}:
raise SystemExit(".mcp.json: basic-memory server uses an unexpected command")
# --- Hooks ---
hooks_json = read_json(plugin_dir / "hooks" / "hooks.json")
hooks = hooks_json.get("hooks")
if not isinstance(hooks, dict):
raise SystemExit("hooks/hooks.json: expected hooks object")
for event in REQUIRED_HOOK_EVENTS:
if event not in hooks:
raise SystemExit(f"hooks/hooks.json: missing {event}")
for rel in REQUIRED_HOOK_SCRIPTS:
script = plugin_dir / rel
require_path(script, "hook script")
if not os.access(script, os.X_OK):
raise SystemExit(f"Hook script is not executable: {script}")
# --- Skills ---
skills_root = plugin_dir / "skills"
require_path(skills_root, "skills directory")
present = {path.name for path in skills_root.iterdir() if path.is_dir()}
for skill_name in REQUIRED_SKILLS:
if skill_name not in present:
raise SystemExit(f"Missing required skill: skills/{skill_name}/SKILL.md")
for skill_dir in sorted(path for path in skills_root.iterdir() if path.is_dir()):
skill_file = skill_dir / "SKILL.md"
require_path(skill_file, "skill file")
frontmatter = parse_frontmatter(skill_file)
if frontmatter.get("name") != skill_dir.name:
raise SystemExit(f"{skill_file}: name must match directory")
if not frontmatter.get("description"):
raise SystemExit(f"{skill_file}: missing description")
for rel in REQUIRED_SKILL_AGENT_FILES:
require_path(skill_dir / rel, f"skill {rel}")
# --- Schemas ---
schemas_root = plugin_dir / "schemas"
require_path(schemas_root, "schemas directory")
for schema_name in REQUIRED_SCHEMAS:
schema_file = schemas_root / schema_name
require_path(schema_file, "schema")
frontmatter = parse_frontmatter(schema_file)
if frontmatter.get("type") != "schema":
raise SystemExit(f"{schema_file}: expected type: schema")
if not frontmatter.get("entity"):
raise SystemExit(f"{schema_file}: missing entity")
print(f"validated Codex plugin in {plugin_dir}")
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("plugin_dir", nargs="?", default="plugins/codex")
args = parser.parse_args()
validate_plugin(Path.cwd() / args.plugin_dir)
if __name__ == "__main__":
main()
+29 -3
View File
@@ -4,9 +4,31 @@
from __future__ import annotations
import argparse
import re
from pathlib import Path
PLAIN_SCALAR_MAPPING_VALUE = re.compile(r":(?:\s|$)")
def strip_matching_quotes(value: str) -> str:
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
return value[1:-1]
return value
def validate_plain_scalar(path: Path, line_number: int, key: str, value: str) -> None:
"""Catch invalid plain-scalar YAML that Codex rejects while loading skills."""
stripped = value.strip()
if not stripped or stripped[0] in {'"', "'"} or stripped in {"|", ">", "|-", ">-", "|+", ">+"}:
return
if PLAIN_SCALAR_MAPPING_VALUE.search(stripped):
raise SystemExit(
f"{path}:{line_number}: invalid YAML frontmatter for {key!r}: "
"unquoted ':' followed by whitespace; quote the value"
)
def parse_frontmatter(path: Path) -> dict[str, str]:
"""Extract top-level frontmatter keys from a Markdown file.
@@ -15,14 +37,15 @@ def parse_frontmatter(path: Path) -> dict[str, str]:
skipped, so nested blocks (a schema note's `schema:`/`settings:` children) can't
overwrite a top-level key like `type` or `entity` via last-write-wins. It does
not interpret block scalars or multi-line values; callers rely on single-line
top-level fields (name, description, type, entity).
top-level fields (name, description, type, entity). Keep the Codex-facing YAML
guard here dependency-free so package checks work under bare `python3`.
"""
lines = path.read_text().splitlines()
if not lines or lines[0] != "---":
raise SystemExit(f"{path}: missing YAML frontmatter")
frontmatter: dict[str, str] = {}
for line in lines[1:]:
for line_number, line in enumerate(lines[1:], start=2):
if line == "---":
break
if line[:1] in (" ", "\t"): # nested key — not a top-level field
@@ -30,7 +53,10 @@ def parse_frontmatter(path: Path) -> dict[str, str]:
if ":" not in line:
continue
key, value = line.split(":", 1)
frontmatter[key.strip()] = value.strip().strip('"')
key = key.strip()
value = value.strip()
validate_plain_scalar(path, line_number, key, value)
frontmatter[key] = strip_matching_quotes(value)
else:
raise SystemExit(f"{path}: unclosed YAML frontmatter")
+10 -4
View File
@@ -6,16 +6,22 @@
"url": "https://github.com/basicmachines-co/basic-memory.git",
"source": "github"
},
"version": "0.21.5",
"version": "0.21.6",
"packages": [
{
"registryType": "pypi",
"identifier": "basic-memory",
"version": "0.21.5",
"version": "0.21.6",
"runtimeHint": "uvx",
"runtimeArguments": [
{"type": "positional", "value": "basic-memory"},
{"type": "positional", "value": "mcp"}
{
"type": "positional",
"value": "basic-memory"
},
{
"type": "positional",
"value": "mcp"
}
],
"transport": {
"type": "stdio"
+56
View File
@@ -0,0 +1,56 @@
---
name: memory-ci-capture
description: Synthesize GitHub delivery context into a concise Basic Memory project update. Use in CI after `bm ci collect` prepares a ProjectUpdateContext; return only structured AgentSynthesis JSON for `bm ci publish`.
---
# Memory CI Capture
Turn a meaningful GitHub delivery moment into project memory. GitHub records the
mechanics. Basic Memory remembers what changed and why.
## Inputs
Read the `ProjectUpdateContext` JSON produced by `bm ci collect` at
`.github/basic-memory/project-update-context.json`. Treat it as the immutable
source of truth for repository, event type, PR number, workflow run, SHA, source
URL, timestamps, and deployment environment.
Do not invent tests, deploy checks, linked issues, product impact, or decisions.
If evidence is absent, say so briefly in `verification` or leave the field empty.
## Output
Return only JSON matching the `AgentSynthesis` shape:
```json
{
"summary": "What changed.",
"why_it_matters": "Why this update matters for future humans and agents.",
"user_facing_changes": [],
"internal_changes": [],
"verification": [],
"follow_ups": [],
"decision_candidates": [],
"task_candidates": []
}
```
## Synthesis Rules
- Prefer a short explanation over a commit-by-commit changelog.
- Preserve intent, changed behavior, source links, verification evidence present
in the context, and concrete follow-ups.
- Put explicit product or architecture decisions in `decision_candidates` only
when the source context clearly contains them.
- Put future work in `task_candidates` only when it is concrete enough to act on.
- Keep the tone factual and useful. This is project memory, not marketing copy.
## Event Guidance
For merged pull requests, focus on why the PR existed, what area changed, what
issues it advanced or closed, and what verification evidence appears in the
context.
For production deploys, focus on what reached production, the deployed SHA,
environment, workflow run, and verification evidence. Do not overclaim success
beyond the workflow and source facts.
+1 -1
View File
@@ -1,7 +1,7 @@
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
# Package version - updated by release automation
__version__ = "0.21.5"
__version__ = "0.21.6"
# API version for FastAPI - independent of package version
__api_version__ = "v0"
+21 -4
View File
@@ -2,8 +2,11 @@
import asyncio
import os
from contextlib import suppress
from logging.config import fileConfig
from loguru import logger
# Allow nested event loops (needed for pytest-asyncio and other async contexts)
# Note: nest_asyncio doesn't work with uvloop or Python 3.14+, so we handle those cases separately
import sys
@@ -13,9 +16,15 @@ if sys.version_info < (3, 14):
import nest_asyncio
nest_asyncio.apply()
except (ImportError, ValueError):
# nest_asyncio not available or can't patch this loop type (e.g., uvloop)
pass
except (ImportError, ValueError) as exc:
# Trigger: nest_asyncio is absent (ImportError) or refuses to patch the
# running loop (ValueError, e.g. the uvloop policy installed for the
# Postgres backend - #831/#877).
# Why: the uvloop ValueError is now an *expected* path on every Postgres
# startup, so swallowing it silently hides a routine branch.
# Outcome: log at DEBUG (observable, not noisy) and fall through to the
# thread-based migration fallback.
logger.debug(f"nest_asyncio not applied ({exc!r}); using thread-based migration fallback")
# For Python 3.14+, we rely on the thread-based fallback in run_migrations_online()
from sqlalchemy import engine_from_config, pool
@@ -115,7 +124,15 @@ async def run_async_migrations(connectable):
"""Run migrations asynchronously with AsyncEngine."""
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
# Trigger: startup migrations on the asyncpg backend dispose the engine
# while the event loop may be tearing down around them.
# Why: that race surfaces "IndexError: pop from an empty deque" from
# base_events._run_once (#831/#877); shielding lets dispose finish atomically
# and suppressing CancelledError keeps a cancelled teardown from re-raising it.
# Outcome: the migration engine always disposes cleanly. (uvloop is the
# structural fix for the race; this hardens the teardown path.)
with suppress(asyncio.CancelledError):
await asyncio.shield(connectable.dispose())
def _run_async_migrations_with_asyncio_run(connectable) -> None:
@@ -64,7 +64,9 @@ async def search(
or query.permalink
or query.permalink_match
),
has_filters=bool(query.note_types or query.entity_types or query.metadata_filters),
has_filters=bool(
query.note_types or query.entity_types or query.categories or query.metadata_filters
),
):
offset = (page - 1) * page_size
exact_count_available = query.retrieval_mode == SearchRetrievalMode.FTS
+4 -2
View File
@@ -18,7 +18,9 @@ from basic_memory.services.context_service import (
class EntityBatchLookup(Protocol):
async def find_by_ids_for_hydration(self, ids: List[int]) -> Sequence[Any]: ...
async def find_by_ids_for_hydration(
self, ids: List[int], *, include_cross_project: bool = False
) -> Sequence[Any]: ...
class EntityServiceBatchLookup(Protocol):
@@ -88,7 +90,7 @@ async def to_graph_context(
result_count=len(entity_ids_needed),
):
entities = await entity_repository.find_by_ids_for_hydration(
list(entity_ids_needed)
list(entity_ids_needed), include_cross_project=True
)
for e in entities:
entity_title_lookup[e.id] = e.title
+225
View File
@@ -0,0 +1,225 @@
# Basic Memory CI
Basic Memory CI turns meaningful GitHub delivery moments into durable
`project_update` notes in Basic Memory.
GitHub records the mechanics: pull requests, workflow runs, SHAs, URLs, labels,
changed files, commits, linked issues, and timestamps. The agent reads those
facts and writes the delivery story: what problem was being addressed, why the
fix solved it, what changed in the system, what complexity or cleanup came with
it, and why future humans or agents should care. The Basic Memory CLI owns
authentication, schema guidance, idempotency, and publishing.
The semantic layer is the point: GitHub can answer when something merged or
deployed, but the project memory should answer later questions such as what
problem was solved, what choices were made, what changed in the architecture,
and what risks, cleanup, or follow-up work came with the change.
The product voice is:
> GitHub records the mechanics. Basic Memory remembers what changed and why.
## Flow
```text
GitHub event
-> workflow eligibility filter
-> bm ci collect
-> Codex Action reads ProjectUpdateContext + memory-ci-capture prompt
-> Codex writes AgentSynthesis JSON
-> bm ci publish
-> Basic Memory project_update note
```
The v0 workflow is GitHub-only and uses `openai/codex-action@v1` as the
synthesis runner. The Codex step runs in read-only mode and does not receive the
Basic Memory API key.
## Setup CI/CD
Use `bm ci setup` from the GitHub repository root. The command installs the
workflow/config/prompt/soul files and seeds the Basic Memory schema notes.
For the common cloud path:
```bash
bm cloud api-key save bmc_...
bm cloud workspace list
bm ci setup --project <project-name> --workspace <workspace-slug-or-id> --cloud --yes
```
Use the `Slug` column from `bm cloud workspace list` for `--workspace`; the
`Workspace ID` column also works when a slug is unavailable or ambiguous.
Prefer `--project-id <external_id>` when the same project name exists in more
than one workspace:
```bash
bm ci setup --project <project-name> --project-id <project-external-id> --cloud --yes
```
Setup does not overwrite existing schema notes by default. After upgrading Auto
BM, refresh the installed schema guidance with either spelling:
```bash
bm ci setup --project <project-name> --workspace <workspace-slug-or-id> --cloud --yes --refresh-schemas
bm ci setup --project <project-name> --workspace <workspace-slug-or-id> --cloud --yes --update-schemas
```
The shorter aliases `--refresh` and `--update` are also accepted. Refresh keeps
custom schema note paths when it finds existing notes, and only writes the
canonical Auto BM schema content. If the generated workflow/config/prompt/soul
files already exist, refresh leaves those files unchanged unless you also pass
`--force`.
Then review and commit the generated files:
```text
.github/workflows/basic-memory.yml
.github/basic-memory/config.yml
.github/basic-memory/memory-ci-capture.md
.github/basic-memory/SOUL.md
```
Add these GitHub repository secrets:
- `OPENAI_API_KEY`: used only by `openai/codex-action`.
- `BASIC_MEMORY_API_KEY`: mapped to `BASIC_MEMORY_CLOUD_API_KEY` only for
`bm ci publish`.
Add this optional GitHub repository variable only when using a non-default cloud
host:
- `BASIC_MEMORY_CLOUD_HOST`
Configure production deploy capture in `.github/basic-memory/config.yml`:
```yaml
project: <project-name>
workspace: <workspace-slug-or-id>
deploy_workflows:
- Deploy Production
production_environments:
- production
```
The generated workflow also uses `deploy_workflows` to populate
`on.workflow_run.workflows`. If you change deploy workflow names later, rerun
`bm ci setup --force` or update both `.github/basic-memory/config.yml` and
`.github/workflows/basic-memory.yml` together.
The generated workflow installs `basic-memory` from PyPI. When dogfooding an
unreleased `bm ci` change, temporarily edit the workflow install step to install
the branch or commit that contains the CI commands.
This repository's live workflow may temporarily install from the checked-out
repository while `bm ci` is being dogfooded ahead of the next package release.
After the files and secrets are in place, verify the first run by merging a test
PR or completing a configured production deploy workflow. The Auto BM workflow
should create or update a `project_update` note under:
```text
project-updates/github/<owner>/<repo>/
```
Failures in the Auto BM workflow fail only the project-update capture workflow;
they do not roll back the original merge or deploy.
## Commands
`bm ci setup`
Installs the repository automation files:
- `.github/workflows/basic-memory.yml`
- `.github/basic-memory/config.yml`
- `.github/basic-memory/memory-ci-capture.md`
- `.github/basic-memory/SOUL.md`
`SOUL.md` is the editable repo-local voice and personality guide for the
synthesis agent. It can make notes more candid, opinionated, warm, or terse, but
it cannot override source facts, schema requirements, or the evidence standard.
It also seeds the canonical Basic Memory schema notes when they do not already
exist:
- `ProjectUpdate`
- `GitHubPullRequestUpdate`
- `GitHubProductionDeployUpdate`
Use `--refresh-schemas` or `--update-schemas` when you want setup to update
those schema notes instead of only creating missing ones.
`bm ci collect`
Reads the current GitHub event payload and normalizes it into
`ProjectUpdateContext`. This command decides whether the event is eligible.
Merged pull requests and configured successful production deploy workflow runs
are eligible. Routine CI runs, failed deploys, and unmerged PR closures are
no-ops.
For merged pull requests, the generated workflow passes `GITHUB_TOKEN` to
`bm ci collect` so the context can include changed files, commit messages, and
linked issue details. If `GITHUB_TOKEN` is unavailable, local collection still
uses the event payload fields. If the token is present and GitHub API enrichment
fails, the Auto BM workflow fails fast instead of publishing a weak note.
`bm ci agent-schema`
Writes the optional `AgentSynthesis` JSON schema used by the generated workflow
as a CI guardrail. This schema is not a Basic Memory domain schema and is not
committed by setup. The schema intentionally requires narrative fields such as
`story`, `problem_addressed`, `solution`, `system_impact`,
`components_changed`, `complexity_introduced`, and `refactors_or_removals` so
the agent does more than fill out shallow buckets.
`bm ci publish`
Combines deterministic GitHub facts with the agent synthesis and upserts a
Basic Memory `project_update` note. Agent-supplied identity fields are ignored;
source identity comes from `ProjectUpdateContext`.
## Auth Boundary
The generated workflow needs these secrets:
- `OPENAI_API_KEY`
- `BASIC_MEMORY_API_KEY`
`OPENAI_API_KEY` is passed only to the Codex Action. `BASIC_MEMORY_API_KEY` is
mapped to `BASIC_MEMORY_CLOUD_API_KEY` only for the publish step, where the
Basic Memory client uses it as `cloud_api_key`. The publish step also passes
`--cloud` so CI writes to the configured cloud project.
When `.github/basic-memory/config.yml` includes `workspace` and no `project_id`,
CI routes the project as `<workspace>/<project>`. Use a workspace slug there, or
prefer `project_id` when project names collide across workspaces.
## Idempotency
Project updates are upserted by stable GitHub identity:
- PR merge:
`github:<owner>/<repo>:pull_request_merged:<pr_number>`
- Production deploy:
`github:<owner>/<repo>:production_deploy_succeeded:<environment>:<workflow_run_id>`
The default note folder is:
```text
project-updates/github/<owner>/<repo>/
```
## Module Responsibilities
`project_updates.py` contains the v0 domain model and rendering helpers:
- `ProjectUpdateConfig`: non-secret repo configuration.
- `ProjectUpdateContext`: normalized immutable GitHub facts.
- `AgentSynthesis`: agent-authored narrative fields.
- `ProjectUpdateNote`: final Basic Memory note payload.
- workflow, prompt, and schema-note seed rendering.
The CLI command group lives in `basic_memory.cli.commands.ci` and performs file
installation, event collection, schema seeding, and publish orchestration.
+1
View File
@@ -0,0 +1 @@
"""CI helpers for Basic Memory project update capture."""
+984
View File
@@ -0,0 +1,984 @@
"""Project update capture helpers for GitHub Actions.
Auto BM treats GitHub as the immutable source layer, the agent as the synthesis
layer, and Basic Memory as the durable project-memory layer.
"""
from __future__ import annotations
import json
import os
import re
import subprocess
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any, Literal
import yaml
from pydantic import BaseModel, ConfigDict, Field, field_validator
PULL_REQUEST_MERGED = "pull_request_merged"
PRODUCTION_DEPLOY_SUCCEEDED = "production_deploy_succeeded"
DEFAULT_NOTE_FOLDER_TEMPLATE = "project-updates/github/{owner}/{repo}"
DEFAULT_CONFIG_PATH = ".github/basic-memory/config.yml"
DEFAULT_WORKFLOW_PATH = ".github/workflows/basic-memory.yml"
DEFAULT_PROMPT_PATH = ".github/basic-memory/memory-ci-capture.md"
DEFAULT_SOUL_PATH = ".github/basic-memory/SOUL.md"
DEFAULT_CONTEXT_PATH = ".github/basic-memory/project-update-context.json"
class ProjectUpdateConfig(BaseModel):
"""Non-secret Auto BM repository configuration."""
project: str | None = None
project_id: str | None = None
workspace: str | None = None
deploy_workflows: list[str] = Field(default_factory=lambda: ["Deploy Production"])
production_environments: list[str] = Field(default_factory=lambda: ["production"])
note_folder: str = DEFAULT_NOTE_FOLDER_TEMPLATE
codex_model: str | None = None
codex_effort: str | None = None
@field_validator("deploy_workflows", "production_environments")
@classmethod
def _non_empty_list(cls, value: list[str]) -> list[str]:
cleaned = [item.strip() for item in value if item.strip()]
if not cleaned:
raise ValueError("must contain at least one non-empty value")
return cleaned
class ChangedFile(BaseModel):
"""A GitHub pull request file summary."""
filename: str
status: str | None = None
additions: int | None = None
deletions: int | None = None
changes: int | None = None
class CommitSummary(BaseModel):
"""A compact GitHub pull request commit summary."""
sha: str | None = None
message: str | None = None
author: str | None = None
class LinkedIssueDetail(BaseModel):
"""GitHub issue context referenced by a pull request."""
number: int
title: str | None = None
body_excerpt: str | None = None
state: str | None = None
url: str | None = None
class ProjectUpdateContext(BaseModel):
"""Normalized facts collected from a GitHub event payload."""
eligible: bool
source: Literal["github"] = "github"
source_event: str | None = None
skip_reason: str | None = None
repo: str | None = None
repo_url: str | None = None
source_url: str | None = None
occurred_at: str | None = None
idempotency_key: str | None = None
sha: str | None = None
pr_number: int | None = None
workflow_run_id: str | None = None
environment: str | None = None
title: str | None = None
body: str | None = None
author: str | None = None
labels: list[str] = Field(default_factory=list)
linked_issues: list[str] = Field(default_factory=list)
linked_issue_details: list[LinkedIssueDetail] = Field(default_factory=list)
changed_files: list[ChangedFile] = Field(default_factory=list)
changed_files_count: int | None = None
commits: list[CommitSummary] = Field(default_factory=list)
class AgentSynthesis(BaseModel):
"""Agent-authored synthesis for a project update."""
model_config = ConfigDict(extra="ignore")
summary: str
story: str
problem_addressed: str
solution: str
system_impact: str
why_it_matters: str
components_changed: list[str] = Field(default_factory=list)
complexity_introduced: list[str] = Field(default_factory=list)
refactors_or_removals: list[str] = Field(default_factory=list)
user_facing_changes: list[str] = Field(default_factory=list)
internal_changes: list[str] = Field(default_factory=list)
verification: list[str] = Field(default_factory=list)
follow_ups: list[str] = Field(default_factory=list)
decision_candidates: list[str] = Field(default_factory=list)
task_candidates: list[str] = Field(default_factory=list)
@field_validator(
"summary",
"story",
"problem_addressed",
"solution",
"system_impact",
"why_it_matters",
)
@classmethod
def _required_text(cls, value: str) -> str:
stripped = value.strip()
if not stripped:
raise ValueError("must not be empty")
return stripped
class ProjectUpdateNote(BaseModel):
"""Final note payload for the Basic Memory writer."""
title: str
directory: str
content: str
metadata: dict[str, Any]
tags: list[str]
class SchemaSeedSpec(BaseModel):
"""Basic Memory schema note seed."""
title: str
entity: str
content: str
metadata: dict[str, Any]
def parse_github_remote(remote_url: str) -> tuple[str, str]:
"""Parse an HTTPS or SSH GitHub remote into owner/repo."""
patterns = (
r"^https://github\.com/(?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?/?$",
r"^git@github\.com:(?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?$",
r"^ssh://git@github\.com/(?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?$",
)
for pattern in patterns:
match = re.match(pattern, remote_url.strip())
if match:
return match.group("owner"), match.group("repo")
raise ValueError(f"Expected a GitHub remote, got: {remote_url}")
def detect_github_repo(cwd: Path) -> tuple[str, str]:
"""Detect the GitHub origin for a repository checkout."""
result = subprocess.run(
["git", "config", "--get", "remote.origin.url"],
cwd=cwd,
check=False,
capture_output=True,
text=True,
)
if result.returncode != 0 or not result.stdout.strip():
raise ValueError("No remote.origin.url found; run this from a GitHub repository checkout")
return parse_github_remote(result.stdout.strip())
def load_project_update_config(path: Path) -> ProjectUpdateConfig:
"""Load Auto BM repository config from YAML."""
if not path.exists():
return ProjectUpdateConfig()
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
if not isinstance(raw, dict):
raise ValueError(f"{path} must contain a YAML object")
return ProjectUpdateConfig.model_validate(raw)
def write_project_update_config(path: Path, config: ProjectUpdateConfig) -> None:
"""Write repository config YAML."""
path.parent.mkdir(parents=True, exist_ok=True)
data = config.model_dump(exclude_none=True)
path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8")
def _load_event_payload(event_path: Path) -> dict[str, Any]:
try:
payload = json.loads(event_path.read_text(encoding="utf-8"))
except FileNotFoundError as exc:
raise ValueError(f"GitHub event payload not found: {event_path}") from exc
except json.JSONDecodeError as exc:
raise ValueError(f"GitHub event payload is not valid JSON: {event_path}") from exc
if not isinstance(payload, dict):
raise ValueError("GitHub event payload must be a JSON object")
return payload
def collect_project_update_context(
*,
event_name: str,
event_path: Path,
config: ProjectUpdateConfig,
) -> ProjectUpdateContext:
"""Normalize a GitHub Actions event into a project update context."""
payload = _load_event_payload(event_path)
if event_name == "pull_request":
return _collect_pull_request_context(payload)
if event_name == "workflow_run":
return _collect_workflow_run_context(payload, config)
return ProjectUpdateContext(
eligible=False,
skip_reason=f"unsupported GitHub event: {event_name}",
)
def _repo_fields(payload: dict[str, Any]) -> tuple[str | None, str | None]:
repo = payload.get("repository")
if not isinstance(repo, dict):
return None, None
full_name = repo.get("full_name")
html_url = repo.get("html_url")
return (
full_name if isinstance(full_name, str) else None,
html_url if isinstance(html_url, str) else None,
)
def _label_names(labels: Any) -> list[str]:
if not isinstance(labels, list):
return []
names: list[str] = []
for label in labels:
if isinstance(label, dict) and isinstance(label.get("name"), str):
names.append(label["name"])
return names
def _linked_issues(*texts: str | None) -> list[str]:
seen: set[str] = set()
issues: list[str] = []
for text in texts:
if not text:
continue
for match in re.finditer(r"(?<![\w/])#(?P<number>\d+)\b", text):
issue = f"#{match.group('number')}"
if issue not in seen:
seen.add(issue)
issues.append(issue)
return issues
def _github_api_get(path: str, token: str) -> list[Any] | dict[str, Any]:
request = urllib.request.Request(
f"https://api.github.com{path}",
headers={
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {token}",
"User-Agent": "basic-memory-ci",
"X-GitHub-Api-Version": "2022-11-28",
},
)
try:
with urllib.request.urlopen(request, timeout=20) as response:
payload = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
raise ValueError(f"GitHub API request failed ({exc.code}) for {path}: {body}") from exc
except urllib.error.URLError as exc:
raise ValueError(f"GitHub API request failed for {path}: {exc.reason}") from exc
if not isinstance(payload, (list, dict)):
raise ValueError(f"GitHub API response for {path} must be a JSON object or array")
return payload
def _github_api_get_list(path: str, token: str) -> list[Any]:
items: list[Any] = []
page = 1
while True:
separator = "&" if "?" in path else "?"
payload = _github_api_get(f"{path}{separator}per_page=100&page={page}", token)
if not isinstance(payload, list):
raise ValueError(f"GitHub API response for {path} must be a JSON array")
items.extend(payload)
if len(payload) < 100:
return items
page += 1
def _text_or_none(value: Any) -> str | None:
return value if isinstance(value, str) else None
def _int_or_none(value: Any) -> int | None:
return value if isinstance(value, int) else None
def _body_excerpt(value: Any, *, limit: int = 2000) -> str | None:
if not isinstance(value, str):
return None
stripped = value.strip()
if len(stripped) <= limit:
return stripped
return stripped[: limit - 15].rstrip() + "... [truncated]"
def _changed_file_from_github(raw: Any) -> ChangedFile | None:
if not isinstance(raw, dict) or not isinstance(raw.get("filename"), str):
return None
return ChangedFile(
filename=raw["filename"],
status=_text_or_none(raw.get("status")),
additions=_int_or_none(raw.get("additions")),
deletions=_int_or_none(raw.get("deletions")),
changes=_int_or_none(raw.get("changes")),
)
def _commit_summary_from_github(raw: Any) -> CommitSummary | None:
if not isinstance(raw, dict):
return None
commit = raw.get("commit")
if not isinstance(commit, dict):
return None
author = commit.get("author")
return CommitSummary(
sha=_text_or_none(raw.get("sha")),
message=_text_or_none(commit.get("message")),
author=_text_or_none(author.get("name")) if isinstance(author, dict) else None,
)
def _issue_number(issue: str) -> int | None:
match = re.fullmatch(r"#(?P<number>\d+)", issue)
return int(match.group("number")) if match else None
def _issue_detail_from_github(raw: Any) -> LinkedIssueDetail | None:
if not isinstance(raw, dict) or not isinstance(raw.get("number"), int):
return None
return LinkedIssueDetail(
number=raw["number"],
title=_text_or_none(raw.get("title")),
body_excerpt=_body_excerpt(raw.get("body")),
state=_text_or_none(raw.get("state")),
url=_text_or_none(raw.get("html_url")),
)
def _enrich_pull_request_context(context: ProjectUpdateContext) -> ProjectUpdateContext:
token = os.environ.get("GITHUB_TOKEN")
if not token or not context.repo or context.pr_number is None:
return context
files = [
file
for file in (
_changed_file_from_github(raw)
for raw in _github_api_get_list(
f"/repos/{context.repo}/pulls/{context.pr_number}/files", token
)
)
if file is not None
]
commits = [
commit
for commit in (
_commit_summary_from_github(raw)
for raw in _github_api_get_list(
f"/repos/{context.repo}/pulls/{context.pr_number}/commits", token
)
)
if commit is not None
]
issue_details: list[LinkedIssueDetail] = []
for issue in context.linked_issues:
number = _issue_number(issue)
if number is None:
continue
detail = _issue_detail_from_github(
_github_api_get(f"/repos/{context.repo}/issues/{number}", token)
)
if detail is not None:
issue_details.append(detail)
return context.model_copy(
update={
"changed_files": files,
"commits": commits,
"linked_issue_details": issue_details,
}
)
def _collect_pull_request_context(payload: dict[str, Any]) -> ProjectUpdateContext:
pr = payload.get("pull_request")
if not isinstance(pr, dict):
return ProjectUpdateContext(eligible=False, skip_reason="pull request payload missing")
if payload.get("action") != "closed":
return ProjectUpdateContext(
eligible=False, skip_reason="pull request action was not closed"
)
if pr.get("merged") is not True:
return ProjectUpdateContext(
eligible=False,
skip_reason="pull request was closed without merging",
)
repo, repo_url = _repo_fields(payload)
number = pr.get("number")
title = pr.get("title") if isinstance(pr.get("title"), str) else None
body = pr.get("body") if isinstance(pr.get("body"), str) else None
source_url = pr.get("html_url") if isinstance(pr.get("html_url"), str) else None
sha = pr.get("merge_commit_sha") if isinstance(pr.get("merge_commit_sha"), str) else None
occurred_at = pr.get("merged_at") if isinstance(pr.get("merged_at"), str) else None
author = None
user = pr.get("user")
if isinstance(user, dict) and isinstance(user.get("login"), str):
author = user["login"]
idempotency_key = None
if repo and isinstance(number, int):
idempotency_key = f"github:{repo}:{PULL_REQUEST_MERGED}:{number}"
context = ProjectUpdateContext(
eligible=True,
source_event=PULL_REQUEST_MERGED,
repo=repo,
repo_url=repo_url,
source_url=source_url,
occurred_at=occurred_at,
idempotency_key=idempotency_key,
sha=sha,
pr_number=number if isinstance(number, int) else None,
title=title,
body=body,
author=author,
labels=_label_names(pr.get("labels")),
linked_issues=_linked_issues(title, body),
changed_files_count=(
pr["changed_files"] if isinstance(pr.get("changed_files"), int) else None
),
)
return _enrich_pull_request_context(context)
def _collect_workflow_run_context(
payload: dict[str, Any],
config: ProjectUpdateConfig,
) -> ProjectUpdateContext:
run = payload.get("workflow_run")
if not isinstance(run, dict):
return ProjectUpdateContext(eligible=False, skip_reason="workflow run payload missing")
conclusion = run.get("conclusion")
if conclusion != "success":
return ProjectUpdateContext(
eligible=False,
skip_reason=f"workflow conclusion was {conclusion or 'missing'}",
)
workflow_name = run.get("name")
if workflow_name not in config.deploy_workflows:
return ProjectUpdateContext(
eligible=False,
skip_reason=f"workflow '{workflow_name}' is not configured for project updates",
)
repo, repo_url = _repo_fields(payload)
run_id = run.get("id")
workflow_run_id = str(run_id) if run_id is not None else None
environment = config.production_environments[0]
source_url = run.get("html_url") if isinstance(run.get("html_url"), str) else None
sha = run.get("head_sha") if isinstance(run.get("head_sha"), str) else None
occurred_at = run.get("updated_at") if isinstance(run.get("updated_at"), str) else None
idempotency_key = None
if repo and workflow_run_id:
idempotency_key = (
f"github:{repo}:{PRODUCTION_DEPLOY_SUCCEEDED}:{environment}:{workflow_run_id}"
)
return ProjectUpdateContext(
eligible=True,
source_event=PRODUCTION_DEPLOY_SUCCEEDED,
repo=repo,
repo_url=repo_url,
source_url=source_url,
occurred_at=occurred_at,
idempotency_key=idempotency_key,
sha=sha,
workflow_run_id=workflow_run_id,
environment=environment,
title=str(workflow_name) if workflow_name is not None else None,
)
def _owner_repo(repo: str) -> tuple[str, str]:
if "/" not in repo:
raise ValueError(f"Repository must be owner/repo, got: {repo}")
owner, repo_name = repo.split("/", 1)
return owner, repo_name
def _note_directory(context: ProjectUpdateContext, config: ProjectUpdateConfig | None) -> str:
if not context.repo:
raise ValueError("Project update context is missing repo")
owner, repo_name = _owner_repo(context.repo)
template = config.note_folder if config else DEFAULT_NOTE_FOLDER_TEMPLATE
return template.format(owner=owner, repo=repo_name, full_repo=context.repo)
def _note_title(context: ProjectUpdateContext) -> str:
if context.source_event == PULL_REQUEST_MERGED and context.pr_number:
title = context.title or "Merged pull request"
return f"PR #{context.pr_number}: {title}"
if context.source_event == PRODUCTION_DEPLOY_SUCCEEDED:
environment = context.environment or "production"
when = (context.occurred_at or "").split("T", 1)[0] or "unknown date"
return f"{environment.title()} deploy: {when}"
return "Project update"
def build_project_update_note(
*,
context: ProjectUpdateContext,
synthesis: AgentSynthesis,
config: ProjectUpdateConfig | None = None,
) -> ProjectUpdateNote:
"""Build the final Basic Memory project update note."""
if not context.eligible:
raise ValueError(f"Cannot build a note for an ineligible context: {context.skip_reason}")
if not context.source_event or not context.repo or not context.idempotency_key:
raise ValueError("Project update context is missing deterministic identity fields")
metadata: dict[str, Any] = {
"source": "github",
"source_event": context.source_event,
"repo": context.repo,
"source_url": context.source_url,
"occurred_at": context.occurred_at,
"idempotency_key": context.idempotency_key,
"sha": context.sha,
"pr_number": context.pr_number,
"workflow_run_id": context.workflow_run_id,
"environment": context.environment,
}
metadata = {key: value for key, value in metadata.items() if value is not None}
sections = [
f"# {_note_title(context)}",
"## Summary",
synthesis.summary,
"## Story",
synthesis.story,
"## Problem Addressed",
synthesis.problem_addressed,
"## How The Change Solves It",
synthesis.solution,
"## Impact On The System",
synthesis.system_impact,
# Keep the structured-output field stable while using the clearer note heading.
"## Project Memory",
synthesis.why_it_matters,
]
_extend_list_section(sections, "Components Changed", synthesis.components_changed)
_extend_list_section(sections, "Complexity Introduced", synthesis.complexity_introduced)
_extend_list_section(sections, "Refactors Or Removals", synthesis.refactors_or_removals)
_extend_list_section(sections, "User-Facing Changes", synthesis.user_facing_changes)
_extend_list_section(sections, "Internal Changes", synthesis.internal_changes)
_extend_list_section(sections, "Verification", synthesis.verification)
_extend_list_section(sections, "Follow-Ups", synthesis.follow_ups)
_extend_list_section(sections, "Decision Candidates", synthesis.decision_candidates)
_extend_list_section(sections, "Task Candidates", synthesis.task_candidates)
source_links = []
if context.source_url:
source_links.append(f"- Source: {context.source_url}")
if context.repo_url:
source_links.append(f"- Repository: {context.repo_url}")
source_links.extend(_linked_issue_source_links(context))
if source_links:
sections.extend(["## Source Links", *source_links])
observations = [
f"- [summary] {synthesis.summary}",
f"- [impact] {synthesis.system_impact}",
f"- [source] GitHub {context.source_event} in {context.repo}",
]
sections.extend(["## Observations", *observations])
return ProjectUpdateNote(
title=_note_title(context),
directory=_note_directory(context, config),
content="\n\n".join(sections).strip() + "\n",
metadata=metadata,
tags=["github", "project-update", context.source_event],
)
def _extend_list_section(sections: list[str], title: str, values: list[str]) -> None:
cleaned = [value.strip() for value in values if value.strip()]
if cleaned:
sections.extend([f"## {title}", *[f"- {value}" for value in cleaned]])
def _linked_issue_source_links(context: ProjectUpdateContext) -> list[str]:
"""Render linked issue references as durable source links."""
details_by_number = {detail.number: detail for detail in context.linked_issue_details}
issue_numbers = [
number for number in (_issue_number(issue) for issue in context.linked_issues) if number
]
for number in details_by_number:
if number not in issue_numbers:
issue_numbers.append(number)
links: list[str] = []
for number in issue_numbers:
detail = details_by_number.get(number)
label = _linked_issue_label(number, detail)
url = detail.url if detail and detail.url else _github_issue_url(context.repo_url, number)
rendered = f"[{label}]({url})" if url else label
links.append(f"- Linked issue: {rendered}")
return links
def _linked_issue_label(number: int, detail: LinkedIssueDetail | None) -> str:
label = f"#{number}"
if detail is None:
return label
if detail.title:
label = f"{label} {detail.title}"
if detail.state:
label = f"{label} ({detail.state})"
return label
def _github_issue_url(repo_url: str | None, number: int) -> str | None:
if not repo_url:
return None
return f"{repo_url.rstrip('/')}/issues/{number}"
def render_agent_synthesis_schema() -> str:
"""Render the optional Codex structured-output schema guardrail."""
properties = {
"summary": {"type": "string", "minLength": 1},
"story": {"type": "string", "minLength": 1},
"problem_addressed": {"type": "string", "minLength": 1},
"solution": {"type": "string", "minLength": 1},
"system_impact": {"type": "string", "minLength": 1},
"why_it_matters": {"type": "string", "minLength": 1},
"components_changed": {"type": "array", "items": {"type": "string"}},
"complexity_introduced": {"type": "array", "items": {"type": "string"}},
"refactors_or_removals": {"type": "array", "items": {"type": "string"}},
"user_facing_changes": {"type": "array", "items": {"type": "string"}},
"internal_changes": {"type": "array", "items": {"type": "string"}},
"verification": {"type": "array", "items": {"type": "string"}},
"follow_ups": {"type": "array", "items": {"type": "string"}},
"decision_candidates": {"type": "array", "items": {"type": "string"}},
"task_candidates": {"type": "array", "items": {"type": "string"}},
}
schema = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "AgentSynthesis",
"type": "object",
"additionalProperties": False,
"required": list(properties),
"properties": properties,
}
return json.dumps(schema, indent=2, sort_keys=True) + "\n"
def render_capture_prompt() -> str:
"""Render the prompt contract used by the generated workflow."""
return f"""# 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 `{DEFAULT_CONTEXT_PATH}`.
- Read `{DEFAULT_SOUL_PATH}` 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.
"""
def render_soul_template() -> str:
"""Render the editable Auto BM voice and personality guide."""
return """# 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.
"""
def render_workflow(config: ProjectUpdateConfig) -> str:
"""Render the generated GitHub Actions workflow."""
workflow_names = json.dumps(config.deploy_workflows)
model_line = f" model: {config.codex_model}\n" if config.codex_model else ""
effort_line = f" effort: {config.codex_effort}\n" if config.codex_effort else ""
return f"""name: Basic Memory Project Updates
"on":
pull_request:
types: [closed]
workflow_run:
workflows: {workflow_names}
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
run: |
python -m pip install --upgrade pip
pip install basic-memory
- name: Collect project update context
id: collect
env:
GITHUB_TOKEN: ${{{{ github.token }}}}
run: |
bm ci collect \\
--config {DEFAULT_CONFIG_PATH} \\
--output {DEFAULT_CONTEXT_PATH}
- 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: {DEFAULT_PROMPT_PATH}
output-file: ${{{{ runner.temp }}}}/agent-synthesis.json
output-schema-file: ${{{{ runner.temp }}}}/agent-synthesis.schema.json
sandbox: read-only
safety-strategy: drop-sudo
{model_line}{effort_line}
- 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 {DEFAULT_CONFIG_PATH} \\
--context {DEFAULT_CONTEXT_PATH} \\
--synthesis "${{{{ runner.temp }}}}/agent-synthesis.json"
"""
def schema_seed_specs() -> list[SchemaSeedSpec]:
"""Return Basic Memory schema note seeds for Auto BM project updates."""
return [
_schema_seed(
title="ProjectUpdate",
entity="ProjectUpdate",
schema={
"summary": "string, concise account of what changed",
"story": "string, narrative connecting problem -> solution -> impact",
"problem_addressed": "string, concrete problem or delivery need",
"solution": "string, why the change solves the problem",
"system_impact": "string, impact on system behavior or architecture",
"why_it_matters": "string, durable context for future humans and agents",
"components_changed": "array, modules, workflows, commands, or services touched",
"complexity_introduced": "array, tradeoffs or new moving parts",
"refactors_or_removals": "array, cleanup, simplification, or deleted paths",
"source": "string, source system such as github",
"source_event": ("string, pull_request_merged or production_deploy_succeeded"),
"repo": "string, owner/repository",
"source_url": "string, canonical source URL",
"occurred_at?": "string, ISO timestamp",
"idempotency_key": "string, stable source identity",
"sha?": "string, commit SHA",
"pr_number?": "integer, pull request number",
"workflow_run_id?": "string, GitHub Actions workflow run id",
"environment?": "string, deployment environment",
},
body=(
"A ProjectUpdate preserves what changed in a project and the durable "
"context future readers need. "
"It should tell the delivery story: the problem, why the solution worked, "
"what components changed, what complexity or cleanup followed, and the "
"impact on the system. GitHub records mechanics; Basic Memory keeps the "
"durable narrative."
),
),
_schema_seed(
title="GitHubPullRequestUpdate",
entity="GitHubPullRequestUpdate",
schema={
"intent": "string, purpose of the merged pull request",
"problem_addressed": "string, issue, bug, missing capability, or workflow pain",
"solution": "string, why this implementation solves the problem",
"system_impact": "string, behavior, architecture, or workflow impact",
"changed_area?(array)": "string, product or implementation areas touched",
"components_changed?(array)": "string, modules, workflows, commands, or docs touched",
"complexity_introduced?(array)": "string, tradeoffs or new moving parts",
"refactors_or_removals?(array)": "string, cleanup, simplification, or deleted paths",
"linked_issue?(array)": "string, issues closed or advanced",
"verification?(array)": "string, checks and tests observed",
"follow_up?(array)": "string, concrete remaining work",
},
body=(
"Guidance for pull request project updates: preserve the story behind "
"the PR, not just the title. Explain the problem, the fix, why it works, "
"changed components, tradeoffs, cleanup, issue links, and verification. "
"Do not summarize commit by commit unless that is the clearest explanation."
),
),
_schema_seed(
title="GitHubProductionDeployUpdate",
entity="GitHubProductionDeployUpdate",
schema={
"deployed_sha": "string, deployed commit SHA",
"environment": "string, production environment",
"workflow_run_id": "string, GitHub Actions workflow run id",
"story": "string, what changed since the previous production deploy",
"system_impact": "string, production impact on behavior or operations",
"components_changed?(array)": "string, deployed modules, services, or workflows",
"complexity_introduced?(array)": "string, operational tradeoffs or new moving parts",
"verification?(array)": "string, deploy evidence and smoke checks",
"user_impact?(array)": "string, user-facing impact since previous deploy",
"rollback_note?": "string, rollback or mitigation note when known",
},
body=(
"Guidance for production deploy project updates: preserve what actually "
"reached production, the durable context, the deployed SHA, environment, "
"workflow run, changed components, operational complexity, and "
"verification evidence. Do not overclaim beyond the source facts."
),
),
]
def _schema_seed(
*,
title: str,
entity: str,
schema: dict[str, str],
body: str,
) -> SchemaSeedSpec:
metadata = {
"type": "schema",
"entity": entity,
"version": 1,
"schema": schema,
"settings": {"validation": "warn"},
}
return SchemaSeedSpec(
title=title,
entity=entity,
content=f"# {title}\n\n{body}\n",
metadata=metadata,
)
+9
View File
@@ -57,6 +57,14 @@ def app_callback(
container = CliContainer.create()
set_container(container)
# Trigger: Postgres backend resolved at CLI startup, before any asyncio.run().
# Why: uvloop must own the event-loop policy before the loop is created so the
# asyncpg engine-dispose race (#831/#877) cannot fire. No-op for SQLite.
# Outcome: subsequent asyncio.run() calls in CLI commands use uvloop on Postgres.
from basic_memory.db import maybe_install_uvloop
maybe_install_uvloop(container.config)
# Trigger: first-run init confirmation before command output.
# Why: informational "initialized" message belongs above command results, not in the upsell panel.
# Outcome: one-time plain line printed before the subcommand runs.
@@ -86,6 +94,7 @@ def app_callback(
"reindex",
"update",
"watch",
"workspace",
}
if (
not version
+4 -1
View File
@@ -1,6 +1,6 @@
"""CLI commands for basic-memory."""
from . import status, db, doctor, import_memory_json, mcp, import_claude_conversations, orphans
from . import ci, status, db, doctor, import_memory_json, mcp, import_claude_conversations, orphans
from . import (
import_claude_projects,
import_chatgpt,
@@ -9,10 +9,12 @@ from . import (
format,
schema,
update,
workspace,
)
__all__ = [
"status",
"ci",
"db",
"doctor",
"import_memory_json",
@@ -26,4 +28,5 @@ __all__ = [
"format",
"schema",
"update",
"workspace",
]
+437
View File
@@ -0,0 +1,437 @@
"""GitHub CI project update commands."""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Annotated, Any, Optional, cast
import typer
from pydantic import ValidationError
from rich.console import Console
from basic_memory.ci.project_updates import (
DEFAULT_CONFIG_PATH,
DEFAULT_PROMPT_PATH,
DEFAULT_SOUL_PATH,
DEFAULT_WORKFLOW_PATH,
AgentSynthesis,
ProjectUpdateConfig,
ProjectUpdateContext,
ProjectUpdateNote,
build_project_update_note,
collect_project_update_context,
detect_github_repo,
load_project_update_config,
render_agent_synthesis_schema,
render_capture_prompt,
render_soul_template,
render_workflow,
schema_seed_specs,
write_project_update_config,
)
from basic_memory.cli.app import app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
from basic_memory.mcp.tools import search_notes as mcp_search_notes
from basic_memory.mcp.tools import write_note as mcp_write_note
console = Console()
ci_app = typer.Typer(help="Capture GitHub delivery moments into Basic Memory")
app.add_typer(ci_app, name="ci")
@ci_app.command()
def setup(
project: Annotated[str, typer.Option(help="Basic Memory project for project updates")],
repo_root: Annotated[
Path,
typer.Option("--repo-root", help="GitHub repository root to configure"),
] = Path("."),
project_id: Annotated[
Optional[str],
typer.Option("--project-id", help="Basic Memory project external_id"),
] = None,
workspace: Annotated[
Optional[str],
typer.Option("--workspace", help="Cloud workspace slug for generated config"),
] = None,
deploy_workflow: Annotated[
Optional[list[str]],
typer.Option("--deploy-workflow", help="Production deploy workflow name"),
] = None,
environment: Annotated[
Optional[list[str]],
typer.Option("--environment", help="Production environment name"),
] = None,
force: bool = typer.Option(False, "--force", help="Overwrite generated Auto BM files"),
yes: bool = typer.Option(False, "--yes", help="Skip confirmation prompts"),
local: bool = typer.Option(False, "--local", help="Force local API routing for schema seeding"),
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing for schema seeding"),
refresh_schemas: bool = typer.Option(
False,
"--refresh-schemas",
"--update-schemas",
"--refresh",
"--update",
help="Update existing Auto BM schema notes instead of only seeding missing ones",
),
) -> None:
"""Install the GitHub Actions workflow and seed project update schemas."""
try:
validate_routing_flags(local, cloud)
repo_root = repo_root.resolve()
owner, repo = detect_github_repo(repo_root)
config = ProjectUpdateConfig(
project=project,
project_id=project_id,
workspace=workspace,
deploy_workflows=deploy_workflow or ["Deploy Production"],
production_environments=environment or ["production"],
)
if not yes:
confirmed = typer.confirm(
f"Install Auto BM for {owner}/{repo} and write updates to {project}?"
)
if not confirmed:
raise typer.Exit(1)
wrote_generated_files = _write_generated_files(
repo_root,
config,
force=force,
preserve_existing=refresh_schemas,
)
with force_routing(local=local, cloud=cloud):
seeded = run_with_cleanup(
seed_project_update_schemas(
project=project,
project_id=project_id,
workspace=workspace,
refresh=refresh_schemas,
)
)
if wrote_generated_files:
console.print("[green]Auto BM GitHub workflow installed[/green]")
else:
console.print(
"[yellow]Auto BM GitHub workflow already exists; generated files unchanged[/yellow]"
)
console.print(f"Repository: {owner}/{repo}")
console.print(f"Project: {project}")
if seeded:
verb = "Updated" if refresh_schemas else "Seeded"
console.print(f"{verb} schemas: {', '.join(seeded)}")
else:
console.print("Schema notes already exist; nothing seeded")
console.print("\nAdd these GitHub secrets before enabling the workflow:")
console.print("- OPENAI_API_KEY")
console.print("- BASIC_MEMORY_API_KEY")
except ValueError as exc:
console.print(f"[red]{exc}[/red]")
raise typer.Exit(1) from exc
@ci_app.command()
def collect(
output: Annotated[
Path,
typer.Option("--output", help="Where to write ProjectUpdateContext JSON"),
],
config_path: Annotated[
Path,
typer.Option("--config", help="Auto BM repository config path"),
] = Path(DEFAULT_CONFIG_PATH),
event_name: Annotated[
Optional[str],
typer.Option("--event-name", help="GitHub event name; defaults to GITHUB_EVENT_NAME"),
] = None,
event_path: Annotated[
Optional[Path],
typer.Option("--event-path", help="GitHub event payload; defaults to GITHUB_EVENT_PATH"),
] = None,
) -> None:
"""Normalize the current GitHub event into project update context JSON."""
try:
effective_event_name = event_name or os.environ.get("GITHUB_EVENT_NAME")
if not effective_event_name:
raise ValueError("Missing event name. Pass --event-name or set GITHUB_EVENT_NAME.")
event_path_value = event_path or (
Path(os.environ["GITHUB_EVENT_PATH"]) if os.environ.get("GITHUB_EVENT_PATH") else None
)
if event_path_value is None:
raise ValueError("Missing event payload. Pass --event-path or set GITHUB_EVENT_PATH.")
config = load_project_update_config(config_path)
context = collect_project_update_context(
event_name=effective_event_name,
event_path=event_path_value,
config=config,
)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(
json.dumps(context.model_dump(mode="json"), indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
_write_github_output("eligible", str(context.eligible).lower())
_write_github_output("skip_reason", context.skip_reason or "")
console.print(f"Wrote project update context to {output}")
if not context.eligible:
console.print(f"Skipped: {context.skip_reason}")
except ValueError as exc:
console.print(f"[red]{exc}[/red]")
raise typer.Exit(1) from exc
@ci_app.command("agent-schema")
def agent_schema(
output: Annotated[
Path,
typer.Option("--output", help="Where to write the temporary AgentSynthesis schema"),
],
) -> None:
"""Write the temporary Codex structured-output schema."""
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(render_agent_synthesis_schema(), encoding="utf-8")
console.print(f"Wrote agent synthesis schema to {output}")
@ci_app.command()
def publish(
context_path: Annotated[
Path,
typer.Option("--context", help="ProjectUpdateContext JSON from bm ci collect"),
],
synthesis_path: Annotated[
Path,
typer.Option("--synthesis", help="AgentSynthesis JSON produced by Codex"),
],
config_path: Annotated[
Path,
typer.Option("--config", help="Auto BM repository config path"),
] = Path(DEFAULT_CONFIG_PATH),
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
) -> None:
"""Publish an agent synthesis as an idempotent Basic Memory project update."""
try:
validate_routing_flags(local, cloud)
config = load_project_update_config(config_path)
context = ProjectUpdateContext.model_validate(_read_json(context_path))
if not context.eligible:
console.print(f"Auto BM skipped: {context.skip_reason}")
return
synthesis = AgentSynthesis.model_validate(_read_json(synthesis_path))
note = build_project_update_note(context=context, synthesis=synthesis, config=config)
with force_routing(local=local, cloud=cloud):
result = run_with_cleanup(
publish_project_update_note(config=config, context=context, note=note)
)
console.print(json.dumps(result, indent=2, sort_keys=True, default=str))
except (ValueError, ValidationError) as exc:
console.print(f"[red]{exc}[/red]")
raise typer.Exit(1) from exc
async def seed_project_update_schemas(
*,
project: str | None,
project_id: str | None = None,
workspace: str | None = None,
refresh: bool = False,
) -> list[str]:
"""Seed Auto BM schema notes without overwriting customized schemas."""
seeded: list[str] = []
routed_project = _routed_project(project=project, project_id=project_id, workspace=workspace)
for spec in schema_seed_specs():
existing = await mcp_search_notes(
query=None,
project=routed_project,
project_id=project_id,
metadata_filters={"type": "schema", "entity": spec.entity},
output_format="json",
page_size=1,
)
existing_results = _search_results(existing)
if existing_results and not refresh:
continue
title, directory = _note_write_target(
existing,
default_title=spec.title,
default_directory="schemas",
)
await mcp_write_note(
title=title,
content=spec.content,
directory=directory,
project=routed_project,
project_id=project_id,
note_type="schema",
metadata=spec.metadata,
overwrite=bool(existing_results) and refresh,
output_format="json",
)
seeded.append(spec.entity)
return seeded
async def publish_project_update_note(
*,
config: ProjectUpdateConfig,
context: ProjectUpdateContext,
note: ProjectUpdateNote,
) -> dict[str, Any]:
"""Search by idempotency key and then upsert the deterministic note path."""
routed_project = _routed_project(
project=config.project,
project_id=config.project_id,
workspace=config.workspace,
)
existing = await mcp_search_notes(
query=None,
project=routed_project,
project_id=config.project_id,
metadata_filters={"type": "project_update", "idempotency_key": context.idempotency_key},
output_format="json",
page_size=1,
)
title, directory = _note_write_target(
existing, default_title=note.title, default_directory=note.directory
)
result = await mcp_write_note(
title=title,
content=note.content,
directory=directory,
project=routed_project,
project_id=config.project_id,
tags=note.tags,
note_type="project_update",
metadata=note.metadata,
overwrite=True,
output_format="json",
)
if isinstance(result, dict):
return result
return {"result": result}
def _note_write_target(
search_payload: object,
*,
default_title: str,
default_directory: str,
) -> tuple[str, str]:
"""Use an existing idempotency match's path when one exists."""
results = _search_results(search_payload)
if not results:
return default_title, default_directory
match = results[0]
if not isinstance(match, dict):
return default_title, default_directory
raw_title = match.get("title")
title = raw_title if isinstance(raw_title, str) else default_title
file_path = match.get("file_path")
if isinstance(file_path, str) and file_path.strip():
parent = Path(file_path).parent.as_posix()
directory = "" if parent == "." else parent
return title, directory
return title, default_directory
def _write_generated_files(
repo_root: Path,
config: ProjectUpdateConfig,
*,
force: bool,
preserve_existing: bool = False,
) -> bool:
files = {
repo_root / DEFAULT_WORKFLOW_PATH: render_workflow(config),
repo_root / DEFAULT_PROMPT_PATH: render_capture_prompt(),
repo_root / DEFAULT_SOUL_PATH: render_soul_template(),
}
config_path = repo_root / DEFAULT_CONFIG_PATH
targets = [*files, config_path]
if preserve_existing and not force and any(path.exists() for path in targets):
return False
_validate_generated_targets(targets, force=force)
for path, content in files.items():
_write_generated_file(path, content, force=force)
write_project_update_config(config_path, config)
return True
def _validate_generated_targets(paths: list[Path], *, force: bool) -> None:
if force:
return
for path in paths:
if path.exists():
raise ValueError(f"{path} already exists; pass --force to overwrite")
def _write_generated_file(path: Path, content: str, *, force: bool) -> None:
if path.exists() and not force:
raise ValueError(f"{path} already exists; pass --force to overwrite")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
def _read_json(path: Path) -> dict[str, Any]:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError as exc:
raise ValueError(f"JSON file not found: {path}") from exc
except json.JSONDecodeError as exc:
raise ValueError(f"Invalid JSON in {path}: {exc}") from exc
if not isinstance(payload, dict):
raise ValueError(f"{path} must contain a JSON object")
return payload
def _write_github_output(key: str, value: str) -> None:
output_path = os.environ.get("GITHUB_OUTPUT")
if not output_path:
return
with Path(output_path).open("a", encoding="utf-8") as handle:
handle.write(f"{key}={value}\n")
def _routed_project(
*,
project: str | None,
project_id: str | None,
workspace: str | None,
) -> str | None:
"""Return a workspace-qualified project route when the config can enforce one."""
if project_id or not project or not workspace or "/" in project:
return project
return f"{workspace.strip('/')}/{project}"
def _search_results(payload: object) -> list[Any]:
if isinstance(payload, dict):
payload_dict = cast(dict[str, Any], payload)
results = payload_dict.get("results")
if isinstance(results, list):
return results
nested = payload_dict.get("result")
if isinstance(nested, dict) and isinstance(nested.get("results"), list):
return nested["results"]
return []
@@ -7,6 +7,7 @@ they are cloud-specific operations.
import os
from datetime import datetime
from enum import Enum
import typer
from rich.console import Console
@@ -16,10 +17,14 @@ from basic_memory.cli.commands.cloud.bisync_commands import get_mount_info
from basic_memory.cli.commands.cloud.rclone_commands import (
RcloneError,
SyncProject,
TransferDirection,
TransferPlan,
get_project_bisync_state,
project_bisync,
project_check,
project_diff,
project_sync,
project_transfer,
)
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.cli.commands.routing import force_routing
@@ -35,9 +40,34 @@ console = Console()
TEAM_WORKSPACE_BISYNC_UNSUPPORTED = (
"The bisync operation is only supported on Personal workspaces.\n"
"Use `bm cloud sync --name {name}` instead."
"Use `bm cloud pull --name {name}` / `bm cloud push --name {name}` instead."
)
TEAM_WORKSPACE_SYNC_UNSUPPORTED = (
"The sync operation mirrors local onto the shared bucket and can delete a "
"teammate's files, so it is only supported on Personal workspaces.\n"
"Use `bm cloud pull --name {name}` (fetch) / `bm cloud push --name {name}` "
"(additive upload) instead."
)
class ConflictStrategy(str, Enum):
"""How push/pull resolves files that differ on both sides.
Default is ``fail``: surface the conflicts and abort before transferring,
leaving the user to re-run with an explicit resolution like git refusing
to clobber local changes.
This is the Typer-facing enum; the engine in ``rclone_commands`` accepts the
same values as a ``ConflictStrategy`` Literal. ``_run_directional_transfer``
bridges the two by passing ``on_conflict.value``. Keep the values in sync.
"""
fail = "fail"
keep_local = "keep-local"
keep_cloud = "keep-cloud"
keep_both = "keep-both"
# --- Shared helpers ---
@@ -92,8 +122,18 @@ async def _get_workspace_for_project(name: str, config: BasicMemoryConfig) -> Wo
)
def _require_personal_workspace(name: str, config: BasicMemoryConfig) -> WorkspaceInfo:
"""Exit before bisync work when the target workspace is not personal."""
def _require_personal_workspace(
name: str,
config: BasicMemoryConfig,
*,
unsupported_message: str = TEAM_WORKSPACE_BISYNC_UNSUPPORTED,
) -> WorkspaceInfo:
"""Exit before mirror work when the target workspace is not personal.
Used to gate the destructive mirror operations (`sync`, `bisync`) to
Personal workspaces. ``unsupported_message`` lets each command point Team
users at the right Team-safe alternative.
"""
try:
workspace = run_with_cleanup(_get_workspace_for_project(name, config))
except Exception as exc:
@@ -101,7 +141,7 @@ def _require_personal_workspace(name: str, config: BasicMemoryConfig) -> Workspa
raise typer.Exit(1)
if workspace.workspace_type != "personal":
console.print(f"[red]{TEAM_WORKSPACE_BISYNC_UNSUPPORTED.format(name=name)}[/red]")
console.print(f"[red]{unsupported_message.format(name=name)}[/red]")
raise typer.Exit(1)
return workspace
@@ -146,11 +186,15 @@ def _get_sync_project(
@cloud_app.command("sync")
def sync_project_command(
name: str = typer.Option(..., "--name", help="Project name to sync"),
name: str = typer.Option(..., "--name", "--project", help="Project name to sync"),
dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without syncing"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
) -> None:
"""One-way sync: local -> cloud (make cloud identical to local).
"""One-way mirror: local -> cloud (make cloud identical to local).
Personal workspaces only. This deletes cloud files not present locally, so
on Team workspaces use `bm cloud push` (additive upload) / `bm cloud pull`
(fetch) instead.
Example:
bm cloud sync --name research
@@ -158,6 +202,7 @@ def sync_project_command(
"""
config = ConfigManager().config
_require_cloud_credentials(config)
_require_personal_workspace(name, config, unsupported_message=TEAM_WORKSPACE_SYNC_UNSUPPORTED)
try:
# Get tenant info for bucket name
@@ -191,9 +236,175 @@ def sync_project_command(
raise typer.Exit(1)
def _print_conflict_abort(name: str, direction: TransferDirection, plan: TransferPlan) -> None:
"""Explain a conflict abort and how to resolve it (git-pull style)."""
console.print(
f"[red]{direction.capitalize()} aborted: {len(plan.conflicts)} file(s) differ between "
f"local and cloud.[/red]"
)
for path in plan.conflicts:
console.print(f" [yellow]*[/yellow] {path}")
console.print("\nRe-run with one of:")
console.print(" [dim]--on-conflict keep-cloud[/dim] take the cloud version")
console.print(" [dim]--on-conflict keep-local[/dim] keep your local version")
console.print(
" [dim]--on-conflict keep-both[/dim] keep both (writes <name>.conflict-<date>)"
)
def _run_directional_transfer(
name: str,
direction: TransferDirection,
*,
on_conflict: ConflictStrategy,
dry_run: bool,
verbose: bool,
) -> None:
"""Shared orchestration for `bm cloud push` / `bm cloud pull`.
Detects conflicts first, then aborts (the default) or applies the chosen
resolution. Uses additive `rclone copy`, so it never deletes on the
destination safe for Team workspaces and therefore not gated.
"""
config = ConfigManager().config
_require_cloud_credentials(config)
try:
# Get tenant info for bucket name
tenant_info = run_with_cleanup(get_mount_info())
bucket_name = tenant_info.bucket_name
# Get project info
with force_routing(cloud=True):
project_data = run_with_cleanup(_get_cloud_project(name))
if not project_data:
console.print(f"[red]Error: Project '{name}' not found[/red]")
raise typer.Exit(1)
sync_project, _ = _get_sync_project(name, config, project_data)
# --- Detect before transferring ---
plan = project_diff(sync_project, bucket_name, direction)
# Trigger: rclone could not read/hash some files.
# Why: comparing is the whole basis for a safe transfer — never guess.
# Outcome: abort before moving any bytes.
if plan.errors:
console.print(
f"[red]{direction.capitalize()} aborted: rclone could not compare "
f"{len(plan.errors)} file(s)[/red]"
)
for path in plan.errors:
console.print(f" [red]![/red] {path}")
raise typer.Exit(1)
# Trigger: files differ on both sides and the user chose no resolution.
# Why: "no surprises" — never silently pick a winner.
# Outcome: list the conflicts and exit, like git refusing to clobber.
if plan.conflicts and on_conflict is ConflictStrategy.fail:
_print_conflict_abort(name, direction, plan)
raise typer.Exit(1)
# --- Transfer ---
arrow = "cloud -> local" if direction == "pull" else "local -> cloud"
console.print(f"[blue]{direction.capitalize()} {name} ({arrow})...[/blue]")
conflict_suffix = datetime.now().strftime("%Y%m%d-%H%M%S")
success = project_transfer(
sync_project,
bucket_name,
direction,
plan,
strategy=on_conflict.value,
conflict_suffix=conflict_suffix,
dry_run=dry_run,
verbose=verbose,
)
if not success:
console.print(f"[red]{name} {direction} failed[/red]")
raise typer.Exit(1)
console.print(f"[green]{name} {direction} completed successfully[/green]")
# Without a sync baseline (see #862) we cannot tell an intentional delete
# from a file the other side simply never had, so deletions never sync.
if plan.dest_only:
kept_on = "local" if direction == "pull" else "cloud"
console.print(
f"[dim]{len(plan.dest_only)} file(s) exist only on {kept_on} and were left "
"untouched (deletions are not propagated).[/dim]"
)
except RcloneError as e:
console.print(f"[red]{direction.capitalize()} error: {e}[/red]")
raise typer.Exit(1)
except typer.Exit:
# Already-handled exits (not found, conflicts, errors) propagate cleanly.
raise
except Exception as e:
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(1)
@cloud_app.command("pull")
def pull_project_command(
name: str = typer.Option(..., "--name", "--project", help="Project name to pull"),
on_conflict: ConflictStrategy = typer.Option(
ConflictStrategy.fail,
"--on-conflict",
help="Resolve files that differ on both sides (default: fail and list them)",
),
dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without pulling"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
) -> None:
"""Fetch cloud changes into local (cloud -> local), git-pull style.
Additive and Team-safe: downloads new/changed cloud files and never deletes
local files. A file that differs on both sides is a conflict; by default
pull aborts and lists them. Deletions are not propagated (see #862).
Examples:
bm cloud pull --name research
bm cloud pull --name research --dry-run
bm cloud pull --name research --on-conflict keep-cloud
"""
_run_directional_transfer(
name, "pull", on_conflict=on_conflict, dry_run=dry_run, verbose=verbose
)
@cloud_app.command("push")
def push_project_command(
name: str = typer.Option(..., "--name", "--project", help="Project name to push"),
on_conflict: ConflictStrategy = typer.Option(
ConflictStrategy.fail,
"--on-conflict",
help="Resolve files that differ on both sides (default: fail and list them)",
),
dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without pushing"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
) -> None:
"""Upload local changes to cloud (local -> cloud), additive and Team-safe.
Uploads new/changed local files and never deletes cloud files. A file that
differs on both sides is a conflict; by default push aborts and lists them
(like git rejecting a push when the remote is ahead pull first). Deletions
are not propagated (see #862).
Examples:
bm cloud push --name research
bm cloud push --name research --dry-run
bm cloud push --name research --on-conflict keep-local
"""
_run_directional_transfer(
name, "push", on_conflict=on_conflict, dry_run=dry_run, verbose=verbose
)
@cloud_app.command("bisync")
def bisync_project_command(
name: str = typer.Option(..., "--name", help="Project name to bisync"),
name: str = typer.Option(..., "--name", "--project", help="Project name to bisync"),
dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without syncing"),
resync: bool = typer.Option(False, "--resync", help="Force new baseline"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
@@ -256,7 +467,7 @@ def bisync_project_command(
@cloud_app.command("check")
def check_project_command(
name: str = typer.Option(..., "--name", help="Project name to check"),
name: str = typer.Option(..., "--name", "--project", help="Project name to check"),
one_way: bool = typer.Option(False, "--one-way", help="Check one direction only (faster)"),
) -> None:
"""Verify file integrity between local and cloud.
@@ -395,9 +606,15 @@ def setup_project_sync(
console.print(f"[green]Sync configured for project '{name}'[/green]")
console.print(f"\nLocal sync path: {resolved_path}")
# Lead with the Team-safe additive commands (work on any workspace); the
# `sync`/`bisync` mirrors are Personal-workspace-only.
console.print("\nNext steps:")
console.print(f" 1. Preview: bm cloud sync --name {name} --dry-run")
console.print(f" 2. Sync: bm cloud sync --name {name}")
console.print(f" 1. Preview a pull: bm cloud pull --name {name} --dry-run")
console.print(f" 2. Fetch from cloud: bm cloud pull --name {name}")
console.print(f" 3. Upload local changes: bm cloud push --name {name}")
console.print(
f" Personal workspaces can also mirror with: bm cloud bisync --name {name} --resync"
)
except Exception as e:
console.print(f"[red]Error configuring sync: {str(e)}[/red]")
raise typer.Exit(1)
@@ -11,10 +11,10 @@ Replaces tenant-wide sync with project-scoped workflows.
import re
import subprocess
from dataclasses import dataclass
from dataclasses import dataclass, field
from functools import lru_cache
from pathlib import Path
from typing import Callable, Optional, Protocol
from pathlib import Path, PurePosixPath
from typing import Callable, Literal, Optional, Protocol
from loguru import logger
from rich.console import Console
@@ -42,6 +42,7 @@ TIGRIS_CONSISTENCY_HEADERS = [
class RunResult(Protocol):
returncode: int
stdout: str
stderr: str
RunFunc = Callable[..., RunResult]
@@ -184,6 +185,91 @@ def get_project_remote(project: SyncProject, bucket_name: str) -> str:
return f"basic-memory-cloud:{bucket_name}/{cloud_path}"
# --- Directional transfer primitives (push / pull) ---
#
# These power the Team-safe `bm cloud push` / `bm cloud pull` commands. Unlike
# the mirror operations (`sync`/`bisync`), they use `rclone copy` so they never
# delete on the destination, and conflicts are surfaced to the caller rather
# than silently resolved. See issue #858 for the full design rationale.
# push = local -> cloud, pull = cloud -> local.
TransferDirection = Literal["push", "pull"]
# How a directional transfer treats files that differ on both sides. "fail" is
# the safe default: the caller is expected to abort before any transfer runs.
ConflictStrategy = Literal["fail", "keep-local", "keep-cloud", "keep-both"]
@dataclass
class TransferPlan:
"""Classification of how local and cloud differ for a directional transfer.
Built from ``rclone check --combined``. Paths are relative to the project
root. ``conflicts`` are files present on both sides with differing content
without a sync baseline (see #862) every divergence is a conflict, because
we cannot tell a teammate's edit from a stale local copy.
"""
new: list[str] = field(default_factory=list) # only on source → safe to bring over
conflicts: list[str] = field(default_factory=list) # differ on both sides
dest_only: list[str] = field(default_factory=list) # only on destination → left untouched
errors: list[str] = field(default_factory=list) # rclone could not read/hash
def _transfer_endpoints(project: SyncProject, bucket_name: str) -> tuple[str, str]:
"""Return (local_path, remote_path) strings for a project's transfer.
Raises:
RcloneError: If the project has no local_sync_path configured.
"""
if not project.local_sync_path:
raise RcloneError(f"Project {project.name} has no local_sync_path configured")
local_path = str(Path(project.local_sync_path).expanduser())
remote_path = get_project_remote(project, bucket_name)
return local_path, remote_path
def _build_transfer_cmd(
operation: str,
source: str,
dest: str,
*,
filter_path: Path,
dry_run: bool,
verbose: bool,
extra_flags: tuple[str, ...] = (),
) -> list[str]:
"""Build an rclone sync/copy command with the shared Basic Memory flags.
All directional transfers share the same tail: Tigris consistency headers,
the .bmignore filter, and --local-no-preallocate (a no-op when local is the
source, required when local is the destination on pull see rclone#6801).
"""
cmd = [
"rclone",
operation,
source,
dest,
*TIGRIS_CONSISTENCY_HEADERS,
"--filter-from",
str(filter_path),
# Prevent NUL byte padding on virtual filesystems (e.g. Google Drive File Stream)
# See: rclone/rclone#6801
"--local-no-preallocate",
*extra_flags,
]
if verbose:
cmd.append("--verbose")
else:
cmd.append("--progress")
if dry_run:
cmd.append("--dry-run")
return cmd
def project_sync(
project: SyncProject,
bucket_name: str,
@@ -219,24 +305,199 @@ def project_sync(
remote_path = get_project_remote(project, bucket_name)
filter_path = filter_path or get_bmignore_filter_path()
cmd = [
"rclone",
cmd = _build_transfer_cmd(
"sync",
str(local_path),
remote_path,
filter_path=filter_path,
dry_run=dry_run,
verbose=verbose,
)
result = run(cmd, text=True)
return result.returncode == 0
def _parse_check_combined(output: str) -> TransferPlan:
"""Parse ``rclone check --combined`` output into a TransferPlan.
rclone emits one prefixed line per path (src is the transfer source):
``=`` identical, ``+`` only on src, ``-`` only on dst, ``*`` differ,
``!`` error reading/hashing. We ignore identical files.
"""
plan = TransferPlan()
for line in output.splitlines():
symbol, _, path = line.partition(" ")
path = path.strip()
if not path:
continue
if symbol == "+":
plan.new.append(path)
elif symbol == "*":
plan.conflicts.append(path)
elif symbol == "-":
plan.dest_only.append(path)
elif symbol == "!":
plan.errors.append(path)
# "=" (identical) is intentionally dropped.
return plan
def project_diff(
project: SyncProject,
bucket_name: str,
direction: TransferDirection,
*,
run: RunFunc = subprocess.run,
is_installed: IsInstalledFunc = is_rclone_installed,
filter_path: Path | None = None,
) -> TransferPlan:
"""Classify how local and cloud differ for a push/pull, without transferring.
Uses ``rclone check`` (content comparison) so the caller can surface
conflicts before any data moves. The source side depends on direction:
pull compares cloudlocal, push compares localcloud.
Raises:
RcloneError: If project has no local_sync_path configured or rclone not installed
"""
check_rclone_installed(is_installed=is_installed)
local_path, remote_path = _transfer_endpoints(project, bucket_name)
filter_path = filter_path or get_bmignore_filter_path()
# Source/dest order matters: rclone check reports "+" for files only on the
# source, which is what we want to bring over.
source, dest = (remote_path, local_path) if direction == "pull" else (local_path, remote_path)
cmd = [
"rclone",
"check",
source,
dest,
*TIGRIS_CONSISTENCY_HEADERS,
"--filter-from",
str(filter_path),
# Prevent NUL byte padding on virtual filesystems (e.g. Google Drive File Stream)
# See: rclone/rclone#6801
"--local-no-preallocate",
"--combined",
"-",
]
# rclone check exits non-zero when files differ — that's expected here, so we
# parse the combined listing rather than trusting the return code.
result = run(cmd, capture_output=True, text=True)
plan = _parse_check_combined(result.stdout)
# Trigger: non-zero exit AND the combined listing produced no entries at all.
# Why: a difference always yields +/-/*/! lines, so an empty listing on a
# non-zero exit means the check itself failed (auth, missing remote, network,
# bad filter) rather than finding zero differences. Without this guard the
# caller would see an empty plan, transfer nothing, and report success.
# Outcome: fail fast with rclone's stderr instead of a silent no-op.
if result.returncode != 0 and not (plan.new or plan.conflicts or plan.dest_only or plan.errors):
detail = result.stderr.strip() or f"rclone check exited with code {result.returncode}"
raise RcloneError(f"Failed to compare {project.name} with cloud: {detail}")
return plan
def project_copy(
project: SyncProject,
bucket_name: str,
direction: TransferDirection,
*,
overwrite: bool,
dry_run: bool = False,
verbose: bool = False,
run: RunFunc = subprocess.run,
is_installed: IsInstalledFunc = is_rclone_installed,
filter_path: Path | None = None,
) -> bool:
"""Additive transfer via ``rclone copy`` — never deletes on the destination.
Trigger: ``overwrite=False`` adds ``--ignore-existing`` so files already on
the destination are left as-is (used when the destination side wins a
conflict, and for the no-conflict fast path).
Why: keeps the loser's bytes intact unless the caller explicitly chose to
overwrite, matching the "no surprises" contract.
Raises:
RcloneError: If project has no local_sync_path configured or rclone not installed
"""
check_rclone_installed(is_installed=is_installed)
local_path, remote_path = _transfer_endpoints(project, bucket_name)
filter_path = filter_path or get_bmignore_filter_path()
source, dest = (remote_path, local_path) if direction == "pull" else (local_path, remote_path)
# Overwrite mode compares by checksum so the transfer decision matches
# project_diff's content-based conflict detection (rclone check). Without
# --checksum, copy's default size+modtime comparison could skip a file the
# diff flagged as a conflict (same size, destination not older) — silently
# ignoring the user's explicit keep-cloud/keep-local choice. New-only mode
# uses --ignore-existing, which skips by existence so the comparison basis
# does not matter.
extra_flags = ("--checksum",) if overwrite else ("--ignore-existing",)
cmd = _build_transfer_cmd(
"copy",
source,
dest,
filter_path=filter_path,
dry_run=dry_run,
verbose=verbose,
extra_flags=extra_flags,
)
result = run(cmd, text=True)
return result.returncode == 0
def _conflict_copy_name(rel_path: str, suffix: str) -> str:
"""Insert a ``.conflict-<suffix>`` marker before the extension of a rel path."""
p = PurePosixPath(rel_path)
return str(p.with_name(f"{p.stem}.conflict-{suffix}{p.suffix}"))
def project_copy_file(
project: SyncProject,
bucket_name: str,
direction: TransferDirection,
source_rel_path: str,
dest_rel_path: str,
*,
dry_run: bool = False,
verbose: bool = False,
run: RunFunc = subprocess.run,
is_installed: IsInstalledFunc = is_rclone_installed,
) -> bool:
"""Copy a single file from source to destination under a (possibly renamed) path.
Used for the ``keep-both`` strategy: the incoming version is written beside
the destination's own copy as ``name.conflict-<date>`` so nothing is lost.
Raises:
RcloneError: If project has no local_sync_path configured or rclone not installed
"""
check_rclone_installed(is_installed=is_installed)
local_path, remote_path = _transfer_endpoints(project, bucket_name)
source_root, dest_root = (
(remote_path, local_path) if direction == "pull" else (local_path, remote_path)
)
cmd = [
"rclone",
"copyto",
f"{source_root}/{source_rel_path}",
f"{dest_root}/{dest_rel_path}",
*TIGRIS_CONSISTENCY_HEADERS,
# Matches _build_transfer_cmd: on pull this writes the conflict copy to
# the local filesystem, where this prevents NUL byte padding on virtual
# filesystems (e.g. Google Drive File Stream). See rclone/rclone#6801.
"--local-no-preallocate",
]
if verbose:
cmd.append("--verbose")
else:
cmd.append("--progress")
if dry_run:
cmd.append("--dry-run")
@@ -244,6 +505,72 @@ def project_sync(
return result.returncode == 0
def _strategy_overwrites_dest(direction: TransferDirection, strategy: ConflictStrategy) -> bool:
"""True when the strategy lets the source side overwrite the destination.
The source side is cloud on pull, local on push. "keep-cloud" wins on pull,
"keep-local" wins on push; otherwise the destination is preserved.
"""
if strategy == "keep-cloud":
return direction == "pull"
if strategy == "keep-local":
return direction == "push"
return False # "fail" (no conflicts) and "keep-both" never overwrite existing dest files
def project_transfer(
project: SyncProject,
bucket_name: str,
direction: TransferDirection,
plan: TransferPlan,
*,
strategy: ConflictStrategy = "fail",
conflict_suffix: str = "",
dry_run: bool = False,
verbose: bool = False,
run: RunFunc = subprocess.run,
is_installed: IsInstalledFunc = is_rclone_installed,
filter_path: Path | None = None,
) -> bool:
"""Execute a directional transfer for the chosen conflict strategy.
Callers detect conflicts with ``project_diff`` first and abort when
``strategy == "fail"`` and conflicts exist; this function assumes that gate
has already passed and applies the resolution.
"""
# keep-both: preserve the destination's version and drop the incoming one
# beside it as a conflict copy, then do an additive (new-only) pass.
if strategy == "keep-both":
for rel_path in plan.conflicts:
dest_rel = _conflict_copy_name(rel_path, conflict_suffix)
copied = project_copy_file(
project,
bucket_name,
direction,
rel_path,
dest_rel,
dry_run=dry_run,
verbose=verbose,
run=run,
is_installed=is_installed,
)
if not copied:
return False
overwrite = _strategy_overwrites_dest(direction, strategy)
return project_copy(
project,
bucket_name,
direction,
overwrite=overwrite,
dry_run=dry_run,
verbose=verbose,
run=run,
is_installed=is_installed,
filter_path=filter_path,
)
def project_bisync(
project: SyncProject,
bucket_name: str,
@@ -43,15 +43,17 @@ def list_workspaces() -> None:
table = Table(title="Available Workspaces")
table.add_column("Name", style="cyan")
table.add_column("Slug", style="cyan")
table.add_column("Type", style="blue")
table.add_column("Role", style="green")
table.add_column("Tenant ID", style="yellow")
table.add_column("Workspace ID", style="yellow")
table.add_column("Default", style="magenta")
for workspace in workspaces:
is_default = "[X]" if workspace.tenant_id == default_ws else ""
table.add_row(
workspace.name,
workspace.slug,
workspace.workspace_type,
workspace.role,
workspace.tenant_id,
@@ -59,6 +61,9 @@ def list_workspaces() -> None:
)
console.print(table)
console.print(
"[dim]Use the Slug or Workspace ID anywhere a command asks for --workspace.[/dim]"
)
@workspace_app.command("set-default")
+12
View File
@@ -98,6 +98,18 @@ def mcp(
if transport == "stdio":
threading.Thread(target=_run_background_auto_update, daemon=True).start()
# Trigger: MCP server startup on the Postgres backend, before the transport
# creates its event loop.
# Why: the watcher/lifespan path runs startup migrations + engine.dispose() on
# asyncpg, which races stdlib asyncio teardown and crashes the container loop
# (#831/#877). uvloop's C scheduler structurally avoids that race and must own
# the loop policy before the loop is created. No-op for SQLite.
# Outcome: `basic-memory mcp` on Postgres runs on uvloop. (The CLI callback also
# installs it; this keeps the server startup seam explicit and self-contained.)
from basic_memory.db import maybe_install_uvloop
maybe_install_uvloop(ConfigManager().config)
# Run the MCP server (blocks)
# Lifespan handles: initialization, migrations, file sync, cleanup
logger.info(f"Starting MCP server with {transport.upper()} transport")
+70 -5
View File
@@ -1,8 +1,9 @@
"""Status command for basic-memory CLI."""
import asyncio
import json
from typing import Set, Dict
from typing import Annotated, Optional
import time
from typing import Annotated, Dict, Optional, Set
from mcp.server.fastmcp.exceptions import ToolError
import typer
@@ -142,20 +143,58 @@ def display_changes(
console.print(Panel(tree, expand=False))
class StatusTimeout(Exception):
"""Raised when --wait does not reach a synced state before the deadline."""
async def run_status(
project: Optional[str] = None,
wait: bool = False,
timeout: float = 30.0,
poll_interval: float = 0.5,
) -> tuple[str, SyncReportResponse]:
"""Fetch sync status of files vs database.
When ``wait`` is False this performs a single live disk-vs-DB scan and
returns immediately. When ``wait`` is True it polls until the project has
no pending changes (``sync_report.total == 0``) or the timeout elapses.
Returns (project_name, sync_report) for the caller to render.
Raises:
StatusTimeout: If ``wait`` is True and the deadline passes before the
project reaches a synced state.
"""
# Resolve default project so get_client() can route per-project
project = project or ConfigManager().default_project
# Reuse a single client/context across polls so we don't reconnect each loop.
async with get_client(project_name=project) as client:
project_item = await get_active_project(client, project, None)
sync_report = await ProjectClient(client).get_status(project_item.external_id)
return project_item.name, sync_report
project_client = ProjectClient(client)
# Trigger: caller did not request --wait
# Why: preserve the original single-scan behavior for the common case
# Outcome: one status scan, returned as-is
if not wait:
sync_report = await project_client.get_status(project_item.external_id)
return project_item.name, sync_report
# Trigger: --wait requested
# Why: callers (bulk imports, benchmarks, tests) need to block until the
# index has caught up instead of polling externally
# Outcome: poll get_status until total == 0 or the deadline is reached
deadline = time.monotonic() + timeout
while True:
sync_report = await project_client.get_status(project_item.external_id)
if sync_report.total == 0:
return project_item.name, sync_report
if time.monotonic() >= deadline:
raise StatusTimeout(
f"Timed out after {timeout:g}s waiting for '{project_item.name}' "
f"to finish indexing ({sync_report.total} pending change(s) remaining)."
)
await asyncio.sleep(poll_interval)
@app.command()
@@ -166,6 +205,10 @@ def status(
] = None,
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed file information"),
json_output: bool = typer.Option(False, "--json", help="Output in JSON format"),
wait: bool = typer.Option(
False, "--wait", help="Block until indexing is complete (no pending changes)"
),
timeout: float = typer.Option(30.0, "--timeout", help="Max seconds to wait when --wait is set"),
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
@@ -174,11 +217,21 @@ def status(
"""Show sync status between files and database.
Use --json for machine-readable output.
Use --wait to block until indexing is complete (e.g. after a bulk import);
combine with --timeout to bound the wait. On timeout the command exits 1.
Use --local to force local routing when cloud mode is enabled.
Use --cloud to force cloud routing when cloud mode is disabled.
"""
from basic_memory.cli.commands.command_utils import run_with_cleanup
# Trigger: --wait with a negative --timeout
# Why: a negative deadline times out on the very first poll, producing a confusing
# "Timed out after -5s" message instead of flagging the bad input. Raised
# before the try/except so typer renders a clean usage error (exit 2).
# Outcome: reject it up front with a clear parameter error.
if wait and timeout < 0:
raise typer.BadParameter("--timeout must be >= 0", param_hint="'--timeout'")
try:
validate_routing_flags(local, cloud)
# Trigger: no explicit routing flag provided
@@ -189,12 +242,24 @@ def status(
if not local and not cloud:
local = True
with force_routing(local=local, cloud=cloud):
project_name, sync_report = run_with_cleanup(run_status(project))
project_name, sync_report = run_with_cleanup(
run_status(project, wait=wait, timeout=timeout)
)
if json_output:
print(json.dumps(sync_report.model_dump(mode="json"), indent=2, default=str))
else:
display_changes(project_name, "Status", sync_report, verbose)
except StatusTimeout as e:
# Trigger: --wait deadline passed before the project finished indexing
# Why: callers depend on exit code 1 to detect that indexing did not
# complete in time, while still getting a clear machine/human message
# Outcome: emit the timeout message (JSON-shaped under --json) and exit 1
if json_output:
print(json.dumps({"error": str(e)}, indent=2))
else:
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(code=1)
except (ValueError, ToolError) as e:
if json_output:
print(json.dumps({"error": str(e)}, indent=2))
+140 -1
View File
@@ -15,6 +15,7 @@ from basic_memory.cli.app import app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
from basic_memory.mcp.tools import build_context as mcp_build_context
from basic_memory.mcp.tools import delete_note as mcp_delete_note
from basic_memory.mcp.tools import edit_note as mcp_edit_note
from basic_memory.mcp.tools import list_memory_projects as mcp_list_projects
from basic_memory.mcp.tools import list_workspaces as mcp_list_workspaces
@@ -40,6 +41,26 @@ def _print_json(result: Any) -> None:
print(json.dumps(result, indent=2, ensure_ascii=True, default=str))
def _delete_note_failure_message(result: dict[str, Any]) -> str | None:
"""Return the CLI failure message for delete-note JSON results, if any."""
error = result.get("error")
if error:
return str(error)
failed_deletes = result.get("failed_deletes")
# Trigger: directory deletion can partially fail without raising from the service.
# Why: cleanup scripts need a non-zero exit when files remain undeleted.
# Outcome: the CLI fails even if older MCP JSON did not include an error field.
if (
result.get("is_directory") is True
and isinstance(failed_deletes, int)
and failed_deletes > 0
):
return f"Directory delete incomplete: {failed_deletes} file(s) failed"
return None
# --- Commands ---
@@ -56,6 +77,16 @@ def write_note(
tags: Annotated[
Optional[List[str]], typer.Option(help="A list of tags to apply to the note")
] = None,
note_type: Annotated[
str,
typer.Option(
"--type",
help=(
"Note type stored in frontmatter (e.g. 'guide', 'report'). "
"A 'type:' in the note's own content frontmatter takes precedence."
),
),
] = "note",
project: Annotated[
Optional[str],
typer.Option(
@@ -69,6 +100,11 @@ def write_note(
help="Project external_id (UUID). Takes precedence over --project; use to disambiguate same-named projects across cloud workspaces.",
),
] = None,
overwrite: bool = typer.Option(
False,
"--overwrite",
help="Replace an existing note on conflict (matches MCP write_note overwrite=True)",
),
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
@@ -79,7 +115,9 @@ def write_note(
Examples:
bm tool write-note --title "My Note" --folder "notes" --content "Note content"
bm tool write-note --title "My Guide" --folder "notes" --content "..." --type guide
echo "content" | bm tool write-note --title "My Note" --folder "notes"
bm tool write-note --title "My Note" --folder "notes" --overwrite
bm tool write-note --title "My Note" --folder "notes" --local
"""
try:
@@ -111,9 +149,23 @@ def write_note(
project=project,
project_id=project_id,
tags=tags,
note_type=note_type,
overwrite=overwrite,
output_format="json",
)
)
# MCP tool returns an error field on failure in JSON mode (e.g.
# NOTE_ALREADY_EXISTS on a blocked overwrite, SECURITY_VALIDATION_ERROR).
# Trigger: result carries a non-empty `error`.
# Why: parity with delete-note/edit-note/search-notes so exit-code-driven
# scripts detect a failed/blocked write instead of seeing exit 0.
# Outcome: print the error to stderr and exit non-zero.
if isinstance(result, dict) and result.get("error"):
typer.echo(f"Error: {result['error']}", err=True)
_print_json(result)
raise typer.Exit(1)
_print_json(result)
except ValueError as e:
typer.echo(f"Error: {e}", err=True)
@@ -167,6 +219,19 @@ def read_note(
output_format="json",
)
)
# MCP tool returns an error field on failure in JSON mode (e.g.
# SECURITY_VALIDATION_ERROR on a path-traversal identifier). A genuine
# not-found returns null fields with no `error` key, so it still exits 0.
# Trigger: result carries a non-empty `error`.
# Why: parity with edit-note/delete-note/search-notes so a blocked read
# surfaces a non-zero exit instead of looking like success.
# Outcome: print the error to stderr and exit non-zero.
if isinstance(result, dict) and result.get("error"):
typer.echo(f"Error: {result['error']}", err=True)
_print_json(result)
raise typer.Exit(1)
_print_json(result)
except ValueError as e:
typer.echo(f"Error: {e}", err=True)
@@ -178,6 +243,66 @@ def read_note(
raise
@tool_app.command("delete-note")
def delete_note(
identifier: str,
is_directory: bool = typer.Option(
False, "--is-directory", help="Delete a directory instead of a single note"
),
project: Annotated[
Optional[str],
typer.Option(help="The project to use. If not provided, the default project will be used."),
] = None,
project_id: Annotated[
Optional[str],
typer.Option(
"--project-id",
help="Project external_id (UUID). Takes precedence over --project; use to disambiguate same-named projects across cloud workspaces.",
),
] = None,
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
) -> None:
"""Delete a note or directory from the knowledge base.
Examples:
bm tool delete-note notes/old-draft
bm tool delete-note docs/archive --is-directory
"""
try:
validate_routing_flags(local, cloud)
with force_routing(local=local, cloud=cloud):
result = run_with_cleanup(
mcp_delete_note(
identifier=identifier,
is_directory=is_directory,
project=project,
project_id=project_id,
output_format="json",
)
)
if isinstance(result, dict):
failure_message = _delete_note_failure_message(result)
if failure_message:
typer.echo(f"Error: {failure_message}", err=True)
raise typer.Exit(1)
_print_json(result)
except ValueError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
typer.echo(f"Error during delete_note: {e}", err=True)
raise typer.Exit(1)
raise
@tool_app.command()
def edit_note(
identifier: str,
@@ -324,7 +449,9 @@ def recent_activity(
"7d", "--timeframe", help="Timeframe filter (e.g., '7d', '1 week')"
),
page: int = typer.Option(1, "--page", help="Page number for pagination"),
page_size: int = typer.Option(50, "--page-size", help="Number of results per page"),
# Match the MCP recent_activity default (page_size=10) so identical default
# invocations return the same number of rows from CLI and MCP.
page_size: int = typer.Option(10, "--page-size", help="Number of results per page"),
project: Annotated[
Optional[str],
typer.Option(help="The project to use. If not provided, the default project will be used."),
@@ -409,6 +536,16 @@ def search_notes(
help="Filter by search item type: entity, observation, relation (repeatable)",
),
] = None,
categories: Annotated[
Optional[List[str]],
typer.Option(
"--category",
help=(
"Filter observation results to exact categories (repeatable); "
"pair with --entity-type observation"
),
),
] = None,
meta: Annotated[
Optional[List[str]],
typer.Option("--meta", help="Filter by frontmatter key=value (repeatable)"),
@@ -443,6 +580,7 @@ def search_notes(
bm tool search-notes --permalink "specs/*"
bm tool search-notes --tag python --tag async
bm tool search-notes --meta status=draft
bm tool search-notes "auth" --entity-type observation --category requirement
"""
try:
validate_routing_flags(local, cloud)
@@ -508,6 +646,7 @@ def search_notes(
page_size=page_size,
note_types=note_types,
entity_types=entity_types,
categories=categories,
metadata_filters=metadata_filters,
tags=tags,
status=status,
@@ -0,0 +1,23 @@
"""Top-level `workspace` stub that redirects users to `bm cloud workspace`."""
import typer
from basic_memory.cli.app import app
# Trigger: user runs `bm workspace`, `bm workspace list`, etc.
# Why: workspace verbs live under `bm cloud workspace`; a bare top-level miss
# only emits Typer's terse "No such command 'workspace'." with exit 2.
# Outcome: allow_extra_args + ignore_unknown_options absorb any trailing tokens
# (e.g. `list`, `set-default foo`) so every invocation reaches this body and
# prints actionable guidance instead of being rejected as bad usage.
@app.command(
"workspace",
context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
)
def workspace_stub(ctx: typer.Context) -> None:
"""Point users to the real workspace verbs under `bm cloud workspace`."""
typer.echo("'bm workspace' is not a command. Workspace verbs live under 'bm cloud workspace':")
typer.echo(" bm cloud workspace list")
typer.echo(" bm cloud workspace set-default <name>")
raise typer.Exit(1)
+2
View File
@@ -16,6 +16,7 @@ def _version_only_invocation(argv: list[str]) -> bool:
if not _version_only_invocation(sys.argv[1:]):
# Register commands only when not short-circuiting for --version
from basic_memory.cli.commands import ( # noqa: F401 # pragma: no cover
ci,
cloud,
db,
doctor,
@@ -30,6 +31,7 @@ if not _version_only_invocation(sys.argv[1:]):
status,
tool,
update,
workspace,
)
warnings.filterwarnings("ignore") # pragma: no cover

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