Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 59e865c079 | |||
| 96b6b21a88 | |||
| 5973f9b787 | |||
| 8887267256 | |||
| a6cfed96f1 | |||
| 2de8183e85 | |||
| 0adbc12f09 | |||
| 80ca5a07fd | |||
| 4502bd3c3b | |||
| b386502020 | |||
| 16ba55d7b4 | |||
| 5458c35b35 |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,7 @@
|
||||
project: dev
|
||||
workspace: basic-memory-7020de4e925843c68c9056c60d101d9e
|
||||
deploy_workflows:
|
||||
- Deploy Production
|
||||
production_environments:
|
||||
- production
|
||||
note_folder: project-updates/github/{owner}/{repo}
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
@@ -38,6 +38,7 @@ jobs:
|
||||
mcp
|
||||
sync
|
||||
ui
|
||||
ci
|
||||
deps
|
||||
installer
|
||||
plugins
|
||||
|
||||
@@ -55,6 +55,7 @@ ENV/
|
||||
claude-output
|
||||
**/.claude/settings.local.json
|
||||
.mcp.json
|
||||
!/plugins/codex/.mcp.json
|
||||
.mcpregistry_*
|
||||
/.testmondata
|
||||
.benchmarks/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -506,7 +506,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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -265,8 +265,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 +278,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
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.)_"]
|
||||
|
||||
@@ -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"})`.
|
||||
|
||||
@@ -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,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
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "codex",
|
||||
"version": "0.1.0+codex.20260604201213",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"basic-memory": {
|
||||
"command": "uvx",
|
||||
"args": [
|
||||
"basic-memory",
|
||||
"mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 10 KiB |
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
"""CI helpers for Basic Memory project update capture."""
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
@@ -13,6 +13,7 @@ from . import (
|
||||
|
||||
__all__ = [
|
||||
"status",
|
||||
"ci",
|
||||
"db",
|
||||
"doctor",
|
||||
"import_memory_json",
|
||||
|
||||
@@ -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 []
|
||||
@@ -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")
|
||||
|
||||
@@ -15,7 +15,6 @@ 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
|
||||
@@ -41,26 +40,6 @@ 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 ---
|
||||
|
||||
|
||||
@@ -199,66 +178,6 @@ 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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -315,22 +315,14 @@ async def delete_note(
|
||||
)
|
||||
result = await knowledge_client.delete_directory(directory_identifier)
|
||||
if output_format == "json":
|
||||
response = {
|
||||
return {
|
||||
"deleted": result.failed_deletes == 0,
|
||||
"is_directory": True,
|
||||
"identifier": identifier,
|
||||
"total_files": result.total_files,
|
||||
"successful_deletes": result.successful_deletes,
|
||||
"failed_deletes": result.failed_deletes,
|
||||
"deleted_files": result.deleted_files,
|
||||
"errors": [error.model_dump() for error in result.errors],
|
||||
}
|
||||
if result.failed_deletes > 0:
|
||||
response["error"] = (
|
||||
"Directory delete incomplete: "
|
||||
f"{result.failed_deletes} of {result.total_files} file(s) failed"
|
||||
)
|
||||
return response
|
||||
|
||||
# Build success message for directory delete
|
||||
result_lines = [
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
"""Integration tests for `basic-memory tool delete-note`."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from basic_memory.cli.main import app as cli_app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def _write_note(
|
||||
title: str,
|
||||
folder: str,
|
||||
content: str,
|
||||
*,
|
||||
project: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
args = [
|
||||
"tool",
|
||||
"write-note",
|
||||
"--title",
|
||||
title,
|
||||
"--folder",
|
||||
folder,
|
||||
"--content",
|
||||
content,
|
||||
]
|
||||
if project is not None:
|
||||
args.extend(["--project", project])
|
||||
|
||||
result = runner.invoke(cli_app, args)
|
||||
assert result.exit_code == 0, result.output
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def _read_note(identifier: str, *, project: str | None = None) -> dict[str, Any]:
|
||||
args = ["tool", "read-note", identifier]
|
||||
if project is not None:
|
||||
args.extend(["--project", project])
|
||||
|
||||
result = runner.invoke(cli_app, args)
|
||||
assert result.exit_code == 0, result.output
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def _delete_note(
|
||||
identifier: str,
|
||||
*,
|
||||
is_directory: bool = False,
|
||||
project: str | None = None,
|
||||
project_id: str | None = None,
|
||||
local: bool = False,
|
||||
) -> tuple[int, dict[str, Any], str]:
|
||||
args = ["tool", "delete-note", identifier]
|
||||
if is_directory:
|
||||
args.append("--is-directory")
|
||||
if project is not None:
|
||||
args.extend(["--project", project])
|
||||
if project_id is not None:
|
||||
args.extend(["--project-id", project_id])
|
||||
if local:
|
||||
args.append("--local")
|
||||
|
||||
result = runner.invoke(cli_app, args)
|
||||
payload = json.loads(result.stdout) if result.stdout else {}
|
||||
return result.exit_code, payload, result.output
|
||||
|
||||
|
||||
def _search_notes(
|
||||
query: str,
|
||||
*,
|
||||
mode_flag: str | None = None,
|
||||
page_size: int = 20,
|
||||
) -> dict[str, Any]:
|
||||
args = ["tool", "search-notes", query, "--page-size", str(page_size)]
|
||||
if mode_flag is not None:
|
||||
args.append(mode_flag)
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
args,
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def _project_file(test_project, file_path: str) -> Path:
|
||||
return Path(test_project.path) / file_path
|
||||
|
||||
|
||||
def test_delete_note_removes_file_database_record_and_search_result(
|
||||
app, app_config, test_project, config_manager
|
||||
) -> None:
|
||||
"""Single-note deletion removes the note from every user-visible surface."""
|
||||
note = _write_note(
|
||||
"CLI Delete Single Note",
|
||||
"delete-cli",
|
||||
"# CLI Delete Single Note\n\nUniqueSingleDeleteToken\n\n- [status] ready to delete",
|
||||
)
|
||||
note_path = _project_file(test_project, note["file_path"])
|
||||
assert note_path.exists()
|
||||
|
||||
exit_code, payload, output = _delete_note(note["permalink"])
|
||||
|
||||
assert exit_code == 0, output
|
||||
assert payload == {
|
||||
"deleted": True,
|
||||
"title": "CLI Delete Single Note",
|
||||
"permalink": note["permalink"],
|
||||
"file_path": note["file_path"],
|
||||
}
|
||||
assert not note_path.exists()
|
||||
|
||||
missing = _read_note(note["permalink"])
|
||||
assert missing["title"] is None
|
||||
assert missing["permalink"] is None
|
||||
assert missing["content"] is None
|
||||
|
||||
search = _search_notes("CLI Delete Single Note", mode_flag="--title")
|
||||
assert search["total"] == 0
|
||||
assert search["results"] == []
|
||||
|
||||
|
||||
def test_delete_note_not_found_returns_json_without_error(
|
||||
app, app_config, test_project, config_manager
|
||||
) -> None:
|
||||
"""A missing note is machine-readable and does not produce a CLI failure."""
|
||||
exit_code, payload, output = _delete_note("delete-cli/missing-note")
|
||||
|
||||
assert exit_code == 0, output
|
||||
assert payload == {
|
||||
"deleted": False,
|
||||
"title": None,
|
||||
"permalink": None,
|
||||
"file_path": None,
|
||||
}
|
||||
|
||||
|
||||
def test_delete_note_case_mismatch_does_not_delete_exact_note(
|
||||
app, app_config, test_project, config_manager
|
||||
) -> None:
|
||||
"""Strict CLI deletes must not fuzzy-match a differently cased title."""
|
||||
note = _write_note(
|
||||
"CLI CamelCase Delete Note",
|
||||
"delete-cli",
|
||||
"# CLI CamelCase Delete Note\n\nCaseSensitiveDeleteToken",
|
||||
)
|
||||
|
||||
exit_code, payload, output = _delete_note("cli camelcase delete note")
|
||||
|
||||
assert exit_code == 0, output
|
||||
assert payload["deleted"] is False
|
||||
still_there = _read_note(note["permalink"])
|
||||
assert still_there["title"] == "CLI CamelCase Delete Note"
|
||||
assert "CaseSensitiveDeleteToken" in still_there["content"]
|
||||
|
||||
|
||||
def test_delete_note_project_id_takes_precedence_over_wrong_project_name(
|
||||
app, app_config, test_project, config_manager
|
||||
) -> None:
|
||||
"""CLI `--project-id` routes destructive operations to the exact project."""
|
||||
note = _write_note(
|
||||
"CLI Delete By Project ID",
|
||||
"delete-cli",
|
||||
"# CLI Delete By Project ID\n\nProjectIdDeleteToken",
|
||||
)
|
||||
|
||||
exit_code, payload, output = _delete_note(
|
||||
note["file_path"],
|
||||
project="not-the-test-project",
|
||||
project_id=test_project.external_id,
|
||||
)
|
||||
|
||||
assert exit_code == 0, output
|
||||
assert payload["deleted"] is True
|
||||
assert payload["title"] == "CLI Delete By Project ID"
|
||||
assert _read_note(note["permalink"])["title"] is None
|
||||
|
||||
|
||||
def test_delete_note_memory_url_detects_project_from_identifier(
|
||||
app, app_config, test_project, config_manager
|
||||
) -> None:
|
||||
"""A memory:// URL can select the project without a separate --project flag."""
|
||||
note = _write_note(
|
||||
"CLI Delete Memory URL",
|
||||
"delete-cli",
|
||||
"# CLI Delete Memory URL\n\nMemoryUrlDeleteToken",
|
||||
project=test_project.name,
|
||||
)
|
||||
memory_url = f"memory://{test_project.name}/{note['permalink']}"
|
||||
|
||||
exit_code, payload, output = _delete_note(memory_url)
|
||||
|
||||
assert exit_code == 0, output
|
||||
assert payload["deleted"] is True
|
||||
assert payload["permalink"] == note["permalink"]
|
||||
assert _read_note(note["permalink"], project=test_project.name)["title"] is None
|
||||
|
||||
|
||||
def test_delete_directory_removes_nested_files_database_records_and_search_results(
|
||||
app, app_config, test_project, config_manager
|
||||
) -> None:
|
||||
"""Directory deletion removes nested notes and reports a complete JSON summary."""
|
||||
notes = [
|
||||
_write_note(
|
||||
"CLI Delete Directory Root",
|
||||
"delete-cli-dir",
|
||||
"# CLI Delete Directory Root\n\nDirectoryDeleteTokenRoot",
|
||||
),
|
||||
_write_note(
|
||||
"CLI Delete Directory Child",
|
||||
"delete-cli-dir/child",
|
||||
"# CLI Delete Directory Child\n\nDirectoryDeleteTokenChild",
|
||||
),
|
||||
_write_note(
|
||||
"CLI Delete Directory Deep Child",
|
||||
"delete-cli-dir/child/deep",
|
||||
"# CLI Delete Directory Deep Child\n\nDirectoryDeleteTokenDeep",
|
||||
),
|
||||
]
|
||||
note_paths = [_project_file(test_project, note["file_path"]) for note in notes]
|
||||
assert all(path.exists() for path in note_paths)
|
||||
|
||||
exit_code, payload, output = _delete_note("delete-cli-dir", is_directory=True, local=True)
|
||||
|
||||
assert exit_code == 0, output
|
||||
assert payload["deleted"] is True
|
||||
assert payload["is_directory"] is True
|
||||
assert payload["identifier"] == "delete-cli-dir"
|
||||
assert payload["total_files"] == 3
|
||||
assert payload["successful_deletes"] == 3
|
||||
assert payload["failed_deletes"] == 0
|
||||
assert payload["errors"] == []
|
||||
assert set(payload["deleted_files"]) == {note["file_path"] for note in notes}
|
||||
assert not any(path.exists() for path in note_paths)
|
||||
|
||||
for note in notes:
|
||||
assert _read_note(note["permalink"])["title"] is None
|
||||
|
||||
search = _search_notes("CLI Delete Directory", mode_flag="--title")
|
||||
assert search["total"] == 0
|
||||
assert search["results"] == []
|
||||
|
||||
|
||||
def test_delete_directory_without_flag_does_not_delete_child_notes(
|
||||
app, app_config, test_project, config_manager
|
||||
) -> None:
|
||||
"""The CLI must not treat a directory path as destructive without --is-directory."""
|
||||
note = _write_note(
|
||||
"CLI Delete Directory Safety",
|
||||
"delete-cli-safety",
|
||||
"# CLI Delete Directory Safety\n\nDirectorySafetyToken",
|
||||
)
|
||||
|
||||
exit_code, payload, output = _delete_note("delete-cli-safety")
|
||||
|
||||
assert exit_code == 0, output
|
||||
assert payload["deleted"] is False
|
||||
still_there = _read_note(note["permalink"])
|
||||
assert still_there["title"] == "CLI Delete Directory Safety"
|
||||
assert _project_file(test_project, note["file_path"]).exists()
|
||||
|
||||
|
||||
def test_delete_note_rejects_conflicting_routing_flags(
|
||||
app, app_config, test_project, config_manager
|
||||
) -> None:
|
||||
"""delete-note validates the same --local/--cloud conflict as other tool commands."""
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
["tool", "delete-note", "delete-cli/missing-note", "--local", "--cloud"],
|
||||
)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "Cannot specify both --local and --cloud" in result.output
|
||||
@@ -0,0 +1,718 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from pydantic import ValidationError
|
||||
|
||||
from basic_memory.ci.project_updates import (
|
||||
AgentSynthesis,
|
||||
ProjectUpdateConfig,
|
||||
ProjectUpdateContext,
|
||||
build_project_update_note,
|
||||
collect_project_update_context,
|
||||
detect_github_repo,
|
||||
load_project_update_config,
|
||||
parse_github_remote,
|
||||
render_agent_synthesis_schema,
|
||||
render_capture_prompt,
|
||||
render_soul_template,
|
||||
render_workflow,
|
||||
schema_seed_specs,
|
||||
)
|
||||
from basic_memory.ci import project_updates
|
||||
|
||||
|
||||
def _write_json(path: Path, payload: dict) -> Path:
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def _pr_payload(*, merged: bool = True) -> dict:
|
||||
return {
|
||||
"action": "closed",
|
||||
"repository": {
|
||||
"full_name": "basicmachines-co/basic-memory",
|
||||
"html_url": "https://github.com/basicmachines-co/basic-memory",
|
||||
},
|
||||
"pull_request": {
|
||||
"number": 123,
|
||||
"title": "Remember project updates",
|
||||
"body": "Adds Auto BM capture.\n\nCloses #77",
|
||||
"html_url": "https://github.com/basicmachines-co/basic-memory/pull/123",
|
||||
"merged": merged,
|
||||
"merged_at": "2026-06-04T18:42:00Z" if merged else None,
|
||||
"merge_commit_sha": "abc123",
|
||||
"changed_files": 4,
|
||||
"labels": [{"name": "feature"}, {"name": "ci"}],
|
||||
"user": {"login": "octocat"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _synthesis_payload(**overrides: object) -> dict[str, object]:
|
||||
payload: dict[str, object] = {
|
||||
"summary": "Auto BM now records project updates.",
|
||||
"story": (
|
||||
"GitHub delivery events were losing their useful narrative after merge. "
|
||||
"Auto BM collects source facts, lets the agent explain the change, and "
|
||||
"publishes the result as durable project memory."
|
||||
),
|
||||
"problem_addressed": "Project delivery context was not preserved after GitHub events.",
|
||||
"solution": "Collect GitHub facts and publish an idempotent Basic Memory note.",
|
||||
"system_impact": "Future humans and agents can recover the delivery narrative.",
|
||||
"why_it_matters": "Future agents can recover project context.",
|
||||
"components_changed": ["basic_memory.ci.project_updates"],
|
||||
"complexity_introduced": [],
|
||||
"refactors_or_removals": [],
|
||||
"user_facing_changes": [],
|
||||
"internal_changes": [],
|
||||
"verification": [],
|
||||
"follow_ups": [],
|
||||
"decision_candidates": [],
|
||||
"task_candidates": [],
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
def test_collect_merged_pull_request_context(tmp_path: Path) -> None:
|
||||
event_path = _write_json(tmp_path / "event.json", _pr_payload())
|
||||
|
||||
context = collect_project_update_context(
|
||||
event_name="pull_request",
|
||||
event_path=event_path,
|
||||
config=ProjectUpdateConfig(project="team-memory"),
|
||||
)
|
||||
|
||||
assert context.eligible is True
|
||||
assert context.source_event == "pull_request_merged"
|
||||
assert context.repo == "basicmachines-co/basic-memory"
|
||||
assert context.idempotency_key == "github:basicmachines-co/basic-memory:pull_request_merged:123"
|
||||
assert context.pr_number == 123
|
||||
assert context.sha == "abc123"
|
||||
assert context.labels == ["feature", "ci"]
|
||||
assert context.linked_issues == ["#77"]
|
||||
assert context.source_url == "https://github.com/basicmachines-co/basic-memory/pull/123"
|
||||
|
||||
|
||||
def test_collect_enriches_pull_request_context_from_github_api(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def fake_github_api_get(path: str, token: str) -> list[dict] | dict:
|
||||
assert token == "github-token"
|
||||
if path.startswith("/repos/basicmachines-co/basic-memory/pulls/123/files"):
|
||||
return [
|
||||
{
|
||||
"filename": "src/basic_memory/ci/project_updates.py",
|
||||
"status": "modified",
|
||||
"additions": 42,
|
||||
"deletions": 7,
|
||||
"changes": 49,
|
||||
}
|
||||
]
|
||||
if path.startswith("/repos/basicmachines-co/basic-memory/pulls/123/commits"):
|
||||
return [
|
||||
{
|
||||
"sha": "abc123def456",
|
||||
"commit": {
|
||||
"message": "fix ci synthesis schema\n\nRequire all fields.",
|
||||
"author": {"name": "Pat"},
|
||||
},
|
||||
}
|
||||
]
|
||||
if path == "/repos/basicmachines-co/basic-memory/issues/77":
|
||||
return {
|
||||
"number": 77,
|
||||
"title": "Codex structured output rejects optional schema fields",
|
||||
"body": "Auto BM failed before publish when optional fields were omitted.",
|
||||
"html_url": "https://github.com/basicmachines-co/basic-memory/issues/77",
|
||||
"state": "closed",
|
||||
}
|
||||
raise AssertionError(f"unexpected GitHub API path: {path}")
|
||||
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "github-token")
|
||||
monkeypatch.setattr(project_updates, "_github_api_get", fake_github_api_get, raising=False)
|
||||
event_path = _write_json(tmp_path / "event.json", _pr_payload())
|
||||
|
||||
context = collect_project_update_context(
|
||||
event_name="pull_request",
|
||||
event_path=event_path,
|
||||
config=ProjectUpdateConfig(project="team-memory"),
|
||||
)
|
||||
|
||||
assert context.changed_files[0].filename == "src/basic_memory/ci/project_updates.py"
|
||||
assert context.changed_files[0].status == "modified"
|
||||
assert context.commits[0].message == "fix ci synthesis schema\n\nRequire all fields."
|
||||
assert context.linked_issue_details[0].number == 77
|
||||
assert (
|
||||
context.linked_issue_details[0].title
|
||||
== "Codex structured output rejects optional schema fields"
|
||||
)
|
||||
|
||||
|
||||
def test_github_api_get_list_fetches_multiple_pages(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls: list[str] = []
|
||||
|
||||
def fake_github_api_get(path: str, token: str) -> list[dict]:
|
||||
assert token == "github-token"
|
||||
calls.append(path)
|
||||
if path.endswith("page=1"):
|
||||
return [{"filename": f"file-{index}.py"} for index in range(100)]
|
||||
if path.endswith("page=2"):
|
||||
return [{"filename": "file-100.py"}]
|
||||
raise AssertionError(f"unexpected GitHub API path: {path}")
|
||||
|
||||
monkeypatch.setattr(project_updates, "_github_api_get", fake_github_api_get, raising=False)
|
||||
|
||||
files = project_updates._github_api_get_list(
|
||||
"/repos/basicmachines-co/basic-memory/pulls/123/files",
|
||||
"github-token",
|
||||
)
|
||||
|
||||
assert len(files) == 101
|
||||
assert calls == [
|
||||
"/repos/basicmachines-co/basic-memory/pulls/123/files?per_page=100&page=1",
|
||||
"/repos/basicmachines-co/basic-memory/pulls/123/files?per_page=100&page=2",
|
||||
]
|
||||
|
||||
|
||||
def test_collect_handles_sparse_pull_request_payload(tmp_path: Path) -> None:
|
||||
payload = {
|
||||
"action": "closed",
|
||||
"repository": {},
|
||||
"pull_request": {
|
||||
"number": 123,
|
||||
"merged": True,
|
||||
"labels": "not-a-list",
|
||||
},
|
||||
}
|
||||
event_path = _write_json(tmp_path / "event.json", payload)
|
||||
|
||||
context = collect_project_update_context(
|
||||
event_name="pull_request",
|
||||
event_path=event_path,
|
||||
config=ProjectUpdateConfig(project="team-memory"),
|
||||
)
|
||||
|
||||
assert context.eligible is True
|
||||
assert context.repo is None
|
||||
assert context.repo_url is None
|
||||
assert context.labels == []
|
||||
assert context.linked_issues == []
|
||||
|
||||
|
||||
def test_collect_handles_missing_repository_payload(tmp_path: Path) -> None:
|
||||
payload = {
|
||||
"action": "closed",
|
||||
"pull_request": {
|
||||
"number": 123,
|
||||
"merged": True,
|
||||
},
|
||||
}
|
||||
event_path = _write_json(tmp_path / "event.json", payload)
|
||||
|
||||
context = collect_project_update_context(
|
||||
event_name="pull_request",
|
||||
event_path=event_path,
|
||||
config=ProjectUpdateConfig(project="team-memory"),
|
||||
)
|
||||
|
||||
assert context.eligible is True
|
||||
assert context.repo is None
|
||||
assert context.repo_url is None
|
||||
|
||||
|
||||
def test_collect_rejects_missing_payload_shapes(tmp_path: Path) -> None:
|
||||
pr_context = collect_project_update_context(
|
||||
event_name="pull_request",
|
||||
event_path=_write_json(tmp_path / "pr.json", {"action": "closed"}),
|
||||
config=ProjectUpdateConfig(project="team-memory"),
|
||||
)
|
||||
workflow_context = collect_project_update_context(
|
||||
event_name="workflow_run",
|
||||
event_path=_write_json(tmp_path / "workflow.json", {"action": "completed"}),
|
||||
config=ProjectUpdateConfig(project="team-memory"),
|
||||
)
|
||||
|
||||
assert pr_context.eligible is False
|
||||
assert pr_context.skip_reason == "pull request payload missing"
|
||||
assert workflow_context.eligible is False
|
||||
assert workflow_context.skip_reason == "workflow run payload missing"
|
||||
|
||||
|
||||
def test_collect_ignores_non_closed_pull_request_action(tmp_path: Path) -> None:
|
||||
payload = _pr_payload()
|
||||
payload["action"] = "opened"
|
||||
event_path = _write_json(tmp_path / "event.json", payload)
|
||||
|
||||
context = collect_project_update_context(
|
||||
event_name="pull_request",
|
||||
event_path=event_path,
|
||||
config=ProjectUpdateConfig(project="team-memory"),
|
||||
)
|
||||
|
||||
assert context.eligible is False
|
||||
assert context.skip_reason == "pull request action was not closed"
|
||||
|
||||
|
||||
def test_collect_ignores_closed_unmerged_pull_request(tmp_path: Path) -> None:
|
||||
event_path = _write_json(tmp_path / "event.json", _pr_payload(merged=False))
|
||||
|
||||
context = collect_project_update_context(
|
||||
event_name="pull_request",
|
||||
event_path=event_path,
|
||||
config=ProjectUpdateConfig(project="team-memory"),
|
||||
)
|
||||
|
||||
assert context.eligible is False
|
||||
assert context.skip_reason == "pull request was closed without merging"
|
||||
|
||||
|
||||
def test_collect_successful_configured_production_deploy(tmp_path: Path) -> None:
|
||||
payload = {
|
||||
"action": "completed",
|
||||
"repository": {
|
||||
"full_name": "basicmachines-co/basic-memory-cloud",
|
||||
"html_url": "https://github.com/basicmachines-co/basic-memory-cloud",
|
||||
},
|
||||
"workflow_run": {
|
||||
"id": 98765,
|
||||
"name": "Deploy Production",
|
||||
"conclusion": "success",
|
||||
"html_url": "https://github.com/basicmachines-co/basic-memory-cloud/actions/runs/98765",
|
||||
"head_sha": "def456",
|
||||
"updated_at": "2026-06-04T19:10:00Z",
|
||||
},
|
||||
}
|
||||
event_path = _write_json(tmp_path / "event.json", payload)
|
||||
|
||||
context = collect_project_update_context(
|
||||
event_name="workflow_run",
|
||||
event_path=event_path,
|
||||
config=ProjectUpdateConfig(
|
||||
project="cloud-memory",
|
||||
deploy_workflows=["Deploy Production"],
|
||||
production_environments=["production"],
|
||||
),
|
||||
)
|
||||
|
||||
assert context.eligible is True
|
||||
assert context.source_event == "production_deploy_succeeded"
|
||||
assert context.workflow_run_id == "98765"
|
||||
assert context.environment == "production"
|
||||
assert context.idempotency_key == (
|
||||
"github:basicmachines-co/basic-memory-cloud:production_deploy_succeeded:production:98765"
|
||||
)
|
||||
|
||||
|
||||
def test_collect_ignores_failed_or_unconfigured_deploy(tmp_path: Path) -> None:
|
||||
payload = {
|
||||
"action": "completed",
|
||||
"repository": {"full_name": "basicmachines-co/basic-memory"},
|
||||
"workflow_run": {"id": 1, "name": "Tests", "conclusion": "failure"},
|
||||
}
|
||||
event_path = _write_json(tmp_path / "event.json", payload)
|
||||
|
||||
context = collect_project_update_context(
|
||||
event_name="workflow_run",
|
||||
event_path=event_path,
|
||||
config=ProjectUpdateConfig(project="team-memory"),
|
||||
)
|
||||
|
||||
assert context.eligible is False
|
||||
assert context.skip_reason == "workflow conclusion was failure"
|
||||
|
||||
|
||||
def test_collect_ignores_successful_unconfigured_deploy(tmp_path: Path) -> None:
|
||||
payload = {
|
||||
"action": "completed",
|
||||
"repository": {"full_name": "basicmachines-co/basic-memory"},
|
||||
"workflow_run": {"id": 1, "name": "Tests", "conclusion": "success"},
|
||||
}
|
||||
event_path = _write_json(tmp_path / "event.json", payload)
|
||||
|
||||
context = collect_project_update_context(
|
||||
event_name="workflow_run",
|
||||
event_path=event_path,
|
||||
config=ProjectUpdateConfig(project="team-memory"),
|
||||
)
|
||||
|
||||
assert context.eligible is False
|
||||
assert context.skip_reason == "workflow 'Tests' is not configured for project updates"
|
||||
|
||||
|
||||
def test_collect_ignores_unsupported_event(tmp_path: Path) -> None:
|
||||
event_path = _write_json(tmp_path / "event.json", {})
|
||||
|
||||
context = collect_project_update_context(
|
||||
event_name="push",
|
||||
event_path=event_path,
|
||||
config=ProjectUpdateConfig(project="team-memory"),
|
||||
)
|
||||
|
||||
assert context.eligible is False
|
||||
assert context.skip_reason == "unsupported GitHub event: push"
|
||||
|
||||
|
||||
def test_collect_rejects_missing_or_invalid_event_payload(tmp_path: Path) -> None:
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
collect_project_update_context(
|
||||
event_name="pull_request",
|
||||
event_path=tmp_path / "missing.json",
|
||||
config=ProjectUpdateConfig(project="team-memory"),
|
||||
)
|
||||
|
||||
invalid_json = tmp_path / "invalid.json"
|
||||
invalid_json.write_text("{", encoding="utf-8")
|
||||
with pytest.raises(ValueError, match="not valid JSON"):
|
||||
collect_project_update_context(
|
||||
event_name="pull_request",
|
||||
event_path=invalid_json,
|
||||
config=ProjectUpdateConfig(project="team-memory"),
|
||||
)
|
||||
|
||||
list_json = tmp_path / "list.json"
|
||||
list_json.write_text("[]", encoding="utf-8")
|
||||
with pytest.raises(ValueError, match="JSON object"):
|
||||
collect_project_update_context(
|
||||
event_name="pull_request",
|
||||
event_path=list_json,
|
||||
config=ProjectUpdateConfig(project="team-memory"),
|
||||
)
|
||||
|
||||
|
||||
def test_build_project_update_note_uses_deterministic_identity_fields(tmp_path: Path) -> None:
|
||||
event_path = _write_json(tmp_path / "event.json", _pr_payload())
|
||||
context = collect_project_update_context(
|
||||
event_name="pull_request",
|
||||
event_path=event_path,
|
||||
config=ProjectUpdateConfig(project="team-memory"),
|
||||
)
|
||||
synthesis = AgentSynthesis.model_validate(
|
||||
_synthesis_payload(
|
||||
why_it_matters="Future agents can recover the delivery narrative.",
|
||||
repo="evil/repo",
|
||||
source_event="production_deploy_succeeded",
|
||||
verification=["Unit tests cover event normalization."],
|
||||
)
|
||||
)
|
||||
|
||||
note = build_project_update_note(context=context, synthesis=synthesis)
|
||||
|
||||
assert note.title == "PR #123: Remember project updates"
|
||||
assert note.directory == "project-updates/github/basicmachines-co/basic-memory"
|
||||
assert note.metadata["repo"] == "basicmachines-co/basic-memory"
|
||||
assert note.metadata["source_event"] == "pull_request_merged"
|
||||
assert note.metadata["idempotency_key"] == context.idempotency_key
|
||||
assert "evil/repo" not in note.content
|
||||
|
||||
|
||||
def test_build_project_update_note_renders_story_sections(tmp_path: Path) -> None:
|
||||
event_path = _write_json(tmp_path / "event.json", _pr_payload())
|
||||
context = collect_project_update_context(
|
||||
event_name="pull_request",
|
||||
event_path=event_path,
|
||||
config=ProjectUpdateConfig(project="team-memory"),
|
||||
)
|
||||
synthesis = AgentSynthesis.model_validate(
|
||||
{
|
||||
"summary": "Auto BM now publishes durable project updates.",
|
||||
"story": (
|
||||
"Auto BM needed to preserve the delivery narrative, not just the mechanics. "
|
||||
"The change adds a CI handoff where Codex synthesizes context and bm publishes it."
|
||||
),
|
||||
"problem_addressed": "Project context was lost after meaningful GitHub delivery events.",
|
||||
"solution": "Collect GitHub facts, let Codex synthesize intent, then publish idempotently.",
|
||||
"system_impact": "Merges now leave durable memory for future humans and agents.",
|
||||
"why_it_matters": "Future work can recover why the delivery happened.",
|
||||
"components_changed": [
|
||||
"basic_memory.ci.project_updates",
|
||||
"basic_memory.cli.commands.ci",
|
||||
],
|
||||
"complexity_introduced": ["Adds a CI-only agent synthesis boundary."],
|
||||
"refactors_or_removals": ["Keeps Basic Memory auth out of the agent step."],
|
||||
"verification": ["Unit tests cover collect and publish behavior."],
|
||||
}
|
||||
)
|
||||
|
||||
note = build_project_update_note(context=context, synthesis=synthesis)
|
||||
|
||||
assert "## Story" in note.content
|
||||
assert "## Problem Addressed" in note.content
|
||||
assert "## How The Change Solves It" in note.content
|
||||
assert "## Impact On The System" in note.content
|
||||
assert "## Project Memory" in note.content
|
||||
assert "## Why It Matters" not in note.content
|
||||
assert "## Components Changed" in note.content
|
||||
assert "basic_memory.ci.project_updates" in note.content
|
||||
assert "## Complexity Introduced" in note.content
|
||||
assert "## Refactors Or Removals" in note.content
|
||||
|
||||
|
||||
def test_build_project_update_note_renders_linked_issue_details_as_links() -> None:
|
||||
context = ProjectUpdateContext(
|
||||
eligible=True,
|
||||
source_event="pull_request_merged",
|
||||
repo="basicmachines-co/basic-memory",
|
||||
repo_url="https://github.com/basicmachines-co/basic-memory",
|
||||
source_url="https://github.com/basicmachines-co/basic-memory/pull/123",
|
||||
idempotency_key="github:basicmachines-co/basic-memory:pull_request_merged:123",
|
||||
pr_number=123,
|
||||
title="Remember project updates",
|
||||
linked_issues=["#77", "#88"],
|
||||
linked_issue_details=[
|
||||
project_updates.LinkedIssueDetail(
|
||||
number=77,
|
||||
title="Codex structured output rejects optional schema fields",
|
||||
state="closed",
|
||||
url="https://github.com/basicmachines-co/basic-memory/issues/77",
|
||||
)
|
||||
],
|
||||
)
|
||||
synthesis = AgentSynthesis.model_validate(_synthesis_payload())
|
||||
|
||||
note = build_project_update_note(context=context, synthesis=synthesis)
|
||||
|
||||
assert (
|
||||
"- Linked issue: [#77 Codex structured output rejects optional schema fields "
|
||||
"(closed)](https://github.com/basicmachines-co/basic-memory/issues/77)" in note.content
|
||||
)
|
||||
assert (
|
||||
"- Linked issue: [#88](https://github.com/basicmachines-co/basic-memory/issues/88)"
|
||||
in note.content
|
||||
)
|
||||
assert "- Linked issues: #77, #88" not in note.content
|
||||
|
||||
|
||||
def test_build_project_update_note_for_production_deploy(tmp_path: Path) -> None:
|
||||
payload = {
|
||||
"action": "completed",
|
||||
"repository": {
|
||||
"full_name": "basicmachines-co/basic-memory-cloud",
|
||||
"html_url": "https://github.com/basicmachines-co/basic-memory-cloud",
|
||||
},
|
||||
"workflow_run": {
|
||||
"id": 98765,
|
||||
"name": "Deploy Production",
|
||||
"conclusion": "success",
|
||||
"html_url": "https://github.com/basicmachines-co/basic-memory-cloud/actions/runs/98765",
|
||||
"head_sha": "def456",
|
||||
"updated_at": "2026-06-04T19:10:00Z",
|
||||
},
|
||||
}
|
||||
context = collect_project_update_context(
|
||||
event_name="workflow_run",
|
||||
event_path=_write_json(tmp_path / "event.json", payload),
|
||||
config=ProjectUpdateConfig(
|
||||
project="cloud-memory",
|
||||
deploy_workflows=["Deploy Production"],
|
||||
production_environments=["production"],
|
||||
),
|
||||
)
|
||||
synthesis = AgentSynthesis.model_validate(
|
||||
_synthesis_payload(
|
||||
summary="Production deploy completed.",
|
||||
story=(
|
||||
"A configured production workflow completed successfully. "
|
||||
"The deploy SHA is now recorded as durable project memory."
|
||||
),
|
||||
problem_addressed="Production delivery needed a durable deployment record.",
|
||||
solution="Publish a project update for the successful workflow run.",
|
||||
system_impact="The production deploy is connected to its workflow run and SHA.",
|
||||
why_it_matters="The latest project update reached users.",
|
||||
)
|
||||
)
|
||||
|
||||
note = build_project_update_note(context=context, synthesis=synthesis)
|
||||
|
||||
assert note.title == "Production deploy: 2026-06-04"
|
||||
assert note.metadata["workflow_run_id"] == "98765"
|
||||
assert note.metadata["environment"] == "production"
|
||||
assert "https://github.com/basicmachines-co/basic-memory-cloud/actions/runs/98765" in (
|
||||
note.content
|
||||
)
|
||||
|
||||
|
||||
def test_build_project_update_note_rejects_invalid_context() -> None:
|
||||
synthesis = AgentSynthesis.model_validate(
|
||||
_synthesis_payload(
|
||||
summary="Auto BM records project updates.",
|
||||
why_it_matters="Future agents can recover context.",
|
||||
)
|
||||
)
|
||||
with pytest.raises(ValueError, match="ineligible"):
|
||||
build_project_update_note(
|
||||
context=ProjectUpdateContext(eligible=False, skip_reason="not useful"),
|
||||
synthesis=synthesis,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="deterministic identity"):
|
||||
build_project_update_note(
|
||||
context=ProjectUpdateContext(
|
||||
eligible=True,
|
||||
source_event="pull_request_merged",
|
||||
repo="basicmachines-co/basic-memory",
|
||||
),
|
||||
synthesis=synthesis,
|
||||
)
|
||||
|
||||
|
||||
def test_agent_synthesis_requires_summary_and_why_it_matters() -> None:
|
||||
missing_why = _synthesis_payload()
|
||||
missing_why.pop("why_it_matters")
|
||||
with pytest.raises(ValidationError):
|
||||
AgentSynthesis.model_validate(missing_why)
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
AgentSynthesis.model_validate(_synthesis_payload(summary=" "))
|
||||
|
||||
|
||||
def test_agent_synthesis_requires_delivery_narrative_fields() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
AgentSynthesis.model_validate(
|
||||
{
|
||||
"summary": "Auto BM records project updates.",
|
||||
"why_it_matters": "Future agents can recover context.",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_project_update_config_requires_non_empty_lists() -> None:
|
||||
with pytest.raises(ValueError, match="at least one"):
|
||||
ProjectUpdateConfig(deploy_workflows=[" "])
|
||||
|
||||
|
||||
def test_render_workflow_invokes_codex_read_only_without_basic_memory_secret() -> None:
|
||||
workflow = render_workflow(
|
||||
ProjectUpdateConfig(
|
||||
project="team-memory",
|
||||
deploy_workflows=["Deploy Production"],
|
||||
production_environments=["production"],
|
||||
)
|
||||
)
|
||||
|
||||
assert "openai/codex-action@v1" in workflow
|
||||
assert "sandbox: read-only" in workflow
|
||||
assert "output-schema-file: ${{ runner.temp }}/agent-synthesis.schema.json" in workflow
|
||||
assert "BASIC_MEMORY_CLOUD_API_KEY: ${{ secrets.BASIC_MEMORY_API_KEY }}" in workflow
|
||||
assert "BASIC_MEMORY_CLOUD_HOST: ${{ vars.BASIC_MEMORY_CLOUD_HOST || '' }}" not in workflow
|
||||
assert "BASIC_MEMORY_CI_CLOUD_HOST: ${{ vars.BASIC_MEMORY_CLOUD_HOST }}" in workflow
|
||||
assert 'if [ -n "$BASIC_MEMORY_CI_CLOUD_HOST" ]' in workflow
|
||||
assert "--context .github/basic-memory/project-update-context.json" in workflow
|
||||
assert "GITHUB_TOKEN: ${{ github.token }}" in workflow
|
||||
assert "--cloud \\" in workflow
|
||||
codex_step = workflow.split("- name: Synthesize project update with Codex", 1)[1].split(
|
||||
"- name: Publish project update", 1
|
||||
)[0]
|
||||
assert "BASIC_MEMORY_API_KEY" not in codex_step
|
||||
|
||||
|
||||
def test_render_workflow_outputs_valid_github_actions_yaml() -> None:
|
||||
workflow = render_workflow(ProjectUpdateConfig(project="team-memory"))
|
||||
|
||||
parsed = yaml.safe_load(workflow)
|
||||
|
||||
assert isinstance(parsed, dict)
|
||||
assert parsed["on"]["pull_request"]["types"] == ["closed"]
|
||||
assert parsed["on"]["workflow_run"]["types"] == ["completed"]
|
||||
|
||||
|
||||
def test_render_capture_prompt_uses_workspace_context_path() -> None:
|
||||
prompt = render_capture_prompt()
|
||||
|
||||
assert ".github/basic-memory/project-update-context.json" in prompt
|
||||
assert ".github/basic-memory/SOUL.md" in prompt
|
||||
assert "${{ runner.temp }}" not in prompt
|
||||
assert "Do not write a fill-in-the-blanks note" in prompt
|
||||
assert "Read the PR diff before writing" in prompt
|
||||
assert "problem -> solution -> impact" in prompt
|
||||
assert "It is okay to say when the code is messy" in prompt
|
||||
assert "Ground all judgments" in prompt
|
||||
|
||||
|
||||
def test_render_soul_template_guides_personality_without_overriding_facts() -> None:
|
||||
soul = render_soul_template()
|
||||
|
||||
assert soul.startswith("# Auto BM Soul")
|
||||
assert "It is okay to say when code is messy" in soul
|
||||
assert "Notice good simplifications" in soul
|
||||
assert "Do not invent intent, impact, tests, or drama" in soul
|
||||
assert "Keep personality in service of memory" in soul
|
||||
|
||||
|
||||
def test_render_agent_synthesis_schema_is_ci_guardrail_not_domain_schema() -> None:
|
||||
schema = json.loads(render_agent_synthesis_schema())
|
||||
|
||||
assert schema["title"] == "AgentSynthesis"
|
||||
assert "summary" in schema["required"]
|
||||
assert "story" in schema["required"]
|
||||
assert "problem_addressed" in schema["required"]
|
||||
assert "solution" in schema["required"]
|
||||
assert "system_impact" in schema["required"]
|
||||
assert "components_changed" in schema["required"]
|
||||
assert "why_it_matters" in schema["required"]
|
||||
assert set(schema["required"]) == set(schema["properties"])
|
||||
assert "project_update" not in json.dumps(schema)
|
||||
|
||||
|
||||
def test_schema_seed_specs_are_basic_memory_schema_notes() -> None:
|
||||
specs = schema_seed_specs()
|
||||
|
||||
assert {spec.entity for spec in specs} == {
|
||||
"ProjectUpdate",
|
||||
"GitHubPullRequestUpdate",
|
||||
"GitHubProductionDeployUpdate",
|
||||
}
|
||||
assert all(spec.metadata["type"] == "schema" for spec in specs)
|
||||
assert all(spec.metadata["settings"]["validation"] == "warn" for spec in specs)
|
||||
project_update = next(spec for spec in specs if spec.entity == "ProjectUpdate")
|
||||
assert "story" in project_update.metadata["schema"]
|
||||
assert "problem_addressed" in project_update.metadata["schema"]
|
||||
|
||||
|
||||
def test_parse_github_remote_accepts_https_and_ssh() -> None:
|
||||
assert parse_github_remote("https://github.com/basicmachines-co/basic-memory.git") == (
|
||||
"basicmachines-co",
|
||||
"basic-memory",
|
||||
)
|
||||
assert parse_github_remote("git@github.com:basicmachines-co/basic-memory.git") == (
|
||||
"basicmachines-co",
|
||||
"basic-memory",
|
||||
)
|
||||
|
||||
|
||||
def test_parse_github_remote_rejects_non_github_remote() -> None:
|
||||
with pytest.raises(ValueError, match="GitHub remote"):
|
||||
parse_github_remote("https://example.com/basicmachines-co/basic-memory.git")
|
||||
|
||||
|
||||
def test_detect_github_repo_requires_origin_remote(tmp_path: Path) -> None:
|
||||
with pytest.raises(ValueError, match="No remote.origin.url"):
|
||||
detect_github_repo(tmp_path)
|
||||
|
||||
|
||||
def test_load_project_update_config_handles_missing_and_invalid_yaml(tmp_path: Path) -> None:
|
||||
assert load_project_update_config(tmp_path / "missing.yml") == ProjectUpdateConfig()
|
||||
|
||||
invalid = tmp_path / "invalid.yml"
|
||||
invalid.write_text("- not\n- an\n- object\n", encoding="utf-8")
|
||||
with pytest.raises(ValueError, match="YAML object"):
|
||||
load_project_update_config(invalid)
|
||||
|
||||
|
||||
def test_private_note_helpers_reject_invalid_repo_shape() -> None:
|
||||
context = ProjectUpdateContext(eligible=True, repo="not-owner-repo")
|
||||
with pytest.raises(ValueError, match="owner/repo"):
|
||||
project_updates._note_directory(context, ProjectUpdateConfig(project="team-memory"))
|
||||
|
||||
missing_repo = ProjectUpdateContext(eligible=True)
|
||||
with pytest.raises(ValueError, match="missing repo"):
|
||||
project_updates._note_directory(missing_repo, ProjectUpdateConfig(project="team-memory"))
|
||||
|
||||
|
||||
def test_private_note_title_uses_generic_fallback_for_unknown_event() -> None:
|
||||
context = ProjectUpdateContext(eligible=True, source_event="unknown")
|
||||
|
||||
assert project_updates._note_title(context) == "Project update"
|
||||
@@ -0,0 +1,524 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from basic_memory.cli.commands.ci import seed_project_update_schemas
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from basic_memory.cli.main import app as cli_app
|
||||
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def _init_github_repo(path: Path) -> None:
|
||||
subprocess.run(["git", "init"], cwd=path, check=True, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "remote", "add", "origin", "https://github.com/basicmachines-co/demo.git"],
|
||||
cwd=path,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
|
||||
def _write_pr_event(path: Path) -> Path:
|
||||
payload = {
|
||||
"action": "closed",
|
||||
"repository": {
|
||||
"full_name": "basicmachines-co/demo",
|
||||
"html_url": "https://github.com/basicmachines-co/demo",
|
||||
},
|
||||
"pull_request": {
|
||||
"number": 7,
|
||||
"title": "Add project update capture",
|
||||
"body": "Closes #4",
|
||||
"html_url": "https://github.com/basicmachines-co/demo/pull/7",
|
||||
"merged": True,
|
||||
"merged_at": "2026-06-04T18:42:00Z",
|
||||
"merge_commit_sha": "abc123",
|
||||
"labels": [{"name": "ci"}],
|
||||
},
|
||||
}
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def _synthesis_payload(**overrides: object) -> dict[str, object]:
|
||||
payload: dict[str, object] = {
|
||||
"summary": "Auto BM records project updates.",
|
||||
"story": (
|
||||
"GitHub delivery moments were not leaving durable project memory. "
|
||||
"Auto BM collects source facts, asks the agent for the delivery story, "
|
||||
"and publishes an idempotent note."
|
||||
),
|
||||
"problem_addressed": "GitHub delivery context was lost after merge.",
|
||||
"solution": "Publish an idempotent Basic Memory project update from CI.",
|
||||
"system_impact": "Future agents can recover the project delivery narrative.",
|
||||
"why_it_matters": "Future agents can recover project context.",
|
||||
"components_changed": ["basic_memory.ci.project_updates"],
|
||||
"complexity_introduced": [],
|
||||
"refactors_or_removals": [],
|
||||
"user_facing_changes": [],
|
||||
"internal_changes": [],
|
||||
"verification": [],
|
||||
"follow_ups": [],
|
||||
"decision_candidates": [],
|
||||
"task_candidates": [],
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.ci.seed_project_update_schemas", new_callable=AsyncMock)
|
||||
def test_setup_writes_workflow_config_and_prompt(
|
||||
mock_seed: AsyncMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_init_github_repo(tmp_path)
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
[
|
||||
"ci",
|
||||
"setup",
|
||||
"--project",
|
||||
"team-memory",
|
||||
"--repo-root",
|
||||
str(tmp_path),
|
||||
"--yes",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert (tmp_path / ".github/workflows/basic-memory.yml").exists()
|
||||
assert (tmp_path / ".github/basic-memory/config.yml").exists()
|
||||
assert (tmp_path / ".github/basic-memory/memory-ci-capture.md").exists()
|
||||
assert (tmp_path / ".github/basic-memory/SOUL.md").exists()
|
||||
assert "Keep personality in service of memory" in (
|
||||
tmp_path / ".github/basic-memory/SOUL.md"
|
||||
).read_text(encoding="utf-8")
|
||||
assert "OPENAI_API_KEY" in result.output
|
||||
assert "BASIC_MEMORY_API_KEY" in result.output
|
||||
mock_seed.assert_awaited_once_with(
|
||||
project="team-memory",
|
||||
project_id=None,
|
||||
workspace=None,
|
||||
refresh=False,
|
||||
)
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.ci.seed_project_update_schemas", new_callable=AsyncMock)
|
||||
def test_setup_refreshes_or_updates_existing_schema_notes_when_requested(
|
||||
mock_seed: AsyncMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
for flag in ("--refresh", "--update-schemas"):
|
||||
repo_path = tmp_path / flag.removeprefix("--")
|
||||
repo_path.mkdir()
|
||||
_init_github_repo(repo_path)
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
[
|
||||
"ci",
|
||||
"setup",
|
||||
"--project",
|
||||
"team-memory",
|
||||
"--repo-root",
|
||||
str(repo_path),
|
||||
flag,
|
||||
"--yes",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
assert mock_seed.await_count == 2
|
||||
for seed_call in mock_seed.await_args_list:
|
||||
assert seed_call.kwargs == {
|
||||
"project": "team-memory",
|
||||
"project_id": None,
|
||||
"workspace": None,
|
||||
"refresh": True,
|
||||
}
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.ci.seed_project_update_schemas", new_callable=AsyncMock)
|
||||
def test_setup_refreshes_schema_notes_when_generated_files_already_exist(
|
||||
mock_seed: AsyncMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_init_github_repo(tmp_path)
|
||||
workflow_path = tmp_path / ".github/workflows/basic-memory.yml"
|
||||
config_path = tmp_path / ".github/basic-memory/config.yml"
|
||||
prompt_path = tmp_path / ".github/basic-memory/memory-ci-capture.md"
|
||||
soul_path = tmp_path / ".github/basic-memory/SOUL.md"
|
||||
workflow_path.parent.mkdir(parents=True)
|
||||
config_path.parent.mkdir(parents=True)
|
||||
workflow_path.write_text("custom workflow\n", encoding="utf-8")
|
||||
config_path.write_text("project: existing\n", encoding="utf-8")
|
||||
prompt_path.write_text("custom prompt\n", encoding="utf-8")
|
||||
soul_path.write_text("custom soul\n", encoding="utf-8")
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
[
|
||||
"ci",
|
||||
"setup",
|
||||
"--project",
|
||||
"team-memory",
|
||||
"--repo-root",
|
||||
str(tmp_path),
|
||||
"--refresh-schemas",
|
||||
"--yes",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "generated files unchanged" in result.output
|
||||
assert workflow_path.read_text(encoding="utf-8") == "custom workflow\n"
|
||||
assert config_path.read_text(encoding="utf-8") == "project: existing\n"
|
||||
assert prompt_path.read_text(encoding="utf-8") == "custom prompt\n"
|
||||
assert soul_path.read_text(encoding="utf-8") == "custom soul\n"
|
||||
mock_seed.assert_awaited_once_with(
|
||||
project="team-memory",
|
||||
project_id=None,
|
||||
workspace=None,
|
||||
refresh=True,
|
||||
)
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.ci.seed_project_update_schemas", new_callable=AsyncMock)
|
||||
def test_setup_does_not_partially_write_generated_files_when_target_exists(
|
||||
mock_seed: AsyncMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_init_github_repo(tmp_path)
|
||||
config_path = tmp_path / ".github/basic-memory/config.yml"
|
||||
config_path.parent.mkdir(parents=True)
|
||||
config_path.write_text("project: existing\n", encoding="utf-8")
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
[
|
||||
"ci",
|
||||
"setup",
|
||||
"--project",
|
||||
"team-memory",
|
||||
"--repo-root",
|
||||
str(tmp_path),
|
||||
"--yes",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "pass --force to overwrite" in result.output
|
||||
assert not (tmp_path / ".github/workflows/basic-memory.yml").exists()
|
||||
assert not (tmp_path / ".github/basic-memory/memory-ci-capture.md").exists()
|
||||
mock_seed.assert_not_awaited()
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.ci.mcp_search_notes", new_callable=AsyncMock)
|
||||
@patch("basic_memory.cli.commands.ci.mcp_write_note", new_callable=AsyncMock)
|
||||
async def test_seed_project_update_schemas_skips_existing_notes_by_default(
|
||||
mock_write: AsyncMock,
|
||||
mock_search: AsyncMock,
|
||||
) -> None:
|
||||
mock_search.return_value = {
|
||||
"results": [{"title": "ProjectUpdate", "file_path": "schemas/ProjectUpdate.md"}]
|
||||
}
|
||||
|
||||
seeded = await seed_project_update_schemas(project="team-memory")
|
||||
|
||||
assert seeded == []
|
||||
mock_write.assert_not_awaited()
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.ci.mcp_search_notes", new_callable=AsyncMock)
|
||||
@patch("basic_memory.cli.commands.ci.mcp_write_note", new_callable=AsyncMock)
|
||||
async def test_seed_project_update_schemas_refreshes_existing_notes(
|
||||
mock_write: AsyncMock,
|
||||
mock_search: AsyncMock,
|
||||
) -> None:
|
||||
mock_search.return_value = {
|
||||
"results": [{"title": "Custom ProjectUpdate", "file_path": "custom/schemas/update.md"}]
|
||||
}
|
||||
mock_write.return_value = {"action": "updated"}
|
||||
|
||||
seeded = await seed_project_update_schemas(project="team-memory", refresh=True)
|
||||
|
||||
assert seeded == [
|
||||
"ProjectUpdate",
|
||||
"GitHubPullRequestUpdate",
|
||||
"GitHubProductionDeployUpdate",
|
||||
]
|
||||
assert mock_write.await_count == 3
|
||||
first_call = mock_write.await_args_list[0].kwargs
|
||||
assert first_call["title"] == "Custom ProjectUpdate"
|
||||
assert first_call["directory"] == "custom/schemas"
|
||||
assert first_call["overwrite"] is True
|
||||
|
||||
|
||||
def test_setup_rejects_non_github_repo(tmp_path: Path) -> None:
|
||||
subprocess.run(["git", "init"], cwd=tmp_path, check=True, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "remote", "add", "origin", "https://example.com/basicmachines-co/demo.git"],
|
||||
cwd=tmp_path,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
[
|
||||
"ci",
|
||||
"setup",
|
||||
"--project",
|
||||
"team-memory",
|
||||
"--repo-root",
|
||||
str(tmp_path),
|
||||
"--yes",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "GitHub remote" in result.output
|
||||
|
||||
|
||||
def test_collect_command_writes_context_and_github_outputs(tmp_path: Path) -> None:
|
||||
event_path = _write_pr_event(tmp_path / "event.json")
|
||||
config_path = tmp_path / "config.yml"
|
||||
config_path.write_text("project: team-memory\nworkspace: product\n", encoding="utf-8")
|
||||
output_path = tmp_path / "context.json"
|
||||
github_output = tmp_path / "github-output.txt"
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
[
|
||||
"ci",
|
||||
"collect",
|
||||
"--event-name",
|
||||
"pull_request",
|
||||
"--event-path",
|
||||
str(event_path),
|
||||
"--config",
|
||||
str(config_path),
|
||||
"--output",
|
||||
str(output_path),
|
||||
],
|
||||
env={"GITHUB_OUTPUT": str(github_output)},
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
context = json.loads(output_path.read_text(encoding="utf-8"))
|
||||
assert context["eligible"] is True
|
||||
assert context["source_event"] == "pull_request_merged"
|
||||
assert "eligible=true" in github_output.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_agent_schema_command_writes_schema(tmp_path: Path) -> None:
|
||||
output_path = tmp_path / "agent-synthesis.schema.json"
|
||||
|
||||
result = runner.invoke(cli_app, ["ci", "agent-schema", "--output", str(output_path)])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
schema = json.loads(output_path.read_text(encoding="utf-8"))
|
||||
assert schema["title"] == "AgentSynthesis"
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.ci.mcp_search_notes", new_callable=AsyncMock)
|
||||
@patch("basic_memory.cli.commands.ci.mcp_write_note", new_callable=AsyncMock)
|
||||
def test_publish_command_upserts_project_update_note(
|
||||
mock_write: AsyncMock,
|
||||
mock_search: AsyncMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
mock_search.return_value = {"results": []}
|
||||
mock_write.return_value = {
|
||||
"title": "PR #7: Add project update capture",
|
||||
"permalink": "project-updates/github/basicmachines-co/demo/pr-7-add-project-update-capture",
|
||||
"action": "created",
|
||||
}
|
||||
event_path = _write_pr_event(tmp_path / "event.json")
|
||||
context_path = tmp_path / "context.json"
|
||||
config_path = tmp_path / "config.yml"
|
||||
synthesis_path = tmp_path / "synthesis.json"
|
||||
config_path.write_text("project: team-memory\nworkspace: product\n", encoding="utf-8")
|
||||
|
||||
collect_result = runner.invoke(
|
||||
cli_app,
|
||||
[
|
||||
"ci",
|
||||
"collect",
|
||||
"--event-name",
|
||||
"pull_request",
|
||||
"--event-path",
|
||||
str(event_path),
|
||||
"--config",
|
||||
str(config_path),
|
||||
"--output",
|
||||
str(context_path),
|
||||
],
|
||||
)
|
||||
assert collect_result.exit_code == 0, collect_result.output
|
||||
synthesis_path.write_text(
|
||||
json.dumps(
|
||||
_synthesis_payload(
|
||||
repo="evil/repo",
|
||||
)
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
[
|
||||
"ci",
|
||||
"publish",
|
||||
"--config",
|
||||
str(config_path),
|
||||
"--context",
|
||||
str(context_path),
|
||||
"--synthesis",
|
||||
str(synthesis_path),
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
mock_search.assert_awaited_once()
|
||||
mock_write.assert_awaited_once()
|
||||
assert mock_search.call_args.kwargs["project"] == "product/team-memory"
|
||||
kwargs = mock_write.call_args.kwargs
|
||||
assert kwargs["project"] == "product/team-memory"
|
||||
assert kwargs["note_type"] == "project_update"
|
||||
assert kwargs["overwrite"] is True
|
||||
assert kwargs["metadata"]["repo"] == "basicmachines-co/demo"
|
||||
assert kwargs["metadata"]["source_event"] == "pull_request_merged"
|
||||
assert (
|
||||
kwargs["metadata"]["idempotency_key"]
|
||||
== "github:basicmachines-co/demo:pull_request_merged:7"
|
||||
)
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.ci.mcp_search_notes", new_callable=AsyncMock)
|
||||
@patch("basic_memory.cli.commands.ci.mcp_write_note", new_callable=AsyncMock)
|
||||
def test_publish_command_preserves_existing_note_path_for_idempotency_match(
|
||||
mock_write: AsyncMock,
|
||||
mock_search: AsyncMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
mock_search.return_value = {
|
||||
"results": [
|
||||
{
|
||||
"title": "Existing PR update",
|
||||
"file_path": "custom/project-updates/existing-pr-update.md",
|
||||
}
|
||||
]
|
||||
}
|
||||
mock_write.return_value = {"title": "Existing PR update", "action": "updated"}
|
||||
event_path = _write_pr_event(tmp_path / "event.json")
|
||||
context_path = tmp_path / "context.json"
|
||||
config_path = tmp_path / "config.yml"
|
||||
synthesis_path = tmp_path / "synthesis.json"
|
||||
config_path.write_text("project: team-memory\n", encoding="utf-8")
|
||||
|
||||
collect_result = runner.invoke(
|
||||
cli_app,
|
||||
[
|
||||
"ci",
|
||||
"collect",
|
||||
"--event-name",
|
||||
"pull_request",
|
||||
"--event-path",
|
||||
str(event_path),
|
||||
"--config",
|
||||
str(config_path),
|
||||
"--output",
|
||||
str(context_path),
|
||||
],
|
||||
)
|
||||
assert collect_result.exit_code == 0, collect_result.output
|
||||
synthesis_path.write_text(
|
||||
json.dumps(_synthesis_payload()),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
[
|
||||
"ci",
|
||||
"publish",
|
||||
"--config",
|
||||
str(config_path),
|
||||
"--context",
|
||||
str(context_path),
|
||||
"--synthesis",
|
||||
str(synthesis_path),
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
kwargs = mock_write.call_args.kwargs
|
||||
assert kwargs["title"] == "Existing PR update"
|
||||
assert kwargs["directory"] == "custom/project-updates"
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.ci.mcp_search_notes", new_callable=AsyncMock)
|
||||
@patch("basic_memory.cli.commands.ci.mcp_write_note", new_callable=AsyncMock)
|
||||
def test_publish_command_uses_project_id_without_workspace_qualifying_project(
|
||||
mock_write: AsyncMock,
|
||||
mock_search: AsyncMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
mock_search.return_value = {"results": []}
|
||||
mock_write.return_value = {"title": "Project update", "action": "created"}
|
||||
event_path = _write_pr_event(tmp_path / "event.json")
|
||||
context_path = tmp_path / "context.json"
|
||||
config_path = tmp_path / "config.yml"
|
||||
synthesis_path = tmp_path / "synthesis.json"
|
||||
config_path.write_text(
|
||||
"project: team-memory\nproject_id: project-uuid\nworkspace: product\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
collect_result = runner.invoke(
|
||||
cli_app,
|
||||
[
|
||||
"ci",
|
||||
"collect",
|
||||
"--event-name",
|
||||
"pull_request",
|
||||
"--event-path",
|
||||
str(event_path),
|
||||
"--config",
|
||||
str(config_path),
|
||||
"--output",
|
||||
str(context_path),
|
||||
],
|
||||
)
|
||||
assert collect_result.exit_code == 0, collect_result.output
|
||||
synthesis_path.write_text(
|
||||
json.dumps(_synthesis_payload()),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
[
|
||||
"ci",
|
||||
"publish",
|
||||
"--config",
|
||||
str(config_path),
|
||||
"--context",
|
||||
str(context_path),
|
||||
"--synthesis",
|
||||
str(synthesis_path),
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert mock_search.call_args.kwargs["project"] == "team-memory"
|
||||
assert mock_search.call_args.kwargs["project_id"] == "project-uuid"
|
||||
assert mock_write.call_args.kwargs["project"] == "team-memory"
|
||||
assert mock_write.call_args.kwargs["project_id"] == "project-uuid"
|
||||
@@ -39,21 +39,6 @@ EDIT_NOTE_RESULT = {
|
||||
"operation": "append",
|
||||
}
|
||||
|
||||
DELETE_NOTE_RESULT = {
|
||||
"deleted": True,
|
||||
"is_directory": False,
|
||||
"title": "Test Note",
|
||||
"permalink": "notes/test-note",
|
||||
"file_path": "notes/Test Note.md",
|
||||
}
|
||||
|
||||
DELETE_DIRECTORY_RESULT = {
|
||||
"deleted": True,
|
||||
"is_directory": True,
|
||||
"directory": "notes/archive",
|
||||
"deleted_count": 3,
|
||||
}
|
||||
|
||||
BUILD_CONTEXT_RESULT = {
|
||||
"results": [],
|
||||
"metadata": {"uri": "test/topic", "depth": 1},
|
||||
@@ -230,138 +215,6 @@ def test_read_note_include_frontmatter(mock_mcp_read):
|
||||
assert mock_mcp_read.call_args.kwargs["include_frontmatter"] is True
|
||||
|
||||
|
||||
# --- delete-note ---
|
||||
|
||||
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool.mcp_delete_note",
|
||||
new_callable=AsyncMock,
|
||||
return_value=DELETE_NOTE_RESULT,
|
||||
)
|
||||
def test_delete_note_json_output(mock_mcp_delete: AsyncMock) -> None:
|
||||
"""delete-note outputs valid JSON from MCP tool."""
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
["tool", "delete-note", "test-note"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
||||
data = json.loads(result.output)
|
||||
assert data["deleted"] is True
|
||||
assert data["permalink"] == "notes/test-note"
|
||||
mock_mcp_delete.assert_called_once()
|
||||
assert mock_mcp_delete.call_args.kwargs["output_format"] == "json"
|
||||
assert mock_mcp_delete.call_args.kwargs["is_directory"] is False
|
||||
|
||||
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool.mcp_delete_note",
|
||||
new_callable=AsyncMock,
|
||||
return_value=DELETE_DIRECTORY_RESULT,
|
||||
)
|
||||
def test_delete_note_directory_flag(mock_mcp_delete: AsyncMock) -> None:
|
||||
"""delete-note --is-directory passes directory mode to MCP."""
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
["tool", "delete-note", "notes/archive", "--is-directory"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
||||
data = json.loads(result.output)
|
||||
assert data["is_directory"] is True
|
||||
assert mock_mcp_delete.call_args.kwargs["is_directory"] is True
|
||||
|
||||
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool.mcp_delete_note",
|
||||
new_callable=AsyncMock,
|
||||
return_value={
|
||||
"deleted": False,
|
||||
"is_directory": False,
|
||||
"identifier": "missing-note",
|
||||
"error": None,
|
||||
},
|
||||
)
|
||||
def test_delete_note_not_found_outputs_json(mock_mcp_delete: AsyncMock) -> None:
|
||||
"""delete-note treats not-found JSON without an error as a successful command."""
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
["tool", "delete-note", "missing-note"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
||||
data = json.loads(result.output)
|
||||
assert data["deleted"] is False
|
||||
assert data["identifier"] == "missing-note"
|
||||
assert mock_mcp_delete.call_args.kwargs["output_format"] == "json"
|
||||
|
||||
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool.mcp_delete_note",
|
||||
new_callable=AsyncMock,
|
||||
return_value={
|
||||
"deleted": False,
|
||||
"is_directory": False,
|
||||
"identifier": "test-note",
|
||||
"error": "Delete failed",
|
||||
},
|
||||
)
|
||||
def test_delete_note_error_response(mock_mcp_delete: AsyncMock) -> None:
|
||||
"""delete-note exits with code 1 when MCP tool returns an error field."""
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
["tool", "delete-note", "test-note"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Error: Delete failed" in result.output
|
||||
assert mock_mcp_delete.call_args.kwargs["output_format"] == "json"
|
||||
|
||||
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool.mcp_delete_note",
|
||||
new_callable=AsyncMock,
|
||||
return_value={
|
||||
"deleted": False,
|
||||
"is_directory": True,
|
||||
"identifier": "notes/archive",
|
||||
"total_files": 3,
|
||||
"successful_deletes": 2,
|
||||
"failed_deletes": 1,
|
||||
"errors": [{"path": "notes/archive/locked.md", "error": "permission denied"}],
|
||||
},
|
||||
)
|
||||
def test_delete_note_directory_partial_failure_exits_nonzero(
|
||||
mock_mcp_delete: AsyncMock,
|
||||
) -> None:
|
||||
"""delete-note --is-directory exits 1 when any directory file remains undeleted."""
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
["tool", "delete-note", "notes/archive", "--is-directory"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Error: Directory delete incomplete: 1 file(s) failed" in result.output
|
||||
assert mock_mcp_delete.call_args.kwargs["output_format"] == "json"
|
||||
|
||||
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool.mcp_delete_note",
|
||||
new_callable=AsyncMock,
|
||||
return_value=DELETE_NOTE_RESULT,
|
||||
)
|
||||
def test_delete_note_project_id_passthrough(mock_mcp_delete: AsyncMock) -> None:
|
||||
"""--project-id forwards to the MCP tool's project_id parameter."""
|
||||
uuid = "11111111-1111-1111-1111-111111111111"
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
["tool", "delete-note", "test-note", "--project-id", uuid],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
||||
assert mock_mcp_delete.call_args.kwargs["project_id"] == uuid
|
||||
|
||||
|
||||
# --- edit-note ---
|
||||
|
||||
|
||||
|
||||
@@ -66,9 +66,13 @@ def test_workspace_list_prints_available_workspaces(runner, monkeypatch):
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Available Workspaces" in result.stdout
|
||||
assert "Slug" in result.stdout
|
||||
assert "Workspace ID" in result.stdout
|
||||
assert "Personal" in result.stdout
|
||||
assert "Team" in result.stdout
|
||||
# Tenant ID may be truncated by Rich table rendering
|
||||
assert "personal" in result.stdout
|
||||
assert "team" in result.stdout
|
||||
# Workspace ID may be truncated by Rich table rendering
|
||||
assert "11111111" in result.stdout
|
||||
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ from basic_memory.mcp.tools import (
|
||||
recent_activity,
|
||||
write_note,
|
||||
)
|
||||
from basic_memory.schemas.response import DirectoryDeleteError, DirectoryDeleteResult
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -327,40 +326,6 @@ async def test_delete_directory_json_mode_returns_structured_error_on_failure(
|
||||
assert "simulated directory delete failure" in json_delete["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_directory_json_mode_reports_partial_delete_failure(
|
||||
app, test_project, monkeypatch
|
||||
):
|
||||
async def mock_delete_directory(self, directory: str):
|
||||
return DirectoryDeleteResult(
|
||||
total_files=2,
|
||||
successful_deletes=1,
|
||||
failed_deletes=1,
|
||||
deleted_files=["mode-tests/deleted.md"],
|
||||
errors=[
|
||||
DirectoryDeleteError(
|
||||
path="mode-tests/locked.md",
|
||||
error="permission denied",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(KnowledgeClient, "delete_directory", mock_delete_directory)
|
||||
|
||||
json_delete = await delete_note(
|
||||
identifier="mode-tests",
|
||||
is_directory=True,
|
||||
project=test_project.name,
|
||||
output_format="json",
|
||||
)
|
||||
assert isinstance(json_delete, dict)
|
||||
assert json_delete["deleted"] is False
|
||||
assert json_delete["failed_deletes"] == 1
|
||||
assert json_delete["deleted_files"] == ["mode-tests/deleted.md"]
|
||||
assert json_delete["errors"] == [{"path": "mode-tests/locked.md", "error": "permission denied"}]
|
||||
assert "Directory delete incomplete" in json_delete["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_note_text_and_json_modes(app, test_project):
|
||||
await write_note(
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_codex_plugin_mcp_config_is_tracked_and_not_ignored() -> None:
|
||||
repo_root = Path(__file__).resolve().parents[1]
|
||||
rel_path = "plugins/codex/.mcp.json"
|
||||
|
||||
ignored = subprocess.run(
|
||||
["git", "check-ignore", "--quiet", rel_path],
|
||||
cwd=repo_root,
|
||||
check=False,
|
||||
)
|
||||
assert ignored.returncode == 1
|
||||
|
||||
tracked = subprocess.run(
|
||||
["git", "ls-files", "--error-unmatch", rel_path],
|
||||
cwd=repo_root,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert tracked.returncode == 0, tracked.stderr
|
||||
|
||||
|
||||
def test_codex_plugin_hooks_use_clear_portable_runtime_patterns() -> None:
|
||||
repo_root = Path(__file__).resolve().parents[1]
|
||||
pre_compact_sh = (repo_root / "plugins/codex/hooks/pre-compact.sh").read_text(encoding="utf-8")
|
||||
pre_compact_py = (repo_root / "plugins/codex/hooks/pre-compact.py").read_text(encoding="utf-8")
|
||||
session_start_sh = (repo_root / "plugins/codex/hooks/session-start.sh").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
session_start_py = (repo_root / "plugins/codex/hooks/session-start.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
assert "python3 <<'PY'" not in pre_compact_sh
|
||||
assert "python3 <<'PY'" not in session_start_sh
|
||||
assert 'uv run --script "$script_dir/pre-compact.py"' in pre_compact_sh
|
||||
assert 'uv run --script "$script_dir/session-start.py"' in session_start_sh
|
||||
assert pre_compact_py.startswith("#!/usr/bin/env -S uv run --script\n")
|
||||
assert session_start_py.startswith("#!/usr/bin/env -S uv run --script\n")
|
||||
assert "from datetime import datetime, timezone" in pre_compact_py
|
||||
assert "datetime.now(timezone.utc)" in pre_compact_py
|
||||
assert 'now.isoformat(timespec="seconds")' in pre_compact_py
|
||||
assert "if r not in codex_rows" not in session_start_py
|
||||
|
||||
|
||||
def test_codex_plugin_docs_explain_global_install_and_repo_mapping() -> None:
|
||||
repo_root = Path(__file__).resolve().parents[1]
|
||||
readme = (repo_root / "plugins/codex/README.md").read_text(encoding="utf-8")
|
||||
|
||||
assert "## Install" in readme
|
||||
assert 'codex plugin marketplace add "$(git rev-parse --show-toplevel)"' in readme
|
||||
assert "codex plugin add codex@basic-memory-local" in readme
|
||||
assert "Plugin installation is user-level in Codex" in readme
|
||||
assert "Each repository still needs its own `.codex/basic-memory.json`" in readme
|
||||
@@ -0,0 +1,18 @@
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def _semantic_pr_action_inputs() -> dict:
|
||||
workflow = yaml.safe_load(Path(".github/workflows/pr-title.yml").read_text(encoding="utf-8"))
|
||||
steps = workflow["jobs"]["main"]["steps"]
|
||||
action_step = next(
|
||||
step for step in steps if step.get("uses") == "amannn/action-semantic-pull-request@v6"
|
||||
)
|
||||
return action_step["with"]
|
||||
|
||||
|
||||
def test_pr_title_workflow_allows_ci_scope() -> None:
|
||||
scopes = _semantic_pr_action_inputs()["scopes"].split()
|
||||
|
||||
assert "ci" in scopes
|
||||