mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
Compare commits
297 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ae8115f39 | |||
| 86d044818c | |||
| c6c0c011af | |||
| 9fa7cb9155 | |||
| fc2ee07076 | |||
| 61891f2e33 | |||
| 5f59ca49a2 | |||
| 2ba1ca8722 | |||
| 4b6de80faa | |||
| 1eb4cd154f | |||
| bfee8958ad | |||
| 63f5c62dfd | |||
| 451f27cfd9 | |||
| df5f0f4c04 | |||
| 731e7baac1 | |||
| 877e52fd73 | |||
| fc8ac86fa1 | |||
| df3eb3208c | |||
| 2886ab64e1 | |||
| 36c245f0d2 | |||
| 2bd52676d9 | |||
| 215a2d9f99 | |||
| f485c1ef3f | |||
| 3ff40d51fc | |||
| 82f6d15946 | |||
| 4e7a217fa7 | |||
| 354e1642b2 | |||
| b1bd6d57e8 | |||
| 5365f971ef | |||
| 4ea03d2bf1 | |||
| f652a09664 | |||
| d8901efb46 | |||
| c717d9997e | |||
| f865d39d3d | |||
| 6a667dd3f8 | |||
| 8d21a38588 | |||
| 597b12fcd1 | |||
| c86f4402f6 | |||
| f07643d3a8 | |||
| 8bf7bdbc0d | |||
| d0ae373f45 | |||
| 1acec0a69d | |||
| a2276a8a04 | |||
| ff5d872a8c | |||
| 96ee4eafd2 | |||
| b109b7337f | |||
| 36b51b676e | |||
| 9af320187c | |||
| 5a34a420c9 | |||
| a7e2368f9e | |||
| c755127317 | |||
| 94c04ee456 | |||
| d4ed02ba74 | |||
| 40ed7129c8 | |||
| c4ef7abff5 | |||
| c75f45f3cf | |||
| 5ae8a733ea | |||
| b94ef01f82 | |||
| 12af7930de | |||
| 60ec6728de | |||
| a84e77f4af | |||
| 6910620968 | |||
| 214a54d740 | |||
| 5a90c7cedf | |||
| 9e3fe26a83 | |||
| 47ee982041 | |||
| 4d22c398c6 | |||
| 8ac2d975f9 | |||
| 34830bfad7 | |||
| f5e0c42047 | |||
| 3f98da8c67 | |||
| 14ff77d1c2 | |||
| 4e4da6128d | |||
| 3bed6d8890 | |||
| 4cba7ba01c | |||
| 8eeec64e28 | |||
| c6fa185bf3 | |||
| 415c2b3d6e | |||
| 4aa0cbdd62 | |||
| 55f314237d | |||
| 9862ef5411 | |||
| df5e8d805f | |||
| 831dc1ecdc | |||
| 26381aeed1 | |||
| 7918e5c6bf | |||
| f312341020 | |||
| e871298e95 | |||
| 177ae21ba7 | |||
| 3415fd1014 | |||
| b08659e228 | |||
| 09a4b09436 | |||
| a661e924df | |||
| 05adda1502 | |||
| 0a72d81bb3 | |||
| 5ccf433cad | |||
| b4bf14ebf7 | |||
| 0b335476d6 | |||
| 2fccc74a20 | |||
| c4956cac16 | |||
| 128c2da40c | |||
| 2bfb9c76df | |||
| 3d927b848f | |||
| 953fe20aef | |||
| a4282d9f2f | |||
| 799dd6c629 | |||
| 26e74ea118 | |||
| ee1558ea68 | |||
| 4d62b623db | |||
| 2fe4488eda | |||
| f3e46d7984 | |||
| 56d6f1b4a5 | |||
| 1b39062ecd | |||
| c4cf0aff1e | |||
| 1c343bed66 | |||
| c50d97e548 | |||
| e2e65575d6 | |||
| bf9a6b4a75 | |||
| 474100efef | |||
| b3d5448355 | |||
| 4e53bb83fd | |||
| 8f2b25f0e0 | |||
| 052545b661 | |||
| abd4a5a6da | |||
| a872947e03 | |||
| 093c94fea5 | |||
| cc104f761f | |||
| 7945c1e2f7 | |||
| d7f3f6a96f | |||
| 540da418b3 | |||
| 3e40cb9657 | |||
| 8c81d3ce17 | |||
| b35d594ef0 | |||
| e982900084 | |||
| b3403e96b3 | |||
| fe04a0b2a2 | |||
| 86ad639890 | |||
| 88c8f18200 | |||
| 41a16b93cb | |||
| 367fcaac50 | |||
| 69808b23ca | |||
| 6f207c20c0 | |||
| cff31c5797 | |||
| 2d1ccfa36c | |||
| a2e0f935d6 | |||
| e6b98a15c7 | |||
| 733c4f7514 | |||
| cfa70004be | |||
| 7696fca826 | |||
| 98a2a3cbaf | |||
| 01cbad1dbe | |||
| a4e0422926 | |||
| 552a835669 | |||
| 888e3c2909 | |||
| d1320f671e | |||
| 94bdfe77e4 | |||
| 4791e19685 | |||
| 36848410a1 | |||
| a77b51a28e | |||
| 1a6a65571e | |||
| c8b00449d2 | |||
| 013864ebf0 | |||
| dd91b49054 | |||
| 7c96a0777d | |||
| 148e07c580 | |||
| 21334cc29b | |||
| db60942267 | |||
| 7bfac158df | |||
| 87616924ff | |||
| 5cb0502ed2 | |||
| a94a717b1b | |||
| 6e4bb72f10 | |||
| 11b0e31e24 | |||
| a5c9e77f16 | |||
| 30a89357cb | |||
| 222ec5d3b6 | |||
| d42aec7ea9 | |||
| 9809b469c6 | |||
| 76ac880f2d | |||
| ad3f2650d9 | |||
| d6508d985c | |||
| 7b95b9f37b | |||
| 0bce4be1a6 | |||
| a316424edf | |||
| af71cf4896 | |||
| e846ae85d8 | |||
| 63e4bcdf1d | |||
| f23dd0474b | |||
| fced804438 | |||
| 1fdc9fdc69 | |||
| 2feecdfaf7 | |||
| 195229f78e | |||
| d15f6a8427 | |||
| b8a3a14ad2 | |||
| 9b199c6dcb | |||
| fe4a7b1622 | |||
| 7d2012a82c | |||
| 7f2d4d2a6f | |||
| 8b7f39ee9b | |||
| a368d06fd2 | |||
| 3a2b80b7e9 | |||
| bd5099120f | |||
| 8e64caf784 | |||
| ccb5740920 | |||
| 373893a7ee | |||
| 1987581ee3 | |||
| 59134affa4 | |||
| c7d97decd6 | |||
| 5d5efa02a5 | |||
| 4f12182e28 | |||
| 2c5b63c4a2 | |||
| c4c9f842ea | |||
| dc0ca71c18 | |||
| 236ae268aa | |||
| bd5923a370 | |||
| 4ea5396ddd | |||
| e97eafa55a | |||
| 254e30423d | |||
| 496af07ced | |||
| b5667f9b55 | |||
| 54b968b93c | |||
| e59b5cb6d9 | |||
| f0335b998e | |||
| 66effb04a7 | |||
| 9331126ba1 | |||
| 3bbb44af0b | |||
| f9b2a075a9 | |||
| 0a3f3f07f8 | |||
| 184ea6d9fa | |||
| f5a0e942b0 | |||
| e46555bf2c | |||
| 74e6afdc0f | |||
| aa635b8a8b | |||
| 1856d4b462 | |||
| 73413486bc | |||
| b09eca1698 | |||
| 3004d0d1fe | |||
| b6369d3d14 | |||
| db6d0dcd9e | |||
| 5a5eb443ea | |||
| e1df23d793 | |||
| 0462a7d4ba | |||
| 79db876bd6 | |||
| 0130573342 | |||
| c372dfb09f | |||
| d763d86798 | |||
| 538af97cba | |||
| 054178d155 | |||
| 18f00f861d | |||
| da4d369c32 | |||
| 0f3889fdd0 | |||
| c44291830c | |||
| e1cccba72d | |||
| 6ff39076a0 | |||
| 56cefbaafd | |||
| 3337c7d1ff | |||
| e248763a73 | |||
| 2e5813d31e | |||
| 2cde8d2659 | |||
| 9515130b2a | |||
| b86dd6fb53 | |||
| 8451f2b1d7 | |||
| ee0397513d | |||
| 9c9ff2931d | |||
| bbe6c1e8f3 | |||
| edb7991ccf | |||
| d7faeb754e | |||
| deef89a724 | |||
| 30499a9f61 | |||
| 1ac65b944c | |||
| 306e562281 | |||
| ed82b0c417 | |||
| 0cb3f95d67 | |||
| 0a36256f8a | |||
| 36e67e6eec | |||
| f2683291e4 | |||
| 8c05a9ec80 | |||
| a6d8d4c0f6 | |||
| 0239f4abb4 | |||
| 9259a7eb59 | |||
| 55d675e278 | |||
| 6afe4fd0cc | |||
| 113d1b6f1b | |||
| 545804f194 | |||
| 8bc03d1357 | |||
| f6e0a5b5bb | |||
| 7624a20d8d | |||
| d84708ca7f | |||
| 1428d18de1 | |||
| 312662f382 | |||
| ed9487708e | |||
| 8df88e4d02 | |||
| 07778790d3 | |||
| b609c4e531 | |||
| f1a065bce3 | |||
| 2b94d9a278 | |||
| 344e651693 | |||
| c97733d785 |
@@ -0,0 +1,159 @@
|
||||
---
|
||||
name: adversarial-review
|
||||
description: Cross-vendor adversarial code review of the current branch. Two different model families (Claude + Codex/GPT) review the diff independently, then try to refute each other's findings; survivors are reported by confidence. Runs from either Claude Code or Codex. Use when the user asks for an adversarial review, a cross-model / second-opinion review, or wants high-confidence findings before merging. Report-only — never auto-applies fixes.
|
||||
license: MIT
|
||||
---
|
||||
|
||||
# Adversarial code review
|
||||
|
||||
Two reviewers from **different model families** — **Claude** and **Codex/GPT** — review the
|
||||
same diff independently, then each tries to **refute** the other's findings. A finding's
|
||||
confidence comes from whether it survives that cross-examination. This kills the two failure
|
||||
modes of solo LLM review: self-ratification (a model won't critique its own work) and
|
||||
confident false positives.
|
||||
|
||||
## You are the orchestrator — and one of the two reviewers
|
||||
|
||||
This skill runs from **either** Claude Code **or** Codex. First, **identify which model
|
||||
family you are** (Claude or Codex/GPT). Then:
|
||||
|
||||
- **You** are reviewer #1. You review **natively**, in this session, using your own tools.
|
||||
- **The other family** is reviewer #2. You invoke it as a **subprocess CLI** for an
|
||||
independent pass: a fresh process, no shared context — that independence is the point.
|
||||
|
||||
The CLI for "the other model":
|
||||
|
||||
| If you are… | Invoke the other via… |
|
||||
|-------------|------------------------|
|
||||
| **Claude** | `codex exec` (GPT) |
|
||||
| **Codex** | `claude -p` (Claude) |
|
||||
|
||||
Everything else in the flow is symmetric. Resolve the `prompts/` and `schemas/` paths
|
||||
below relative to **this skill's own directory** (where this SKILL.md lives).
|
||||
|
||||
## Inputs
|
||||
|
||||
Two independent, optional inputs:
|
||||
|
||||
- `BASE` — the ref to diff against. Default `main`.
|
||||
- `SCOPE` — a pathspec to narrow the review (e.g. `src/basic_memory`). Default: none (whole diff).
|
||||
|
||||
These are separate: a ref and a pathspec are not interchangeable. Build the **canonical diff
|
||||
command** once in preflight and reuse it everywhere below — never re-spell the diff inline
|
||||
(the scattered, inconsistent spelling is what broke earlier). Build it as an **argv array**,
|
||||
not a string, so a `$SCOPE` containing spaces or glob characters survives intact:
|
||||
|
||||
```bash
|
||||
BASE="${BASE:-main}"
|
||||
DIFF=(git diff "$BASE...HEAD") # argv array — never a scalar string
|
||||
[ -n "$SCOPE" ] && DIFF+=(-- "$SCOPE") # pathspec stays one argument even with spaces
|
||||
DIFF_STR=$(printf '%q ' "${DIFF[@]}") # shell-quoted rendering, for embedding in a prompt
|
||||
```
|
||||
|
||||
To **run** it, use `"${DIFF[@]}"` (quoted, no word-splitting). To **embed** it as text inside
|
||||
a subprocess prompt, use `$DIFF_STR`.
|
||||
|
||||
## Preflight
|
||||
|
||||
0. Set `SKILL_DIR` to the directory this SKILL.md lives in. Canonical location is
|
||||
`.agents/skills/adversarial-review` (the shared agent-skills store); Claude Code reaches it
|
||||
via the `.claude/skills/adversarial-review` symlink, Codex via its own skills path. The
|
||||
`prompts/` and `schemas/` subdirs are siblings of this file in every case.
|
||||
1. Confirm the *other* model's CLI is on PATH (`codex` if you're Claude, `claude` if you're
|
||||
Codex). If it's missing, tell the user the panel falls back to single-model (which loses
|
||||
the cross-vendor benefit) and ask whether to proceed or stop.
|
||||
2. Run `"${DIFF[@]}"`. If it prints nothing, report "nothing to review against $BASE"
|
||||
(mention `$SCOPE` if set) and stop.
|
||||
3. `RUN=$(mktemp -d)` — scratch dir for the other model's output. Transient, never committed.
|
||||
No persisted artifacts, no state file.
|
||||
|
||||
## Phase 0 — Deterministic gates (before the models)
|
||||
|
||||
Models are statistically blind to negation ("never do X"). Enforce mechanical house rules
|
||||
with tools, not prompts, and treat hits as high-confidence facts (reported separately from
|
||||
model findings):
|
||||
|
||||
- `just lint` and `just typecheck` if the diff touches `src/`.
|
||||
- Grep the diff for catchable house-rule violations: `getattr(.*,.*,` defaults, bare
|
||||
`except:` / `except Exception: pass`, function-scope imports.
|
||||
|
||||
## Phase 1 — Independent review (you + the other model, concurrently)
|
||||
|
||||
Both reviewers get the same brief: `prompts/review.md` + the repo's `CLAUDE.md` house rules,
|
||||
reviewing the diff from `"${DIFF[@]}"`. Both emit findings matching `schemas/findings.schema.json`.
|
||||
|
||||
**Your native pass:** review as yourself, following `prompts/review.md`. Hold your findings
|
||||
as that JSON shape.
|
||||
|
||||
**The other model's pass** — run, from the repo root, the row that matches you:
|
||||
|
||||
Always redirect `codex` stdin from `/dev/null` — if stdin is a pipe (e.g. the call gets
|
||||
backgrounded), `codex exec` blocks "Reading additional input from stdin..." and fails.
|
||||
|
||||
```bash
|
||||
# You are Claude → run Codex:
|
||||
codex exec -s read-only \
|
||||
--output-schema "$SKILL_DIR/schemas/findings.schema.json" \
|
||||
-o "$RUN/other_findings.json" \
|
||||
"$(cat "$SKILL_DIR/prompts/review.md")
|
||||
|
||||
Review the diff: $DIFF_STR" </dev/null
|
||||
|
||||
# You are Codex → run Claude (read-only via plan mode; parse the JSON block it returns):
|
||||
claude -p --permission-mode plan --output-format json \
|
||||
"$(cat "$SKILL_DIR/prompts/review.md")
|
||||
|
||||
Review the diff: $DIFF_STR
|
||||
Return ONLY a JSON object matching this schema:
|
||||
$(cat "$SKILL_DIR/schemas/findings.schema.json")" </dev/null > "$RUN/other_raw.json"
|
||||
# claude --output-format json output shape varies by CLI version: it may be a JSON ARRAY
|
||||
# of event objects, OR a single result object. Normalize before reading: if it's an array,
|
||||
# take the element with type=='result'; otherwise use the object as-is. Then read its
|
||||
# .result string, strip the ```json fence if present, and parse that.
|
||||
# (Verified empirically: the CLI in this environment emits the array form.)
|
||||
```
|
||||
|
||||
> Runtime note for Codex orchestrating: `claude -p` needs network access, which Codex's
|
||||
> default sandbox blocks. Run it from a Codex session whose project is trusted with network
|
||||
> allowed (or approve the `claude` call when prompted). Keep Codex's own sandbox on — do not
|
||||
> bypass it just to reach the network.
|
||||
|
||||
Tag each finding with its origin (`claude` / `codex`).
|
||||
|
||||
## Phase 2 — Cross-refute
|
||||
|
||||
Each model tries to refute the *other's* findings, per `prompts/refute.md`
|
||||
(verdicts match `schemas/verdicts.schema.json`).
|
||||
|
||||
- **You** refute the other model's findings natively.
|
||||
- **The other model** refutes *your* findings — invoke it again the same way (swap
|
||||
`prompts/review.md` for `prompts/refute.md`, append your findings JSON **and `$DIFF_STR`**
|
||||
so it judges against the right base and scope, and for Codex use
|
||||
`--output-schema "$SKILL_DIR/schemas/verdicts.schema.json"`).
|
||||
|
||||
Match verdicts to findings by `id`.
|
||||
|
||||
## Phase 3 — Synthesize and report (no auto-fix)
|
||||
|
||||
Merge, dedupe (same file + overlapping lines + same root cause = one finding), assign
|
||||
confidence from provenance:
|
||||
|
||||
- **High** — both models raised it independently, OR one raised it and the other upheld it.
|
||||
- **Medium** — one raised it; the other could not refute it but did not independently find it.
|
||||
- **Low / contested** — one raised it and the other **refuted** it. Keep it, show both sides,
|
||||
let the human judge. Never silently drop a contested finding.
|
||||
- Deterministic-gate hits are reported as facts, separate from the model panel.
|
||||
|
||||
Rank by `severity × confidence`. Present a compact table: `severity | confidence | file:line
|
||||
| claim | found-by / upheld-or-refuted-by`. Expand the high-confidence ones with `why` and
|
||||
any suggested fix.
|
||||
|
||||
End by asking which findings, if any, to fix. **Do not edit code until the user picks.**
|
||||
Convergence between the models is not correctness — your job is to surface a ranked,
|
||||
cross-examined list, not to declare the branch clean.
|
||||
|
||||
## Deliberately NOT done
|
||||
|
||||
- No loop-until-both-agree (models converge by going silent, not by being right).
|
||||
- No persisted artifacts / state machine — the scratch dir is thrown away.
|
||||
- No auto-applying fixes.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Refute the other reviewer
|
||||
|
||||
A different reviewer (a different model family) produced the findings below against the
|
||||
same diff under review (the exact `git diff` command is provided with the findings). Your
|
||||
job is to try to **refute** each one.
|
||||
|
||||
Default to skepticism: assume a finding is wrong until the code proves it right. A finding
|
||||
that survives a genuine attempt to disprove it is worth far more than one nobody checked.
|
||||
|
||||
For each finding, read the actual code it points at and return a verdict:
|
||||
|
||||
- **refuted** — the claim is wrong, the code does not do what the finding says, the case
|
||||
cannot occur, or it is pure style with no correctness impact. Cite the specific code or
|
||||
fact that disproves it.
|
||||
- **upheld** — you tried to refute it and could not; the finding is real.
|
||||
- **partial** — the underlying issue is real but the finding mis-states the severity or
|
||||
scope. Explain, and set `corrected_severity` if the severity should change.
|
||||
|
||||
Do not be agreeable for its own sake, and do not refute for its own sake. Follow the code.
|
||||
|
||||
Return ONLY the structured verdicts object conforming to the provided schema. Every
|
||||
verdict's `id` must match the `id` of the finding it judges.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Adversarial reviewer
|
||||
|
||||
You are an independent, skeptical code reviewer. Another agent wrote this code; your
|
||||
job is to find what is actually wrong with it — not to praise it, not to rubber-stamp it.
|
||||
|
||||
You are reviewing a specific diff — the exact `git diff` command to run is provided at the
|
||||
end of this prompt by the orchestrator. Run it, then read the changed files in full for
|
||||
context, not just the hunks.
|
||||
|
||||
## What to look for, in priority order
|
||||
|
||||
1. **Correctness** — logic errors, wrong conditions, off-by-one, unhandled `None`,
|
||||
broken async/await, races, resource leaks, incorrect error handling.
|
||||
2. **Security** — injection, path traversal, secret leakage, missing authz, unsafe
|
||||
deserialization.
|
||||
3. **House rules** (this repo's `CLAUDE.md`/`AGENTS.md` — these are hard rules):
|
||||
- No swallowed exceptions / no silent fallback logic. Code must fail fast.
|
||||
- Imports at the top of the file unless deferral is justified in a comment.
|
||||
- No speculative `getattr(obj, "attr", default)` to paper over unknown attributes.
|
||||
- Repository pattern for data access; MCP tools talk to API routers via the httpx
|
||||
ASGI client, not directly to services.
|
||||
- 100-char lines; full type annotations; async SQLAlchemy 2.0; Pydantic v2.
|
||||
- New code needs tests (coverage stays at 100%).
|
||||
4. **Performance** — N+1 queries, work inside hot loops, sync I/O on the async path.
|
||||
5. **Maintainability** — only when it materially risks a bug. Do not report pure style.
|
||||
|
||||
## Rules of engagement
|
||||
|
||||
- Every finding MUST be falsifiable: cite the specific file, line, and the code that
|
||||
triggers it. "This could be cleaner" is not a finding.
|
||||
- Do not invent issues to seem thorough. An empty findings list is a valid, good result.
|
||||
- Watch your own negation blindness: when a rule says "never do X," check the diff for X
|
||||
explicitly rather than trusting a gestalt impression.
|
||||
- Prefer few high-confidence findings over many speculative ones.
|
||||
- Assign severity honestly: `critical` = data loss/security/crash in normal use;
|
||||
`high` = wrong behavior on a common path; `medium` = wrong on an edge case or a real
|
||||
house-rule violation; `low` = minor.
|
||||
|
||||
Return ONLY the structured findings object conforming to the provided schema.
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "AdversarialReviewFindings",
|
||||
"description": "Structured output for one reviewer's pass over a diff.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["findings"],
|
||||
"properties": {
|
||||
"findings": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "file", "line", "severity", "category", "claim", "why", "suggested_fix"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Short stable slug for this finding, e.g. 'swallowed-exc-sync-service'."
|
||||
},
|
||||
"file": {
|
||||
"type": "string",
|
||||
"description": "Path relative to repo root."
|
||||
},
|
||||
"line": {
|
||||
"type": "integer",
|
||||
"description": "Best line number in the new file, or 0 if not line-specific."
|
||||
},
|
||||
"severity": {
|
||||
"type": "string",
|
||||
"enum": ["critical", "high", "medium", "low"]
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"enum": ["correctness", "security", "house-rule", "performance", "maintainability"]
|
||||
},
|
||||
"claim": {
|
||||
"type": "string",
|
||||
"description": "One sentence: what is wrong."
|
||||
},
|
||||
"why": {
|
||||
"type": "string",
|
||||
"description": "Concrete reasoning + the specific code that triggers it. Must be falsifiable, not vibes."
|
||||
},
|
||||
"suggested_fix": {
|
||||
"type": ["string", "null"],
|
||||
"description": "The smallest change that resolves it, or null if none is obvious."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "AdversarialReviewVerdicts",
|
||||
"description": "One reviewer's attempt to refute another reviewer's findings.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["verdicts"],
|
||||
"properties": {
|
||||
"verdicts": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "verdict", "reasoning", "corrected_severity"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "The id of the finding being judged (must match the input finding's id)."
|
||||
},
|
||||
"verdict": {
|
||||
"type": "string",
|
||||
"enum": ["upheld", "refuted", "partial"],
|
||||
"description": "upheld = the finding is real; refuted = it is wrong or a non-issue; partial = real but mis-scoped/wrong-severity."
|
||||
},
|
||||
"reasoning": {
|
||||
"type": "string",
|
||||
"description": "Why. For refuted, cite the specific code or fact that disproves the claim."
|
||||
},
|
||||
"corrected_severity": {
|
||||
"type": ["string", "null"],
|
||||
"enum": ["critical", "high", "medium", "low", null],
|
||||
"description": "Set only when verdict is 'partial' and severity should change; otherwise null."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
---
|
||||
name: instrumentation
|
||||
description: Add Pydantic Logfire observability to applications. Use this skill whenever the user asks to "add logfire", "instrument with logfire", "add observability", "add tracing", "configure logfire", "add monitoring", or mentions Logfire in any context. Supports Python, JavaScript/TypeScript, and Rust. Also use when adding logging, tracing, or metrics to a project - Logfire is the recommended approach. Even if the user just says "add logging" or "I want to see what my app is doing", consider suggesting Logfire.
|
||||
---
|
||||
|
||||
# Instrument with Logfire
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Invoke this skill when:
|
||||
- User asks to "add logfire", "add observability", "add tracing", or "add monitoring"
|
||||
- User wants to instrument an app with structured logging or tracing (Python, JS/TS, or Rust)
|
||||
- User mentions Logfire in any context
|
||||
- User asks to "add logging" or "see what my app is doing"
|
||||
- User wants to monitor AI/LLM calls (PydanticAI, OpenAI, Anthropic)
|
||||
- User asks to add observability to an AI agent or LLM pipeline
|
||||
|
||||
## How Logfire Works
|
||||
|
||||
Logfire is an observability platform built on OpenTelemetry. It captures traces, logs, and metrics from applications. Logfire has native SDKs for Python, JavaScript/TypeScript, and Rust, plus support for any language via OpenTelemetry.
|
||||
|
||||
The reason this skill exists is that Claude tends to get a few things subtly wrong with Logfire - especially the ordering of `configure()` vs `instrument_*()` calls, the structured logging syntax, and which extras to install. These matter because a misconfigured setup silently drops traces.
|
||||
|
||||
## Step 1: Detect Language and Frameworks
|
||||
|
||||
Identify the project language and instrumentable libraries:
|
||||
|
||||
- **Python**: Read `pyproject.toml` or `requirements.txt`. Common instrumentable libraries: FastAPI, httpx, asyncpg, SQLAlchemy, psycopg, Redis, Celery, Django, Flask, requests, PydanticAI.
|
||||
- **JavaScript/TypeScript**: Read `package.json`. Common frameworks: Express, Next.js, Fastify. Also check for Cloudflare Workers or Deno.
|
||||
- **Rust**: Read `Cargo.toml`.
|
||||
|
||||
Then follow the language-specific steps below.
|
||||
|
||||
---
|
||||
|
||||
## Python
|
||||
|
||||
### Install with Extras
|
||||
|
||||
Install `logfire` with extras matching the detected frameworks. Each instrumented library needs its corresponding extra - without it, the `instrument_*()` call will fail at runtime with a missing dependency error.
|
||||
|
||||
```bash
|
||||
uv add 'logfire[fastapi,httpx,asyncpg]'
|
||||
```
|
||||
|
||||
The full list of available extras: `fastapi`, `starlette`, `django`, `flask`, `httpx`, `requests`, `asyncpg`, `psycopg`, `psycopg2`, `sqlalchemy`, `redis`, `pymongo`, `mysql`, `sqlite3`, `celery`, `aiohttp`, `aws-lambda`, `system-metrics`, `litellm`, `dspy`, `google-genai`.
|
||||
|
||||
### Configure and Instrument
|
||||
|
||||
This is where ordering matters. `logfire.configure()` initializes the SDK and must come before everything else. The `instrument_*()` calls register hooks into each library. If you call `instrument_*()` before `configure()`, the hooks register but traces go nowhere.
|
||||
|
||||
```python
|
||||
import logfire
|
||||
|
||||
# 1. Configure first - always
|
||||
logfire.configure()
|
||||
|
||||
# 2. Instrument libraries - after configure, before app starts
|
||||
logfire.instrument_fastapi(app)
|
||||
logfire.instrument_httpx()
|
||||
logfire.instrument_asyncpg()
|
||||
```
|
||||
|
||||
Placement rules:
|
||||
- `logfire.configure()` goes in the application entry point (`main.py`, or the module that creates the app)
|
||||
- Call it **once per process** - not inside request handlers, not in library code
|
||||
- `instrument_*()` calls go right after `configure()`
|
||||
- Web framework instrumentors (`instrument_fastapi`, `instrument_flask`, `instrument_django`) need the app instance as an argument. HTTP client and database instrumentors (`instrument_httpx`, `instrument_asyncpg`) are global and take no arguments.
|
||||
- In **Gunicorn** deployments, call `logfire.configure()` inside the `post_fork` hook, not at module level - each worker is a separate process
|
||||
|
||||
### Structured Logging
|
||||
|
||||
Replace `print()` and `logging.*()` calls with Logfire's structured logging. The key pattern: use `{key}` placeholders with keyword arguments, never f-strings.
|
||||
|
||||
```python
|
||||
# Correct - each {key} becomes a searchable attribute in the Logfire UI
|
||||
logfire.info("Created user {user_id}", user_id=uid)
|
||||
logfire.error("Payment failed {amount} {currency}", amount=100, currency="USD")
|
||||
|
||||
# Wrong - creates a flat string, nothing is searchable
|
||||
logfire.info(f"Created user {uid}")
|
||||
```
|
||||
|
||||
For grouping related operations and measuring duration, use spans:
|
||||
|
||||
```python
|
||||
with logfire.span("Processing order {order_id}", order_id=order_id):
|
||||
items = await fetch_items(order_id)
|
||||
total = calculate_total(items)
|
||||
logfire.info("Calculated total {total}", total=total)
|
||||
```
|
||||
|
||||
For exceptions, use `logfire.exception()` which automatically captures the traceback:
|
||||
|
||||
```python
|
||||
try:
|
||||
await process_order(order_id)
|
||||
except Exception:
|
||||
logfire.exception("Failed to process order {order_id}", order_id=order_id)
|
||||
raise
|
||||
```
|
||||
|
||||
### AI/LLM Instrumentation (Python)
|
||||
|
||||
Logfire auto-instruments AI libraries to capture LLM calls, token usage, tool invocations, and agent runs.
|
||||
|
||||
```bash
|
||||
uv add 'logfire[pydantic-ai]'
|
||||
# or: uv add 'logfire[openai]' / uv add 'logfire[anthropic]'
|
||||
```
|
||||
|
||||
Available AI extras: `pydantic-ai`, `openai`, `anthropic`, `litellm`, `dspy`, `google-genai`.
|
||||
|
||||
```python
|
||||
logfire.configure()
|
||||
logfire.instrument_pydantic_ai() # captures agent runs, tool calls, LLM request/response
|
||||
# or:
|
||||
logfire.instrument_openai() # captures chat completions, embeddings, token counts
|
||||
logfire.instrument_anthropic() # captures messages, token usage
|
||||
```
|
||||
|
||||
For PydanticAI, each agent run becomes a parent span containing child spans for every tool call and LLM request.
|
||||
|
||||
---
|
||||
|
||||
## JavaScript / TypeScript
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
# Node.js
|
||||
npm install @pydantic/logfire-node
|
||||
|
||||
# Cloudflare Workers
|
||||
npm install @pydantic/logfire-cf-workers logfire
|
||||
|
||||
# Next.js / generic
|
||||
npm install logfire
|
||||
```
|
||||
|
||||
### Configure
|
||||
|
||||
**Node.js (Express, Fastify, etc.)** - create an `instrumentation.ts` loaded before your app:
|
||||
|
||||
```typescript
|
||||
import * as logfire from '@pydantic/logfire-node'
|
||||
logfire.configure()
|
||||
```
|
||||
|
||||
Launch with: `node --require ./instrumentation.js app.js`
|
||||
|
||||
The SDK auto-instruments common libraries when loaded before the app. Set `LOGFIRE_TOKEN` in your environment or pass `token` to `configure()`.
|
||||
|
||||
**Cloudflare Workers** - wrap your handler with `instrument()`:
|
||||
|
||||
```typescript
|
||||
import { instrument } from '@pydantic/logfire-cf-workers'
|
||||
|
||||
export default instrument(handler, {
|
||||
service: { name: 'my-worker', version: '1.0.0' }
|
||||
})
|
||||
```
|
||||
|
||||
**Next.js** - set environment variables for OpenTelemetry export:
|
||||
|
||||
```
|
||||
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=https://logfire-api.pydantic.dev/v1/traces
|
||||
OTEL_EXPORTER_OTLP_HEADERS=Authorization=<your-write-token>
|
||||
```
|
||||
|
||||
### Structured Logging (JS/TS)
|
||||
|
||||
```typescript
|
||||
// Structured attributes as second argument
|
||||
logfire.info('Created user', { user_id: uid })
|
||||
logfire.error('Payment failed', { amount: 100, currency: 'USD' })
|
||||
|
||||
// Spans
|
||||
logfire.span('Processing order', { order_id }, {}, async () => {
|
||||
logfire.info('Processing step completed')
|
||||
})
|
||||
|
||||
// Error reporting
|
||||
logfire.reportError('order processing', error)
|
||||
```
|
||||
|
||||
Log levels: `trace`, `debug`, `info`, `notice`, `warn`, `error`, `fatal`.
|
||||
|
||||
---
|
||||
|
||||
## Rust
|
||||
|
||||
### Install
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
logfire = "0.6"
|
||||
```
|
||||
|
||||
### Configure
|
||||
|
||||
```rust
|
||||
let shutdown_handler = logfire::configure()
|
||||
.install_panic_handler()
|
||||
.finish()?;
|
||||
```
|
||||
|
||||
Set `LOGFIRE_TOKEN` in your environment or use the Logfire CLI to select a project.
|
||||
|
||||
### Structured Logging (Rust)
|
||||
|
||||
The Rust SDK is built on `tracing` and `opentelemetry` - existing `tracing` macros work automatically.
|
||||
|
||||
```rust
|
||||
// Spans
|
||||
logfire::span!("processing order", order_id = order_id).in_scope(|| {
|
||||
// traced code
|
||||
});
|
||||
|
||||
// Events
|
||||
logfire::info!("Created user {user_id}", user_id = uid);
|
||||
```
|
||||
|
||||
Always call `shutdown_handler.shutdown()` before program exit to flush data.
|
||||
|
||||
---
|
||||
|
||||
## Verify
|
||||
|
||||
After instrumentation, verify the setup works:
|
||||
|
||||
1. Run `logfire auth` to check authentication (or set `LOGFIRE_TOKEN`)
|
||||
2. Start the app and trigger a request
|
||||
3. Check https://logfire.pydantic.dev/ for traces
|
||||
|
||||
If traces aren't appearing: check that `configure()` is called before `instrument_*()` (Python), check that `LOGFIRE_TOKEN` is set, and check that the correct packages/extras are installed.
|
||||
|
||||
## References
|
||||
|
||||
Detailed patterns and integration tables, organized by language:
|
||||
|
||||
- **Python**: `${CLAUDE_PLUGIN_ROOT}/skills/instrumentation/references/python/logging-patterns.md` (log levels, spans, stdlib integration, metrics, capfire testing) and `${CLAUDE_PLUGIN_ROOT}/skills/instrumentation/references/python/integrations.md` (full instrumentor table with extras)
|
||||
- **JavaScript/TypeScript**: `${CLAUDE_PLUGIN_ROOT}/skills/instrumentation/references/javascript/patterns.md` (log levels, spans, error handling, config) and `${CLAUDE_PLUGIN_ROOT}/skills/instrumentation/references/javascript/frameworks.md` (Node.js, Cloudflare Workers, Next.js, Deno setup)
|
||||
- **Rust**: `${CLAUDE_PLUGIN_ROOT}/skills/instrumentation/references/rust/patterns.md` (macros, spans, tracing/log crate integration, async, shutdown)
|
||||
@@ -0,0 +1,78 @@
|
||||
# JavaScript Framework Setup
|
||||
|
||||
## Node.js (Express, Fastify, etc.)
|
||||
|
||||
Create `instrumentation.ts` and load it before your app:
|
||||
|
||||
```typescript
|
||||
// instrumentation.ts
|
||||
import * as logfire from '@pydantic/logfire-node'
|
||||
import 'dotenv/config'
|
||||
|
||||
logfire.configure()
|
||||
```
|
||||
|
||||
Launch:
|
||||
|
||||
```bash
|
||||
node --require ./instrumentation.js app.js
|
||||
# or with ts-node:
|
||||
npx ts-node --require ./instrumentation.ts app.ts
|
||||
```
|
||||
|
||||
The SDK auto-instruments common libraries (http, fetch, express, etc.) when loaded before the app via `--require`.
|
||||
|
||||
## Cloudflare Workers
|
||||
|
||||
```typescript
|
||||
import { instrument } from '@pydantic/logfire-cf-workers'
|
||||
|
||||
const handler = {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
|
||||
return new Response('Hello')
|
||||
},
|
||||
}
|
||||
|
||||
export default instrument(handler, {
|
||||
service: { name: 'my-worker', version: '1.0.0' },
|
||||
})
|
||||
```
|
||||
|
||||
Add `LOGFIRE_TOKEN` to `.dev.vars` and enable `nodejs_compat` in `wrangler.toml`:
|
||||
|
||||
```toml
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
```
|
||||
|
||||
## Next.js / Vercel
|
||||
|
||||
Set environment variables in `.env.local` or Vercel dashboard:
|
||||
|
||||
```bash
|
||||
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=https://logfire-api.pydantic.dev/v1/traces
|
||||
OTEL_EXPORTER_OTLP_METRICS_ENDPOINT=https://logfire-api.pydantic.dev/v1/metrics
|
||||
OTEL_EXPORTER_OTLP_HEADERS=Authorization=<your-write-token>
|
||||
```
|
||||
|
||||
Optionally use the `logfire` package for manual spans in server components and API routes:
|
||||
|
||||
```typescript
|
||||
import * as logfire from 'logfire'
|
||||
|
||||
logfire.info('Server action executed', { action: 'createUser' })
|
||||
```
|
||||
|
||||
## Deno
|
||||
|
||||
Deno has built-in OpenTelemetry support. Set environment variables:
|
||||
|
||||
```bash
|
||||
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=https://logfire-api.pydantic.dev/v1/traces
|
||||
OTEL_EXPORTER_OTLP_HEADERS=Authorization=<your-write-token>
|
||||
```
|
||||
|
||||
Run with telemetry enabled:
|
||||
|
||||
```bash
|
||||
deno run --allow-env --unstable-otel app.ts
|
||||
```
|
||||
@@ -0,0 +1,75 @@
|
||||
# JavaScript / TypeScript Patterns
|
||||
|
||||
## Log Levels
|
||||
|
||||
From lowest to highest severity:
|
||||
|
||||
```typescript
|
||||
logfire.trace('Detailed trace', { detail: x })
|
||||
logfire.debug('Debug info', { state: s })
|
||||
logfire.info('Normal operation', { event: e })
|
||||
logfire.notice('Notable event', { event: e })
|
||||
logfire.warn('Warning', { issue: i })
|
||||
logfire.error('Error occurred', { error: err })
|
||||
logfire.fatal('Fatal error', { error: err })
|
||||
```
|
||||
|
||||
All methods accept `(message, attributes?, options?)`. Options can include `{ tags: ['tag1'] }`.
|
||||
|
||||
## Spans
|
||||
|
||||
### Callback-based (auto-closes)
|
||||
|
||||
```typescript
|
||||
await logfire.span('Processing order', { order_id }, {}, async () => {
|
||||
const items = await fetchItems(order_id)
|
||||
logfire.info('Fetched items', { count: items.length })
|
||||
return processItems(items)
|
||||
})
|
||||
```
|
||||
|
||||
### Manual control
|
||||
|
||||
```typescript
|
||||
const span = logfire.startSpan('Long operation', { job_id })
|
||||
try {
|
||||
await doWork()
|
||||
} finally {
|
||||
span.end()
|
||||
}
|
||||
```
|
||||
|
||||
Child spans reference their parent via the `parentSpan` option.
|
||||
|
||||
## Error Handling
|
||||
|
||||
```typescript
|
||||
try {
|
||||
await processOrder(orderId)
|
||||
} catch (error) {
|
||||
logfire.reportError('order processing', error)
|
||||
throw error
|
||||
}
|
||||
```
|
||||
|
||||
`reportError` automatically extracts stack traces and error details into structured span attributes.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment variables
|
||||
|
||||
```bash
|
||||
LOGFIRE_TOKEN=your-write-token
|
||||
LOGFIRE_SERVICE_NAME=my-service
|
||||
LOGFIRE_SERVICE_VERSION=1.0.0
|
||||
```
|
||||
|
||||
### Programmatic
|
||||
|
||||
```typescript
|
||||
logfire.configure({
|
||||
token: process.env.LOGFIRE_TOKEN,
|
||||
serviceName: 'my-service',
|
||||
serviceVersion: '1.0.0',
|
||||
})
|
||||
```
|
||||
@@ -0,0 +1,67 @@
|
||||
# Python Integration Reference
|
||||
|
||||
## Web Frameworks
|
||||
|
||||
| Framework | Instrumentor | Needs app instance | Extra |
|
||||
|-----------|-------------|-------------------|-------|
|
||||
| FastAPI | `logfire.instrument_fastapi(app)` | Yes | `fastapi` |
|
||||
| Django | `logfire.instrument_django(app)` | Yes | `django` |
|
||||
| Flask | `logfire.instrument_flask(app)` | Yes | `flask` |
|
||||
| Starlette | `logfire.instrument_starlette(app)` | Yes | `starlette` |
|
||||
| AIOHTTP | `logfire.instrument_aiohttp_client()` | No | `aiohttp` |
|
||||
|
||||
## HTTP Clients
|
||||
|
||||
| Library | Instrumentor | Extra |
|
||||
|---------|-------------|-------|
|
||||
| httpx | `logfire.instrument_httpx()` | `httpx` |
|
||||
| requests | `logfire.instrument_requests()` | `requests` |
|
||||
|
||||
## Databases
|
||||
|
||||
| Library | Instrumentor | Extra |
|
||||
|---------|-------------|-------|
|
||||
| asyncpg | `logfire.instrument_asyncpg()` | `asyncpg` |
|
||||
| psycopg | `logfire.instrument_psycopg()` | `psycopg` |
|
||||
| psycopg2 | `logfire.instrument_psycopg2()` | `psycopg2` |
|
||||
| SQLAlchemy | `logfire.instrument_sqlalchemy()` | `sqlalchemy` |
|
||||
| PyMongo | `logfire.instrument_pymongo()` | `pymongo` |
|
||||
| MySQL | `logfire.instrument_mysql()` | `mysql` |
|
||||
| SQLite3 | `logfire.instrument_sqlite3()` | `sqlite3` |
|
||||
| Redis | `logfire.instrument_redis()` | `redis` |
|
||||
|
||||
## AI/LLM Frameworks
|
||||
|
||||
| Framework | Instrumentor | Extra |
|
||||
|-----------|-------------|-------|
|
||||
| PydanticAI | `logfire.instrument_pydantic_ai()` | `pydantic-ai` |
|
||||
| OpenAI | `logfire.instrument_openai()` | `openai` |
|
||||
| Anthropic | `logfire.instrument_anthropic()` | `anthropic` |
|
||||
| LiteLLM | `logfire.instrument_litellm()` | `litellm` |
|
||||
| DSPy | `logfire.instrument_dspy()` | `dspy` |
|
||||
| Google GenAI | `logfire.instrument_google_genai()` | `google-genai` |
|
||||
|
||||
## Task Queues
|
||||
|
||||
| Framework | Instrumentor | Extra |
|
||||
|-----------|-------------|-------|
|
||||
| Celery | `logfire.instrument_celery()` | `celery` |
|
||||
|
||||
## Other
|
||||
|
||||
| Feature | Instrumentor | Extra |
|
||||
|---------|-------------|-------|
|
||||
| System Metrics | `logfire.instrument_system_metrics()` | `system-metrics` |
|
||||
| Pydantic Models | `logfire.instrument_pydantic()` | - (built-in) |
|
||||
| AWS Lambda | handler wrapper | `aws-lambda` |
|
||||
|
||||
## Gunicorn Configuration
|
||||
|
||||
```python
|
||||
# gunicorn.conf.py
|
||||
import logfire
|
||||
|
||||
def post_fork(server, worker):
|
||||
logfire.configure()
|
||||
logfire.instrument_fastapi(app)
|
||||
```
|
||||
@@ -0,0 +1,101 @@
|
||||
# Python Logging Patterns
|
||||
|
||||
## Log Levels
|
||||
|
||||
From lowest to highest severity:
|
||||
|
||||
```python
|
||||
logfire.trace("Detailed trace {detail}", detail=x)
|
||||
logfire.debug("Debug info {state}", state=s)
|
||||
logfire.info("Normal operation {event}", event=e)
|
||||
logfire.notice("Notable event {event}", event=e)
|
||||
logfire.warn("Warning {issue}", issue=i)
|
||||
logfire.error("Error occurred {error}", error=err)
|
||||
logfire.fatal("Fatal error {error}", error=err)
|
||||
```
|
||||
|
||||
## Nested Spans
|
||||
|
||||
Spans nest to create a tree visible in the Logfire UI. Use them to show the structure of an operation, not just that it happened:
|
||||
|
||||
```python
|
||||
with logfire.span("HTTP request {method} {url}", method="POST", url=url):
|
||||
with logfire.span("Serialize payload"):
|
||||
payload = model.model_dump_json()
|
||||
with logfire.span("Send request"):
|
||||
response = await client.post(url, content=payload)
|
||||
logfire.info("Response {status}", status=response.status_code)
|
||||
```
|
||||
|
||||
## Standard Library Logging Integration
|
||||
|
||||
For projects that already use Python's `logging` module, route existing log calls through Logfire rather than rewriting them all:
|
||||
|
||||
```python
|
||||
from logging import basicConfig
|
||||
import logfire
|
||||
|
||||
logfire.configure()
|
||||
basicConfig(handlers=[logfire.LogfireLoggingHandler()])
|
||||
```
|
||||
|
||||
Or with `dictConfig`:
|
||||
|
||||
```python
|
||||
from logging.config import dictConfig
|
||||
import logfire
|
||||
|
||||
logfire.configure()
|
||||
dictConfig({
|
||||
'version': 1,
|
||||
'handlers': {
|
||||
'logfire': {'class': 'logfire.LogfireLoggingHandler'},
|
||||
},
|
||||
'root': {'handlers': ['logfire']},
|
||||
})
|
||||
```
|
||||
|
||||
## Suppressing Noisy Libraries
|
||||
|
||||
Some libraries emit excessive debug logs. Silence them at the `logging` level:
|
||||
|
||||
```python
|
||||
import logging
|
||||
|
||||
logging.getLogger('httpcore').setLevel(logging.WARNING)
|
||||
logging.getLogger('httpx').setLevel(logging.WARNING)
|
||||
```
|
||||
|
||||
## Custom Metrics
|
||||
|
||||
For dashboards and alerting, create metrics:
|
||||
|
||||
```python
|
||||
counter = logfire.metric_counter("orders_processed", unit="1")
|
||||
counter.add(1, {"status": "success"})
|
||||
|
||||
histogram = logfire.metric_histogram("request_duration", unit="s")
|
||||
histogram.record(0.123, {"endpoint": "/api/users"})
|
||||
|
||||
gauge = logfire.metric_gauge("active_connections")
|
||||
gauge.set(42)
|
||||
```
|
||||
|
||||
## Testing with capfire
|
||||
|
||||
Use the `capfire` pytest fixture to assert on emitted spans without sending data to production:
|
||||
|
||||
```python
|
||||
from logfire.testing import CaptureLogfire
|
||||
|
||||
def test_order_processing(capfire: CaptureLogfire) -> None:
|
||||
process_order(order_id=123)
|
||||
|
||||
spans = capfire.exporter.exported_spans_as_dict()
|
||||
assert any(
|
||||
span['attributes'].get('order_id') == 123
|
||||
for span in spans
|
||||
)
|
||||
```
|
||||
|
||||
Configure logfire with `send_to_logfire=False` in test fixtures to prevent production data leakage.
|
||||
@@ -0,0 +1,106 @@
|
||||
# Rust Patterns
|
||||
|
||||
## Core Macros
|
||||
|
||||
The Rust SDK is built on `tracing` and `opentelemetry`. All `tracing` macros work automatically with Logfire.
|
||||
|
||||
### Events (log points)
|
||||
|
||||
```rust
|
||||
logfire::trace!("Detailed trace {detail}", detail = x);
|
||||
logfire::debug!("Debug info {state}", state = s);
|
||||
logfire::info!("Normal operation {event}", event = e);
|
||||
logfire::warn!("Warning {issue}", issue = i);
|
||||
logfire::error!("Error occurred {err}", err = e);
|
||||
```
|
||||
|
||||
### Spans
|
||||
|
||||
```rust
|
||||
// Scoped - span closes when closure completes
|
||||
logfire::span!("Processing order {order_id}", order_id = id).in_scope(|| {
|
||||
let items = fetch_items(id);
|
||||
logfire::info!("Fetched {count} items", count = items.len());
|
||||
process_items(items)
|
||||
});
|
||||
|
||||
// Guard-based - span closes when guard is dropped
|
||||
let _guard = logfire::span!("Long operation {job_id}", job_id = id).entered();
|
||||
do_work();
|
||||
// span ends when _guard goes out of scope
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
```rust
|
||||
use logfire;
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let shutdown_handler = logfire::configure()
|
||||
.install_panic_handler() // captures panics as error spans
|
||||
.finish()?;
|
||||
|
||||
// application code...
|
||||
|
||||
shutdown_handler.shutdown()?; // flush all pending spans
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
Set `LOGFIRE_TOKEN` in your environment or use the Logfire CLI (`logfire auth`).
|
||||
|
||||
## Tracing Crate Compatibility
|
||||
|
||||
Any library using `tracing` macros automatically sends data through Logfire:
|
||||
|
||||
```rust
|
||||
use tracing;
|
||||
|
||||
tracing::info!("This also appears in Logfire");
|
||||
|
||||
#[tracing::instrument]
|
||||
fn my_function(param: &str) {
|
||||
// automatically creates a span with param as an attribute
|
||||
}
|
||||
```
|
||||
|
||||
## Log Crate Integration
|
||||
|
||||
The `log` crate is automatically captured and forwarded to Logfire. Libraries using `log::info!()`, `log::error!()`, etc. will appear in your Logfire dashboard without any additional configuration.
|
||||
|
||||
## Async Spans
|
||||
|
||||
```rust
|
||||
use tracing::Instrument;
|
||||
|
||||
async fn process_order(order_id: u64) {
|
||||
let span = logfire::span!("process order {order_id}", order_id = order_id);
|
||||
async {
|
||||
fetch_items(order_id).await;
|
||||
logfire::info!("Order processed");
|
||||
}
|
||||
.instrument(span)
|
||||
.await;
|
||||
}
|
||||
```
|
||||
|
||||
## Shutdown
|
||||
|
||||
Always call `shutdown()` before program exit to flush pending data:
|
||||
|
||||
```rust
|
||||
// In main()
|
||||
let shutdown_handler = logfire::configure().finish()?;
|
||||
|
||||
// ... app runs ...
|
||||
|
||||
// Before exit
|
||||
shutdown_handler.shutdown()?;
|
||||
```
|
||||
|
||||
For web servers using `tokio`, handle shutdown via signal:
|
||||
|
||||
```rust
|
||||
tokio::signal::ctrl_c().await?;
|
||||
shutdown_handler.shutdown()?;
|
||||
```
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "basicmachines-co",
|
||||
"owner": {
|
||||
"name": "Basic Machines",
|
||||
"email": "hello@basicmachines.co"
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Official Basic Memory plugins from the canonical basic-memory repository",
|
||||
"version": "0.3.13"
|
||||
},
|
||||
"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",
|
||||
"author": {
|
||||
"name": "Basic Machines"
|
||||
},
|
||||
"keywords": ["memory", "knowledge", "mcp", "specs", "context"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -42,7 +42,7 @@ The justfile target handles:
|
||||
- ✅ Version format validation
|
||||
- ✅ Git status and branch checks
|
||||
- ✅ Quality checks (`just check` - lint, format, type-check, tests)
|
||||
- ✅ Version update in `src/basic_memory/__init__.py`
|
||||
- ✅ Version update across all consolidated manifests via `just set-version` (Python package + Claude Code plugin/marketplaces + Hermes + OpenClaw)
|
||||
- ✅ Automatic commit with proper message
|
||||
- ✅ Tag creation and pushing to GitHub
|
||||
- ✅ Release workflow trigger (automatic on tag push)
|
||||
@@ -191,7 +191,13 @@ Users can now upgrade:
|
||||
- This creates production releases used by end users
|
||||
- Must pass all quality gates before proceeding
|
||||
- Uses the automated justfile target for consistency
|
||||
- Version is automatically updated in `__init__.py` and `server.json`
|
||||
- Version is automatically updated across **all** consolidated manifests via
|
||||
`just set-version <version>` (which calls `scripts/update_versions.py`): the
|
||||
Python package (`__init__.py`, `server.json`) **and** the plugin/agent artifacts
|
||||
(Claude Code `plugin.json` + root/local marketplaces, Hermes `plugin.yaml` +
|
||||
`__init__.py`, OpenClaw `package.json`). To bump only the plugin/agent artifacts
|
||||
out of band, use `just set-packages-version <version>` (preview with
|
||||
`just set-packages-version-dry-run <version>`).
|
||||
- Triggers automated GitHub release with changelog
|
||||
- Package is published to PyPI for `pip` and `uv` users
|
||||
- Homebrew formula is automatically updated for stable releases
|
||||
|
||||
+19
-3
@@ -1,5 +1,21 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"basic-memory@basicmachines": true
|
||||
}
|
||||
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
||||
"env": {
|
||||
"CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR": "1",
|
||||
"CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY": "1",
|
||||
"CLAUDE_CODE_NO_FLICKER": "1",
|
||||
"CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING": "1"
|
||||
},
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(just fast-check)",
|
||||
"Bash(just check)",
|
||||
"Bash(just fix)",
|
||||
"Bash(just typecheck)",
|
||||
"Bash(just lint)",
|
||||
"Bash(just test)"
|
||||
],
|
||||
"deny": []
|
||||
},
|
||||
"enableAllProjectMcpServers": true
|
||||
}
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/adversarial-review
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/instrumentation
|
||||
@@ -10,6 +10,9 @@ on:
|
||||
# - "src/**/*.js"
|
||||
# - "src/**/*.jsx"
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
# Only run for organization members and collaborators
|
||||
@@ -27,7 +30,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@ on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -12,7 +15,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -68,4 +71,4 @@ jobs:
|
||||
|
||||
Read the issue carefully and provide helpful triage with appropriate labels.
|
||||
|
||||
claude_args: '--allowed-tools "Bash(gh issue:*),Bash(gh search:*),Read"'
|
||||
claude_args: '--allowed-tools "Bash(gh issue:*),Bash(gh search:*),Read"'
|
||||
|
||||
@@ -12,6 +12,9 @@ on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize]
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
@@ -41,7 +44,7 @@ jobs:
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
# For pull_request_target, checkout the PR head to review the actual changes
|
||||
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
|
||||
@@ -65,4 +68,3 @@ jobs:
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://docs.claude.com/en/docs/claude-code/sdk#command-line for available options
|
||||
# claude_args: '--model claude-opus-4-1-20250805 --allowed-tools Bash(gh pr:*)'
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
name: Consolidated Packages
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- ".claude-plugin/**"
|
||||
- "plugins/**"
|
||||
- "skills/**"
|
||||
- "integrations/**"
|
||||
- "scripts/update_versions.py"
|
||||
- "scripts/validate_*.py"
|
||||
- "justfile"
|
||||
- ".github/workflows/consolidated-packages.yml"
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- ".claude-plugin/**"
|
||||
- "plugins/**"
|
||||
- "skills/**"
|
||||
- "integrations/**"
|
||||
- "scripts/update_versions.py"
|
||||
- "scripts/validate_*.py"
|
||||
- "justfile"
|
||||
- ".github/workflows/consolidated-packages.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
claude-code:
|
||||
name: Claude Code marketplace
|
||||
permissions:
|
||||
contents: read
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: extractions/setup-just@v4
|
||||
|
||||
- name: Validate manifests, agent, and bundled skills
|
||||
run: just --justfile plugins/claude-code/justfile --working-directory plugins/claude-code ci-check
|
||||
|
||||
- name: Verify shared version dry run
|
||||
run: just release-dry-run v0.99.0
|
||||
|
||||
skills:
|
||||
name: Shared skills
|
||||
permissions:
|
||||
contents: read
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: extractions/setup-just@v4
|
||||
|
||||
- name: Validate SKILL.md source
|
||||
run: just --justfile skills/justfile --working-directory skills check
|
||||
|
||||
hermes:
|
||||
name: Hermes unit tests
|
||||
permissions:
|
||||
contents: read
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: integrations/hermes
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: extractions/setup-just@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v3
|
||||
|
||||
- name: Set up Python
|
||||
run: uv python install 3.12
|
||||
|
||||
- name: Validate manifest and run unit tests
|
||||
run: just check
|
||||
|
||||
openclaw:
|
||||
name: OpenClaw package
|
||||
permissions:
|
||||
contents: read
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: integrations/openclaw
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: extractions/setup-just@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.8"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Release readiness
|
||||
run: just release-check
|
||||
@@ -5,6 +5,9 @@ on:
|
||||
branches: [main]
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
dev-release:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -13,12 +16,12 @@ jobs:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
@@ -50,4 +53,4 @@ jobs:
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
password: ${{ secrets.PYPI_TOKEN }}
|
||||
skip-existing: true # Don't fail if version already exists
|
||||
skip-existing: true # Don't fail if version already exists
|
||||
|
||||
@@ -9,6 +9,7 @@ on:
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: basicmachines-co/basic-memory
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
@@ -19,17 +20,17 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -37,7 +38,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
@@ -48,7 +49,7 @@ jobs:
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
@@ -58,4 +59,3 @@ jobs:
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
|
||||
@@ -7,11 +7,14 @@ on:
|
||||
- edited
|
||||
- synchronize
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
main:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: amannn/action-semantic-pull-request@v5
|
||||
- uses: amannn/action-semantic-pull-request@v6
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
@@ -37,5 +40,8 @@ jobs:
|
||||
ui
|
||||
deps
|
||||
installer
|
||||
plugins
|
||||
skills
|
||||
integrations
|
||||
# Allow breaking changes (needs "!" after type/scope)
|
||||
requireScopeForBreakingChange: true
|
||||
requireScopeForBreakingChange: true
|
||||
|
||||
+102
-22
@@ -5,6 +5,9 @@ on:
|
||||
tags:
|
||||
- 'v*' # Trigger on version tags like v1.0.0, v0.13.0, etc.
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -13,12 +16,12 @@ jobs:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
@@ -39,7 +42,7 @@ jobs:
|
||||
echo "Build completed successfully"
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
files: |
|
||||
dist/*.whl
|
||||
@@ -53,6 +56,46 @@ jobs:
|
||||
with:
|
||||
password: ${{ secrets.PYPI_TOKEN }}
|
||||
|
||||
openclaw:
|
||||
name: Publish OpenClaw npm Package
|
||||
needs: release
|
||||
# npm publishes only for stable product tags. Pre-release Python tags use
|
||||
# versions like 0.21.3b1, which are not valid npm pre-release semver.
|
||||
if: ${{ !contains(github.ref_name, 'dev') && !contains(github.ref_name, 'b') && !contains(github.ref_name, 'rc') }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: integrations/openclaw
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: extractions/setup-just@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.8"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Release readiness
|
||||
run: just release-check
|
||||
|
||||
- name: Publish to npm
|
||||
run: npm publish --access public --provenance
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
homebrew:
|
||||
name: Update Homebrew Formula
|
||||
needs: release
|
||||
@@ -60,26 +103,63 @@ jobs:
|
||||
# Only run for stable releases (not dev, beta, or rc versions)
|
||||
if: ${{ !contains(github.ref_name, 'dev') && !contains(github.ref_name, 'b') && !contains(github.ref_name, 'rc') }}
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
# Inline bump replaces mislav/bump-homebrew-formula-action@v4.x.
|
||||
# The action does a HEAD request to api.github.com /repos/.../tarball/<ref>
|
||||
# with the bearer token and expects a 302 redirect. GitHub now returns
|
||||
# 303 on that endpoint when authenticated, which the action treats as a
|
||||
# fatal error. Re-implementing the bump as plain git+sed keeps the same
|
||||
# contract (update url + sha256, commit, push) with no third-party action.
|
||||
- name: Update Homebrew formula
|
||||
uses: mislav/bump-homebrew-formula-action@v3
|
||||
with:
|
||||
# Formula name in homebrew-basic-memory repo
|
||||
formula-name: basic-memory
|
||||
# The tap repository
|
||||
homebrew-tap: basicmachines-co/homebrew-basic-memory
|
||||
# Base branch of the tap repository
|
||||
base-branch: main
|
||||
# Download URL will be automatically constructed from the tag
|
||||
download-url: https://github.com/basicmachines-co/basic-memory/archive/refs/tags/${{ github.ref_name }}.tar.gz
|
||||
# Commit message for the formula update
|
||||
commit-message: |
|
||||
{{formulaName}} {{version}}
|
||||
|
||||
Created by https://github.com/basicmachines-co/basic-memory/actions/runs/${{ github.run_id }}
|
||||
env:
|
||||
# Personal Access Token with repo scope for homebrew-basic-memory repo
|
||||
COMMITTER_TOKEN: ${{ secrets.HOMEBREW_TOKEN }}
|
||||
HOMEBREW_TOKEN: ${{ secrets.HOMEBREW_TOKEN }}
|
||||
REF: ${{ github.ref_name }}
|
||||
REPO: ${{ github.repository }}
|
||||
RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${REF#v}"
|
||||
ARCHIVE_URL="https://github.com/${REPO}/archive/refs/tags/${REF}.tar.gz"
|
||||
|
||||
echo "::group::Compute tarball sha256"
|
||||
SHA256="$(curl --fail --silent --location "$ARCHIVE_URL" | sha256sum | awk '{print $1}')"
|
||||
test -n "$SHA256"
|
||||
echo "sha256: $SHA256"
|
||||
echo "::endgroup::"
|
||||
|
||||
echo "::group::Clone tap"
|
||||
git clone \
|
||||
--depth 1 \
|
||||
"https://x-access-token:${HOMEBREW_TOKEN}@github.com/basicmachines-co/homebrew-basic-memory.git" \
|
||||
tap
|
||||
cd tap
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
echo "::endgroup::"
|
||||
|
||||
echo "::group::Patch Formula/basic-memory.rb"
|
||||
# Pipe-delimited sed because the URL contains slashes. The Formula
|
||||
# only has one `url` and one `sha256` directive, so a first-match
|
||||
# replacement is unambiguous. POSIX character classes ([[:space:]])
|
||||
# keep this portable across BSD and GNU sed.
|
||||
sed -i -E \
|
||||
-e "s|^([[:space:]]*url[[:space:]]+)\"[^\"]+\"|\1\"${ARCHIVE_URL}\"|" \
|
||||
-e "s|^([[:space:]]*sha256[[:space:]]+)\"[^\"]+\"|\1\"${SHA256}\"|" \
|
||||
Formula/basic-memory.rb
|
||||
git --no-pager diff Formula/basic-memory.rb
|
||||
echo "::endgroup::"
|
||||
|
||||
if git diff --quiet Formula/basic-memory.rb; then
|
||||
echo "Formula already at ${REF}; nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "::group::Commit & push"
|
||||
git add Formula/basic-memory.rb
|
||||
git commit -m "basic-memory ${VERSION}
|
||||
|
||||
Created by ${RUN_URL}"
|
||||
git push origin HEAD:main
|
||||
echo "::endgroup::"
|
||||
|
||||
+210
-83
@@ -1,53 +1,41 @@
|
||||
name: Tests
|
||||
|
||||
concurrency:
|
||||
group: bm-ci-${{ github.workflow }}-${{ github.repository }}-${{ github.head_ref || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
# Trigger: PR branch pushes already publish commit statuses that show up on the PR.
|
||||
# Why: running the full matrix on both push and pull_request doubles CI time for the
|
||||
# exact same branch head commit.
|
||||
# Outcome: each branch push runs the test suite once, including PR updates.
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
# pull_request_target runs on the BASE of the PR, not the merge result.
|
||||
# It has write permissions and access to secrets.
|
||||
# It's useful for PRs from forks or automated PRs but requires careful use for security reasons.
|
||||
# See: https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_target
|
||||
pull_request_target:
|
||||
branches: [ "main" ]
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
test-sqlite:
|
||||
name: Test SQLite (${{ matrix.os }}, Python ${{ matrix.python-version }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
python-version: [ "3.12", "3.13", "3.14" ]
|
||||
runs-on: ${{ matrix.os }}
|
||||
static-checks:
|
||||
name: Static Checks (Python 3.12)
|
||||
timeout-minutes: 20
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: 'pip'
|
||||
python-version: "3.12"
|
||||
cache: "pip"
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
pip install uv
|
||||
|
||||
- name: Install just (Linux/macOS)
|
||||
if: runner.os != 'Windows'
|
||||
run: |
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to /usr/local/bin
|
||||
|
||||
- name: Install just (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
run: |
|
||||
# Install just using Chocolatey (pre-installed on GitHub Actions Windows runners)
|
||||
choco install just --yes
|
||||
shell: pwsh
|
||||
- uses: extractions/setup-just@v4
|
||||
|
||||
- name: Create virtual env
|
||||
run: |
|
||||
@@ -55,7 +43,7 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv pip install -e .[dev]
|
||||
uv pip install -e ".[dev]"
|
||||
|
||||
- name: Run type checks
|
||||
run: |
|
||||
@@ -65,28 +53,30 @@ jobs:
|
||||
run: |
|
||||
just lint
|
||||
|
||||
- name: Run tests (SQLite)
|
||||
run: |
|
||||
uv pip install pytest pytest-cov
|
||||
just test-sqlite
|
||||
|
||||
test-postgres:
|
||||
name: Test Postgres (Python ${{ matrix.python-version }})
|
||||
test-sqlite-unit:
|
||||
name: Test SQLite Unit (${{ matrix.os }}, Python ${{ matrix.python-version }})
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: [ "3.12", "3.13", "3.14" ]
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# Note: No services section needed - testcontainers handles Postgres in Docker
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
python-version: "3.12"
|
||||
- os: ubuntu-latest
|
||||
python-version: "3.13"
|
||||
- os: ubuntu-latest
|
||||
python-version: "3.14"
|
||||
- os: windows-latest
|
||||
python-version: "3.12"
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: 'pip'
|
||||
@@ -95,9 +85,7 @@ jobs:
|
||||
run: |
|
||||
pip install uv
|
||||
|
||||
- name: Install just
|
||||
run: |
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to /usr/local/bin
|
||||
- uses: extractions/setup-just@v4
|
||||
|
||||
- name: Create virtual env
|
||||
run: |
|
||||
@@ -105,24 +93,184 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv pip install -e .[dev]
|
||||
uv pip install -e ".[dev]"
|
||||
|
||||
- name: Run tests (Postgres via testcontainers)
|
||||
- name: Run tests
|
||||
run: |
|
||||
uv pip install pytest pytest-cov
|
||||
just test-postgres
|
||||
just test-unit-sqlite
|
||||
|
||||
coverage:
|
||||
name: Coverage Summary (combined, Python 3.12)
|
||||
test-sqlite-integration:
|
||||
name: Test SQLite Integration (${{ matrix.os }}, Python ${{ matrix.python-version }})
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
python-version: "3.12"
|
||||
- os: ubuntu-latest
|
||||
python-version: "3.13"
|
||||
- os: ubuntu-latest
|
||||
python-version: "3.14"
|
||||
- os: windows-latest
|
||||
python-version: "3.12"
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
pip install uv
|
||||
|
||||
- uses: extractions/setup-just@v4
|
||||
|
||||
- name: Create virtual env
|
||||
run: |
|
||||
uv venv
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv pip install -e ".[dev]"
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
just test-int-sqlite
|
||||
|
||||
test-postgres-unit:
|
||||
name: Test Postgres Unit (Python ${{ matrix.python-version }})
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- python-version: "3.12"
|
||||
- python-version: "3.13"
|
||||
- python-version: "3.14"
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
env:
|
||||
POSTGRES_USER: basic_memory_user
|
||||
POSTGRES_PASSWORD: dev_password
|
||||
POSTGRES_DB: basic_memory_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U basic_memory_user -d basic_memory_test"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
env:
|
||||
BASIC_MEMORY_TEST_POSTGRES_URL: postgresql://basic_memory_user:dev_password@127.0.0.1:5432/basic_memory_test
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
pip install uv
|
||||
|
||||
- uses: extractions/setup-just@v4
|
||||
|
||||
- name: Create virtual env
|
||||
run: |
|
||||
uv venv
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv pip install -e ".[dev]"
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
just test-unit-postgres
|
||||
|
||||
test-postgres-integration:
|
||||
name: Test Postgres Integration (Python ${{ matrix.python-version }})
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- python-version: "3.12"
|
||||
- python-version: "3.13"
|
||||
- python-version: "3.14"
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
env:
|
||||
POSTGRES_USER: basic_memory_user
|
||||
POSTGRES_PASSWORD: dev_password
|
||||
POSTGRES_DB: basic_memory_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U basic_memory_user -d basic_memory_test"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
env:
|
||||
BASIC_MEMORY_TEST_POSTGRES_URL: postgresql://basic_memory_user:dev_password@127.0.0.1:5432/basic_memory_test
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
pip install uv
|
||||
|
||||
- uses: extractions/setup-just@v4
|
||||
|
||||
- name: Create virtual env
|
||||
run: |
|
||||
uv venv
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv pip install -e ".[dev]"
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
just test-int-postgres
|
||||
|
||||
test-semantic:
|
||||
name: Test Semantic (Python 3.12)
|
||||
timeout-minutes: 45
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip"
|
||||
@@ -131,9 +279,7 @@ jobs:
|
||||
run: |
|
||||
pip install uv
|
||||
|
||||
- name: Install just
|
||||
run: |
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to /usr/local/bin
|
||||
- uses: extractions/setup-just@v4
|
||||
|
||||
- name: Create virtual env
|
||||
run: |
|
||||
@@ -141,27 +287,8 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv pip install -e .[dev]
|
||||
uv pip install -e ".[dev]"
|
||||
|
||||
- name: Run combined coverage (SQLite + Postgres)
|
||||
- name: Run tests
|
||||
run: |
|
||||
uv pip install pytest pytest-cov
|
||||
just coverage
|
||||
|
||||
- name: Add coverage report to job summary
|
||||
if: always()
|
||||
run: |
|
||||
{
|
||||
echo "## Coverage"
|
||||
echo ""
|
||||
echo '```'
|
||||
uv run coverage report -m
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload HTML coverage report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: htmlcov
|
||||
path: htmlcov/
|
||||
just test-semantic
|
||||
|
||||
@@ -57,3 +57,10 @@ claude-output
|
||||
.mcp.json
|
||||
.mcpregistry_*
|
||||
/.testmondata
|
||||
.benchmarks/
|
||||
|
||||
# Consolidated package build artifacts
|
||||
/integrations/openclaw/node_modules/
|
||||
/integrations/openclaw/dist/
|
||||
/integrations/openclaw/skills/
|
||||
/integrations/openclaw/*.tgz
|
||||
|
||||
@@ -22,15 +22,22 @@ See the [README.md](README.md) file for a project overview.
|
||||
- Run unit tests (Postgres): `just test-unit-postgres`
|
||||
- Run integration tests (SQLite): `just test-int-sqlite`
|
||||
- Run integration tests (Postgres): `just test-int-postgres`
|
||||
- Run impacted tests: `just testmon` (pytest-testmon)
|
||||
- Run impacted tests: `just testmon` (pytest-testmon; only tests affected by changed code)
|
||||
- Run MCP smoke test: `just test-smoke`
|
||||
- Fast local loop: `just fast-check`
|
||||
- Fast local loop: `just fast-check` (default iteration flow)
|
||||
- Local consistency check: `just doctor`
|
||||
- Run all consolidated agent package checks: `just package-check`
|
||||
- Run Claude Code plugin checks: `just package-check-claude-code`
|
||||
- Run shared skills checks: `just package-check-skills`
|
||||
- Run Hermes plugin checks: `just package-check-hermes`
|
||||
- Run OpenClaw plugin checks: `just package-check-openclaw`
|
||||
- Run host-native agent harness checks: `just agent-harness-check`
|
||||
- Generate HTML coverage: `just coverage`
|
||||
- Single test: `pytest tests/path/to/test_file.py::test_function_name`
|
||||
- Run benchmarks: `pytest test-int/test_sync_performance_benchmark.py -v -m "benchmark and not slow"`
|
||||
- Lint: `just lint` or `ruff check . --fix`
|
||||
- Type check: `just typecheck` or `uv run pyright`
|
||||
- Type check: `just typecheck` or `uv run ty check src tests test-int`
|
||||
- Type check (pyright): `just typecheck-pyright` or `uv run pyright`
|
||||
- Format: `just format` or `uv run ruff format .`
|
||||
- Run all code checks: `just check` (runs lint, format, typecheck, test)
|
||||
- Create db migration: `just migration "Your migration message"`
|
||||
@@ -47,12 +54,37 @@ See the [README.md](README.md) file for a project overview.
|
||||
### Code/Test/Verify Loop (fast path)
|
||||
|
||||
1) **Code:** make changes.
|
||||
2) **Test:** `just fast-check` (lint/format/typecheck + impacted tests + MCP smoke).
|
||||
2) **Test:** `just fast-check` (lint/format/typecheck + pytest-testmon impacted tests for changed code).
|
||||
3) **Verify:** `just doctor` (end-to-end file ↔ DB loop in a temp project).
|
||||
4) **Full gate (when needed):** `just test` or `just check` for SQLite + Postgres.
|
||||
4) **Package verify:** `just package-check` when changes touch `plugins/`, `skills/`, `integrations/`, package metadata, or release wiring.
|
||||
5) **Full gate (when needed):** `just test` or `just check` for SQLite + Postgres.
|
||||
|
||||
Run `just test-smoke` when you specifically need the MCP smoke flow.
|
||||
|
||||
If testmon is “cold,” the first run may be long. Subsequent runs get much faster.
|
||||
|
||||
### Consolidated Agent Package Checks
|
||||
|
||||
The monorepo ships several host-native packages alongside the Python core. Use the root justfile as the canonical entry point:
|
||||
|
||||
- `just package-check` — validates every copied package and generated bundle path.
|
||||
- `just package-check-claude-code` — validates the root and plugin-local Claude marketplace manifests, the SessionStart/PreCompact hooks, the bundled output style, and the seed schemas, then runs `claude plugin validate . --strict`.
|
||||
- `just package-check-skills` — validates every top-level `skills/memory-*/SKILL.md` frontmatter block.
|
||||
- `just package-check-hermes` — validates `integrations/hermes/plugin.yaml`, the Hermes provider entrypoint, bundled skill, and runs the hermetic unit suite.
|
||||
- `just package-check-openclaw` — runs the OpenClaw package install, copies top-level skills into the generated bundle, typechecks, lints, builds `dist/`, runs Bun tests, and performs `npm pack --dry-run`.
|
||||
- `just agent-harness-check` — checks the host-specific harnesses without the shared markdown-only skills target.
|
||||
|
||||
Package-local justfiles live in `plugins/claude-code/`, `skills/`, `integrations/hermes/`, and `integrations/openclaw/`. Prefer the root targets for PR verification so command names stay stable as package internals evolve.
|
||||
|
||||
### PR CI Gate
|
||||
|
||||
Before opening or updating a PR, run the checks that mirror the common required CI failures:
|
||||
|
||||
- Run `just typecheck` in addition to targeted `ruff` and `pytest` commands when tests were added or changed.
|
||||
- Sign commits with `git commit -s` so DCO passes. If a PR branch already has unsigned commits, rewrite the branch with signed-off commits before asking for review.
|
||||
- Use a semantic PR title accepted by `.github/workflows/pr-title.yml`: `type(scope): summary`.
|
||||
- Use one of the allowed scopes: `core`, `cli`, `api`, `mcp`, `sync`, `ui`, `deps`, `installer`, `plugins`, `skills`, `integrations`.
|
||||
|
||||
### Test Structure
|
||||
|
||||
- `tests/` - Unit tests for individual components (mocked, fast)
|
||||
@@ -150,6 +182,10 @@ See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for detailed architecture docum
|
||||
- `/schemas` - Pydantic models for validation
|
||||
- `/services` - Business logic layer
|
||||
- `/sync` - File synchronization services + `coordinator.py` for lifecycle management
|
||||
- `/plugins/claude-code` - Claude Code plugin marketplace package, hooks, skills, and agent harness
|
||||
- `/skills` - Canonical framework-agnostic Basic Memory `SKILL.md` source
|
||||
- `/integrations/hermes` - Hermes memory-provider plugin
|
||||
- `/integrations/openclaw` - OpenClaw npm/TypeScript plugin
|
||||
|
||||
**Composition Roots:**
|
||||
Each entrypoint (API, MCP, CLI) has a composition root that:
|
||||
@@ -189,27 +225,46 @@ Flow: MCP Tool → Typed Client → HTTP API → Router → Service → Reposito
|
||||
|
||||
### Async Client Pattern (Important!)
|
||||
|
||||
**All MCP tools and CLI commands use the context manager pattern for HTTP clients:**
|
||||
**MCP tools use `get_project_client()` for per-project routing:**
|
||||
|
||||
```python
|
||||
from basic_memory.mcp.project_context import get_project_client
|
||||
|
||||
@mcp.tool()
|
||||
async def my_tool(project: str | None = None, context: Context | None = None):
|
||||
async with get_project_client(project, context) as (client, active_project):
|
||||
# client is routed based on project's mode (local ASGI or cloud HTTP)
|
||||
response = await call_get(client, "/path")
|
||||
return response
|
||||
```
|
||||
|
||||
**CLI commands and non-project-scoped code use `get_client()` directly:**
|
||||
|
||||
```python
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
|
||||
async def my_mcp_tool():
|
||||
async def my_cli_command():
|
||||
async with get_client() as client:
|
||||
# Use client for API calls
|
||||
response = await call_get(client, "/path")
|
||||
return response
|
||||
|
||||
# Per-project routing (when project name is known):
|
||||
async with get_client(project_name="research") as client:
|
||||
...
|
||||
```
|
||||
|
||||
**Do NOT use:**
|
||||
- ❌ `from basic_memory.mcp.async_client import client` (deprecated module-level client)
|
||||
- ❌ Manual auth header management
|
||||
- ❌ `inject_auth_header()` (deleted)
|
||||
- ❌ Separate `get_client()` + `get_active_project()` in MCP tools (use `get_project_client()` instead)
|
||||
|
||||
**Key principles:**
|
||||
- Auth happens at client creation, not per-request
|
||||
- Proper resource management via context managers
|
||||
- Supports three modes: Local (ASGI), CLI cloud (HTTP + auth), Cloud app (factory injection)
|
||||
- Per-project routing: each project can be LOCAL or CLOUD independently
|
||||
- Cloud projects use API key (`cloud_api_key` in config) as Bearer token
|
||||
- Routing priority: factory injection > force-local > per-project cloud > global cloud > local ASGI
|
||||
- Factory pattern enables dependency injection for cloud consolidation
|
||||
|
||||
**For cloud app integration:**
|
||||
@@ -222,6 +277,33 @@ async_client.set_client_factory(your_custom_factory)
|
||||
|
||||
See SPEC-16 for full context manager refactor details.
|
||||
|
||||
### Release Process
|
||||
|
||||
Releases are driven by `just release` / `just beta` — never by a bare `git tag`. The recipes bump version metadata, run pre-flight checks, commit, tag, and push. GitHub Actions then publishes to PyPI and updates the Homebrew formula.
|
||||
|
||||
**Stable release:**
|
||||
|
||||
```
|
||||
just release v0.21.3
|
||||
```
|
||||
|
||||
The recipe runs `just lint` + `just typecheck`, then updates every release manifest through `scripts/update_versions.py`: `src/basic_memory/__init__.py`, `server.json`, the root Claude marketplace, the Claude Code plugin manifest and local marketplace, the Hermes `plugin.yaml`, and the OpenClaw `package.json`. It commits as `chore: update version to X.Y.Z for vX.Y.Z release`, creates the `vX.Y.Z` tag, and pushes both the commit and the tag to `origin/main`. After the tag lands, the `Release` workflow builds the Python package, publishes to PyPI, creates the GitHub release with auto-generated notes, publishes the OpenClaw npm package, and updates the Homebrew formula. The recipe finishes by printing the post-release tasks the workflow doesn't cover.
|
||||
|
||||
**Beta release:** `just beta v0.21.3b1` — same flow with a beta-suffixed tag. PyPI consumers install with `pip install basic-memory --pre`.
|
||||
|
||||
**Release dry run:** `just release-dry-run v0.21.4` previews the consolidated version update without writing files.
|
||||
|
||||
**Development builds:** every commit to `main` publishes a `0.21.3.dev26+468a22f`-style version to PyPI automatically via `.github/workflows/dev-release.yml`. No human action.
|
||||
|
||||
**Do not tag releases by hand.** A bare `git tag vX.Y.Z` skips the in-code version bump. Package metadata is still correct (uv-dynamic-versioning derives it from the git tag) but `basic-memory --version` reports the previous release, which is what happened with v0.21.2 → v0.21.3.
|
||||
|
||||
**Post-release tasks** the recipe surfaces but doesn't run:
|
||||
- `docs.basicmemory.com` — add notes to `src/pages/latest-releases.mdx`
|
||||
- `basicmachines.co` — bump version in `src/components/sections/hero.tsx`
|
||||
- MCP Registry — `mcp-publisher publish` from the repo root
|
||||
|
||||
See `.claude/commands/release/release.md` (and `beta.md`, `release-check.md`, `changelog.md` alongside it) for the full release + post-release runbook, including the slash commands.
|
||||
|
||||
## BASIC MEMORY PRODUCT USAGE
|
||||
|
||||
### Knowledge Structure
|
||||
@@ -250,15 +332,19 @@ See SPEC-16 for full context manager refactor details.
|
||||
- List projects: `basic-memory project list`
|
||||
- Add project: `basic-memory project add "name" ~/path`
|
||||
- Project info: `basic-memory project info`
|
||||
- Set cloud mode: `basic-memory project set-cloud "name"`
|
||||
- Set local mode: `basic-memory project set-local "name"`
|
||||
- One-way sync (local -> cloud): `basic-memory project sync`
|
||||
- Bidirectional sync: `basic-memory project bisync`
|
||||
- Integrity check: `basic-memory project check`
|
||||
|
||||
**Cloud Commands (requires subscription):**
|
||||
- Authenticate: `basic-memory cloud login`
|
||||
- Logout: `basic-memory cloud logout`
|
||||
- Authenticate (global): `basic-memory cloud login`
|
||||
- Logout (global): `basic-memory cloud logout`
|
||||
- Check cloud status: `basic-memory cloud status`
|
||||
- Setup cloud sync: `basic-memory cloud setup`
|
||||
- Save API key: `basic-memory cloud set-key bmc_...`
|
||||
- Create API key: `basic-memory cloud create-key "name"`
|
||||
- Manage snapshots: `basic-memory cloud snapshot [create|list|delete|show|browse]`
|
||||
- Restore from snapshot: `basic-memory cloud restore <path> --snapshot <id>`
|
||||
|
||||
@@ -329,9 +415,22 @@ Basic Memory now supports cloud synchronization and storage (requires active sub
|
||||
- Background relation resolution (non-blocking startup)
|
||||
- API performance optimizations (SPEC-11)
|
||||
|
||||
**CLI Routing Flags:**
|
||||
**Per-Project Cloud Routing:**
|
||||
|
||||
When cloud mode is enabled, CLI commands route to the cloud API by default. Use `--local` and `--cloud` flags to override:
|
||||
Individual projects can be routed through the cloud while others stay local, using an API key:
|
||||
|
||||
```bash
|
||||
# Save API key and set project to cloud mode
|
||||
basic-memory cloud set-key bmc_abc123...
|
||||
basic-memory project set-cloud research # route through cloud
|
||||
basic-memory project set-local research # revert to local
|
||||
```
|
||||
|
||||
MCP tools use `get_project_client()` which automatically routes based on the project's mode. Cloud projects use the `cloud_api_key` from config as Bearer token.
|
||||
|
||||
**CLI Routing Flags (Global Cloud Mode):**
|
||||
|
||||
When global cloud mode is enabled, CLI commands route to the cloud API by default. Use `--local` and `--cloud` flags to override:
|
||||
|
||||
```bash
|
||||
# Force local routing (ignore cloud mode)
|
||||
@@ -348,6 +447,7 @@ Key behaviors:
|
||||
- This allows simultaneous use of local Claude Desktop and cloud-based clients
|
||||
- Some commands (like `project default`, `project sync-config`, `project move`) require `--local` in cloud mode since they modify local configuration
|
||||
- Environment variable `BASIC_MEMORY_FORCE_LOCAL=true` forces local routing globally
|
||||
- Per-project cloud routing via API key works independently of global cloud mode
|
||||
|
||||
## AI-Human Collaborative Development
|
||||
|
||||
@@ -404,5 +504,9 @@ With GitHub integration, the development workflow includes:
|
||||
3. **Branch management** - Claude can create feature branches for implementations
|
||||
4. **Documentation maintenance** - Claude can keep documentation updated as the code evolves
|
||||
5. **Code Commits**: ALWAYS sign off commits with `git commit -s`
|
||||
6. **Pull Request Titles**: PR titles must follow the semantic format enforced by `.github/workflows/pr-title.yml`: `type(scope): summary`
|
||||
- Allowed types: `feat`, `fix`, `chore`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`
|
||||
- Allowed scopes: `core`, `cli`, `api`, `mcp`, `sync`, `ui`, `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.
|
||||
|
||||
+427
-5
@@ -1,5 +1,427 @@
|
||||
# CHANGELOG
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Core
|
||||
|
||||
- Consolidated the Basic Memory satellite repositories into the main
|
||||
`basic-memory` tree as the canonical source, discovery, documentation,
|
||||
issue, and release home.
|
||||
- Added root marketplace/package validation for the consolidated repository
|
||||
layout while keeping Phase 1 focused on parity.
|
||||
- Added root and package-local justfile targets so Claude Code, skills,
|
||||
Hermes, and OpenClaw builds can be verified from the monorepo.
|
||||
|
||||
### 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`.
|
||||
|
||||
### 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.
|
||||
|
||||
## v0.21.5 (2026-05-26)
|
||||
|
||||
Workspace/project routing fixes for MCP, plus a SQLite vector reindex stability fix.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **#854**: MCP project listing now keeps duplicate cloud project rows distinct by
|
||||
workspace, so only the selected workspace row inherits local project state.
|
||||
- **#853**: `write_note` returns workspace-qualified permalinks for cloud
|
||||
workspace writes, allowing follow-up `memory://` reads to route back to the
|
||||
correct workspace/project.
|
||||
- **#852**: Full SQLite vector reindex now loads `sqlite-vec` before dropping
|
||||
`vec0` virtual tables, preventing reindex crashes.
|
||||
- **#838**: Local ASGI database initialization is preloaded so MCP routing can
|
||||
safely enter local project contexts.
|
||||
|
||||
## v0.21.1 (2026-05-16)
|
||||
|
||||
CI-only release. No user-facing changes.
|
||||
|
||||
### Maintenance
|
||||
|
||||
- **#833**: Replace `mislav/bump-homebrew-formula-action` with an inline
|
||||
bash bump step. The action's `resolveRedirect` HEAD-requests
|
||||
`api.github.com /repos/.../tarball/<ref>` expecting HTTP 302; GitHub now
|
||||
returns 303 for authenticated requests on that endpoint, which broke
|
||||
the v0.21.0 Homebrew job and required a manual tap bump. The inline
|
||||
step does the same work (curl + sha256sum, clone tap, sed-bump
|
||||
`url`/`sha256`, commit, push) without any third-party dependency.
|
||||
|
||||
## v0.21.0 (2026-05-16)
|
||||
|
||||
Workspace-aware everywhere: every MCP tool and CLI command now routes through the
|
||||
same workspace/project model, the search and sync paths are noticeably faster,
|
||||
and a handful of long-standing parsing and routing footguns are gone.
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Relation parsing no longer treats unquoted multi-word text before a wikilink as a
|
||||
custom relation type. Use single-token relation types like `relates_to [[Target]]`,
|
||||
or quote multi-word relation types like `"relates to" [[Target]]` or
|
||||
`'relates to' [[Target]]`.
|
||||
- Bare list wikilinks like `- [[Target]]` now index as `links_to`.
|
||||
- Prose list items like `- some other thing [[Target]]` now index as `links_to`.
|
||||
- To preserve existing multi-word relation types on re-sync, quote them before upgrading.
|
||||
- See **#824**.
|
||||
|
||||
### Features
|
||||
|
||||
- **#816**: `bm orphan` CLI command surfaces entities whose underlying markdown
|
||||
files are gone, with a flag to clean them out.
|
||||
- **#789**: Create projects directly by cloud workspace slug from MCP
|
||||
(`create_memory_project(workspace=...)`).
|
||||
- **#757**: Discover projects across every accessible cloud workspace in MCP's
|
||||
project list — no more per-workspace blind spots.
|
||||
- **#766**: MCP tools accept training-data-friendly parameter aliases
|
||||
(`q`/`search`/`text` for `query`, etc.) so models reach for them naturally.
|
||||
- **#776**: `bm db reset` refuses to run while a `basic-memory mcp` process is
|
||||
alive, so resets can no longer corrupt an open session.
|
||||
- **#791**: Search responses include result totals so pagers can stop guessing.
|
||||
- **#719**: Cloud `note_content` tenant schema primitive lands on the backend.
|
||||
- **#715**: `bm project add` accepts a `--visibility` flag for cloud projects.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
#### Search and recent activity
|
||||
- **#832**: SQLite project deletion now sweeps `search_index`,
|
||||
`search_vector_chunks`, and `search_vector_embeddings`, so a project that
|
||||
reuses a recycled auto-increment id can't inherit the previous tenant's content.
|
||||
- **#812**: `recent_activity` orders and filters by `updated_at`, so edits bubble
|
||||
to the top instead of staying pinned to creation time.
|
||||
- **#807**: Multi-project `search_notes` is opt-in (`search_all_projects=True`);
|
||||
default search stays scoped to the resolved project.
|
||||
- **#785**: `recent_activity` caps responses and emits an explicit truncation
|
||||
footer instead of silently dropping rows.
|
||||
- **#713**: Eliminated an N+1 query in search result hydration.
|
||||
|
||||
#### Workspace / project routing
|
||||
- **#822**: `bm project list` now includes projects from every workspace, not
|
||||
just the current one.
|
||||
- **#813**, **#808**, **#806**, **#803**, **#801**, **#795**, **#790**, **#778**,
|
||||
**#777**, **#722**, **#712**, **#704**: Workspace-qualified permalink routing
|
||||
is centralized and applied consistently across `edit_note`, `delete_project`,
|
||||
`build_context`, `memory://` URLs, factory-mode project listing, cloud uploads,
|
||||
and the API client.
|
||||
|
||||
#### Sync
|
||||
- **#827**: `rclone bisync` filters from `.bmignore` are preserved across syncs.
|
||||
- **#815**: Watch service ignores hidden paths relative to the watched project,
|
||||
not just relative to `$HOME`.
|
||||
- **#814**: `scan` subprocesses no longer go through the shell, avoiding quoting
|
||||
issues with paths that contain special characters.
|
||||
- **#759**: Watch service stays inside `--project` scope.
|
||||
- **#746**: Canonical markdown is preserved during single-file sync.
|
||||
|
||||
#### Parsing
|
||||
- **#796**: Picoschema modifier descriptions (`field?(modifier, description)`)
|
||||
parse correctly.
|
||||
- **#769**: Obsidian callout blocks are skipped by the observation parser
|
||||
instead of being mis-extracted as observations.
|
||||
|
||||
#### CLI
|
||||
- **#775**: `bm project set-cloud` / `set-local` cleans up local DB state for
|
||||
the affected project.
|
||||
- **#773**: `bm cloud logout` clears `default_workspace`.
|
||||
- **#780**: `bm cloud setup` hint points at `bm cloud sync-setup`.
|
||||
- **#734**: `bm project info` shows cloud index freshness.
|
||||
- **#718**: Private cloud projects display under their `display_name` instead of
|
||||
raw UUID.
|
||||
- **#768**: `read_note` / `view_note` drop no-op pagination params from their
|
||||
signatures.
|
||||
|
||||
#### Stability
|
||||
- **#774**: `sqlite-vec` failures during init degrade gracefully to keyword-only
|
||||
search instead of crashing startup.
|
||||
- **#733**: Delete-vector and cloud-sync cleanup is now consistent.
|
||||
- **#702**: Race conditions in concurrent `delete_entity` are resolved.
|
||||
- **#724**: `external_id` is preserved when entities are re-upserted during a
|
||||
re-index.
|
||||
- **#728**: Vector init no longer issues runtime `ALTER TABLE`.
|
||||
- **#744**: `BASIC_MEMORY_CONFIG_DIR` is honored across remaining call sites.
|
||||
- **#743**: FastEmbed cache lives under the data dir instead of `/tmp`.
|
||||
- **#752**: Cloud projects report `source=cloud` in factory mode.
|
||||
- Stripped null bytes from markdown content before DB insert.
|
||||
- Allowed long `relation_type` values in API responses.
|
||||
|
||||
#### Installer
|
||||
- **#772**: Docker-compose config volume mounts under the `appuser` home.
|
||||
- **#695**: Bumped `brew outdated` timeout from 15s to 60s.
|
||||
|
||||
### Performance
|
||||
|
||||
- **#828**: CLI startup no longer pulls in the local ASGI FastAPI app when it
|
||||
isn't needed.
|
||||
- **#751**, **#726**, **#717**, **#714**, single-file/batch indexing: marked
|
||||
speedups on the sync hot path; unchanged markdown is skipped entirely.
|
||||
- **#731**, **#723**: Vector sync is faster on both backends, with tuned
|
||||
fastembed defaults.
|
||||
|
||||
### Maintenance
|
||||
|
||||
- **#825**: Dependency refresh + security hardening.
|
||||
- Updated to `fastmcp` 3.3.1.
|
||||
- **#754**: Removed in-house telemetry wrappers in favor of direct `logfire`
|
||||
usage.
|
||||
- **#736**: `ty` is now the default typechecker.
|
||||
- **#771**, **#770**, **#716**: New regression guards for vector-row cleanup,
|
||||
long relation types, and recent activity hydration.
|
||||
|
||||
## v0.20.3 (2026-03-26)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **#698**: CLI cloud commands now use API key when configured
|
||||
- `get_authenticated_headers()` only checked OAuth tokens, ignoring `config.cloud_api_key`
|
||||
- All CLI cloud commands (`upload`, `status`, `snapshot`, `restore`, etc.) failed for API-key-only users while MCP tools worked fine
|
||||
- Now mirrors the same credential priority as MCP: API key first, OAuth fallback
|
||||
- Fixes `bm cloud upload --project` returning "project does not exist" when authenticated with `bmc_*` API key
|
||||
|
||||
## v0.20.2 (2026-03-10)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix auto-update Homebrew detection: `brew outdated` exits 1 when a formula is outdated, not on error
|
||||
- Previously treated exit code 1 as a failure, causing "Automatic update check failed" instead of detecting the available update
|
||||
|
||||
## v0.20.1 (2026-03-10)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **#661**: Fix `bm project list` MCP column to show transport type (stdio/https) instead of DB presence
|
||||
- Renamed "MCP (stdio)" column to "MCP"
|
||||
- Shows actual routing mode: `stdio` for local, `https` for cloud projects
|
||||
- Clears local path display for cloud-mode projects
|
||||
- **#662**: Invalidate config cache when file is modified by another process
|
||||
- Adds mtime-based cache validation to `ConfigManager.load_config()`
|
||||
- Long-lived processes (MCP stdio server) now detect external config changes
|
||||
- Fixes `bm project set-cloud` having no effect on running MCP server
|
||||
|
||||
## v0.20.0 (2026-03-10)
|
||||
|
||||
### Features
|
||||
|
||||
- **#643**: Default-on auto-update system and `bm update` command
|
||||
- Automatic background update checks for CLI installs (uv tool, Homebrew)
|
||||
- Install-source detection (homebrew, uv_tool, uvx, unknown) with uvx skip behavior
|
||||
- Periodic check gating via `auto_update_last_checked_at` + `update_check_interval` config
|
||||
- Manager-specific update flows: Homebrew (`brew upgrade`) and uv tool (`uv tool upgrade`)
|
||||
- Silent, non-blocking MCP behavior via daemon thread before server run
|
||||
- Manual commands: `bm update` (force check + apply) and `bm update --check` (check only)
|
||||
- New config fields: `auto_update`, `update_check_interval`, `auto_update_last_checked_at`
|
||||
|
||||
## v0.19.2 (2026-03-09)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **#657**: Coerce string params to list/dict in MCP tools
|
||||
- MCP clients that serialize `list`/`dict` arguments as JSON strings no longer fail Pydantic validation
|
||||
- Adds `BeforeValidator` coercion to `search_notes` (`entity_types`, `note_types`, `tags`, `metadata_filters`), `write_note` (`metadata`), and `canvas` (`nodes`, `edges`)
|
||||
- **#655**: Handle SQLite and Windows semantic search regressions
|
||||
- Fix embedding status query for non-semantic SQLite databases
|
||||
- Windows-safe log file rotation with per-process log filenames
|
||||
- Robust `setup_logging` that handles all environments cleanly
|
||||
|
||||
## v0.19.1 (2026-03-08)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **#649**: Enforce strict entity resolution in destructive MCP tools (`edit_note`, `move_note`, `delete_note`)
|
||||
- Prevents fuzzy-match fallback from silently editing/moving/deleting the wrong note
|
||||
- DST-related timeframe validation fix (round instead of truncate days)
|
||||
|
||||
### Features
|
||||
|
||||
- **#648**: Add `insert_before_section` and `insert_after_section` edit operations
|
||||
- Add `GET /knowledge/graph` endpoint for full graph visualization
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Bump authlib from 1.6.6 to 1.6.7
|
||||
|
||||
## v0.19.0 (2026-03-07)
|
||||
|
||||
### Highlights
|
||||
|
||||
- **Semantic vector search** for SQLite and Postgres with FastEmbed embeddings
|
||||
- **Schema system** for validating and inferring knowledge base structure
|
||||
- **Per-project cloud routing** with API key authentication
|
||||
- **Upgraded to FastMCP 3.0** with tool annotations
|
||||
- **CLI overhaul** with JSON output, workspace awareness, and project dashboard
|
||||
|
||||
### Features
|
||||
|
||||
- **#550**: Add semantic vector search for SQLite and Postgres
|
||||
- FastEmbed-based embeddings with automatic backfill
|
||||
- Hybrid search combining full-text and vector similarity
|
||||
- Score-based fusion replacing RRF for better ranking
|
||||
- `min_similarity` override for tuning search precision
|
||||
- Semantic dependencies are now default, with optional extras fallback
|
||||
|
||||
- **#549**: Schema system for Basic Memory
|
||||
- `schema_infer` — infer schema from existing notes
|
||||
- `schema_validate` — validate notes against a schema definition
|
||||
- `schema_diff` — compare schemas across projects
|
||||
- Frontmatter validation support (#597)
|
||||
- Read schema definitions from file instead of stale DB metadata (#635)
|
||||
|
||||
- **#555**: Per-project local/cloud routing with API key auth
|
||||
- Individual projects route through cloud while others stay local
|
||||
- `basic-memory cloud set-key` and `basic-memory project set-cloud/set-local`
|
||||
- Stdio MCP honors per-project cloud routing (#590)
|
||||
|
||||
- **#598**: Upgrade FastMCP 2.12.3 to 3.0.1 with tool annotations
|
||||
|
||||
- **#585**: Add JSON output mode for MCP tools (default text)
|
||||
- `--json` output for CLI commands for scripting and CI
|
||||
|
||||
- **#576**: Add workspace selection flow for MCP and CLI
|
||||
- Workspace-aware cloud project listing
|
||||
- CLI refactoring for workspace support
|
||||
|
||||
- **#544**: Project-prefixed permalinks and memory URL routing
|
||||
|
||||
- **#632**: Add overwrite guard to `write_note` tool
|
||||
|
||||
- **#614**: `edit_note` append/prepend auto-creates note if not found
|
||||
|
||||
- **#609**: Richer content context in search results
|
||||
- Return matched chunk text in search results (#601)
|
||||
- Improved content hit rate
|
||||
|
||||
- **#602**: Add `created_by` and `last_updated_by` user tracking to Entity
|
||||
|
||||
- **#600**: Rename `entity_type` to `note_type` across codebase
|
||||
|
||||
- **#574**: Add `display_name` and `is_private` to ProjectItem
|
||||
|
||||
- **#569**: Expose `external_id` in EntityResponse and link resolver
|
||||
|
||||
- **#567**: Isolate default SQLite DB by config dir
|
||||
|
||||
- **#560**: Enable `default_project_mode` by default
|
||||
|
||||
- **#559**: Add `basic-memory watch` CLI command
|
||||
|
||||
- **#546**: Add cloud discovery touchpoints to CLI and MCP
|
||||
|
||||
- **#572**: CLI analytics via Umami event collector
|
||||
|
||||
- Replace project info with htop-inspired dashboard
|
||||
|
||||
- Merge `search_by_metadata` into `search_notes` with optional query
|
||||
|
||||
- Add `--strip-frontmatter` to `basic-memory tool read-note`
|
||||
|
||||
- Add `destination_folder` parameter to `move_note` tool
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **#644**: Fix default project resolution in cloud mode
|
||||
- ChatGPT search/fetch tools broken in cloud mode
|
||||
- `resolve_project_parameter` falls back to projects API
|
||||
|
||||
- **#638**: Restore API backward compatibility for v0.18.x clients
|
||||
|
||||
- **#637**: Create backup before config migration overwrites old format
|
||||
|
||||
- **#636**: `list_workspaces` bypasses factory pattern on cloud MCP server
|
||||
|
||||
- **#631**: `build_context` related_results schema validation failure
|
||||
|
||||
- **#613**: Reduce excessive log volume by demoting per-request noise to DEBUG
|
||||
|
||||
- **#612**: Handle quoted picoschema enum strings in YAML frontmatter
|
||||
|
||||
- **#607**: Guard against closed streams in promo and missing vector tables
|
||||
|
||||
- **#606**: Accept null for `expected_replacements` in `edit_note`
|
||||
|
||||
- **#595**: `recent_activity` dedup and pagination across MCP tools
|
||||
|
||||
- **#593**: Backend-specific distance-to-similarity conversion
|
||||
|
||||
- **#582**: Use LinkResolver fallback in `build_context` for flexible identifier matching
|
||||
|
||||
- **#577**: Replace RRF with score-based fusion in hybrid search
|
||||
|
||||
- **#575**: Remove hardcoded "main" default from `default_project`
|
||||
|
||||
- **#534**: Speed up `bm --version` startup
|
||||
|
||||
- Fix semantic embeddings not generated on fresh DB or upgrade
|
||||
|
||||
- Clarify `search_notes` parameter naming and fix `note_types` case sensitivity
|
||||
|
||||
- Parse `tag:` prefix at MCP tool level to avoid hybrid search failure
|
||||
|
||||
- Cap sqlite-vec knn k parameter at 4096 limit
|
||||
|
||||
- Parameterize SQL queries in search repository type filters
|
||||
|
||||
- Coerce list frontmatter values to strings for title and type fields
|
||||
|
||||
- Avoid `Post(**metadata)` crash when frontmatter contains 'content' or 'handler' keys
|
||||
|
||||
- Upgrade cryptography and python-multipart for security advisories
|
||||
|
||||
### Internal
|
||||
|
||||
- **#594**: Add `ty` as supplemental type checker
|
||||
- Batched vector sync orchestration across repositories
|
||||
- FastEmbed parallel guardrails and provider caching
|
||||
- Improved cloud CLI status and error messages
|
||||
- CI coverage and Postgres test fixes
|
||||
|
||||
## v0.18.5 (2026-02-13)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Strip NUL bytes from content before PostgreSQL search indexing
|
||||
([`ec9b2c4`](https://github.com/basicmachines-co/basic-memory/commit/ec9b2c4))
|
||||
|
||||
## v0.18.4 (2026-02-12)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Use global `--header` flag for Tigris consistency on all rclone transactions
|
||||
([`0eae0e1`](https://github.com/basicmachines-co/basic-memory/commit/0eae0e1))
|
||||
- `--header-download` / `--header-upload` only apply to GET/PUT requests, missing S3
|
||||
ListObjectsV2 calls that bisync issues first. Non-US users saw stale edge-cached metadata.
|
||||
- `--header` applies to ALL HTTP transactions (list, download, upload), fixing bisync for
|
||||
users outside the Tigris origin region.
|
||||
|
||||
## v0.18.2 (2026-02-11)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **#562**: Use VIRTUAL instead of STORED columns in SQLite migration
|
||||
([`344e651`](https://github.com/basicmachines-co/basic-memory/commit/344e651))
|
||||
- Fixes compatibility issue with SQLite STORED generated columns
|
||||
|
||||
## v0.18.1 (2026-02-11)
|
||||
|
||||
### Features
|
||||
@@ -1891,12 +2313,12 @@ Signed-off-by: phernandez <paul@basicmachines.co>
|
||||
- Update CLAUDE.md ([#33](https://github.com/basicmachines-co/basic-memory/pull/33),
|
||||
[`dfaf0fe`](https://github.com/basicmachines-co/basic-memory/commit/dfaf0fea9cf5b97d169d51a6276ec70162c21a7e))
|
||||
|
||||
fix spelling in CLAUDE.md: enviroment -> environment Signed-off-by: Ikko Eltociear Ashimine
|
||||
fix spelling in CLAUDE.md: environment typo Signed-off-by: Ikko Eltociear Ashimine
|
||||
<eltociear@gmail.com>
|
||||
|
||||
### Refactoring
|
||||
|
||||
- Move project stats into projct subcommand
|
||||
- Move project stats into project subcommand
|
||||
([`2a881b1`](https://github.com/basicmachines-co/basic-memory/commit/2a881b1425c73947f037fbe7ac5539c015b62526))
|
||||
|
||||
Signed-off-by: phernandez <paul@basicmachines.co>
|
||||
@@ -2325,7 +2747,7 @@ Co-authored-by: phernandez <phernandez@basicmachines.co>
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Refix vitual env in installer build
|
||||
- Refix virtual env in installer build
|
||||
([`052f491`](https://github.com/basicmachines-co/basic-memory/commit/052f491fff629e8ead629c9259f8cb46c608d584))
|
||||
|
||||
|
||||
@@ -2344,7 +2766,7 @@ Co-authored-by: phernandez <phernandez@basicmachines.co>
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix path to intaller app artifact
|
||||
- Fix path to installer app artifact
|
||||
([`53d220d`](https://github.com/basicmachines-co/basic-memory/commit/53d220df585561f9edd0d49a9e88f1d4055059cf))
|
||||
|
||||
|
||||
@@ -2352,7 +2774,7 @@ Co-authored-by: phernandez <phernandez@basicmachines.co>
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Activate vitualenv in installer build
|
||||
- Activate virtualenv in installer build
|
||||
([`d4c8293`](https://github.com/basicmachines-co/basic-memory/commit/d4c8293687a52eaf3337fe02e2f7b80e4cc9a1bb))
|
||||
|
||||
- Trigger installer build on release
|
||||
|
||||
@@ -224,40 +224,6 @@ See `test-int/BENCHMARKS.md` for detailed benchmark documentation.
|
||||
- **Fixtures**: Use async pytest fixtures for setup and teardown
|
||||
- **Markers**: Use `@pytest.mark.benchmark` for benchmarks, `@pytest.mark.slow` for slow tests
|
||||
|
||||
## Release Process
|
||||
|
||||
Basic Memory uses automatic versioning based on git tags with `uv-dynamic-versioning`. Here's how releases work:
|
||||
|
||||
### Version Management
|
||||
- **Development versions**: Automatically generated from git commits (e.g., `0.12.4.dev26+468a22f`)
|
||||
- **Beta releases**: Created by tagging with beta suffixes (e.g., `git tag v0.13.0b1`)
|
||||
- **Stable releases**: Created by tagging with version numbers (e.g., `git tag v0.13.0`)
|
||||
|
||||
### Release Workflows
|
||||
|
||||
#### Development Builds
|
||||
- Automatically published to PyPI on every commit to `main`
|
||||
- Version format: `0.12.4.dev26+468a22f` (base version + dev + commit count + hash)
|
||||
- Users install with: `pip install basic-memory --pre --force-reinstall`
|
||||
|
||||
#### Beta Releases
|
||||
1. Create and push a beta tag: `git tag v0.13.0b1 && git push origin v0.13.0b1`
|
||||
2. GitHub Actions automatically builds and publishes to PyPI
|
||||
3. Users install with: `pip install basic-memory --pre`
|
||||
|
||||
#### Stable Releases
|
||||
1. Create and push a version tag: `git tag v0.13.0 && git push origin v0.13.0`
|
||||
2. GitHub Actions automatically:
|
||||
- Builds the package with version `0.13.0`
|
||||
- Creates GitHub release with auto-generated notes
|
||||
- Publishes to PyPI
|
||||
3. Users install with: `pip install basic-memory`
|
||||
|
||||
### For Contributors
|
||||
- No manual version bumping required
|
||||
- Versions are automatically derived from git tags
|
||||
- Focus on code changes, not version management
|
||||
|
||||
## Creating Issues
|
||||
|
||||
If you're planning to work on something, please create an issue first to discuss the approach. Include:
|
||||
|
||||
+494
@@ -0,0 +1,494 @@
|
||||
# Note Format Reference
|
||||
|
||||
Every document in Basic Memory is a plain Markdown file. Files are the source of truth — changes to files automatically update the knowledge graph in the database. You maintain complete ownership, files work with git, and knowledge persists independently of any AI conversation.
|
||||
|
||||
## Document Structure
|
||||
|
||||
A note has three parts: YAML frontmatter, content (observations), and relations.
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: Coffee Brewing Methods
|
||||
type: note
|
||||
tags: [coffee, brewing]
|
||||
permalink: coffee-brewing-methods
|
||||
---
|
||||
|
||||
# Coffee Brewing Methods
|
||||
|
||||
## Observations
|
||||
- [method] Pour over provides more flavor clarity than French press
|
||||
- [technique] Water temperature at 205°F extracts optimal compounds #brewing
|
||||
- [preference] Ethiopian beans work well with lighter roasts (personal experience)
|
||||
|
||||
## Relations
|
||||
- relates_to [[Coffee Bean Origins]]
|
||||
- requires [[Proper Grinding Technique]]
|
||||
- contrasts_with [[Tea Brewing Methods]]
|
||||
```
|
||||
|
||||
The `## Observations` and `## Relations` headings are conventional but not required — the parser detects observations and relations by their syntax patterns anywhere in the document.
|
||||
|
||||
## Frontmatter
|
||||
|
||||
YAML metadata between `---` fences at the top of the file.
|
||||
|
||||
| Field | Required | Default | Description |
|
||||
|-------|----------|---------|-------------|
|
||||
| `title` | No | filename stem | Used for linking and references. Auto-set from filename if missing. |
|
||||
| `type` | No | `note` | Entity type. Used for schema resolution and filtering. |
|
||||
| `tags` | No | `[]` | List or comma-separated string. Used for organization and search. |
|
||||
| `permalink` | No | generated from title | Stable identifier. Persists even if the file moves. |
|
||||
| `schema` | No | none | Schema attachment — dict (inline), string (reference), or omitted (implicit). |
|
||||
|
||||
Custom fields are allowed. Any key not in the standard set is stored as `entity_metadata` and indexed for search and filtering.
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: Paul Graham
|
||||
type: Person
|
||||
tags: [startups, essays, lisp]
|
||||
permalink: paul-graham
|
||||
status: active
|
||||
source: wikipedia
|
||||
---
|
||||
```
|
||||
|
||||
Here `status` and `source` are custom fields stored in `entity_metadata`.
|
||||
|
||||
### Frontmatter Value Handling
|
||||
|
||||
YAML automatically converts some values to native types. Basic Memory normalizes them:
|
||||
|
||||
- Date strings (`2025-10-24`) → kept as ISO format strings
|
||||
- Numbers (`1.0`) → converted to strings
|
||||
- Booleans (`true`) → converted to strings (`"True"`)
|
||||
- Lists and dicts → preserved, items normalized recursively
|
||||
|
||||
This prevents errors when downstream code expects string values.
|
||||
|
||||
## Observations
|
||||
|
||||
An observation is a categorized fact about the entity. Written as a Markdown list item.
|
||||
|
||||
**Syntax:**
|
||||
|
||||
```
|
||||
- [category] content text #tag1 #tag2 (context)
|
||||
```
|
||||
|
||||
| Part | Required | Description |
|
||||
|------|----------|-------------|
|
||||
| `[category]` | Yes | Classification in square brackets. Any text except `[]()` chars. |
|
||||
| content | Yes | The fact or statement. |
|
||||
| `#tags` | No | Inline tags. Space-separated, each starting with `#`. |
|
||||
| `(context)` | No | Parenthesized text at end of line. Supporting details or source. |
|
||||
|
||||
### Examples
|
||||
|
||||
```markdown
|
||||
- [tech] Uses SQLite for storage #database
|
||||
- [design] Follows local-first architecture #architecture
|
||||
- [decision] Selected bcrypt for passwords #security (based on OWASP audit)
|
||||
- [name] Paul Graham
|
||||
- [expertise] Startups
|
||||
- [expertise] Lisp
|
||||
- [expertise] Essay writing
|
||||
```
|
||||
|
||||
Array-like fields use repeated categories — multiple `[expertise]` observations above.
|
||||
|
||||
### What Is Not an Observation
|
||||
|
||||
The parser excludes these list item patterns:
|
||||
|
||||
| Pattern | Example | Reason |
|
||||
|---------|---------|--------|
|
||||
| Checkboxes | `- [ ] Todo item`, `- [x] Done`, `- [-] Cancelled` | Task list syntax |
|
||||
| Markdown links | `- [text](url)` | URL link syntax |
|
||||
| Bare wiki links | `- [[Target]]` | Treated as a relation instead |
|
||||
|
||||
A list item with `#tags` but no `[category]` is still parsed — the tags are extracted and the category defaults to `Note`.
|
||||
|
||||
## Relations
|
||||
|
||||
Relations connect documents to form the knowledge graph. There are two kinds.
|
||||
|
||||
### Explicit Relations
|
||||
|
||||
Written as list items with a relation type and a `[[wiki link]]` target.
|
||||
|
||||
**Syntax:**
|
||||
|
||||
```
|
||||
- relation_type [[Target Entity]] (context)
|
||||
```
|
||||
|
||||
| Part | Required | Description |
|
||||
|------|----------|-------------|
|
||||
| `relation_type` | No | Text before `[[`. Defaults to `relates_to` if omitted. |
|
||||
| `[[Target]]` | Yes | Wiki link to the target entity. Matched by title or permalink. |
|
||||
| `(context)` | No | Parenthesized text after `]]`. Supporting details. |
|
||||
|
||||
### Examples
|
||||
|
||||
```markdown
|
||||
- implements [[Search Design]]
|
||||
- depends_on [[Database Schema]]
|
||||
- works_at [[Y Combinator]] (co-founder)
|
||||
- [[Some Entity]]
|
||||
```
|
||||
|
||||
The last example — a bare `[[wiki link]]` in a list item — gets relation type `relates_to`.
|
||||
|
||||
Common relation types:
|
||||
- `implements`, `depends_on`, `relates_to`, `inspired_by`
|
||||
- `extends`, `part_of`, `contains`, `pairs_with`
|
||||
- `works_at`, `authored`, `collaborated_with`
|
||||
|
||||
Any text works as a relation type. These are conventions, not a fixed set.
|
||||
|
||||
### Inline References
|
||||
|
||||
Wiki links appearing in regular prose (not as list items) create implicit `links_to` relations.
|
||||
|
||||
```markdown
|
||||
This builds on [[Core Design]] and uses [[Utility Functions]].
|
||||
```
|
||||
|
||||
This creates two relations: `links_to [[Core Design]]` and `links_to [[Utility Functions]]`.
|
||||
|
||||
### Forward References
|
||||
|
||||
Relations can link to entities that don't exist yet. Basic Memory resolves them when the target is created.
|
||||
|
||||
## Permalinks and memory:// URLs
|
||||
|
||||
Every document has a unique **permalink** — a stable identifier derived from its title. You can set one explicitly in frontmatter, or let the system generate it.
|
||||
|
||||
```yaml
|
||||
permalink: auth-approaches-2024
|
||||
```
|
||||
|
||||
Permalinks form the basis of `memory://` URLs:
|
||||
|
||||
```
|
||||
memory://auth-approaches-2024 # By permalink
|
||||
memory://Authentication Approaches # By title (auto-resolves)
|
||||
memory://project/auth-approaches # By path
|
||||
```
|
||||
|
||||
Pattern matching is supported:
|
||||
|
||||
```
|
||||
memory://auth* # Starts with "auth"
|
||||
memory://*/approaches # Ends with "approaches"
|
||||
memory://project/*/requirements # Nested wildcard
|
||||
```
|
||||
|
||||
## Schemas
|
||||
|
||||
Schemas declare the expected structure of a note — which observation categories and relation types a well-formed note should have. They use Picoschema, a compact notation from Google's Dotprompt that fits naturally in YAML frontmatter.
|
||||
|
||||
### Picoschema Syntax
|
||||
|
||||
```yaml
|
||||
schema:
|
||||
name: string, full name # required field with description
|
||||
email?: string, contact email # ? = optional
|
||||
role?: string, job title
|
||||
works_at?: Organization, employer # capitalized type = entity reference
|
||||
tags?(array): string, categories # array of type
|
||||
status?(enum): [active, inactive] # enum with allowed values
|
||||
metadata?(object): # nested object
|
||||
updated_at?: string
|
||||
source?: string
|
||||
```
|
||||
|
||||
| Notation | Meaning | Example |
|
||||
|----------|---------|---------|
|
||||
| `field: type` | Required field | `name: string` |
|
||||
| `field?: type` | Optional field | `role?: string` |
|
||||
| `field(array): type` | Array of values | `expertise(array): string` |
|
||||
| `field?(enum): [vals]` | Enum with allowed values | `status?(enum): [active, inactive]` |
|
||||
| `field?(object):` | Nested object with sub-fields | `metadata?(object):` |
|
||||
| `, description` | Description after comma | `name: string, full name` |
|
||||
| `EntityName` | Capitalized type = entity reference | `works_at?: Organization` |
|
||||
|
||||
**Scalar types:** `string`, `integer`, `number`, `boolean`, `any`
|
||||
|
||||
Any type not in that set whose first letter is uppercase is treated as an entity reference (a relation target).
|
||||
|
||||
### Schema-to-Note Mapping
|
||||
|
||||
Schemas validate against existing observation/relation syntax. Note authors don't learn new syntax.
|
||||
|
||||
| Schema Declaration | Maps To | Example in Note |
|
||||
|--------------------|---------|-----------------|
|
||||
| `field: string` | Observation `[field] value` | `- [name] Paul Graham` |
|
||||
| `field?(array): string` | Multiple `[field]` observations | `- [expertise] Lisp` (repeated) |
|
||||
| `field?: EntityType` | Relation `field [[Target]]` | `- works_at [[Y Combinator]]` |
|
||||
| `field?(array): EntityType` | Multiple `field` relations | `- authored [[Book]]` (repeated) |
|
||||
| `tags` | Frontmatter `tags` array | `tags: [startups, essays]` |
|
||||
| `field?(enum): [vals]` | Observation `[field] value` where value is in the set | `- [status] active` |
|
||||
|
||||
Observations and relations not covered by the schema are valid — schemas describe a subset, not a straitjacket.
|
||||
|
||||
### Schema Attachment
|
||||
|
||||
Three ways to attach a schema to a note, resolved in priority order:
|
||||
|
||||
**1. Inline schema** — `schema` is a dict in frontmatter:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: Team Standup 2024-01-15
|
||||
type: meeting
|
||||
schema:
|
||||
attendees(array): string, who was there
|
||||
decisions(array): string, what was decided
|
||||
action_items(array): string, follow-ups
|
||||
blockers?(array): string, anything stuck
|
||||
---
|
||||
```
|
||||
|
||||
Good for one-off structured notes or prototyping a schema before extracting it.
|
||||
|
||||
**2. Explicit reference** — `schema` is a string naming a schema note:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: Basic Memory
|
||||
schema: SoftwareProject
|
||||
---
|
||||
```
|
||||
|
||||
or by permalink:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: LLM Memory Patterns
|
||||
schema: schema/research-project
|
||||
---
|
||||
```
|
||||
|
||||
Use when the note's `type` differs from the schema it should validate against, or when multiple schema variants exist.
|
||||
|
||||
**3. Implicit by type** — no `schema` field, resolved by matching `type`:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: Paul Graham
|
||||
type: Person
|
||||
---
|
||||
```
|
||||
|
||||
The system looks up a schema note where `entity: Person`. If found, it applies. If not, no validation occurs.
|
||||
|
||||
**4. No schema** — perfectly fine. Most notes don't need one.
|
||||
|
||||
### Schema Notes
|
||||
|
||||
A schema is itself a Basic Memory note with `type: schema`. It lives anywhere (though `schema/` is the conventional directory).
|
||||
|
||||
```yaml
|
||||
# schema/Person.md
|
||||
---
|
||||
title: Person
|
||||
type: schema
|
||||
entity: Person
|
||||
version: 1
|
||||
schema:
|
||||
name: string, full name
|
||||
role?: string, job title or position
|
||||
works_at?: Organization, employer
|
||||
expertise?(array): string, areas of knowledge
|
||||
email?: string, contact email
|
||||
settings:
|
||||
validation: warn
|
||||
---
|
||||
|
||||
# Person
|
||||
|
||||
A human individual in the knowledge graph.
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `type` | Yes | Must be `schema` |
|
||||
| `entity` | Yes | The entity type this schema describes (e.g., `Person`) |
|
||||
| `version` | No | Schema version number (default: `1`) |
|
||||
| `schema` | Yes | Picoschema dict defining the fields |
|
||||
| `settings.validation` | No | Validation mode (default: `warn`) |
|
||||
|
||||
Schema notes are regular notes — they show up in search, can have observations and relations, and participate in the knowledge graph.
|
||||
|
||||
### Validation Modes
|
||||
|
||||
| Mode | Behavior |
|
||||
|------|----------|
|
||||
| `warn` | Warnings in output, doesn't block (default) |
|
||||
| `strict` | Errors that block sync, for CI/CD enforcement |
|
||||
| `off` | No validation |
|
||||
|
||||
### Validation Output
|
||||
|
||||
```
|
||||
$ bm schema validate people/ada-lovelace.md
|
||||
|
||||
⚠ Person schema validation:
|
||||
- Missing required field: name (expected [name] observation)
|
||||
- Missing optional field: role
|
||||
- Missing optional field: works_at (no relation found)
|
||||
|
||||
ℹ Unmatched observations: [fact] ×2, [born] ×1
|
||||
ℹ Unmatched relations: collaborated_with
|
||||
```
|
||||
|
||||
"Unmatched" items are informational — observations and relations the schema doesn't cover.
|
||||
|
||||
### Schema Inference
|
||||
|
||||
Generate schemas from existing notes by analyzing observation and relation frequency:
|
||||
|
||||
```
|
||||
$ bm schema infer Person
|
||||
|
||||
Analyzing 30 notes with type: Person...
|
||||
|
||||
Observations found:
|
||||
[name] 30/30 100% → name: string
|
||||
[role] 27/30 90% → role?: string
|
||||
[expertise] 18/30 60% → expertise?(array): string
|
||||
[email] 8/30 27% → email?: string
|
||||
|
||||
Relations found:
|
||||
works_at 22/30 73% → works_at?: Organization
|
||||
|
||||
Suggested schema:
|
||||
name: string, full name
|
||||
role?: string, job title
|
||||
expertise?(array): string, areas of knowledge
|
||||
email?: string, contact email
|
||||
works_at?: Organization, employer
|
||||
|
||||
Save to schema/Person.md? [y/n]
|
||||
```
|
||||
|
||||
Frequency thresholds:
|
||||
- **100% present** → required field
|
||||
- **25%+ present** → optional field
|
||||
- **Below 25%** → excluded from suggestion
|
||||
|
||||
### Schema Drift Detection
|
||||
|
||||
Track how usage patterns shift over time:
|
||||
|
||||
```
|
||||
$ bm schema diff Person
|
||||
|
||||
Schema drift detected:
|
||||
|
||||
+ expertise: now in 81% of notes (was 12%)
|
||||
- department: dropped to 3% of notes
|
||||
~ works_at: cardinality changed (one → many)
|
||||
|
||||
Update schema? [y/n/review]
|
||||
```
|
||||
|
||||
## Complete Examples
|
||||
|
||||
### Simple Note (No Schema)
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: Project Ideas
|
||||
type: note
|
||||
tags: [ideas, brainstorm]
|
||||
---
|
||||
|
||||
# Project Ideas
|
||||
|
||||
## Observations
|
||||
- [idea] Build a CLI tool for markdown linting #tooling
|
||||
- [idea] Create a recipe knowledge base #cooking
|
||||
- [priority] Focus on developer tools first (Q1 goal)
|
||||
|
||||
## Relations
|
||||
- inspired_by [[Developer Workflow Research]]
|
||||
- part_of [[Q1 Planning]]
|
||||
```
|
||||
|
||||
### Schema-Validated Note
|
||||
|
||||
Schema at `schema/Person.md`:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: Person
|
||||
type: schema
|
||||
entity: Person
|
||||
version: 1
|
||||
schema:
|
||||
name: string, full name
|
||||
role?: string, job title or position
|
||||
works_at?: Organization, employer
|
||||
expertise?(array): string, areas of knowledge
|
||||
email?: string, contact email
|
||||
settings:
|
||||
validation: warn
|
||||
---
|
||||
|
||||
# Person
|
||||
|
||||
A human individual in the knowledge graph.
|
||||
```
|
||||
|
||||
Note at `people/paul-graham.md`:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: Paul Graham
|
||||
type: Person
|
||||
tags: [startups, essays, lisp]
|
||||
---
|
||||
|
||||
# Paul Graham
|
||||
|
||||
## Observations
|
||||
- [name] Paul Graham
|
||||
- [role] Essayist and investor
|
||||
- [expertise] Startups
|
||||
- [expertise] Lisp
|
||||
- [expertise] Essay writing
|
||||
- [fact] Created Viaweb, the first web app
|
||||
|
||||
## Relations
|
||||
- works_at [[Y Combinator]]
|
||||
- authored [[Hackers and Painters]]
|
||||
```
|
||||
|
||||
The `[fact]` observation and `authored` relation are not in the schema — they're valid, just unmatched. The schema only checks that `[name]` exists (required) and looks for optional fields like `[role]`, `[expertise]`, and `works_at`.
|
||||
|
||||
### Inline Schema Note
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: Team Standup 2024-01-15
|
||||
type: meeting
|
||||
schema:
|
||||
attendees(array): string, who was there
|
||||
decisions(array): string, what was decided
|
||||
action_items(array): string, follow-ups
|
||||
blockers?(array): string, anything stuck
|
||||
---
|
||||
|
||||
# Team Standup 2024-01-15
|
||||
|
||||
## Observations
|
||||
- [attendees] Paul
|
||||
- [attendees] Sarah
|
||||
- [decisions] Ship v2 by Friday
|
||||
- [action_items] Paul to review PR #42
|
||||
- [blockers] Waiting on API credentials
|
||||
```
|
||||
+67
-2
@@ -8,6 +8,71 @@
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Use this section to tell people how to report a vulnerability.
|
||||
If you find a vulnerability, please contact hello@basicmachines.co.
|
||||
|
||||
If you find a vulnerability, please contact hello@basicmachines.co
|
||||
Please do not open a public GitHub issue for security vulnerabilities. We aim
|
||||
to respond within 72 hours and will coordinate a fix and disclosure timeline
|
||||
with you.
|
||||
|
||||
## Threat Model
|
||||
|
||||
Basic Memory is a local-first MCP server that reads and writes markdown files
|
||||
inside configured project directories. It runs on your machine with your user
|
||||
permissions, so local configuration deserves the same care as any other
|
||||
developer tool that can access your files.
|
||||
|
||||
### What Basic Memory Controls
|
||||
|
||||
- Filesystem-touching tools validate paths against the configured project root
|
||||
with `validate_project_path()`, resolved paths, and `Path.is_relative_to()`.
|
||||
Path traversal attempts such as `../../etc/passwd` are blocked at this layer.
|
||||
- Scan optimizations in `sync_service.py` call `find` through
|
||||
`asyncio.create_subprocess_exec()` with explicit argument lists. Project paths
|
||||
are passed as data, not interpolated into shell strings.
|
||||
- Auto-update code uses hardcoded commands, list-form arguments, and
|
||||
`stdin=DEVNULL`. User-controlled strings do not reach a shell there.
|
||||
|
||||
### MCP Client-Side Risk
|
||||
|
||||
Recent MCP ecosystem research has highlighted a client-side pattern where an
|
||||
MCP host can be configured to run arbitrary commands as "servers." That risk is
|
||||
in the host configuration, not in notes or Basic Memory tool input.
|
||||
|
||||
The recommended Basic Memory MCP configuration uses a known command with
|
||||
explicit arguments:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"basic-memory": {
|
||||
"command": "uvx",
|
||||
"args": ["basic-memory", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Only add MCP server entries from sources you trust. Avoid inline shell scripts
|
||||
or command strings copied from untrusted sources. Treat third-party MCP server
|
||||
configuration with the same scrutiny as any locally executed program.
|
||||
|
||||
Related ecosystem context:
|
||||
|
||||
- OX Security: The Mother of All AI Supply Chains
|
||||
- CSO Online: RCE by design: MCP architectural choice haunts AI agent ecosystem
|
||||
|
||||
### Out Of Scope
|
||||
|
||||
- Basic Memory does not execute note content as code. Notes are returned as
|
||||
data to the LLM.
|
||||
- Basic Memory does not open network ports by default. The MCP server uses
|
||||
stdio; the optional REST API is intended for localhost use.
|
||||
- Basic Memory is designed for single-user local knowledge bases and does not
|
||||
implement access controls between operating-system users.
|
||||
|
||||
## Secure Configuration Checklist
|
||||
|
||||
- MCP config `command` points to `uvx` or a trusted binary, not a shell string.
|
||||
- Project paths in Basic Memory config come from trusted local configuration.
|
||||
- If exposing the REST API, bind it only to localhost.
|
||||
- Review any third-party MCP servers before adding them to your host config.
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# Docker Compose configuration for Basic Memory with PostgreSQL
|
||||
# Use this for local development and testing with Postgres backend
|
||||
# Use this for local development and testing with Postgres backend.
|
||||
#
|
||||
# The Postgres backend requires the pgvector extension (semantic search).
|
||||
# This image bundles pgvector; plain postgres:17 will not work for vector search.
|
||||
#
|
||||
# Usage:
|
||||
# docker-compose -f docker-compose-postgres.yml up -d
|
||||
@@ -7,7 +10,7 @@
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17
|
||||
image: pgvector/pgvector:pg17
|
||||
container_name: basic-memory-postgres
|
||||
environment:
|
||||
# Local development/test credentials - NOT for production
|
||||
|
||||
+3
-1
@@ -17,7 +17,9 @@ services:
|
||||
volumes:
|
||||
|
||||
# Persistent storage for configuration and database
|
||||
- basic-memory-config:/root/.basic-memory:rw
|
||||
# Container runs as `appuser` (Dockerfile USER directive), so the CLI
|
||||
# config dir lives under /home/appuser, not /root.
|
||||
- basic-memory-config:/home/appuser/.basic-memory:rw
|
||||
|
||||
# Mount your knowledge directory (required)
|
||||
# Change './knowledge' to your actual Obsidian vault or knowledge directory
|
||||
|
||||
+29
-12
@@ -18,7 +18,7 @@ Each entrypoint uses a **composition root** pattern to manage configuration and
|
||||
A composition root is the single place in an application where dependencies are wired together. In Basic Memory, each entrypoint has its own composition root that:
|
||||
|
||||
1. Reads configuration from `ConfigManager`
|
||||
2. Resolves runtime mode (cloud/local/test)
|
||||
2. Resolves runtime mode (local/test)
|
||||
3. Creates and provides dependencies to downstream code
|
||||
|
||||
**Key principle**: Only composition roots read global configuration. All other modules receive configuration explicitly.
|
||||
@@ -52,10 +52,7 @@ class Container:
|
||||
def create(cls) -> "Container":
|
||||
"""Create container by reading ConfigManager."""
|
||||
config = ConfigManager().config
|
||||
mode = resolve_runtime_mode(
|
||||
cloud_mode_enabled=config.cloud_mode_enabled,
|
||||
is_test_env=config.is_test_env,
|
||||
)
|
||||
mode = resolve_runtime_mode(is_test_env=config.is_test_env)
|
||||
return cls(config=config, mode=mode)
|
||||
|
||||
@property
|
||||
@@ -99,17 +96,20 @@ class RuntimeMode(Enum):
|
||||
return self == RuntimeMode.TEST
|
||||
```
|
||||
|
||||
Resolution follows this precedence: **TEST > CLOUD > LOCAL**
|
||||
Resolution follows this precedence in local app flows: **TEST > LOCAL**
|
||||
|
||||
```python
|
||||
def resolve_runtime_mode(cloud_mode_enabled: bool, is_test_env: bool) -> RuntimeMode:
|
||||
def resolve_runtime_mode(is_test_env: bool) -> RuntimeMode:
|
||||
if is_test_env:
|
||||
return RuntimeMode.TEST
|
||||
if cloud_mode_enabled:
|
||||
return RuntimeMode.CLOUD
|
||||
return RuntimeMode.LOCAL
|
||||
```
|
||||
|
||||
**Note**: `RuntimeMode` determines global behavior (e.g., whether to start file sync).
|
||||
Per-project routing is orthogonal: individual projects can be set to `cloud` mode via `ProjectMode`,
|
||||
which affects client routing in `get_client(project_name=...)` without changing global runtime mode.
|
||||
`RuntimeMode.CLOUD` may remain for compatibility, but standard local runtime resolution does not select it.
|
||||
|
||||
## Dependencies Package
|
||||
|
||||
### Structure
|
||||
@@ -221,9 +221,7 @@ async def search_notes(
|
||||
tags: list[str] | None = None,
|
||||
status: str | None = None,
|
||||
) -> SearchResponse:
|
||||
async with get_client() as client:
|
||||
active_project = await get_active_project(client, project)
|
||||
|
||||
async with get_project_client(project, context) as (client, active_project):
|
||||
# Import client inside function to avoid circular imports
|
||||
from basic_memory.mcp.clients import SearchClient
|
||||
from basic_memory.schemas.search import SearchQuery
|
||||
@@ -238,6 +236,25 @@ async def search_notes(
|
||||
return await search_client.search(search_query.model_dump())
|
||||
```
|
||||
|
||||
### Per-Project Client Routing
|
||||
|
||||
`get_project_client()` from `mcp/project_context.py` is an async context manager that:
|
||||
1. Resolves the project name from config (no network call)
|
||||
2. Creates the correctly-routed client based on the project's mode (local ASGI or cloud HTTP with API key)
|
||||
3. Validates the project via the API
|
||||
4. Yields `(client, active_project)` tuple
|
||||
|
||||
This solves the bootstrap problem: you need the project name to choose the right client (local vs cloud), but you need the client to validate the project exists.
|
||||
|
||||
```python
|
||||
from basic_memory.mcp.project_context import get_project_client
|
||||
|
||||
async with get_project_client(project, context) as (client, active_project):
|
||||
# client is routed based on project's mode (local or cloud)
|
||||
# active_project is validated via the API
|
||||
...
|
||||
```
|
||||
|
||||
## Sync Coordination
|
||||
|
||||
### SyncCoordinator
|
||||
|
||||
+26
-8
@@ -106,7 +106,7 @@ The parser excludes these list item patterns:
|
||||
|---------|---------|--------|
|
||||
| Checkboxes | `- [ ] Todo item`, `- [x] Done`, `- [-] Cancelled` | Task list syntax |
|
||||
| Markdown links | `- [text](url)` | URL link syntax |
|
||||
| Bare wiki links | `- [[Target]]` | Treated as a relation instead |
|
||||
| Bare wiki links | `- [[Target]]` | Treated as a `links_to` relation instead |
|
||||
|
||||
A list item with `#tags` but no `[category]` is still parsed — the tags are extracted and the category defaults to `Note`.
|
||||
|
||||
@@ -116,47 +116,65 @@ Relations connect documents to form the knowledge graph. There are two kinds.
|
||||
|
||||
### Explicit Relations
|
||||
|
||||
Written as list items with a relation type and a `[[wiki link]]` target.
|
||||
Written as list items with a relation type and a `[[wiki link]]` target. Unquoted
|
||||
relation types are single tokens. Quote relation types that contain spaces.
|
||||
|
||||
**Syntax:**
|
||||
|
||||
```
|
||||
- relation_type [[Target Entity]] (context)
|
||||
- "multi word relation type" [[Target Entity]] (context)
|
||||
- 'multi word relation type' [[Target Entity]] (context)
|
||||
```
|
||||
|
||||
| Part | Required | Description |
|
||||
|------|----------|-------------|
|
||||
| `relation_type` | No | Text before `[[`. Defaults to `relates_to` if omitted. |
|
||||
| `relation_type` | Yes | Single unquoted token before `[[`, or quoted text for multi-word labels. |
|
||||
| `[[Target]]` | Yes | Wiki link to the target entity. Matched by title or permalink. |
|
||||
| `(context)` | No | Parenthesized text after `]]`. Supporting details. |
|
||||
|
||||
### Examples
|
||||
|
||||
Explicit relations:
|
||||
|
||||
```markdown
|
||||
- implements [[Search Design]]
|
||||
- depends_on [[Database Schema]]
|
||||
- works_at [[Y Combinator]] (co-founder)
|
||||
- [[Some Entity]]
|
||||
- "based on" [[Customer Interview]]
|
||||
- 'in response to' [[Incident Review]]
|
||||
```
|
||||
|
||||
The last example — a bare `[[wiki link]]` in a list item — gets relation type `relates_to`.
|
||||
Bare wiki links and prose list items create implicit `links_to` relations:
|
||||
|
||||
```markdown
|
||||
- [[Some Entity]]
|
||||
- some other thing [[Some Entity]]
|
||||
```
|
||||
|
||||
Both examples above create `links_to [[Some Entity]]`. Use quotes when the words before
|
||||
`[[` are meant to be a multi-word relation type.
|
||||
|
||||
Common relation types:
|
||||
- `implements`, `depends_on`, `relates_to`, `inspired_by`
|
||||
- `extends`, `part_of`, `contains`, `pairs_with`
|
||||
- `works_at`, `authored`, `collaborated_with`
|
||||
|
||||
Any text works as a relation type. These are conventions, not a fixed set.
|
||||
Any single-token text or quoted text works as a relation type. These are conventions,
|
||||
not a fixed set.
|
||||
|
||||
### Inline References
|
||||
|
||||
Wiki links appearing in regular prose (not as list items) create implicit `links_to` relations.
|
||||
Wiki links appearing in regular prose create implicit `links_to` relations. This includes
|
||||
list items that do not match the explicit relation grammar above.
|
||||
|
||||
```markdown
|
||||
This builds on [[Core Design]] and uses [[Utility Functions]].
|
||||
- We should revisit [[Search Design]] after the API changes.
|
||||
```
|
||||
|
||||
This creates two relations: `links_to [[Core Design]]` and `links_to [[Utility Functions]]`.
|
||||
This creates three relations: `links_to [[Core Design]]`, `links_to [[Utility Functions]]`,
|
||||
and `links_to [[Search Design]]`.
|
||||
|
||||
### Forward References
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
# Simplified Local/Cloud Routing
|
||||
|
||||
## Context
|
||||
|
||||
Basic Memory now uses explicit, project-aware routing without a global cloud-mode toggle.
|
||||
Routing is determined by command-level flags and project mode, not by a global `cloud_mode` state.
|
||||
|
||||
This document is the canonical contract for local/cloud routing behavior in CLI, MCP, and API-adjacent clients.
|
||||
|
||||
## Goals
|
||||
|
||||
1. Remove global `cloud_mode` from runtime/routing semantics.
|
||||
2. Keep MCP HTTP/SSE local-only; let stdio honor per-project routing.
|
||||
3. Make CLI routing explicit and easy to reason about.
|
||||
4. Support projects that exist in both local and cloud without ambiguity.
|
||||
|
||||
## Routing Contract
|
||||
|
||||
Routing is resolved in this order:
|
||||
|
||||
1. Injected client factory (for composition/integration contexts)
|
||||
2. Explicit routing override (`--local` / `--cloud` or env vars below)
|
||||
3. Project-scoped routing (`project.mode`) when a project is known
|
||||
4. Default local routing
|
||||
|
||||
### Routing Environment Variables
|
||||
|
||||
- `BASIC_MEMORY_FORCE_LOCAL=true`: force local transport
|
||||
- `BASIC_MEMORY_FORCE_CLOUD=true`: force cloud proxy transport
|
||||
- `BASIC_MEMORY_EXPLICIT_ROUTING=true`: marks routing as explicitly chosen for this command
|
||||
|
||||
When explicit routing is active, project mode does not override the selected route.
|
||||
|
||||
## Config Semantics
|
||||
|
||||
- `project.mode` is the only config-based routing signal for project-scoped operations.
|
||||
- Legacy `cloud_mode` values may be encountered during migration/loading but are not used for routing behavior.
|
||||
- Normalization saves remove stale `cloud_mode` from `~/.basic-memory/config.json`.
|
||||
|
||||
### Example Config
|
||||
|
||||
```json
|
||||
{
|
||||
"projects": {
|
||||
"main": {
|
||||
"path": "/Users/me/basic-memory",
|
||||
"mode": "local",
|
||||
"local_sync_path": null,
|
||||
"bisync_initialized": false,
|
||||
"last_sync": null
|
||||
},
|
||||
"specs": {
|
||||
"path": "specs",
|
||||
"mode": "cloud",
|
||||
"local_sync_path": "/Users/me/dev/specs",
|
||||
"bisync_initialized": true,
|
||||
"last_sync": "2026-02-06T17:36:38.544153"
|
||||
}
|
||||
},
|
||||
"default_project": "main",
|
||||
"cloud_api_key": "bmc_abc123...",
|
||||
"cloud_host": "https://cloud.basicmemory.com"
|
||||
}
|
||||
```
|
||||
|
||||
## Cloud Commands Are Auth-Only
|
||||
|
||||
`bm cloud login`, `bm cloud logout`, and `bm cloud status` manage authentication state.
|
||||
|
||||
- `bm cloud login`
|
||||
- performs OAuth device flow
|
||||
- stores/refreshes token material
|
||||
- may verify cloud health/subscription
|
||||
- does not change routing defaults
|
||||
- `bm cloud logout`
|
||||
- removes stored OAuth session tokens
|
||||
- does not change routing defaults
|
||||
- `bm cloud status`
|
||||
- reports auth state (API key, OAuth token validity)
|
||||
- runs health checks only when credentials are available
|
||||
|
||||
## MCP Transport Routing
|
||||
|
||||
### Stdio (default)
|
||||
|
||||
`bm mcp --transport stdio` uses natural per-project routing.
|
||||
|
||||
- Local-mode projects route through the in-process ASGI transport.
|
||||
- Cloud-mode projects route to the cloud proxy with Bearer auth (API key).
|
||||
- No explicit routing env vars are injected by the CLI command.
|
||||
- Externally-set env vars are honored (e.g. `BASIC_MEMORY_FORCE_CLOUD=true` for cloud deployments).
|
||||
- Users who need all projects forced local can set `BASIC_MEMORY_FORCE_LOCAL=true` externally.
|
||||
|
||||
### HTTP and SSE Transports
|
||||
|
||||
`bm mcp --transport streamable-http` and `bm mcp --transport sse` always route locally.
|
||||
|
||||
These transports set explicit local routing (`BASIC_MEMORY_FORCE_LOCAL=true` and
|
||||
`BASIC_MEMORY_EXPLICIT_ROUTING=true`) before starting the server. This prevents cloud
|
||||
routing regardless of project mode, since HTTP/SSE serve as local API endpoints.
|
||||
|
||||
## Project List UX for Dual Presence
|
||||
|
||||
Projects may exist in both local and cloud. `bm project list` should display that clearly in one row per logical
|
||||
project identity, with explicit source/target signals.
|
||||
|
||||
Recommended display contract:
|
||||
|
||||
1. Keep one row per normalized project name/permalink.
|
||||
2. Show both local and cloud presence as separate columns/indicators.
|
||||
3. Show an explicit `MCP (stdio)` target column that always resolves to `local`.
|
||||
4. Keep CLI route semantics explicit:
|
||||
- no flags: default local for non-project commands
|
||||
- `--cloud`: force cloud
|
||||
- `--local`: force local
|
||||
|
||||
## Project LS Targeting
|
||||
|
||||
`bm project ls` should clearly identify which project instance is being listed.
|
||||
|
||||
Targeting rules:
|
||||
|
||||
1. No routing flags: list local project files.
|
||||
2. `--cloud`: list cloud project files.
|
||||
3. `--local`: list local project files (explicit override).
|
||||
4. Output should label the active target (`LOCAL` or `CLOUD`) in heading or status line.
|
||||
|
||||
## Runtime Mode
|
||||
|
||||
Runtime mode is no longer a cloud/local routing switch for local app flows.
|
||||
|
||||
- `resolve_runtime_mode(is_test_env)` resolves to:
|
||||
- `TEST` when running in test environment
|
||||
- `LOCAL` otherwise
|
||||
- `RuntimeMode.CLOUD` may remain for compatibility with existing tests/call sites but is not selected by normal local
|
||||
runtime resolution.
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
1. Loading config with legacy `cloud_mode` succeeds.
|
||||
2. Saving config strips legacy `cloud_mode`.
|
||||
3. `--local/--cloud` always override per-project mode for that command.
|
||||
4. No-project + no-flags commands route local by default.
|
||||
5. `bm cloud login/logout` do not toggle routing behavior.
|
||||
6. `bm mcp` stdio routes per-project mode; HTTP/SSE remain local-forced.
|
||||
7. `bm project list` communicates dual local/cloud presence without ambiguity.
|
||||
8. `bm project ls` output identifies route target explicitly.
|
||||
@@ -91,10 +91,11 @@ SQLite Database (Index)
|
||||
# List all projects
|
||||
projects = await list_memory_projects()
|
||||
|
||||
# Response structure:
|
||||
# Response structure (each entry includes external_id you can pass as project_id):
|
||||
# [
|
||||
# {
|
||||
# "name": "main",
|
||||
# "external_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
# "path": "/Users/name/notes",
|
||||
# "is_default": True,
|
||||
# "note_count": 156,
|
||||
@@ -102,6 +103,7 @@ projects = await list_memory_projects()
|
||||
# },
|
||||
# {
|
||||
# "name": "work",
|
||||
# "external_id": "9f86d081-884c-42a3-b5e3-1c0c5b4c8e52",
|
||||
# "path": "/Users/name/work-notes",
|
||||
# "is_default": False,
|
||||
# "note_count": 89,
|
||||
@@ -164,6 +166,44 @@ active_project = "main"
|
||||
results = await search_notes(query="topic", project=active_project)
|
||||
```
|
||||
|
||||
### `project` vs `project_id`
|
||||
|
||||
Every project has two identifiers:
|
||||
|
||||
- **`project`** — human-readable name (e.g., `"main"`). Easy to use, but can collide across cloud workspaces.
|
||||
- **`project_id`** — stable `external_id` UUID. Always unambiguous; takes precedence over `project` when both are passed.
|
||||
|
||||
**When to prefer `project_id`:**
|
||||
|
||||
1. **Cloud multi-workspace setups.** If the user belongs to more than one workspace (personal + organization, or several organizations) and the same project name might exist in more than one of them, pass `project_id` to route to the exact project. Without it, name resolution falls back to the default workspace, which may not be the one the user means.
|
||||
2. **After `list_memory_projects()`.** Once you have the `external_id`, prefer using it — it's the same number of characters in JSON and saves a name-resolution round-trip.
|
||||
3. **When persisting a project choice across a long session.** UUIDs are stable; names can be renamed.
|
||||
|
||||
**When `project` (name) is fine:**
|
||||
|
||||
- Local single-workspace setups (no collision risk).
|
||||
- One-off operations where the name is clearly visible to the user (e.g., quick `search_notes(project="main", ...)`).
|
||||
- The user explicitly references a project by name in their message.
|
||||
|
||||
**Example — cloud multi-workspace pattern:**
|
||||
|
||||
```python
|
||||
# Discover and pick the right project for this user
|
||||
projects = await list_memory_projects()
|
||||
target = next(p for p in projects if p["name"] == "research" and p["workspace"]["slug"] == "acme")
|
||||
|
||||
# Use the UUID for all subsequent operations — no ambiguity
|
||||
await write_note(
|
||||
title="Meeting Notes",
|
||||
content="...",
|
||||
folder="meetings",
|
||||
project_id=target["external_id"],
|
||||
)
|
||||
results = await search_notes(query="kickoff", project_id=target["external_id"])
|
||||
```
|
||||
|
||||
**Precedence rule:** When both are passed, `project_id` wins. This lets you safely supply `project="main"` for backward compatibility while still routing precisely with `project_id`.
|
||||
|
||||
### Cross-Project Operations
|
||||
|
||||
**Some tools work across all projects when project parameter omitted:**
|
||||
@@ -427,6 +467,8 @@ await write_note(
|
||||
)
|
||||
```
|
||||
|
||||
> **Important**: `write_note` errors if the note already exists. Use `edit_note` for incremental changes, or pass `overwrite=True` to replace.
|
||||
|
||||
**Well-structured note**:
|
||||
|
||||
```python
|
||||
@@ -760,6 +802,9 @@ notes = await read_note(
|
||||
identifier="memory://specs/*",
|
||||
project="main"
|
||||
)
|
||||
|
||||
# Cross-project URL (auto-routes to the correct project)
|
||||
note = await read_note(identifier="memory://research/specs/api-design")
|
||||
```
|
||||
|
||||
```python
|
||||
@@ -1060,16 +1105,19 @@ results = await search_notes(
|
||||
project="main"
|
||||
)
|
||||
|
||||
# Metadata-only search
|
||||
results = await search_by_metadata(
|
||||
filters={"type": "spec", "status": "in-progress"},
|
||||
# Metadata-only search (no query needed)
|
||||
results = await search_notes(
|
||||
metadata_filters={"type": "spec", "status": "in-progress"},
|
||||
project="main"
|
||||
)
|
||||
```
|
||||
|
||||
### Search Types
|
||||
|
||||
**Text search (default)**:
|
||||
Available types: `"text"`, `"title"`, `"permalink"`, `"vector"`/`"semantic"`, `"hybrid"`.
|
||||
Default is `"hybrid"` when semantic search is enabled, `"text"` otherwise.
|
||||
|
||||
**Text search**:
|
||||
|
||||
```python
|
||||
# Full-text search across all content
|
||||
@@ -1080,15 +1128,50 @@ results = await search_notes(
|
||||
)
|
||||
```
|
||||
|
||||
**Semantic search**:
|
||||
**Title and permalink search**:
|
||||
|
||||
```python
|
||||
# Search by title only
|
||||
results = await search_notes(query="API Design", search_type="title", project="main")
|
||||
|
||||
# Search by permalink
|
||||
results = await search_notes(query="specs/api-design", search_type="permalink", project="main")
|
||||
```
|
||||
|
||||
**Semantic/vector search**:
|
||||
|
||||
```python
|
||||
# Semantic/vector search (if enabled)
|
||||
results = await search_notes(
|
||||
query="user login security",
|
||||
search_type="semantic",
|
||||
search_type="semantic", # or "vector"
|
||||
project="main"
|
||||
)
|
||||
|
||||
# Override similarity threshold
|
||||
results = await search_notes(
|
||||
query="user login security",
|
||||
search_type="semantic",
|
||||
min_similarity=0.5,
|
||||
project="main"
|
||||
)
|
||||
```
|
||||
|
||||
**Hybrid search** (combines text + semantic):
|
||||
|
||||
```python
|
||||
results = await search_notes(
|
||||
query="authentication best practices",
|
||||
search_type="hybrid",
|
||||
project="main"
|
||||
)
|
||||
```
|
||||
|
||||
**Tag shorthand in query**:
|
||||
|
||||
```python
|
||||
# Use tag: prefix as shorthand
|
||||
results = await search_notes(query="tag:security", project="main")
|
||||
```
|
||||
|
||||
### Search Response
|
||||
@@ -2161,6 +2244,31 @@ active_project = projects[0]["name"]
|
||||
results = await search_notes(query="test", project=active_project)
|
||||
```
|
||||
|
||||
### Note Already Exists
|
||||
|
||||
**Error**: `write_note` called for a note that already exists
|
||||
|
||||
**Solution**:
|
||||
|
||||
```python
|
||||
# Preferred: use edit_note for incremental updates
|
||||
await edit_note(
|
||||
identifier="Existing Topic",
|
||||
operation="append",
|
||||
content="\n- [update] new information",
|
||||
project="main"
|
||||
)
|
||||
|
||||
# Alternative: replace the entire note
|
||||
await write_note(
|
||||
title="Existing Topic",
|
||||
content="# Existing Topic\n...",
|
||||
folder="notes",
|
||||
overwrite=True,
|
||||
project="main"
|
||||
)
|
||||
```
|
||||
|
||||
### Entity Not Found
|
||||
|
||||
**Error**: Note doesn't exist
|
||||
@@ -2716,14 +2824,15 @@ await write_note(
|
||||
|
||||
### Content Management
|
||||
|
||||
**write_note(title, content, folder, tags, note_type, project)**
|
||||
- Create or update markdown notes
|
||||
**write_note(title, content, folder, tags, note_type, overwrite, project)**
|
||||
- Create new markdown notes (errors if note already exists unless overwrite=True)
|
||||
- Parameters:
|
||||
- `title` (required): Note title
|
||||
- `content` (required): Markdown content
|
||||
- `folder` (required): Destination folder
|
||||
- `tags` (optional): List of tags
|
||||
- `note_type` (optional): Type of note (stored in frontmatter). Can be "note", "person", "meeting", "guide", etc.
|
||||
- `overwrite` (optional): Set to True to replace an existing note (default: error if exists)
|
||||
- `project` (required unless default_project_mode): Target project
|
||||
- Returns: Created/updated entity with permalink
|
||||
- Example:
|
||||
@@ -2890,19 +2999,20 @@ contents = await list_directory(
|
||||
|
||||
### Search & Discovery
|
||||
|
||||
**search_notes(query, page, page_size, search_type, types, entity_types, after_date, metadata_filters, tags, status, project)**
|
||||
**search_notes(query, page, page_size, search_type, types, entity_types, after_date, metadata_filters, tags, status, min_similarity, project)**
|
||||
- Search across knowledge base
|
||||
- Parameters:
|
||||
- `query` (required): Search query
|
||||
- `query` (optional): Search query (not required for filter-only searches)
|
||||
- `page` (optional): Page number (default: 1)
|
||||
- `page_size` (optional): Results per page (default: 10)
|
||||
- `search_type` (optional): "text" or "semantic"
|
||||
- `search_type` (optional): "text", "title", "permalink", "vector"/"semantic", "hybrid" (default: "hybrid" when semantic enabled, "text" otherwise)
|
||||
- `types` (optional): Entity type filter
|
||||
- `entity_types` (optional): Observation category filter
|
||||
- `after_date` (optional): Date filter (ISO format)
|
||||
- `metadata_filters` (optional): Structured frontmatter filters (dict)
|
||||
- `tags` (optional): Frontmatter tags filter (list)
|
||||
- `metadata_filters` (optional): Structured frontmatter filters (dict, supports `$in`, `$gt`, `$gte`, `$lt`, `$lte`, `$between` operators)
|
||||
- `tags` (optional): Frontmatter tags filter (list); also available via `tag:` query shorthand
|
||||
- `status` (optional): Frontmatter status filter (string)
|
||||
- `min_similarity` (optional): Override similarity threshold for vector/hybrid search
|
||||
- `project` (required unless default_project_mode): Target project
|
||||
- Returns: Matching entities with scores
|
||||
- Example:
|
||||
@@ -2915,18 +3025,11 @@ results = await search_notes(
|
||||
)
|
||||
```
|
||||
|
||||
**search_by_metadata(filters, limit, offset, project)**
|
||||
- Metadata-only search using structured frontmatter
|
||||
- Parameters:
|
||||
- `filters` (required): Dict of field -> value (supports $in, $gt/$gte/$lt/$lte, $between)
|
||||
- `limit` (optional): Max results (default: 20)
|
||||
- `offset` (optional): Pagination offset (default: 0)
|
||||
- `project` (required unless default_project_mode): Target project
|
||||
- Returns: Matching entities
|
||||
- Example:
|
||||
**Metadata-only search (via search_notes)**
|
||||
- Use `search_notes` with `metadata_filters` and no `query` for metadata-only searches:
|
||||
```python
|
||||
results = await search_by_metadata(
|
||||
filters={"type": "spec", "status": "in-progress"},
|
||||
results = await search_notes(
|
||||
metadata_filters={"type": "spec", "status": "in-progress"},
|
||||
project="main"
|
||||
)
|
||||
```
|
||||
@@ -2978,6 +3081,15 @@ await delete_project(project_name="old-project")
|
||||
status = await sync_status(project="main")
|
||||
```
|
||||
|
||||
**list_workspaces()**
|
||||
- List available workspaces (cloud)
|
||||
- Parameters: None
|
||||
- Returns: List of workspaces with metadata
|
||||
- Example:
|
||||
```python
|
||||
workspaces = await list_workspaces()
|
||||
```
|
||||
|
||||
### Visualization
|
||||
|
||||
**canvas(nodes, edges, title, folder, project)**
|
||||
@@ -3246,8 +3358,8 @@ await edit_note(
|
||||
project="main"
|
||||
)
|
||||
|
||||
# Avoid: Complete rewrite
|
||||
# (unless necessary for major restructuring)
|
||||
# When full rewrite is needed, use overwrite=True
|
||||
await write_note(title="Note", content="...", folder="notes", overwrite=True)
|
||||
```
|
||||
|
||||
### 14. Tagging Strategy
|
||||
|
||||
+290
-90
@@ -5,7 +5,7 @@ The Basic Memory Cloud CLI provides seamless integration between local and cloud
|
||||
## Overview
|
||||
|
||||
The cloud CLI enables you to:
|
||||
- **Toggle cloud mode** - All regular `bm` commands work with cloud when enabled
|
||||
- **Authenticate cloud access** - OAuth/API key credentials are stored locally for cloud operations
|
||||
- **Project-scoped sync** - Each project independently manages its sync configuration
|
||||
- **Explicit operations** - Sync only what you want, when you want
|
||||
- **Bidirectional sync** - Keep local and cloud in sync with rclone bisync
|
||||
@@ -17,6 +17,8 @@ Before using Basic Memory Cloud, you need:
|
||||
|
||||
- **Active Subscription**: An active Basic Memory Cloud subscription is required to access cloud features
|
||||
- **Subscribe**: Visit [https://basicmemory.com/subscribe](https://basicmemory.com/subscribe) to sign up
|
||||
- **Optional**: Cloud is optional. Local-first open-source usage continues without cloud.
|
||||
- **OSS Discount**: Use code `{{OSS_DISCOUNT_CODE}}` for 20% off for 3 months.
|
||||
|
||||
If you attempt to log in without an active subscription, you'll receive a "Subscription Required" error with a link to subscribe.
|
||||
|
||||
@@ -38,7 +40,7 @@ If you attempt to log in without an active subscription, you'll receive a "Subsc
|
||||
|
||||
1. **Cloud-only** - Project exists on cloud, no local copy
|
||||
2. **Cloud + Local (synced)** - Project has a local working directory that syncs
|
||||
3. **Local-only** - Project exists locally (when cloud mode is disabled)
|
||||
3. **Local-only** - Project exists locally and is not routed to cloud
|
||||
|
||||
**Example:**
|
||||
|
||||
@@ -48,9 +50,9 @@ If you attempt to log in without an active subscription, you'll receive a "Subsc
|
||||
# - work: wants local sync at ~/work-notes
|
||||
# - temp: cloud-only, no local sync needed
|
||||
|
||||
bm project add research --local-path ~/Documents/research
|
||||
bm project add work --local-path ~/work-notes
|
||||
bm project add temp # No local sync
|
||||
bm project add research --cloud --local-path ~/Documents/research
|
||||
bm project add work --cloud --local-path ~/work-notes
|
||||
bm project add temp --cloud # No local sync
|
||||
|
||||
# Now you can sync individually (after initial --resync):
|
||||
bm project bisync --name research
|
||||
@@ -66,9 +68,9 @@ bm project bisync --name work
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Enable Cloud Mode
|
||||
### 1. Authenticate Cloud Access
|
||||
|
||||
Authenticate and enable cloud mode:
|
||||
Authenticate with cloud:
|
||||
|
||||
```bash
|
||||
bm cloud login
|
||||
@@ -76,11 +78,12 @@ bm cloud login
|
||||
|
||||
**What this does:**
|
||||
1. Opens browser to Basic Memory Cloud authentication page
|
||||
2. Stores authentication token in `~/.basic-memory/auth/token`
|
||||
3. **Enables cloud mode** - all CLI commands now work against cloud
|
||||
4. Validates your subscription status
|
||||
2. Stores authentication tokens in `~/.basic-memory/basic-memory-cloud.json`
|
||||
3. Validates your subscription status
|
||||
4. Leaves routing behavior unchanged (auth only)
|
||||
|
||||
**Result:** All `bm project`, `bm tools` commands now work with cloud.
|
||||
**Result:** Cloud credentials are available for cloud-routed commands.
|
||||
Apply OSS discount code `{{OSS_DISCOUNT_CODE}}` during checkout to receive 20% off for 3 months.
|
||||
|
||||
### 2. Set Up Sync
|
||||
|
||||
@@ -91,33 +94,38 @@ bm cloud setup
|
||||
```
|
||||
|
||||
**What this does:**
|
||||
1. Installs rclone automatically (if needed)
|
||||
1. Installs rclone with a supported package manager (if needed)
|
||||
2. Fetches your tenant information from cloud
|
||||
3. Generates scoped S3 credentials for sync
|
||||
4. Configures single rclone remote: `basic-memory-cloud`
|
||||
|
||||
**Result:** You're ready to sync projects. No sync directories created yet - those come with project setup.
|
||||
|
||||
Rclone setup uses package managers such as Homebrew, MacPorts, apt, dnf, yum, pacman,
|
||||
zypper, snap, winget, Chocolatey, or Scoop when available. It does not run remote
|
||||
install scripts with `sudo`; if no supported package manager is found, the CLI prints
|
||||
manual install instructions.
|
||||
|
||||
### 3. Add Projects with Sync
|
||||
|
||||
Create projects with optional local sync paths:
|
||||
|
||||
```bash
|
||||
# Create cloud project without local sync
|
||||
bm project add research
|
||||
bm project add research --cloud
|
||||
|
||||
# Create cloud project WITH local sync
|
||||
bm project add research --local-path ~/Documents/research
|
||||
bm project add research --cloud --local-path ~/Documents/research
|
||||
|
||||
# Or configure sync for existing project
|
||||
bm project sync-setup research ~/Documents/research
|
||||
bm cloud sync-setup research ~/Documents/research
|
||||
```
|
||||
|
||||
**What happens under the covers:**
|
||||
|
||||
When you add a project with `--local-path`:
|
||||
1. Project created on cloud at `/app/data/research`
|
||||
2. Local path stored in config: `cloud_projects.research.local_path = "~/Documents/research"`
|
||||
2. Local path stored in config for that project (`local_sync_path`)
|
||||
3. Local directory created if it doesn't exist
|
||||
4. Bisync state directory created at `~/.basic-memory/bisync-state/research/`
|
||||
|
||||
@@ -179,7 +187,8 @@ bm cloud status
|
||||
```
|
||||
|
||||
You should see:
|
||||
- `Mode: Cloud (enabled)`
|
||||
- `OAuth: token valid` (or missing/expired)
|
||||
- `API Key: configured` (or not set)
|
||||
- `Cloud instance is healthy`
|
||||
- Instructions for project sync commands
|
||||
|
||||
@@ -187,16 +196,16 @@ You should see:
|
||||
|
||||
### Understanding Project Commands
|
||||
|
||||
**Key concept:** When cloud mode is enabled, use regular `bm project` commands (not `bm cloud project`).
|
||||
**Key concept:** Use regular `bm project` commands (not `bm cloud project`).
|
||||
|
||||
```bash
|
||||
# In cloud mode:
|
||||
bm project list # Lists cloud projects
|
||||
bm project add research # Creates cloud project
|
||||
# Local route
|
||||
bm project list --local
|
||||
bm project add research ~/Documents/research
|
||||
|
||||
# In local mode:
|
||||
bm project list # Lists local projects
|
||||
bm project add research ~/Documents/research # Creates local project
|
||||
# Cloud route
|
||||
bm project list --cloud
|
||||
bm project add research --cloud
|
||||
```
|
||||
|
||||
### Creating Projects
|
||||
@@ -204,7 +213,7 @@ bm project add research ~/Documents/research # Creates local project
|
||||
**Use case 1: Cloud-only project (no local sync)**
|
||||
|
||||
```bash
|
||||
bm project add temp-notes
|
||||
bm project add temp-notes --cloud
|
||||
```
|
||||
|
||||
**What this does:**
|
||||
@@ -217,7 +226,7 @@ bm project add temp-notes
|
||||
**Use case 2: Cloud project with local sync**
|
||||
|
||||
```bash
|
||||
bm project add research --local-path ~/Documents/research
|
||||
bm project add research --cloud --local-path ~/Documents/research
|
||||
```
|
||||
|
||||
**What this does:**
|
||||
@@ -232,7 +241,7 @@ bm project add research --local-path ~/Documents/research
|
||||
|
||||
```bash
|
||||
# Project already exists on cloud
|
||||
bm project sync-setup research ~/Documents/research
|
||||
bm cloud sync-setup research ~/Documents/research
|
||||
```
|
||||
|
||||
**What this does:**
|
||||
@@ -251,11 +260,35 @@ bm project list
|
||||
```
|
||||
|
||||
**What you see:**
|
||||
- All projects in cloud (when cloud mode enabled)
|
||||
- Local projects always
|
||||
- Cloud projects when credentials are available
|
||||
- Default project marked
|
||||
- Project paths shown
|
||||
- Route-related metadata (for example, local/cloud presence and sync info)
|
||||
|
||||
**Future:** Will show sync status (synced/not synced, last sync time).
|
||||
Example shape (single row for dual-presence projects):
|
||||
|
||||
```text
|
||||
Name Path Local Path Cloud Path CLI Default MCP (stdio)
|
||||
main /basic-memory ~/basic-memory /basic-memory local local
|
||||
specs /specs ~/dev/specs /specs cloud local
|
||||
```
|
||||
|
||||
### When a Project Exists in Both Local and Cloud
|
||||
|
||||
Use routing flags to disambiguate command targets:
|
||||
|
||||
```bash
|
||||
# Force local target for this command
|
||||
bm project info main --local
|
||||
bm project ls --name main --local
|
||||
|
||||
# Force cloud target for this command
|
||||
bm project info main --cloud
|
||||
bm project ls --name main --cloud
|
||||
```
|
||||
|
||||
Default behavior for no-project, no-flag commands is local.
|
||||
For MCP stdio, routing is always local.
|
||||
|
||||
## File Synchronization
|
||||
|
||||
@@ -363,24 +396,28 @@ bm project bisync --name research --dry-run
|
||||
|
||||
**Result:** Safe preview of sync operations.
|
||||
|
||||
### Advanced: List Remote Files
|
||||
### Advanced: List Project Files by Route
|
||||
|
||||
**Use case:** See what files exist on cloud without syncing.
|
||||
**Use case:** Inspect local or cloud project files explicitly.
|
||||
|
||||
```bash
|
||||
# List all files in project
|
||||
# List local project files (default target when no route flag is given)
|
||||
bm project ls --name research
|
||||
bm project ls --name research --local
|
||||
|
||||
# List cloud project files
|
||||
bm project ls --name research --cloud
|
||||
|
||||
# List files in subdirectory
|
||||
bm project ls --name research --path subfolder
|
||||
bm project ls --name research --cloud --path subfolder
|
||||
```
|
||||
|
||||
**What happens:**
|
||||
1. Connects to cloud via rclone
|
||||
2. Lists files in remote project path
|
||||
1. Resolves route from flags (or local default when no route is given)
|
||||
2. Lists files for the chosen project instance
|
||||
3. No files transferred
|
||||
|
||||
**Result:** See cloud file listing.
|
||||
**Result:** See file listing for the target route.
|
||||
|
||||
## Multiple Projects
|
||||
|
||||
@@ -390,9 +427,9 @@ bm project ls --name research --path subfolder
|
||||
|
||||
```bash
|
||||
# Setup multiple projects
|
||||
bm project add research --local-path ~/Documents/research
|
||||
bm project add work --local-path ~/work-notes
|
||||
bm project add personal --local-path ~/personal
|
||||
bm project add research --cloud --local-path ~/Documents/research
|
||||
bm project add work --cloud --local-path ~/work-notes
|
||||
bm project add personal --cloud --local-path ~/personal
|
||||
|
||||
# Establish baselines
|
||||
bm project bisync --name research --resync
|
||||
@@ -417,12 +454,12 @@ bm project bisync --all # Coming soon
|
||||
|
||||
```bash
|
||||
# Projects with sync
|
||||
bm project add research --local-path ~/Documents/research
|
||||
bm project add work --local-path ~/work
|
||||
bm project add research --cloud --local-path ~/Documents/research
|
||||
bm project add work --cloud --local-path ~/work
|
||||
|
||||
# Cloud-only projects
|
||||
bm project add archive
|
||||
bm project add temp-notes
|
||||
bm project add archive --cloud
|
||||
bm project add temp-notes --cloud
|
||||
|
||||
# Sync only the configured ones
|
||||
bm project bisync --name research
|
||||
@@ -433,20 +470,104 @@ bm project bisync --name work
|
||||
|
||||
**Result:** Fine-grained control over what syncs.
|
||||
|
||||
## Disable Cloud Mode
|
||||
## Per-Project Cloud Routing (API Key)
|
||||
|
||||
Return to local mode:
|
||||
Route individual projects through cloud using an API key. This lets you keep some projects local while others route through cloud.
|
||||
|
||||
### Setting Up API Key Auth
|
||||
|
||||
**Option A: Create a key in the web app, then save it locally:**
|
||||
|
||||
```bash
|
||||
bm cloud set-key bmc_abc123...
|
||||
```
|
||||
|
||||
**Option B: Create a key via CLI (requires OAuth login first):**
|
||||
|
||||
```bash
|
||||
bm cloud login # One-time OAuth login
|
||||
bm cloud create-key "my-laptop" # Creates key and saves it locally
|
||||
```
|
||||
|
||||
The API key is account-level — it grants access to all your cloud projects. It's stored in `~/.basic-memory/config.json` as `cloud_api_key`.
|
||||
On POSIX systems, Basic Memory writes `~/.basic-memory/` as user-private (`0700`) and
|
||||
`config.json` as user-read/write only (`0600`). Treat this config file as a credential
|
||||
file when an API key is saved.
|
||||
|
||||
### Setting Project Modes
|
||||
|
||||
```bash
|
||||
# Route a project through cloud
|
||||
bm project set-cloud research
|
||||
|
||||
# Revert to local mode
|
||||
bm project set-local research
|
||||
|
||||
# View project modes
|
||||
bm project list
|
||||
```
|
||||
|
||||
**What happens:**
|
||||
- `set-cloud`: validates the API key exists, then sets the project mode to `cloud` in config
|
||||
- `set-local`: reverts the project to local mode (removes the mode entry from config)
|
||||
- MCP tools and CLI commands for that project will route to `cloud_host/proxy` with the API key as Bearer token
|
||||
|
||||
### How It Works
|
||||
|
||||
When an MCP tool or CLI command runs for a cloud-mode project:
|
||||
|
||||
1. `get_client(project_name="research")` checks the project's mode in config
|
||||
2. If mode is `cloud`, creates an HTTP client pointed at `cloud_host/proxy` with `Authorization: Bearer bmc_...`
|
||||
3. If mode is `local` (default), uses the in-process ASGI transport as usual
|
||||
|
||||
**Routing priority** (highest to lowest):
|
||||
1. Factory injection (cloud app, tests)
|
||||
2. Explicit route override (`--local` / `--cloud`)
|
||||
3. Per-project cloud mode (API key)
|
||||
4. Local ASGI transport (default)
|
||||
|
||||
Route override environment variables:
|
||||
- `BASIC_MEMORY_FORCE_LOCAL=true`
|
||||
- `BASIC_MEMORY_FORCE_CLOUD=true`
|
||||
- `BASIC_MEMORY_EXPLICIT_ROUTING=true`
|
||||
|
||||
No-project, no-flag CLI commands default to local routing.
|
||||
|
||||
### Configuration Example
|
||||
|
||||
```json
|
||||
{
|
||||
"projects": {
|
||||
"personal": "/Users/me/notes",
|
||||
"research": "/Users/me/research"
|
||||
},
|
||||
"project_modes": {
|
||||
"research": "cloud"
|
||||
},
|
||||
"cloud_api_key": "bmc_abc123...",
|
||||
"cloud_host": "https://cloud.basicmemory.com",
|
||||
"default_project": "personal"
|
||||
}
|
||||
```
|
||||
|
||||
In this example, `personal` stays local and `research` routes through cloud. Projects not listed in `project_modes` default to local.
|
||||
|
||||
### Sync Behavior
|
||||
|
||||
Cloud-mode projects are automatically skipped during local file sync (background sync and file watching). Their files live on the cloud instance, not locally.
|
||||
|
||||
## OAuth Logout
|
||||
|
||||
```bash
|
||||
bm cloud logout
|
||||
```
|
||||
|
||||
**What this does:**
|
||||
1. Disables cloud mode in config
|
||||
2. All commands now work locally
|
||||
3. Auth token remains (can re-enable with login)
|
||||
1. Removes stored OAuth token(s)
|
||||
2. Does not change per-project route configuration
|
||||
3. Does not change command routing defaults
|
||||
|
||||
**Result:** All `bm` commands work with local projects again.
|
||||
**Result:** OAuth session is cleared. API-key-based routing still works if `cloud_api_key` is configured.
|
||||
|
||||
## Filter Configuration
|
||||
|
||||
@@ -461,33 +582,62 @@ bm cloud logout
|
||||
**Default patterns:**
|
||||
|
||||
```gitignore
|
||||
# Version control
|
||||
.git/**
|
||||
|
||||
# Python
|
||||
__pycache__/**
|
||||
*.pyc
|
||||
.venv/**
|
||||
venv/**
|
||||
|
||||
# Node.js
|
||||
node_modules/**
|
||||
# Hidden files and directories
|
||||
.*
|
||||
|
||||
# Basic Memory internals
|
||||
memory.db/**
|
||||
memory.db-shm/**
|
||||
memory.db-wal/**
|
||||
config.json/**
|
||||
watch-status.json/**
|
||||
.bmignore.rclone/**
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
config.json
|
||||
|
||||
# Version control
|
||||
.git
|
||||
.svn
|
||||
|
||||
# Python
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.pytest_cache
|
||||
.coverage
|
||||
*.egg-info
|
||||
.tox
|
||||
.mypy_cache
|
||||
.ruff_cache
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
venv
|
||||
env
|
||||
.env
|
||||
|
||||
# Node.js
|
||||
node_modules
|
||||
|
||||
# Build artifacts
|
||||
build
|
||||
dist
|
||||
.cache
|
||||
|
||||
# IDE
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
# OS files
|
||||
.DS_Store/**
|
||||
Thumbs.db/**
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
# Environment files
|
||||
.env/**
|
||||
.env.local/**
|
||||
# Obsidian
|
||||
.obsidian
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
@@ -496,6 +646,11 @@ Thumbs.db/**
|
||||
3. Rclone uses filters during sync
|
||||
4. Same patterns used by all projects
|
||||
|
||||
During conversion, file patterns exclude the direct match and recursive contents.
|
||||
For example, `config.json` becomes both `- config.json` and `- config.json/**`,
|
||||
while `.*` becomes both `- .*` and `- .*/**`. Directory-only patterns keep
|
||||
their trailing slash, so `cache/` becomes `- cache/` and `- cache/**`.
|
||||
|
||||
**Customizing:**
|
||||
|
||||
```bash
|
||||
@@ -503,7 +658,7 @@ Thumbs.db/**
|
||||
code ~/.basic-memory/.bmignore
|
||||
|
||||
# Add custom patterns
|
||||
echo "*.tmp/**" >> ~/.basic-memory/.bmignore
|
||||
echo "*.tmp" >> ~/.basic-memory/.bmignore
|
||||
|
||||
# Next sync uses updated patterns
|
||||
bm project bisync --name research
|
||||
@@ -511,6 +666,33 @@ bm project bisync --name research
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Rclone Setup Cannot Install Automatically
|
||||
|
||||
**Problem:** `bm cloud setup` cannot find a supported package manager, or package-manager
|
||||
installation fails.
|
||||
|
||||
**Explanation:** The CLI avoids remote privileged install scripts. It only invokes known
|
||||
package managers and otherwise asks you to install rclone manually.
|
||||
|
||||
**Solution:** Install rclone with your OS package manager, then rerun setup:
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
brew install rclone
|
||||
|
||||
# Debian/Ubuntu
|
||||
sudo apt install rclone
|
||||
|
||||
# Fedora
|
||||
sudo dnf install rclone
|
||||
|
||||
# Arch
|
||||
sudo pacman -S rclone
|
||||
|
||||
# After rclone is on PATH
|
||||
bm cloud setup
|
||||
```
|
||||
|
||||
### Authentication Issues
|
||||
|
||||
**Problem:** "Authentication failed" or "Invalid token"
|
||||
@@ -626,7 +808,7 @@ bm project sync --name research
|
||||
**Solution:**
|
||||
|
||||
```bash
|
||||
bm project sync-setup research ~/Documents/research
|
||||
bm cloud sync-setup research ~/Documents/research
|
||||
bm project bisync --name research --resync
|
||||
```
|
||||
|
||||
@@ -646,37 +828,53 @@ If instance is down, wait a few minutes and retry.
|
||||
|
||||
- **Authentication**: OAuth 2.1 with PKCE flow
|
||||
- **Tokens**: Stored securely in `~/.basic-memory/basic-memory-cloud.json`
|
||||
- **API keys**: Stored in `~/.basic-memory/config.json`, which is written with private file permissions on POSIX systems
|
||||
- **Transport**: All data encrypted in transit (HTTPS)
|
||||
- **Credentials**: Scoped S3 credentials (read-write to your tenant only)
|
||||
- **Rclone setup**: Uses package managers or manual instructions; no remote privileged install-script fallback
|
||||
- **Isolation**: Your data isolated from other tenants
|
||||
- **Ignore patterns**: Sensitive files automatically excluded via `.bmignore`
|
||||
|
||||
## Command Reference
|
||||
|
||||
### Cloud Mode Management
|
||||
### Cloud Authentication
|
||||
|
||||
```bash
|
||||
bm cloud login # Authenticate and enable cloud mode
|
||||
bm cloud logout # Disable cloud mode
|
||||
bm cloud status # Check cloud mode and instance health
|
||||
bm cloud login # Authenticate and store OAuth credentials
|
||||
bm cloud logout # Remove stored OAuth credentials
|
||||
bm cloud status # Check auth state and instance health
|
||||
bm cloud promo --off # Disable CLI cloud promo notices
|
||||
```
|
||||
|
||||
### API Key Management
|
||||
|
||||
```bash
|
||||
bm cloud set-key <key> # Save a cloud API key (bmc_ prefixed)
|
||||
bm cloud create-key <name> # Create API key via cloud API (requires OAuth login)
|
||||
```
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
bm cloud setup # Install rclone and configure credentials
|
||||
bm cloud setup # Install rclone via package manager and configure credentials
|
||||
```
|
||||
|
||||
### Project Management
|
||||
|
||||
When cloud mode is enabled:
|
||||
```bash
|
||||
bm project list --local # Local project list
|
||||
bm project list --cloud # Cloud project list
|
||||
bm project add <name> --cloud # Create cloud project (no sync)
|
||||
bm project add <name> --cloud --local-path <path> # Create with local sync
|
||||
bm cloud sync-setup <name> <path> # Add sync to existing project
|
||||
bm project rm <name> # Delete project
|
||||
```
|
||||
|
||||
### Per-Project Routing
|
||||
|
||||
```bash
|
||||
bm project list # List cloud projects
|
||||
bm project add <name> # Create cloud project (no sync)
|
||||
bm project add <name> --local-path <path> # Create with local sync
|
||||
bm project sync-setup <name> <path> # Add sync to existing project
|
||||
bm project rm <name> # Delete project
|
||||
bm project set-cloud <name> # Route project through cloud (requires API key)
|
||||
bm project set-local <name> # Revert project to local mode
|
||||
```
|
||||
|
||||
### File Synchronization
|
||||
@@ -697,18 +895,20 @@ bm project bisync --name <project> --verbose
|
||||
bm project check --name <project>
|
||||
bm project check --name <project> --one-way
|
||||
|
||||
# List remote files
|
||||
bm project ls --name <project>
|
||||
bm project ls --name <project> --path <subpath>
|
||||
# List project files by route
|
||||
bm project ls --name <project> # Default target: local
|
||||
bm project ls --name <project> --local
|
||||
bm project ls --name <project> --cloud
|
||||
bm project ls --name <project> --cloud --path <subpath>
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
**Basic Memory Cloud uses project-scoped sync:**
|
||||
|
||||
1. **Enable cloud mode** - `bm cloud login`
|
||||
1. **Authenticate cloud access** - `bm cloud login`
|
||||
2. **Install rclone** - `bm cloud setup`
|
||||
3. **Add projects with sync** - `bm project add research --local-path ~/Documents/research`
|
||||
3. **Add projects with sync** - `bm project add research --cloud --local-path ~/Documents/research`
|
||||
4. **Preview first sync** - `bm project bisync --name research --resync --dry-run`
|
||||
5. **Establish baseline** - `bm project bisync --name research --resync`
|
||||
6. **Daily workflow** - `bm project bisync --name research`
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# Cloud Semantic Search Value (Customer-Facing Technical Story)
|
||||
|
||||
This document explains why teams should buy cloud semantic search even when local search exists.
|
||||
|
||||
## Core Promise
|
||||
|
||||
Markdown files remain the source of truth in both local and cloud modes.
|
||||
|
||||
- Files are portable.
|
||||
- Search indexes are derived and rebuildable.
|
||||
- You never get locked into proprietary document storage.
|
||||
|
||||
## The Customer Problem
|
||||
|
||||
Teams paying for cloud are usually not optimizing for "can this run locally." They are optimizing for:
|
||||
|
||||
- finding the right note the first time,
|
||||
- keeping retrieval quality high as note volume grows,
|
||||
- avoiding search slowdowns while content is actively changing,
|
||||
- getting consistent results across users, agents, and sessions.
|
||||
|
||||
## Why Cloud Is the Aspirin
|
||||
|
||||
Cloud semantic search is the immediate pain reliever because it fixes the problems users feel right now.
|
||||
|
||||
### 1) Better hit rate on real queries
|
||||
|
||||
Cloud uses stronger managed embeddings than the default local model, which improves semantic recall for paraphrases and vague questions.
|
||||
|
||||
Customer outcome:
|
||||
|
||||
- fewer "I know this exists but search missed it" moments,
|
||||
- less query rewording,
|
||||
- faster time to answer.
|
||||
|
||||
### 2) Better behavior under active workloads
|
||||
|
||||
Cloud indexing runs out of band in workers, so indexing does not compete with interactive read/write traffic.
|
||||
|
||||
Customer outcome:
|
||||
|
||||
- stable search responsiveness during heavy updates,
|
||||
- fresher semantic results shortly after edits,
|
||||
- less user-visible performance variance.
|
||||
|
||||
### 3) Better consistency for shared knowledge
|
||||
|
||||
Cloud retrieval runs against a centralized tenant index, so teams and agents resolve against the same semantic state.
|
||||
|
||||
Customer outcome:
|
||||
|
||||
- fewer "works on my machine" search differences,
|
||||
- more predictable agent behavior across environments,
|
||||
- easier cross-user collaboration on large knowledge bases.
|
||||
|
||||
### 4) Better quality at higher scale
|
||||
|
||||
With Postgres + `pgvector` per tenant, cloud can sustain larger note collections and higher query volumes than typical local setups.
|
||||
|
||||
Customer outcome:
|
||||
|
||||
- confidence as repositories grow to tens of thousands of notes,
|
||||
- less need for user-side tuning,
|
||||
- fewer quality regressions as usage increases.
|
||||
|
||||
## Local Is the Vitamin
|
||||
|
||||
Local semantic search still matters and should stay strong.
|
||||
|
||||
- offline use,
|
||||
- privacy-first operation,
|
||||
- no cloud dependency,
|
||||
- user-controlled runtime.
|
||||
|
||||
It compounds long-term ownership and resilience, but does not remove the immediate pain points cloud solves for teams at scale.
|
||||
|
||||
## Recommended Messaging
|
||||
|
||||
One-liner:
|
||||
|
||||
"Cloud semantic search is the aspirin: it fixes retrieval quality and performance pain now. Local semantic search is the vitamin: it builds long-term control and resilience."
|
||||
|
||||
Long form:
|
||||
|
||||
"Basic Memory keeps markdown as the source of truth everywhere. Local gives privacy and offline control. Cloud adds immediate, measurable improvements in search quality, consistency, and responsiveness for teams and agents running at scale."
|
||||
|
||||
## Packaging Guidance
|
||||
|
||||
- Base: local FTS plus optional local semantic search.
|
||||
- Cloud value: higher semantic quality, stable performance under load, and consistent team-wide retrieval.
|
||||
- Keep interfaces pluggable (`EmbeddingProvider`, vector backend protocol) so implementation can evolve without changing user workflows.
|
||||
@@ -0,0 +1,499 @@
|
||||
# Logfire Instrumentation Strategy
|
||||
|
||||
## Why
|
||||
|
||||
We want Logfire in Basic Memory for two specific use cases:
|
||||
|
||||
1. Local development and performance investigation
|
||||
2. Cloud deployments where Basic Memory runs inside Basic Memory Cloud
|
||||
|
||||
This instrumentation must be:
|
||||
|
||||
- Disabled by default
|
||||
- Useful when enabled
|
||||
- Safe for local-first users
|
||||
- Searchable in Logfire over time
|
||||
|
||||
The previous integration added telemetry, but it leaned too much on generic framework instrumentation. That created noisy spans with weak names and made the trace view harder to navigate. This strategy favors manual instrumentation around Basic Memory's real units of work.
|
||||
|
||||
## Core Principles
|
||||
|
||||
### 1. Default-off
|
||||
|
||||
Basic Memory should ship with Logfire disabled unless the operator explicitly enables it.
|
||||
|
||||
That means:
|
||||
|
||||
- no required token for normal local usage
|
||||
- no surprise outbound telemetry
|
||||
- no behavior change for existing users
|
||||
|
||||
### 2. Manual spans over automatic framework spans
|
||||
|
||||
We should not rely on broad auto-instrumentation for FastAPI, MCP, SQLAlchemy, or HTTP as the primary experience.
|
||||
|
||||
Why:
|
||||
|
||||
- auto-generated span names are often generic
|
||||
- routes and middleware produce too many low-signal spans
|
||||
- it becomes harder to answer product questions like "why was `write_note` slow?" or "where did sync time go?"
|
||||
|
||||
The preferred model is:
|
||||
|
||||
- one meaningful root span per high-level operation
|
||||
- a small number of child spans for important phases
|
||||
- optional targeted instrumentation only where it adds clear value
|
||||
|
||||
### 3. Logs must live inside traces
|
||||
|
||||
Basic Memory already uses `loguru` pervasively. The Logfire integration should preserve that and make those logs visible inside the active trace/span context.
|
||||
|
||||
If traces exist but the logs are detached from them, the integration is not doing its job.
|
||||
|
||||
### 4. Stable names, selective attributes
|
||||
|
||||
Span names should describe the operation class, not the specific input.
|
||||
|
||||
Good:
|
||||
|
||||
- `mcp.tool.write_note`
|
||||
- `sync.project.scan`
|
||||
- `search.execute`
|
||||
- `routing.resolve_project`
|
||||
|
||||
Bad:
|
||||
|
||||
- `Searching for "foo bar baz"`
|
||||
- `POST /v2/projects/123/search/`
|
||||
- `write note to /specs/api.md`
|
||||
|
||||
Dynamic values belong in attributes, not in the span name.
|
||||
|
||||
## What We Should Not Do
|
||||
|
||||
### Avoid broad FastAPI auto-instrumentation
|
||||
|
||||
We should not turn on `instrument_fastapi()` and treat that as the main telemetry story.
|
||||
|
||||
It may still be useful in narrowly scoped debugging, but it should not define the production trace shape. The meaningful root spans should come from Basic Memory's own entrypoints and service boundaries.
|
||||
|
||||
### Avoid per-file spans by default
|
||||
|
||||
`sync` can process many files. A span per file will explode trace cardinality and make performance views noisy.
|
||||
|
||||
Default behavior should be:
|
||||
|
||||
- one span for the project sync
|
||||
- child spans for scan, move handling, delete handling, markdown sync batch, relation resolution, embedding sync, watermark update
|
||||
- per-file spans only for failures or very slow outliers
|
||||
|
||||
### Avoid high-cardinality attributes on every span
|
||||
|
||||
Do not attach large or highly variable values everywhere:
|
||||
|
||||
- raw note content
|
||||
- file bodies
|
||||
- long search text
|
||||
- arbitrary metadata blobs
|
||||
- unique IDs that make every span shape distinct
|
||||
|
||||
Prefer compact, queryable attributes:
|
||||
|
||||
- `project_name`
|
||||
- `workspace_id`
|
||||
- `route_mode`
|
||||
- `scan_type`
|
||||
- `file_count`
|
||||
- `result_count`
|
||||
- `search_type`
|
||||
- `retrieval_mode`
|
||||
- `duration_ms`
|
||||
|
||||
## Proposed Architecture
|
||||
|
||||
Add a dedicated telemetry module in core Basic Memory, separate from logging setup.
|
||||
|
||||
Suggested shape:
|
||||
|
||||
```python
|
||||
# basic_memory/telemetry.py
|
||||
|
||||
def configure_telemetry(service_name: str, *, enable_logfire: bool) -> None: ...
|
||||
def telemetry_enabled() -> bool: ...
|
||||
def span(name: str, **attrs): ...
|
||||
def bind_telemetry_context(**attrs): ...
|
||||
```
|
||||
|
||||
This module should:
|
||||
|
||||
- configure Logfire only when explicitly enabled
|
||||
- set up the Logfire `loguru` handler
|
||||
- expose lightweight helpers so application code does not import `logfire` directly everywhere
|
||||
- degrade cleanly to no-op behavior when disabled
|
||||
|
||||
This keeps the rest of the codebase readable and makes it easy to reason about what telemetry is doing.
|
||||
|
||||
## Logging Integration Strategy
|
||||
|
||||
### Goal
|
||||
|
||||
When a span is active, logs emitted through `loguru` during that operation should show up in the same trace.
|
||||
|
||||
### Preferred design
|
||||
|
||||
1. Configure Logfire once in the telemetry bootstrap
|
||||
2. Add the Logfire `loguru` handler to the existing `loguru` configuration
|
||||
3. At operation boundaries, bind stable contextual fields with `loguru`
|
||||
4. Let logs emitted inside the span inherit the active trace context
|
||||
|
||||
### Context to bind
|
||||
|
||||
Bind only the fields that help correlate work across the system:
|
||||
|
||||
- `service_name`
|
||||
- `entrypoint`
|
||||
- `project_name`
|
||||
- `workspace_id`
|
||||
- `route_mode`
|
||||
- `tool_name`
|
||||
- `command_name`
|
||||
|
||||
This binding should happen at the root of an operation, not deep in leaf functions.
|
||||
|
||||
### Important nuance
|
||||
|
||||
We should not try to encode the entire trace model into logger extras. The logger context should be a human-meaningful slice of the active operation. Trace linkage comes from the active Logfire/OpenTelemetry context; logger extras are there to improve searchability and readability.
|
||||
|
||||
## Span Model
|
||||
|
||||
### Root spans
|
||||
|
||||
Each user-visible or system-visible operation should get one root span.
|
||||
|
||||
Examples:
|
||||
|
||||
- `cli.command.status`
|
||||
- `cli.command.project_sync`
|
||||
- `api.request.search`
|
||||
- `mcp.tool.write_note`
|
||||
- `mcp.tool.read_note`
|
||||
- `mcp.tool.search_notes`
|
||||
- `sync.project.run`
|
||||
- `db.semantic_backfill`
|
||||
|
||||
### Child spans
|
||||
|
||||
Child spans should represent real phases whose duration we care about.
|
||||
|
||||
Examples:
|
||||
|
||||
- `routing.client_session`
|
||||
- `routing.resolve_project`
|
||||
- `routing.resolve_workspace`
|
||||
- `api.search.execute`
|
||||
- `sync.project.scan`
|
||||
- `sync.project.detect_moves`
|
||||
- `sync.project.apply_changes`
|
||||
- `sync.project.resolve_relations`
|
||||
- `sync.project.sync_embeddings`
|
||||
- `sync.file.markdown`
|
||||
- `sync.file.regular`
|
||||
- `search.execute`
|
||||
- `search.relaxed_fts_retry`
|
||||
- `db.init`
|
||||
- `db.migrate`
|
||||
|
||||
### Span naming rules
|
||||
|
||||
- Use dot-separated names
|
||||
- Start with subsystem
|
||||
- Keep the verb at the end
|
||||
- Keep names stable across runs
|
||||
- Never include request-specific text in the span name
|
||||
|
||||
## Attribute Taxonomy
|
||||
|
||||
### Required attributes on root spans
|
||||
|
||||
Every root span should have a small common set:
|
||||
|
||||
- `service_name`
|
||||
- `entrypoint`
|
||||
- `project_name` when applicable
|
||||
- `workspace_id` when applicable
|
||||
- `route_mode` with values like `local_asgi`, `cloud_proxy`, `factory`
|
||||
|
||||
### Operation-specific attributes
|
||||
|
||||
Examples:
|
||||
|
||||
For search:
|
||||
|
||||
- `search_type`
|
||||
- `retrieval_mode`
|
||||
- `page`
|
||||
- `page_size`
|
||||
- `result_count`
|
||||
- `fallback_used`
|
||||
|
||||
For sync:
|
||||
|
||||
- `scan_type`
|
||||
- `force_full`
|
||||
- `new_count`
|
||||
- `modified_count`
|
||||
- `deleted_count`
|
||||
- `move_count`
|
||||
- `skipped_count`
|
||||
- `embeddings_enabled`
|
||||
|
||||
For note operations:
|
||||
|
||||
- `tool_name`
|
||||
- `note_type`
|
||||
- `directory`
|
||||
- `overwrite`
|
||||
- `output_format`
|
||||
|
||||
### Attributes to avoid by default
|
||||
|
||||
- full `query.text`
|
||||
- full note titles if they create privacy or cardinality issues
|
||||
- file content
|
||||
- raw frontmatter
|
||||
- raw HTTP bodies
|
||||
|
||||
If we need richer payloads for a local debugging session, that should be an explicit temporary mode, not the default telemetry shape.
|
||||
|
||||
## Instrumentation Plan By Layer
|
||||
|
||||
### 1. Entrypoints
|
||||
|
||||
Instrument these first:
|
||||
|
||||
- `cli.app` callback and major commands
|
||||
- API lifespan and selected routers
|
||||
- MCP server lifespan
|
||||
- MCP tool entrypoints
|
||||
|
||||
Why:
|
||||
|
||||
- this establishes clean root spans
|
||||
- it gives us trace boundaries that match how users think about the product
|
||||
|
||||
### 2. Routing and context resolution
|
||||
|
||||
Instrument:
|
||||
|
||||
- client routing decisions
|
||||
- workspace resolution
|
||||
- project resolution
|
||||
- default-project fallback
|
||||
|
||||
Why:
|
||||
|
||||
- Basic Memory has local/cloud/per-project routing logic
|
||||
- when something is slow or surprising, we need to know which path was taken
|
||||
|
||||
### 3. Sync and indexing
|
||||
|
||||
This is the highest-value area to instrument deeply.
|
||||
|
||||
Instrument:
|
||||
|
||||
- sync root
|
||||
- scan strategy decision
|
||||
- filesystem scan
|
||||
- move detection
|
||||
- delete handling
|
||||
- markdown sync phase
|
||||
- relation resolution
|
||||
- vector embedding sync
|
||||
- scan watermark update
|
||||
|
||||
Why:
|
||||
|
||||
- this is where performance work will happen
|
||||
- cloud and local both benefit from this visibility
|
||||
|
||||
### 4. Search
|
||||
|
||||
Instrument:
|
||||
|
||||
- search execution
|
||||
- retrieval mode
|
||||
- relaxed FTS fallback
|
||||
- result shaping
|
||||
|
||||
Why:
|
||||
|
||||
- search is user-facing and latency-sensitive
|
||||
- hybrid/vector/FTS paths need to be distinguishable
|
||||
|
||||
### 5. Database and initialization
|
||||
|
||||
Instrument selectively:
|
||||
|
||||
- DB init
|
||||
- migrations
|
||||
- semantic backfill
|
||||
- connection mode selection
|
||||
|
||||
Avoid full automatic SQL span firehose by default.
|
||||
|
||||
## Recommended Rollout Phases
|
||||
|
||||
## Task List
|
||||
|
||||
- [x] Phase 1: Bootstrap and config gating
|
||||
- [x] Phase 2: Root spans for entrypoints and primary operations
|
||||
- [x] Phase 3: Child spans for sync, search, and routing
|
||||
- [x] Phase 4: Failure-focused detail and final verification
|
||||
- [x] Phase 5: Loguru context binding and scoped context inheritance
|
||||
|
||||
## Recommended Rollout Phases
|
||||
|
||||
### Phase 1: Bootstrap and config gating
|
||||
|
||||
Add:
|
||||
|
||||
- telemetry bootstrap module
|
||||
- config/env gating
|
||||
- `loguru` + Logfire handler integration
|
||||
|
||||
This gives immediate value with low noise.
|
||||
|
||||
### Phase 2: Root spans for entrypoints and primary operations
|
||||
|
||||
Add:
|
||||
|
||||
- root spans for CLI, API, MCP, and main MCP tools
|
||||
- stable root attributes for project, workspace, route mode, and operation type
|
||||
|
||||
This gives us clean top-level traces that match how users think about the product.
|
||||
|
||||
### Phase 3: Child spans for sync, search, and routing
|
||||
|
||||
Add child spans to:
|
||||
|
||||
- sync
|
||||
- search
|
||||
- routing
|
||||
|
||||
This is the main performance-investigation layer.
|
||||
|
||||
### Phase 4: Failure-focused detail
|
||||
|
||||
Add selective deeper spans/log enrichment for:
|
||||
|
||||
- sync failures
|
||||
- relation resolution failures
|
||||
- slow file operations
|
||||
- cloud routing/auth failures
|
||||
|
||||
This keeps normal traces clean while improving debuggability.
|
||||
|
||||
### Phase 5: Loguru context binding and scoped context inheritance
|
||||
|
||||
Add:
|
||||
|
||||
- context-local telemetry state in `basic_memory.telemetry`
|
||||
- a shared `scope(...)` helper that opens a span and binds stable logger context together
|
||||
- context inheritance for routing, sync, and search so downstream `loguru` logs carry the active operation fields
|
||||
|
||||
This makes the trace view and the log stream tell the same story without forcing logger rewrites across the codebase.
|
||||
|
||||
## Local Dev Playbook
|
||||
|
||||
The fastest way to sanity-check the current trace shape is:
|
||||
|
||||
```bash
|
||||
LOGFIRE_TOKEN=lf_... just telemetry-smoke
|
||||
```
|
||||
|
||||
What this does:
|
||||
|
||||
- creates an isolated temp home, config dir, and project path
|
||||
- enables Logfire for the run
|
||||
- automatically exports to Logfire when `LOGFIRE_TOKEN` is present
|
||||
- defaults `BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=false` so the smoke run stays fast and trace-friendly
|
||||
- disables promo telemetry so the trace is about Basic Memory work, not analytics noise
|
||||
- runs a small CLI workflow:
|
||||
- `project add`
|
||||
- `tool write-note`
|
||||
- `tool read-note`
|
||||
- `tool edit-note`
|
||||
- `tool build-context`
|
||||
- `tool search-notes`
|
||||
- `doctor`
|
||||
|
||||
If you want to exercise the instrumentation without exporting anything upstream:
|
||||
|
||||
```bash
|
||||
BASIC_MEMORY_LOGFIRE_SEND_TO_LOGFIRE=false just telemetry-smoke
|
||||
```
|
||||
|
||||
If you want the smoke run to include vector or hybrid retrieval spans too:
|
||||
|
||||
```bash
|
||||
LOGFIRE_TOKEN=lf_... BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=true just telemetry-smoke
|
||||
```
|
||||
|
||||
The recipe sets `BASIC_MEMORY_LOGFIRE_ENVIRONMENT=telemetry-smoke` by default so these traces are easy to isolate in Logfire. Override it if you want the smoke traces grouped under a different environment name.
|
||||
|
||||
### What to look for
|
||||
|
||||
You should see a small set of comparable root spans rather than a framework-generated span forest:
|
||||
|
||||
- `cli.command.project`
|
||||
- `cli.command.tool`
|
||||
- `mcp.tool.write_note`
|
||||
- `mcp.tool.read_note`
|
||||
- `mcp.tool.edit_note`
|
||||
- `mcp.tool.build_context`
|
||||
- `mcp.tool.search_notes`
|
||||
- `sync.project.run`
|
||||
|
||||
You should also see correlated logs under those traces with stable fields like:
|
||||
|
||||
- `project_name`
|
||||
- `route_mode`
|
||||
- `tool_name`
|
||||
- `entrypoint`
|
||||
|
||||
### Expected nuance
|
||||
|
||||
`doctor` creates its own temporary project on purpose. That means the sync trace will usually show a different project name than the `telemetry-smoke` write/search traces. That is fine for smoke testing because the goal is to confirm:
|
||||
|
||||
- root span names are meaningful
|
||||
- scoped logs stay attached to the active trace
|
||||
- routing, tool, search, and sync phases are easy to distinguish
|
||||
|
||||
## Validation Checklist
|
||||
|
||||
We should consider the integration successful when the following are true:
|
||||
|
||||
1. With telemetry disabled, Basic Memory behaves exactly as it does today.
|
||||
2. With telemetry enabled, one user action produces one obvious root span.
|
||||
3. Logs emitted during that action are visible inside the same trace.
|
||||
4. A search in Logfire for `mcp.tool.write_note` or `sync.project.run` returns comparable spans across runs.
|
||||
5. Trace views show phase timing clearly without drowning in framework noise.
|
||||
6. Sensitive payloads are not captured by default.
|
||||
|
||||
## Immediate Implementation Direction
|
||||
|
||||
When we start coding, the first pass should be:
|
||||
|
||||
1. Add `basic_memory.telemetry`
|
||||
2. Add config/env switches for `enabled`, `send_to_logfire`, and service name
|
||||
3. Wire telemetry bootstrap into CLI, API, and MCP entrypoints
|
||||
4. Configure `loguru` to emit to both existing sinks and the Logfire handler when enabled
|
||||
5. Add manual root spans around:
|
||||
- CLI commands
|
||||
- API request handlers we care about
|
||||
- MCP tool entrypoints
|
||||
- sync root
|
||||
- search root
|
||||
6. Add child spans to the sync and routing phases only after the root span model feels clean
|
||||
|
||||
That gives us a strong foundation without repeating the earlier "turn on instrumentation everywhere" approach.
|
||||
@@ -0,0 +1,138 @@
|
||||
# MCP UI Bakeoff - Instructions & Test Plan
|
||||
|
||||
Last updated: 2026-02-02
|
||||
|
||||
## Scope
|
||||
|
||||
Compare three presentation paths for Basic Memory MCP tools:
|
||||
|
||||
1. **Tool‑UI (React)** via MCP App resources.
|
||||
2. **MCP‑UI Python SDK** embedded UI resources (legacy host path).
|
||||
3. **ASCII/ANSI** output for TUI clients.
|
||||
|
||||
This doc is the running instruction set and test plan. Update as implementation progresses.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Repo: `basic-memory` (worktree: `basic-memory-mcp-ui-poc`)
|
||||
- Node for tool‑ui build (already used for POC)
|
||||
- Python 3.12+ with `uv`
|
||||
|
||||
Optional (for MCP‑UI Python SDK path):
|
||||
|
||||
- Local repo: `/Users/phernandez/dev/mcp-ui`
|
||||
- Install the server SDK into the Basic Memory venv:
|
||||
- `uv pip install -e /Users/phernandez/dev/mcp-ui/sdks/python/server`
|
||||
|
||||
---
|
||||
|
||||
## Build / Refresh Steps
|
||||
|
||||
### Tool‑UI React bundle
|
||||
|
||||
```bash
|
||||
cd ui/tool-ui-react
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
This regenerates:
|
||||
|
||||
- `src/basic_memory/mcp/ui/html/search-results-tool-ui.html`
|
||||
- `src/basic_memory/mcp/ui/html/note-preview-tool-ui.html`
|
||||
|
||||
---
|
||||
|
||||
## How to Run the MCP Server
|
||||
|
||||
```bash
|
||||
basic-memory mcp --transport stdio
|
||||
```
|
||||
|
||||
Optional to pick UI variant for MCP App resources:
|
||||
|
||||
```bash
|
||||
export BASIC_MEMORY_MCP_UI_VARIANT=tool-ui # or vanilla | mcp-ui
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Cases
|
||||
|
||||
### 1) MCP App Resource UI (tool‑ui / vanilla / mcp‑ui)
|
||||
|
||||
Tools:
|
||||
- `search_notes`
|
||||
- `read_note`
|
||||
|
||||
Expect:
|
||||
- Tool meta points to `ui://basic-memory/search-results` and `ui://basic-memory/note-preview`
|
||||
- Resource content differs by `BASIC_MEMORY_MCP_UI_VARIANT`
|
||||
- Variant‑specific URIs also available:
|
||||
- `ui://basic-memory/search-results/vanilla`
|
||||
- `ui://basic-memory/search-results/tool-ui`
|
||||
- `ui://basic-memory/search-results/mcp-ui`
|
||||
- `ui://basic-memory/note-preview/vanilla`
|
||||
- `ui://basic-memory/note-preview/tool-ui`
|
||||
- `ui://basic-memory/note-preview/mcp-ui`
|
||||
|
||||
Manual check:
|
||||
- Trigger tool in MCP‑App‑capable host and confirm UI renders.
|
||||
|
||||
---
|
||||
|
||||
### 2) Text / JSON Output Modes
|
||||
|
||||
Tools:
|
||||
- `search_notes(output_format="text" | "json")`
|
||||
- `read_note(output_format="text" | "json")`
|
||||
- `write_note(output_format="text" | "json")`
|
||||
- `edit_note(output_format="text" | "json")`
|
||||
- `recent_activity(output_format="text" | "json")`
|
||||
- `list_memory_projects(output_format="text" | "json")`
|
||||
- `create_memory_project(output_format="text" | "json")`
|
||||
- `delete_note(output_format="text" | "json")`
|
||||
- `move_note(output_format="text" | "json")`
|
||||
- `build_context(output_format="json" | "text")`
|
||||
|
||||
Expect:
|
||||
- `text` mode preserves existing human-readable responses.
|
||||
- `json` mode returns structured dict/list payloads for machine-readable clients.
|
||||
|
||||
Automated:
|
||||
- `uv run pytest test-int/mcp/test_output_format_json_integration.py`
|
||||
|
||||
---
|
||||
|
||||
### 3) MCP‑UI Python SDK (embedded UI resource)
|
||||
|
||||
Tools (embedded resource responses):
|
||||
- `search_notes_ui` (MCP‑UI SDK)
|
||||
- `read_note_ui` (MCP‑UI SDK)
|
||||
|
||||
Expected output:
|
||||
- Tool response content contains an EmbeddedResource (`type: "resource"`)
|
||||
- `mimeType` is `text/html`
|
||||
- `_meta` includes:
|
||||
- `mcpui.dev/ui-preferred-frame-size`
|
||||
- `mcpui.dev/ui-initial-render-data`
|
||||
|
||||
Manual check:
|
||||
- Render tool responses using `UIResourceRenderer` (legacy host flow).
|
||||
|
||||
Automated (if SDK installed):
|
||||
- `uv run pytest test-int/mcp/test_ui_sdk_integration.py`
|
||||
|
||||
---
|
||||
|
||||
## Bakeoff Notes Template
|
||||
|
||||
Fill in after running:
|
||||
|
||||
- Tool‑UI (React): __
|
||||
- MCP‑UI SDK (embedded): __
|
||||
- Text/JSON modes: __
|
||||
|
||||
Decision + rationale: __
|
||||
@@ -0,0 +1,260 @@
|
||||
# Metadata Search Reference
|
||||
|
||||
Basic Memory automatically indexes custom frontmatter fields so you can query them with structured filters. Any YAML key in a note's frontmatter beyond the standard set (`title`, `type`, `tags`, `permalink`, `schema`) is stored as `entity_metadata` and becomes searchable.
|
||||
|
||||
## Querying with `search_notes`
|
||||
|
||||
`search_notes` is the single search tool for all queries — text, metadata filters, or both. The `query` parameter is optional, so you can use metadata filters alone without passing an empty string.
|
||||
|
||||
## Filter Syntax
|
||||
|
||||
Filters are a JSON dictionary where each key targets a frontmatter field and the value specifies the match condition. Multiple keys combine with **AND** logic — every filter must match.
|
||||
|
||||
### Equality
|
||||
|
||||
Match a single value exactly.
|
||||
|
||||
```json
|
||||
{"status": "active"}
|
||||
```
|
||||
|
||||
Finds notes whose frontmatter contains `status: active`.
|
||||
|
||||
### Array Contains (all)
|
||||
|
||||
Pass a list to require **all** listed values to be present in the field.
|
||||
|
||||
```json
|
||||
{"tags": ["security", "oauth"]}
|
||||
```
|
||||
|
||||
Finds notes tagged with both `security` and `oauth`.
|
||||
|
||||
### `$in` (any of)
|
||||
|
||||
Match if the field equals **any** value in the list.
|
||||
|
||||
```json
|
||||
{"priority": {"$in": ["high", "critical"]}}
|
||||
```
|
||||
|
||||
### `$gt`, `$gte`, `$lt`, `$lte`
|
||||
|
||||
Numeric and text comparisons. Numeric values use numeric comparison; strings use lexicographic comparison.
|
||||
|
||||
```json
|
||||
{"confidence": {"$gt": 0.7}}
|
||||
{"score": {"$lte": 100}}
|
||||
```
|
||||
|
||||
### `$between`
|
||||
|
||||
Range filter (inclusive). Takes a `[min, max]` pair.
|
||||
|
||||
```json
|
||||
{"score": {"$between": [0.3, 0.8]}}
|
||||
```
|
||||
|
||||
### Nested Access (dot notation)
|
||||
|
||||
Access nested frontmatter values using dots.
|
||||
|
||||
```json
|
||||
{"schema.version": "2"}
|
||||
```
|
||||
|
||||
This queries the `version` key inside a `schema` object in frontmatter.
|
||||
|
||||
### Summary Table
|
||||
|
||||
| Operator | Syntax | Example |
|
||||
|----------|--------|---------|
|
||||
| Equality | `{"field": "value"}` | `{"status": "active"}` |
|
||||
| Array contains (all) | `{"field": ["a", "b"]}` | `{"tags": ["security", "oauth"]}` |
|
||||
| `$in` (any of) | `{"field": {"$in": [...]}}` | `{"priority": {"$in": ["high", "critical"]}}` |
|
||||
| `$gt` / `$gte` | `{"field": {"$gt": N}}` | `{"confidence": {"$gt": 0.7}}` |
|
||||
| `$lt` / `$lte` | `{"field": {"$lt": N}}` | `{"score": {"$lt": 0.5}}` |
|
||||
| `$between` | `{"field": {"$between": [min, max]}}` | `{"score": {"$between": [0.3, 0.8]}}` |
|
||||
| Nested access | `{"a.b": "value"}` | `{"schema.version": "2"}` |
|
||||
|
||||
**Key rules:**
|
||||
- Filter keys must match `[A-Za-z0-9_-]+` (dots separate nesting levels).
|
||||
- Each operator dict must contain exactly one operator.
|
||||
- `$in` and array-contains require non-empty lists.
|
||||
- `$between` requires exactly two values `[min, max]`.
|
||||
|
||||
## MCP Tool — `search_notes`
|
||||
|
||||
`search_notes` is the single search tool for text queries, metadata filters, or both. The `query` parameter is optional.
|
||||
|
||||
**Relevant parameters:**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `query` | string (optional) | Text search query. Omit for filter-only searches. |
|
||||
| `metadata_filters` | dict | Structured filter dict (see syntax above) |
|
||||
| `tags` | list[str] | Convenience shorthand — merged into `metadata_filters["tags"]` |
|
||||
| `status` | string | Convenience shorthand — merged into `metadata_filters["status"]` |
|
||||
|
||||
**Merging rules:** `tags` and `status` are convenience shortcuts. They are merged into `metadata_filters` using `setdefault` — if the same key already exists in `metadata_filters`, the explicit filter wins.
|
||||
|
||||
**Examples:**
|
||||
|
||||
```python
|
||||
# Text search filtered by metadata
|
||||
await search_notes("authentication", metadata_filters={"status": "draft"})
|
||||
|
||||
# Filter-only search (no query needed)
|
||||
await search_notes(metadata_filters={"type": "spec"})
|
||||
|
||||
# Combine text, tags shortcut, and metadata
|
||||
await search_notes(
|
||||
"oauth flow",
|
||||
tags=["security"],
|
||||
metadata_filters={"confidence": {"$gt": 0.7}},
|
||||
)
|
||||
|
||||
# Convenience shortcuts
|
||||
await search_notes("planning", status="active")
|
||||
await search_notes(tags=["tier1", "alpha"])
|
||||
```
|
||||
|
||||
## Tag Search Shortcuts
|
||||
|
||||
The `tag:` prefix in a search query is a shorthand for tag-based metadata filtering. When `search_notes` receives a query starting with `tag:`, it converts the query into a `tags` filter and clears the text query.
|
||||
|
||||
```python
|
||||
# These are equivalent:
|
||||
await search_notes("tag:tier1")
|
||||
await search_notes("", tags=["tier1"])
|
||||
|
||||
# Multiple tags (comma or space separated) — all must be present:
|
||||
await search_notes("tag:tier1,alpha")
|
||||
await search_notes("tag:tier1 alpha")
|
||||
```
|
||||
|
||||
## CLI Access
|
||||
|
||||
The `bm tool search-notes` command exposes metadata filtering via `--meta` and `--filter` flags.
|
||||
|
||||
### `--meta` — simple key=value filters
|
||||
|
||||
Repeatable flag for equality filters on frontmatter fields.
|
||||
|
||||
```bash
|
||||
# Single filter
|
||||
bm tool search-notes "my query" --meta status=draft
|
||||
|
||||
# Multiple filters (AND logic)
|
||||
bm tool search-notes "" --meta status=active --meta priority=high
|
||||
```
|
||||
|
||||
### `--filter` — advanced JSON filters
|
||||
|
||||
Pass a full JSON filter dictionary for operator-based queries.
|
||||
|
||||
```bash
|
||||
# Range filter
|
||||
bm tool search-notes "" --filter '{"score": {"$between": [0.3, 0.8]}}'
|
||||
|
||||
# $in filter
|
||||
bm tool search-notes "" --filter '{"priority": {"$in": ["high", "critical"]}}'
|
||||
```
|
||||
|
||||
### `--tag` and `--status` — convenience shortcuts
|
||||
|
||||
```bash
|
||||
bm tool search-notes "query" --tag security --tag oauth
|
||||
bm tool search-notes "" --status draft
|
||||
```
|
||||
|
||||
### Combined example
|
||||
|
||||
```bash
|
||||
bm tool search-notes "authentication" --tag security --meta status=draft --type spec
|
||||
```
|
||||
|
||||
## Practical Examples
|
||||
|
||||
### Example notes with custom frontmatter
|
||||
|
||||
**`specs/auth-design.md`:**
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: Auth Design
|
||||
type: spec
|
||||
tags: [security, oauth]
|
||||
status: in-progress
|
||||
priority: high
|
||||
confidence: 0.85
|
||||
---
|
||||
|
||||
# Auth Design
|
||||
|
||||
## Observations
|
||||
- [decision] Use OAuth 2.1 with PKCE for all client types #security
|
||||
- [requirement] Token refresh must be transparent to the user
|
||||
|
||||
## Relations
|
||||
- implements [[Security Requirements]]
|
||||
```
|
||||
|
||||
**`specs/search-redesign.md`:**
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: Search Redesign
|
||||
type: spec
|
||||
tags: [search, performance]
|
||||
status: draft
|
||||
priority: medium
|
||||
confidence: 0.6
|
||||
---
|
||||
|
||||
# Search Redesign
|
||||
|
||||
## Observations
|
||||
- [goal] Sub-100ms search response times #performance
|
||||
- [approach] Hybrid FTS + vector retrieval
|
||||
|
||||
## Relations
|
||||
- depends_on [[Database Schema]]
|
||||
```
|
||||
|
||||
### Queries that find them
|
||||
|
||||
```python
|
||||
# Find all in-progress specs
|
||||
await search_notes(metadata_filters={"status": "in-progress", "type": "spec"})
|
||||
# → Auth Design
|
||||
|
||||
# Find high-confidence specs
|
||||
await search_notes(metadata_filters={"confidence": {"$gt": 0.7}})
|
||||
# → Auth Design (confidence: 0.85)
|
||||
|
||||
# Find specs with priority high or medium
|
||||
await search_notes(metadata_filters={"priority": {"$in": ["high", "medium"]}})
|
||||
# → Auth Design, Search Redesign
|
||||
|
||||
# Find specs in a confidence range
|
||||
await search_notes(metadata_filters={"confidence": {"$between": [0.5, 0.9]}})
|
||||
# → Auth Design (0.85), Search Redesign (0.6)
|
||||
|
||||
# Find notes tagged with security
|
||||
await search_notes("tag:security")
|
||||
# → Auth Design
|
||||
|
||||
# Combined: text search + metadata filter
|
||||
await search_notes("OAuth", metadata_filters={"status": "in-progress"})
|
||||
# → Auth Design
|
||||
```
|
||||
|
||||
### CLI equivalents
|
||||
|
||||
```bash
|
||||
bm tool search-notes "" --meta status=in-progress --type spec
|
||||
bm tool search-notes "" --filter '{"confidence": {"$gt": 0.7}}'
|
||||
bm tool search-notes "OAuth" --meta status=in-progress
|
||||
bm tool search-notes --tag security
|
||||
```
|
||||
@@ -0,0 +1,344 @@
|
||||
# Post-v0.18.0 Test Plan and Acceptance Criteria
|
||||
|
||||
## Goal
|
||||
|
||||
Define a complete validation plan for all major features merged after `v0.18.0`, combining:
|
||||
|
||||
- Coverage-gap-driven automated tests
|
||||
- Real MCP server integration tests (no mocks for target flows)
|
||||
- Manual MCP verification via LLM-driven tool calls
|
||||
|
||||
This plan is based on commits in `v0.18.0..HEAD` and the latest `just check` coverage output.
|
||||
|
||||
## Scope Window
|
||||
|
||||
- Start tag: `v0.18.0` (2026-01-28)
|
||||
- End: current `main`
|
||||
- Change volume: 12 feature commits + 14 bug-fix commits (+ release chores/hotfixes)
|
||||
|
||||
## Execution Strategy
|
||||
|
||||
1. Stabilize all feature-level acceptance criteria in automated tests first.
|
||||
2. Add black-box MCP integration tests for semantic search + schema (real server startup).
|
||||
3. Run manual MCP tool-call verification to confirm real UX and routing behavior.
|
||||
4. Re-run full gate: `just check` + targeted integration packs.
|
||||
|
||||
## Global Quality Gates
|
||||
|
||||
- Feature criteria below must all pass.
|
||||
- No regressions in existing suites.
|
||||
- Coverage improves in targeted low-coverage feature modules.
|
||||
- SQLite and Postgres parity for search/semantic features.
|
||||
|
||||
## Priority Coverage Gaps (from latest run)
|
||||
|
||||
These are the most important post-`v0.18.0` feature modules currently under-covered:
|
||||
|
||||
- `src/basic_memory/mcp/tools/schema.py` (27%)
|
||||
- `src/basic_memory/mcp/clients/schema.py` (36%)
|
||||
- `src/basic_memory/mcp/tools/ui_sdk.py` (43%)
|
||||
- `src/basic_memory/mcp/tools/search.py` (73%)
|
||||
- `src/basic_memory/repository/postgres_search_repository.py` (63%)
|
||||
- `src/basic_memory/mcp/async_client.py` (82%)
|
||||
- `src/basic_memory/api/v2/routers/schema_router.py` (80%)
|
||||
|
||||
## Feature Acceptance Criteria and Test Plan
|
||||
|
||||
### 1) Schema System (`c97733d`) — DONE
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- `schema_validate`, `schema_infer`, and `schema_diff` produce consistent outcomes across CLI/API/MCP for the same fixture set.
|
||||
- Strict validation fails deterministically on required-field/type violations.
|
||||
- Validation warnings are stable and machine-readable in non-strict mode.
|
||||
- Inference output is deterministic for unchanged input corpus.
|
||||
- Drift diff output is deterministic and identifies missing/extra/type-mismatch fields correctly.
|
||||
|
||||
### Existing coverage anchor points
|
||||
|
||||
- `tests/schema/*`
|
||||
- `tests/api/v2/test_schema_router.py`
|
||||
- `test-int/test_schema/*`
|
||||
|
||||
### Gaps to close — DONE
|
||||
|
||||
- ~~MCP schema tool branches (`src/basic_memory/mcp/tools/schema.py`)~~ — 18 tests in `tests/mcp/test_tool_schema.py`
|
||||
- ~~MCP schema client behavior (`src/basic_memory/mcp/clients/schema.py`)~~ — `tests/mcp/test_client_schema.py`
|
||||
- ~~Schema router error-path branches (`src/basic_memory/api/v2/routers/schema_router.py`)~~ — `tests/api/v2/test_schema_router.py`
|
||||
|
||||
### Planned additions — DONE
|
||||
|
||||
- ~~Add MCP tool tests for `schema_validate` strict + non-strict result shapes.~~ **DONE**
|
||||
- ~~Add MCP tool tests for `schema_infer` with explicit `entity_type` and inferred type fallback.~~ **DONE**
|
||||
- ~~Add MCP tool tests for `schema_diff` empty-diff and non-empty-diff paths.~~ **DONE**
|
||||
- ~~Add API tests for schema router invalid payload/edge error handling.~~ **DONE**
|
||||
- Add integration test that starts MCP server and calls schema tools end-to-end on fixture notes. — deferred to backlog item 4.
|
||||
|
||||
### 2) Semantic Search (`0777879`, `1428d18`, `344e651`) — DONE
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- `search_type=text|vector|hybrid` returns expected ranked results on canonical semantic corpus.
|
||||
- Missing semantic dependencies fail fast with actionable install guidance.
|
||||
- Reindex and provider/model changes produce valid vectors without dimension mismatch.
|
||||
- SQLite and Postgres produce equivalent behavior for semantic modes on the same dataset.
|
||||
- Generated-column migration path is valid on SQLite environments in use.
|
||||
|
||||
### Existing coverage anchor points
|
||||
|
||||
- `tests/repository/test_sqlite_vector_search_repository.py`
|
||||
- `tests/repository/test_postgres_search_repository.py`
|
||||
- `tests/services/test_semantic_search.py`
|
||||
- `tests/mcp/test_tool_search.py`
|
||||
- `test-int/test_search_performance_benchmark.py`
|
||||
|
||||
### Gaps to close — DONE
|
||||
|
||||
- ~~Uncovered Postgres vector/hybrid branches~~ — 20 tests in `tests/repository/test_postgres_search_repository_unit.py` + 5 integration tests in `test-int/semantic/test_semantic_coverage.py`
|
||||
- ~~MCP search semantic/output branches~~ — expanded `tests/mcp/test_tool_search.py`
|
||||
|
||||
### Planned additions — DONE
|
||||
|
||||
- ~~Expand Postgres repository tests for vector query composition edge cases.~~ **DONE**
|
||||
- ~~Expand Postgres repository tests for hybrid fusion ranking and pagination branches.~~ **DONE**
|
||||
- ~~Expand Postgres repository tests for embedding/provider error handling branches.~~ **DONE**
|
||||
- ~~Expand MCP search tool tests for vector/hybrid output formatting branches.~~ **DONE**
|
||||
- ~~Expand MCP search tool tests for semantic-disabled and missing-dependency failures.~~ **DONE**
|
||||
- Add MCP integration tests that start server and execute semantic `search_notes` tool calls. — deferred to backlog item 4.
|
||||
|
||||
### Semantic search quality benchmarks (NEW)
|
||||
|
||||
Full benchmark suite in `test-int/semantic/` covering 5 backend×provider combinations:
|
||||
- `sqlite-fts`, `sqlite-fastembed`, `postgres-fts`, `postgres-fastembed`, `postgres-openai`
|
||||
- Quality metrics: hit@1, recall@5, MRR@10 with per-query timing
|
||||
- Realistic corpus with cross-topic vocabulary overlap (240 notes, 4 topics)
|
||||
- Rich CLI viewer: `just semantic-report`
|
||||
- JSON artifact output: `just test-semantic-report`
|
||||
|
||||
Key finding: **FastEmbed (384-d local ONNX) matches or exceeds OpenAI (1536-d) quality at 30x lower latency.** Recommending FastEmbed as default for both local and cloud deployments.
|
||||
|
||||
### 3) Per-Project Local/Cloud Routing + API Key Auth (`d84708c`, `ed94877`, `312662f`) — DONE
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Project mode (`local`/`cloud`) persists and displays correctly.
|
||||
- Routing selects ASGI for local projects and HTTP+Bearer for cloud projects.
|
||||
- Cloud project without key fails with explicit remediation (`cloud set-key`/`cloud create-key`).
|
||||
- Resolution precedence is correct (factory > force-local > per-project cloud > global fallback > local).
|
||||
- Watch/sync only run for local projects.
|
||||
|
||||
### Existing coverage anchor points
|
||||
|
||||
- `tests/mcp/test_async_client_modes.py`
|
||||
- `tests/cli/test_project_set_cloud_local.py`
|
||||
- `tests/mcp/test_project_context.py`
|
||||
- `tests/test_project_resolver.py`
|
||||
- `tests/sync/test_watch_service_reload.py`
|
||||
|
||||
### Gaps to close — DONE
|
||||
|
||||
- ~~Cloud routing branch gaps in `src/basic_memory/mcp/async_client.py`~~ — expanded `tests/mcp/test_async_client_modes.py`
|
||||
|
||||
### Planned additions — DONE
|
||||
|
||||
- ~~Add branch-focused tests for all unresolved routing branches in `get_client()`.~~ **DONE**
|
||||
- Add MCP integration scenario with mixed local/cloud project config — deferred to backlog item 4.
|
||||
|
||||
### 4) Project-Prefixed Permalinks + Memory URL Routing (`545804f`) — DONE
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Project-prefixed permalinks are generated consistently on create/update/import flows.
|
||||
- Memory URLs resolve to the correct project/entity even with duplicate note titles.
|
||||
- `read_note`, `search`, `build_context`, write/edit/move flows preserve project identity correctly.
|
||||
- Link resolution remains correct for context-aware wikilinks.
|
||||
|
||||
### Existing coverage anchor points
|
||||
|
||||
- `tests/utils/test_permalink_formatting.py`
|
||||
- `tests/mcp/test_tool_read_note.py`
|
||||
- `tests/mcp/test_tool_search.py`
|
||||
- `tests/services/test_context_service.py`
|
||||
- `test-int/mcp/test_read_note_integration.py`
|
||||
|
||||
### Gaps to close
|
||||
|
||||
- No major coverage alarm in report, but keep as regression-critical due broad impact surface.
|
||||
|
||||
### Planned additions — DONE
|
||||
|
||||
- ~~Add one integration test with colliding titles across two projects and assert URL routing invariants.~~ **DONE** — `test-int/mcp/test_permalink_collision_integration.py` (2 tests: collision across projects + memory:// URL routing with project prefix)
|
||||
|
||||
### 5) MCP UI Variants + TUI Output (`8bc03d1`) — DONE
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- UI resource variant selection (`tool-ui`, `vanilla`, `mcp-ui`) follows env configuration.
|
||||
- `search_notes` and `read_note` expose expected resource metadata for UI hosts.
|
||||
- `ascii`/`ansi` outputs are deterministic and stable for terminal clients.
|
||||
|
||||
### Existing coverage anchor points
|
||||
|
||||
- `tests/mcp/test_tool_contracts.py`
|
||||
- `test-int/mcp/test_output_format_json_integration.py`
|
||||
- `test-int/mcp/test_ui_sdk_integration.py`
|
||||
|
||||
### Gaps to close — DONE
|
||||
|
||||
- ~~`src/basic_memory/mcp/tools/ui_sdk.py` branch coverage~~ — `tests/mcp/test_ui_sdk.py`
|
||||
- ~~`src/basic_memory/mcp/ui/sdk.py` and `src/basic_memory/mcp/ui/templates.py` branch coverage~~ — `tests/mcp/test_ui_templates.py` + `tests/mcp/test_ui_resources.py`
|
||||
|
||||
### Planned additions — DONE
|
||||
|
||||
- ~~Add unit tests for UI SDK metadata generation and template selection branches.~~ **DONE** — 31 tests
|
||||
- ~~Add integration assertion for variant-specific resource URIs and metadata payload shape.~~ **DONE**
|
||||
|
||||
### 6) Watch Command (`8df88e4`) — DONE
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- `basic-memory watch` starts and processes create/update/delete events.
|
||||
- Watch restart/reload path does not duplicate watchers.
|
||||
- Cloud-mode projects are excluded from active watcher set.
|
||||
|
||||
### Existing coverage anchor points
|
||||
|
||||
- `tests/cli/test_watch.py`
|
||||
- `tests/sync/test_coordinator.py`
|
||||
- `tests/sync/test_watch_service_reload.py`
|
||||
|
||||
### Planned additions — DONE
|
||||
|
||||
- ~~Add one stress-style integration test for rapid file changes and watcher stability.~~ **DONE** — `tests/sync/test_watch_service_stress.py` (3 tests: 50-file batch, mixed add/modify/delete batch, rapid modifications to same file)
|
||||
|
||||
### 7) CLI JSON Output (`a47c9c0`) — DONE
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- `--format json` returns valid JSON with stable keys for success paths.
|
||||
- Error paths also return JSON-shaped output with correct non-zero exits.
|
||||
- Default human output remains unchanged.
|
||||
|
||||
### Existing coverage anchor points
|
||||
|
||||
- `tests/cli/test_cli_tool_json_output.py`
|
||||
- `test-int/cli/test_cli_tool_json_integration.py`
|
||||
|
||||
### Planned additions — DONE
|
||||
|
||||
- ~~Add one failure-path integration test per high-use tool command.~~ **DONE** — `test-int/cli/test_cli_tool_json_failure_integration.py` (4 tests: read-note not found, write-note missing content, write→read roundtrip, recent-activity empty project)
|
||||
|
||||
### 8) Search/Edit and Metadata Fixes (`530cbac`, `f1d50c2`, `8838571`, `009e849`) — DONE
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Metadata filters produce consistent results on SQLite and Postgres.
|
||||
- `tag:` shorthand works alone and with mixed query terms.
|
||||
- Fast write/edit paths preserve `external_id` and metadata integrity.
|
||||
|
||||
### Existing coverage anchor points
|
||||
|
||||
- `tests/repository/test_metadata_filters.py`
|
||||
- `tests/repository/test_search_repository.py`
|
||||
- `tests/services/test_search_service.py`
|
||||
|
||||
### Planned additions — DONE
|
||||
|
||||
- ~~Add Postgres-specific metadata filter edge-case tests to mirror SQLite assertions exactly.~~ **DONE** — `tests/repository/test_metadata_filters_edge_cases.py` (6 tests: missing field, AND logic, contains single-element array, nested path missing intermediate, $gte/$lte boundaries, $between inclusive — all pass on both SQLite and Postgres)
|
||||
|
||||
### 9) Compatibility and Hotfix Regression Pack (`c46d7a6`, `a0e754b`, `343a6e1`, `24ca5f6`, `e3ced49`, `8489a3d`, `b609c4e`, `f6e0a5b`, `7624a20`)
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Legacy endpoints required by older CLI versions function without `405` (`GET /projects/projects`, `POST /projects/projects`, `POST /projects/config/sync`).
|
||||
- Entity creation conflicts map to conflict status (not 500).
|
||||
- `recent_activity` prompt defaults are correct.
|
||||
- No spurious `metadata: {}` in serialized frontmatter.
|
||||
- Tigris/rclone uses global consistency headers for all transaction types.
|
||||
- `bm --version` fast path avoids heavy import path and remains responsive.
|
||||
- Default SQLite DB path is isolated by config dir.
|
||||
|
||||
### Gaps to close
|
||||
|
||||
- ~~Commits with no direct tests added (`c46d7a6`, `344e651`, `f6e0a5b`) need explicit regression tests.~~ **DONE**
|
||||
|
||||
### Planned additions — DONE
|
||||
|
||||
- ~~Add API compat test covering all legacy endpoint methods and payloads.~~ **DONE** — `test_legacy_v1_add_project_endpoint`, `test_legacy_v1_sync_config_endpoint`
|
||||
- ~~Add CLI fast-path test for `--version` import behavior/performance guard.~~ **DONE** — `test_bm_version_does_not_import_heavy_modules`
|
||||
- ~~Add empty metadata serialization regression test.~~ **DONE** — `test_schema_to_markdown_empty_metadata_no_metadata_key`
|
||||
- Add migration safety test for SQLite generated columns (`VIRTUAL` expectation) — deferred, low risk.
|
||||
|
||||
## MCP Manual Verification Plan (LLM Tool Calls)
|
||||
|
||||
Run after automated tests pass.
|
||||
|
||||
### Setup
|
||||
|
||||
- Start MCP server: `basic-memory mcp --transport stdio`
|
||||
- Use an MCP-capable client and issue tool calls directly.
|
||||
|
||||
### Manual scenarios
|
||||
|
||||
- Schema: call `schema_validate`, `schema_infer`, and `schema_diff` on known fixtures.
|
||||
- Schema: verify error and success payloads match acceptance criteria.
|
||||
- Semantic search: call `search_notes` with `search_type=text|vector|hybrid`.
|
||||
- Semantic search: verify ranking relevance on semantic fixture queries.
|
||||
- Routing: call tools with explicit project on mixed local/cloud setup.
|
||||
- Routing: verify success/failure paths with and without API key.
|
||||
- Permalink routing: read/write/search notes across projects with colliding titles.
|
||||
- Permalink routing: verify memory URL routing correctness.
|
||||
- UI/TUI: call `search_notes` and `read_note` with UI variants and `output_format=text|json`.
|
||||
- UI/TUI: verify payload/resource format and metadata completeness.
|
||||
|
||||
## Implementation Backlog (Ordered)
|
||||
|
||||
1. ~~Fill schema MCP/client/router coverage gaps.~~ **DONE** — 18 tests in `test_tool_schema.py` + `test_client_schema.py`
|
||||
2. ~~Fill semantic search MCP + Postgres repository gaps.~~ **DONE** — 20 tests in `test_postgres_search_repository_unit.py` + `test_tool_search.py`
|
||||
3. ~~Add compatibility regression tests (legacy endpoints, migration, version fast path).~~ **DONE** — 5 tests across 3 files (see below)
|
||||
4. ~~Add feature-level integration tests (permalinks, watch, CLI JSON, metadata filters).~~ **DONE** — 15 tests across 4 files (see items 4, 6, 7, 8 above)
|
||||
5. ~~Expand UI SDK and template branch tests.~~ **DONE** — 31 tests in `test_ui_templates.py` + `test_ui_sdk.py` + `test_ui_resources.py`
|
||||
6. ~~Run full gate and capture results in a short release readiness summary.~~ **DONE** — see results below
|
||||
|
||||
### Full Gate Results (`just check`)
|
||||
|
||||
| Phase | Result |
|
||||
|-------|--------|
|
||||
| lint | PASS |
|
||||
| format | PASS |
|
||||
| typecheck | PASS |
|
||||
| Unit tests (SQLite) | 1788 passed, 15 skipped |
|
||||
| Integration tests (SQLite) | 243 passed, 4 skipped, 10 deselected |
|
||||
| Unit tests (Postgres) | 1760 passed, 28 skipped |
|
||||
| Integration tests (Postgres) | 234 passed, 13 skipped, 10 deselected |
|
||||
|
||||
**0 failures. 10 deselected = semantic benchmark tests (run separately via `just test-semantic`).**
|
||||
|
||||
### Item 3 Details — Compatibility Regression Tests
|
||||
|
||||
| Test | File | What it covers |
|
||||
|------|------|----------------|
|
||||
| `test_legacy_v1_add_project_endpoint` | `tests/api/v2/test_project_router.py` | POST `/projects/projects` legacy route reachable (idempotent path) |
|
||||
| `test_legacy_v1_sync_config_endpoint` | `tests/api/v2/test_project_router.py` | POST `/projects/config/sync` legacy route reachable |
|
||||
| `test_bm_version_does_not_import_heavy_modules` | `tests/cli/test_cli_exit.py` | `bm --version` fast path does not load `basic_memory.mcp` |
|
||||
| `test_schema_to_markdown_empty_metadata_no_metadata_key` | `tests/markdown/test_entity_parser_error_handling.py` | `schema_to_markdown()` with `entity_metadata={}` emits no `metadata:` key |
|
||||
| `test_legacy_v1_list_projects_endpoint` | `tests/api/v2/test_project_router.py` | (pre-existing) GET `/projects/projects` legacy route |
|
||||
|
||||
**Suite totals after item 3: 1764 passed, 15 skipped, 0 failures.**
|
||||
|
||||
## Suggested Commands
|
||||
|
||||
- Full suite: `just check`
|
||||
- Fast loop: `just fast-check`
|
||||
- E2E consistency: `just doctor`
|
||||
- SQLite focused: `just test-sqlite`
|
||||
- Postgres focused: `just test-postgres`
|
||||
- Schema integration: `pytest test-int/test_schema -q`
|
||||
- Semantic + repo focus: `pytest tests/repository/test_postgres_search_repository.py tests/mcp/test_tool_search.py tests/services/test_semantic_search.py -q`
|
||||
- MCP integration focus: `pytest test-int/mcp -q`
|
||||
|
||||
## Exit Criteria for This Plan
|
||||
|
||||
- All feature acceptance criteria above are validated.
|
||||
- All identified high-priority coverage gaps are addressed or explicitly documented as intentional.
|
||||
- Manual MCP verification scenarios complete with no P0/P1 findings.
|
||||
@@ -0,0 +1,318 @@
|
||||
# v0.19.0 Release Notes
|
||||
|
||||
## Overview
|
||||
|
||||
v0.19.0 is a major release that introduces semantic vector search, a schema validation system,
|
||||
project-prefixed permalinks, per-project cloud routing, and a significant upgrade to FastMCP 3.0.
|
||||
It includes 90+ commits since v0.18.0 spanning new features, architectural improvements, and
|
||||
stability fixes across both SQLite and Postgres backends.
|
||||
|
||||
---
|
||||
|
||||
## Major Features
|
||||
|
||||
### Semantic Vector Search
|
||||
|
||||
Full vector and hybrid search for SQLite (via sqlite-vec) and Postgres (via pgvector).
|
||||
|
||||
- **Hybrid search mode** combines full-text search (FTS) with vector similarity for best results
|
||||
- **Score-based fusion** replaces RRF for hybrid ranking — `max(vec, fts) + 0.3 * min(vec, fts)` preserves dominant signals and rewards dual-source agreement (#577)
|
||||
- **Default search mode** is now `hybrid` when semantic search is enabled, `text` when disabled
|
||||
- Embedding providers: FastEmbed (local, default) or OpenAI API
|
||||
- Configurable similarity threshold via `semantic_min_similarity` (default 0.55)
|
||||
- Per-query `min_similarity` override on `search_notes` tool
|
||||
- Auto-backfill: existing entities get embeddings generated on first startup
|
||||
- Backend-specific distance-to-similarity conversion (cosine for SQLite, inner product for Postgres)
|
||||
- FTS fallback: if semantic dependencies are missing, search gracefully degrades to text-only
|
||||
- sqlite-vec knn `k` parameter capped at 4096 to prevent backend errors
|
||||
|
||||
**Configuration:**
|
||||
```json
|
||||
{
|
||||
"semantic_search_enabled": true,
|
||||
"semantic_embedding_provider": "fastembed",
|
||||
"semantic_embedding_model": "bge-small-en-v1.5",
|
||||
"semantic_min_similarity": 0.55
|
||||
}
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
```
|
||||
search_notes("machine learning concepts", search_type="hybrid")
|
||||
search_notes("similar to my notes on coffee", search_type="vector")
|
||||
search_notes("exact phrase match", search_type="text")
|
||||
search_notes("broad search", min_similarity=0.3) # lower threshold for more results
|
||||
```
|
||||
|
||||
### Schema System
|
||||
|
||||
Validate note structure against user-defined schemas with frontmatter-based rules.
|
||||
|
||||
- Define schemas as YAML in note frontmatter with field types, required fields, and constraints
|
||||
- Frontmatter validation during sync — malformed notes get clear error messages
|
||||
- Schema inference from existing notes to bootstrap schemas from your content
|
||||
- Schema diff to compare two schemas and see changes
|
||||
- Available via MCP tools and CLI
|
||||
|
||||
### Project-Prefixed Permalinks
|
||||
|
||||
Permalinks now include the project name for unambiguous cross-project references.
|
||||
|
||||
- Memory URLs like `memory://project-name/folder/note` route to the correct project
|
||||
- Existing non-prefixed permalinks continue to work (backwards compatible)
|
||||
- Controlled by `permalinks_include_project` config (default: true)
|
||||
- `build_context` and `search_notes` auto-detect project from URL prefix
|
||||
|
||||
### Per-Project Cloud Routing
|
||||
|
||||
Individual projects can be routed through the cloud while others stay local.
|
||||
|
||||
- Set a project to cloud mode: `bm project set-cloud research`
|
||||
- Revert to local: `bm project set-local research`
|
||||
- Uses API key authentication: `bm cloud set-key bmc_abc123...`
|
||||
- MCP tools automatically route based on each project's mode
|
||||
- Local MCP server (`bm mcp`) still uses local routing for all projects by default
|
||||
- `--local` and `--cloud` CLI flags override per-command
|
||||
|
||||
### Workspace Selection
|
||||
|
||||
Cloud projects can target specific workspaces for multi-tenant environments.
|
||||
|
||||
- `workspace` parameter on MCP tools for explicit workspace targeting
|
||||
- CLI workspace-aware project listing with `bm project list`
|
||||
- Spinner feedback while fetching cloud projects
|
||||
|
||||
---
|
||||
|
||||
## New Tools and Capabilities
|
||||
|
||||
### Dashboard (`bm project info`)
|
||||
|
||||
`bm project info` now displays an htop-inspired compact dashboard with:
|
||||
|
||||
- Horizontal bar charts for note types (top 5)
|
||||
- Embedding coverage bar with Unicode block characters
|
||||
- Colored status dots for at-a-glance health
|
||||
- `EmbeddingStatus` schema and `get_embedding_status()` service method for programmatic access
|
||||
|
||||
### Unified Metadata Search
|
||||
|
||||
`search_by_metadata` has been merged into `search_notes` — one tool for all searches.
|
||||
`query` is now optional, so you can search purely by frontmatter metadata.
|
||||
|
||||
```
|
||||
search_notes(metadata_filters={"status": "in-progress"})
|
||||
search_notes(metadata_filters={"tags": ["security", "oauth"]})
|
||||
search_notes(metadata_filters={"priority": {"$in": ["high", "critical"]}})
|
||||
search_notes(metadata_filters={"schema.confidence": {"$gt": 0.7}})
|
||||
search_notes(tags=["security"]) # convenience shorthand
|
||||
search_notes(status="draft") # convenience shorthand
|
||||
```
|
||||
|
||||
### JSON Output Mode
|
||||
|
||||
All MCP tools now support `output_format="json"` for machine-readable responses.
|
||||
|
||||
- Default remains `"text"` for human-readable output (no breaking changes)
|
||||
- `build_context` defaults to `"json"` with slimmed payloads (redundant fields stripped)
|
||||
- CLI tool commands support `--format json` flag
|
||||
|
||||
### `tag:` Search Shorthand
|
||||
|
||||
Search by tag using convenient shorthand syntax.
|
||||
|
||||
```
|
||||
search_notes("tag:security")
|
||||
search_notes("tag:coffee AND tag:brewing")
|
||||
```
|
||||
|
||||
### Entity User Tracking
|
||||
|
||||
Entities now track `created_by` and `last_updated_by` fields for attribution.
|
||||
|
||||
### Improved Search Result Content (#609)
|
||||
|
||||
Search results now surface more relevant context:
|
||||
|
||||
- `matched_chunk_text` populated for FTS-only hybrid results (no more fallback to truncated content)
|
||||
- `TOP_CHUNKS_PER_RESULT` increased from 3 to 5, catching answers deeper in large notes (~2700 → ~4500 chars)
|
||||
- `CONTENT_DISPLAY_LIMIT` doubled from 2000 to 4000 chars for results without matched chunks
|
||||
|
||||
### `write_note` Overwrite Guard (#632)
|
||||
|
||||
`write_note` is now non-idempotent by default. If a note already exists, the tool returns an
|
||||
error instead of silently overwriting. Pass `overwrite=True` to replace, or use `edit_note`
|
||||
for incremental updates. Config option `write_note_overwrite_default` restores the old upsert
|
||||
behavior.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Changes
|
||||
|
||||
### Score-Based Hybrid Fusion (#577)
|
||||
|
||||
RRF (Reciprocal Rank Fusion) compressed all fused scores to ~0.016, destroying ranking
|
||||
differentiation. The new formula `max(vec, fts) + FUSION_BONUS * min(vec, fts)` preserves
|
||||
dominant signals and rewards dual-source agreement. Zero-score results now produce zero
|
||||
fused score instead of receiving a 0.1 weight floor.
|
||||
|
||||
### FastMCP 3.0 Upgrade
|
||||
|
||||
Upgraded from FastMCP 2.12.3 to 3.0.1.
|
||||
|
||||
- Tool annotations (`readOnlyHint`, `openWorldHint`) for better client integration
|
||||
- Improved MCP protocol compliance
|
||||
- Better error handling and context management
|
||||
|
||||
### Prompts Call MCP Tools Directly
|
||||
|
||||
MCP prompts (`search`, `continue_conversation`) now call MCP tools directly instead of
|
||||
going through API endpoints. This fixes empty results in discovery mode and ensures prompts
|
||||
use the same resolution logic as tools (including LinkResolver fallback).
|
||||
|
||||
### build_context LinkResolver Fallback
|
||||
|
||||
`build_context` now falls back to LinkResolver when an exact permalink lookup returns empty.
|
||||
This uses the same 7-strategy resolution pipeline as `read_note`, so callers no longer get
|
||||
empty results for valid note identifiers that don't match exact permalinks.
|
||||
|
||||
### Sync Handles Semantic Dependency Errors Gracefully
|
||||
|
||||
When sqlite-vec or another embedding provider is unavailable, `sync_file` now catches
|
||||
`SemanticDependenciesMissingError` separately. The entity is created and FTS-indexed
|
||||
successfully — only vector embeddings are skipped, with a clear warning:
|
||||
|
||||
```
|
||||
WARNING: Semantic search dependencies missing — vector embeddings skipped for path=note.md.
|
||||
Run 'bm reindex --embeddings' after resolving the dependency issue.
|
||||
```
|
||||
|
||||
### Unified Project Path
|
||||
|
||||
Cloud projects with bisync now store the local filesystem path in `path` (not the Docker
|
||||
container path). Config migration automatically promotes `local_sync_path` → `path` for
|
||||
existing configs.
|
||||
|
||||
---
|
||||
|
||||
## CLI Improvements
|
||||
|
||||
### Status and Doctor Default to Local Routing
|
||||
|
||||
`bm status` and `bm doctor` now default to local routing since they scan the local filesystem.
|
||||
Previously, cloud-mode projects would route these commands to the cloud API, which returned
|
||||
Docker-internal paths that don't exist locally.
|
||||
|
||||
### `--format json` for CLI Tool Commands
|
||||
|
||||
All `bm tool` subcommands support `--format json` for machine-readable output, enabling
|
||||
integration with scripts and plugins.
|
||||
|
||||
### `--json` for Top-Level CLI Commands
|
||||
|
||||
Five additional CLI commands now support `--json` for machine-readable output:
|
||||
|
||||
- `bm status --json` — sync report with new/modified/deleted/moved files and skipped files
|
||||
- `bm project list --json` — structured project list with name, paths, routing mode, and defaults
|
||||
- `bm schema validate --json` — validation report with per-note pass/fail, warnings, and errors
|
||||
- `bm schema infer --json` — field frequency analysis and suggested schema definition
|
||||
- `bm schema diff --json` — drift report with new fields, dropped fields, and cardinality changes
|
||||
|
||||
This complements the existing `bm project info --json` and `bm tool --format json` support,
|
||||
making all major CLI commands scriptable for CI pipelines and automation.
|
||||
|
||||
### Cloud Promo and Analytics
|
||||
|
||||
- Cloud promo panel shown on first run or version bump with OSS discount code
|
||||
- Anonymous usage telemetry via Umami Cloud (promo/login funnel events only)
|
||||
- Opt out with `BASIC_MEMORY_NO_PROMOS=1`
|
||||
- No PII, no file contents, no per-command tracking
|
||||
- See [Telemetry](https://github.com/basicmachines-co/basic-memory#telemetry) in README
|
||||
|
||||
---
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- **#577**: RRF fusion compressed all hybrid scores to ~0.016, destroying ranking differentiation
|
||||
- **#582**: build_context returns empty results on valid note identifiers
|
||||
- **#575**: Remove hardcoded "main" default from default_project
|
||||
- **#595**: recent_activity dedup and pagination across MCP tools
|
||||
- **#593**: Backend-specific distance-to-similarity conversion
|
||||
- **#592**: Strip NUL bytes from content before PostgreSQL search indexing
|
||||
- **#562**: Use VIRTUAL instead of STORED columns in SQLite migration
|
||||
- **#558**: Add X-Tigris-Consistent headers to all rclone commands
|
||||
- **#541**: Handle EntityCreationError as conflict
|
||||
- **#536**: Stabilize metadata filters on Postgres
|
||||
- **#533**: Fix recent_activity prompt defaults
|
||||
- **#530**: Prevent spurious `metadata: {}` in frontmatter output
|
||||
- **#601**: Return matched chunk text in search results
|
||||
- **#606**: Accept `null` for `expected_replacements` in `edit_note`
|
||||
- **#579, #607**: Guard against closed streams in promo panel and missing vector tables on shutdown
|
||||
- **#609**: FTS-only hybrid results missing `matched_chunk_text`; content limits too conservative
|
||||
- **#631**: `build_context` related_results schema validation failure — replaced fragile `_slim_context()` stripping with Pydantic `exclude=True` field config
|
||||
- **#630**: Skip workspace resolution when client factory is active — prevents 401 errors in cloud MCP server mode
|
||||
- **#30**: `tag:` prefix query fails with hybrid search — moved tag prefix parsing to MCP tool level so it works with all search modes
|
||||
- **#31**: `search_notes` returns cluttered observation/relation-level results — now defaults to entity-level results
|
||||
- **#28**: `schema_infer` and `schema_diff` return raw Pydantic models as "undefined" in LLM output — added markdown formatters
|
||||
- Fix `schema_validate` identifier resolution (now uses LinkResolver) and text rendering (markdown formatter)
|
||||
- **#634**: `schema_validate` and `schema_diff` use stale database metadata instead of reading schema definitions from file — now reads frontmatter directly from the file with fallback to database metadata
|
||||
- Fix `Post(**metadata)` crash when frontmatter contains `content` or `handler` keys
|
||||
- Fix list-valued frontmatter fields (`title`, `type`) crashing on `.strip()` — now coerced to strings
|
||||
- Cap sqlite-vec knn `k` parameter at 4096 to prevent backend errors
|
||||
- Parameterize SQL queries in search repository type filters
|
||||
- Double-default display in project list
|
||||
- `ensure_frontmatter_on_sync` default changed to `True`
|
||||
- Status/doctor commands fail with cloud-mode projects (Docker path error)
|
||||
- Prompts return "0 projects" in discovery mode
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
- Upgrade `cryptography` for CVE advisory
|
||||
- Upgrade `python-multipart` for security advisory
|
||||
|
||||
---
|
||||
|
||||
## Internal / Developer
|
||||
|
||||
- **#598**: Upgrade FastMCP 2.12.3 → 3.0.1 with tool annotations
|
||||
- **#594**: Add `ty` as supplemental type checker
|
||||
- **#538**: Add fast feedback loop tooling (`just fast-check`, `just doctor`, `just testmon`)
|
||||
- **#600**: Rename `entity_type` to `note_type` for consistency
|
||||
- **#596**: Fix CLI runtime defects and audit regressions
|
||||
- CLI refactoring and workspace-aware cloud project listing
|
||||
- Split and speed up PR test matrix in CI
|
||||
- Fix CI: collect coverage from test jobs instead of re-running all tests
|
||||
- Create `search_vector_chunks` in test fixtures for Postgres compatibility
|
||||
|
||||
---
|
||||
|
||||
## Configuration Changes
|
||||
|
||||
| Setting | Old Default | New Default | Notes |
|
||||
|---------|-------------|-------------|-------|
|
||||
| `semantic_search_enabled` | `false` | `true` | Semantic search on by default |
|
||||
| `ensure_frontmatter_on_sync` | `false` | `true` | Frontmatter added during sync |
|
||||
| `permalinks_include_project` | `false` | `true` | Project prefix in permalinks |
|
||||
|
||||
---
|
||||
|
||||
## Upgrade Notes
|
||||
|
||||
- **Semantic search dependencies** are now included by default. If sqlite-vec fails to load,
|
||||
search gracefully falls back to FTS. Run `bm reindex --embeddings` to generate embeddings
|
||||
for existing content.
|
||||
- **Hybrid search scoring** has changed from RRF to score-based fusion. Search result ordering
|
||||
may differ — results should be more accurate with better score differentiation.
|
||||
- **`search_by_metadata`** is removed as a standalone tool. Use `search_notes` with
|
||||
`metadata_filters` instead (same parameters, same behavior).
|
||||
- **Project-prefixed permalinks** are enabled by default. Existing notes keep their current
|
||||
permalinks until modified. Set `permalinks_include_project: false` to disable.
|
||||
- **Frontmatter on sync** is now enabled by default. Files without frontmatter will have it
|
||||
added on next sync. Set `ensure_frontmatter_on_sync: false` to preserve old behavior.
|
||||
- **Config migration** runs automatically for cloud projects with bisync — `local_sync_path`
|
||||
is promoted to `path` so filesystem operations work correctly.
|
||||
- **`write_note` is no longer idempotent** — calls to `write_note` for existing notes now
|
||||
return an error unless `overwrite=True` is passed. Use `edit_note` for incremental changes,
|
||||
or set `write_note_overwrite_default: true` in config to restore the old behavior.
|
||||
@@ -0,0 +1,209 @@
|
||||
# Semantic Search Manual Test Log
|
||||
|
||||
## Overview
|
||||
|
||||
Manual test session for semantic (vector) search on the main project.
|
||||
- Date: 2026-02-15
|
||||
- Database: ~/.basic-memory/memory.db (SQLite)
|
||||
- Entities: 456 embedded, 2714 vector chunks
|
||||
- Search index: 2390 FTS entries
|
||||
- Embedding model: default (384-dim, sqlite-vec)
|
||||
|
||||
## Test Plan
|
||||
|
||||
1. **Search Type Routing** — verify vector/hybrid/text dispatch, invalid search_type handling
|
||||
2. **Conceptual Queries** — natural language where vector should beat FTS
|
||||
3. **Keyword Queries** — exact terms where FTS should be strong
|
||||
4. **Hybrid Ranking** — queries where both FTS and vector contribute
|
||||
5. **Result Types** — entities, observations, relations in vector results
|
||||
6. **Filters + Vector** — combine vector with types/entity_types/after_date
|
||||
7. **Edge Cases** — short queries, long queries, empty, special chars, no-match
|
||||
8. **Pagination** — page > 1, page_size respected
|
||||
|
||||
---
|
||||
|
||||
## Test Results
|
||||
|
||||
### Test 1: Search Type Routing
|
||||
|
||||
#### 1a: search_type="semantic" (invalid value)
|
||||
- **Input:** query="how does the knowledge graph work", search_type="semantic"
|
||||
- **Expected:** error or explicit fallback
|
||||
- **Actual:** Silently falls through to text search (else branch in search.py:430)
|
||||
- **Verdict:** BUG — should either be a recognized alias for "vector" or return an error
|
||||
|
||||
#### 1b: search_type="vector"
|
||||
- **Input:** query="keeping AI context between sessions", search_type="vector"
|
||||
- **Actual:** 5 results, scores ~0.58-0.59, found "Maintaining context across conversation boundaries" observation
|
||||
- **Verdict:** PASS
|
||||
|
||||
#### 1c: search_type="text" with conceptual query
|
||||
- **Input:** query="keeping AI context between sessions", search_type="text"
|
||||
- **Actual:** 0 results (no exact keyword match)
|
||||
- **Verdict:** PASS (expected — FTS requires token overlap)
|
||||
|
||||
#### 1d: search_type="hybrid" with conceptual query
|
||||
- **Input:** query="keeping AI context between sessions", search_type="hybrid"
|
||||
- **Actual:** 5 results, same ranking as vector (FTS contributed nothing here)
|
||||
- **Verdict:** PASS
|
||||
|
||||
#### 1e: search_type="text" with keyword query
|
||||
- **Input:** query="OAuth authentication", search_type="text"
|
||||
- **Actual:** 3 results — AUTH.md Supabase OAuth, OAuth Rip-and-Replace, OAuth Integration Analysis
|
||||
- **Verdict:** PASS
|
||||
|
||||
#### 1f: search_type="vector" with keyword query
|
||||
- **Input:** query="OAuth authentication", search_type="vector"
|
||||
- **Actual:** Same top results as text (keyword-rich content also scores well in vector space)
|
||||
- **Verdict:** PASS
|
||||
|
||||
---
|
||||
|
||||
### Test 2: Conceptual Queries (vector advantage)
|
||||
|
||||
#### 2a: Natural language question
|
||||
- **Input:** query="why do AI assistants forget things", search_type="vector"
|
||||
- **Actual:** 5 results — Manual Testing Session, "Balance security and usability" observation, "Tools should match thought patterns" observation. Scores ~0.56-0.57
|
||||
- **Vector advantage:** Found conceptually related content despite no exact keyword overlap
|
||||
- **Verdict:** PASS
|
||||
|
||||
#### 2b: Same query, text search
|
||||
- **Input:** query="why do AI assistants forget things", search_type="text"
|
||||
- **Actual:** 1 result — "What is Basic Memory?" (likely matched on "AI" token)
|
||||
- **Verdict:** PASS (demonstrates vector advantage — text barely matched)
|
||||
|
||||
#### 2c: Domain concept with no jargon
|
||||
- **Input:** query="pricing strategy for cloud product", search_type="vector"
|
||||
- **Actual:** 3 results — SPEC-16 MCP Cloud Service Consolidation, knowledge architecture observation, Visual Knowledge Spaces relation. Scores ~0.56-0.57
|
||||
- **Verdict:** PASS (found cloud-related content conceptually)
|
||||
|
||||
#### 2d: Technical concept, long query
|
||||
- **Input:** query="SQLite performance optimization WAL mode concurrent writes", search_type="vector"
|
||||
- **Actual:** 3 results — SPEC-11 API Performance Optimization, Real-Time Updates with WebSockets, marketing status update. Scores ~0.55-0.58
|
||||
- **Verdict:** PASS (found performance-related content)
|
||||
|
||||
---
|
||||
|
||||
### Test 3: Keyword Queries (FTS strength)
|
||||
|
||||
#### 3a: Exact term match — "OAuth authentication"
|
||||
- **Text:** 3 results with high relevance (exact matches in titles)
|
||||
- **Vector:** Same top results (keyword overlap helps vector too)
|
||||
- **Verdict:** PASS — FTS and vector converge on keyword-rich queries
|
||||
|
||||
#### 3b: "OAuth" single keyword, hybrid mode
|
||||
- **Input:** query="OAuth", search_type="hybrid"
|
||||
- **Actual:** 5 results — Basic Memory Coding Guide, AI Collaboration Examples, SPEC-18, daily note, Manual Testing Session. FTS + vector blended. Scores ~0.016-0.032
|
||||
- **Note:** Top hybrid result is "Basic Memory Coding Guide" not an OAuth-specific doc — suggests hybrid scoring may dilute strong FTS matches
|
||||
- **Verdict:** PASS but hybrid ranking questionable for single-keyword queries
|
||||
|
||||
---
|
||||
|
||||
### Test 4: Hybrid Ranking
|
||||
|
||||
#### 4a: Hybrid vs vector on "OAuth authentication"
|
||||
- **Hybrid with entity_types=["entity"]:** 5 results — RLS Implementation Lessons, Cloud Readiness Assessment, AUTH.md OAuth, Core Service Implementation, OAuth Rip-and-Replace. Scores ~0.016-0.023
|
||||
- **Vector with entity_types=["entity"]:** 5 results — Core Service Implementation, SPEC-13 CLI Auth, Coding Guide, Authentication Service, ADR Production Auth. Scores ~0.55-0.60
|
||||
- **Observation:** Hybrid surfaces different top results than vector-only. Hybrid found RLS and Cloud Readiness docs that vector didn't prioritize. Different ranking is expected from RRF fusion.
|
||||
- **Verdict:** PASS — hybrid produces meaningfully different ranking
|
||||
|
||||
---
|
||||
|
||||
### Test 5: Result Types
|
||||
|
||||
#### 5a: Vector returns all result types
|
||||
- **Input:** query="keeping AI context between sessions", search_type="vector"
|
||||
- **Entities:** SPEC-18 AI Memory Management Tool (type=entity)
|
||||
- **Relations:** Prompt Builder integrates_with (type=relation)
|
||||
- **Observations:** "Translation layer is key" (type=observation), "Maintaining context across conversation boundaries" (type=observation)
|
||||
- **Verdict:** PASS — all three types appear in vector results
|
||||
|
||||
#### 5b: Observations carry metadata
|
||||
- **Observation result:** category="challenge", content="Maintaining context across conversation boundaries", from_entity="research/ai-knowledge-management-research"
|
||||
- **Verdict:** PASS — category, content, from_entity, tags all present
|
||||
|
||||
#### 5c: Relations carry link info
|
||||
- **Relation result:** relation_type="integrates_with", from_entity="development/features/prompt-builder...", to_entity (present but truncated in some)
|
||||
- **Verdict:** PASS — relation metadata present
|
||||
|
||||
---
|
||||
|
||||
### Test 6: Filters + Vector Search
|
||||
|
||||
#### 6a: entity_types=["entity"] with vector
|
||||
- **Input:** query="OAuth authentication", search_type="vector", entity_types=["entity"]
|
||||
- **Actual:** 5 results, all type="entity" (Core Service Implementation, SPEC-13, Coding Guide, Authentication Service, ADR Auth)
|
||||
- **Verdict:** PASS — filter correctly restricts to entities only
|
||||
|
||||
#### 6b: types=["note"] with vector
|
||||
- **Input:** query="OAuth authentication", search_type="vector", types=["note"]
|
||||
- **Actual:** Same 5 results (all have entity_type="note" in metadata)
|
||||
- **Verdict:** PASS — types filter works with vector search
|
||||
|
||||
#### 6c: after_date with vector
|
||||
- **Input:** query="OAuth authentication", search_type="vector", after_date="2025-06-01"
|
||||
- **Actual:** 3 results — Core Service Implementation, Cloud Web App analysis observation, SPEC-13. Filtered out older OAuth docs.
|
||||
- **Verdict:** PASS — date filter applied correctly
|
||||
|
||||
#### 6d: entity_types=["entity"] with hybrid
|
||||
- **Input:** query="OAuth authentication", search_type="hybrid", entity_types=["entity"]
|
||||
- **Actual:** 5 results, all type="entity" — RLS lessons, Cloud Readiness, AUTH.md OAuth, Core Service, OAuth Rip-and-Replace
|
||||
- **Verdict:** PASS — filter works with hybrid mode too
|
||||
|
||||
#### 6e: types=["entity"] with vector (WRONG filter name)
|
||||
- **Input:** query="OAuth authentication", search_type="vector", types=["entity"]
|
||||
- **Actual:** 0 results
|
||||
- **Note:** `types` filters by entity_type metadata (e.g., "note", "person"), NOT by SearchItemType. Using types=["entity"] looks for entity_type="entity" which few/no notes have. This is a UX confusion point — the param names are ambiguous.
|
||||
- **Verdict:** PASS (correct behavior) but USABILITY ISSUE — easy to confuse types vs entity_types
|
||||
|
||||
---
|
||||
|
||||
### Test 7: Edge Cases
|
||||
|
||||
#### 7a: Single character query
|
||||
- **Input:** query="x", search_type="vector"
|
||||
- **Actual:** 3 results — "Self-contained application bundle" observation, Non-Markdown File Support relation, quick-win-tools entity. Scores ~0.57-0.59
|
||||
- **Note:** Single character still produces an embedding and returns results. Quality is low/random as expected.
|
||||
- **Verdict:** PASS (no crash, returns results)
|
||||
|
||||
#### 7b: Whitespace-only query
|
||||
- **Input:** query=" ", search_type="vector"
|
||||
- **Actual:** 0 results
|
||||
- **Verdict:** PASS (handled gracefully — _check_vector_eligible strips and rejects empty)
|
||||
|
||||
#### 7c: Query with no relevant content
|
||||
- **Input:** query="quantum computing blockchain", search_type="vector"
|
||||
- **Actual:** 3 results — Inter-Agent Communication relation, Self-contained bundle observation, JSON-LD interop observation. Scores ~0.54
|
||||
- **Note:** Still returns results because vector search always finds nearest neighbors. Scores are lower (~0.54) than relevant queries (~0.58-0.60). No relevance threshold applied.
|
||||
- **Verdict:** PASS (expected behavior) but NOTE — no relevance cutoff means irrelevant queries always return something
|
||||
|
||||
---
|
||||
|
||||
### Test 8: Pagination
|
||||
|
||||
#### 8a: Vector search page 2
|
||||
- **Input:** query="keeping AI context between sessions", search_type="vector", page=2, page_size=3
|
||||
- **Actual:** 3 results on page 2, current_page=2. Different results from page 1. Top: "Maintaining context across conversation boundaries" observation (score 0.587)
|
||||
- **Note:** Interestingly, page 2 had a higher-scoring result than some page 1 results. This may indicate pagination doesn't sort globally — it might be paginating within a pre-scored set.
|
||||
- **Verdict:** PASS (pagination works) but POSSIBLE ISSUE — result ordering across pages needs investigation
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
### Passing Tests: 20/21
|
||||
|
||||
### Bugs Found
|
||||
1. **search_type="semantic" silently falls through** (Test 1a) — Invalid search_type values fall to the `else` branch and default to text search without any warning. Should either alias "semantic" to "vector" or raise an error.
|
||||
|
||||
### Usability Issues
|
||||
2. **types vs entity_types confusion** (Test 6e) — `types` filters by entity_type metadata (note, person, etc.) while `entity_types` filters by SearchItemType (entity, observation, relation). The naming is ambiguous and easy to mix up.
|
||||
3. **No relevance threshold** (Test 7c) — Vector search always returns nearest neighbors even for completely irrelevant queries. Consider adding a minimum score threshold or at least documenting expected score ranges.
|
||||
4. **Hybrid ranking for single keywords** (Test 3b) — Hybrid mode on simple keyword queries produced less intuitive rankings than pure FTS or pure vector. The RRF fusion may dilute strong FTS signals.
|
||||
|
||||
### Observations
|
||||
- Vector search successfully finds conceptually related content that FTS misses entirely
|
||||
- Score ranges: relevant queries ~0.56-0.60, irrelevant queries ~0.54 (narrow spread)
|
||||
- All three result types (entity, observation, relation) appear correctly in vector results
|
||||
- Filters (entity_types, types, after_date) all work correctly with vector and hybrid modes
|
||||
- Pagination works but cross-page ordering may need investigation
|
||||
@@ -0,0 +1,271 @@
|
||||
# Semantic Search
|
||||
|
||||
This guide covers Basic Memory's semantic (vector) search feature, which adds meaning-based retrieval alongside the existing full-text search.
|
||||
|
||||
## Overview
|
||||
|
||||
Basic Memory's search supports both full-text search (FTS) and semantic retrieval. Semantic search adds vector embeddings that capture the *meaning* of your content, enabling:
|
||||
|
||||
- **Paraphrase matching**: Find "authentication flow" when searching for "login process"
|
||||
- **Conceptual queries**: Search for "ways to improve performance" and find notes about caching, indexing, and optimization
|
||||
- **Hybrid retrieval**: Combine the precision of keyword search with the recall of semantic similarity
|
||||
|
||||
Semantic search is enabled by default when semantic dependencies are available at runtime. It works on both SQLite (local) and Postgres (cloud) backends.
|
||||
|
||||
## Installation
|
||||
|
||||
Semantic search dependencies (fastembed, sqlite-vec, openai) are included in the default `basic-memory` install.
|
||||
|
||||
```bash
|
||||
pip install basic-memory
|
||||
```
|
||||
|
||||
You can always override with `BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=true|false`.
|
||||
|
||||
### Platform Compatibility
|
||||
|
||||
| Platform | FastEmbed (local) | OpenAI (API) |
|
||||
|---|---|---|
|
||||
| macOS ARM64 (Apple Silicon) | Yes | Yes |
|
||||
| macOS x86_64 (Intel Mac) | No — see workaround below | Yes |
|
||||
| Linux x86_64 | Yes | Yes |
|
||||
| Linux ARM64 | Yes | Yes |
|
||||
| Windows x86_64 | Yes | Yes |
|
||||
|
||||
#### Intel Mac Workaround
|
||||
|
||||
The default install includes FastEmbed, which depends on ONNX Runtime. ONNX Runtime dropped Intel Mac (x86_64) wheels starting in v1.24, so install with a compatible ONNX Runtime pin first:
|
||||
|
||||
```bash
|
||||
pip install basic-memory 'onnxruntime<1.24'
|
||||
```
|
||||
|
||||
After installation, Intel Mac users have two runtime options:
|
||||
|
||||
**Option 1: Use OpenAI embeddings (recommended)**
|
||||
|
||||
```bash
|
||||
export BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=true
|
||||
export BASIC_MEMORY_SEMANTIC_EMBEDDING_PROVIDER=openai
|
||||
export OPENAI_API_KEY=sk-...
|
||||
```
|
||||
|
||||
**Option 2: Use FastEmbed locally**
|
||||
|
||||
Keep the same pinned installation and use FastEmbed (default provider):
|
||||
|
||||
```bash
|
||||
export BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=true
|
||||
export BASIC_MEMORY_SEMANTIC_EMBEDDING_PROVIDER=fastembed
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Install Basic Memory:
|
||||
|
||||
```bash
|
||||
pip install basic-memory
|
||||
```
|
||||
|
||||
2. (Optional) Explicitly enable semantic search:
|
||||
|
||||
```bash
|
||||
export BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=true
|
||||
```
|
||||
|
||||
3. Build vector embeddings for your existing content:
|
||||
|
||||
```bash
|
||||
bm reindex --embeddings
|
||||
```
|
||||
|
||||
4. Search using semantic modes:
|
||||
|
||||
```python
|
||||
# Pure vector similarity
|
||||
search_notes("login process", search_type="vector")
|
||||
|
||||
# Hybrid: combines FTS precision with vector recall (recommended)
|
||||
search_notes("login process", search_type="hybrid")
|
||||
|
||||
# Explicit full-text search
|
||||
search_notes("login process", search_type="text")
|
||||
```
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
All settings are fields on `BasicMemoryConfig` and can be set via environment variables (prefixed with `BASIC_MEMORY_`).
|
||||
|
||||
| Config Field | Env Var | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `semantic_search_enabled` | `BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED` | Auto (`true` when semantic deps are available) | Enable semantic search. Required before vector/hybrid modes work. |
|
||||
| `semantic_embedding_provider` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_PROVIDER` | `"fastembed"` | Embedding provider: `"fastembed"` (local) or `"openai"` (API). |
|
||||
| `semantic_embedding_model` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_MODEL` | `"bge-small-en-v1.5"` | Model identifier. Auto-adjusted per provider if left at default. |
|
||||
| `semantic_embedding_dimensions` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_DIMENSIONS` | Auto-detected | Vector dimensions. 384 for FastEmbed, 1536 for OpenAI. Override only if using a non-default model. |
|
||||
| `semantic_embedding_batch_size` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_BATCH_SIZE` | `64` | Number of texts to embed per batch. |
|
||||
| `semantic_vector_k` | `BASIC_MEMORY_SEMANTIC_VECTOR_K` | `100` | Candidate count for vector nearest-neighbour retrieval. Higher values improve recall at the cost of latency. |
|
||||
|
||||
## Embedding Providers
|
||||
|
||||
### FastEmbed (default)
|
||||
|
||||
FastEmbed runs entirely locally using ONNX models — no API key, no network calls, no cost.
|
||||
|
||||
- **Model**: `BAAI/bge-small-en-v1.5`
|
||||
- **Dimensions**: 384
|
||||
- **Tradeoff**: Smaller model, fast inference, good quality for most use cases
|
||||
|
||||
```bash
|
||||
# Install basic-memory and enable semantic search
|
||||
pip install basic-memory
|
||||
export BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=true
|
||||
```
|
||||
|
||||
### OpenAI
|
||||
|
||||
Uses OpenAI's embeddings API for higher-dimensional vectors. Requires an API key.
|
||||
|
||||
- **Model**: `text-embedding-3-small`
|
||||
- **Dimensions**: 1536
|
||||
- **Tradeoff**: Higher quality embeddings, requires API calls and an OpenAI key
|
||||
|
||||
```bash
|
||||
export BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=true
|
||||
export BASIC_MEMORY_SEMANTIC_EMBEDDING_PROVIDER=openai
|
||||
export OPENAI_API_KEY=sk-...
|
||||
```
|
||||
|
||||
When switching from FastEmbed to OpenAI (or vice versa), you must rebuild embeddings since the vector dimensions differ:
|
||||
|
||||
```bash
|
||||
bm reindex --embeddings
|
||||
```
|
||||
|
||||
## Search Modes
|
||||
|
||||
### `text` (default)
|
||||
|
||||
Full-text keyword search using FTS5 (SQLite) or tsvector (Postgres). Supports boolean operators (`AND`, `OR`, `NOT`), phrase matching, and prefix wildcards.
|
||||
|
||||
```python
|
||||
search_notes("project AND planning", search_type="text")
|
||||
```
|
||||
|
||||
This is the existing default and does not require semantic search to be enabled.
|
||||
|
||||
### `vector`
|
||||
|
||||
Pure semantic similarity search. Embeds your query and finds the nearest content vectors. Good for conceptual or paraphrase queries where exact keywords may not appear in the content.
|
||||
|
||||
```python
|
||||
search_notes("how to speed up the app", search_type="vector")
|
||||
```
|
||||
|
||||
Returns results ranked by cosine similarity. Individual observations and relations surface as first-class results, not collapsed into parent entities.
|
||||
|
||||
### `hybrid`
|
||||
|
||||
Combines FTS and vector results using score-based fusion. This is generally the best mode when you want both keyword precision and semantic recall.
|
||||
|
||||
```python
|
||||
search_notes("authentication security", search_type="hybrid")
|
||||
```
|
||||
|
||||
Score-based fusion uses the formula `max(vec, fts) + bonus * min(vec, fts)` to preserve the dominant signal while rewarding results found by both methods.
|
||||
|
||||
### When to Use Which
|
||||
|
||||
| Mode | Best For |
|
||||
|---|---|
|
||||
| `text` | Exact keyword matching, boolean queries, tag/category searches |
|
||||
| `vector` | Conceptual queries, paraphrase matching, exploratory searches |
|
||||
| `hybrid` | General-purpose search combining precision and recall |
|
||||
|
||||
## The Reindex Command
|
||||
|
||||
The `bm reindex` command rebuilds search indexes without dropping the database.
|
||||
|
||||
```bash
|
||||
# Rebuild everything (FTS + embeddings if semantic is enabled)
|
||||
bm reindex
|
||||
|
||||
# Only rebuild vector embeddings
|
||||
bm reindex --embeddings
|
||||
|
||||
# Only rebuild the full-text search index
|
||||
bm reindex --search
|
||||
|
||||
# Target a specific project
|
||||
bm reindex -p my-project
|
||||
```
|
||||
|
||||
### When You Need to Reindex
|
||||
|
||||
- **Upgrade note**: Migration now performs a one-time automatic embedding backfill on upgrade.
|
||||
- **Manual enable case**: If you explicitly had `semantic_search_enabled=false` and then turn it on
|
||||
- **Provider change**: After switching between `fastembed` and `openai`
|
||||
- **Model change**: After changing `semantic_embedding_model`
|
||||
- **Dimension change**: After changing `semantic_embedding_dimensions`
|
||||
|
||||
The reindex command shows progress with embedded/skipped/error counts:
|
||||
|
||||
```
|
||||
Project: main
|
||||
Building vector embeddings...
|
||||
✓ Embeddings complete: 142 entities embedded, 0 skipped, 0 errors
|
||||
|
||||
Reindex complete!
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
### Chunking
|
||||
|
||||
Each entity in the search index is split into semantic chunks before embedding:
|
||||
|
||||
- **Headers**: Markdown headers (`#`, `##`, etc.) start new chunks
|
||||
- **Bullets**: Each bullet item (`-`, `*`) becomes its own chunk for granular fact retrieval
|
||||
- **Prose sections**: Non-bullet text is merged up to ~900 characters per chunk
|
||||
- **Long sections**: Oversized content is split with ~120 character overlap to preserve context at boundaries
|
||||
|
||||
Each search index item type (entity, observation, relation) is chunked independently, so observations and relations are embeddable as discrete facts.
|
||||
|
||||
### Deduplication
|
||||
|
||||
Each chunk has a `source_hash` (SHA-256 of the chunk text). On re-sync, unchanged chunks skip re-embedding entirely. This makes incremental updates fast — only modified content triggers API calls or model inference.
|
||||
|
||||
### Hybrid Fusion
|
||||
|
||||
Hybrid search uses score-based fusion to merge FTS and vector results:
|
||||
|
||||
1. Run FTS search to get keyword-ranked results; normalize scores to [0, 1]
|
||||
2. Run vector search to get similarity-ranked results (already [0, 1])
|
||||
3. For each result, compute: `fused = max(vec_score, fts_score) + 0.3 * min(vec_score, fts_score)`
|
||||
4. Sort by fused score
|
||||
|
||||
The dominant signal (whichever source scored higher) is preserved, and dual-source agreement adds a bonus. Unlike rank-based fusion, this approach retains score magnitude — a strong vector match stays strong even without an FTS hit.
|
||||
|
||||
### Observation-Level Results
|
||||
|
||||
Vector and hybrid modes return individual observations and relations as first-class search results, not just parent entities. This means a search for "water temperature for brewing" can surface the specific observation about 205°F without returning the entire "Coffee Brewing Methods" entity.
|
||||
|
||||
## Database Backends
|
||||
|
||||
### SQLite (local)
|
||||
|
||||
- **Vector storage**: [sqlite-vec](https://github.com/asg017/sqlite-vec) virtual table
|
||||
- **Table creation**: At runtime when semantic search is first used — no migration needed
|
||||
- **Embedding table**: `search_vector_embeddings` using `vec0(embedding float[N])` where N is the configured dimensions
|
||||
- **Chunk metadata**: `search_vector_chunks` table stores chunk text, keys, and source hashes
|
||||
|
||||
The sqlite-vec extension is loaded per-connection. Vector tables are created lazily on first use.
|
||||
|
||||
### Postgres (cloud)
|
||||
|
||||
- **Vector storage**: [pgvector](https://github.com/pgvector/pgvector) with HNSW indexing
|
||||
- **Local Docker**: use `docker-compose-postgres.yml` (`pgvector/pgvector:pg17`). Plain `postgres:17` lacks the extension; run `CREATE EXTENSION IF NOT EXISTS vector;` on any external instance before first migration.
|
||||
- **Chunk metadata table**: Created via Alembic migration (`search_vector_chunks` with `BIGSERIAL` primary key)
|
||||
- **Embedding table**: `search_vector_embeddings` created at runtime (dimension-dependent, same pattern as SQLite)
|
||||
- **Index**: HNSW index on the embedding column for fast approximate nearest-neighbour queries
|
||||
|
||||
The Alembic migration creates the dimension-independent chunks table. The embeddings table and HNSW index are deferred to runtime because they depend on the configured vector dimensions.
|
||||
@@ -0,0 +1,225 @@
|
||||
# SPEC-LOCAL-PLUS-PUBLISH: Local+ Published Notes and Privacy Tiers
|
||||
|
||||
**Status:** Draft
|
||||
**Date:** 2026-02-14
|
||||
**Owner:** Basic Memory
|
||||
|
||||
## Summary
|
||||
|
||||
Add a paid Local+ feature that lets users publish selected notes to shareable URLs while keeping the
|
||||
main knowledge base local-first. Use this as a product wedge for users who do not want full cloud
|
||||
hosting but do want collaboration and distribution features.
|
||||
|
||||
This spec also captures a practical position on "zero knowledge" for Local+.
|
||||
|
||||
## Context
|
||||
|
||||
Basic Memory already has strong local-first primitives and optional cloud routing/sync. A recurring
|
||||
request is:
|
||||
|
||||
- keep knowledge local by default,
|
||||
- pay for selective value-add,
|
||||
- share specific outputs externally.
|
||||
|
||||
Published Notes fits this model: explicit per-note opt-in, reversible, and easy to understand.
|
||||
|
||||
## Goals
|
||||
|
||||
1. Provide an Obsidian Publish-style sharing experience for selected notes.
|
||||
2. Keep local markdown files as source of truth.
|
||||
3. Make sharing compatible with current cloud/auth/billing primitives.
|
||||
4. Define clear Local+ packaging that does not degrade OSS local workflows.
|
||||
5. Document zero-knowledge constraints so product decisions are explicit.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
1. Full hosted editing for all notes (Cloud Full remains separate).
|
||||
2. Public website builder/CMS features.
|
||||
3. Strict cryptographic zero-knowledge server processing for MCP/search in v1.
|
||||
|
||||
## Local+ Feature Catalog (Sellable)
|
||||
|
||||
Core Local+ candidates:
|
||||
|
||||
1. Published Notes (share URL, revoke, expiry, password).
|
||||
2. Snapshot Time Machine (point-in-time restore for local projects).
|
||||
3. Recovery Drill Reports (automated restore verification).
|
||||
4. Device/API Key Governance (per-device keys, revocation, audit trail).
|
||||
5. BYO Storage Orchestration (managed setup for user-owned object storage).
|
||||
6. Semantic Boost Add-on (higher quality retrieval options while files remain source-of-truth).
|
||||
|
||||
Team-oriented add-ons:
|
||||
|
||||
1. Team-owned shared links and domain branding.
|
||||
2. Role-based publish permissions.
|
||||
3. Shared workspace policies for what can be published.
|
||||
|
||||
## Proposed MVP: Published Notes
|
||||
|
||||
### User Experience
|
||||
|
||||
Per note actions:
|
||||
|
||||
1. Publish.
|
||||
2. Unpublish.
|
||||
3. Copy URL.
|
||||
4. Regenerate URL.
|
||||
5. Set visibility and controls.
|
||||
|
||||
Controls:
|
||||
|
||||
1. Visibility: `unlisted` (default) or `public`.
|
||||
2. Optional password gate.
|
||||
3. Optional expiration datetime.
|
||||
4. Optional "disable indexing" flag for public mode.
|
||||
|
||||
Behavior:
|
||||
|
||||
1. Source note remains local markdown.
|
||||
2. Publish is explicit opt-in per note.
|
||||
3. Unpublish removes public access immediately.
|
||||
4. Republish creates a new URL token unless user chooses to keep current URL.
|
||||
|
||||
### URL Model
|
||||
|
||||
1. Unlisted share URL: high-entropy token path.
|
||||
2. Public URL: slug path (optional, later phase).
|
||||
3. Team plans can support custom domain mapping in later phase.
|
||||
|
||||
### Content Model
|
||||
|
||||
v1 published page includes:
|
||||
|
||||
1. Rendered markdown body.
|
||||
2. Optional metadata (title, updated_at).
|
||||
|
||||
v1 excludes:
|
||||
|
||||
1. Full graph traversal expansion.
|
||||
2. Related note auto-discovery on public pages.
|
||||
|
||||
### Sync Model
|
||||
|
||||
1. Local file remains canonical.
|
||||
2. Publish stores a rendered snapshot plus metadata in cloud.
|
||||
3. Update path:
|
||||
- manual "update published version", or
|
||||
- optional auto-update on note change (plan-gated).
|
||||
|
||||
## Architecture (v1)
|
||||
|
||||
### High-Level Flow
|
||||
|
||||
1. Client selects a note to publish.
|
||||
2. Client sends publish request with note identifier and policy.
|
||||
3. Service resolves note content (local sync artifact or explicit upload payload).
|
||||
4. Service stores published artifact and returns share URL.
|
||||
|
||||
### Data Model
|
||||
|
||||
`published_notes`
|
||||
|
||||
1. `id` (uuid)
|
||||
2. `tenant_id` or `workspace_id`
|
||||
3. `project_id`
|
||||
4. `entity_permalink` (or stable external_id)
|
||||
5. `share_token` (hashed in DB)
|
||||
6. `visibility` (`unlisted`|`public`)
|
||||
7. `password_hash` (nullable)
|
||||
8. `expires_at` (nullable)
|
||||
9. `is_active`
|
||||
10. `published_content` (rendered snapshot or reference)
|
||||
11. `published_at`
|
||||
12. `updated_at`
|
||||
|
||||
### API Shape (Draft)
|
||||
|
||||
1. `POST /api/published-notes`
|
||||
2. `GET /api/published-notes`
|
||||
3. `GET /api/published-notes/{id}`
|
||||
4. `PATCH /api/published-notes/{id}`
|
||||
5. `DELETE /api/published-notes/{id}` (unpublish)
|
||||
6. `POST /api/published-notes/{id}/regenerate-url`
|
||||
7. `GET /p/{token}` (public resolver)
|
||||
|
||||
### CLI Shape (Draft)
|
||||
|
||||
1. `bm cloud publish <identifier>`
|
||||
2. `bm cloud publish list`
|
||||
3. `bm cloud publish update <id>`
|
||||
4. `bm cloud publish unpublish <id>`
|
||||
5. `bm cloud publish rotate-url <id>`
|
||||
|
||||
### Security
|
||||
|
||||
1. Default to unlisted URLs.
|
||||
2. Store only hashed share tokens.
|
||||
3. Passwords hashed server-side.
|
||||
4. Enforce expiration at request time.
|
||||
5. Log publish/unpublish/rotate events for auditability.
|
||||
|
||||
## Packaging and Pricing Direction
|
||||
|
||||
Suggested split:
|
||||
|
||||
1. OSS Local: no publish URLs.
|
||||
2. Local+ Solo: publish URLs + snapshots + recovery.
|
||||
3. Local+ Team: solo features + team governance and branding.
|
||||
4. Cloud Full: hosted app + full cloud workflows.
|
||||
|
||||
Key message:
|
||||
"Keep everything local. Publish only what you choose."
|
||||
|
||||
## Rollout Plan
|
||||
|
||||
1. Phase 1: Unlisted publish URLs + unpublish + regenerate URL.
|
||||
2. Phase 2: Password/expiry controls.
|
||||
3. Phase 3: Auto-update on note change and basic analytics.
|
||||
4. Phase 4: Team branding/domains/policies.
|
||||
|
||||
## Zero-Knowledge Position
|
||||
|
||||
### Strict Zero-Knowledge Definition
|
||||
|
||||
Strict zero-knowledge means the server cannot decrypt note content at all.
|
||||
|
||||
### Why This Conflicts with MCP and Search
|
||||
|
||||
If server cannot decrypt:
|
||||
|
||||
1. MCP tool execution against cloud content cannot read/write semantic content.
|
||||
2. Full-text search cannot index plaintext content.
|
||||
3. Semantic/vector search cannot generate or query embeddings on plaintext.
|
||||
4. Server-side relation resolution and context building become severely limited.
|
||||
|
||||
This matches earlier findings: strict zero-knowledge materially handicaps MCP-driven behavior and
|
||||
search quality.
|
||||
|
||||
### Viable Alternatives (Not Strict Zero-Knowledge)
|
||||
|
||||
1. Encryption at rest/in transit with server-side decrypt in trusted runtime.
|
||||
- Preserves MCP/search quality.
|
||||
- Not zero-knowledge cryptographically.
|
||||
|
||||
2. Client-side retrieval mode.
|
||||
- Keep MCP/search local; cloud is sync/share/backup relay.
|
||||
- Best for privacy-first users.
|
||||
- Requires local agent availability for advanced retrieval.
|
||||
|
||||
3. Limited encrypted indexing.
|
||||
- Blind indexes for exact keywords only.
|
||||
- No high-quality semantic search.
|
||||
- Usually poor UX for natural-language memory recall.
|
||||
|
||||
### Recommendation
|
||||
|
||||
For Local+:
|
||||
|
||||
1. Do not promise strict zero-knowledge for cloud MCP/search paths.
|
||||
2. Offer a privacy-first local mode where advanced retrieval stays local.
|
||||
3. Clearly label tradeoffs:
|
||||
- "Local private mode" (best privacy, best local retrieval).
|
||||
- "Cloud-assisted mode" (best cross-device/MCP consistency, trusted-runtime decrypt).
|
||||
|
||||
This keeps messaging honest and avoids repeating the known incompatibility.
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
# SPEC-SCHEMA-IMPL: Schema System Implementation Plan
|
||||
|
||||
**Status:** Draft
|
||||
**Created:** 2025-02-06
|
||||
**Branch:** `feature/schema-system`
|
||||
**Depends on:** [SPEC-SCHEMA](SPEC-SCHEMA.md)
|
||||
|
||||
## Overview
|
||||
|
||||
Implementation plan for the Basic Memory Schema System. The system is entirely programmatic —
|
||||
no LLM agent runtime or API key required. The LLM already in the user's session (Claude Code,
|
||||
Claude Desktop, etc.) provides the intelligence layer by reading schema notes via existing
|
||||
MCP tools.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Entry Points │
|
||||
│ CLI (bm schema ...) │ MCP (schema_validate) │
|
||||
└──────────┬────────────┴──────────┬──────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Schema Service Layer │
|
||||
│ resolve_schema · validate · infer · diff │
|
||||
└──────────┬────────────────────────┬──────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌──────────────────────┐ ┌────────────────────────┐
|
||||
│ Picoschema Parser │ │ Note/Entity Access │
|
||||
│ YAML → SchemaModel │ │ (existing repository) │
|
||||
└──────────────────────┘ └────────────────────────┘
|
||||
```
|
||||
|
||||
No new database tables. Schemas are notes with `type: schema` — they're already indexed.
|
||||
Validation reads observations and relations from existing data.
|
||||
|
||||
## Components
|
||||
|
||||
### 1. Picoschema Parser
|
||||
|
||||
**Location:** `src/basic_memory/schema/parser.py`
|
||||
|
||||
Parses Picoschema YAML into an internal representation.
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class SchemaField:
|
||||
name: str
|
||||
type: str # string, integer, number, boolean, any, or EntityName
|
||||
required: bool # True unless field name ends with ?
|
||||
is_array: bool # True if (array) notation
|
||||
is_enum: bool # True if (enum) notation
|
||||
enum_values: list[str] # Populated for enums
|
||||
description: str | None # Text after comma
|
||||
is_entity_ref: bool # True if type is capitalized (entity reference)
|
||||
children: list[SchemaField] # For (object) types
|
||||
|
||||
|
||||
@dataclass
|
||||
class SchemaDefinition:
|
||||
entity: str # The entity type this schema describes
|
||||
version: int # Schema version
|
||||
fields: list[SchemaField] # Parsed fields
|
||||
validation_mode: str # "warn" | "strict" | "off"
|
||||
frontmatter_fields: list[SchemaField] # From settings.frontmatter (default: [])
|
||||
|
||||
|
||||
def parse_picoschema(yaml_dict: dict) -> list[SchemaField]:
|
||||
"""Parse a Picoschema YAML dict into a list of SchemaField objects."""
|
||||
|
||||
|
||||
def parse_schema_note(frontmatter: dict) -> SchemaDefinition:
|
||||
"""Parse a full schema note's frontmatter into a SchemaDefinition."""
|
||||
```
|
||||
|
||||
**Input/Output:**
|
||||
```yaml
|
||||
# Input (YAML dict from frontmatter)
|
||||
schema:
|
||||
name: string, full name
|
||||
role?: string, job title
|
||||
works_at?: Organization, employer
|
||||
expertise?(array): string, areas of knowledge
|
||||
```
|
||||
|
||||
```python
|
||||
# Output
|
||||
[
|
||||
SchemaField(name="name", type="string", required=True, description="full name", ...),
|
||||
SchemaField(name="role", type="string", required=False, description="job title", ...),
|
||||
SchemaField(name="works_at", type="Organization", required=False, is_entity_ref=True, ...),
|
||||
SchemaField(name="expertise", type="string", required=False, is_array=True, ...),
|
||||
]
|
||||
```
|
||||
|
||||
### 2. Schema Resolver
|
||||
|
||||
**Location:** `src/basic_memory/schema/resolver.py`
|
||||
|
||||
Finds the applicable schema for a note using the resolution order.
|
||||
|
||||
```python
|
||||
async def resolve_schema(
|
||||
note_frontmatter: dict,
|
||||
search_fn: Callable, # injected search capability
|
||||
) -> SchemaDefinition | None:
|
||||
"""Resolve schema for a note.
|
||||
|
||||
Resolution order:
|
||||
1. Inline schema (frontmatter['schema'] is a dict)
|
||||
2. Explicit reference (frontmatter['schema'] is a string)
|
||||
3. Implicit by type (frontmatter['type'] → schema note with matching entity)
|
||||
4. No schema (returns None)
|
||||
"""
|
||||
```
|
||||
|
||||
### 3. Schema Validator
|
||||
|
||||
**Location:** `src/basic_memory/schema/validator.py`
|
||||
|
||||
Validates a note's observations and relations against a resolved schema.
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class FieldResult:
|
||||
field: SchemaField
|
||||
status: str # "present" | "missing" | "type_mismatch"
|
||||
values: list[str] # Matched observation values or relation targets
|
||||
message: str | None # Human-readable detail
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationResult:
|
||||
note_identifier: str
|
||||
schema_entity: str
|
||||
passed: bool # True if no errors (warnings are OK)
|
||||
field_results: list[FieldResult]
|
||||
unmatched_observations: dict[str, int] # category → count
|
||||
unmatched_relations: list[str] # relation types not in schema
|
||||
warnings: list[str]
|
||||
errors: list[str]
|
||||
|
||||
|
||||
async def validate_note(
|
||||
note: Note,
|
||||
schema: SchemaDefinition,
|
||||
frontmatter: dict | None = None,
|
||||
) -> ValidationResult:
|
||||
"""Validate a note against a schema definition.
|
||||
|
||||
Mapping rules:
|
||||
- field: string → observation [field] exists
|
||||
- field?(array): type → multiple [field] observations
|
||||
- field?: EntityType → relation 'field [[...]]' exists
|
||||
- field?(enum): [v] → observation [field] value ∈ enum values
|
||||
- settings.frontmatter field → frontmatter key presence/value
|
||||
"""
|
||||
```
|
||||
|
||||
### 4. Schema Inference Engine
|
||||
|
||||
**Location:** `src/basic_memory/schema/inference.py`
|
||||
|
||||
Analyzes notes of a given type and suggests a schema based on usage frequency.
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class FieldFrequency:
|
||||
name: str
|
||||
source: str # "observation" | "relation"
|
||||
count: int # notes containing this field
|
||||
total: int # total notes analyzed
|
||||
percentage: float
|
||||
sample_values: list[str] # representative values
|
||||
is_array: bool # True if typically appears multiple times per note
|
||||
target_type: str | None # For relations, the most common target entity type
|
||||
|
||||
|
||||
@dataclass
|
||||
class InferenceResult:
|
||||
entity_type: str
|
||||
notes_analyzed: int
|
||||
field_frequencies: list[FieldFrequency]
|
||||
suggested_schema: dict # Ready-to-use Picoschema YAML dict
|
||||
suggested_required: list[str]
|
||||
suggested_optional: list[str]
|
||||
excluded: list[str] # Below threshold
|
||||
|
||||
|
||||
async def infer_schema(
|
||||
entity_type: str,
|
||||
notes: list[Note],
|
||||
required_threshold: float = 0.95, # 95%+ = required
|
||||
optional_threshold: float = 0.25, # 25%+ = optional
|
||||
) -> InferenceResult:
|
||||
"""Analyze notes and suggest a Picoschema definition."""
|
||||
```
|
||||
|
||||
### 5. Schema Diff
|
||||
|
||||
**Location:** `src/basic_memory/schema/diff.py`
|
||||
|
||||
Compares current note usage against an existing schema definition.
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class SchemaDrift:
|
||||
new_fields: list[FieldFrequency] # Fields not in schema but common in notes
|
||||
dropped_fields: list[FieldFrequency] # Fields in schema but rare in notes
|
||||
cardinality_changes: list[str] # one → many or many → one
|
||||
type_mismatches: list[str] # observation values don't match declared type
|
||||
|
||||
|
||||
async def diff_schema(
|
||||
schema: SchemaDefinition,
|
||||
notes: list[Note],
|
||||
) -> SchemaDrift:
|
||||
"""Compare a schema against actual note usage to detect drift."""
|
||||
```
|
||||
|
||||
## Entry Points
|
||||
|
||||
### CLI Commands
|
||||
|
||||
**Location:** `src/basic_memory/cli/schema.py`
|
||||
|
||||
```python
|
||||
import typer
|
||||
|
||||
schema_app = typer.Typer(name="schema", help="Schema management commands")
|
||||
|
||||
@schema_app.command()
|
||||
async def validate(
|
||||
target: str = typer.Argument(None, help="Note path or entity type"),
|
||||
strict: bool = typer.Option(False, help="Override to strict mode"),
|
||||
):
|
||||
"""Validate notes against their schemas."""
|
||||
|
||||
@schema_app.command()
|
||||
async def infer(
|
||||
entity_type: str = typer.Argument(..., help="Entity type to analyze"),
|
||||
threshold: float = typer.Option(0.25, help="Minimum frequency for optional fields"),
|
||||
save: bool = typer.Option(False, help="Save to schema/ directory"),
|
||||
):
|
||||
"""Infer schema from existing notes of a type."""
|
||||
|
||||
@schema_app.command()
|
||||
async def diff(
|
||||
entity_type: str = typer.Argument(..., help="Entity type to diff"),
|
||||
):
|
||||
"""Show drift between schema and actual usage."""
|
||||
```
|
||||
|
||||
Registered as subcommand: `bm schema validate`, `bm schema infer`, `bm schema diff`.
|
||||
|
||||
### MCP Tools
|
||||
|
||||
**Location:** `src/basic_memory/mcp/tools/schema.py`
|
||||
|
||||
```python
|
||||
@mcp_tool
|
||||
async def schema_validate(
|
||||
entity_type: str | None = None,
|
||||
identifier: str | None = None,
|
||||
project: str | None = None,
|
||||
) -> str:
|
||||
"""Validate notes against their resolved schema."""
|
||||
|
||||
@mcp_tool
|
||||
async def schema_infer(
|
||||
entity_type: str,
|
||||
threshold: float = 0.25,
|
||||
project: str | None = None,
|
||||
) -> str:
|
||||
"""Analyze existing notes and suggest a schema definition."""
|
||||
```
|
||||
|
||||
### API Endpoints
|
||||
|
||||
**Location:** `src/basic_memory/api/schema_router.py`
|
||||
|
||||
```python
|
||||
router = APIRouter(prefix="/schema", tags=["schema"])
|
||||
|
||||
@router.post("/validate")
|
||||
async def validate_schema(...) -> ValidationReport: ...
|
||||
|
||||
@router.post("/infer")
|
||||
async def infer_schema(...) -> InferenceResult: ...
|
||||
|
||||
@router.get("/diff/{entity_type}")
|
||||
async def diff_schema(...) -> SchemaDrift: ...
|
||||
```
|
||||
|
||||
MCP tools call these endpoints via the typed client pattern (consistent with existing
|
||||
architecture).
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Parser + Resolver
|
||||
|
||||
Build the foundation — can parse Picoschema and find schemas for notes.
|
||||
|
||||
**Deliverables:**
|
||||
- `schema/parser.py` — Picoschema YAML → `SchemaDefinition`
|
||||
- `schema/resolver.py` — Resolution order (inline → explicit ref → implicit by type → none)
|
||||
- Unit tests for all Picoschema syntax variations
|
||||
- Unit tests for resolution order
|
||||
|
||||
**No external dependencies.** Pure Python parsing of YAML dicts. Can develop and test
|
||||
in isolation.
|
||||
|
||||
### Phase 2: Validator
|
||||
|
||||
Connect schemas to notes and produce validation results.
|
||||
|
||||
**Deliverables:**
|
||||
- `schema/validator.py` — Validate note observations/relations against schema fields
|
||||
- API endpoint: `POST /schema/validate`
|
||||
- MCP tool: `schema_validate`
|
||||
- CLI command: `bm schema validate`
|
||||
- Integration tests with real notes and schemas
|
||||
|
||||
**Depends on:** Phase 1 (parser + resolver)
|
||||
|
||||
### Phase 3: Inference
|
||||
|
||||
Analyze existing notes to suggest schemas.
|
||||
|
||||
**Deliverables:**
|
||||
- `schema/inference.py` — Frequency analysis across notes of a type
|
||||
- API endpoint: `POST /schema/infer`
|
||||
- MCP tool: `schema_infer`
|
||||
- CLI command: `bm schema infer`
|
||||
- Option to save inferred schema as a note via `write_note`
|
||||
|
||||
**Depends on:** Phase 1 (parser for output format)
|
||||
|
||||
### Phase 4: Diff
|
||||
|
||||
Compare schemas against current usage.
|
||||
|
||||
**Deliverables:**
|
||||
- `schema/diff.py` — Drift detection between schema and actual notes
|
||||
- API endpoint: `GET /schema/diff/{entity_type}`
|
||||
- CLI command: `bm schema diff`
|
||||
|
||||
**Depends on:** Phase 1 (parser), Phase 3 (inference, for frequency analysis)
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- **Unit tests** (`tests/schema/`): Parser edge cases, resolution logic, validation mapping,
|
||||
inference thresholds
|
||||
- **Integration tests** (`test-int/schema/`): End-to-end with real markdown files, schema notes
|
||||
on disk, CLI invocation
|
||||
- Coverage target: 100% (consistent with project standard)
|
||||
|
||||
## What This Does NOT Include
|
||||
|
||||
- No new database tables or migrations
|
||||
- No new markdown syntax (schemas validate existing observations/relations)
|
||||
- No LLM agent runtime or API key management
|
||||
- No hook integration (deferred)
|
||||
- No schema composition/inheritance (deferred)
|
||||
- No OWL/RDF export (deferred)
|
||||
- No built-in templates (deferred)
|
||||
@@ -0,0 +1,492 @@
|
||||
# SPEC-SCHEMA: Basic Memory Schema System
|
||||
|
||||
**Status:** Draft
|
||||
**Created:** 2025-02-06
|
||||
**Branch:** `feature/schema-system`
|
||||
|
||||
## Summary
|
||||
|
||||
A schema system for Basic Memory that uses [Picoschema](https://genkit.dev/docs/dotprompt/)
|
||||
syntax in YAML frontmatter. Schemas validate notes against their existing observation/relation
|
||||
structure — no new data model, no migration, just a declarative lens over what's already there.
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **Schemas are just notes** — A schema is a note with `type: schema`, lives anywhere
|
||||
2. **Use prior art** — Picoschema syntax in YAML frontmatter, no custom notation
|
||||
3. **Validation maps to existing format** — Observations and relations, not a parallel data model
|
||||
4. **Validation is soft** — Warnings by default, not blocking errors
|
||||
5. **Inference over prescription** — Schemas describe reality, emerge from usage
|
||||
6. **No built-in agent** — Programmatic core; the LLM already in the session provides intelligence
|
||||
|
||||
## Picoschema Syntax
|
||||
|
||||
Picoschema is a compact schema notation from Google's Dotprompt that fits naturally in YAML
|
||||
frontmatter.
|
||||
|
||||
### Supported Types
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `string` | Text value |
|
||||
| `integer` | Whole number |
|
||||
| `number` | Decimal number |
|
||||
| `boolean` | True/false |
|
||||
| `any` | Any scalar type |
|
||||
| `EntityName` | Reference to another entity (capitalized = entity reference) |
|
||||
|
||||
### Syntax Rules
|
||||
|
||||
```yaml
|
||||
schema:
|
||||
name: string, full name # required field with description
|
||||
email?: string, contact email # ? = optional
|
||||
role?: string, job title
|
||||
works_at?: Organization, employer # capitalized type = entity reference
|
||||
tags?(array): string, categories # array of type
|
||||
status?(enum): [active, inactive] # enum with allowed values
|
||||
metadata?(object): # nested object
|
||||
updated_at?: string
|
||||
source?: string
|
||||
```
|
||||
|
||||
- `field: type` — required field
|
||||
- `field?: type` — optional field
|
||||
- `field(array): type` — array of values
|
||||
- `field?(enum): [values]` — enumeration
|
||||
- `field?(object):` — nested object with sub-fields
|
||||
- `, description` — description after comma
|
||||
- `EntityName` as type (capitalized) — reference to another entity
|
||||
|
||||
## Schema-to-Note Mapping
|
||||
|
||||
Schemas validate against the existing Basic Memory note format. No new syntax for note
|
||||
authors to learn.
|
||||
|
||||
### Mapping Rules
|
||||
|
||||
| Schema Declaration | Grounded In | Example Match |
|
||||
|--------------------|-------------|---------------|
|
||||
| `field: string` | Observation `[field] value` | `- [name] Paul Graham` |
|
||||
| `field?(array): string` | Multiple `[field]` observations | `- [expertise] Lisp` (×N) |
|
||||
| `field?: EntityType` | Relation `field [[Target]]` | `- works_at [[Y Combinator]]` |
|
||||
| `field?(array): EntityType` | Multiple `field` relations | `- authored [[Book]]` (×N) |
|
||||
| `tags` | Frontmatter `tags` array | `tags: [startups, essays]` |
|
||||
| `field?(enum): [values]` | Observation `[field] value` where value ∈ set | `- [status] active` |
|
||||
| `settings.frontmatter` field | Frontmatter key presence/value | `tags: [python, ai]` |
|
||||
|
||||
### Key Insight
|
||||
|
||||
Schemas don't introduce a new way to store data. They describe the patterns already present
|
||||
in observations and relations. A note doesn't have to change how it's written — the schema
|
||||
just says "a good Person note has a `[name]` observation and a `works_at` relation."
|
||||
|
||||
## Schema Definition
|
||||
|
||||
### As a Dedicated Schema Note
|
||||
|
||||
```yaml
|
||||
# schema/Person.md
|
||||
---
|
||||
title: Person
|
||||
type: schema
|
||||
entity: Person
|
||||
version: 1
|
||||
schema:
|
||||
name: string, full name
|
||||
email?: string, contact email
|
||||
role?: string, job title
|
||||
works_at?: Organization, employer
|
||||
expertise?(array): string, areas of knowledge
|
||||
settings:
|
||||
validation: warn # warn | strict | off
|
||||
frontmatter:
|
||||
tags?(array): string, note categories
|
||||
status?(enum): [draft, review, published]
|
||||
---
|
||||
|
||||
# Person
|
||||
|
||||
A human individual in the knowledge graph.
|
||||
|
||||
Any documentation about this entity type goes here as prose.
|
||||
```
|
||||
|
||||
Schema notes are regular Basic Memory notes. They show up in search, can have their own
|
||||
observations and relations, and can be organized in any folder (though `schema/` is
|
||||
the suggested convention).
|
||||
|
||||
### Inline Schema in a Note
|
||||
|
||||
Notes can carry their own schema directly:
|
||||
|
||||
```yaml
|
||||
# meetings/2024-01-15-standup.md
|
||||
---
|
||||
title: Team Standup 2024-01-15
|
||||
type: meeting
|
||||
schema:
|
||||
attendees(array): string, who was there
|
||||
decisions(array): string, what was decided
|
||||
action_items(array): string, follow-ups
|
||||
blockers?(array): string, anything stuck
|
||||
---
|
||||
|
||||
# Team Standup 2024-01-15
|
||||
|
||||
## Observations
|
||||
- [attendees] Paul
|
||||
- [attendees] Sarah
|
||||
- [decisions] Ship v2 by Friday
|
||||
- [action_items] Paul to review PR #42
|
||||
- [blockers] Waiting on API credentials
|
||||
```
|
||||
|
||||
Good for one-off structured notes or prototyping a schema before extracting it.
|
||||
|
||||
### Explicit Schema Reference
|
||||
|
||||
A note can reference a schema by entity name or permalink:
|
||||
|
||||
```yaml
|
||||
# projects/basic-memory.md
|
||||
---
|
||||
title: Basic Memory
|
||||
schema: SoftwareProject # by entity name
|
||||
---
|
||||
|
||||
# research/llm-memory-patterns.md
|
||||
---
|
||||
title: LLM Memory Patterns
|
||||
schema: schema/research-project # by permalink
|
||||
---
|
||||
```
|
||||
|
||||
Use cases:
|
||||
- Note's `type` differs from the schema it should validate against
|
||||
- Multiple schema variants exist for the same domain
|
||||
- Applying structure to existing notes without changing their type
|
||||
|
||||
## Schema Resolution
|
||||
|
||||
When validating a note, schemas resolve in priority order:
|
||||
|
||||
```
|
||||
1. Inline schema → schema: { ... } (dict in frontmatter)
|
||||
2. Explicit ref → schema: Person (string in frontmatter)
|
||||
3. Implicit by type → type: Person (lookup schema note with entity: Person)
|
||||
4. No schema → no validation (perfectly fine)
|
||||
```
|
||||
|
||||
```python
|
||||
async def resolve_schema(note: Note) -> Schema | None:
|
||||
schema_value = note.frontmatter.get('schema')
|
||||
|
||||
# 1. Inline schema (dict)
|
||||
if isinstance(schema_value, dict):
|
||||
return parse_picoschema(schema_value)
|
||||
|
||||
# 2. Explicit reference (string)
|
||||
if isinstance(schema_value, str):
|
||||
schema_note = await find_schema_note(schema_value)
|
||||
if schema_note:
|
||||
return parse_picoschema(schema_note.frontmatter['schema'])
|
||||
|
||||
# 3. Implicit by type
|
||||
note_type = note.frontmatter.get('type')
|
||||
if note_type:
|
||||
results = await search_notes(f"type:schema entity:{note_type}")
|
||||
if results:
|
||||
return parse_picoschema(results[0].frontmatter['schema'])
|
||||
|
||||
# 4. No schema
|
||||
return None
|
||||
```
|
||||
|
||||
## Validation
|
||||
|
||||
### Modes
|
||||
|
||||
Configured in the schema's `settings.validation`:
|
||||
|
||||
| Mode | Behavior |
|
||||
|------|----------|
|
||||
| `off` | No validation |
|
||||
| `warn` | Warnings in output, doesn't block (default) |
|
||||
| `strict` | Errors that block sync, for CI/CD enforcement |
|
||||
|
||||
### Validation Output
|
||||
|
||||
For a note missing required fields:
|
||||
|
||||
```
|
||||
$ bm schema validate people/ada-lovelace.md
|
||||
|
||||
⚠ Person schema validation:
|
||||
- Missing required field: name (expected [name] observation)
|
||||
- Missing optional field: role
|
||||
- Missing optional field: works_at (no relation found)
|
||||
|
||||
ℹ Unmatched observations: [fact] ×2, [born] ×1
|
||||
ℹ Unmatched relations: collaborated_with
|
||||
```
|
||||
|
||||
"Unmatched" items are informational — observations and relations the schema doesn't cover.
|
||||
They're valid. Schemas are a subset, not a straitjacket.
|
||||
|
||||
### Frontmatter Validation
|
||||
|
||||
Schema notes can declare validation rules for frontmatter keys under `settings.frontmatter`
|
||||
using the same Picoschema syntax as the `schema` block:
|
||||
|
||||
```yaml
|
||||
settings:
|
||||
validation: warn
|
||||
frontmatter:
|
||||
tags?(array): string
|
||||
status?(enum): [draft, review, published]
|
||||
```
|
||||
|
||||
- Frontmatter rules use the same Picoschema key syntax (`?` for optional, `(enum)`, `(array)`)
|
||||
- Only available on schema notes (inline schemas skip frontmatter validation)
|
||||
- Checks key presence (required vs optional) and enum value membership
|
||||
- Unmatched frontmatter keys not in the schema are silently ignored
|
||||
- Missing required frontmatter keys produce a warning (or error in strict mode)
|
||||
|
||||
Example output for a missing required frontmatter key:
|
||||
|
||||
```
|
||||
⚠ Person schema validation:
|
||||
- Missing required frontmatter key: status
|
||||
```
|
||||
|
||||
### Batch Validation
|
||||
|
||||
```
|
||||
$ bm schema validate Person
|
||||
|
||||
Validating 30 notes against Person schema...
|
||||
|
||||
✓ people/paul-graham.md — all fields present
|
||||
✓ people/rich-hickey.md — all fields present
|
||||
⚠ people/ada-lovelace.md — missing: name
|
||||
⚠ people/alan-kay.md — missing: name, role
|
||||
✓ people/linus-torvalds.md — all fields present
|
||||
...
|
||||
|
||||
Summary: 22/30 valid, 8 warnings, 0 errors
|
||||
```
|
||||
|
||||
## Emerging Schemas
|
||||
|
||||
### The Problem with Traditional Schemas
|
||||
|
||||
Most schema systems require: define schema → create conforming content → fight the schema
|
||||
when reality doesn't match. This is backwards. Knowledge grows organically.
|
||||
|
||||
### The Basic Memory Approach
|
||||
|
||||
```
|
||||
Write notes freely → Patterns emerge → Crystallize into schema → Validate future notes
|
||||
```
|
||||
|
||||
### Schema Inference
|
||||
|
||||
Generate schemas from existing notes by analyzing observation and relation frequency:
|
||||
|
||||
```
|
||||
$ bm schema infer Person
|
||||
|
||||
Analyzing 30 notes with type: Person...
|
||||
|
||||
Observations found:
|
||||
[name] 30/30 100% → name: string
|
||||
[role] 27/30 90% → role?: string
|
||||
[fact] 25/30 83% (generic — no single field)
|
||||
[expertise] 18/30 60% → expertise?(array): string
|
||||
[email] 8/30 27% → email?: string
|
||||
[born] 6/30 20% (below threshold)
|
||||
|
||||
Relations found:
|
||||
works_at 22/30 73% → works_at?: Organization
|
||||
authored 11/30 37% → authored?(array): string
|
||||
|
||||
Suggested schema:
|
||||
name: string, full name
|
||||
role?: string, job title
|
||||
expertise?(array): string, areas of knowledge
|
||||
email?: string, contact email
|
||||
works_at?: Organization, employer
|
||||
|
||||
Save to schema/Person.md? [y/n]
|
||||
```
|
||||
|
||||
Frequency thresholds:
|
||||
- 100% present → required field
|
||||
- 25%+ present → optional field
|
||||
- Below 25% → excluded from suggestion (but noted)
|
||||
|
||||
### Schema Drift Detection
|
||||
|
||||
Track how usage patterns shift over time:
|
||||
|
||||
```
|
||||
$ bm schema diff Person
|
||||
|
||||
Schema drift detected:
|
||||
|
||||
+ expertise: now in 81% of notes (was 12%)
|
||||
- department: dropped to 3% of notes
|
||||
~ works_at: cardinality changed (one → many)
|
||||
|
||||
Update schema? [y/n/review]
|
||||
```
|
||||
|
||||
## LLM Integration (AI Guidance)
|
||||
|
||||
No agent runtime or API key required. The LLM already in the session uses schemas as
|
||||
context for note creation.
|
||||
|
||||
### Flow
|
||||
|
||||
1. User asks LLM to "write a note about Rich Hickey"
|
||||
2. LLM determines `type: Person` is appropriate
|
||||
3. LLM calls `search_notes("type:schema entity:Person")` → finds schema
|
||||
4. LLM reads schema fields: required `name`, optional `role`, `works_at`, `expertise`
|
||||
5. LLM calls `write_note` with observations and relations that satisfy the schema
|
||||
|
||||
The schema acts as a creation template. The LLM knows what a "complete" note looks like
|
||||
without any custom agent infrastructure.
|
||||
|
||||
### MCP Tools
|
||||
|
||||
```python
|
||||
@mcp_tool
|
||||
async def schema_validate(
|
||||
entity_type: str | None = None,
|
||||
identifier: str | None = None,
|
||||
project: str | None = None,
|
||||
) -> ValidationReport:
|
||||
"""Validate notes against their resolved schema.
|
||||
|
||||
Validates a specific note (by identifier) or all notes of a given type.
|
||||
Returns warnings/errors based on the schema's validation mode.
|
||||
"""
|
||||
|
||||
@mcp_tool
|
||||
async def schema_infer(
|
||||
entity_type: str,
|
||||
threshold: float = 0.25,
|
||||
project: str | None = None,
|
||||
) -> SuggestedSchema:
|
||||
"""Analyze existing notes and suggest a schema definition.
|
||||
|
||||
Examines observation categories and relation types across all notes
|
||||
of the given type. Returns frequency analysis and suggested Picoschema.
|
||||
"""
|
||||
```
|
||||
|
||||
## CLI Commands
|
||||
|
||||
```bash
|
||||
# Validate a specific note
|
||||
bm schema validate people/ada-lovelace.md
|
||||
|
||||
# Validate all notes of a type
|
||||
bm schema validate Person
|
||||
|
||||
# Validate everything with a schema
|
||||
bm schema validate
|
||||
|
||||
# Infer schema from existing notes
|
||||
bm schema infer Person
|
||||
|
||||
# Show schema drift from current definition
|
||||
bm schema diff Person
|
||||
|
||||
# List all schema notes
|
||||
bm search "type:schema"
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Complete Person Workflow
|
||||
|
||||
**Schema:**
|
||||
```yaml
|
||||
# schema/Person.md
|
||||
---
|
||||
title: Person
|
||||
type: schema
|
||||
entity: Person
|
||||
version: 1
|
||||
schema:
|
||||
name: string, full name
|
||||
role?: string, job title or position
|
||||
works_at?: Organization, employer
|
||||
expertise?(array): string, areas of knowledge
|
||||
email?: string, contact email
|
||||
settings:
|
||||
validation: warn
|
||||
---
|
||||
|
||||
# Person
|
||||
|
||||
A human individual in the knowledge graph.
|
||||
```
|
||||
|
||||
**Valid note:**
|
||||
```yaml
|
||||
# people/paul-graham.md
|
||||
---
|
||||
title: Paul Graham
|
||||
type: Person
|
||||
tags: [startups, essays, lisp]
|
||||
---
|
||||
|
||||
# Paul Graham
|
||||
|
||||
## Observations
|
||||
- [name] Paul Graham
|
||||
- [role] Essayist and investor
|
||||
- [expertise] Startups
|
||||
- [expertise] Lisp
|
||||
- [expertise] Essay writing
|
||||
- [fact] Created Viaweb, the first web app
|
||||
|
||||
## Relations
|
||||
- works_at [[Y Combinator]]
|
||||
- authored [[Hackers and Painters]]
|
||||
```
|
||||
|
||||
**Note with warnings:**
|
||||
```yaml
|
||||
# people/ada-lovelace.md
|
||||
---
|
||||
title: Ada Lovelace
|
||||
type: Person
|
||||
---
|
||||
|
||||
# Ada Lovelace
|
||||
|
||||
## Observations
|
||||
- [fact] Wrote the first computer program
|
||||
- [born] 1815
|
||||
|
||||
## Relations
|
||||
- collaborated_with [[Charles Babbage]]
|
||||
```
|
||||
|
||||
Validation: warns about missing required `[name]` observation. Everything else is optional
|
||||
or unmatched (which is fine).
|
||||
|
||||
## Future Considerations (Deferred)
|
||||
|
||||
These are interesting but out of scope for the initial implementation:
|
||||
|
||||
- **Multiple schema inheritance** — `schema: [Person, Author]`
|
||||
- **Hook integration** — Pre-write validation via the hooks system
|
||||
- **OWL/RDF export** — `bm schema export --format owl`
|
||||
- **SPARQL queries** — Schema-aware graph queries
|
||||
- **Built-in templates** — `bm schema use gtd`, `bm schema use zettelkasten`
|
||||
- **Schema versioning/migration** — Tracking breaking changes across versions
|
||||
@@ -0,0 +1,9 @@
|
||||
[run]
|
||||
source = .
|
||||
omit =
|
||||
tests/*
|
||||
tests/**/*
|
||||
|
||||
[report]
|
||||
fail_under = 85
|
||||
show_missing = True
|
||||
@@ -0,0 +1,48 @@
|
||||
name: integration
|
||||
|
||||
# Heavier than test.yml — installs the real `basic-memory` CLI via uv, runs
|
||||
# every bm_* tool against a live `bm mcp` subprocess. Catches BM-API drift
|
||||
# (e.g., a bm release renaming a tool argument) before our users see it.
|
||||
concurrency:
|
||||
group: hbm-integration-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
integration:
|
||||
name: Integration tests (real bm + mcp)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
# No cache (no uv.lock to key off — see test.yml comment).
|
||||
uses: astral-sh/setup-uv@v3
|
||||
|
||||
- name: Set up Python 3.12
|
||||
# basic-memory itself requires 3.12+; the bm install needs that.
|
||||
# Hermes-runtime compatibility (3.11) is covered by test.yml.
|
||||
run: uv python install 3.12
|
||||
|
||||
- name: Install basic-memory CLI via uv
|
||||
run: |
|
||||
uv tool install basic-memory
|
||||
# uv puts entry-point shims under ~/.local/bin
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Verify bm is on PATH
|
||||
run: |
|
||||
which bm
|
||||
bm --version
|
||||
|
||||
- name: Run integration tests
|
||||
env:
|
||||
BM_INTEGRATION: "1"
|
||||
run: |
|
||||
uv run --with pytest --with mcp --python 3.12 pytest tests/test_integration.py -v
|
||||
@@ -0,0 +1,36 @@
|
||||
name: pr-title
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited, synchronize]
|
||||
|
||||
jobs:
|
||||
semantic-pr-title:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: amannn/action-semantic-pull-request@v5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
# Conventional-commit types we accept in PR titles + commit subjects.
|
||||
types: |
|
||||
feat
|
||||
fix
|
||||
chore
|
||||
docs
|
||||
style
|
||||
refactor
|
||||
perf
|
||||
test
|
||||
build
|
||||
ci
|
||||
# Single-file plugin — no real submodule structure. We don't require
|
||||
# a scope, but if a contributor uses one we accept these:
|
||||
scopes: |
|
||||
core
|
||||
tests
|
||||
ci
|
||||
docs
|
||||
deps
|
||||
requireScope: false
|
||||
requireScopeForBreakingChange: true
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
name: release
|
||||
|
||||
# Manual trigger: Actions → release → Run workflow. Always runs against main
|
||||
# (the workflow validates GITHUB_REF). Steps:
|
||||
# 1. Compute the new version from the `version` input (patch/minor/major
|
||||
# or explicit semver). The *current* version is read from the latest
|
||||
# git tag (`v*.*.*`) — NOT from __init__.py. This is robust to PRs that
|
||||
# pre-bump __init__.py: the bump always runs from the last released
|
||||
# version, not from whatever the working files happen to say.
|
||||
# 2. Update __version__ in __init__.py and version in plugin.yaml to match
|
||||
# the new tag — bringing the files in sync if a PR pre-bumped them.
|
||||
# 3. Commit as `chore(release): vX.Y.Z`, tag, push to main + push the tag.
|
||||
# 4. Publish a GitHub Release. Body is the matching `## [X.Y.Z]` block from
|
||||
# CHANGELOG.md when present; otherwise auto-generated release notes.
|
||||
#
|
||||
# Recommended flow: land a PR that adds a `## [X.Y.Z]` section to CHANGELOG.md
|
||||
# first, then run this workflow with the matching version so the release notes
|
||||
# are the hand-written changelog instead of commit-message-derived notes.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version bump (`patch`, `minor`, `major`) or explicit semver (`0.3.0`)"
|
||||
required: true
|
||||
default: "patch"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Tag and Publish GitHub Release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Validate trigger is main
|
||||
run: |
|
||||
if [ "$GITHUB_REF" != "refs/heads/main" ]; then
|
||||
echo "::error::release must run against main. Got $GITHUB_REF"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Compute new version
|
||||
id: bump
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION_INPUT="${{ github.event.inputs.version }}"
|
||||
|
||||
# Current = latest released tag (sort by version, descending; pick
|
||||
# the first v*.*.* tag). NOT __init__.py — a PR may have pre-bumped
|
||||
# the version files, and we don't want to double-bump on top of
|
||||
# that. The bump is computed from the last *released* version.
|
||||
LAST_TAG=$(git tag --list 'v*.*.*' --sort=-v:refname | head -1 || true)
|
||||
if [ -z "$LAST_TAG" ]; then
|
||||
# First release in the repo. Seed with 0.0.0 so a `patch` bump
|
||||
# yields v0.0.1, `minor` yields v0.1.0, `major` yields v1.0.0.
|
||||
# Explicit semver inputs bypass the seed entirely.
|
||||
CURRENT="0.0.0"
|
||||
echo "No prior v*.*.* tag found; seeding current=0.0.0"
|
||||
else
|
||||
CURRENT="${LAST_TAG#v}"
|
||||
echo "Latest released tag: $LAST_TAG (current=$CURRENT)"
|
||||
fi
|
||||
|
||||
if [[ "$VERSION_INPUT" =~ ^(patch|minor|major)$ ]]; then
|
||||
IFS=. read -r MAJ MIN PAT <<< "$CURRENT"
|
||||
case "$VERSION_INPUT" in
|
||||
major) MAJ=$((MAJ + 1)); MIN=0; PAT=0 ;;
|
||||
minor) MIN=$((MIN + 1)); PAT=0 ;;
|
||||
patch) PAT=$((PAT + 1)) ;;
|
||||
esac
|
||||
NEW="${MAJ}.${MIN}.${PAT}"
|
||||
elif [[ "$VERSION_INPUT" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
NEW="$VERSION_INPUT"
|
||||
else
|
||||
echo "::error::Invalid version input: '$VERSION_INPUT'"
|
||||
echo "::error::Use patch|minor|major or explicit X.Y.Z"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$NEW" = "$CURRENT" ]; then
|
||||
echo "::error::New version equals last released ($NEW). Pick a different version."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Sanity check: refuse if __init__.py is already ahead of the
|
||||
# version we're about to ship. Catches the "PR bumped to 0.5.0 but
|
||||
# workflow was asked for a patch that would land 0.1.8" foot-gun
|
||||
# before it overwrites the files.
|
||||
FILE_VERSION=$(grep -E '^__version__ = ' __init__.py \
|
||||
| sed -E 's/^__version__ = "([^"]+)".*/\1/')
|
||||
if [ -n "$FILE_VERSION" ] && [ "$FILE_VERSION" != "$CURRENT" ] && [ "$FILE_VERSION" != "$NEW" ]; then
|
||||
echo "::error::__init__.py reports version $FILE_VERSION, but the release would ship $NEW (last tag: $CURRENT)."
|
||||
echo "::error::Reconcile by picking a version input that matches __init__.py, or roll __init__.py back to $CURRENT."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "New version: $NEW"
|
||||
echo "current=$CURRENT" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$NEW" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=v$NEW" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Refuse if tag already exists
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ steps.bump.outputs.tag }}"
|
||||
if git rev-parse --verify "refs/tags/$TAG" >/dev/null 2>&1; then
|
||||
echo "::error::Tag $TAG already exists locally. Pick a different version."
|
||||
exit 1
|
||||
fi
|
||||
if git ls-remote --tags origin "$TAG" | grep -q "refs/tags/$TAG$"; then
|
||||
echo "::error::Tag $TAG already exists on origin. Pick a different version."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Update version files
|
||||
run: |
|
||||
set -euo pipefail
|
||||
NEW="${{ steps.bump.outputs.version }}"
|
||||
sed -i -E "s/^__version__ = \"[^\"]+\"/__version__ = \"${NEW}\"/" __init__.py
|
||||
sed -i -E "s/^version: .*/version: ${NEW}/" plugin.yaml
|
||||
|
||||
# Verify both files changed and that the new version is present.
|
||||
grep -q "^__version__ = \"${NEW}\"" __init__.py
|
||||
grep -q "^version: ${NEW}$" plugin.yaml
|
||||
|
||||
echo "--- diff ---"
|
||||
git --no-pager diff -- __init__.py plugin.yaml
|
||||
|
||||
- name: Configure Git identity
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Commit and tag
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ steps.bump.outputs.tag }}"
|
||||
git add __init__.py plugin.yaml
|
||||
if git diff --cached --quiet; then
|
||||
# PR already bumped the files to the target version. Tag the
|
||||
# existing HEAD rather than creating an empty release commit.
|
||||
echo "Version files already at ${TAG}; tagging current HEAD."
|
||||
else
|
||||
git commit -m "chore(release): ${TAG}"
|
||||
fi
|
||||
git tag -a "${TAG}" -m "${TAG}"
|
||||
|
||||
- name: Push commit and tag
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# HEAD push is a no-op when nothing was committed in this run.
|
||||
git push origin HEAD:main
|
||||
git push origin "${{ steps.bump.outputs.tag }}"
|
||||
|
||||
- name: Extract CHANGELOG section for this version
|
||||
id: changelog
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${{ steps.bump.outputs.version }}"
|
||||
# Pull lines between `## [VERSION]` and the next `## [` heading.
|
||||
SECTION=$(awk -v ver="$VERSION" '
|
||||
$0 ~ "^## \\[" ver "\\]" { found=1; next }
|
||||
found && /^## \[/ { exit }
|
||||
found { print }
|
||||
' CHANGELOG.md)
|
||||
if [ -z "$SECTION" ]; then
|
||||
echo "::warning::No CHANGELOG.md section found for v${VERSION} — falling back to auto-generated release notes."
|
||||
echo "has_section=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "has_section=true" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo 'body<<EOF_CHANGELOG'
|
||||
echo "$SECTION"
|
||||
echo 'EOF_CHANGELOG'
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ steps.bump.outputs.tag }}
|
||||
name: ${{ steps.bump.outputs.tag }}
|
||||
body: ${{ steps.changelog.outputs.body }}
|
||||
generate_release_notes: ${{ steps.changelog.outputs.has_section == 'false' }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
name: tests
|
||||
|
||||
# Cancel an in-progress run when a new commit lands on the same branch — the
|
||||
# latest result is the one we care about.
|
||||
concurrency:
|
||||
group: hbm-tests-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
# Branch pushes already cover PRs (the PR branch tip is what's being tested),
|
||||
# so we don't run the matrix twice for the same commit on push + pull_request.
|
||||
push:
|
||||
|
||||
jobs:
|
||||
unit:
|
||||
name: Unit tests (Python ${{ matrix.python-version }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# 3.11 is the runtime Hermes itself ships on today; 3.12-3.14 cover
|
||||
# forward-compat for whenever Hermes upgrades.
|
||||
python-version: ["3.11", "3.12", "3.13", "3.14"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
# No `enable-cache: true` — the action's default cache key globs for
|
||||
# `uv.lock`, which we don't ship (we use `uv run --with` instead of
|
||||
# `uv sync`). Without a lock file the cache step errors out.
|
||||
uses: astral-sh/setup-uv@v3
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
run: uv python install ${{ matrix.python-version }}
|
||||
|
||||
- name: Run unit tests
|
||||
run: uv run --with pytest --python ${{ matrix.python-version }} pytest -q
|
||||
@@ -0,0 +1,6 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
.DS_Store
|
||||
.pytest_cache/
|
||||
*.egg-info/
|
||||
@@ -0,0 +1,133 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.3.2] — 2026-05-23
|
||||
|
||||
### Fixed
|
||||
- **Let Basic Memory v0.21.3 self-route workspace-qualified identifiers and URLs.** Hermes no longer injects its configured default project into `bm_read`, `bm_edit`, `bm_delete`, `bm_move`, or `bm_context` calls when the identifier/URL is already workspace-qualified, such as `personal/main/...`, `memory://personal/main/...`, or an organization workspace slug with a 32-character hash suffix. This preserves Basic Memory Cloud's workspace-aware routing while keeping the existing default-project behavior for short/local identifiers.
|
||||
|
||||
## [0.3.1] — 2026-05-16
|
||||
|
||||
### Changed
|
||||
- **Documented the Hermes Agent v0.14.0-compatible `/bm-*` slash-command monkeypatch.** `MONKEYPATCH.md` now distinguishes the plugin's runtime version from the Hermes Agent-side compatibility patch: plugin `v0.3.0` remains the correct runtime release for Hermes Agent `v0.13.x`, while Hermes Agent `v0.14.0` still needs the updated two-part core patch so gateway startup command discovery loads the active exclusive memory provider and the memory-provider collector delegates `register_command` / `register_skill`.
|
||||
- **Clarified install guidance for users and agents.** The README known-issue section now points Hermes `v0.13.x` and `v0.14.0` users at the compatibility matrix in `MONKEYPATCH.md`, so agents do not mistake a plugin update for the required Hermes Agent core patch.
|
||||
|
||||
### Notes
|
||||
- This is a documentation/compatibility-instructions release only. It does not change the plugin runtime code or Basic Memory data behavior. The plugin remains backward-compatible with Hermes Agent `v0.13.x`; the new documentation explains how to patch Hermes Agent `v0.14.0` until the upstream Hermes fix ships.
|
||||
|
||||
## [0.3.0] — 2026-05-12
|
||||
|
||||
### Added
|
||||
- **Per-call project routing on every `bm_*` tool.** All eight tools now accept optional `project` (name) and `project_id` (UUID from `bm_projects`) parameters. The agent can write or read against a project other than the Hermes-configured one — useful when the user asks to write into a different cloud project (e.g. a personal `main` project) without reconfiguring the plugin. `project_id` takes precedence over `project`; both fall back to the configured default when omitted. Workspace routing is handled transparently by BM via `project_id` — no separate workspace parameter is needed.
|
||||
- **`bm_projects` and `bm_workspaces` agent tools.** Promotes the discovery logic previously available only as `/bm-project` and `/bm-workspace` slash commands to agent-facing tools. `bm_projects` returns JSON with `name` and `external_id` (UUID) per project so the agent can hand the UUID to `bm_write` / `bm_read` / etc. via `project_id` — the unambiguous form across cloud workspaces. `bm_workspaces` lists BM Cloud workspaces (name, type, role, default flag). Together with per-call routing, these unblock the workflow Drew's friction note flagged: agent picks the right project + workspace before writing, instead of silently operating against the active Hermes memory project.
|
||||
- **SKILL.md cross-project workflow** documenting the discovery → route → write → verify recipe end-to-end. Adds a "Permalinks" section covering the three canonical shapes (short, project-qualified, workspace-qualified) and the round-trip property where `bm_write`'s returned permalink self-routes for follow-up reads. A "Cross-project routing" section explains `project` (including workspace-qualified syntax like `"personal/main"`) vs `project_id` and when to use each. Also backfills `bm_recent` documentation (the tool shipped in 0.2.0 but the skill hadn't been updated).
|
||||
- **SKILL.md "Further reading" section** linking to the official docs at [docs.basicmemory.com](https://docs.basicmemory.com), with raw-markdown URLs (`/raw/<path>.md`) the agent can `WebFetch` on demand for deeper material — knowledge format, observations & relations, memory URL wildcards, semantic search, cloud routing, BM's full MCP tool surface, and the `llms.txt` sitemap.
|
||||
|
||||
### Notes
|
||||
- Addresses the routing, discovery, and documentation gaps in the real-world note "Hermes Basic Memory Cloud Task Experience." A proposed `bm_import` tool was evaluated and dropped — `read_file` + `bm_write` already composes the same operation with no new capability, at the cost of one more tool in the surface.
|
||||
- The slash commands `/bm-project` and `/bm-workspace` still exist and behave identically — they continue to call `list_memory_projects` / `list_workspaces` directly via the actor. No behavior change for human use.
|
||||
|
||||
## [0.2.0] — 2026-05-11
|
||||
|
||||
### Added
|
||||
- **Plugin-owned `/bm-*` slash commands** for CLI/gateway sessions. Eight commands give humans direct memory-graph access without going through the agent: `/bm-search`, `/bm-read`, `/bm-context`, `/bm-recent`, `/bm-status`, `/bm-remember`, `/bm-project`, `/bm-workspace`. Closes #2.
|
||||
- **`bm_recent` tool** wrapping BM's `recent_activity`. Surfaces notes updated within a timeframe (`7d` default, accepts natural language like `"2 weeks"` or `"yesterday"`). Agent-facing and reused by `/bm-recent`.
|
||||
- **`remember_folder` config key** (default `"bm-remember"`). Separate from `capture_folder` so manual captures via `/bm-remember` don't intermix with auto-generated session transcripts. Notes are tagged `manual-capture` for further disambiguation.
|
||||
|
||||
### Fixed
|
||||
- **`ctx.register_skill(...)` was silently no-opping since 0.1.5** in real Hermes installs. Hermes loads memory-provider plugins through a stripped-down `_ProviderCollector` context (`plugins/memory/__init__.py`) that captures only `register_memory_provider`; `register_skill` and `register_command` are not delegated. The plugin now writes directly to `PluginManager._plugin_commands` and `_plugin_skills`, matching the entry shape and name normalization `PluginContext.register_command` / `register_skill` produce. This makes both the new slash commands and the bundled SKILL.md work in current Hermes installs. The clean fix lives upstream — a small patch to teach `_ProviderCollector` to delegate — and once that lands, the reach-in becomes a redundant double-write of identical entries. Forward-compat `ctx.register_command` / `ctx.register_skill` calls remain in place for the future code path.
|
||||
|
||||
### Notes
|
||||
- `/bm-remember` derives the title from the first non-empty line of the input, trimmed to 80 chars; falls back to `Note YYYY-MM-DD HHMM UTC`.
|
||||
- `/bm-workspace` short-circuits in local mode with a one-line explanation. Workspaces are a BM Cloud concept.
|
||||
- Mid-session project/workspace switching is intentionally not supported in 0.2.0 — auto-capture would land in unexpected places. Tracked as a follow-up.
|
||||
|
||||
## [0.1.7] — 2026-05-10
|
||||
|
||||
### Changed
|
||||
- **Stronger nudge in `system_prompt_block()`** to steer agents toward the `bm_*` tools instead of shelling out to `bm` CLI. Pre-v0.1.7 the prompt listed the tools neutrally; given Claude/Hermes models' heavy training-data exposure to `bm tool ...` CLI patterns, neutral language wasn't enough — agents reached for the shell by reflex, paying 1-2s of cold-start per call instead of ~0.1s through our persistent MCP connection. New prompt is explicit (**"Use the `bm_*` tools below directly — do not shell out to the `bm` CLI"**) and gives a one-line latency rationale so the model has a reason to follow it.
|
||||
- `SKILL.md` mirrors the directive with a "Use `bm_*`, not the `bm` CLI" section + a tool-vs-CLI table.
|
||||
|
||||
### Added
|
||||
- Regression test `test_system_prompt_block_steers_away_from_cli` locks in the directive language so future prompt edits don't accidentally weaken it.
|
||||
|
||||
## [0.1.6] — 2026-05-10
|
||||
|
||||
### Fixed
|
||||
- **`bm_*` tools were never registered with Hermes's `MemoryManager._tool_to_provider`.** `get_tool_schemas()` was gated on `self._initialized`, but Hermes captures the schema list at *register* time — before `initialize()` runs. The gate caused every session to start with zero tools registered for our provider, so every LLM-issued `bm_search` (and friends) returned `"Unknown tool: bm_search"` from MemoryManager's dispatch. Symptoms were asymmetric: prefetch (recall injection) worked because it's invoked per-turn after init, but tool calls didn't. Schemas are static — they now return unconditionally, with `handle_tool_call()` doing the runtime "is the actor ready?" gate.
|
||||
- Regression test pins this so we don't reintroduce it: `test_get_tool_schemas_unconditional` asserts `get_tool_schemas()` returns all 7 schemas on a fresh, uninitialized provider.
|
||||
|
||||
## [0.1.5] — 2026-05-10
|
||||
|
||||
### Added
|
||||
- Bundled `SKILL.md` is now auto-registered via `ctx.register_skill("basic-memory", ...)` during plugin load. No more manual symlink to `~/.hermes/skills/`. The skill is opt-in (resolvable via `skill:view basic-memory:basic-memory`); always-on agent guidance still flows through `system_prompt_block()`.
|
||||
|
||||
### Changed
|
||||
- README rewritten for community install. Lead command is now `hermes plugins install basicmachines-co/hermes-basic-memory`. Clone-and-symlink instructions moved to the Development section.
|
||||
- Added GitHub Actions CI: unit tests on push and PR.
|
||||
- Added this CHANGELOG.
|
||||
|
||||
## [0.1.4] — 2026-05-10
|
||||
|
||||
### Added
|
||||
- `_uv_binary_path()` and `_install_bm_via_uv()`. When `bm` is missing from the host, the plugin runs `uv tool install basic-memory --quiet` once at first `initialize()`. The bm binary lands at `~/.local/bin/bm` — the same canonical path a manual `uv tool install basic-memory` produces, so subsequent manual installs are no-ops rather than creating a second install.
|
||||
- 8 new unit tests covering `is_available()` with bm/uv combinations, the install subprocess (success / non-zero exit / OSError / no-uv), and `initialize()` install-or-not branching.
|
||||
|
||||
### Changed
|
||||
- `is_available()` now returns `True` when **either** `bm` is on disk **or** `uv` is on disk (we can install the missing CLI ourselves).
|
||||
- README's prerequisites section: dropped manual basic-memory install requirement; added the one-time ~10s cold-start note.
|
||||
|
||||
## [0.1.3] — 2026-05-10
|
||||
|
||||
### Fixed
|
||||
- README's cloud-mode section described the wrong setup (`bm project add ... --cloud --local-path` + `bm cloud bisync`), which gives a local-mode project with file-level cloud sync rather than true cloud routing. Replaced with `bm project set-cloud <name> --workspace <name>`, which flips the project to `ProjectMode.CLOUD` so tool calls route over HTTPS to `<cloud_host>/proxy` directly. No local files involved.
|
||||
- Documented OAuth / API-key auth options, and the `--workspace` requirement when the user belongs to multiple BM Cloud workspaces.
|
||||
|
||||
## [0.1.2] — 2026-05-10
|
||||
|
||||
### Changed
|
||||
- `_default_project()`: `"hermes-memory"` (was `"hermes-{hostname}"`).
|
||||
- `_default_project_path()`: `~/hermes-memory/` (was `~/.basic-memory/hermes/`). The previous path violated the principle that `~/.basic-memory/` is reserved for BM's app state, not project storage.
|
||||
|
||||
### Added
|
||||
- `_bm_known_projects()` reads bm's `~/.basic-memory/config.json`. `BasicMemoryProvider._verify_project_registered()` uses it to refuse initialization when `mode: cloud` is set against a project that isn't registered with bm. Local mode still auto-creates as before.
|
||||
- 13 new unit tests for the introspection + bail-out paths.
|
||||
|
||||
## [0.1.1] — 2026-05-10
|
||||
|
||||
### Added
|
||||
- `tests/test_actor.py` — 15 tests covering `_BmMcpActor` lifecycle, call dispatch, timeout-with-cancellation, idempotent shutdown.
|
||||
- `tests/test_capture.py` — 25 tests for `sync_turn` (first-write + append paths), `on_session_end` summary shape, and gating.
|
||||
- `tests/test_prefetch.py` — 25 tests for `prefetch` / `queue_prefetch` / `_format_prefetch` including forward-compat with unknown response fields.
|
||||
- `tests/test_integration.py` — 12 gated tests exercising every tool against a real `bm` MCP server (`BM_INTEGRATION=1` + `bm` + `mcp`). Each session uses a throwaway BM project that's torn down on completion.
|
||||
|
||||
### Changed
|
||||
- `_BmMcpActor.call` now refuses calls after `shutdown()` (sets `_running=False`) and cancels the underlying coroutine on timeout instead of leaking it.
|
||||
- `_format_prefetch` defensively coerces non-string fields and skips non-dict entries.
|
||||
- Added module-level `__version__`, kept in sync with `plugin.yaml` (verified by a test).
|
||||
|
||||
## [0.1.0] — 2026-05-10
|
||||
|
||||
### Added
|
||||
- Initial release of the Hermes Memory Provider plugin for Basic Memory.
|
||||
- Seven `bm_*` agent tools: `bm_search`, `bm_read`, `bm_write`, `bm_edit`, `bm_context`, `bm_delete`, `bm_move`.
|
||||
- Per-turn capture (`sync_turn`) and end-of-session summary (`on_session_end`).
|
||||
- Local mode (default) with auto-created BM project; cloud mode with project-name-based routing.
|
||||
- Single-file plugin at `__init__.py`, AGPL-3.0-or-later.
|
||||
- 84-test pytest suite.
|
||||
|
||||
[0.3.2]: https://github.com/basicmachines-co/hermes-basic-memory/releases/tag/v0.3.2
|
||||
[0.3.1]: https://github.com/basicmachines-co/hermes-basic-memory/releases/tag/v0.3.1
|
||||
[0.3.0]: https://github.com/basicmachines-co/hermes-basic-memory/releases/tag/v0.3.0
|
||||
[0.2.0]: https://github.com/basicmachines-co/hermes-basic-memory/releases/tag/v0.2.0
|
||||
[0.1.7]: https://github.com/basicmachines-co/hermes-basic-memory/releases/tag/v0.1.7
|
||||
[0.1.6]: https://github.com/basicmachines-co/hermes-basic-memory/releases/tag/v0.1.6
|
||||
[0.1.5]: https://github.com/basicmachines-co/hermes-basic-memory/releases/tag/v0.1.5
|
||||
[0.1.4]: https://github.com/basicmachines-co/hermes-basic-memory/releases/tag/v0.1.4
|
||||
[0.1.3]: https://github.com/basicmachines-co/hermes-basic-memory/releases/tag/v0.1.3
|
||||
[0.1.2]: https://github.com/basicmachines-co/hermes-basic-memory/releases/tag/v0.1.2
|
||||
[0.1.1]: https://github.com/basicmachines-co/hermes-basic-memory/releases/tag/v0.1.1
|
||||
[0.1.0]: https://github.com/basicmachines-co/hermes-basic-memory/releases/tag/v0.1.0
|
||||
@@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
@@ -0,0 +1,269 @@
|
||||
# Hermes slash-command monkeypatch
|
||||
|
||||
This document describes the Hermes Agent-side patch needed when `/bm-*` slash commands do not appear in the gateway even though the Basic Memory provider itself is installed and working.
|
||||
|
||||
This issue is tracked upstream in https://github.com/NousResearch/hermes-agent/issues/23603
|
||||
|
||||
Agents may use this file as a guide to add support for `/bm-*` slash commands in Hermes Agent builds that do not yet include the upstream fix.
|
||||
|
||||
## Current status and compatibility
|
||||
|
||||
This is a **Hermes Agent-side** compatibility patch, not a Basic Memory plugin runtime patch.
|
||||
|
||||
| Hermes Agent version | Basic Memory plugin version | Guidance |
|
||||
|---|---:|---|
|
||||
| `v0.13.x` | `v0.3.0` | Plugin `v0.3.0` remains the right runtime release. If `/bm-*` commands are missing, use the Hermes Agent-side monkeypatch below or wait for the upstream Hermes fix. |
|
||||
| `v0.14.0` / `v2026.5.16` | `v0.3.1` docs, runtime still equivalent to `v0.3.0` | The plugin runtime still works, but Hermes Agent `v0.14.0` still does **not** include the upstream slash-command discovery fix. Use the v0.14.0-compatible Hermes Agent-side patch below. |
|
||||
| Future Hermes release with upstream fix | Latest plugin | Do **not** apply this monkeypatch unless `/bm-*` commands are still absent; the fix should be redundant once Hermes loads active exclusive memory-provider commands during command discovery. |
|
||||
|
||||
Checked against Hermes Agent `v2026.5.16` / `v0.14.0` on 2026-05-16: the upstream Hermes release still does **not** include this fix. After applying the Hermes Agent-side patch below locally, `get_plugin_commands()` returns the expected `/bm-*` commands.
|
||||
|
||||
Important nuance: recent `hermes-basic-memory` versions include a best-effort PluginManager reach-in that registers commands when the provider is loaded. That workaround alone is not enough for gateway startup discovery in affected Hermes builds, because `get_plugin_commands()` does not load the active exclusive memory provider. The Hermes Agent-side patch is still needed until upstream command discovery loads the active memory provider and the memory-provider collector delegates command/skill registration.
|
||||
|
||||
Release/tagging note for agents: `v0.3.1` is a documentation release that clarifies Hermes Agent `v0.14.0` compatibility instructions. It does not require users on Hermes Agent `v0.13.x` to change plugin runtime behavior, and it should not be interpreted as a Basic Memory data/schema migration.
|
||||
|
||||
## Problem
|
||||
|
||||
`hermes-basic-memory` is an **exclusive memory-provider plugin**. Hermes loads exclusive memory providers through `plugins.memory`, not through the normal `PluginManager` discovery path.
|
||||
|
||||
Gateway adapters register native slash commands during startup by calling Hermes's plugin command discovery APIs. In affected Hermes builds, that startup path only sees commands registered by normal plugins. The active memory provider has not been loaded yet, and the memory-provider loader uses a collector that captures only `register_memory_provider(...)`. As a result, commands registered by this plugin with `ctx.register_command(...)` never reach the central plugin command registry before Discord/native slash-command sync.
|
||||
|
||||
Symptoms:
|
||||
|
||||
- `hermes memory status` shows `Provider: basic-memory` and `Status: available`.
|
||||
- Agent tools such as `bm_search`, `bm_read`, and `bm_recent` work.
|
||||
- Native slash commands such as `/bm-search`, `/bm-read`, and `/bm-context` are missing after `hermes gateway restart`.
|
||||
|
||||
## Target behavior
|
||||
|
||||
When Hermes builds its plugin command list, it should also load the configured active memory provider once, allowing that provider to register commands and skills into the same central registries used by ordinary plugins.
|
||||
|
||||
After the patch, `get_plugin_commands()` should include commands such as:
|
||||
|
||||
```text
|
||||
bm-context
|
||||
bm-project
|
||||
bm-read
|
||||
bm-recent
|
||||
bm-remember
|
||||
bm-search
|
||||
bm-status
|
||||
bm-workspace
|
||||
```
|
||||
|
||||
## Files to patch in Hermes Agent
|
||||
|
||||
Patch these files in the Hermes Agent repository, not in this plugin repository:
|
||||
|
||||
```text
|
||||
hermes_cli/plugins.py
|
||||
plugins/memory/__init__.py
|
||||
```
|
||||
|
||||
Recommended tests to add/update in Hermes Agent:
|
||||
|
||||
```text
|
||||
tests/hermes_cli/test_plugin_cli_registration.py
|
||||
tests/hermes_cli/test_plugins.py
|
||||
```
|
||||
|
||||
## Implementation outline
|
||||
|
||||
### 1. Load active memory-provider commands from `get_plugin_commands()`
|
||||
|
||||
In `hermes_cli/plugins.py`, add module-level idempotency/recursion guards near the global plugin manager:
|
||||
|
||||
```python
|
||||
_plugin_manager: Optional[PluginManager] = None
|
||||
_memory_provider_command_loads: set[str] = set()
|
||||
_memory_provider_command_loading = False
|
||||
```
|
||||
|
||||
Update `get_plugin_commands()` so it first ensures normal plugin discovery, then best-effort loads the active memory provider before returning the command registry:
|
||||
|
||||
```python
|
||||
def get_plugin_commands() -> Dict[str, dict]:
|
||||
"""Return the full plugin commands dict (name → {handler, description, plugin}).
|
||||
|
||||
Triggers idempotent plugin discovery so callers can use plugin commands
|
||||
before any explicit discover_plugins() call. Also initializes the active
|
||||
memory provider once so exclusive memory-provider plugins can contribute
|
||||
gateway slash commands during startup discovery.
|
||||
"""
|
||||
manager = _ensure_plugins_discovered()
|
||||
_ensure_active_memory_provider_commands_loaded()
|
||||
return manager._plugin_commands
|
||||
```
|
||||
|
||||
Add the helper:
|
||||
|
||||
```python
|
||||
def _ensure_active_memory_provider_commands_loaded() -> None:
|
||||
"""Best-effort load of the active memory provider's slash commands."""
|
||||
global _memory_provider_command_loading
|
||||
if _memory_provider_command_loading:
|
||||
return
|
||||
try:
|
||||
from plugins import memory as memory_plugins
|
||||
|
||||
active = memory_plugins._get_active_memory_provider()
|
||||
if not active or active in _memory_provider_command_loads:
|
||||
return
|
||||
_memory_provider_command_loading = True
|
||||
try:
|
||||
memory_plugins.load_memory_provider(active)
|
||||
_memory_provider_command_loads.add(active)
|
||||
finally:
|
||||
_memory_provider_command_loading = False
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Failed to load active memory-provider plugin commands: %s",
|
||||
exc,
|
||||
exc_info=_PLUGINS_DEBUG,
|
||||
)
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- This must be best-effort; command discovery should not break Hermes startup if a memory provider is misconfigured.
|
||||
- The recursion guard prevents `load_memory_provider(...)` → provider `register(...)` → `ctx.register_command(...)` → plugin manager access from re-entering endlessly.
|
||||
- The load set prevents duplicate provider command registration work.
|
||||
|
||||
### 2. Make the memory-provider collector delegate commands and skills
|
||||
|
||||
In `plugins/memory/__init__.py`, import `Callable`:
|
||||
|
||||
```python
|
||||
from typing import Callable, List, Optional, Tuple
|
||||
```
|
||||
|
||||
When loading a provider directory, pass the plugin/provider name to the collector:
|
||||
|
||||
```python
|
||||
collector = _ProviderCollector(plugin_name=name)
|
||||
```
|
||||
|
||||
Replace the collector that only captures `register_memory_provider(...)` with a plugin-context shim that also delegates `register_command(...)` and `register_skill(...)` into the central `PluginManager` registries:
|
||||
|
||||
```python
|
||||
class _ProviderCollector:
|
||||
"""Plugin-context shim used while loading memory providers.
|
||||
|
||||
Memory providers are exclusive plugins and are loaded by this module
|
||||
instead of the general PluginManager. They still need access to the same
|
||||
slash-command and skill registries as normal plugins, otherwise active
|
||||
memory-provider commands are invisible during gateway startup discovery.
|
||||
"""
|
||||
|
||||
def __init__(self, plugin_name: str = "memory-provider"):
|
||||
self.provider = None
|
||||
self.plugin_name = plugin_name
|
||||
|
||||
def register_memory_provider(self, provider):
|
||||
self.provider = provider
|
||||
|
||||
def register_command(
|
||||
self,
|
||||
name: str,
|
||||
handler: Callable,
|
||||
description: str = "",
|
||||
args_hint: str = "",
|
||||
) -> None:
|
||||
"""Register a memory-provider slash command with PluginManager."""
|
||||
try:
|
||||
from hermes_cli.plugins import _ensure_plugins_discovered
|
||||
except Exception:
|
||||
return
|
||||
|
||||
clean = name.lower().strip().lstrip("/").replace(" ", "-")
|
||||
if not clean:
|
||||
return
|
||||
|
||||
try:
|
||||
manager = _ensure_plugins_discovered()
|
||||
except Exception:
|
||||
return
|
||||
|
||||
plugin_commands = getattr(manager, "_plugin_commands", None)
|
||||
if plugin_commands is None:
|
||||
return
|
||||
plugin_commands[clean] = {
|
||||
"handler": handler,
|
||||
"description": description or "Plugin command",
|
||||
"plugin": self.plugin_name,
|
||||
"args_hint": (args_hint or "").strip(),
|
||||
}
|
||||
|
||||
def register_skill(
|
||||
self,
|
||||
name: str,
|
||||
path: Path,
|
||||
description: str = "",
|
||||
) -> None:
|
||||
"""Register a memory-provider skill with PluginManager."""
|
||||
try:
|
||||
from agent.skill_utils import _NAMESPACE_RE
|
||||
from hermes_cli.plugins import _ensure_plugins_discovered
|
||||
except Exception:
|
||||
return
|
||||
|
||||
if ":" in name or not name or not _NAMESPACE_RE.match(name):
|
||||
raise ValueError(f"Invalid skill name '{name}'.")
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"SKILL.md not found at {path}")
|
||||
|
||||
try:
|
||||
manager = _ensure_plugins_discovered()
|
||||
except Exception:
|
||||
return
|
||||
|
||||
plugin_skills = getattr(manager, "_plugin_skills", None)
|
||||
if plugin_skills is None:
|
||||
return
|
||||
plugin_skills[f"{self.plugin_name}:{name}"] = {
|
||||
"path": path,
|
||||
"plugin": self.plugin_name,
|
||||
"bare_name": name,
|
||||
"description": description,
|
||||
}
|
||||
```
|
||||
|
||||
Keep existing no-op methods such as `register_tool(...)` and `register_cli_command(...)` as no-ops unless the target Hermes version expects otherwise.
|
||||
|
||||
## Verification
|
||||
|
||||
From the Hermes Agent repository, run focused compile/tests:
|
||||
|
||||
```bash
|
||||
python -m py_compile hermes_cli/plugins.py plugins/memory/__init__.py
|
||||
python -m pytest \
|
||||
tests/hermes_cli/test_plugins.py::TestPluginCommands::test_get_plugin_commands_loads_active_memory_provider_commands \
|
||||
tests/hermes_cli/test_plugin_cli_registration.py::TestProviderCollectorRegistration \
|
||||
-q -o 'addopts='
|
||||
```
|
||||
|
||||
Then verify the active config sees the Basic Memory commands:
|
||||
|
||||
```bash
|
||||
python - <<'PY'
|
||||
import hermes_cli.plugins as p
|
||||
p._plugin_manager = None
|
||||
p._memory_provider_command_loads.clear()
|
||||
cmds = p.get_plugin_commands()
|
||||
print(sorted(k for k in cmds if k.startswith('bm-')))
|
||||
PY
|
||||
```
|
||||
|
||||
Expected output:
|
||||
|
||||
```text
|
||||
['bm-context', 'bm-project', 'bm-read', 'bm-recent', 'bm-remember', 'bm-search', 'bm-status', 'bm-workspace']
|
||||
```
|
||||
|
||||
Finally restart the gateway so native slash commands are synced:
|
||||
|
||||
```bash
|
||||
hermes gateway restart
|
||||
```
|
||||
|
||||
For Discord, global command propagation can lag briefly. If the commands do not show immediately, type `/bm` directly or reload the Discord client.
|
||||
@@ -0,0 +1,235 @@
|
||||
# hermes-basic-memory
|
||||
|
||||
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||
|
||||
Hermes Memory Provider plugin that gives [Hermes Agent](https://github.com/NousResearch/hermes-agent) a persistent knowledge graph backed by [Basic Memory](https://github.com/basicmachines-co/basic-memory).
|
||||
|
||||
The plugin replaces Hermes's "no external memory provider" with a real graph: search-before-answer recall, per-turn capture, end-of-session summaries, and ten `bm_*` tools the agent can call directly. Local mode by default; one CLI flip switches to true cloud routing through Basic Memory Cloud.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
hermes plugins install basicmachines-co/basic-memory --path integrations/hermes
|
||||
```
|
||||
|
||||
Then activate it in `~/.hermes/config.yaml`:
|
||||
|
||||
```yaml
|
||||
memory:
|
||||
provider: basic-memory
|
||||
```
|
||||
|
||||
If you run the gateway, restart it (`hermes gateway restart`). Done.
|
||||
|
||||
If your installed Hermes build does not support `--path`, use the final deprecated `basicmachines-co/hermes-basic-memory` pointer release until Hermes subpath installs are available. Ongoing development now lives in [`basic-memory/integrations/hermes`](https://github.com/basicmachines-co/basic-memory/tree/main/integrations/hermes).
|
||||
|
||||
The plugin self-installs the `basic-memory` CLI on first init via `uv tool install basic-memory` (one-time ~10s pause if it isn't already present). The bm binary lands at `~/.local/bin/bm` — the same location a manual `uv tool install basic-memory` would produce, so a later manual install or upgrade is a no-op rather than a second install.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Hermes Agent](https://github.com/NousResearch/hermes-agent)
|
||||
- [`uv`](https://docs.astral.sh/uv/) on PATH (used for the bootstrap install)
|
||||
- The `mcp` Python package in the Hermes venv. If `hermes plugins install` doesn't auto-install it (it follows `pip_dependencies` in `plugin.yaml`), run:
|
||||
```bash
|
||||
uv pip install --python ~/.hermes/hermes-agent/venv/bin/python mcp
|
||||
```
|
||||
|
||||
### Verify
|
||||
|
||||
```bash
|
||||
hermes memory status
|
||||
```
|
||||
|
||||
Expected:
|
||||
```
|
||||
Provider: basic-memory
|
||||
Plugin: installed ✓
|
||||
Status: available ✓
|
||||
```
|
||||
|
||||
## What the agent gets
|
||||
|
||||
Ten tools (curated subset of Basic Memory's MCP surface):
|
||||
|
||||
| Tool | Use |
|
||||
|---|---|
|
||||
| `bm_search` | Semantic + full-text search; **call this before answering** |
|
||||
| `bm_read` | Fetch a note by title, permalink, or `memory://` URL |
|
||||
| `bm_write` | Create a new note (capture decisions, meeting notes, insights) |
|
||||
| `bm_edit` | Append, prepend, find/replace, replace-section |
|
||||
| `bm_context` | Navigate via `memory://` URLs to find related notes |
|
||||
| `bm_delete` | Delete a note |
|
||||
| `bm_move` | Move a note to a different folder |
|
||||
| `bm_recent` | List notes updated recently (default `7d`; accepts natural-language timeframes) |
|
||||
| `bm_projects` | List available projects with their UUIDs (for cross-project routing) |
|
||||
| `bm_workspaces` | List Basic Memory Cloud workspaces |
|
||||
|
||||
Every read/write tool also accepts optional `project` / `project_id` for per-call routing — write or read against a project other than the configured one without reconfiguring the plugin.
|
||||
|
||||
Plus automatic capture:
|
||||
- **Per turn**: every user/assistant exchange appends to a running session-transcript note
|
||||
- **End of session**: a separate summary note is written, linked back to the transcript via a `summary_of` relation
|
||||
|
||||
A bundled skill (`skill:view basic-memory:basic-memory`) gives the agent a longer reference doc on top of the always-on `system_prompt_block`.
|
||||
|
||||
## Slash commands
|
||||
|
||||
For direct, in-session use without going through the agent (requires Hermes ≥ v0.11.0):
|
||||
|
||||
| Command | Use |
|
||||
|---|---|
|
||||
| `/bm-search <query>` | Search the knowledge graph; returns compact title/permalink/preview rows. |
|
||||
| `/bm-read <identifier>` | Read a note by title, permalink, or `memory://` URL. |
|
||||
| `/bm-context <identifier>` | Build context for a note (target + related). |
|
||||
| `/bm-recent [timeframe]` | Recently updated notes. Default `7d`; accepts `"2 weeks"`, `"yesterday"`, etc. |
|
||||
| `/bm-status` | Plugin/provider state: mode, project, capture flags, bm CLI path. |
|
||||
| `/bm-remember <text>` | Capture a quick note. Title = first line (≤80 chars), folder = `remember_folder` (default `bm-remember`), tagged `manual-capture`. |
|
||||
| `/bm-project` | List all known projects; the active one is marked. |
|
||||
| `/bm-workspace` | List BM Cloud workspaces. Cloud mode only — prints an explanatory line in local mode. |
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
/bm-search Q3 OKRs
|
||||
/bm-read decisions/auth-rewrite
|
||||
/bm-recent yesterday
|
||||
/bm-remember Reminder: switch the staging job to the new image after the rebase lands.
|
||||
```
|
||||
|
||||
`/bm-project` and `/bm-workspace` are read-only in 0.2.0 — mid-session switching is intentionally not supported because auto-capture would otherwise land in the wrong place. Tracked as a follow-up.
|
||||
|
||||
### Known issue: `/bm-*` commands may not appear in some Hermes gateway builds
|
||||
|
||||
Plugin v0.2.0 and later register the commands above, but some Hermes Agent gateway builds do not discover slash commands contributed by an **exclusive memory-provider plugin** during startup. The symptoms are:
|
||||
|
||||
- the memory tools work for the agent (`bm_search`, `bm_read`, etc.);
|
||||
- `hermes memory status` shows `Provider: basic-memory` and `Status: available`; but
|
||||
- Discord/native slash command pickers do not show `/bm-search`, `/bm-read`, `/bm-context`, and the other `/bm-*` commands after `hermes gateway restart`.
|
||||
|
||||
This is a Hermes Agent plugin-discovery issue, not a Basic Memory runtime issue. It is tracked upstream in [NousResearch/hermes-agent#23603](https://github.com/NousResearch/hermes-agent/issues/23603). Updating the Basic Memory plugin alone cannot fix affected gateway startup discovery; Hermes Agent itself must include or receive the compatibility patch. Until the upstream Hermes fix is available in your installed Hermes version, use one of these workarounds:
|
||||
|
||||
1. apply the Hermes Agent-side patch described in [MONKEYPATCH.md](MONKEYPATCH.md), which includes compatibility notes for Hermes Agent v0.13.x and v0.14.0; or
|
||||
2. use the agent tools directly (`bm_search`, `bm_read`, `bm_recent`, etc.) instead of native slash commands.
|
||||
|
||||
After applying an updated or patched Hermes build, restart the gateway so Discord/native slash commands are re-synced:
|
||||
|
||||
```bash
|
||||
hermes gateway restart
|
||||
```
|
||||
|
||||
If Discord still does not show the commands immediately, type `/bm` directly or reload the Discord client; global command propagation can lag briefly.
|
||||
|
||||
## Configuration
|
||||
|
||||
Defaults are reasonable for local use:
|
||||
|
||||
| Key | Default | Notes |
|
||||
|---|---|---|
|
||||
| `mode` | `local` | `local` (in-process) or `cloud` (route through BM Cloud API) |
|
||||
| `project` | `hermes-memory` | BM project name |
|
||||
| `project_path` | `~/hermes-memory/` | Local mode only — where session notes land |
|
||||
| `capture_folder` | `hermes-sessions` | Folder within the project for session notes |
|
||||
| `capture_per_turn` | `true` | Append every turn to a session transcript |
|
||||
| `capture_session_end` | `true` | Write a summary note when the session ends |
|
||||
| `remember_folder` | `bm-remember` | Folder where `/bm-remember` captures land (kept separate from session transcripts) |
|
||||
|
||||
To override, write `~/.hermes/basic-memory.json` or run `hermes memory setup basic-memory`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "local",
|
||||
"project": "hermes-memory",
|
||||
"project_path": "~/hermes-memory/",
|
||||
"capture_per_turn": true,
|
||||
"capture_session_end": true,
|
||||
"capture_folder": "hermes-sessions",
|
||||
"remember_folder": "bm-remember"
|
||||
}
|
||||
```
|
||||
|
||||
In local mode the plugin auto-creates the BM project on first init via `bm project add`. In cloud mode it doesn't — you create the cloud-routed project yourself (see below) and the plugin verifies it's registered before initializing.
|
||||
|
||||
### Cloud mode
|
||||
|
||||
When `mode: cloud`, tool calls route directly through the BM cloud API — no local file mirror, no bisync. You set this up once with the BM CLI:
|
||||
|
||||
```bash
|
||||
# Authenticate (OAuth) or save an API key
|
||||
bm cloud login # OAuth — interactive
|
||||
# OR for headless/automation:
|
||||
bm cloud create-key "hermes"
|
||||
bm cloud set-key bmc_...
|
||||
|
||||
# Create the project, then flip it to cloud routing.
|
||||
# --workspace is required if you belong to more than one workspace
|
||||
# (otherwise BM auto-resolves the only one available).
|
||||
bm project add hermes-memory-cloud
|
||||
bm project set-cloud hermes-memory-cloud --workspace Personal
|
||||
|
||||
# Point the plugin at it
|
||||
cat > ~/.hermes/basic-memory.json <<EOF
|
||||
{
|
||||
"mode": "cloud",
|
||||
"project": "hermes-memory-cloud",
|
||||
"capture_per_turn": true,
|
||||
"capture_session_end": true,
|
||||
"capture_folder": "hermes-sessions"
|
||||
}
|
||||
EOF
|
||||
|
||||
hermes gateway restart
|
||||
```
|
||||
|
||||
Tool calls now route from `bm mcp` → `<cloud_host>/proxy` over HTTPS using your OAuth token (or API key). Notes never touch local disk.
|
||||
|
||||
**Don't confuse cloud mode with `bm cloud bisync`.** Bisync is rclone-style two-way file sync between a *local* project and cloud storage, intended for keeping local working copies. For agent-driven capture you want true cloud routing (`set-cloud`), not bisync.
|
||||
|
||||
## Updating / removing
|
||||
|
||||
```bash
|
||||
hermes plugins update basic-memory
|
||||
hermes plugins remove basic-memory # then revert memory.provider in config.yaml
|
||||
```
|
||||
|
||||
## Foot-guns
|
||||
|
||||
- **`<memory-context>` tags in notes**: Hermes's streaming output scrubber strips literal `<memory-context>...</memory-context>` blocks from assistant text. If a note contains those tags and the assistant echoes the body verbatim, the echoed copy gets eaten mid-stream. Tool results inbound are unaffected. Avoid those tags in BM notes; if you must include them, fence in a code block.
|
||||
- **Single external provider**: Hermes accepts only one external memory provider at a time. Activating basic-memory displaces any other.
|
||||
- **CLI cold start**: `hermes -z ...` invocations spawn `bm mcp` per run (~2-5s). Long-running gateway sessions amortize this.
|
||||
- **Multiple cloud workspaces**: if your BM Cloud account belongs to more than one workspace, `bm project set-cloud` must be invoked with `--workspace <name>`. Otherwise tool calls fail with "Multiple workspaces are available".
|
||||
|
||||
## Development
|
||||
|
||||
The plugin is a single-file Python module at `__init__.py`. The Hermes plugin loader expects `register(ctx)` and grep-detects either `register_memory_provider` or `MemoryProvider` in the file.
|
||||
|
||||
For local development (point Hermes at your working tree instead of going through `hermes plugins install`):
|
||||
|
||||
```bash
|
||||
git clone https://github.com/basicmachines-co/basic-memory ~/code/basic-memory
|
||||
mkdir -p ~/.hermes/plugins
|
||||
ln -snf ~/code/basic-memory/integrations/hermes ~/.hermes/plugins/basic-memory
|
||||
```
|
||||
|
||||
### Running tests
|
||||
|
||||
```bash
|
||||
# From the monorepo root
|
||||
just package-check-hermes
|
||||
|
||||
# Or from integrations/hermes
|
||||
just check
|
||||
|
||||
# Unit tests (fast, hermetic — no Hermes or bm required)
|
||||
uv run --with pytest pytest
|
||||
|
||||
# Integration tests (gated — exercise every tool against a real bm MCP server)
|
||||
BM_INTEGRATION=1 uv run --with pytest --with mcp pytest tests/test_integration.py
|
||||
```
|
||||
|
||||
The unit suite stubs out Hermes-internal imports (`agent.memory_provider`, `tools.registry`) so it runs without a Hermes install. `mcp` is optional at unit-test time — its absence just makes `is_available()` return False, which the tests verify.
|
||||
|
||||
Integration tests require `BM_INTEGRATION=1`, `bm` CLI on PATH, and `mcp` Python package importable. Each session creates a unique throwaway BM project (under `tempfile.mkdtemp`) and removes it on teardown, so they never touch your real BM projects.
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0-or-later, matching [basic-memory](https://github.com/basicmachines-co/basic-memory). See [LICENSE](LICENSE).
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
# Basic Memory Hermes plugin checks
|
||||
|
||||
repo_root := "../.."
|
||||
|
||||
# Validate plugin.yaml, module entrypoint, bundled skill, and test layout.
|
||||
manifest-check:
|
||||
python3 {{repo_root}}/scripts/validate_hermes_plugin.py .
|
||||
|
||||
# Unit tests are hermetic and do not require a Hermes install.
|
||||
test:
|
||||
uv run --no-project --with pytest --with pytest-cov --python 3.12 pytest -q --cov=. --cov-report=term-missing
|
||||
|
||||
# Gated integration test against a real bm MCP server.
|
||||
test-int:
|
||||
BM_INTEGRATION=1 uv run --no-project --with pytest --with mcp --python 3.12 pytest tests/test_integration.py -q
|
||||
|
||||
# Full local check.
|
||||
check: manifest-check test
|
||||
|
||||
# Show available recipes
|
||||
default:
|
||||
@just --list
|
||||
@@ -0,0 +1,11 @@
|
||||
name: basic-memory
|
||||
version: 0.3.2
|
||||
description: "Basic Memory — persistent knowledge graph backed by the basic-memory MCP server"
|
||||
pip_dependencies:
|
||||
- mcp
|
||||
hooks:
|
||||
- prefetch
|
||||
- queue_prefetch
|
||||
- sync_turn
|
||||
- on_session_end
|
||||
- shutdown
|
||||
@@ -0,0 +1,8 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
addopts = -ra
|
||||
pythonpath =
|
||||
.
|
||||
tests/stubs
|
||||
filterwarnings =
|
||||
ignore::DeprecationWarning
|
||||
@@ -0,0 +1 @@
|
||||
pytest>=7.0
|
||||
@@ -0,0 +1,242 @@
|
||||
---
|
||||
name: basic-memory
|
||||
description: Use the Basic Memory knowledge graph for persistent memory across sessions. Search before answering; capture decisions, meetings, and insights as notes.
|
||||
category: memory
|
||||
---
|
||||
|
||||
# Basic Memory Knowledge Graph
|
||||
|
||||
You have access to a persistent knowledge graph backed by Basic Memory. The graph survives across sessions and is shared with other tools (Claude Desktop, Obsidian, the `bm` CLI). Use the `bm_*` tools below to recall and capture information.
|
||||
|
||||
## Use `bm_*`, not the `bm` CLI
|
||||
|
||||
**Always invoke the `bm_*` tools directly. Do not shell out to the `bm` CLI for note operations.**
|
||||
|
||||
The `bm_*` tools route through a persistent MCP connection — roughly 0.1 seconds per call. Running `bm` from the shell spawns a fresh Python process per call (1-2 seconds of cold-start every time) and bypasses Hermes's automatic per-turn capture, so the session-transcript and summary notes won't reflect what you did.
|
||||
|
||||
The CLI is fine when you genuinely need a feature these wrappers don't expose (rare). Otherwise, prefer:
|
||||
|
||||
| Use case | Tool (not CLI) |
|
||||
|---|---|
|
||||
| Search the graph | `bm_search` |
|
||||
| Read a note | `bm_read` |
|
||||
| Create / update a note | `bm_write` / `bm_edit` |
|
||||
| Navigate relations | `bm_context` |
|
||||
| Move / delete | `bm_move` / `bm_delete` |
|
||||
| What's been touched lately | `bm_recent` |
|
||||
| List available projects | `bm_projects` |
|
||||
| List cloud workspaces | `bm_workspaces` |
|
||||
|
||||
## Tool reference
|
||||
|
||||
### `bm_search` — search the graph
|
||||
Use **before** answering questions about prior decisions, projects, meetings, or anything that might already be documented.
|
||||
|
||||
```
|
||||
bm_search({ query: "auth strategy decision", limit: 5 })
|
||||
```
|
||||
|
||||
### `bm_read` — fetch a note's full content
|
||||
After search shows a relevant note, read it for context.
|
||||
|
||||
```
|
||||
bm_read({ identifier: "decisions/auth-strategy" })
|
||||
bm_read({ identifier: "memory://projects/api-redesign" })
|
||||
```
|
||||
|
||||
### `bm_context` — navigate via memory:// URLs
|
||||
Returns the target note plus related notes via traversed relations.
|
||||
|
||||
```
|
||||
bm_context({ url: "memory://projects/api-redesign", depth: 1 })
|
||||
```
|
||||
|
||||
### `bm_write` — capture new knowledge
|
||||
When the user shares a decision, meeting outcome, or insight worth keeping, capture it. Use clear titles and a folder.
|
||||
|
||||
```
|
||||
bm_write({
|
||||
title: "API Authentication Decision",
|
||||
folder: "decisions",
|
||||
content: "# API Authentication\n\n## Context\n...\n\n## Decision\n..."
|
||||
})
|
||||
```
|
||||
|
||||
Recommended folders: `projects/`, `decisions/`, `meetings/`, `concepts/`, `weekly/`.
|
||||
|
||||
### `bm_edit` — incremental updates
|
||||
Operations: `append`, `prepend`, `find_replace` (requires `find_text`), `replace_section` (requires `section`).
|
||||
|
||||
```
|
||||
bm_edit({
|
||||
identifier: "projects/api-redesign",
|
||||
operation: "append",
|
||||
content: "\n## Update 2026-05-09\nDeployed to staging."
|
||||
})
|
||||
```
|
||||
|
||||
### `bm_delete` / `bm_move` — maintenance
|
||||
Use sparingly. `bm_move` takes `new_folder`.
|
||||
|
||||
### `bm_recent` — what's been touched lately
|
||||
Returns notes updated within a window. Use when there's no specific query yet — e.g. "what was I working on yesterday?"
|
||||
|
||||
```
|
||||
bm_recent({ timeframe: "7d" })
|
||||
bm_recent({ timeframe: "yesterday", limit: 20 })
|
||||
bm_recent({ timeframe: "2 weeks", type: "entity" })
|
||||
```
|
||||
|
||||
`timeframe` accepts natural language (`"yesterday"`, `"2 weeks"`, `"last month"`) or compact forms (`"7d"`, `"24h"`). Default is `7d`.
|
||||
|
||||
### `bm_projects` — list available projects
|
||||
Returns name, workspace slug, and `external_id` (UUID) per project across local and cloud. Call this when the user names a project that isn't the active one. Route follow-up tool calls either by workspace-qualified name (`project: "personal/main"`) or by UUID (`project_id: "bf2a4c1e-d77f-..."`) — see Cross-project routing below.
|
||||
|
||||
```
|
||||
bm_projects()
|
||||
```
|
||||
|
||||
### `bm_workspaces` — list BM Cloud workspaces
|
||||
Workspaces are a BM Cloud concept. Returns name, type, role, and default flag. Pair with `bm_projects` when the same project name might exist in more than one workspace and you need to disambiguate.
|
||||
|
||||
```
|
||||
bm_workspaces()
|
||||
```
|
||||
|
||||
## Permalinks
|
||||
|
||||
A permalink is the canonical, URL-friendly identifier for a note. Three shapes exist; the read/write tools accept all of them:
|
||||
|
||||
| Shape | Example | When |
|
||||
|---|---|---|
|
||||
| **Short** | `decisions/auth-strategy` | Bare `folder/note-slug`. Tools need a `project` (or `project_id`) arg to route — the permalink alone isn't enough. |
|
||||
| **Project-qualified** | `main/decisions/auth-strategy` | `project-name/folder/note-slug`. Carries enough context to route without a separate `project` arg. |
|
||||
| **Workspace-qualified** | `personal/main/decisions/auth-strategy` | `workspace-slug/project-name/folder/note-slug`. Fully routes, including across cloud workspaces with same-named projects. |
|
||||
|
||||
**Important: the permalink returned by `bm_write` already encodes the routing it needs for follow-up reads.** If you wrote with `project="personal/main"`, you get back `personal/main/folder/note-slug` and can call `bm_read({ identifier: <that permalink> })` with no `project` arg. The permalink self-routes.
|
||||
|
||||
`memory://` URLs follow the same shapes: `memory://personal/main/decisions/auth-strategy` is valid. The `memory://` prefix is optional for `bm_read` (any of the three permalink shapes works directly); `bm_context` expects the prefix.
|
||||
|
||||
## Cross-project routing
|
||||
|
||||
Every read/write tool (`bm_search`, `bm_read`, `bm_write`, `bm_edit`, `bm_context`, `bm_delete`, `bm_move`, `bm_recent`) accepts optional `project` and `project_id`:
|
||||
|
||||
- `project` — project name, optionally workspace-qualified. Plain (`"main"`) when the name is globally unique; qualified (`"personal/main"`, `"team-paul/research"`) when you need to pick a specific cloud workspace by slug.
|
||||
- `project_id` — UUID from `bm_projects` (`external_id` field). The most stable identifier — survives project renames and works across workspaces without qualification. Wins over `project` if both are passed.
|
||||
|
||||
Omit both and the call uses the Hermes-configured active project.
|
||||
|
||||
```
|
||||
# Plain project name (unique)
|
||||
bm_write({ title: "...", folder: "...", content: "...", project: "main" })
|
||||
|
||||
# Workspace-qualified name (disambiguates same-named projects across workspaces)
|
||||
bm_write({ title: "...", folder: "...", content: "...", project: "personal/main" })
|
||||
|
||||
# UUID (most stable, survives renames)
|
||||
bm_write({ title: "...", folder: "...", content: "...", project_id: "bf2a4c1e-d77f-..." })
|
||||
```
|
||||
|
||||
`bm_projects` and `bm_workspaces` themselves do **not** take routing — they list across everything.
|
||||
|
||||
## Recipe: writing an existing file into a specific project
|
||||
|
||||
When the user asks something like *"save this markdown file to my personal `main` project, return the permalink"*:
|
||||
|
||||
1. **Discover the project.** Call `bm_projects()` and find the entry matching the user's described project + workspace. You can route by either the workspace-qualified name (`personal/main`) or the UUID (`external_id`).
|
||||
|
||||
```
|
||||
bm_projects()
|
||||
# → [{name: "main", external_id: "bf2a4c1e-d77f-4b7a-9c3e-5d8a1f0e2b6d", workspace: "Personal", ...}, ...]
|
||||
```
|
||||
|
||||
If a project name appears in multiple workspaces, use `bm_workspaces()` to confirm which slug you want.
|
||||
|
||||
2. **Read the file from disk.** Use Hermes's filesystem tool (not a `bm_*` tool — local files aren't in the graph yet).
|
||||
|
||||
3. **Write the note with explicit routing.** Either form works; the workspace-qualified name reads cleaner in logs, the UUID is more durable.
|
||||
|
||||
```
|
||||
bm_write({
|
||||
title: "StartWithDrew Level 9 Task Queue",
|
||||
folder: "startwithdrew",
|
||||
content: <file body>,
|
||||
project: "personal/main"
|
||||
})
|
||||
# → returns "personal/main/startwithdrew/start-with-drew-level-9-task-queue"
|
||||
# (the returned permalink is workspace-qualified — carries its own routing)
|
||||
```
|
||||
|
||||
4. **Verify by reading back.** No `project` arg needed — the workspace-qualified permalink routes itself.
|
||||
|
||||
```
|
||||
bm_read({ identifier: "personal/main/startwithdrew/start-with-drew-level-9-task-queue" })
|
||||
```
|
||||
|
||||
Return the permalink (and the project name for clarity) to the user.
|
||||
|
||||
## When to use each tool
|
||||
|
||||
| Situation | Tool |
|
||||
|---|---|
|
||||
| User asks about a topic that might already be documented | `bm_search` first, then `bm_read` |
|
||||
| User exposes a decision, plan, or meeting outcome | offer to `bm_write` |
|
||||
| Updating prior work | `bm_edit` (append for time-ordered logs, replace_section for living docs) |
|
||||
| Exploring related concepts | `bm_context` |
|
||||
| "What was I working on yesterday?" / no specific query yet | `bm_recent` |
|
||||
| User names a project that isn't the active one | `bm_projects` → call read/write tool with `project: "workspace/name"` or `project_id: "<uuid>"` |
|
||||
| Same project name might exist in multiple workspaces | `bm_projects` (+ `bm_workspaces` if needed) → route with workspace-qualified `project` or `project_id` |
|
||||
| Following up on a freshly-written note | Use the returned permalink directly — it already encodes the routing |
|
||||
|
||||
## Note structure
|
||||
|
||||
BM treats `- [category]` lines as **observations** and WikiLink lines under `## Relations` as **relations**. Categories (`[decision]`, `[insight]`, `[risk]`, `[fact]`, `[todo]`, …) and relation types (`relates_to`, `implements`, `depends_on`, `blocks`, …) are open-ended — use what fits the content. YAML frontmatter is supported with `title`, `type`, `tags`, and `permalink` as standard fields; any custom fields are allowed. See the [knowledge format docs](https://docs.basicmemory.com/raw/concepts/knowledge-format.md) for the full convention.
|
||||
|
||||
```markdown
|
||||
# Clear Title
|
||||
|
||||
## Context
|
||||
Background and current situation.
|
||||
|
||||
## Key Points
|
||||
- Main insights
|
||||
- Important details
|
||||
|
||||
## Observations
|
||||
- [decision] We chose PostgreSQL for ACID guarantees
|
||||
- [insight] Users prefer social login
|
||||
- [risk] Deployment lacks rollback path
|
||||
|
||||
## Relations
|
||||
- relates_to [[Other Note Title]]
|
||||
- depends_on [[Database Choice]]
|
||||
|
||||
## Next Steps
|
||||
- [ ] Implement
|
||||
- [ ] Document
|
||||
```
|
||||
|
||||
## Behavior guidelines
|
||||
|
||||
1. **Search before answering.** If the user asks "what did we decide about X?", run `bm_search` first.
|
||||
2. **Offer to capture.** When the user shares decisions or meeting outcomes, ask: "Should I save this as a note?"
|
||||
3. **Suggest connections.** When a search returns related notes, surface them so the user knows what already exists.
|
||||
4. **Don't over-capture.** Auto-capture is already running per turn. Don't create a `bm_write` for every response — only for substantive content the user wants preserved.
|
||||
5. **Sensitive info.** Don't capture credentials or personal data without confirmation.
|
||||
|
||||
## Footgun
|
||||
|
||||
If a note's body contains literal `<memory-context>...</memory-context>` tags, Hermes's streaming output scrubber will eat those tags (and the text between paired ones) when you echo the note verbatim back to the user. Tool *inputs* are unaffected. If you must include such content, fence it in a code block.
|
||||
|
||||
## Further reading
|
||||
|
||||
Official docs live at [docs.basicmemory.com](https://docs.basicmemory.com). Every page has an AI-friendly raw markdown view at `/raw/<path>.md` (or send `Accept: text/markdown` to the canonical URL). `WebFetch` any of these when you need detail beyond what this skill covers:
|
||||
|
||||
- **[Knowledge format](https://docs.basicmemory.com/raw/concepts/knowledge-format.md)** — observation categories, relation types, frontmatter conventions.
|
||||
- **[Observations & relations](https://docs.basicmemory.com/raw/concepts/observations-and-relations.md)** — how notes form a graph that's searchable and traversable.
|
||||
- **[Memory URLs](https://docs.basicmemory.com/raw/concepts/memory-urls.md)** — title-based addressing, wildcards (`memory://docs/*`), and routing resolution order.
|
||||
- **[Projects & folders](https://docs.basicmemory.com/raw/concepts/projects-and-folders.md)** — multi-project layout, folder organization, cloud routing behavior.
|
||||
- **[Semantic search](https://docs.basicmemory.com/raw/concepts/semantic-search.md)** — how `bm_search` resolves queries (semantic + full-text).
|
||||
- **[MCP tools reference](https://docs.basicmemory.com/raw/reference/mcp-tools-reference.md)** — Basic Memory's full MCP surface (the `bm_*` tools here are a curated subset).
|
||||
- **[Cloud routing](https://docs.basicmemory.com/raw/cloud/routing.md)** — local vs cloud project modes, per-project routing setup.
|
||||
- **[llms.txt index](https://docs.basicmemory.com/llms.txt)** — full sitemap of raw markdown pages, useful when you need to look up a page not listed above.
|
||||
@@ -0,0 +1,167 @@
|
||||
"""
|
||||
Pytest configuration: stub Hermes-internal imports so the plugin loads
|
||||
without a Hermes install, and expose the loaded plugin module as a fixture.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _stub_hermes_modules() -> None:
|
||||
"""
|
||||
The plugin imports `agent.memory_provider.MemoryProvider` and
|
||||
`tools.registry.tool_error`. These are provided by Hermes at runtime,
|
||||
not as a pip-installable package. Stub them before module load so unit
|
||||
tests don't need Hermes installed.
|
||||
"""
|
||||
agent_mod = types.ModuleType("agent")
|
||||
agent_mp_mod = types.ModuleType("agent.memory_provider")
|
||||
|
||||
class _StubMemoryProvider:
|
||||
"""Stand-in for `agent.memory_provider.MemoryProvider`."""
|
||||
|
||||
agent_mp_mod.MemoryProvider = _StubMemoryProvider
|
||||
sys.modules.setdefault("agent", agent_mod)
|
||||
sys.modules.setdefault("agent.memory_provider", agent_mp_mod)
|
||||
|
||||
tools_mod = types.ModuleType("tools")
|
||||
tools_registry_mod = types.ModuleType("tools.registry")
|
||||
|
||||
def _tool_error(msg: str) -> str:
|
||||
return json.dumps({"error": str(msg)})
|
||||
|
||||
tools_registry_mod.tool_error = _tool_error
|
||||
sys.modules.setdefault("tools", tools_mod)
|
||||
sys.modules.setdefault("tools.registry", tools_registry_mod)
|
||||
|
||||
|
||||
_stub_hermes_modules()
|
||||
|
||||
|
||||
_PLUGIN_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "__init__.py")
|
||||
_spec = importlib.util.spec_from_file_location("hermes_basic_memory_plugin", _PLUGIN_PATH)
|
||||
assert _spec is not None and _spec.loader is not None
|
||||
_plugin = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_plugin)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bm():
|
||||
"""The loaded plugin module."""
|
||||
return _plugin
|
||||
|
||||
|
||||
# ---- Synthetic MCP CallToolResult objects (no MCP SDK needed at test time) ----
|
||||
|
||||
|
||||
class FakeContent:
|
||||
"""Stand-in for `mcp.types.TextContent`."""
|
||||
|
||||
def __init__(self, text: str):
|
||||
self.text = text
|
||||
|
||||
|
||||
class FakeCallToolResult:
|
||||
"""Stand-in for `mcp.types.CallToolResult`."""
|
||||
|
||||
def __init__(self, content_texts, is_error: bool = False):
|
||||
self.content = [FakeContent(t) for t in content_texts]
|
||||
self.isError = is_error
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_result():
|
||||
return FakeCallToolResult
|
||||
|
||||
|
||||
# ---- Actor test helpers ----
|
||||
|
||||
|
||||
class FakeSession:
|
||||
"""
|
||||
Stand-in for `mcp.ClientSession`. `call_tool` is an async coroutine
|
||||
that records every call and returns a configurable CallToolResult.
|
||||
|
||||
- default_response: dict serialized to JSON in the result text
|
||||
- hang: when True, call_tool sleeps long enough to force a client-side timeout
|
||||
- is_error: when True, return value has isError=True
|
||||
"""
|
||||
|
||||
def __init__(self, default_response=None, hang: bool = False, is_error: bool = False):
|
||||
self.default_response = default_response if default_response is not None else {"ok": True}
|
||||
self.hang = hang
|
||||
self.is_error = is_error
|
||||
self.calls: list = []
|
||||
self.was_cancelled = False
|
||||
self._stub_handlers: dict = {}
|
||||
|
||||
def stub(self, tool_name: str, handler):
|
||||
"""Register a per-tool handler. Handler receives (args) and returns a response dict (or raises)."""
|
||||
self._stub_handlers[tool_name] = handler
|
||||
|
||||
async def call_tool(self, name: str, args: dict):
|
||||
self.calls.append((name, dict(args)))
|
||||
if self.hang:
|
||||
try:
|
||||
import asyncio as _a
|
||||
|
||||
await _a.sleep(60)
|
||||
except BaseException as e:
|
||||
# Track cooperative cancellation so tests can verify cleanup
|
||||
if type(e).__name__ in ("CancelledError",):
|
||||
self.was_cancelled = True
|
||||
raise
|
||||
if name in self._stub_handlers:
|
||||
response = self._stub_handlers[name](args)
|
||||
else:
|
||||
response = self.default_response
|
||||
return FakeCallToolResult([json.dumps(response)], is_error=self.is_error)
|
||||
|
||||
|
||||
def make_scripted_actor(
|
||||
bm, session=None, raise_at_init: BaseException | None = None, fake_tools: list | None = None
|
||||
):
|
||||
"""
|
||||
Build an actor whose `_main()` is replaced with a deterministic version.
|
||||
The fake `_main` mirrors the production happy path (sets _session, _stop_future,
|
||||
fills _tools_cache, signals ready, awaits stop) but skips the stdio subprocess.
|
||||
|
||||
raise_at_init: if set, raised inside _main so we can test failure paths.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
actor = bm._BmMcpActor(["fake-bm", "mcp"])
|
||||
sess = session or FakeSession()
|
||||
|
||||
async def _fake_main():
|
||||
if raise_at_init is not None:
|
||||
actor._init_error = raise_at_init
|
||||
actor._ready.set()
|
||||
raise raise_at_init
|
||||
actor._session = sess
|
||||
actor._stop_future = asyncio.get_running_loop().create_future()
|
||||
actor._tools_cache = fake_tools or [
|
||||
{"name": n, "description": ""}
|
||||
for n in [
|
||||
"search_notes",
|
||||
"read_note",
|
||||
"write_note",
|
||||
"edit_note",
|
||||
"build_context",
|
||||
"delete_note",
|
||||
"move_note",
|
||||
]
|
||||
]
|
||||
actor._ready.set()
|
||||
await actor._stop_future
|
||||
|
||||
actor._main = _fake_main # type: ignore[assignment]
|
||||
actor._test_session = sess # convenience handle for assertions
|
||||
return actor
|
||||
@@ -0,0 +1 @@
|
||||
"""Test-only Hermes agent package stub."""
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Test-only stand-in for Hermes's memory provider interface."""
|
||||
|
||||
|
||||
class MemoryProvider:
|
||||
"""Minimal base class used by the plugin during unit tests."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Test-only Hermes tools package stub."""
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Test-only stand-in for Hermes's tool registry helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
|
||||
def tool_error(msg: str) -> str:
|
||||
return json.dumps({"error": str(msg)})
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Tests for _BmMcpActor: lifecycle, call dispatch, timeout, shutdown."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import concurrent.futures
|
||||
import json
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import FakeSession, make_scripted_actor
|
||||
|
||||
|
||||
# ---- Lifecycle ----
|
||||
|
||||
|
||||
def test_new_actor_is_not_running(bm):
|
||||
actor = bm._BmMcpActor(["fake-bm", "mcp"])
|
||||
assert actor._running is False
|
||||
|
||||
|
||||
def test_start_brings_actor_up(bm):
|
||||
actor = make_scripted_actor(bm)
|
||||
actor.start(timeout=5.0)
|
||||
try:
|
||||
assert actor._running is True
|
||||
assert actor._session is not None
|
||||
assert actor._stop_future is not None
|
||||
assert actor._thread is not None and actor._thread.is_alive()
|
||||
# Tools cache populated
|
||||
names = {t["name"] for t in actor.list_tools()}
|
||||
assert "search_notes" in names
|
||||
finally:
|
||||
actor.shutdown(timeout=2.0)
|
||||
|
||||
|
||||
def test_start_is_idempotent_when_thread_alive(bm):
|
||||
actor = make_scripted_actor(bm)
|
||||
actor.start(timeout=5.0)
|
||||
try:
|
||||
first_thread = actor._thread
|
||||
actor.start(timeout=5.0) # second call
|
||||
assert actor._thread is first_thread # same thread, no new one spawned
|
||||
finally:
|
||||
actor.shutdown(timeout=2.0)
|
||||
|
||||
|
||||
def test_start_init_error_raises(bm):
|
||||
boom = ValueError("BM unreachable")
|
||||
actor = make_scripted_actor(bm, raise_at_init=boom)
|
||||
with pytest.raises(RuntimeError, match="BM unreachable"):
|
||||
actor.start(timeout=5.0)
|
||||
assert actor._running is False
|
||||
|
||||
|
||||
def test_start_timeout_raises(bm):
|
||||
"""If _ready never gets set within the timeout, start() raises TimeoutError."""
|
||||
import asyncio
|
||||
|
||||
actor = bm._BmMcpActor(["fake-bm", "mcp"])
|
||||
|
||||
async def _hang_forever():
|
||||
# Never call self._ready.set(); start() should time out waiting.
|
||||
await asyncio.sleep(60)
|
||||
|
||||
actor._main = _hang_forever # type: ignore[assignment]
|
||||
|
||||
with pytest.raises(TimeoutError):
|
||||
actor.start(timeout=0.5)
|
||||
assert actor._running is False
|
||||
|
||||
|
||||
# ---- Shutdown ----
|
||||
|
||||
|
||||
def test_shutdown_before_start_is_noop(bm):
|
||||
actor = bm._BmMcpActor(["fake-bm", "mcp"])
|
||||
# Should not raise even though nothing is running
|
||||
actor.shutdown(timeout=1.0)
|
||||
assert actor._running is False
|
||||
|
||||
|
||||
def test_shutdown_stops_running_actor(bm):
|
||||
actor = make_scripted_actor(bm)
|
||||
actor.start(timeout=5.0)
|
||||
assert actor._thread is not None and actor._thread.is_alive()
|
||||
actor.shutdown(timeout=5.0)
|
||||
assert actor._running is False
|
||||
# Thread should exit shortly after stop_future resolves
|
||||
actor._thread.join(timeout=2.0)
|
||||
assert not actor._thread.is_alive()
|
||||
|
||||
|
||||
def test_shutdown_is_idempotent(bm):
|
||||
actor = make_scripted_actor(bm)
|
||||
actor.start(timeout=5.0)
|
||||
actor.shutdown(timeout=2.0)
|
||||
# Second call should not raise
|
||||
actor.shutdown(timeout=2.0)
|
||||
assert actor._running is False
|
||||
|
||||
|
||||
# ---- call() dispatch ----
|
||||
|
||||
|
||||
def test_call_before_start_raises(bm):
|
||||
actor = bm._BmMcpActor(["fake-bm", "mcp"])
|
||||
with pytest.raises(RuntimeError):
|
||||
actor.call("search_notes", {})
|
||||
|
||||
|
||||
def test_call_after_shutdown_raises(bm):
|
||||
actor = make_scripted_actor(bm)
|
||||
actor.start(timeout=5.0)
|
||||
actor.shutdown(timeout=2.0)
|
||||
with pytest.raises(RuntimeError, match="not running"):
|
||||
actor.call("search_notes", {})
|
||||
|
||||
|
||||
def test_call_dispatches_through_actor_loop(bm):
|
||||
session = FakeSession(default_response={"results": [], "ok": True})
|
||||
actor = make_scripted_actor(bm, session=session)
|
||||
actor.start(timeout=5.0)
|
||||
try:
|
||||
out = actor.call("search_notes", {"query": "hi"}, timeout=5.0)
|
||||
# Output is whatever _extract_mcp_text produces; we just verify it
|
||||
# contains the response payload we configured.
|
||||
assert "ok" in out
|
||||
assert session.calls == [("search_notes", {"query": "hi"})]
|
||||
finally:
|
||||
actor.shutdown(timeout=2.0)
|
||||
|
||||
|
||||
def test_call_returns_mcp_extracted_text(bm):
|
||||
session = FakeSession(default_response={"permalink": "p/q/r", "title": "T"})
|
||||
actor = make_scripted_actor(bm, session=session)
|
||||
actor.start(timeout=5.0)
|
||||
try:
|
||||
out = actor.call("write_note", {"title": "T"}, timeout=5.0)
|
||||
parsed = json.loads(out)
|
||||
assert parsed["permalink"] == "p/q/r"
|
||||
finally:
|
||||
actor.shutdown(timeout=2.0)
|
||||
|
||||
|
||||
def test_call_timeout_raises_and_cancels_coroutine(bm):
|
||||
session = FakeSession(hang=True)
|
||||
actor = make_scripted_actor(bm, session=session)
|
||||
actor.start(timeout=5.0)
|
||||
try:
|
||||
t0 = time.monotonic()
|
||||
with pytest.raises(concurrent.futures.TimeoutError):
|
||||
actor.call("search_notes", {}, timeout=0.3)
|
||||
elapsed = time.monotonic() - t0
|
||||
# Sanity: we didn't accidentally wait the full session-side sleep
|
||||
assert elapsed < 2.0
|
||||
# Give the actor loop a moment to propagate the cancellation
|
||||
deadline = time.monotonic() + 2.0
|
||||
while time.monotonic() < deadline and not session.was_cancelled:
|
||||
time.sleep(0.05)
|
||||
assert session.was_cancelled is True, (
|
||||
"Expected the underlying coroutine to be cancelled when call() times out"
|
||||
)
|
||||
finally:
|
||||
actor.shutdown(timeout=2.0)
|
||||
|
||||
|
||||
def test_list_tools_returns_independent_copy(bm):
|
||||
actor = make_scripted_actor(bm)
|
||||
actor.start(timeout=5.0)
|
||||
try:
|
||||
snapshot = actor.list_tools()
|
||||
snapshot.append({"name": "tampered"})
|
||||
assert "tampered" not in {t["name"] for t in actor.list_tools()}
|
||||
finally:
|
||||
actor.shutdown(timeout=2.0)
|
||||
|
||||
|
||||
def test_actor_init_error_logs_and_marks_not_running(bm, caplog):
|
||||
actor = make_scripted_actor(bm, raise_at_init=RuntimeError("server crashed"))
|
||||
with pytest.raises(RuntimeError):
|
||||
actor.start(timeout=5.0)
|
||||
assert actor._running is False
|
||||
@@ -0,0 +1,337 @@
|
||||
"""
|
||||
Tests for the capture pipeline: sync_turn (per-turn) and on_session_end (summary).
|
||||
|
||||
These tests run the *real* sync_turn code path with a mocked actor, so threading
|
||||
and argument-shape regressions are caught.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _provider_with_mock_actor(bm, *, project="test-proj", capture_folder="hermes-sessions"):
|
||||
p = bm.BasicMemoryProvider()
|
||||
p._initialized = True
|
||||
p._project = project
|
||||
p._capture_folder = capture_folder
|
||||
p._session_id = "20260510_123456_abcdef"
|
||||
p._session_started_at = datetime(2026, 5, 10, 12, 34, 56, tzinfo=timezone.utc)
|
||||
actor = MagicMock()
|
||||
p._actor = actor
|
||||
return p, actor
|
||||
|
||||
|
||||
def _wait_for_thread(p, attr="_sync_thread", timeout=5.0):
|
||||
t = getattr(p, attr)
|
||||
if t is not None:
|
||||
t.join(timeout=timeout)
|
||||
assert not t.is_alive(), f"{attr} did not finish within {timeout}s"
|
||||
|
||||
|
||||
# ---- sync_turn first-turn path: write_note ----
|
||||
|
||||
|
||||
def test_sync_turn_first_turn_calls_write_note(bm):
|
||||
p, actor = _provider_with_mock_actor(bm)
|
||||
actor.call.return_value = json.dumps(
|
||||
{
|
||||
"permalink": "test-proj/hermes-sessions/hermes-session-2026-05-10-1234-abcdef",
|
||||
"title": "Hermes Session 2026-05-10 1234 abcdef",
|
||||
}
|
||||
)
|
||||
|
||||
p.sync_turn("hello", "hi back")
|
||||
_wait_for_thread(p, "_sync_thread")
|
||||
|
||||
actor.call.assert_called_once()
|
||||
bm_tool, bm_args = actor.call.call_args[0][:2]
|
||||
assert bm_tool == "write_note"
|
||||
assert bm_args["project"] == "test-proj"
|
||||
assert bm_args["directory"] == "hermes-sessions"
|
||||
assert "Hermes Session" in bm_args["title"]
|
||||
assert "hello" in bm_args["content"]
|
||||
assert "hi back" in bm_args["content"]
|
||||
assert "## Turns" in bm_args["content"]
|
||||
assert bm_args["output_format"] == "json"
|
||||
assert "hermes-session" in bm_args["tags"]
|
||||
|
||||
|
||||
def test_sync_turn_first_turn_stores_extracted_permalink(bm):
|
||||
p, actor = _provider_with_mock_actor(bm)
|
||||
actor.call.return_value = json.dumps(
|
||||
{
|
||||
"permalink": "test-proj/hermes-sessions/hermes-session-foo",
|
||||
"title": "T",
|
||||
}
|
||||
)
|
||||
|
||||
p.sync_turn("u", "a")
|
||||
_wait_for_thread(p, "_sync_thread")
|
||||
|
||||
assert p._session_note_id == "test-proj/hermes-sessions/hermes-session-foo"
|
||||
|
||||
|
||||
def test_sync_turn_records_first_user_message(bm):
|
||||
p, actor = _provider_with_mock_actor(bm)
|
||||
actor.call.return_value = json.dumps({"permalink": "x"})
|
||||
|
||||
p.sync_turn("the very first user message", "reply")
|
||||
_wait_for_thread(p, "_sync_thread")
|
||||
|
||||
assert p._first_user_msg == "the very first user message"
|
||||
|
||||
# A subsequent first-user-msg call should NOT overwrite the original
|
||||
p.sync_turn("a much later user message", "another reply")
|
||||
_wait_for_thread(p, "_sync_thread")
|
||||
assert p._first_user_msg == "the very first user message"
|
||||
|
||||
|
||||
# ---- sync_turn append path: edit_note ----
|
||||
|
||||
|
||||
def test_sync_turn_subsequent_turn_calls_edit_note_append(bm):
|
||||
p, actor = _provider_with_mock_actor(bm)
|
||||
p._session_note_id = "test-proj/hermes-sessions/already-exists"
|
||||
|
||||
p.sync_turn("turn 2 user", "turn 2 assistant")
|
||||
_wait_for_thread(p, "_sync_thread")
|
||||
|
||||
actor.call.assert_called_once()
|
||||
bm_tool, bm_args = actor.call.call_args[0][:2]
|
||||
assert bm_tool == "edit_note"
|
||||
assert bm_args["identifier"] == "test-proj/hermes-sessions/already-exists"
|
||||
assert bm_args["operation"] == "append"
|
||||
assert "turn 2 user" in bm_args["content"]
|
||||
assert "turn 2 assistant" in bm_args["content"]
|
||||
|
||||
|
||||
def test_sync_turn_session_note_id_stable_across_turns(bm):
|
||||
p, actor = _provider_with_mock_actor(bm)
|
||||
actor.call.return_value = json.dumps({"permalink": "test-proj/folder/note-perma"})
|
||||
|
||||
p.sync_turn("u1", "a1")
|
||||
_wait_for_thread(p, "_sync_thread")
|
||||
first_id = p._session_note_id
|
||||
|
||||
# Reconfigure mock to return something different — should be IGNORED for
|
||||
# the second turn since we're now using the existing permalink to append.
|
||||
actor.call.return_value = json.dumps({"permalink": "wrong-id"})
|
||||
|
||||
p.sync_turn("u2", "a2")
|
||||
_wait_for_thread(p, "_sync_thread")
|
||||
|
||||
assert p._session_note_id == first_id, "session_note_id mutated on second turn"
|
||||
# And the second call was edit_note, not write_note
|
||||
second_call_tool = actor.call.call_args_list[1][0][0]
|
||||
assert second_call_tool == "edit_note"
|
||||
|
||||
|
||||
# ---- sync_turn gating ----
|
||||
|
||||
|
||||
def test_sync_turn_skipped_when_capture_per_turn_off(bm):
|
||||
p, actor = _provider_with_mock_actor(bm)
|
||||
p._capture_per_turn = False
|
||||
p.sync_turn("u", "a")
|
||||
actor.call.assert_not_called()
|
||||
|
||||
|
||||
def test_sync_turn_skipped_when_uninitialized(bm):
|
||||
p = bm.BasicMemoryProvider()
|
||||
p._actor = MagicMock()
|
||||
p.sync_turn("u", "a")
|
||||
p._actor.call.assert_not_called()
|
||||
|
||||
|
||||
def test_sync_turn_skipped_when_actor_none(bm):
|
||||
p, _ = _provider_with_mock_actor(bm)
|
||||
p._actor = None
|
||||
p.sync_turn("u", "a") # should not raise
|
||||
|
||||
|
||||
def test_sync_turn_skipped_when_circuit_open(bm):
|
||||
import time as _time
|
||||
|
||||
p, actor = _provider_with_mock_actor(bm)
|
||||
p._failure_pause_until = _time.monotonic() + 60.0
|
||||
p.sync_turn("u", "a")
|
||||
actor.call.assert_not_called()
|
||||
|
||||
|
||||
def test_sync_turn_records_failure_on_actor_exception(bm):
|
||||
p, actor = _provider_with_mock_actor(bm)
|
||||
actor.call.side_effect = RuntimeError("boom")
|
||||
|
||||
p.sync_turn("u", "a")
|
||||
_wait_for_thread(p, "_sync_thread")
|
||||
|
||||
assert p._failure_count >= 1
|
||||
|
||||
|
||||
def test_sync_turn_thread_is_daemonic(bm):
|
||||
p, actor = _provider_with_mock_actor(bm)
|
||||
actor.call.return_value = json.dumps({"permalink": "x"})
|
||||
p.sync_turn("u", "a")
|
||||
assert p._sync_thread is not None
|
||||
assert p._sync_thread.daemon is True
|
||||
_wait_for_thread(p, "_sync_thread")
|
||||
|
||||
|
||||
# ---- _capture_turn directly (no thread) ----
|
||||
|
||||
|
||||
def test_capture_turn_first_call_writes_note_with_session_metadata(bm):
|
||||
p, actor = _provider_with_mock_actor(bm)
|
||||
actor.call.return_value = json.dumps({"permalink": "p/folder/note"})
|
||||
p._capture_turn("u msg", "a msg")
|
||||
bm_tool, bm_args = actor.call.call_args[0][:2]
|
||||
assert bm_tool == "write_note"
|
||||
assert "20260510_123456_abcdef" in bm_args["content"]
|
||||
# Auto-captured banner present
|
||||
assert "Auto-captured" in bm_args["content"]
|
||||
|
||||
|
||||
def test_capture_turn_truncates_huge_messages(bm):
|
||||
p, actor = _provider_with_mock_actor(bm)
|
||||
actor.call.return_value = json.dumps({"permalink": "p/x"})
|
||||
huge = "X" * 10000
|
||||
p._capture_turn(huge, huge)
|
||||
bm_args = actor.call.call_args[0][1]
|
||||
# Body should not contain 10k Xs verbatim — _truncate caps at 4000
|
||||
assert bm_args["content"].count("X") < 9000
|
||||
assert "..." in bm_args["content"]
|
||||
|
||||
|
||||
# ---- on_session_end summary ----
|
||||
|
||||
|
||||
def test_on_session_end_writes_summary_note(bm):
|
||||
p, actor = _provider_with_mock_actor(bm)
|
||||
p._session_note_id = "p/folder/transcript"
|
||||
actor.call.return_value = json.dumps({"permalink": "p/folder/summary"})
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "first user message"},
|
||||
{"role": "assistant", "content": "first assistant"},
|
||||
{"role": "user", "content": "second user"},
|
||||
{"role": "assistant", "content": "last assistant message"},
|
||||
]
|
||||
p.on_session_end(messages)
|
||||
|
||||
actor.call.assert_called_once()
|
||||
bm_tool, bm_args = actor.call.call_args[0][:2]
|
||||
assert bm_tool == "write_note"
|
||||
assert "Hermes Session Summary" in bm_args["title"]
|
||||
assert bm_args["directory"] == "hermes-sessions"
|
||||
assert "first user message" in bm_args["content"]
|
||||
assert "last assistant message" in bm_args["content"]
|
||||
# Summary should link back to the transcript via Relations when
|
||||
# session_note_id is known
|
||||
assert "summary_of [[p/folder/transcript]]" in bm_args["content"]
|
||||
|
||||
|
||||
def test_on_session_end_omits_relations_when_no_transcript_id(bm):
|
||||
p, actor = _provider_with_mock_actor(bm)
|
||||
p._session_note_id = None
|
||||
actor.call.return_value = json.dumps({"permalink": "p/folder/summary"})
|
||||
|
||||
p.on_session_end([{"role": "user", "content": "u"}, {"role": "assistant", "content": "a"}])
|
||||
|
||||
bm_args = actor.call.call_args[0][1]
|
||||
assert "## Relations" not in bm_args["content"]
|
||||
|
||||
|
||||
def test_on_session_end_handles_list_of_dicts_content(bm):
|
||||
"""OpenAI-style content blocks: list of {type: text, text: ...}."""
|
||||
p, actor = _provider_with_mock_actor(bm)
|
||||
actor.call.return_value = json.dumps({"permalink": "p/x"})
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": [{"type": "text", "text": "hello world"}]},
|
||||
{"role": "assistant", "content": [{"type": "text", "text": "goodbye"}]},
|
||||
]
|
||||
p.on_session_end(messages)
|
||||
bm_args = actor.call.call_args[0][1]
|
||||
assert "hello world" in bm_args["content"]
|
||||
assert "goodbye" in bm_args["content"]
|
||||
|
||||
|
||||
def test_on_session_end_uses_first_user_msg_when_set(bm):
|
||||
p, actor = _provider_with_mock_actor(bm)
|
||||
p._first_user_msg = "captured-via-sync_turn"
|
||||
actor.call.return_value = json.dumps({"permalink": "p/x"})
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "in-the-messages-list"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
]
|
||||
p.on_session_end(messages)
|
||||
bm_args = actor.call.call_args[0][1]
|
||||
# _first_user_msg takes priority over messages list
|
||||
assert "captured-via-sync_turn" in bm_args["content"]
|
||||
|
||||
|
||||
def test_on_session_end_handles_empty_messages(bm):
|
||||
p, actor = _provider_with_mock_actor(bm)
|
||||
actor.call.return_value = json.dumps({"permalink": "p/x"})
|
||||
p.on_session_end([])
|
||||
bm_args = actor.call.call_args[0][1]
|
||||
assert "(no user message)" in bm_args["content"]
|
||||
assert "(no assistant message)" in bm_args["content"]
|
||||
assert "0 user / 0 assistant" in bm_args["content"]
|
||||
|
||||
|
||||
def test_on_session_end_skipped_when_disabled(bm):
|
||||
p, actor = _provider_with_mock_actor(bm)
|
||||
p._capture_session_end = False
|
||||
p.on_session_end([{"role": "user", "content": "u"}])
|
||||
actor.call.assert_not_called()
|
||||
|
||||
|
||||
def test_on_session_end_skipped_when_uninitialized(bm):
|
||||
p = bm.BasicMemoryProvider()
|
||||
p._actor = MagicMock()
|
||||
p.on_session_end([{"role": "user", "content": "u"}])
|
||||
p._actor.call.assert_not_called()
|
||||
|
||||
|
||||
def test_on_session_end_logs_and_swallows_errors(bm, caplog):
|
||||
p, actor = _provider_with_mock_actor(bm)
|
||||
actor.call.side_effect = RuntimeError("BM down")
|
||||
# Should not raise — the summary is best-effort
|
||||
p.on_session_end([{"role": "user", "content": "u"}])
|
||||
|
||||
|
||||
# ---- shutdown lifecycle ----
|
||||
|
||||
|
||||
def test_shutdown_clears_initialized_flag(bm):
|
||||
p, actor = _provider_with_mock_actor(bm)
|
||||
p.shutdown()
|
||||
assert p._initialized is False
|
||||
assert p._actor is None
|
||||
actor.shutdown.assert_called_once()
|
||||
|
||||
|
||||
def test_shutdown_swallows_actor_errors(bm):
|
||||
p, actor = _provider_with_mock_actor(bm)
|
||||
actor.shutdown.side_effect = RuntimeError("bad")
|
||||
# Must not raise
|
||||
p.shutdown()
|
||||
assert p._initialized is False
|
||||
|
||||
|
||||
def test_shutdown_idempotent(bm):
|
||||
p, _ = _provider_with_mock_actor(bm)
|
||||
p.shutdown()
|
||||
p.shutdown() # should not raise
|
||||
|
||||
|
||||
def test_shutdown_before_initialize_is_noop(bm):
|
||||
p = bm.BasicMemoryProvider()
|
||||
p.shutdown() # _actor is None, shouldn't error
|
||||
@@ -0,0 +1,725 @@
|
||||
"""
|
||||
Tests for the plugin-owned /bm-* slash commands.
|
||||
|
||||
Covers:
|
||||
- registration through ctx.register_command (forward-compat path)
|
||||
- PluginManager reach-in (production path with current Hermes collector)
|
||||
- per-handler behavior: usage text, uninitialized provider, happy path,
|
||||
and exception → plain-text error.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from .conftest import FakeSession, make_scripted_actor
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers for the reach-in tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _ProviderCollectorLike:
|
||||
"""
|
||||
Mirror of Hermes's real `_ProviderCollector` shape: captures
|
||||
`register_memory_provider` and no-ops everything else. NOT a MagicMock —
|
||||
`hasattr(collector, "register_command")` must return False, matching the
|
||||
real collector.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.provider = None
|
||||
|
||||
def register_memory_provider(self, provider):
|
||||
self.provider = provider
|
||||
|
||||
|
||||
class _FakePluginManager:
|
||||
"""Stand-in for hermes_cli.plugins.PluginManager — just the bits we touch."""
|
||||
|
||||
def __init__(self):
|
||||
self._plugin_commands: dict = {}
|
||||
self._plugin_skills: dict = {}
|
||||
|
||||
|
||||
def _install_fake_hermes_cli(monkeypatch, *, resolve_returns=None):
|
||||
"""
|
||||
Insert a fake `hermes_cli.plugins` (with `_ensure_plugins_discovered`) and
|
||||
`hermes_cli.commands` (with `resolve_command`) into sys.modules so the
|
||||
reach-in's lazy imports resolve. Returns the FakePluginManager instance
|
||||
so tests can assert against its registries.
|
||||
|
||||
resolve_returns: optional mapping of command name → truthy/falsy value
|
||||
the fake resolve_command should return. Use a truthy value to simulate a
|
||||
built-in conflict for that name.
|
||||
"""
|
||||
fake_mgr = _FakePluginManager()
|
||||
|
||||
plugins_mod = types.ModuleType("hermes_cli.plugins")
|
||||
|
||||
def _ensure_plugins_discovered(force: bool = False):
|
||||
return fake_mgr
|
||||
|
||||
plugins_mod._ensure_plugins_discovered = _ensure_plugins_discovered # type: ignore[attr-defined]
|
||||
|
||||
commands_mod = types.ModuleType("hermes_cli.commands")
|
||||
|
||||
def _resolve_command(name: str):
|
||||
if resolve_returns and name in resolve_returns:
|
||||
return resolve_returns[name]
|
||||
return None
|
||||
|
||||
commands_mod.resolve_command = _resolve_command # type: ignore[attr-defined]
|
||||
|
||||
hermes_cli = types.ModuleType("hermes_cli")
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", hermes_cli)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli.plugins", plugins_mod)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli.commands", commands_mod)
|
||||
|
||||
return fake_mgr
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_EXPECTED_COMMANDS = {
|
||||
"bm-search",
|
||||
"bm-read",
|
||||
"bm-context",
|
||||
"bm-recent",
|
||||
"bm-status",
|
||||
"bm-remember",
|
||||
"bm-project",
|
||||
"bm-workspace",
|
||||
}
|
||||
|
||||
|
||||
def test_register_wires_up_all_slash_commands_on_modern_ctx(bm):
|
||||
"""Forward-compat path: when ctx supports register_command (e.g. after the
|
||||
upstream collector patch lands, or for plugins loaded via PluginContext),
|
||||
every /bm-* command is registered through that path."""
|
||||
ctx = MagicMock()
|
||||
bm._active_providers.clear()
|
||||
bm.register(ctx)
|
||||
names = {call.args[0] for call in ctx.register_command.call_args_list}
|
||||
assert names == _EXPECTED_COMMANDS
|
||||
bm._active_providers.clear()
|
||||
|
||||
|
||||
def test_register_command_calls_include_description_and_args_hint(bm):
|
||||
ctx = MagicMock()
|
||||
bm._active_providers.clear()
|
||||
bm.register(ctx)
|
||||
for call in ctx.register_command.call_args_list:
|
||||
name, handler = call.args[0], call.args[1]
|
||||
kwargs = call.kwargs
|
||||
assert callable(handler)
|
||||
assert "description" in kwargs and kwargs["description"]
|
||||
assert "args_hint" in kwargs # may be empty string for no-arg commands
|
||||
bm._active_providers.clear()
|
||||
|
||||
|
||||
def test_register_tolerates_old_hermes_without_register_command(bm):
|
||||
"""Plugins must not crash on Hermes < v0.11.0 (no register_command)."""
|
||||
|
||||
class _OldCtx:
|
||||
def __init__(self):
|
||||
self.memory_calls = []
|
||||
|
||||
def register_memory_provider(self, provider):
|
||||
self.memory_calls.append(provider)
|
||||
|
||||
ctx = _OldCtx()
|
||||
bm._active_providers.clear()
|
||||
bm.register(ctx) # must not raise
|
||||
assert len(ctx.memory_calls) == 1
|
||||
bm._active_providers.clear()
|
||||
|
||||
|
||||
def test_register_swallows_register_command_errors(bm, caplog):
|
||||
"""If one register_command call fails, the others — and provider
|
||||
registration — still proceed."""
|
||||
ctx = MagicMock()
|
||||
ctx.register_command.side_effect = ValueError("name collision with builtin")
|
||||
bm._active_providers.clear()
|
||||
with caplog.at_level("WARNING"):
|
||||
bm.register(ctx)
|
||||
ctx.register_memory_provider.assert_called_once()
|
||||
assert ctx.register_command.call_count == len(_EXPECTED_COMMANDS)
|
||||
assert "register_command" in caplog.text
|
||||
bm._active_providers.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reach-in (production path): ctx is the no-op _ProviderCollector
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_reach_in_writes_all_commands_to_plugin_manager(bm, monkeypatch):
|
||||
"""Regression for Codex P1: with the real collector shape (no
|
||||
register_command method), reach into PluginManager and write commands
|
||||
directly. The unit suite previously used MagicMock, which masked this
|
||||
silent-skip by making every attribute exist."""
|
||||
fake_mgr = _install_fake_hermes_cli(monkeypatch)
|
||||
ctx = _ProviderCollectorLike()
|
||||
assert not hasattr(ctx, "register_command"), (
|
||||
"test collector must mirror real _ProviderCollector — no register_command"
|
||||
)
|
||||
|
||||
bm._active_providers.clear()
|
||||
bm.register(ctx)
|
||||
try:
|
||||
assert ctx.provider is not None # memory provider still registered
|
||||
assert set(fake_mgr._plugin_commands.keys()) == _EXPECTED_COMMANDS
|
||||
for name, entry in fake_mgr._plugin_commands.items():
|
||||
assert callable(entry["handler"])
|
||||
assert entry["plugin"] == "basic-memory"
|
||||
assert "description" in entry
|
||||
assert "args_hint" in entry
|
||||
finally:
|
||||
bm._active_providers.clear()
|
||||
|
||||
|
||||
def test_reach_in_writes_skill_to_plugin_manager(bm, monkeypatch):
|
||||
"""Same silent-skip applies to register_skill — the bundled skill never
|
||||
landed in real installs prior to this fix. Reach-in writes the namespaced
|
||||
entry directly."""
|
||||
fake_mgr = _install_fake_hermes_cli(monkeypatch)
|
||||
ctx = _ProviderCollectorLike()
|
||||
bm._active_providers.clear()
|
||||
bm.register(ctx)
|
||||
try:
|
||||
assert "basic-memory:basic-memory" in fake_mgr._plugin_skills
|
||||
skill = fake_mgr._plugin_skills["basic-memory:basic-memory"]
|
||||
assert skill["plugin"] == "basic-memory"
|
||||
assert skill["bare_name"] == "basic-memory"
|
||||
assert isinstance(skill["path"], Path)
|
||||
assert skill["path"].name == "SKILL.md"
|
||||
finally:
|
||||
bm._active_providers.clear()
|
||||
|
||||
|
||||
def test_reach_in_skips_command_conflicting_with_builtin(bm, monkeypatch, caplog):
|
||||
"""Mirror Hermes's PluginContext.register_command guard — when
|
||||
resolve_command(name) returns a truthy value, skip that command and
|
||||
log a warning rather than overwriting a built-in."""
|
||||
# Simulate /bm-search colliding with a built-in.
|
||||
fake_mgr = _install_fake_hermes_cli(monkeypatch, resolve_returns={"bm-search": object()})
|
||||
ctx = _ProviderCollectorLike()
|
||||
bm._active_providers.clear()
|
||||
with caplog.at_level("WARNING"):
|
||||
bm.register(ctx)
|
||||
try:
|
||||
assert "bm-search" not in fake_mgr._plugin_commands
|
||||
# Other commands still landed
|
||||
assert "bm-read" in fake_mgr._plugin_commands
|
||||
assert "conflicts with a built-in" in caplog.text
|
||||
finally:
|
||||
bm._active_providers.clear()
|
||||
|
||||
|
||||
def test_reach_in_degrades_when_hermes_cli_missing(bm, monkeypatch, caplog):
|
||||
"""If hermes_cli.plugins isn't importable (e.g. running outside a Hermes
|
||||
install), the reach-in must log and continue — never crash the plugin's
|
||||
memory-provider registration."""
|
||||
# Don't install fake modules; force import to fail.
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli.plugins", None) # type: ignore[arg-type]
|
||||
ctx = _ProviderCollectorLike()
|
||||
bm._active_providers.clear()
|
||||
with caplog.at_level("DEBUG"):
|
||||
bm.register(ctx) # must not raise
|
||||
try:
|
||||
assert ctx.provider is not None
|
||||
# Either DEBUG message logged or nothing — both acceptable degrade modes.
|
||||
finally:
|
||||
bm._active_providers.clear()
|
||||
|
||||
|
||||
def test_reach_in_degrades_when_plugin_manager_missing_attrs(bm, monkeypatch):
|
||||
"""Forward-compat: if Hermes ever refactors _plugin_commands /
|
||||
_plugin_skills away, the reach-in must not crash."""
|
||||
fake_mgr = _FakePluginManager()
|
||||
# Strip the attrs to simulate the rename/refactor
|
||||
del fake_mgr._plugin_commands
|
||||
del fake_mgr._plugin_skills
|
||||
|
||||
plugins_mod = types.ModuleType("hermes_cli.plugins")
|
||||
plugins_mod._ensure_plugins_discovered = lambda force=False: fake_mgr # type: ignore[attr-defined]
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli.plugins", plugins_mod)
|
||||
|
||||
ctx = _ProviderCollectorLike()
|
||||
bm._active_providers.clear()
|
||||
bm.register(ctx) # must not raise
|
||||
bm._active_providers.clear()
|
||||
|
||||
|
||||
def test_reach_in_normalizes_command_names(bm, monkeypatch):
|
||||
"""Reach-in must mirror Hermes's name normalization (lowercase, strip,
|
||||
leading slash removed, spaces → hyphens). All our names are already
|
||||
canonical, so this is a defensive check on the transform itself."""
|
||||
fake_mgr = _install_fake_hermes_cli(monkeypatch)
|
||||
ctx = _ProviderCollectorLike()
|
||||
bm._active_providers.clear()
|
||||
bm.register(ctx)
|
||||
try:
|
||||
for name in fake_mgr._plugin_commands:
|
||||
assert name == name.lower()
|
||||
assert not name.startswith("/")
|
||||
assert " " not in name
|
||||
finally:
|
||||
bm._active_providers.clear()
|
||||
|
||||
|
||||
def test_reach_in_entries_match_hermes_internal_shape(bm, monkeypatch):
|
||||
"""The dict shape PluginContext.register_command writes
|
||||
(plugins.py:447-452) is the contract Hermes's dispatch reads. Our
|
||||
reach-in must produce byte-identical entries."""
|
||||
fake_mgr = _install_fake_hermes_cli(monkeypatch)
|
||||
ctx = _ProviderCollectorLike()
|
||||
bm._active_providers.clear()
|
||||
bm.register(ctx)
|
||||
try:
|
||||
entry = fake_mgr._plugin_commands["bm-search"]
|
||||
assert set(entry.keys()) == {"handler", "description", "plugin", "args_hint"}
|
||||
assert callable(entry["handler"])
|
||||
assert entry["description"] # non-empty string
|
||||
assert entry["plugin"] == "basic-memory"
|
||||
assert isinstance(entry["args_hint"], str)
|
||||
finally:
|
||||
bm._active_providers.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-handler tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _ready_provider(bm, session: FakeSession | None = None):
|
||||
"""Build a provider in 'initialized' state with a scripted actor."""
|
||||
provider = bm.BasicMemoryProvider()
|
||||
actor = make_scripted_actor(bm, session=session)
|
||||
actor.start()
|
||||
provider._actor = actor
|
||||
provider._initialized = True
|
||||
provider._project = "test-proj"
|
||||
return provider, actor
|
||||
|
||||
|
||||
def _handlers_by_name(bm, provider):
|
||||
return {name: handler for name, handler, _, _ in bm._build_slash_commands(provider)}
|
||||
|
||||
|
||||
# ---- Usage strings ----
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name,args",
|
||||
[
|
||||
("bm-search", ""),
|
||||
("bm-search", "help"),
|
||||
("bm-read", ""),
|
||||
("bm-read", "-h"),
|
||||
("bm-context", ""),
|
||||
("bm-remember", ""),
|
||||
("bm-remember", "--help"),
|
||||
# Commands that take no args use 'help' to surface their usage line
|
||||
("bm-recent", "help"),
|
||||
("bm-status", "help"),
|
||||
("bm-project", "help"),
|
||||
("bm-workspace", "help"),
|
||||
],
|
||||
)
|
||||
def test_usage_returned_for_empty_or_help_args(bm, name, args):
|
||||
provider = bm.BasicMemoryProvider() # not initialized — usage path shouldn't need it
|
||||
handlers = _handlers_by_name(bm, provider)
|
||||
out = handlers[name](args)
|
||||
assert isinstance(out, str)
|
||||
assert out.lower().startswith("usage:")
|
||||
|
||||
|
||||
# ---- Uninitialized provider ----
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name,args",
|
||||
[
|
||||
("bm-search", "hello"),
|
||||
("bm-read", "some/note"),
|
||||
("bm-context", "memory://x"),
|
||||
("bm-recent", ""),
|
||||
("bm-remember", "a thought"),
|
||||
("bm-project", ""),
|
||||
],
|
||||
)
|
||||
def test_handler_init_failure_returns_message(bm, monkeypatch, name, args):
|
||||
provider = bm.BasicMemoryProvider()
|
||||
monkeypatch.setattr(provider, "initialize", lambda *a, **kw: None)
|
||||
handlers = _handlers_by_name(bm, provider)
|
||||
out = handlers[name](args)
|
||||
assert "not initialized" in out
|
||||
assert name in out # message includes command name
|
||||
|
||||
|
||||
def test_handler_lazily_initializes_provider_for_slash_command(bm, monkeypatch):
|
||||
provider = bm.BasicMemoryProvider()
|
||||
calls = []
|
||||
|
||||
def fake_initialize(*args, **kwargs):
|
||||
calls.append((args, kwargs))
|
||||
session = FakeSession(default_response={"results": []})
|
||||
actor = make_scripted_actor(bm, session=session)
|
||||
actor.start()
|
||||
provider._actor = actor
|
||||
provider._initialized = True
|
||||
|
||||
monkeypatch.setattr(provider, "initialize", fake_initialize)
|
||||
out = _handlers_by_name(bm, provider)["bm-search"]("widgets")
|
||||
assert "No results" in out
|
||||
assert len(calls) == 1
|
||||
assert calls[0][1]["session_id"].startswith("slash:bm-search:")
|
||||
|
||||
|
||||
# ---- /bm-status ----
|
||||
|
||||
|
||||
def test_bm_status_renders_provider_state(bm, monkeypatch):
|
||||
provider = bm.BasicMemoryProvider()
|
||||
provider._mode = "local"
|
||||
provider._project = "demo"
|
||||
provider._project_path = "/tmp/demo"
|
||||
provider._capture_per_turn = True
|
||||
provider._capture_session_end = False
|
||||
provider._capture_folder = "transcripts"
|
||||
provider._remember_folder = "inbox"
|
||||
monkeypatch.setattr(bm, "_bm_binary_path", lambda: "/fake/bin/bm")
|
||||
out = _handlers_by_name(bm, provider)["bm-status"]("")
|
||||
assert "demo" in out
|
||||
assert "/tmp/demo" in out
|
||||
assert "/fake/bin/bm" in out
|
||||
assert "Initialized: no" in out
|
||||
assert "transcripts" in out and "inbox" in out
|
||||
|
||||
|
||||
# ---- /bm-search ----
|
||||
|
||||
|
||||
def test_bm_search_happy_path(bm):
|
||||
session = FakeSession()
|
||||
session.stub(
|
||||
"search_notes",
|
||||
lambda args: {
|
||||
"results": [
|
||||
{"title": "Decisions", "permalink": "decisions/foo", "content": "we chose X"},
|
||||
{"title": "Plan", "permalink": "plans/p1", "preview": "next quarter"},
|
||||
]
|
||||
},
|
||||
)
|
||||
provider, actor = _ready_provider(bm, session)
|
||||
try:
|
||||
out = _handlers_by_name(bm, provider)["bm-search"]("widgets")
|
||||
assert "Decisions" in out
|
||||
assert "decisions/foo" in out
|
||||
assert "we chose X" in out
|
||||
# Args sent to BM
|
||||
call = session.calls[-1]
|
||||
assert call[0] == "search_notes"
|
||||
assert call[1]["query"] == "widgets"
|
||||
assert call[1]["project"] == "test-proj"
|
||||
assert call[1]["output_format"] == "json"
|
||||
finally:
|
||||
actor.shutdown()
|
||||
|
||||
|
||||
def test_bm_search_empty_results(bm):
|
||||
session = FakeSession(default_response={"results": []})
|
||||
provider, actor = _ready_provider(bm, session)
|
||||
try:
|
||||
out = _handlers_by_name(bm, provider)["bm-search"]("missing")
|
||||
assert "No results" in out
|
||||
assert "missing" in out
|
||||
finally:
|
||||
actor.shutdown()
|
||||
|
||||
|
||||
def test_bm_search_actor_exception_returns_plain_string(bm):
|
||||
session = FakeSession()
|
||||
|
||||
def _boom(_args):
|
||||
raise RuntimeError("MCP transport closed")
|
||||
|
||||
session.stub("search_notes", _boom)
|
||||
provider, actor = _ready_provider(bm, session)
|
||||
try:
|
||||
out = _handlers_by_name(bm, provider)["bm-search"]("anything")
|
||||
assert isinstance(out, str)
|
||||
assert out.startswith("bm-search:")
|
||||
assert "MCP transport closed" in out
|
||||
finally:
|
||||
actor.shutdown()
|
||||
|
||||
|
||||
# ---- /bm-read ----
|
||||
|
||||
|
||||
def test_bm_read_returns_text_body(bm):
|
||||
"""BM's read_note returns markdown wrapped in {"text": "..."} once our
|
||||
extractor wraps the non-JSON response. The handler should unwrap and
|
||||
return the bare markdown."""
|
||||
session = FakeSession()
|
||||
session.stub(
|
||||
"read_note",
|
||||
# FakeSession serializes whatever the handler returns; emit the JSON
|
||||
# the wrapper would produce for a markdown response.
|
||||
lambda args: {"text": "# Foo\n\nbody text"},
|
||||
)
|
||||
provider, actor = _ready_provider(bm, session)
|
||||
try:
|
||||
out = _handlers_by_name(bm, provider)["bm-read"]("foo")
|
||||
assert out == "# Foo\n\nbody text"
|
||||
assert session.calls[-1][1]["identifier"] == "foo"
|
||||
finally:
|
||||
actor.shutdown()
|
||||
|
||||
|
||||
# ---- /bm-recent ----
|
||||
|
||||
|
||||
def test_bm_recent_default_timeframe(bm):
|
||||
session = FakeSession(default_response={"results": []})
|
||||
provider, actor = _ready_provider(bm, session)
|
||||
try:
|
||||
out = _handlers_by_name(bm, provider)["bm-recent"]("")
|
||||
assert "7d" in out
|
||||
assert session.calls[-1][1]["timeframe"] == "7d"
|
||||
finally:
|
||||
actor.shutdown()
|
||||
|
||||
|
||||
def test_bm_recent_custom_timeframe(bm):
|
||||
session = FakeSession()
|
||||
session.stub(
|
||||
"recent_activity",
|
||||
lambda args: {"results": [{"title": "Recent thing", "permalink": "x/y"}]},
|
||||
)
|
||||
provider, actor = _ready_provider(bm, session)
|
||||
try:
|
||||
out = _handlers_by_name(bm, provider)["bm-recent"]("2 weeks")
|
||||
assert "2 weeks" in out
|
||||
assert "Recent thing" in out
|
||||
assert session.calls[-1][1]["timeframe"] == "2 weeks"
|
||||
finally:
|
||||
actor.shutdown()
|
||||
|
||||
|
||||
def test_bm_recent_bare_list_shape(bm):
|
||||
"""Regression: BM's `recent_activity(output_format="json")` returns a bare
|
||||
`list[dict]` (signature: `-> str | list[dict]`), not a dict-with-results.
|
||||
The handler must surface those rows, not report "no activity"."""
|
||||
session = FakeSession()
|
||||
session.stub(
|
||||
"recent_activity",
|
||||
lambda args: [
|
||||
{"title": "Edited yesterday", "permalink": "notes/a", "content": "blob"},
|
||||
{"title": "Edited 3d ago", "permalink": "notes/b"},
|
||||
],
|
||||
)
|
||||
provider, actor = _ready_provider(bm, session)
|
||||
try:
|
||||
out = _handlers_by_name(bm, provider)["bm-recent"]("")
|
||||
assert "Edited yesterday" in out
|
||||
assert "Edited 3d ago" in out
|
||||
assert "No activity" not in out
|
||||
finally:
|
||||
actor.shutdown()
|
||||
|
||||
|
||||
# ---- /bm-remember ----
|
||||
|
||||
|
||||
def test_bm_remember_derives_title_from_first_line(bm):
|
||||
captured = {}
|
||||
|
||||
def _write(args):
|
||||
captured.update(args)
|
||||
return {"permalink": "bm-remember/note-perm"}
|
||||
|
||||
session = FakeSession()
|
||||
session.stub("write_note", _write)
|
||||
provider, actor = _ready_provider(bm, session)
|
||||
provider._remember_folder = "bm-remember"
|
||||
try:
|
||||
out = _handlers_by_name(bm, provider)["bm-remember"](
|
||||
"Quarterly OKR review notes\n\nWe agreed to ship X."
|
||||
)
|
||||
assert "Saved:" in out
|
||||
assert "bm-remember/note-perm" in out
|
||||
assert captured["title"] == "Quarterly OKR review notes"
|
||||
assert captured["directory"] == "bm-remember"
|
||||
assert "manual-capture" in captured["tags"]
|
||||
finally:
|
||||
actor.shutdown()
|
||||
|
||||
|
||||
def test_bm_remember_long_first_line_truncated_to_80(bm):
|
||||
session = FakeSession(default_response={"permalink": "x"})
|
||||
provider, actor = _ready_provider(bm, session)
|
||||
try:
|
||||
long_line = "A" * 200
|
||||
_handlers_by_name(bm, provider)["bm-remember"](long_line)
|
||||
title = session.calls[-1][1]["title"]
|
||||
assert len(title) == 80
|
||||
finally:
|
||||
actor.shutdown()
|
||||
|
||||
|
||||
def test_bm_remember_uses_configured_folder(bm):
|
||||
session = FakeSession(default_response={"permalink": "x"})
|
||||
provider, actor = _ready_provider(bm, session)
|
||||
provider._remember_folder = "scratch"
|
||||
try:
|
||||
_handlers_by_name(bm, provider)["bm-remember"]("hello")
|
||||
assert session.calls[-1][1]["directory"] == "scratch"
|
||||
finally:
|
||||
actor.shutdown()
|
||||
|
||||
|
||||
# ---- /bm-project ----
|
||||
|
||||
|
||||
def test_bm_project_lists_and_marks_active(bm):
|
||||
session = FakeSession()
|
||||
session.stub(
|
||||
"list_memory_projects",
|
||||
lambda args: {
|
||||
"projects": [
|
||||
{"name": "other-proj"},
|
||||
{"name": "test-proj"},
|
||||
]
|
||||
},
|
||||
)
|
||||
provider, actor = _ready_provider(bm, session)
|
||||
try:
|
||||
out = _handlers_by_name(bm, provider)["bm-project"]("")
|
||||
assert "other-proj" in out
|
||||
assert "test-proj" in out
|
||||
# Active project line includes the marker
|
||||
active_line = next(line for line in out.splitlines() if "test-proj" in line)
|
||||
assert "active" in active_line
|
||||
finally:
|
||||
actor.shutdown()
|
||||
|
||||
|
||||
# ---- /bm-workspace ----
|
||||
|
||||
|
||||
def test_bm_workspace_local_mode_message(bm):
|
||||
provider, actor = _ready_provider(bm)
|
||||
provider._mode = "local"
|
||||
try:
|
||||
out = _handlers_by_name(bm, provider)["bm-workspace"]("")
|
||||
assert "Cloud" in out
|
||||
assert "local" in out
|
||||
finally:
|
||||
actor.shutdown()
|
||||
|
||||
|
||||
def test_bm_workspace_cloud_mode_lists(bm):
|
||||
session = FakeSession()
|
||||
session.stub(
|
||||
"list_workspaces",
|
||||
lambda args: {
|
||||
"workspaces": [
|
||||
{
|
||||
"name": "Personal",
|
||||
"workspace_type": "personal",
|
||||
"role": "owner",
|
||||
"is_default": True,
|
||||
},
|
||||
{"name": "Acme", "workspace_type": "team", "role": "member"},
|
||||
]
|
||||
},
|
||||
)
|
||||
provider, actor = _ready_provider(bm, session)
|
||||
provider._mode = "cloud"
|
||||
try:
|
||||
out = _handlers_by_name(bm, provider)["bm-workspace"]("")
|
||||
assert "Personal" in out
|
||||
assert "Acme" in out
|
||||
assert "default" in out
|
||||
finally:
|
||||
actor.shutdown()
|
||||
|
||||
|
||||
def test_bm_workspace_lazily_initializes_before_mode_check(bm, monkeypatch):
|
||||
session = FakeSession()
|
||||
session.stub(
|
||||
"list_workspaces",
|
||||
lambda args: {"workspaces": [{"name": "Personal", "workspace_type": "personal"}]},
|
||||
)
|
||||
provider = bm.BasicMemoryProvider()
|
||||
calls = []
|
||||
|
||||
def fake_initialize(*args, **kwargs):
|
||||
calls.append((args, kwargs))
|
||||
actor = make_scripted_actor(bm, session=session)
|
||||
actor.start()
|
||||
provider._actor = actor
|
||||
provider._initialized = True
|
||||
provider._mode = "cloud"
|
||||
|
||||
monkeypatch.setattr(provider, "initialize", fake_initialize)
|
||||
try:
|
||||
out = _handlers_by_name(bm, provider)["bm-workspace"]("")
|
||||
assert "Personal" in out
|
||||
assert "no workspaces to list" not in out
|
||||
assert calls
|
||||
assert session.calls[-1][0] == "list_workspaces"
|
||||
finally:
|
||||
if provider._actor is not None:
|
||||
provider._actor.shutdown()
|
||||
|
||||
|
||||
# ---- _unwrap_json_or_text helper ----
|
||||
|
||||
|
||||
def test_unwrap_passes_through_raw_string(bm):
|
||||
assert bm._unwrap_json_or_text("plain text") == "plain text"
|
||||
|
||||
|
||||
def test_unwrap_returns_inner_json_when_text_wraps_json(bm):
|
||||
outer = json.dumps({"text": json.dumps({"a": 1})})
|
||||
assert bm._unwrap_json_or_text(outer) == {"a": 1}
|
||||
|
||||
|
||||
def test_unwrap_returns_text_value_when_inner_is_markdown(bm):
|
||||
outer = json.dumps({"text": "# Heading\n\nbody"})
|
||||
assert bm._unwrap_json_or_text(outer) == "# Heading\n\nbody"
|
||||
|
||||
|
||||
def test_unwrap_returns_dict_when_top_level_json(bm):
|
||||
outer = json.dumps({"results": [1, 2]})
|
||||
assert bm._unwrap_json_or_text(outer) == {"results": [1, 2]}
|
||||
|
||||
|
||||
# ---- _remember_title ----
|
||||
|
||||
|
||||
def test_remember_title_strips_markdown_heading(bm):
|
||||
assert bm._remember_title("# Decisions\n\nbody") == "Decisions"
|
||||
|
||||
|
||||
def test_remember_title_skips_blank_lines(bm):
|
||||
assert bm._remember_title("\n\nFirst real line\nrest") == "First real line"
|
||||
|
||||
|
||||
def test_remember_title_falls_back_to_timestamp(bm):
|
||||
title = bm._remember_title(" \n\n")
|
||||
assert title.startswith("Note ")
|
||||
@@ -0,0 +1,531 @@
|
||||
"""Unit tests for the pure helpers in __init__.py."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---- _truncate ----
|
||||
|
||||
|
||||
def test_truncate_short_passes_through(bm):
|
||||
assert bm._truncate("hello", 10) == "hello"
|
||||
|
||||
|
||||
def test_truncate_long_gets_ellipsis(bm):
|
||||
out = bm._truncate("a" * 100, 10)
|
||||
assert out.endswith("...")
|
||||
assert len(out) == 10
|
||||
|
||||
|
||||
def test_truncate_non_string_coerced(bm):
|
||||
assert bm._truncate(42, 10) == "42"
|
||||
|
||||
|
||||
def test_truncate_none(bm):
|
||||
assert bm._truncate(None, 10) == ""
|
||||
|
||||
|
||||
# ---- _join_message_content ----
|
||||
|
||||
|
||||
def test_join_string_content(bm):
|
||||
assert bm._join_message_content("hello") == "hello"
|
||||
|
||||
|
||||
def test_join_list_of_dicts(bm):
|
||||
parts = [{"text": "a"}, {"text": "b"}, {"content": "c"}]
|
||||
assert bm._join_message_content(parts) == "a\nb\nc"
|
||||
|
||||
|
||||
def test_join_list_of_strings(bm):
|
||||
parts = ["a", "b"]
|
||||
assert bm._join_message_content(parts) == "a\nb"
|
||||
|
||||
|
||||
def test_join_mixed(bm):
|
||||
parts = ["a", {"text": "b"}, {"foo": "bar"}, "c"]
|
||||
assert bm._join_message_content(parts) == "a\nb\nc"
|
||||
|
||||
|
||||
def test_join_none(bm):
|
||||
assert bm._join_message_content(None) == ""
|
||||
|
||||
|
||||
# ---- _coerce_bool ----
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value,expected",
|
||||
[
|
||||
(True, True),
|
||||
(False, False),
|
||||
("true", True),
|
||||
("True", True),
|
||||
("YES", True),
|
||||
("1", True),
|
||||
("y", True),
|
||||
("false", False),
|
||||
("False", False),
|
||||
("NO", False),
|
||||
("0", False),
|
||||
("n", False),
|
||||
],
|
||||
)
|
||||
def test_coerce_bool(bm, value, expected):
|
||||
assert bm._coerce_bool(value) is expected
|
||||
|
||||
|
||||
def test_coerce_bool_non_bool_passes_through(bm):
|
||||
assert bm._coerce_bool(42) == 42
|
||||
assert bm._coerce_bool("hello") == "hello"
|
||||
|
||||
|
||||
# ---- _extract_mcp_text ----
|
||||
|
||||
|
||||
def test_extract_mcp_text_passes_json_through(bm, fake_result):
|
||||
payload = json.dumps({"permalink": "foo/bar", "title": "T"})
|
||||
out = bm._extract_mcp_text(fake_result([payload]))
|
||||
assert json.loads(out)["permalink"] == "foo/bar"
|
||||
|
||||
|
||||
def test_extract_mcp_text_wraps_markdown(bm, fake_result):
|
||||
md = "# Created note\npermalink: foo/bar"
|
||||
out = bm._extract_mcp_text(fake_result([md]))
|
||||
parsed = json.loads(out)
|
||||
assert parsed["text"] == md
|
||||
|
||||
|
||||
def test_extract_mcp_text_joins_multiple_blocks(bm, fake_result):
|
||||
out = bm._extract_mcp_text(fake_result(["a", "b"]))
|
||||
parsed = json.loads(out)
|
||||
assert parsed["text"] == "a\nb"
|
||||
|
||||
|
||||
def test_extract_mcp_text_empty(bm, fake_result):
|
||||
out = bm._extract_mcp_text(fake_result([]))
|
||||
assert json.loads(out) == {"ok": True}
|
||||
|
||||
|
||||
def test_extract_mcp_text_error(bm, fake_result):
|
||||
out = bm._extract_mcp_text(fake_result(["something broke"], is_error=True))
|
||||
assert "error" in json.loads(out)
|
||||
|
||||
|
||||
# ---- _extract_permalink ----
|
||||
|
||||
|
||||
def test_extract_permalink_from_bare_json(bm):
|
||||
text = json.dumps({"permalink": "proj/folder/note", "title": "T"})
|
||||
assert bm._extract_permalink(text, "fb") == "proj/folder/note"
|
||||
|
||||
|
||||
def test_extract_permalink_from_wrapped_json(bm):
|
||||
text = json.dumps({"text": json.dumps({"permalink": "proj/folder/note"})})
|
||||
assert bm._extract_permalink(text, "fb") == "proj/folder/note"
|
||||
|
||||
|
||||
def test_extract_permalink_from_wrapped_markdown(bm):
|
||||
md = (
|
||||
"# Created note\n"
|
||||
"project: hermes-jodys-imac\n"
|
||||
"file_path: x/y.md\n"
|
||||
"permalink: hermes-jodys-imac/folder/slug-name\n"
|
||||
"checksum: unknown\n"
|
||||
)
|
||||
text = json.dumps({"text": md})
|
||||
assert bm._extract_permalink(text, "fb") == "hermes-jodys-imac/folder/slug-name"
|
||||
|
||||
|
||||
def test_extract_permalink_from_raw_markdown(bm):
|
||||
md = "# Created note\npermalink: proj/folder/slug\n"
|
||||
# Raw, not wrapped — strategy 4 path
|
||||
assert bm._extract_permalink(md, "fb") == "proj/folder/slug"
|
||||
|
||||
|
||||
def test_extract_permalink_no_match(bm):
|
||||
assert bm._extract_permalink('{"foo":"bar"}', "fallback-title") == "fallback-title"
|
||||
|
||||
|
||||
def test_extract_permalink_empty(bm):
|
||||
assert bm._extract_permalink("", "fb") == "fb"
|
||||
|
||||
|
||||
def test_extract_permalink_invalid(bm):
|
||||
assert bm._extract_permalink("not json or markdown", "fb") == "fb"
|
||||
|
||||
|
||||
def test_extract_permalink_strips_trailing_punct(bm):
|
||||
md = "# Created note\npermalink: proj/folder/slug,"
|
||||
assert bm._extract_permalink(md, "fb") == "proj/folder/slug"
|
||||
|
||||
|
||||
# ---- _translate_args ----
|
||||
|
||||
|
||||
def test_translate_search(bm):
|
||||
tool, args = bm._translate_args("bm_search", {"query": "hi", "limit": 7}, "proj")
|
||||
assert tool == "search_notes"
|
||||
assert args == {"project": "proj", "query": "hi", "page_size": 7}
|
||||
|
||||
|
||||
def test_translate_search_no_limit(bm):
|
||||
tool, args = bm._translate_args("bm_search", {"query": "hi"}, "proj")
|
||||
assert tool == "search_notes"
|
||||
assert args == {"project": "proj", "query": "hi"}
|
||||
|
||||
|
||||
def test_translate_read(bm):
|
||||
tool, args = bm._translate_args("bm_read", {"identifier": "x/y"}, "proj")
|
||||
assert tool == "read_note"
|
||||
assert args == {"project": "proj", "identifier": "x/y"}
|
||||
|
||||
|
||||
def test_translate_read_workspace_qualified_identifier_self_routes(bm):
|
||||
tool, args = bm._translate_args(
|
||||
"bm_read",
|
||||
{"identifier": "personal/main/scratch/note"},
|
||||
"hermes-memory",
|
||||
)
|
||||
assert tool == "read_note"
|
||||
assert args == {"identifier": "personal/main/scratch/note"}
|
||||
|
||||
|
||||
def test_translate_read_org_workspace_qualified_identifier_self_routes(bm):
|
||||
tool, args = bm._translate_args(
|
||||
"bm_read",
|
||||
{"identifier": "basic-memory-7020de4e925843c68c9056c60d101d9e/main/scratch/note"},
|
||||
"hermes-memory",
|
||||
)
|
||||
assert tool == "read_note"
|
||||
assert args == {"identifier": "basic-memory-7020de4e925843c68c9056c60d101d9e/main/scratch/note"}
|
||||
|
||||
|
||||
def test_translate_write(bm):
|
||||
tool, args = bm._translate_args(
|
||||
"bm_write",
|
||||
{"title": "T", "content": "C", "folder": "F", "tags": ["a", "b"]},
|
||||
"proj",
|
||||
)
|
||||
assert tool == "write_note"
|
||||
assert args == {
|
||||
"project": "proj",
|
||||
"title": "T",
|
||||
"content": "C",
|
||||
"directory": "F",
|
||||
"tags": ["a", "b"],
|
||||
}
|
||||
|
||||
|
||||
def test_translate_write_no_tags(bm):
|
||||
tool, args = bm._translate_args(
|
||||
"bm_write",
|
||||
{"title": "T", "content": "C", "folder": "F"},
|
||||
"proj",
|
||||
)
|
||||
assert tool == "write_note"
|
||||
assert "tags" not in args
|
||||
assert args["directory"] == "F"
|
||||
|
||||
|
||||
def test_translate_edit_minimal(bm):
|
||||
tool, args = bm._translate_args(
|
||||
"bm_edit",
|
||||
{"identifier": "x", "operation": "append", "content": "more"},
|
||||
"proj",
|
||||
)
|
||||
assert tool == "edit_note"
|
||||
assert args == {
|
||||
"project": "proj",
|
||||
"identifier": "x",
|
||||
"operation": "append",
|
||||
"content": "more",
|
||||
}
|
||||
|
||||
|
||||
def test_translate_edit_find_replace(bm):
|
||||
tool, args = bm._translate_args(
|
||||
"bm_edit",
|
||||
{
|
||||
"identifier": "x",
|
||||
"operation": "find_replace",
|
||||
"content": "new",
|
||||
"find_text": "old",
|
||||
},
|
||||
"proj",
|
||||
)
|
||||
assert args["find_text"] == "old"
|
||||
|
||||
|
||||
def test_translate_edit_replace_section(bm):
|
||||
tool, args = bm._translate_args(
|
||||
"bm_edit",
|
||||
{
|
||||
"identifier": "x",
|
||||
"operation": "replace_section",
|
||||
"content": "new",
|
||||
"section": "## Notes",
|
||||
},
|
||||
"proj",
|
||||
)
|
||||
assert args["section"] == "## Notes"
|
||||
|
||||
|
||||
def test_translate_context(bm):
|
||||
tool, args = bm._translate_args("bm_context", {"url": "memory://x", "depth": 2}, "proj")
|
||||
assert tool == "build_context"
|
||||
assert args == {"project": "proj", "url": "memory://x", "depth": 2}
|
||||
|
||||
|
||||
def test_translate_context_workspace_qualified_url_self_routes(bm):
|
||||
tool, args = bm._translate_args(
|
||||
"bm_context",
|
||||
{"url": "memory://personal/main/scratch/note", "depth": 1},
|
||||
"hermes-memory",
|
||||
)
|
||||
assert tool == "build_context"
|
||||
assert args == {"url": "memory://personal/main/scratch/note", "depth": 1}
|
||||
|
||||
|
||||
def test_translate_context_org_workspace_qualified_url_self_routes(bm):
|
||||
tool, args = bm._translate_args(
|
||||
"bm_context",
|
||||
{
|
||||
"url": "memory://basic-memory-7020de4e925843c68c9056c60d101d9e/main/scratch/note",
|
||||
"depth": 1,
|
||||
},
|
||||
"hermes-memory",
|
||||
)
|
||||
assert tool == "build_context"
|
||||
assert args == {
|
||||
"url": "memory://basic-memory-7020de4e925843c68c9056c60d101d9e/main/scratch/note",
|
||||
"depth": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_translate_delete(bm):
|
||||
tool, args = bm._translate_args("bm_delete", {"identifier": "x"}, "proj")
|
||||
assert tool == "delete_note"
|
||||
assert args == {"project": "proj", "identifier": "x"}
|
||||
|
||||
|
||||
def test_translate_move(bm):
|
||||
tool, args = bm._translate_args(
|
||||
"bm_move", {"identifier": "x", "new_folder": "archive/2026"}, "proj"
|
||||
)
|
||||
assert tool == "move_note"
|
||||
assert args == {
|
||||
"project": "proj",
|
||||
"identifier": "x",
|
||||
"destination_folder": "archive/2026",
|
||||
}
|
||||
|
||||
|
||||
def test_translate_recent_defaults(bm):
|
||||
tool, args = bm._translate_args("bm_recent", {}, "proj")
|
||||
assert tool == "recent_activity"
|
||||
assert args == {"project": "proj"}
|
||||
|
||||
|
||||
def test_translate_recent_full(bm):
|
||||
tool, args = bm._translate_args(
|
||||
"bm_recent",
|
||||
{"timeframe": "2 weeks", "limit": 25, "type": "entity"},
|
||||
"proj",
|
||||
)
|
||||
assert tool == "recent_activity"
|
||||
assert args == {
|
||||
"project": "proj",
|
||||
"timeframe": "2 weeks",
|
||||
"page_size": 25,
|
||||
"type": "entity",
|
||||
}
|
||||
|
||||
|
||||
# ---- Per-call project routing ----
|
||||
|
||||
|
||||
def test_translate_uses_default_project_when_no_override(bm):
|
||||
"""Existing behavior preserved: with no project override, the configured
|
||||
default flows through."""
|
||||
_, args = bm._translate_args("bm_search", {"query": "hi"}, "default-proj")
|
||||
assert args["project"] == "default-proj"
|
||||
assert "project_id" not in args
|
||||
|
||||
|
||||
def test_translate_uses_project_name_override(bm):
|
||||
"""Agent passes project="main" → that name reaches BM, not the default."""
|
||||
_, args = bm._translate_args("bm_search", {"query": "hi", "project": "main"}, "default-proj")
|
||||
assert args["project"] == "main"
|
||||
assert "project_id" not in args
|
||||
|
||||
|
||||
def test_translate_uses_project_id_override(bm):
|
||||
"""Agent passes project_id=<uuid> → reaches BM as project_id, with no
|
||||
project name in the call (would be redundant and risk server-side
|
||||
precedence surprises)."""
|
||||
uuid = "bf2a4c1e-d77f-4b7a-9c3e-5d8a1f0e2b6d"
|
||||
_, args = bm._translate_args("bm_search", {"query": "hi", "project_id": uuid}, "default-proj")
|
||||
assert args["project_id"] == uuid
|
||||
assert "project" not in args
|
||||
|
||||
|
||||
def test_translate_project_id_wins_when_both_supplied(bm):
|
||||
"""If the agent passes both, project_id is the more specific identifier
|
||||
(UUID across workspaces) and takes precedence. Only project_id reaches BM."""
|
||||
uuid = "bf2a4c1e-d77f-4b7a-9c3e-5d8a1f0e2b6d"
|
||||
_, args = bm._translate_args(
|
||||
"bm_search",
|
||||
{"query": "hi", "project": "main", "project_id": uuid},
|
||||
"default-proj",
|
||||
)
|
||||
assert args["project_id"] == uuid
|
||||
assert "project" not in args
|
||||
|
||||
|
||||
def test_translate_routing_coerces_to_string(bm):
|
||||
"""Defensive: if a model passes a non-string identifier (e.g. an int),
|
||||
coerce rather than crash. BM accepts strings."""
|
||||
_, args = bm._translate_args("bm_search", {"query": "hi", "project_id": 12345}, "default-proj")
|
||||
assert args["project_id"] == "12345"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tool,base_args",
|
||||
[
|
||||
("bm_search", {"query": "x"}),
|
||||
("bm_read", {"identifier": "x"}),
|
||||
("bm_write", {"title": "t", "content": "c", "folder": "f"}),
|
||||
("bm_edit", {"identifier": "x", "operation": "append", "content": "c"}),
|
||||
("bm_context", {"url": "memory://x"}),
|
||||
("bm_delete", {"identifier": "x"}),
|
||||
("bm_move", {"identifier": "x", "new_folder": "f"}),
|
||||
("bm_recent", {}),
|
||||
],
|
||||
)
|
||||
def test_translate_routing_works_for_every_tool(bm, tool, base_args):
|
||||
"""Routing applies uniformly across every per-project tool. Global
|
||||
discovery tools (bm_projects, bm_workspaces) are tested separately."""
|
||||
args_with = dict(base_args, project="main")
|
||||
_, out = bm._translate_args(tool, args_with, "default-proj")
|
||||
assert out["project"] == "main"
|
||||
|
||||
args_with_id = dict(base_args, project_id="e1d3a5b8-0492-4c1f-8e7d-2a4b6c8d0e2f")
|
||||
_, out = bm._translate_args(tool, args_with_id, "default-proj")
|
||||
assert out["project_id"] == "e1d3a5b8-0492-4c1f-8e7d-2a4b6c8d0e2f"
|
||||
assert "project" not in out
|
||||
|
||||
_, out = bm._translate_args(tool, base_args, "default-proj")
|
||||
assert out["project"] == "default-proj"
|
||||
|
||||
|
||||
# ---- Global discovery tools (bm_projects, bm_workspaces) ----
|
||||
|
||||
|
||||
def test_translate_bm_projects_no_routing(bm):
|
||||
"""bm_projects is a global discovery tool — it lists across all projects
|
||||
and workspaces. _translate_args must NOT inject a default project
|
||||
(would make BM scope the listing) and MUST request JSON so the agent
|
||||
can parse identifiers out of the response."""
|
||||
tool, out = bm._translate_args("bm_projects", {}, "default-proj")
|
||||
assert tool == "list_memory_projects"
|
||||
assert "project" not in out
|
||||
assert "project_id" not in out
|
||||
assert out == {"output_format": "json"}
|
||||
|
||||
|
||||
def test_translate_bm_workspaces_no_routing(bm):
|
||||
tool, out = bm._translate_args("bm_workspaces", {}, "default-proj")
|
||||
assert tool == "list_workspaces"
|
||||
assert "project" not in out
|
||||
assert "project_id" not in out
|
||||
assert out == {"output_format": "json"}
|
||||
|
||||
|
||||
def test_translate_global_tools_ignore_project_kwargs(bm):
|
||||
"""Even if a confused caller passes project/project_id to a global tool,
|
||||
those args are dropped — BM doesn't accept them and silently scoping
|
||||
the listing would be worse than ignoring the args."""
|
||||
_, out = bm._translate_args(
|
||||
"bm_projects",
|
||||
{"project": "main", "project_id": "e1d3a5b8-0492-4c1f-8e7d-2a4b6c8d0e2f"},
|
||||
"default-proj",
|
||||
)
|
||||
assert "project" not in out
|
||||
assert "project_id" not in out
|
||||
|
||||
|
||||
# ---- TOOL_SCHEMAS routing properties ----
|
||||
|
||||
|
||||
def test_every_tool_schema_advertises_project_routing(bm):
|
||||
"""Every per-project bm_* tool must expose `project` and `project_id` so
|
||||
the agent sees them in the tool surface. Regression: forgetting to add
|
||||
routing props to a new tool would silently lock the agent into the
|
||||
active project — exactly the friction Drew's note flagged.
|
||||
|
||||
Global discovery tools (bm_projects, bm_workspaces) are excluded — they
|
||||
list across projects/workspaces and don't take routing args."""
|
||||
for schema in bm.TOOL_SCHEMAS:
|
||||
props = schema["parameters"]["properties"]
|
||||
if schema["name"] in bm._GLOBAL_TOOLS:
|
||||
assert "project" not in props, (
|
||||
f"{schema['name']} is a global tool; should not have project prop"
|
||||
)
|
||||
assert "project_id" not in props, (
|
||||
f"{schema['name']} is a global tool; should not have project_id prop"
|
||||
)
|
||||
continue
|
||||
assert "project" in props, f"{schema['name']} missing project prop"
|
||||
assert "project_id" in props, f"{schema['name']} missing project_id prop"
|
||||
# Routing is always optional — never in `required`.
|
||||
required = schema["parameters"].get("required", [])
|
||||
assert "project" not in required
|
||||
assert "project_id" not in required
|
||||
|
||||
|
||||
# ---- _default_project / _hostname ----
|
||||
|
||||
|
||||
def test_default_project_format(bm):
|
||||
p = bm._default_project()
|
||||
assert p.startswith("hermes-")
|
||||
# Hostnames are lowercased and stripped
|
||||
assert " " not in p
|
||||
|
||||
|
||||
def test_hostname_lowercased(bm, monkeypatch):
|
||||
monkeypatch.setattr(bm.socket, "gethostname", lambda: "Some.Long.Host")
|
||||
assert bm._hostname() == "some"
|
||||
|
||||
|
||||
# ---- TOOL_SCHEMAS ----
|
||||
|
||||
|
||||
def test_tool_schemas_complete(bm):
|
||||
names = {s["name"] for s in bm.TOOL_SCHEMAS}
|
||||
expected = {
|
||||
"bm_search",
|
||||
"bm_read",
|
||||
"bm_write",
|
||||
"bm_edit",
|
||||
"bm_context",
|
||||
"bm_delete",
|
||||
"bm_move",
|
||||
"bm_recent",
|
||||
"bm_projects",
|
||||
"bm_workspaces",
|
||||
}
|
||||
assert names == expected
|
||||
|
||||
|
||||
def test_tool_schemas_have_descriptions(bm):
|
||||
for s in bm.TOOL_SCHEMAS:
|
||||
assert s["description"], f"{s['name']} missing description"
|
||||
assert "parameters" in s
|
||||
assert s["parameters"]["type"] == "object"
|
||||
|
||||
|
||||
def test_hermes_to_bm_complete(bm):
|
||||
assert set(bm._HERMES_TO_BM.keys()) == {s["name"] for s in bm.TOOL_SCHEMAS}
|
||||
@@ -0,0 +1,423 @@
|
||||
"""
|
||||
Gated integration tests against a real basic-memory MCP server.
|
||||
|
||||
These tests spin up the real `bm mcp` subprocess via the production actor and
|
||||
exercise every tool through `handle_tool_call`, mirroring the production code
|
||||
path. They are skipped unless BOTH:
|
||||
|
||||
BM_INTEGRATION=1
|
||||
AND `bm` is installed AND `mcp` Python package is importable
|
||||
|
||||
A throwaway BM project is created for the test session and removed afterward,
|
||||
so these tests never touch your real BM projects.
|
||||
|
||||
Run them with:
|
||||
|
||||
BM_INTEGRATION=1 uv run --with pytest --with mcp pytest tests/test_integration.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---- Gating ----
|
||||
|
||||
_INTEGRATION_ENABLED = os.environ.get("BM_INTEGRATION") == "1"
|
||||
_BM_BIN = shutil.which("bm") or (
|
||||
os.path.expanduser("~/.local/bin/bm")
|
||||
if os.path.isfile(os.path.expanduser("~/.local/bin/bm"))
|
||||
else None
|
||||
)
|
||||
|
||||
try:
|
||||
import mcp # noqa: F401
|
||||
|
||||
_MCP_OK = True
|
||||
except Exception:
|
||||
_MCP_OK = False
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.skipif(
|
||||
not _INTEGRATION_ENABLED,
|
||||
reason="set BM_INTEGRATION=1 to run integration tests",
|
||||
),
|
||||
pytest.mark.skipif(_BM_BIN is None, reason="bm CLI not on PATH"),
|
||||
pytest.mark.skipif(not _MCP_OK, reason="mcp Python package not installed"),
|
||||
]
|
||||
|
||||
|
||||
# ---- Session-scoped BM project ----
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def temp_bm_project():
|
||||
"""Create a throwaway BM project for the session; remove when done."""
|
||||
project_name = f"hermes-bm-test-{uuid.uuid4().hex[:8]}"
|
||||
project_dir = tempfile.mkdtemp(prefix=f"{project_name}-")
|
||||
# Register
|
||||
subprocess.run(
|
||||
[_BM_BIN, "project", "add", project_name, project_dir],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
timeout=20,
|
||||
)
|
||||
yield project_name, project_dir
|
||||
# Tear down
|
||||
subprocess.run(
|
||||
[_BM_BIN, "project", "remove", project_name],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
timeout=20,
|
||||
)
|
||||
shutil.rmtree(project_dir, ignore_errors=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def provider(bm, temp_bm_project, tmp_path):
|
||||
"""Initialized provider pointing at the temp project."""
|
||||
project_name, project_dir = temp_bm_project
|
||||
# Pre-write a config file in this test's hermes_home so initialize picks it up
|
||||
cfg = {
|
||||
"mode": "local",
|
||||
"project": project_name,
|
||||
"project_path": project_dir,
|
||||
"capture_per_turn": True,
|
||||
"capture_session_end": True,
|
||||
"capture_folder": "test-sessions",
|
||||
}
|
||||
(tmp_path / "basic-memory.json").write_text(json.dumps(cfg))
|
||||
|
||||
p = bm.BasicMemoryProvider()
|
||||
p.initialize(
|
||||
session_id=f"integration-{uuid.uuid4().hex[:6]}",
|
||||
hermes_home=str(tmp_path),
|
||||
platform="cli",
|
||||
)
|
||||
if not p._initialized:
|
||||
pytest.fail("Provider failed to initialize against the real bm MCP server")
|
||||
yield p
|
||||
p.shutdown()
|
||||
|
||||
|
||||
def _parse_tool_result(raw):
|
||||
try:
|
||||
d = json.loads(raw)
|
||||
except Exception:
|
||||
return None
|
||||
return d
|
||||
|
||||
|
||||
# ---- Actor smoke ----
|
||||
|
||||
|
||||
def test_actor_starts_and_lists_expected_tools(provider, bm):
|
||||
tools = {t["name"] for t in provider._actor.list_tools()}
|
||||
expected = set(bm._HERMES_TO_BM.values())
|
||||
missing = expected - tools
|
||||
assert not missing, f"BM MCP server missing tools we depend on: {missing}"
|
||||
|
||||
|
||||
# ---- Tool surface ----
|
||||
|
||||
|
||||
def test_bm_write_returns_full_permalink(provider, bm):
|
||||
title = f"Integration Write Test {uuid.uuid4().hex[:6]}"
|
||||
raw = provider.handle_tool_call(
|
||||
"bm_write",
|
||||
{
|
||||
"title": title,
|
||||
"content": f"# {title}\n\nbody.\n",
|
||||
"folder": "tests",
|
||||
"tags": ["integration"],
|
||||
},
|
||||
)
|
||||
permalink = bm._extract_permalink(raw, "")
|
||||
assert permalink, f"no permalink extracted from: {raw[:300]}"
|
||||
# BM permalinks include the project prefix
|
||||
assert permalink.split("/")[0] == provider._project, (
|
||||
f"permalink should start with project name: {permalink}"
|
||||
)
|
||||
|
||||
|
||||
def test_bm_read_round_trips_a_written_note(provider, bm):
|
||||
title = f"Read RT {uuid.uuid4().hex[:6]}"
|
||||
body = f"# {title}\n\nMARKER-{uuid.uuid4().hex}\n"
|
||||
raw = provider.handle_tool_call(
|
||||
"bm_write",
|
||||
{
|
||||
"title": title,
|
||||
"content": body,
|
||||
"folder": "tests",
|
||||
},
|
||||
)
|
||||
permalink = bm._extract_permalink(raw, "")
|
||||
raw = provider.handle_tool_call("bm_read", {"identifier": permalink})
|
||||
d = _parse_tool_result(raw)
|
||||
text = (d or {}).get("text") or json.dumps(d or {})
|
||||
assert title in text
|
||||
|
||||
|
||||
def test_bm_edit_append_lands_in_note(provider, bm):
|
||||
title = f"Append Test {uuid.uuid4().hex[:6]}"
|
||||
raw = provider.handle_tool_call(
|
||||
"bm_write",
|
||||
{
|
||||
"title": title,
|
||||
"content": f"# {title}\nseed\n",
|
||||
"folder": "tests",
|
||||
},
|
||||
)
|
||||
permalink = bm._extract_permalink(raw, "")
|
||||
marker = f"APPEND-MARKER-{uuid.uuid4().hex}"
|
||||
provider.handle_tool_call(
|
||||
"bm_edit",
|
||||
{
|
||||
"identifier": permalink,
|
||||
"operation": "append",
|
||||
"content": f"\n{marker}\n",
|
||||
},
|
||||
)
|
||||
raw = provider.handle_tool_call("bm_read", {"identifier": permalink})
|
||||
d = _parse_tool_result(raw)
|
||||
text = (d or {}).get("text") or json.dumps(d or {})
|
||||
assert marker in text
|
||||
|
||||
|
||||
def test_bm_edit_replace_section_swaps_content(provider, bm):
|
||||
title = f"ReplaceSection {uuid.uuid4().hex[:6]}"
|
||||
body = f"# {title}\n\n## Notes\noriginal-body\n"
|
||||
raw = provider.handle_tool_call(
|
||||
"bm_write",
|
||||
{
|
||||
"title": title,
|
||||
"content": body,
|
||||
"folder": "tests",
|
||||
},
|
||||
)
|
||||
permalink = bm._extract_permalink(raw, "")
|
||||
new_marker = f"REPLACED-{uuid.uuid4().hex}"
|
||||
provider.handle_tool_call(
|
||||
"bm_edit",
|
||||
{
|
||||
"identifier": permalink,
|
||||
"operation": "replace_section",
|
||||
"section": "## Notes",
|
||||
"content": new_marker,
|
||||
},
|
||||
)
|
||||
raw = provider.handle_tool_call("bm_read", {"identifier": permalink})
|
||||
d = _parse_tool_result(raw)
|
||||
text = (d or {}).get("text") or json.dumps(d or {})
|
||||
assert new_marker in text
|
||||
assert "original-body" not in text
|
||||
|
||||
|
||||
def test_bm_search_finds_a_freshly_written_note(provider, bm):
|
||||
unique = f"SEARCH-MARKER-{uuid.uuid4().hex}"
|
||||
title = f"Search Test {unique}"
|
||||
provider.handle_tool_call(
|
||||
"bm_write",
|
||||
{
|
||||
"title": title,
|
||||
"content": f"# {title}\nbody.\n",
|
||||
"folder": "tests",
|
||||
},
|
||||
)
|
||||
raw = provider.handle_tool_call("bm_search", {"query": unique, "limit": 5})
|
||||
d = _parse_tool_result(raw)
|
||||
text = (d or {}).get("text") or json.dumps(d or {})
|
||||
assert unique in text or title in text
|
||||
|
||||
|
||||
def test_bm_context_returns_results(provider, bm):
|
||||
title = f"Context Test {uuid.uuid4().hex[:6]}"
|
||||
raw = provider.handle_tool_call(
|
||||
"bm_write",
|
||||
{
|
||||
"title": title,
|
||||
"content": f"# {title}\n",
|
||||
"folder": "tests",
|
||||
},
|
||||
)
|
||||
permalink = bm._extract_permalink(raw, "")
|
||||
raw = provider.handle_tool_call(
|
||||
"bm_context",
|
||||
{
|
||||
"url": f"memory://{permalink}",
|
||||
"depth": 1,
|
||||
},
|
||||
)
|
||||
d = _parse_tool_result(raw)
|
||||
assert d is not None
|
||||
# build_context returns a JSON dict with `results` (and other fields)
|
||||
text_blob = json.dumps(d)
|
||||
assert "results" in text_blob
|
||||
|
||||
|
||||
def test_bm_move_relocates_note(provider, bm):
|
||||
"""
|
||||
BM permalinks are stable IDs that don't change on move — only the
|
||||
file_path moves. So we verify by:
|
||||
1. The move response itself reports the new destination
|
||||
2. Reading by the original permalink still succeeds (note wasn't lost)
|
||||
"""
|
||||
title = f"Move Test {uuid.uuid4().hex[:6]}"
|
||||
raw = provider.handle_tool_call(
|
||||
"bm_write",
|
||||
{
|
||||
"title": title,
|
||||
"content": f"# {title}\n",
|
||||
"folder": "tests",
|
||||
},
|
||||
)
|
||||
permalink = bm._extract_permalink(raw, "")
|
||||
assert permalink, "expected a permalink from bm_write"
|
||||
|
||||
raw = provider.handle_tool_call(
|
||||
"bm_move",
|
||||
{
|
||||
"identifier": permalink,
|
||||
"new_folder": "tests/archive",
|
||||
},
|
||||
)
|
||||
d = _parse_tool_result(raw)
|
||||
move_text = (d or {}).get("text") or json.dumps(d or {})
|
||||
# Move response text reports both the old and new locations
|
||||
assert "moved successfully" in move_text.lower() or "moved" in move_text.lower(), (
|
||||
f"move response missing success indicator: {move_text[:200]}"
|
||||
)
|
||||
assert "tests/archive" in move_text, f"move response missing new folder: {move_text[:200]}"
|
||||
|
||||
# Permalink is stable — reading by it should still work
|
||||
raw = provider.handle_tool_call("bm_read", {"identifier": permalink})
|
||||
d = _parse_tool_result(raw)
|
||||
read_text = (d or {}).get("text") or json.dumps(d or {})
|
||||
assert title in read_text, "note should still be readable after move"
|
||||
|
||||
|
||||
def test_bm_delete_removes_note(provider, bm):
|
||||
title = f"Delete Test {uuid.uuid4().hex[:6]}"
|
||||
raw = provider.handle_tool_call(
|
||||
"bm_write",
|
||||
{
|
||||
"title": title,
|
||||
"content": f"# {title}\n",
|
||||
"folder": "tests",
|
||||
},
|
||||
)
|
||||
permalink = bm._extract_permalink(raw, "")
|
||||
provider.handle_tool_call("bm_delete", {"identifier": permalink})
|
||||
# Read should now indicate "not found"
|
||||
raw = provider.handle_tool_call("bm_read", {"identifier": permalink})
|
||||
d = _parse_tool_result(raw)
|
||||
text = (d or {}).get("text") or json.dumps(d or {})
|
||||
assert "not found" in text.lower() or "no notes found" in text.lower(), (
|
||||
f"expected a 'not found' indication, got: {text[:200]}"
|
||||
)
|
||||
|
||||
|
||||
# ---- Capture pipeline ----
|
||||
|
||||
|
||||
def test_sync_turn_writes_then_appends_to_same_session_note(provider, bm):
|
||||
# First turn — creates the session note
|
||||
provider.sync_turn("integration turn-1 user", "integration turn-1 assistant")
|
||||
if provider._sync_thread:
|
||||
provider._sync_thread.join(timeout=20.0)
|
||||
sid_1 = provider._session_note_id
|
||||
assert sid_1, "first sync_turn should set _session_note_id"
|
||||
assert sid_1.startswith(provider._project + "/"), (
|
||||
f"session_note_id should include project prefix, got: {sid_1}"
|
||||
)
|
||||
|
||||
# Second turn — should append to the same note
|
||||
marker = f"TURN-2-MARKER-{uuid.uuid4().hex}"
|
||||
provider.sync_turn("integration turn-2 user", marker)
|
||||
if provider._sync_thread:
|
||||
provider._sync_thread.join(timeout=20.0)
|
||||
sid_2 = provider._session_note_id
|
||||
assert sid_2 == sid_1, "session_note_id should NOT change between turns"
|
||||
|
||||
# Verify both turn markers are present in the persisted note
|
||||
raw = provider.handle_tool_call("bm_read", {"identifier": sid_1})
|
||||
d = _parse_tool_result(raw)
|
||||
text = (d or {}).get("text") or json.dumps(d or {})
|
||||
assert "integration turn-1 user" in text
|
||||
assert marker in text
|
||||
|
||||
|
||||
def test_on_session_end_writes_summary_with_relations(provider, bm):
|
||||
# Seed a session note via sync_turn
|
||||
provider.sync_turn("first message", "first reply")
|
||||
if provider._sync_thread:
|
||||
provider._sync_thread.join(timeout=20.0)
|
||||
sid = provider._session_note_id
|
||||
assert sid
|
||||
|
||||
provider.on_session_end(
|
||||
[
|
||||
{"role": "user", "content": "first message"},
|
||||
{"role": "assistant", "content": "first reply"},
|
||||
]
|
||||
)
|
||||
|
||||
# Search for the summary
|
||||
raw = provider.handle_tool_call(
|
||||
"bm_search",
|
||||
{
|
||||
"query": "Hermes Session Summary",
|
||||
"limit": 5,
|
||||
},
|
||||
)
|
||||
d = _parse_tool_result(raw)
|
||||
text = (d or {}).get("text") or json.dumps(d or {})
|
||||
assert "Hermes Session Summary" in text
|
||||
|
||||
|
||||
def test_prefetch_against_real_bm(provider, bm):
|
||||
# Seed a recognizable note
|
||||
unique = f"PREFETCH-MARKER-{uuid.uuid4().hex}"
|
||||
provider.handle_tool_call(
|
||||
"bm_write",
|
||||
{
|
||||
"title": f"Prefetch Test {unique}",
|
||||
"content": f"# Prefetch Test\n{unique}\n",
|
||||
"folder": "tests",
|
||||
},
|
||||
)
|
||||
|
||||
# BM's FTS index is updated synchronously inside the write_note API
|
||||
# path (knowledge_router.py:272), so this loop is really only smoothing
|
||||
# over the round-trip cost of a few RPCs on a slow runner. prefetch
|
||||
# explicitly requests search_type="text" so we don't get pulled onto
|
||||
# BM's hybrid path, where vector indexing is async and would race the
|
||||
# search.
|
||||
budget_secs = 10.0
|
||||
deadline = time.monotonic() + budget_secs
|
||||
out = ""
|
||||
attempts = 0
|
||||
while time.monotonic() < deadline:
|
||||
attempts += 1
|
||||
out = provider.prefetch(unique)
|
||||
if out:
|
||||
break
|
||||
time.sleep(0.25)
|
||||
|
||||
assert out, (
|
||||
f"prefetch returned nothing after {attempts} attempt(s) over "
|
||||
f"{budget_secs}s; provider._failure_count={provider._failure_count}, "
|
||||
f"circuit_open={provider._is_circuit_open()}. "
|
||||
f"Either BM didn't index the note in time or prefetch's actor.call "
|
||||
f"is timing out internally."
|
||||
)
|
||||
assert "Basic Memory Recall" in out
|
||||
assert unique in out or "Prefetch Test" in out
|
||||
@@ -0,0 +1,296 @@
|
||||
"""Tests for prefetch / queue_prefetch / _format_prefetch."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _initialized_provider(bm):
|
||||
p = bm.BasicMemoryProvider()
|
||||
p._initialized = True
|
||||
p._project = "test-proj"
|
||||
p._actor = MagicMock()
|
||||
return p
|
||||
|
||||
|
||||
# ---- prefetch ----
|
||||
|
||||
|
||||
def test_prefetch_returns_cached_value_drained(bm):
|
||||
p = _initialized_provider(bm)
|
||||
p._pending_prefetch = "## cached recall"
|
||||
out = p.prefetch("any query")
|
||||
assert out == "## cached recall"
|
||||
# Cache must be drained so the next prefetch doesn't return stale results
|
||||
assert p._pending_prefetch == ""
|
||||
p._actor.call.assert_not_called()
|
||||
|
||||
|
||||
def test_prefetch_calls_search_when_cache_empty(bm):
|
||||
p = _initialized_provider(bm)
|
||||
p._actor.call.return_value = json.dumps(
|
||||
{"results": [{"title": "T", "permalink": "p/t", "content": "c"}]}
|
||||
)
|
||||
out = p.prefetch("hello world")
|
||||
assert "## Basic Memory Recall" in out
|
||||
assert "**T**" in out
|
||||
p._actor.call.assert_called_once()
|
||||
bm_tool, bm_args = p._actor.call.call_args[0][:2]
|
||||
assert bm_tool == "search_notes"
|
||||
assert bm_args["query"] == "hello world"
|
||||
assert bm_args["page_size"] == 5
|
||||
assert bm_args["output_format"] == "json"
|
||||
# Pin search_type=text so BM doesn't fall into the hybrid+async-vector
|
||||
# path on the prefetch hot path. See prefetch() comment for rationale.
|
||||
assert bm_args["search_type"] == "text"
|
||||
|
||||
|
||||
def test_prefetch_returns_empty_when_uninitialized(bm):
|
||||
p = bm.BasicMemoryProvider()
|
||||
assert p.prefetch("x") == ""
|
||||
|
||||
|
||||
def test_prefetch_returns_empty_when_circuit_open(bm):
|
||||
p = _initialized_provider(bm)
|
||||
p._failure_pause_until = time.monotonic() + 60.0
|
||||
assert p.prefetch("x") == ""
|
||||
p._actor.call.assert_not_called()
|
||||
|
||||
|
||||
def test_prefetch_records_failure_on_actor_error(bm):
|
||||
p = _initialized_provider(bm)
|
||||
p._actor.call.side_effect = RuntimeError("boom")
|
||||
assert p.prefetch("x") == ""
|
||||
assert p._failure_count == 1
|
||||
|
||||
|
||||
def test_prefetch_returns_empty_for_empty_results(bm):
|
||||
p = _initialized_provider(bm)
|
||||
p._actor.call.return_value = json.dumps({"results": []})
|
||||
assert p.prefetch("x") == ""
|
||||
|
||||
|
||||
# ---- queue_prefetch ----
|
||||
|
||||
|
||||
def test_queue_prefetch_fills_cache_in_background(bm):
|
||||
p = _initialized_provider(bm)
|
||||
p._actor.call.return_value = json.dumps(
|
||||
{"results": [{"title": "Bg", "permalink": "p/bg", "content": "c"}]}
|
||||
)
|
||||
p.queue_prefetch("user typed something")
|
||||
# Wait for the daemon thread to finish
|
||||
if p._prefetch_thread:
|
||||
p._prefetch_thread.join(timeout=5.0)
|
||||
# Cache should now have the formatted result
|
||||
assert "**Bg**" in p._pending_prefetch
|
||||
# And subsequent prefetch returns it without making another call
|
||||
p._actor.call.reset_mock()
|
||||
out = p.prefetch("anything")
|
||||
assert "**Bg**" in out
|
||||
p._actor.call.assert_not_called()
|
||||
|
||||
|
||||
def test_queue_prefetch_uses_longer_timeout_than_sync_prefetch(bm):
|
||||
"""queue_prefetch runs in background, so it can afford a longer timeout."""
|
||||
p = _initialized_provider(bm)
|
||||
p._actor.call.return_value = json.dumps({"results": []})
|
||||
p.queue_prefetch("q")
|
||||
if p._prefetch_thread:
|
||||
p._prefetch_thread.join(timeout=5.0)
|
||||
timeout = p._actor.call.call_args.kwargs.get("timeout") or p._actor.call.call_args[1].get(
|
||||
"timeout"
|
||||
)
|
||||
# Background prefetch is more patient than the foreground 3.0s
|
||||
assert timeout is not None and timeout > 3.0
|
||||
|
||||
|
||||
def test_queue_prefetch_skipped_when_thread_in_flight(bm):
|
||||
p = _initialized_provider(bm)
|
||||
|
||||
# Simulate an already-running prefetch thread
|
||||
class _StillAlive:
|
||||
def is_alive(self):
|
||||
return True
|
||||
|
||||
p._prefetch_thread = _StillAlive() # type: ignore[assignment]
|
||||
p.queue_prefetch("q")
|
||||
p._actor.call.assert_not_called()
|
||||
|
||||
|
||||
def test_queue_prefetch_skipped_when_circuit_open(bm):
|
||||
p = _initialized_provider(bm)
|
||||
p._failure_pause_until = time.monotonic() + 60.0
|
||||
p.queue_prefetch("q")
|
||||
p._actor.call.assert_not_called()
|
||||
|
||||
|
||||
def test_queue_prefetch_skipped_when_uninitialized(bm):
|
||||
p = bm.BasicMemoryProvider()
|
||||
p._actor = MagicMock()
|
||||
p.queue_prefetch("q")
|
||||
p._actor.call.assert_not_called()
|
||||
|
||||
|
||||
def test_queue_prefetch_records_failure_on_bg_error(bm):
|
||||
p = _initialized_provider(bm)
|
||||
p._actor.call.side_effect = RuntimeError("backend down")
|
||||
p.queue_prefetch("q")
|
||||
if p._prefetch_thread:
|
||||
p._prefetch_thread.join(timeout=5.0)
|
||||
assert p._failure_count >= 1
|
||||
|
||||
|
||||
# ---- _format_prefetch ----
|
||||
|
||||
|
||||
def _format(bm, payload):
|
||||
"""Helper: call _format_prefetch on a fresh provider."""
|
||||
return bm.BasicMemoryProvider()._format_prefetch(payload)
|
||||
|
||||
|
||||
def test_format_prefetch_with_results(bm):
|
||||
payload = json.dumps(
|
||||
{
|
||||
"results": [
|
||||
{"title": "A", "permalink": "p/a", "content": "first line"},
|
||||
{"title": "B", "permalink": "p/b", "content": "second line"},
|
||||
]
|
||||
}
|
||||
)
|
||||
out = _format(bm, payload)
|
||||
assert "## Basic Memory Recall" in out
|
||||
assert "**A**" in out and "**B**" in out
|
||||
assert "p/a" in out and "p/b" in out
|
||||
|
||||
|
||||
def test_format_prefetch_caps_at_5_entries(bm):
|
||||
results = [{"title": f"T{i}", "permalink": f"p/{i}", "content": "x"} for i in range(20)]
|
||||
payload = json.dumps({"results": results})
|
||||
out = _format(bm, payload)
|
||||
# Five lines + one heading = 6 lines max
|
||||
assert out.count("\n- **") == 5
|
||||
|
||||
|
||||
def test_format_prefetch_caps_preview_length(bm):
|
||||
payload = json.dumps(
|
||||
{
|
||||
"results": [
|
||||
{"title": "T", "permalink": "p/t", "content": "x" * 5000},
|
||||
]
|
||||
}
|
||||
)
|
||||
out = _format(bm, payload)
|
||||
# Each result line includes the preview, capped to 200 chars
|
||||
line = [l for l in out.split("\n") if l.startswith("- ")][0]
|
||||
# Some boilerplate around the preview, but the long preview is capped
|
||||
assert len(line) < 400
|
||||
|
||||
|
||||
def test_format_prefetch_collapses_whitespace(bm):
|
||||
payload = json.dumps(
|
||||
{
|
||||
"results": [
|
||||
{"title": "T", "permalink": "p/t", "content": "first\n\n second\tthird"},
|
||||
]
|
||||
}
|
||||
)
|
||||
out = _format(bm, payload)
|
||||
assert "first second third" in out
|
||||
|
||||
|
||||
def test_format_prefetch_falls_back_to_preview_field(bm):
|
||||
"""BM may use 'preview' instead of 'content' in some response shapes."""
|
||||
payload = json.dumps(
|
||||
{
|
||||
"results": [
|
||||
{"title": "T", "permalink": "p/t", "preview": "preview-only"},
|
||||
]
|
||||
}
|
||||
)
|
||||
out = _format(bm, payload)
|
||||
assert "preview-only" in out
|
||||
|
||||
|
||||
def test_format_prefetch_handles_non_string_content(bm):
|
||||
"""Defensive: BM could conceivably return non-string content fields."""
|
||||
payload = json.dumps(
|
||||
{
|
||||
"results": [
|
||||
{"title": "T", "permalink": "p/t", "content": 12345},
|
||||
]
|
||||
}
|
||||
)
|
||||
# Must not raise
|
||||
out = _format(bm, payload)
|
||||
assert "12345" in out
|
||||
|
||||
|
||||
def test_format_prefetch_handles_missing_title_permalink(bm):
|
||||
payload = json.dumps(
|
||||
{
|
||||
"results": [
|
||||
{"content": "orphan note"},
|
||||
]
|
||||
}
|
||||
)
|
||||
out = _format(bm, payload)
|
||||
assert "(untitled)" in out
|
||||
assert "orphan note" in out
|
||||
|
||||
|
||||
def test_format_prefetch_skips_non_dict_entries(bm):
|
||||
payload = json.dumps(
|
||||
{
|
||||
"results": [
|
||||
"not-a-dict",
|
||||
{"title": "Real", "permalink": "p/r", "content": "x"},
|
||||
]
|
||||
}
|
||||
)
|
||||
out = _format(bm, payload)
|
||||
assert "Real" in out
|
||||
assert "not-a-dict" not in out
|
||||
|
||||
|
||||
def test_format_prefetch_with_text_wrapped_results(bm):
|
||||
"""BM text-format responses arrive wrapped as {"text": "..."} by _extract_mcp_text."""
|
||||
inner = json.dumps({"results": [{"title": "X", "permalink": "p/x", "content": "c"}]})
|
||||
payload = json.dumps({"text": inner})
|
||||
out = _format(bm, payload)
|
||||
assert "**X**" in out
|
||||
|
||||
|
||||
def test_format_prefetch_empty(bm):
|
||||
assert _format(bm, json.dumps({"results": []})) == ""
|
||||
|
||||
|
||||
def test_format_prefetch_no_results_key(bm):
|
||||
assert _format(bm, json.dumps({"foo": "bar"})) == ""
|
||||
|
||||
|
||||
def test_format_prefetch_malformed(bm):
|
||||
assert _format(bm, "not-json") == ""
|
||||
|
||||
|
||||
def test_format_prefetch_handles_extra_unknown_fields(bm):
|
||||
"""Forward-compatibility: unknown fields shouldn't break formatting."""
|
||||
payload = json.dumps(
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"title": "T",
|
||||
"permalink": "p/t",
|
||||
"content": "c",
|
||||
"future_field_42": {"nested": "value"},
|
||||
"score": 0.9,
|
||||
},
|
||||
]
|
||||
}
|
||||
)
|
||||
out = _format(bm, payload)
|
||||
assert "**T**" in out
|
||||
@@ -0,0 +1,637 @@
|
||||
"""Tests for BasicMemoryProvider with the MCP actor mocked out."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
def test_is_available_no_mcp(bm, monkeypatch):
|
||||
monkeypatch.setattr(bm, "_MCP_AVAILABLE", False)
|
||||
p = bm.BasicMemoryProvider()
|
||||
assert p.is_available() is False
|
||||
|
||||
|
||||
def test_is_available_no_bm_no_uv(bm, monkeypatch):
|
||||
"""No bm AND no uv → can't install, can't operate → unavailable."""
|
||||
monkeypatch.setattr(bm, "_MCP_AVAILABLE", True)
|
||||
monkeypatch.setattr(bm, "_bm_binary_path", lambda: None)
|
||||
monkeypatch.setattr(bm, "_uv_binary_path", lambda: None)
|
||||
p = bm.BasicMemoryProvider()
|
||||
assert p.is_available() is False
|
||||
|
||||
|
||||
def test_is_available_bm_present(bm, monkeypatch):
|
||||
"""bm already installed → available regardless of uv."""
|
||||
monkeypatch.setattr(bm, "_MCP_AVAILABLE", True)
|
||||
monkeypatch.setattr(bm, "_bm_binary_path", lambda: "/fake/bm")
|
||||
monkeypatch.setattr(bm, "_uv_binary_path", lambda: None)
|
||||
p = bm.BasicMemoryProvider()
|
||||
assert p.is_available() is True
|
||||
|
||||
|
||||
def test_is_available_bm_missing_but_uv_present(bm, monkeypatch):
|
||||
"""bm missing but uv available → we can install bm at init time → available."""
|
||||
monkeypatch.setattr(bm, "_MCP_AVAILABLE", True)
|
||||
monkeypatch.setattr(bm, "_bm_binary_path", lambda: None)
|
||||
monkeypatch.setattr(bm, "_uv_binary_path", lambda: "/fake/uv")
|
||||
p = bm.BasicMemoryProvider()
|
||||
assert p.is_available() is True
|
||||
|
||||
|
||||
def test_name(bm):
|
||||
assert bm.BasicMemoryProvider().name == "basic-memory"
|
||||
assert bm.BasicMemoryProvider().name == bm.PROVIDER_NAME
|
||||
|
||||
|
||||
def test_get_tool_schemas_unconditional(bm):
|
||||
"""
|
||||
Regression: Hermes captures the schema list at *register* time, before
|
||||
`initialize()` runs. If get_tool_schemas() returns [] when uninitialized,
|
||||
Hermes builds _tool_to_provider with no entries for us and every
|
||||
subsequent bm_* invocation returns "Unknown tool: bm_*" forever.
|
||||
Schemas are static — return them unconditionally.
|
||||
"""
|
||||
# Fresh provider, never initialized, should still expose all 10 schemas
|
||||
p = bm.BasicMemoryProvider()
|
||||
assert p._initialized is False
|
||||
schemas = p.get_tool_schemas()
|
||||
assert len(schemas) == 10
|
||||
names = {s["name"] for s in schemas}
|
||||
assert names == {
|
||||
"bm_search",
|
||||
"bm_read",
|
||||
"bm_write",
|
||||
"bm_edit",
|
||||
"bm_context",
|
||||
"bm_delete",
|
||||
"bm_move",
|
||||
"bm_recent",
|
||||
"bm_projects",
|
||||
"bm_workspaces",
|
||||
}
|
||||
|
||||
# Initialized provider also returns 10 (idempotent)
|
||||
p._initialized = True
|
||||
assert len(p.get_tool_schemas()) == 10
|
||||
|
||||
|
||||
def test_get_tool_schemas_returns_independent_copies(bm):
|
||||
"""Mutating the returned list shouldn't affect the next call."""
|
||||
p = bm.BasicMemoryProvider()
|
||||
schemas = p.get_tool_schemas()
|
||||
schemas.clear()
|
||||
assert len(p.get_tool_schemas()) == 10
|
||||
|
||||
|
||||
def test_handle_tool_call_uninitialized(bm):
|
||||
p = bm.BasicMemoryProvider()
|
||||
out = json.loads(p.handle_tool_call("bm_search", {"query": "x"}))
|
||||
assert "error" in out
|
||||
|
||||
|
||||
def test_handle_tool_call_unknown_tool(bm):
|
||||
p = bm.BasicMemoryProvider()
|
||||
p._initialized = True
|
||||
p._actor = MagicMock()
|
||||
out = json.loads(p.handle_tool_call("bm_bogus", {}))
|
||||
assert "error" in out
|
||||
|
||||
|
||||
def test_handle_tool_call_dispatches(bm):
|
||||
p = bm.BasicMemoryProvider()
|
||||
p._initialized = True
|
||||
p._project = "proj"
|
||||
actor = MagicMock()
|
||||
actor.call.return_value = json.dumps({"results": []})
|
||||
p._actor = actor
|
||||
|
||||
out = p.handle_tool_call("bm_search", {"query": "hi", "limit": 3})
|
||||
actor.call.assert_called_once()
|
||||
bm_tool, bm_args = actor.call.call_args[0][:2]
|
||||
assert bm_tool == "search_notes"
|
||||
assert bm_args == {"project": "proj", "query": "hi", "page_size": 3}
|
||||
|
||||
|
||||
def test_handle_tool_call_missing_arg(bm):
|
||||
p = bm.BasicMemoryProvider()
|
||||
p._initialized = True
|
||||
p._actor = MagicMock()
|
||||
out = json.loads(p.handle_tool_call("bm_write", {"title": "x"}))
|
||||
assert "error" in out
|
||||
|
||||
|
||||
def test_handle_tool_call_actor_failure(bm):
|
||||
p = bm.BasicMemoryProvider()
|
||||
p._initialized = True
|
||||
p._project = "proj"
|
||||
actor = MagicMock()
|
||||
actor.call.side_effect = RuntimeError("boom")
|
||||
p._actor = actor
|
||||
out = json.loads(p.handle_tool_call("bm_search", {"query": "x"}))
|
||||
assert "error" in out
|
||||
assert p._failure_count == 1
|
||||
|
||||
|
||||
def test_circuit_breaker_opens_after_5_failures(bm, monkeypatch):
|
||||
p = bm.BasicMemoryProvider()
|
||||
p._initialized = True
|
||||
p._project = "proj"
|
||||
actor = MagicMock()
|
||||
actor.call.side_effect = RuntimeError("boom")
|
||||
p._actor = actor
|
||||
|
||||
for _ in range(5):
|
||||
p.handle_tool_call("bm_search", {"query": "x"})
|
||||
|
||||
assert p._failure_pause_until > 0
|
||||
assert p._is_circuit_open() is True
|
||||
|
||||
|
||||
def test_circuit_breaker_resets_after_pause(bm, monkeypatch):
|
||||
p = bm.BasicMemoryProvider()
|
||||
p._failure_count = 5
|
||||
p._failure_pause_until = 1.0 # already in the past
|
||||
monkeypatch.setattr(bm.time, "monotonic", lambda: 9999.0)
|
||||
assert p._is_circuit_open() is False
|
||||
assert p._failure_count == 0
|
||||
|
||||
|
||||
def test_session_note_title_with_session_id(bm):
|
||||
from datetime import datetime, timezone
|
||||
|
||||
p = bm.BasicMemoryProvider()
|
||||
p._session_started_at = datetime(2026, 5, 10, 13, 5, tzinfo=timezone.utc)
|
||||
p._session_id = "20260510_080249_571920"
|
||||
title = p._session_note_title()
|
||||
# Date appears once; trailing random component is the disambiguator
|
||||
assert title == "Hermes Session 2026-05-10 1305 571920"
|
||||
|
||||
|
||||
def test_session_note_title_no_session_id(bm):
|
||||
from datetime import datetime, timezone
|
||||
|
||||
p = bm.BasicMemoryProvider()
|
||||
p._session_started_at = datetime(2026, 5, 10, 13, 5, 42, tzinfo=timezone.utc)
|
||||
p._session_id = ""
|
||||
title = p._session_note_title()
|
||||
# Falls back to seconds for disambiguation
|
||||
assert title == "Hermes Session 2026-05-10 1305 42"
|
||||
|
||||
|
||||
def test_session_note_title_short_session_id(bm):
|
||||
from datetime import datetime, timezone
|
||||
|
||||
p = bm.BasicMemoryProvider()
|
||||
p._session_started_at = datetime(2026, 5, 10, 13, 5, tzinfo=timezone.utc)
|
||||
p._session_id = "abcdef"
|
||||
title = p._session_note_title()
|
||||
# No `_` in id → use last 6 chars
|
||||
assert title == "Hermes Session 2026-05-10 1305 abcdef"
|
||||
|
||||
|
||||
def test_system_prompt_block_uninitialized(bm):
|
||||
p = bm.BasicMemoryProvider()
|
||||
assert p.system_prompt_block() == ""
|
||||
|
||||
|
||||
def test_system_prompt_block_mentions_tools(bm):
|
||||
p = bm.BasicMemoryProvider()
|
||||
p._initialized = True
|
||||
p._project = "test-proj"
|
||||
p._mode = "local"
|
||||
out = p.system_prompt_block()
|
||||
assert "bm_search" in out
|
||||
assert "test-proj" in out
|
||||
assert "local" in out
|
||||
|
||||
|
||||
def test_system_prompt_block_steers_away_from_cli(bm):
|
||||
"""
|
||||
Felix's training data is biased toward `bm tool ...` CLI patterns. The
|
||||
system_prompt_block must explicitly direct the model to the bm_* tools
|
||||
AND give a reason (latency) so it has a justification for following the
|
||||
directive. Without this nudge, agents reach for bash/terminal tools and
|
||||
pay 1-2s per call instead of ~0.1s.
|
||||
"""
|
||||
p = bm.BasicMemoryProvider()
|
||||
p._initialized = True
|
||||
p._project = "test-proj"
|
||||
p._mode = "local"
|
||||
out = p.system_prompt_block().lower()
|
||||
# Directive: don't use the bm CLI
|
||||
assert "do not shell out" in out or "do not use the" in out or "do not run" in out
|
||||
assert "bm" in out and "cli" in out
|
||||
# Reason given (latency / capture bypass)
|
||||
assert "mcp" in out # the persistent connection is the mechanism we cite
|
||||
assert "spawn" in out or "process" in out # cold-start cost is mentioned
|
||||
|
||||
|
||||
def test_save_config_writes_json(bm, tmp_path):
|
||||
p = bm.BasicMemoryProvider()
|
||||
p.save_config({"mode": "local", "project": "x", "capture_per_turn": "true"}, str(tmp_path))
|
||||
written = json.loads((tmp_path / "basic-memory.json").read_text())
|
||||
assert written["mode"] == "local"
|
||||
assert written["project"] == "x"
|
||||
assert written["capture_per_turn"] is True # coerced from "true"
|
||||
|
||||
|
||||
def test_save_config_merges_existing(bm, tmp_path):
|
||||
cfg = tmp_path / "basic-memory.json"
|
||||
cfg.write_text(json.dumps({"mode": "cloud", "project": "old"}))
|
||||
p = bm.BasicMemoryProvider()
|
||||
p.save_config({"project": "new"}, str(tmp_path))
|
||||
after = json.loads(cfg.read_text())
|
||||
assert after["mode"] == "cloud" # preserved
|
||||
assert after["project"] == "new" # updated
|
||||
|
||||
|
||||
def test_load_config_missing_returns_empty(bm, tmp_path):
|
||||
assert bm._load_config(str(tmp_path)) == {}
|
||||
|
||||
|
||||
def test_load_config_corrupt_returns_empty(bm, tmp_path):
|
||||
(tmp_path / "basic-memory.json").write_text("{not json")
|
||||
assert bm._load_config(str(tmp_path)) == {}
|
||||
|
||||
|
||||
def test_get_config_schema_shape(bm):
|
||||
schema = bm.BasicMemoryProvider().get_config_schema()
|
||||
keys = {entry["key"] for entry in schema}
|
||||
assert {
|
||||
"mode",
|
||||
"project",
|
||||
"project_path",
|
||||
"capture_per_turn",
|
||||
"capture_session_end",
|
||||
"capture_folder",
|
||||
}.issubset(keys)
|
||||
|
||||
|
||||
def test_register_appends_to_active_providers(bm):
|
||||
fake_ctx = MagicMock()
|
||||
bm._active_providers.clear()
|
||||
bm.register(fake_ctx)
|
||||
fake_ctx.register_memory_provider.assert_called_once()
|
||||
assert len(bm._active_providers) == 1
|
||||
bm._active_providers.clear()
|
||||
|
||||
|
||||
def test_register_also_registers_bundled_skill(bm):
|
||||
"""Plugin's bundled SKILL.md should auto-register when `hermes plugins install`
|
||||
drops the repo into ~/.hermes/plugins/. Avoids the manual symlink step."""
|
||||
fake_ctx = MagicMock()
|
||||
bm._active_providers.clear()
|
||||
bm.register(fake_ctx)
|
||||
fake_ctx.register_skill.assert_called_once()
|
||||
args, kwargs = fake_ctx.register_skill.call_args
|
||||
# First positional arg is the bare skill name
|
||||
assert args[0] == "basic-memory"
|
||||
# Second positional arg is the SKILL.md path; it should resolve to a real file
|
||||
assert args[1].name == "SKILL.md"
|
||||
assert args[1].is_file()
|
||||
bm._active_providers.clear()
|
||||
|
||||
|
||||
def test_register_tolerates_old_hermes_without_register_skill(bm):
|
||||
"""Older Hermes versions don't have ctx.register_skill; we shouldn't crash."""
|
||||
|
||||
class _OldCtx:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def register_memory_provider(self, provider):
|
||||
self.calls.append(("memory", provider))
|
||||
|
||||
ctx = _OldCtx()
|
||||
bm._active_providers.clear()
|
||||
bm.register(ctx) # must not raise
|
||||
assert any(c[0] == "memory" for c in ctx.calls)
|
||||
bm._active_providers.clear()
|
||||
|
||||
|
||||
def test_register_swallows_register_skill_errors(bm, caplog):
|
||||
"""If register_skill raises (path validation, ABC mismatch, etc.) we log
|
||||
and continue — don't break the memory-provider registration."""
|
||||
fake_ctx = MagicMock()
|
||||
fake_ctx.register_skill.side_effect = ValueError("invalid skill name")
|
||||
bm._active_providers.clear()
|
||||
with caplog.at_level("WARNING"):
|
||||
bm.register(fake_ctx)
|
||||
fake_ctx.register_memory_provider.assert_called_once()
|
||||
assert "register_skill failed" in caplog.text
|
||||
bm._active_providers.clear()
|
||||
|
||||
|
||||
# ---- Edge cases ----
|
||||
|
||||
|
||||
def test_handle_tool_call_with_none_args(bm):
|
||||
"""args=None must not crash; should be coerced to {} and surface a missing-arg error."""
|
||||
p = bm.BasicMemoryProvider()
|
||||
p._initialized = True
|
||||
p._actor = MagicMock()
|
||||
out = json.loads(p.handle_tool_call("bm_search", None)) # type: ignore[arg-type]
|
||||
assert "error" in out
|
||||
|
||||
|
||||
def test_handle_tool_call_unknown_does_not_invoke_actor(bm):
|
||||
p = bm.BasicMemoryProvider()
|
||||
p._initialized = True
|
||||
p._actor = MagicMock()
|
||||
p.handle_tool_call("bm_does_not_exist", {"x": 1})
|
||||
p._actor.call.assert_not_called()
|
||||
|
||||
|
||||
def test_translate_args_unknown_tool_raises(bm):
|
||||
"""Unknown tool names should raise KeyError so callers handle it explicitly."""
|
||||
import pytest
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
bm._translate_args("not_a_tool", {}, "proj")
|
||||
|
||||
|
||||
# ---- Version metadata ----
|
||||
|
||||
|
||||
def test_module_version_present(bm):
|
||||
import re
|
||||
|
||||
assert hasattr(bm, "__version__")
|
||||
assert isinstance(bm.__version__, str)
|
||||
# Matches the Python release versions written by scripts/update_versions.py.
|
||||
assert re.fullmatch(r"\d+\.\d+\.\d+(?:(?:b|rc)\d+)?", bm.__version__), bm.__version__
|
||||
|
||||
|
||||
def test_module_version_matches_plugin_yaml(bm):
|
||||
"""plugin.yaml ships to Hermes; __version__ is what tooling reads. Keep them in sync."""
|
||||
import os
|
||||
import re
|
||||
|
||||
plugin_yaml = os.path.join(
|
||||
os.path.dirname(os.path.abspath(bm.__file__)),
|
||||
"plugin.yaml",
|
||||
)
|
||||
text = open(plugin_yaml).read()
|
||||
m = re.search(r"^\s*version\s*:\s*(\S+)\s*$", text, re.MULTILINE)
|
||||
assert m is not None, "plugin.yaml is missing a version field"
|
||||
assert m.group(1) == bm.__version__, (
|
||||
f"plugin.yaml version ({m.group(1)}) doesn't match __version__ ({bm.__version__})"
|
||||
)
|
||||
|
||||
|
||||
# ---- uv bootstrap ----
|
||||
|
||||
|
||||
def test_install_bm_via_uv_no_uv(bm, monkeypatch):
|
||||
"""If uv isn't available, install returns None without trying to spawn."""
|
||||
monkeypatch.setattr(bm, "_uv_binary_path", lambda: None)
|
||||
assert bm._install_bm_via_uv() is None
|
||||
|
||||
|
||||
def test_install_bm_via_uv_runs_uv_tool_install(bm, monkeypatch):
|
||||
"""Install shells out to `uv tool install basic-memory`."""
|
||||
calls: list = []
|
||||
|
||||
class _Result:
|
||||
returncode = 0
|
||||
stdout = b""
|
||||
stderr = b""
|
||||
|
||||
def _fake_run(argv, **kwargs):
|
||||
calls.append((argv, kwargs))
|
||||
return _Result()
|
||||
|
||||
monkeypatch.setattr(bm, "_uv_binary_path", lambda: "/fake/uv")
|
||||
monkeypatch.setattr(bm.subprocess, "run", _fake_run)
|
||||
monkeypatch.setattr(bm, "_bm_binary_path", lambda: "/fake/bm-after-install")
|
||||
|
||||
result = bm._install_bm_via_uv()
|
||||
assert result == "/fake/bm-after-install"
|
||||
assert len(calls) == 1
|
||||
argv = calls[0][0]
|
||||
assert argv[0] == "/fake/uv"
|
||||
assert argv[1:] == ["tool", "install", "basic-memory", "--quiet"]
|
||||
|
||||
|
||||
def test_install_bm_via_uv_failed_returncode(bm, monkeypatch):
|
||||
"""Non-zero exit logs and returns None — doesn't pretend success."""
|
||||
|
||||
class _Result:
|
||||
returncode = 2
|
||||
stdout = b""
|
||||
stderr = b"network unreachable"
|
||||
|
||||
monkeypatch.setattr(bm, "_uv_binary_path", lambda: "/fake/uv")
|
||||
monkeypatch.setattr(bm.subprocess, "run", lambda *a, **kw: _Result())
|
||||
|
||||
assert bm._install_bm_via_uv() is None
|
||||
|
||||
|
||||
def test_install_bm_via_uv_subprocess_exception(bm, monkeypatch):
|
||||
"""If subprocess raises (timeout, OSError, etc.) we degrade to None, not crash."""
|
||||
monkeypatch.setattr(bm, "_uv_binary_path", lambda: "/fake/uv")
|
||||
|
||||
def _raise(*a, **kw):
|
||||
raise OSError("boom")
|
||||
|
||||
monkeypatch.setattr(bm.subprocess, "run", _raise)
|
||||
assert bm._install_bm_via_uv() is None
|
||||
|
||||
|
||||
def test_initialize_invokes_uv_install_when_bm_missing(bm, monkeypatch, tmp_path):
|
||||
"""Cold-start path: bm absent, uv present → initialize triggers install."""
|
||||
install_calls: list = []
|
||||
|
||||
def _fake_install():
|
||||
install_calls.append(True)
|
||||
return None # install reports failure; initialize logs and returns
|
||||
|
||||
monkeypatch.setattr(bm, "_MCP_AVAILABLE", True)
|
||||
monkeypatch.setattr(bm, "_bm_binary_path", lambda: None)
|
||||
monkeypatch.setattr(bm, "_uv_binary_path", lambda: "/fake/uv")
|
||||
monkeypatch.setattr(bm, "_install_bm_via_uv", _fake_install)
|
||||
|
||||
p = bm.BasicMemoryProvider()
|
||||
p.initialize(session_id="test", hermes_home=str(tmp_path))
|
||||
|
||||
assert install_calls == [True], "expected initialize() to attempt the install"
|
||||
assert p._initialized is False # install reported failure → don't start actor
|
||||
|
||||
|
||||
def test_initialize_skips_uv_install_when_bm_present(bm, monkeypatch, tmp_path):
|
||||
"""Steady-state path: bm already installed → no install attempt."""
|
||||
install_calls: list = []
|
||||
|
||||
monkeypatch.setattr(bm, "_bm_binary_path", lambda: "/fake/bm")
|
||||
|
||||
def _fake_install():
|
||||
install_calls.append(True)
|
||||
return "/should-not-be-called"
|
||||
|
||||
monkeypatch.setattr(bm, "_install_bm_via_uv", _fake_install)
|
||||
monkeypatch.setattr(bm, "_MCP_AVAILABLE", False) # short-circuit actor start
|
||||
|
||||
p = bm.BasicMemoryProvider()
|
||||
p.initialize(session_id="test", hermes_home=str(tmp_path))
|
||||
|
||||
assert install_calls == [], "should not invoke install when bm is already present"
|
||||
|
||||
|
||||
def test_initialize_bails_when_no_bm_no_uv(bm, monkeypatch, tmp_path, caplog):
|
||||
"""No bm, no uv → log clear error, don't try to install, don't initialize."""
|
||||
monkeypatch.setattr(bm, "_MCP_AVAILABLE", True)
|
||||
monkeypatch.setattr(bm, "_bm_binary_path", lambda: None)
|
||||
monkeypatch.setattr(bm, "_uv_binary_path", lambda: None)
|
||||
|
||||
install_calls: list = []
|
||||
monkeypatch.setattr(bm, "_install_bm_via_uv", lambda: install_calls.append(True) or None)
|
||||
|
||||
p = bm.BasicMemoryProvider()
|
||||
with caplog.at_level("ERROR"):
|
||||
p.initialize(session_id="test", hermes_home=str(tmp_path))
|
||||
|
||||
assert install_calls == [] # never attempted — no uv to call
|
||||
assert p._initialized is False
|
||||
assert "uv is not installed" in caplog.text or "uv" in caplog.text.lower()
|
||||
|
||||
|
||||
# ---- Defaults: stay out of bm's app dir ----
|
||||
|
||||
|
||||
def test_default_project_name_is_hermes_memory(bm):
|
||||
"""The default project name no longer carries a hostname suffix.
|
||||
Each machine has its own isolated local store with this same name."""
|
||||
assert bm._default_project() == "hermes-memory"
|
||||
|
||||
|
||||
def test_default_project_path_is_in_user_space(bm):
|
||||
"""~/.basic-memory/ is reserved for bm's app state. Projects live in user space."""
|
||||
p = bm._default_project_path()
|
||||
assert ".basic-memory" not in p, (
|
||||
f"default project path must not live inside ~/.basic-memory/, got: {p}"
|
||||
)
|
||||
assert p.rstrip("/").endswith("hermes-memory")
|
||||
|
||||
|
||||
# ---- bm config introspection ----
|
||||
|
||||
|
||||
def test_bm_known_projects_missing_file(bm, monkeypatch, tmp_path):
|
||||
"""When bm has never been run, return None (callers should treat as 'unknown')."""
|
||||
monkeypatch.setattr(bm, "_bm_config_path", lambda: tmp_path / "config.json")
|
||||
assert bm._bm_known_projects() is None
|
||||
|
||||
|
||||
def test_bm_known_projects_corrupt_file(bm, monkeypatch, tmp_path):
|
||||
cfg = tmp_path / "config.json"
|
||||
cfg.write_text("{not json")
|
||||
monkeypatch.setattr(bm, "_bm_config_path", lambda: cfg)
|
||||
assert bm._bm_known_projects() is None
|
||||
|
||||
|
||||
def test_bm_known_projects_returns_dict(bm, monkeypatch, tmp_path):
|
||||
cfg = tmp_path / "config.json"
|
||||
cfg.write_text(json.dumps({"projects": {"main": {}, "hermes-memory": {}}}))
|
||||
monkeypatch.setattr(bm, "_bm_config_path", lambda: cfg)
|
||||
result = bm._bm_known_projects()
|
||||
assert isinstance(result, dict)
|
||||
assert set(result.keys()) == {"main", "hermes-memory"}
|
||||
|
||||
|
||||
def test_bm_known_projects_handles_non_dict_root(bm, monkeypatch, tmp_path):
|
||||
cfg = tmp_path / "config.json"
|
||||
cfg.write_text(json.dumps(["not a dict"]))
|
||||
monkeypatch.setattr(bm, "_bm_config_path", lambda: cfg)
|
||||
assert bm._bm_known_projects() is None
|
||||
|
||||
|
||||
# ---- Project verification ----
|
||||
|
||||
|
||||
def test_verify_project_registered_no_bm_config(bm, monkeypatch):
|
||||
"""No bm config yet → assume registration is fine; let downstream surface real failures."""
|
||||
monkeypatch.setattr(bm, "_bm_known_projects", lambda: None)
|
||||
p = bm.BasicMemoryProvider()
|
||||
p._project = "anything-goes"
|
||||
assert p._verify_project_registered() is True
|
||||
|
||||
|
||||
def test_verify_project_registered_present(bm, monkeypatch):
|
||||
monkeypatch.setattr(bm, "_bm_known_projects", lambda: {"hermes-memory": {}, "main": {}})
|
||||
p = bm.BasicMemoryProvider()
|
||||
p._project = "hermes-memory"
|
||||
assert p._verify_project_registered() is True
|
||||
|
||||
|
||||
def test_verify_project_registered_missing(bm, monkeypatch):
|
||||
monkeypatch.setattr(bm, "_bm_known_projects", lambda: {"main": {}, "other": {}})
|
||||
p = bm.BasicMemoryProvider()
|
||||
p._project = "hermes-memory-cloud"
|
||||
assert p._verify_project_registered() is False
|
||||
|
||||
|
||||
def test_log_missing_project_local_hint_includes_path(bm, caplog):
|
||||
p = bm.BasicMemoryProvider()
|
||||
p._mode = "local"
|
||||
p._project = "hermes-memory"
|
||||
p._project_path = "/tmp/somewhere"
|
||||
with caplog.at_level("ERROR"):
|
||||
p._log_missing_project()
|
||||
msg = caplog.text
|
||||
assert "hermes-memory" in msg
|
||||
assert "/tmp/somewhere" in msg
|
||||
assert "--cloud" not in msg
|
||||
|
||||
|
||||
def test_log_missing_project_cloud_hint_uses_cloud_flag(bm, caplog):
|
||||
p = bm.BasicMemoryProvider()
|
||||
p._mode = "cloud"
|
||||
p._project = "hermes-memory-cloud"
|
||||
with caplog.at_level("ERROR"):
|
||||
p._log_missing_project()
|
||||
msg = caplog.text
|
||||
assert "hermes-memory-cloud" in msg
|
||||
assert "--cloud" in msg
|
||||
|
||||
|
||||
# ---- initialize() bail-out on missing project ----
|
||||
|
||||
|
||||
def test_initialize_bails_when_project_missing(bm, monkeypatch, tmp_path):
|
||||
"""If bm config says the project doesn't exist, refuse to initialize."""
|
||||
# bm config exists, but our project isn't in it
|
||||
bm_cfg = tmp_path / ".basic-memory" / "config.json"
|
||||
bm_cfg.parent.mkdir(parents=True)
|
||||
bm_cfg.write_text(json.dumps({"projects": {"main": {}}}))
|
||||
monkeypatch.setattr(bm, "_bm_config_path", lambda: bm_cfg)
|
||||
|
||||
# Cloud mode so _ensure_local_project doesn't auto-create
|
||||
plugin_cfg = tmp_path / "basic-memory.json"
|
||||
plugin_cfg.write_text(json.dumps({"mode": "cloud", "project": "not-registered"}))
|
||||
|
||||
p = bm.BasicMemoryProvider()
|
||||
p.initialize(session_id="test", hermes_home=str(tmp_path))
|
||||
|
||||
assert p._initialized is False
|
||||
assert p._actor is None
|
||||
|
||||
|
||||
def test_initialize_proceeds_when_bm_config_absent(bm, monkeypatch, tmp_path):
|
||||
"""If bm config doesn't exist (fresh install), don't false-reject — let actor try."""
|
||||
monkeypatch.setattr(bm, "_bm_config_path", lambda: tmp_path / "no-such" / "config.json")
|
||||
monkeypatch.setattr(bm, "_MCP_AVAILABLE", False) # shortcut: actor won't actually start
|
||||
|
||||
plugin_cfg = tmp_path / "basic-memory.json"
|
||||
plugin_cfg.write_text(json.dumps({"mode": "cloud", "project": "anything"}))
|
||||
|
||||
p = bm.BasicMemoryProvider()
|
||||
# We don't fully assert _initialized here because the actor won't start without
|
||||
# MCP — but we DO assert _verify_project_registered didn't gate us out before
|
||||
# actor-start was attempted.
|
||||
p.initialize(session_id="test", hermes_home=str(tmp_path))
|
||||
# Initialization fails at actor-start (MCP unavailable), not at verify.
|
||||
assert p._initialized is False # expected — actor couldn't start
|
||||
# _project should have been set despite the failure (proves we got past verify)
|
||||
assert p._project == "anything"
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if ! command -v bun >/dev/null 2>&1; then
|
||||
echo "bun is required to run pre-commit checks." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Running pre-commit checks..."
|
||||
bun run lint
|
||||
bun run check-types
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.8"
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Fetch skills
|
||||
run: bun scripts/fetch-skills.ts
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Lint
|
||||
run: bun run lint
|
||||
|
||||
- name: Typecheck
|
||||
run: bun run check-types
|
||||
|
||||
- name: Unit tests
|
||||
run: bun test
|
||||
@@ -0,0 +1,99 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version bump (`patch`, `minor`, `major`) or explicit semver (`0.2.0`)"
|
||||
required: true
|
||||
default: "patch"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.8"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Fetch skills
|
||||
run: bun scripts/fetch-skills.ts
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Release checks
|
||||
run: bun run release:check
|
||||
|
||||
- name: Configure Git identity
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Bump version and create tag
|
||||
id: bump
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
VERSION_INPUT="${{ github.event.inputs.version }}"
|
||||
|
||||
if [[ "$VERSION_INPUT" =~ ^(patch|minor|major)$ ]]; then
|
||||
npm version "$VERSION_INPUT" -m "chore(release): %s"
|
||||
elif [[ "$VERSION_INPUT" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-.+)?$ ]]; then
|
||||
npm version "$VERSION_INPUT" -m "chore(release): %s"
|
||||
else
|
||||
echo "Unsupported version input: $VERSION_INPUT" >&2
|
||||
echo "Use patch|minor|major or explicit semver like 0.2.0 or 0.2.0-alpha.1" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TAG="$(git describe --tags --abbrev=0)"
|
||||
VERSION="$(node -p "require('./package.json').version")"
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Push commit and tag
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git push origin HEAD:${GITHUB_REF_NAME}
|
||||
git push origin "${{ steps.bump.outputs.tag }}"
|
||||
|
||||
- name: Publish to npm
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version }}"
|
||||
if [[ "$VERSION" == *-* ]]; then
|
||||
DIST_TAG="${VERSION##*-}" # e.g. alpha.6 -> alpha
|
||||
DIST_TAG="${DIST_TAG%%.*}" # strip .N suffix
|
||||
npm publish --provenance --access public --tag "$DIST_TAG"
|
||||
else
|
||||
npm publish --provenance --access public
|
||||
fi
|
||||
|
||||
- name: Create GitHub release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ steps.bump.outputs.tag }}
|
||||
name: ${{ steps.bump.outputs.tag }}
|
||||
generate_release_notes: true
|
||||
@@ -0,0 +1,146 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Sveltekit cache directory
|
||||
.svelte-kit/
|
||||
|
||||
# vitepress build output
|
||||
**/.vitepress/dist
|
||||
|
||||
# vitepress cache directory
|
||||
**/.vitepress/cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# Firebase cache directory
|
||||
.firebase/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v3
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
|
||||
# Vite logs files
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
.idea/
|
||||
benchmark/datasets/
|
||||
benchmark/corpus-locomo/
|
||||
|
||||
# Generated from ../../../skills by scripts/fetch-skills.ts
|
||||
skills/
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
CLAUDE.md
|
||||
@@ -0,0 +1,208 @@
|
||||
# Basic Memory CLI & Cloud Setup
|
||||
|
||||
## How the Plugin Connects
|
||||
|
||||
Basic Memory is **local-first** — everything works out of the box with no cloud account, no internet connection, and no external services. Your notes live as markdown files on disk, indexed locally with SQLite.
|
||||
|
||||
The plugin spawns a Basic Memory MCP session via stdio:
|
||||
|
||||
```
|
||||
bm mcp --transport stdio --project <name>
|
||||
```
|
||||
|
||||
All tool calls route through this MCP session. No cloud configuration is required for normal use.
|
||||
|
||||
## Cloud Configuration (Optional)
|
||||
|
||||
Cloud sync is entirely optional. If you want to sync your local knowledge base to Basic Memory Cloud for backup or cross-device access, you can configure per-project cloud routing.
|
||||
|
||||
### Why Cloud?
|
||||
|
||||
- **Your agent's memory travels with you** — laptop, desktop, hosted environment. Same knowledge graph everywhere, synced bidirectionally.
|
||||
- **Team knowledge sharing** — org workspaces let multiple agents and team members build on a shared knowledge base.
|
||||
- **Durable memory for production agents** — CI runners, containers, and hosted environments are ephemeral. Cloud gives agents persistent memory that survives teardowns.
|
||||
- **Multi-agent coordination** — multiple agents (or the same agent across services) can read and write to a shared graph.
|
||||
|
||||
Cloud extends local-first — it doesn't replace it. Your notes are still plain markdown, still editable locally, still yours. Start with a [7-day free trial](https://basicmemory.com) — no credit card required. Use code `BMCLAW` for 20% off for 3 months.
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Authenticate with Basic Memory Cloud
|
||||
bm cloud login
|
||||
|
||||
# Save API key for per-project cloud routing
|
||||
bm cloud set-key bmc_...
|
||||
|
||||
# Route a project through the cloud
|
||||
bm project set-cloud <name>
|
||||
|
||||
# Revert a project to local routing
|
||||
bm project set-local <name>
|
||||
|
||||
# Check cloud connection state
|
||||
bm cloud status
|
||||
```
|
||||
|
||||
When a project is set to cloud mode, the MCP server routes tool calls for that project through the cloud API using the saved API key as a Bearer token. Local projects (the default) continue to use the local SQLite index. You can mix local and cloud projects freely.
|
||||
|
||||
## Project Management
|
||||
|
||||
```bash
|
||||
# List all projects
|
||||
bm project list
|
||||
|
||||
# Add a new project
|
||||
bm project add "name" ~/path
|
||||
|
||||
# Show current project details
|
||||
bm project info
|
||||
|
||||
# Set the default project
|
||||
bm project default "name"
|
||||
|
||||
# One-way sync (local -> cloud)
|
||||
bm project sync
|
||||
|
||||
# Bidirectional sync
|
||||
bm project bisync
|
||||
```
|
||||
|
||||
## Cross-Project Operations
|
||||
|
||||
All plugin tools accept an optional `project` parameter to operate on a different project:
|
||||
|
||||
```
|
||||
search_notes(query="authentication", project="other-project")
|
||||
read_note(identifier="notes/api-design", project="docs")
|
||||
write_note(title="New Note", content="...", folder="notes", project="research")
|
||||
```
|
||||
|
||||
## Workspace Support
|
||||
|
||||
Workspaces group projects by owner (personal or organization):
|
||||
|
||||
```
|
||||
list_workspaces()
|
||||
|
||||
list_memory_projects(workspace="my-org")
|
||||
```
|
||||
|
||||
## Auto-Recall
|
||||
|
||||
When `autoRecall` is enabled (the default), the plugin injects relevant context at the start of each agent session by listening for the `agent_start` event. On each trigger it:
|
||||
|
||||
1. **Queries active tasks** — searches the knowledge graph for notes with `type: Task` and `status: active` (up to 5 results)
|
||||
2. **Fetches recent activity** — gets notes modified in the last 24 hours
|
||||
3. **Formats and injects context** — returns the results as structured context for the agent
|
||||
|
||||
The injected context looks like:
|
||||
|
||||
```
|
||||
## Active Tasks
|
||||
- **Fix login bug** — Description of Fix login bug
|
||||
- **Update API docs** — Description of Update API docs
|
||||
|
||||
## Recent Activity
|
||||
- Daily standup notes (memory/daily-standup-notes.md)
|
||||
- API design decisions (memory/api-design-decisions.md)
|
||||
|
||||
---
|
||||
Check for active tasks and recent activity. Summarize anything relevant to the current session.
|
||||
```
|
||||
|
||||
The trailing instruction (after `---`) is the `recallPrompt`, which you can customize to change what the agent focuses on. For example:
|
||||
|
||||
```json
|
||||
{
|
||||
"recallPrompt": "Focus on blocked tasks and any decisions made in the last 24 hours."
|
||||
}
|
||||
```
|
||||
|
||||
To disable auto-recall entirely:
|
||||
|
||||
```json
|
||||
{
|
||||
"autoRecall": false
|
||||
}
|
||||
```
|
||||
|
||||
## Auto-Capture
|
||||
|
||||
When `autoCapture` is enabled (the default), the plugin automatically records agent conversations after each turn:
|
||||
|
||||
1. Extracts the last user + assistant messages
|
||||
2. Appends them as timestamped entries to a daily conversation note (`conversations-YYYY-MM-DD`)
|
||||
3. Skips very short exchanges (< `captureMinChars` chars each, default 10)
|
||||
|
||||
This builds a searchable history of agent interactions in the knowledge graph without any manual effort.
|
||||
|
||||
## Slash Commands
|
||||
|
||||
### Memory commands
|
||||
|
||||
- **`/remember <text>`** — Save a quick note to the knowledge graph
|
||||
- **`/recall <query>`** — Search the knowledge graph (top 5 results)
|
||||
|
||||
### Skill workflows
|
||||
|
||||
These commands inject step-by-step workflow instructions from the bundled skill files:
|
||||
|
||||
| Command | What it does |
|
||||
|---------|-------------|
|
||||
| `/tasks` | Task management — create, track, resume structured tasks that survive context compaction |
|
||||
| `/reflect` | Memory reflection — review recent activity and consolidate insights into long-term memory |
|
||||
| `/defrag` | Memory defrag — reorganize, split, prune, and clean up memory files |
|
||||
| `/schema` | Schema management — infer, create, validate, and evolve Picoschema definitions |
|
||||
|
||||
Each command accepts optional arguments for context:
|
||||
|
||||
```
|
||||
/tasks create a task for the API migration
|
||||
/reflect focus on decisions from this week
|
||||
/defrag clean up completed tasks older than 2 weeks
|
||||
/schema infer a schema for Meeting notes
|
||||
```
|
||||
|
||||
When invoked without arguments, the agent receives the full workflow instructions and follows them interactively.
|
||||
|
||||
## Plugin Configuration
|
||||
|
||||
The plugin accepts these config fields in `openclaw.config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": {
|
||||
"entries": {
|
||||
"openclaw-basic-memory": {
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"project": "my-project",
|
||||
"memoryDir": "memory/",
|
||||
"memoryFile": "MEMORY.md",
|
||||
"autoCapture": true,
|
||||
"autoRecall": true,
|
||||
"recallPrompt": "Check for active tasks and recent activity. Summarize anything relevant.",
|
||||
"debug": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"slots": {
|
||||
"memory": "openclaw-basic-memory"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Default | Description |
|
||||
|-------|---------|-------------|
|
||||
| `project` | `openclaw-<hostname>` | BM project name |
|
||||
| `bmPath` | `bm` | Path to BM CLI binary |
|
||||
| `memoryDir` | `memory/` | Relative path for memory files |
|
||||
| `memoryFile` | `MEMORY.md` | Working memory file name |
|
||||
| `projectPath` | same as `memoryDir` | Absolute path to project root |
|
||||
| `autoCapture` | `true` | Auto-index conversations after each turn |
|
||||
| `captureMinChars` | `10` | Min chars to trigger capture |
|
||||
| `autoRecall` | `true` | Inject context (active tasks, recent activity) at session start |
|
||||
| `recallPrompt` | *(see above)* | Instruction appended to recalled context |
|
||||
| `debug` | `false` | Verbose logging |
|
||||
@@ -0,0 +1,632 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code when working with the OpenClaw package inside the Basic Memory monorepo.
|
||||
|
||||
## Project Overview
|
||||
|
||||
`@basicmemory/openclaw-basic-memory` is a TypeScript OpenClaw plugin that integrates [Basic Memory](https://github.com/basicmachines-co/basic-memory) with the OpenClaw agent framework. It lives under `integrations/openclaw/` in the monorepo, manages a persistent MCP stdio session to a `bm mcp` process, exposes 14 agent tools (including workspace/project management and cross-project operations), composited memory search/get providers, slash commands, CLI commands, and optional auto-capture of conversations.
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
# Install dependencies (uses Bun)
|
||||
bun install
|
||||
|
||||
# Run all unit tests (Bun native test runner)
|
||||
bun test
|
||||
|
||||
# Run a single test file
|
||||
bun test tools/search-notes.test.ts
|
||||
|
||||
# Integration tests (requires basic-memory CLI installed)
|
||||
bun run test:int
|
||||
|
||||
# Type checking (no emit)
|
||||
bun run check-types
|
||||
|
||||
# Lint (Biome)
|
||||
bun run lint
|
||||
|
||||
# Lint + auto-fix
|
||||
bun run lint:fix
|
||||
|
||||
# All quality checks (fetch skills + type-check + lint + build + tests)
|
||||
just check
|
||||
|
||||
# Release readiness (check + npm pack dry-run)
|
||||
just release-check
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Plugin Lifecycle (`index.ts`)
|
||||
|
||||
The default export is an OpenClaw plugin object (`id: "openclaw-basic-memory"`, `kind: "memory"`). The `register(api)` function:
|
||||
|
||||
1. Parses config via `parseConfig()` from `config.ts`
|
||||
2. Creates a `BmClient` instance (the MCP stdio client)
|
||||
3. Registers all tools, providers, hooks, commands, and the service lifecycle
|
||||
4. The service `start()` launches the MCP process (`bm mcp --transport stdio`), ensures the project exists, and sets the workspace directory
|
||||
5. The service `stop()` tears down the MCP connection
|
||||
|
||||
### MCP Client (`bm-client.ts` — largest file, ~675 lines)
|
||||
|
||||
Central orchestration layer that:
|
||||
- Spawns and manages a **persistent** `bm mcp --transport stdio` child process via `@modelcontextprotocol/sdk`
|
||||
- Validates 15 required MCP tools at connect time
|
||||
- Implements reconnection with bounded retries (500ms, 1s, 2s exponential backoff)
|
||||
- Distinguishes recoverable errors (broken pipe, transport closed) from fatal errors
|
||||
- All tool calls require `output_format: "json"` and extract `structuredContent.result`
|
||||
- Public methods: `search`, `readNote`, `writeNote`, `editNote`, `deleteNote`, `moveNote`, `buildContext`, `recentActivity`, `indexConversation`, `ensureProject`, `listProjects`, `listWorkspaces`, `schemaValidate`, `schemaInfer`, `schemaDiff`
|
||||
- All content methods accept an optional `project` parameter for cross-project operations
|
||||
- `listProjects` accepts an optional `workspace` parameter for workspace-scoped listing
|
||||
|
||||
### Tools (`tools/`)
|
||||
|
||||
Each tool file exports a function that calls `api.registerTool()` with a TypeBox schema and handler. Tools delegate to `BmClient` methods and return OpenClaw-standard responses (`{ content: [{type: "text", text}], details? }`).
|
||||
|
||||
- `search-notes.ts`, `read-note.ts`, `write-note.ts`, `edit-note.ts`, `delete-note.ts`, `move-note.ts`, `build-context.ts`, `list-memory-projects.ts`, `list-workspaces.ts`, `schema-validate.ts`, `schema-infer.ts`, `schema-diff.ts` — thin wrappers around `BmClient`; all content tools accept an optional `project` param for cross-project operations
|
||||
- `memory-provider.ts` — composited `memory_search` + `memory_get` providers. `memory_search` queries 3 sources in parallel: MEMORY.md (grep), BM knowledge graph (FTS + vector), and active task notes (YAML frontmatter scan)
|
||||
|
||||
### Commands & Hooks
|
||||
|
||||
- `commands/slash.ts` — `/remember` and `/recall` slash commands
|
||||
- `commands/cli.ts` — `openclaw basic-memory <subcommand>` CLI registration
|
||||
- `hooks/capture.ts` — auto-capture hook on `agent_end` events, writes timestamped daily conversation notes
|
||||
|
||||
### Configuration (`config.ts`)
|
||||
|
||||
Flexible config with defaults, snake_case aliases (`memory_dir`/`memory_file`), tilde/relative/absolute path resolution, and unknown-key validation. Cloud routing is configured through `bm cloud` and per-project BM settings, not plugin config.
|
||||
|
||||
## Key Patterns
|
||||
|
||||
- **TypeBox schemas** (`@sinclair/typebox`) for all tool parameter validation
|
||||
- **Bun-native test runner** with `describe`/`it`/`expect` and `jest.fn()` mocking
|
||||
- **ES modules** (`"type": "module"` in package.json)
|
||||
- **Biome** for linting and formatting (configured in `biome.json`)
|
||||
- **Build output** — `bun run build` emits `dist/` for `runtimeExtensions`; TypeScript source also stays in the package for source-compatible hosts
|
||||
- **Strict TypeScript** with `noEmit` (type-checking only)
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit tests live alongside source files (`*.test.ts`) and mock `BmClient` / `OpenClawPluginApi`
|
||||
- Integration tests in `integration/` launch a real `bm mcp` process against a temp project
|
||||
- `scripts/bm-local.sh` runs BM from the monorepo root via `uv run --project ...` when available, then falls back to `bm` on PATH
|
||||
|
||||
## CI/CD
|
||||
|
||||
- **Package CI** (root `.github/workflows/consolidated-packages.yml`): validates skills, typechecks, lints, builds, tests, and runs `npm pack --dry-run`.
|
||||
- **Release** (root `.github/workflows/release.yml`): runs from Basic Memory tags and publishes this npm package after the Python release job. Version bumps are handled by the root `just release` / `just beta` recipes.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Runtime**: `@modelcontextprotocol/sdk` (MCP client/transport), `@sinclair/typebox` (schema validation)
|
||||
- **Peer**: `openclaw` (>=2026.5.2)
|
||||
- **Dev**: `typescript`, `@biomejs/biome`, `@types/node`
|
||||
- **External**: Basic Memory CLI (`bm`) must be installed separately (Python, installed via `uv`)
|
||||
|
||||
---
|
||||
|
||||
# Basic Memory Plugin — Agent Instructions
|
||||
|
||||
This plugin provides sophisticated knowledge management through Basic Memory's knowledge graph. Use these tools and guidelines to help users build and navigate their persistent knowledge base.
|
||||
|
||||
## Cross-Project Operations
|
||||
|
||||
All content tools (`search_notes`, `read_note`, `write_note`, `edit_note`, `delete_note`, `move_note`, `build_context`, `schema_validate`, `schema_infer`, `schema_diff`) accept an optional `project` parameter to operate on a different project than the default. Use this when the user needs to work across multiple knowledge bases.
|
||||
|
||||
```
|
||||
# Search in a different project
|
||||
search_notes(query="meeting notes", project="team-wiki")
|
||||
|
||||
# Read a note from another project
|
||||
read_note(identifier="decisions/auth-strategy", project="backend")
|
||||
|
||||
# Write to a shared project
|
||||
write_note(title="Shared Insight", content="...", folder="insights", project="shared")
|
||||
```
|
||||
|
||||
## Available Tools
|
||||
|
||||
### `list_workspaces`
|
||||
**Purpose**: List all workspaces (personal and organization) accessible to this user
|
||||
**When to use**: When the user wants to see what workspaces are available, or before filtering projects by workspace
|
||||
**Returns**: Workspace names, types (personal/organization), roles, and subscription status
|
||||
|
||||
**Examples**:
|
||||
```
|
||||
list_workspaces()
|
||||
```
|
||||
|
||||
### `list_memory_projects`
|
||||
**Purpose**: List all Basic Memory projects, optionally filtered by workspace
|
||||
**When to use**: When the user wants to see available projects, discover projects in a specific workspace, or before cross-project operations
|
||||
**Returns**: Project names, paths, default status, and workspace metadata
|
||||
|
||||
**Examples**:
|
||||
```
|
||||
# List all projects
|
||||
list_memory_projects()
|
||||
|
||||
# List projects in a specific workspace
|
||||
list_memory_projects(workspace="my-organization")
|
||||
```
|
||||
|
||||
### `search_notes`
|
||||
**Purpose**: Search the knowledge graph for relevant notes, concepts, and connections
|
||||
**When to use**: When the user asks about topics, seeks information, or you need context for a discussion
|
||||
**Returns**: Ranked results with titles, content previews, and relevance scores
|
||||
|
||||
**Examples**:
|
||||
```
|
||||
# User asks "What did we decide about the API design?"
|
||||
search_notes(query="API design decisions", limit=5)
|
||||
|
||||
# Looking for context on a project
|
||||
search_notes(query="authentication implementation", limit=3)
|
||||
|
||||
# Exploring a broad topic
|
||||
search_notes(query="meeting notes client feedback", limit=10)
|
||||
|
||||
# Search in a different project
|
||||
search_notes(query="API endpoints", project="backend-docs")
|
||||
```
|
||||
|
||||
### `read_note`
|
||||
**Purpose**: Read full content of specific notes
|
||||
**When to use**: When search results show relevant notes that need detailed reading, or when you have a specific note identifier
|
||||
**Returns**: Complete note content with metadata
|
||||
|
||||
**Examples**:
|
||||
```
|
||||
# Read a note found in search results
|
||||
read_note(identifier="projects/api-redesign")
|
||||
|
||||
# Navigate to a memory URL
|
||||
read_note(identifier="memory://agents/decisions/auth-strategy")
|
||||
|
||||
# Read by exact title
|
||||
read_note(identifier="Weekly Review 2024-02-01")
|
||||
|
||||
# Read raw markdown including YAML frontmatter
|
||||
read_note(identifier="projects/api-redesign", include_frontmatter=true)
|
||||
|
||||
# Read from another project
|
||||
read_note(identifier="decisions/auth-strategy", project="backend")
|
||||
```
|
||||
|
||||
### `write_note`
|
||||
**Purpose**: Create new notes in the knowledge graph
|
||||
**When to use**: When users share important information, make decisions, or want to save insights for later
|
||||
**Best practices**: Use clear titles, organize in appropriate folders, structure with headings
|
||||
|
||||
**Examples**:
|
||||
```
|
||||
# Save a decision or insight
|
||||
write_note(
|
||||
title="API Authentication Decision",
|
||||
folder="decisions",
|
||||
content="""
|
||||
# API Authentication Decision
|
||||
|
||||
## Context
|
||||
The team discussed authentication options for the new API.
|
||||
|
||||
## Decision
|
||||
We chose JWT tokens with refresh token rotation.
|
||||
|
||||
## Reasoning
|
||||
- Better security than simple JWTs
|
||||
- Familiar to the team
|
||||
- Good ecosystem support
|
||||
|
||||
## Next Steps
|
||||
- [ ] Implement JWT middleware
|
||||
- [ ] Set up token refresh logic
|
||||
- [ ] Update API documentation
|
||||
"""
|
||||
)
|
||||
|
||||
# Document a meeting
|
||||
write_note(
|
||||
title="Client Meeting - February 8, 2024",
|
||||
folder="meetings",
|
||||
content="""
|
||||
# Client Meeting - February 8, 2024
|
||||
|
||||
## Attendees
|
||||
- John (client)
|
||||
- Sarah (product)
|
||||
- Me (engineering)
|
||||
|
||||
## Key Points
|
||||
- Client wants faster search functionality
|
||||
- Budget approved for additional features
|
||||
- Timeline moved up to March 15
|
||||
|
||||
## Action Items
|
||||
- [ ] Prototype search improvements
|
||||
- [ ] Prepare feature estimate
|
||||
- [ ] Schedule follow-up meeting
|
||||
"""
|
||||
)
|
||||
```
|
||||
|
||||
### `edit_note`
|
||||
**Purpose**: Modify existing notes incrementally
|
||||
**When to use**: To add updates, fix information, or organize existing content
|
||||
**Operations**: append, prepend, find_replace, replace_section
|
||||
|
||||
**Examples**:
|
||||
```
|
||||
# Add an update to an existing note
|
||||
edit_note(
|
||||
identifier="projects/api-redesign",
|
||||
operation="append",
|
||||
content="""
|
||||
|
||||
## Update - February 8, 2024
|
||||
Authentication implementation is complete. All tests passing.
|
||||
Next: Deploy to staging environment.
|
||||
"""
|
||||
)
|
||||
|
||||
# Update a specific section
|
||||
edit_note(
|
||||
identifier="weekly-review",
|
||||
operation="replace_section",
|
||||
section="## This Week",
|
||||
content="""## This Week
|
||||
- Completed API authentication
|
||||
- Client meeting went well
|
||||
- Working on search improvements
|
||||
- Delayed deployment due to testing issues
|
||||
"""
|
||||
)
|
||||
|
||||
# Fix a specific detail
|
||||
edit_note(
|
||||
identifier="team-contacts",
|
||||
operation="find_replace",
|
||||
find_text="sarah@oldcompany.com",
|
||||
content="sarah@newcompany.com",
|
||||
expected_replacements=1
|
||||
)
|
||||
```
|
||||
|
||||
### `delete_note`
|
||||
**Purpose**: Remove notes from the knowledge graph
|
||||
**When to use**: When content is outdated, duplicated, or no longer needed
|
||||
**Returns**: Confirmation of deletion
|
||||
|
||||
**Examples**:
|
||||
```
|
||||
# Remove an old draft
|
||||
delete_note(identifier="notes/old-draft")
|
||||
|
||||
# Clean up test notes
|
||||
delete_note(identifier="tests/test-1.0")
|
||||
```
|
||||
|
||||
### `move_note`
|
||||
**Purpose**: Move notes between folders for organization
|
||||
**When to use**: When reorganizing knowledge, archiving old content, or correcting folder placement
|
||||
**Returns**: Updated note with new location
|
||||
|
||||
**Examples**:
|
||||
```
|
||||
# Archive a completed project
|
||||
move_note(identifier="projects/api-redesign", newFolder="archive/projects")
|
||||
|
||||
# Reorganize into a better folder
|
||||
move_note(identifier="notes/meeting-notes", newFolder="meetings")
|
||||
```
|
||||
|
||||
### `build_context`
|
||||
**Purpose**: Navigate the knowledge graph through semantic connections
|
||||
**When to use**: To explore related concepts, find connected information, or build comprehensive understanding
|
||||
**Returns**: Target note plus related notes with relationship information
|
||||
|
||||
**Examples**:
|
||||
```
|
||||
# Explore connections around a project
|
||||
build_context(url="memory://projects/api-redesign", depth=1)
|
||||
|
||||
# Deep dive into related concepts
|
||||
build_context(url="memory://concepts/authentication", depth=2)
|
||||
|
||||
# Discover decision context
|
||||
build_context(url="memory://decisions/database-choice", depth=1)
|
||||
```
|
||||
|
||||
### `schema_validate`
|
||||
**Purpose**: Validate notes against their Picoschema definitions
|
||||
**When to use**: When checking note consistency, after schema changes, or when the user wants to audit note quality
|
||||
|
||||
**Examples**:
|
||||
```
|
||||
# Validate all notes of a type
|
||||
schema_validate(noteType="person")
|
||||
|
||||
# Validate a single note
|
||||
schema_validate(identifier="notes/john-doe")
|
||||
|
||||
# Validate in another project
|
||||
schema_validate(noteType="meeting", project="team")
|
||||
```
|
||||
|
||||
### `schema_infer`
|
||||
**Purpose**: Analyze existing notes and suggest a Picoschema definition
|
||||
**When to use**: When creating a new schema from existing notes, or exploring what structure notes of a type share
|
||||
|
||||
**Examples**:
|
||||
```
|
||||
schema_infer(noteType="meeting")
|
||||
schema_infer(noteType="person", threshold=0.5)
|
||||
```
|
||||
|
||||
### `schema_diff`
|
||||
**Purpose**: Detect drift between a schema definition and actual note usage
|
||||
**When to use**: When checking if a schema is still accurate, or after adding new fields to notes
|
||||
|
||||
**Examples**:
|
||||
```
|
||||
schema_diff(noteType="person")
|
||||
schema_diff(noteType="Task", project="work")
|
||||
```
|
||||
|
||||
## Knowledge Graph Structure
|
||||
|
||||
### Understanding the Graph
|
||||
Basic Memory organizes information as a **semantic knowledge graph** where:
|
||||
- **Notes** are documents with content, titles, and metadata
|
||||
- **Observations** are structured insights extracted from notes
|
||||
- **Relations** connect related concepts, topics, and decisions
|
||||
|
||||
### Memory URLs
|
||||
Use `memory://` URLs to navigate semantically:
|
||||
- `memory://projects/api-redesign` - Direct reference to a note
|
||||
- `memory://agents/decisions` - Category of decision-related notes
|
||||
- `memory://concepts/authentication` - All content related to authentication
|
||||
|
||||
### Organizational Patterns
|
||||
|
||||
**Recommended folder structure**:
|
||||
- `projects/` - Project-specific documentation
|
||||
- `decisions/` - Important decisions and rationale
|
||||
- `meetings/` - Meeting notes and action items
|
||||
- `concepts/` - Technical concepts and explanations
|
||||
- `agent/` - Agent-captured observations and insights
|
||||
- `weekly/` - Regular review notes
|
||||
|
||||
## Writing Best Practices
|
||||
|
||||
### Note Structure
|
||||
Use consistent markdown structure for better organization:
|
||||
|
||||
```markdown
|
||||
# Clear, Descriptive Title
|
||||
|
||||
## Context
|
||||
Background information and current situation.
|
||||
|
||||
## Key Points
|
||||
- Main insights or decisions
|
||||
- Important details
|
||||
- Relevant constraints
|
||||
|
||||
## Next Steps
|
||||
- [ ] Specific action items
|
||||
- [ ] Follow-up tasks
|
||||
- [ ] Future considerations
|
||||
```
|
||||
|
||||
### Observation Format
|
||||
When capturing insights, use this structure:
|
||||
|
||||
```markdown
|
||||
## Observations
|
||||
- [Decision] We chose PostgreSQL over MongoDB for better ACID guarantees
|
||||
- [Insight] User authentication patterns suggest social login preference
|
||||
- [Risk] Current deployment process lacks proper rollback mechanism
|
||||
- [Opportunity] Search performance could improve with better indexing
|
||||
```
|
||||
|
||||
### Linking and Relations
|
||||
Create connections between notes:
|
||||
- Reference other notes by title: `As discussed in [[API Design Principles]]`
|
||||
- Use consistent terminology for better semantic linking
|
||||
- Tag important concepts with clear labels
|
||||
- Cross-reference related decisions and implementations
|
||||
|
||||
## When to Use Each Tool
|
||||
|
||||
### Discover Workspaces and Projects
|
||||
Use `list_workspaces` and `list_memory_projects` when:
|
||||
- User asks what workspaces or projects are available
|
||||
- Before cross-project operations, to confirm project names
|
||||
- When switching between personal and organization contexts
|
||||
|
||||
### Start with Search
|
||||
**Always begin with `search_notes`** when:
|
||||
- User asks about any topic
|
||||
- You need context for a discussion
|
||||
- Looking for relevant previous decisions
|
||||
- Exploring what information already exists
|
||||
|
||||
### Read for Details
|
||||
Use `read_note` when:
|
||||
- Search results show relevant notes that need full content
|
||||
- Following up on specific references
|
||||
- User asks for complete information on a known topic
|
||||
- Exploring context relationships found in search
|
||||
|
||||
### Write for Capture
|
||||
Use `write_note` when:
|
||||
- User shares important information to remember
|
||||
- Decisions are made that should be documented
|
||||
- Meeting notes or insights need to be preserved
|
||||
- Creating structured documentation
|
||||
|
||||
### Edit for Updates
|
||||
Use `edit_note` when:
|
||||
- Adding updates to existing notes
|
||||
- Fixing or updating specific information
|
||||
- Organizing existing content better
|
||||
- Appending new insights to previous notes
|
||||
|
||||
### Context for Exploration
|
||||
Use `build_context` when:
|
||||
- Exploring relationships between concepts
|
||||
- Building comprehensive understanding
|
||||
- Finding related information user might not know exists
|
||||
- Navigating complex topic areas
|
||||
|
||||
## User Interaction Guidelines
|
||||
|
||||
### Be Proactive
|
||||
- **Search first**: Before answering questions, search the knowledge graph
|
||||
- **Suggest connections**: Point out related notes and concepts
|
||||
- **Offer to save**: When users share important info, offer to document it
|
||||
- **Recommend organization**: Help users structure their knowledge well
|
||||
|
||||
### Helpful Patterns
|
||||
```
|
||||
User: "What did we decide about the database?"
|
||||
1. Search: search_notes(query="database decision", limit=5)
|
||||
2. Read relevant: read_note(identifier="decisions/database-choice")
|
||||
3. Provide answer with context
|
||||
4. Ask: "Should I add any updates to this decision note?"
|
||||
|
||||
User: "I just had a great meeting with the client"
|
||||
1. Ask for details
|
||||
2. Offer: "Would you like me to create a meeting note to capture this?"
|
||||
3. Write: write_note(title="Client Meeting - [date]", ...)
|
||||
4. Suggest: "I'll also add this to your weekly review notes"
|
||||
```
|
||||
|
||||
### Memory URL Navigation
|
||||
Help users discover their knowledge:
|
||||
```
|
||||
# After finding a note about "API design"
|
||||
"I found your API design notes. Let me explore related concepts..."
|
||||
build_context(url="memory://projects/api-design", depth=2)
|
||||
|
||||
# Show user what's connected to their decisions
|
||||
build_context(url="memory://decisions", depth=1)
|
||||
```
|
||||
|
||||
## Working with User Memory Patterns
|
||||
|
||||
### Daily/Weekly Reviews
|
||||
If users maintain review notes, help them:
|
||||
```
|
||||
# Update weekly review
|
||||
edit_note(
|
||||
identifier="weekly-review",
|
||||
operation="replace_section",
|
||||
section="## This Week",
|
||||
content="Updated accomplishments and next steps"
|
||||
)
|
||||
```
|
||||
|
||||
### Project Documentation
|
||||
Keep project notes current:
|
||||
```
|
||||
# Add project updates
|
||||
edit_note(
|
||||
identifier="projects/current-sprint",
|
||||
operation="append",
|
||||
content="""
|
||||
## Sprint Review
|
||||
- Completed authentication
|
||||
- Started search feature
|
||||
"""
|
||||
)
|
||||
```
|
||||
|
||||
### Decision Tracking
|
||||
Document important decisions:
|
||||
```
|
||||
write_note(
|
||||
title="Technical Decision: Database Migration Approach",
|
||||
folder="decisions",
|
||||
content="""
|
||||
# Database Migration Decision
|
||||
|
||||
## Problem
|
||||
Current SQLite database can't handle increased load.
|
||||
|
||||
## Options Considered
|
||||
1. Upgrade to PostgreSQL
|
||||
2. Switch to MongoDB
|
||||
3. Migrate to cloud database
|
||||
|
||||
## Decision
|
||||
PostgreSQL with staged migration.
|
||||
|
||||
## Rationale
|
||||
- Better performance characteristics
|
||||
- Team expertise exists
|
||||
- Strong ACID guarantees needed
|
||||
- Migration path is well-understood
|
||||
"""
|
||||
)
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Tool Failures
|
||||
If Basic Memory tools fail:
|
||||
1. Check if the Basic Memory service is running
|
||||
2. Suggest user verify `bm` CLI installation
|
||||
3. Recommend checking OpenClaw plugin configuration
|
||||
4. Fall back to built-in memory tools if available
|
||||
|
||||
### Search No Results
|
||||
When searches return empty:
|
||||
- Try broader terms
|
||||
- Suggest creating a new note for the topic
|
||||
- Look for related concepts that might exist
|
||||
- Offer to help organize information differently
|
||||
|
||||
### Note Not Found
|
||||
When reading fails:
|
||||
- Verify the identifier exists
|
||||
- Suggest searching for similar titles
|
||||
- Offer to create the note if it should exist
|
||||
- Check for typos in memory URLs
|
||||
|
||||
## Integration Tips
|
||||
|
||||
### With Other Tools
|
||||
The knowledge graph complements other tools:
|
||||
- **Web search**: Save research findings as notes
|
||||
- **File operations**: Reference files in knowledge notes
|
||||
- **Calendar**: Link meeting notes to calendar events
|
||||
- **Task management**: Connect tasks to project notes
|
||||
|
||||
### With User Workflows
|
||||
Support user patterns:
|
||||
- **Morning review**: Search for yesterday's notes and updates
|
||||
- **End of day**: Capture insights and plan next steps
|
||||
- **Weekly planning**: Review project notes and decisions
|
||||
- **Knowledge sharing**: Help organize information for others
|
||||
|
||||
## Privacy and Content Guidelines
|
||||
|
||||
### Sensitive Information
|
||||
- Don't automatically save sensitive data (passwords, personal info)
|
||||
- Ask before documenting confidential business information
|
||||
- Respect user preferences for what to capture
|
||||
- Use appropriate folder organization for different privacy levels
|
||||
|
||||
### Content Quality
|
||||
- Encourage clear, structured writing
|
||||
- Help users create searchable content
|
||||
- Suggest consistent terminology and naming
|
||||
- Promote good information architecture
|
||||
|
||||
---
|
||||
|
||||
Remember: The knowledge graph becomes more valuable over time. Help users build it systematically and navigate it effectively. Focus on creating connections between ideas and making information easily discoverable.
|
||||
@@ -0,0 +1,275 @@
|
||||
# Basic Memory ContextEngine Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Complete the Basic Memory integration with OpenClaw's native memory lifecycle so BM works as a decorator around the default OpenClaw flow instead of relying on `agent_start` / `agent_end` shims.
|
||||
|
||||
The target model is:
|
||||
|
||||
- OpenClaw owns session state, context assembly pipeline, and compaction.
|
||||
- Basic Memory owns durable knowledge, cross-session recall, and long-term capture.
|
||||
- This plugin enriches the default flow without replacing it.
|
||||
|
||||
## Scope
|
||||
|
||||
This plan is for [issue #34](https://github.com/basicmachines-co/openclaw-basic-memory/issues/34), updated to match the "complement, don't replace" direction discussed there.
|
||||
|
||||
We will use the new OpenClaw `ContextEngine` lifecycle introduced in OpenClaw `2026.3.7` on March 6, 2026, but we will not implement a custom compaction strategy.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not replace or override OpenClaw compaction behavior.
|
||||
- Do not compete with lossless-claw or other alternate context engines.
|
||||
- Do not turn BM into the canonical source of current-session state.
|
||||
- Do not remove existing BM tools such as `memory_search`, `memory_get`, `search_notes`, `read_note`, and note CRUD tools.
|
||||
- Do not add aggressive semantic retrieval on every turn.
|
||||
|
||||
## Design Principles
|
||||
|
||||
### Decorator, not replacement
|
||||
|
||||
The plugin should behave like a wrapper around the default OpenClaw memory model:
|
||||
|
||||
- OpenClaw tracks the live conversation.
|
||||
- BM stores durable notes, tasks, decisions, and cross-session context.
|
||||
- The plugin bridges the two systems at official lifecycle boundaries.
|
||||
|
||||
### Keep the baseline flow intact
|
||||
|
||||
Where the ContextEngine API requires behavior that OpenClaw already provides well, we should pass through to the default behavior instead of re-implementing it.
|
||||
|
||||
### Add value only where BM is strongest
|
||||
|
||||
BM should improve:
|
||||
|
||||
- session bootstrap recall
|
||||
- durable post-turn capture
|
||||
- subagent memory inheritance
|
||||
- cross-session continuity through notes and graph search
|
||||
|
||||
BM should not try to improve:
|
||||
|
||||
- session-local compaction
|
||||
- low-level pruning logic
|
||||
- runtime token budgeting heuristics
|
||||
|
||||
## Current State
|
||||
|
||||
Today the plugin uses:
|
||||
|
||||
- `api.on("agent_start", ...)` for recall
|
||||
- `api.on("agent_end", ...)` for capture
|
||||
- composited `memory_search` / `memory_get` tools for explicit retrieval
|
||||
|
||||
This works, but it lives beside OpenClaw's memory lifecycle instead of inside it.
|
||||
|
||||
Relevant current files:
|
||||
|
||||
- `index.ts`
|
||||
- `hooks/recall.ts`
|
||||
- `hooks/capture.ts`
|
||||
- `tools/memory-provider.ts`
|
||||
- `types/openclaw.d.ts`
|
||||
|
||||
Current dependency constraint:
|
||||
|
||||
- `package.json` currently pins `openclaw` peer support to `>=2026.1.29`
|
||||
- the local installed dependency is `openclaw@2026.2.6`
|
||||
- ContextEngine work requires moving to the `2026.3.7+` SDK surface
|
||||
|
||||
## Target Architecture
|
||||
|
||||
Add a `BasicMemoryContextEngine` that composes with the default OpenClaw flow.
|
||||
|
||||
Expected lifecycle usage:
|
||||
|
||||
- `bootstrap`
|
||||
- initialize BM session-side recall state
|
||||
- gather small, high-signal context such as active tasks and recent activity
|
||||
- `assemble`
|
||||
- pass through OpenClaw messages
|
||||
- optionally add a compact BM recall block when useful
|
||||
- `afterTurn`
|
||||
- persist durable takeaways from the completed turn into BM
|
||||
- `prepareSubagentSpawn`
|
||||
- prepare a minimal BM handoff for a child session
|
||||
- `onSubagentEnded`
|
||||
- capture child results back into BM
|
||||
- `compact`
|
||||
- do not customize
|
||||
- use legacy/default pass-through behavior only if the interface requires it
|
||||
|
||||
## Phase Plan
|
||||
|
||||
## Phase 1
|
||||
|
||||
### Commit goal
|
||||
|
||||
`feat(context-engine): move recall and capture into native lifecycle`
|
||||
|
||||
### Deliverables
|
||||
|
||||
- bump OpenClaw compatibility to `2026.3.7+`
|
||||
- replace the local SDK shim with the real ContextEngine-capable SDK types where possible
|
||||
- add a `BasicMemoryContextEngine`
|
||||
- register the engine through `api.registerContextEngine(...)`
|
||||
- migrate recall behavior from `agent_start` into `bootstrap`
|
||||
- migrate capture behavior from `agent_end` into `afterTurn`
|
||||
- keep existing BM tools and service startup behavior intact
|
||||
- keep compaction fully default
|
||||
|
||||
### Expected behavior
|
||||
|
||||
- session startup still recalls active tasks and recent activity
|
||||
- turns still get captured into BM
|
||||
- plugin behavior is functionally similar to today, but now uses official lifecycle hooks
|
||||
|
||||
### Test coverage
|
||||
|
||||
- engine registration works
|
||||
- `bootstrap` returns expected initialized state when recall finds data
|
||||
- `bootstrap` is a no-op when recall finds nothing
|
||||
- `afterTurn` captures only valid turn content
|
||||
- `afterTurn` handles failures without breaking the run
|
||||
- existing service startup and BM client lifecycle tests still pass
|
||||
|
||||
## Phase 2
|
||||
|
||||
### Commit goal
|
||||
|
||||
`feat(context-engine): add bounded assemble-time BM recall`
|
||||
|
||||
### Deliverables
|
||||
|
||||
- implement a minimal `assemble` hook
|
||||
- preserve incoming OpenClaw messages in order
|
||||
- add an optional BM recall block only when there is useful context
|
||||
- bound the size of injected BM context so it stays cheap and predictable
|
||||
- avoid per-turn graph-heavy retrieval unless explicitly configured later
|
||||
|
||||
### Expected behavior
|
||||
|
||||
- the model sees a small BM memory summary automatically when helpful
|
||||
- explicit `memory_search` and `memory_get` remain available for deeper retrieval
|
||||
- OpenClaw remains in charge of the actual context pipeline and compaction
|
||||
|
||||
### Test coverage
|
||||
|
||||
- `assemble` returns original messages unchanged when no recall block exists
|
||||
- `assemble` adds a BM block when recall content exists
|
||||
- injected content is size-bounded
|
||||
- assembly remains stable across repeated turns when recall content is unchanged
|
||||
|
||||
## Phase 3
|
||||
|
||||
### Commit goal
|
||||
|
||||
`feat(context-engine): add subagent memory handoff`
|
||||
|
||||
### Deliverables
|
||||
|
||||
- implement `prepareSubagentSpawn`
|
||||
- implement `onSubagentEnded`
|
||||
- create a small BM handoff model for parent to child context transfer
|
||||
- capture child outputs or summaries back into the parent knowledge base
|
||||
- keep subagent integration lightweight and failure-tolerant
|
||||
|
||||
### Expected behavior
|
||||
|
||||
- subagents start with relevant BM context instead of a cold memory start
|
||||
- useful child outputs become durable BM knowledge after completion
|
||||
- failures in handoff/capture do not break subagent execution
|
||||
|
||||
### Test coverage
|
||||
|
||||
- child handoff is created for subagent sessions
|
||||
- rollback path works if spawn fails after preparation
|
||||
- child completion writes back expected BM artifacts
|
||||
- delete/release/sweep paths are handled safely
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Engine shape
|
||||
|
||||
Prefer a small, explicit implementation instead of pushing logic back into `index.ts`.
|
||||
|
||||
Likely new files:
|
||||
|
||||
- `context-engine/basic-memory-context-engine.ts`
|
||||
- `context-engine/basic-memory-context-engine.test.ts`
|
||||
- optional small helper modules for recall/capture formatting
|
||||
|
||||
### Hook migration
|
||||
|
||||
After Phase 1 lands, the old event-hook path in `index.ts` should be removed or disabled so we do not double-capture or double-recall.
|
||||
|
||||
### Tool preservation
|
||||
|
||||
The BM tool surface remains part of the product even after lifecycle integration:
|
||||
|
||||
- composited `memory_search` and `memory_get`
|
||||
- graph CRUD tools
|
||||
- schema tools
|
||||
- slash commands and CLI commands
|
||||
|
||||
Lifecycle integration complements explicit retrieval; it does not replace it.
|
||||
|
||||
### Compatibility posture
|
||||
|
||||
This work should be shipped as the canonical BM integration path for OpenClaw `2026.3.7+`.
|
||||
|
||||
If we need a temporary compatibility story for older OpenClaw versions, keep it shallow and time-boxed. The long-term target should be one code path based on the native lifecycle.
|
||||
|
||||
## Risks
|
||||
|
||||
### Single-slot context engine model
|
||||
|
||||
OpenClaw currently resolves one `contextEngine` slot, not a middleware stack.
|
||||
|
||||
Implication:
|
||||
|
||||
- our engine must behave like "default behavior plus BM enrichment"
|
||||
- we should not assume we can stack with other context engines automatically
|
||||
|
||||
### Over-injection
|
||||
|
||||
If `assemble` injects too much, BM could bloat prompt cost and work against the default system.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- keep Phase 2 narrow
|
||||
- bound injected size
|
||||
- prefer summaries over raw note dumps
|
||||
|
||||
### Double-processing during migration
|
||||
|
||||
If old hooks and new lifecycle paths run together, recall and capture may happen twice.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- Phase 1 should explicitly remove or disable the legacy hook wiring
|
||||
- add tests that assert only one capture path is active
|
||||
|
||||
## Success Criteria
|
||||
|
||||
This feature is complete when:
|
||||
|
||||
- recall and capture happen through ContextEngine lifecycle hooks, not event shims
|
||||
- BM enriches default session context without taking over compaction
|
||||
- subagents inherit and return useful durable memory
|
||||
- explicit BM tools remain intact
|
||||
- the architecture clearly reflects "BM decorates OpenClaw memory"
|
||||
|
||||
## Commit Sequence
|
||||
|
||||
1. `feat(context-engine): move recall and capture into native lifecycle`
|
||||
2. `feat(context-engine): add bounded assemble-time BM recall`
|
||||
3. `feat(context-engine): add subagent memory handoff`
|
||||
|
||||
## Out of Scope for This Stack
|
||||
|
||||
- custom `compact` logic
|
||||
- BM-driven token budgeting
|
||||
- replacing post-compaction context reinjection
|
||||
- new retrieval heuristics beyond a compact recall block
|
||||
- multi-engine composition support inside OpenClaw core
|
||||
@@ -0,0 +1,130 @@
|
||||
# Development
|
||||
|
||||
## Local Setup
|
||||
|
||||
Clone and link locally for plugin development:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/basicmachines-co/basic-memory.git
|
||||
cd basic-memory/integrations/openclaw
|
||||
bun install
|
||||
bun run fetch-skills
|
||||
openclaw plugins install -l "$PWD"
|
||||
openclaw plugins enable openclaw-basic-memory --slot memory
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
Or load directly from a path in your OpenClaw config:
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
load: {
|
||||
paths: ["~/dev/basic-memory/integrations/openclaw"]
|
||||
},
|
||||
entries: {
|
||||
"openclaw-basic-memory": {
|
||||
enabled: true
|
||||
}
|
||||
},
|
||||
slots: {
|
||||
memory: "openclaw-basic-memory"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
just check # fetch skills, typecheck, lint, build, unit tests
|
||||
just release-check # check + npm pack --dry-run
|
||||
bun run check-types # Type checking
|
||||
bun run build # Compile package runtime to dist/
|
||||
bun run lint # Linting
|
||||
bun test # Run tests
|
||||
bun run test:int # Real BM MCP integration tests
|
||||
```
|
||||
|
||||
## Integration Tests
|
||||
|
||||
Real end-to-end tests for `BmClient` in `integration/bm-client.integration.test.ts`. These launch a real `bm mcp --transport stdio` process and assert actual filesystem/index results.
|
||||
|
||||
```bash
|
||||
bun run test:int
|
||||
```
|
||||
|
||||
By default this uses `./scripts/bm-local.sh`, which runs BM from the monorepo root via `uv run --project ...` when present, and falls back to `bm` on `PATH` otherwise.
|
||||
|
||||
Optional overrides:
|
||||
|
||||
```bash
|
||||
BM_BIN=/absolute/path/to/bm bun run test:int
|
||||
BASIC_MEMORY_REPO=/absolute/path/to/basic-memory bun run test:int
|
||||
```
|
||||
|
||||
## Publishing to npm
|
||||
|
||||
This package is published as `@basicmemory/openclaw-basic-memory`.
|
||||
|
||||
```bash
|
||||
# Verify release readiness (types + build + tests + npm pack dry run)
|
||||
just release-check
|
||||
|
||||
# Inspect publish payload
|
||||
just release-pack
|
||||
|
||||
# Authenticate once (if needed)
|
||||
npm login
|
||||
|
||||
# Publish current version from package.json
|
||||
just release-publish
|
||||
```
|
||||
|
||||
For a full release (version bump + publish + push tag):
|
||||
|
||||
```bash
|
||||
just release patch # or: minor, major, 0.2.0, etc.
|
||||
```
|
||||
|
||||
### GitHub Actions CI/CD
|
||||
|
||||
- CI workflow: `.github/workflows/ci.yml` runs on PRs and `main` pushes
|
||||
- Release workflow: `.github/workflows/release.yml` runs manually (`workflow_dispatch`)
|
||||
1. Runs release checks
|
||||
2. Bumps version and creates a git tag
|
||||
3. Pushes commit + tag
|
||||
4. Publishes to npm
|
||||
5. Creates a GitHub release
|
||||
|
||||
Publishing uses npm OIDC trusted publishing — no secrets required.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
openclaw-basic-memory/
|
||||
├── index.ts # Plugin entry — manages MCP lifecycle, registers tools
|
||||
├── config.ts # Configuration parsing
|
||||
├── bm-client.ts # Persistent Basic Memory MCP stdio client
|
||||
├── tools/ # Agent tools
|
||||
│ ├── search-notes.ts # search_notes
|
||||
│ ├── read-note.ts # read_note
|
||||
│ ├── write-note.ts # write_note
|
||||
│ ├── edit-note.ts # edit_note
|
||||
│ ├── delete-note.ts # delete_note
|
||||
│ ├── move-note.ts # move_note
|
||||
│ ├── build-context.ts # build_context
|
||||
│ ├── list-memory-projects.ts # list_memory_projects
|
||||
│ ├── list-workspaces.ts # list_workspaces
|
||||
│ ├── schema-validate.ts # schema_validate
|
||||
│ ├── schema-infer.ts # schema_infer
|
||||
│ ├── schema-diff.ts # schema_diff
|
||||
│ └── memory-provider.ts # Composited memory_search + memory_get
|
||||
├── commands/
|
||||
│ ├── slash.ts # /remember, /recall
|
||||
│ ├── skills.ts # /tasks, /reflect, /defrag, /schema
|
||||
│ └── cli.ts # openclaw basic-memory CLI
|
||||
└── hooks/
|
||||
├── capture.ts # Auto-capture conversations
|
||||
└── recall.ts # Auto-recall (active tasks + recent activity)
|
||||
```
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Basic Machines
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,156 @@
|
||||
# Memory + Task Flow
|
||||
|
||||
This document explains how `openclaw-basic-memory` manages short-term memory, long-term memory, and active tasks during normal agent usage.
|
||||
|
||||
## Mental Model
|
||||
|
||||
The plugin composes memory from three places:
|
||||
|
||||
1. `MEMORY.md` (working memory): lightweight, grep-style retrieval.
|
||||
2. Basic Memory knowledge graph: semantic + FTS search over indexed markdown notes.
|
||||
3. `memory/tasks/*.md` (active tasks): execution state with explicit status.
|
||||
|
||||
`memory_search` merges these into one response so agents can reason over current context and durable notes together.
|
||||
|
||||
> **Note on MEMORY.md role:** OpenClaw's default convention treats `MEMORY.md` as [long-term curated memory](https://docs.openclaw.ai/concepts/memory) — decisions, preferences, and durable facts. This plugin **flips that role**: because Basic Memory provides a full knowledge graph for durable storage (`memory/**/*.md`), `MEMORY.md` serves as short-horizon working memory instead. Long-lived knowledge belongs in the knowledge graph where it gets indexed, linked, and searchable. `MEMORY.md` stays concise and focused on current session context.
|
||||
|
||||
## End-to-End Flow
|
||||
|
||||
1. Agent/user writes notes with `write_note` (or manually edits markdown files).
|
||||
2. The MCP session monitors file changes and indexes them automatically.
|
||||
3. Updated notes become searchable in the knowledge graph.
|
||||
4. Agent calls `memory_search`:
|
||||
- searches `MEMORY.md` for matching lines with context
|
||||
- queries BM graph for top knowledge results
|
||||
- resolves active tasks (graph query first, filesystem fallback)
|
||||
5. Agent calls `memory_get` or `read_note` for full note content.
|
||||
6. Agent updates notes/tasks via `edit_note`.
|
||||
7. Completed tasks are excluded from active task results when `status: done`.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["Agent/User updates notes (write_note, edit_note, markdown files)"] --> B["MCP session monitors file changes"]
|
||||
B --> C["Basic Memory index refresh"]
|
||||
C --> D["Agent calls memory_search(query)"]
|
||||
|
||||
D --> E["Search MEMORY.md (line-context grep)"]
|
||||
D --> F["Search Knowledge Graph (BM FTS + semantic)"]
|
||||
D --> G["Resolve Active Tasks"]
|
||||
|
||||
G --> G1["BM task query first"]
|
||||
G1 --> G2["Fallback scan: memory/tasks/*.md"]
|
||||
G2 --> G3["Filter out status: done"]
|
||||
|
||||
E --> H["Composited memory_search response"]
|
||||
F --> H
|
||||
G3 --> H
|
||||
|
||||
H --> I["Agent drills into result (memory_get or read_note)"]
|
||||
I --> J["Agent updates task state (current_step, checkboxes, status)"]
|
||||
J --> D
|
||||
```
|
||||
|
||||
## Task Notes: Recommended Structure
|
||||
|
||||
Use `memory/tasks/` and include:
|
||||
|
||||
- `status` in frontmatter (`active`, `blocked`, `done`, etc.)
|
||||
- `current_step` in frontmatter
|
||||
- `## Context` section with concise situational state
|
||||
|
||||
Example:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: migrate-auth-routes
|
||||
type: Task
|
||||
status: active
|
||||
current_step: 3
|
||||
---
|
||||
|
||||
## Context
|
||||
Auth middleware is deployed behind a flag. Monitoring error rates before full rollout.
|
||||
|
||||
## Plan
|
||||
- [x] Add middleware
|
||||
- [x] Add tests
|
||||
- [x] Deploy to staging
|
||||
- [ ] Validate metrics
|
||||
- [ ] Roll out to production
|
||||
```
|
||||
|
||||
Completion update:
|
||||
|
||||
```yaml
|
||||
status: done
|
||||
```
|
||||
|
||||
## How Active Task Resolution Works
|
||||
|
||||
When `memory_search` runs, task results are computed in this order:
|
||||
|
||||
1. Query BM graph using a task-focused query.
|
||||
2. If no results (or BM task query fails), scan `memory/tasks/*.md` directly.
|
||||
3. Exclude tasks where `status` is exactly `done`.
|
||||
4. Build preview snippets from task metadata and `## Context`.
|
||||
|
||||
This fallback behavior keeps tasks discoverable even if the graph index is stale or temporarily unavailable.
|
||||
|
||||
## Common Operations
|
||||
|
||||
### Create a task
|
||||
|
||||
```
|
||||
write_note(
|
||||
title="migrate-auth-routes",
|
||||
folder="tasks",
|
||||
content="""---
|
||||
title: migrate-auth-routes
|
||||
type: Task
|
||||
status: active
|
||||
current_step: 1
|
||||
---
|
||||
|
||||
## Context
|
||||
Starting auth route migration.
|
||||
|
||||
## Plan
|
||||
- [ ] Implement middleware
|
||||
- [ ] Add tests"""
|
||||
)
|
||||
```
|
||||
|
||||
### Advance a task
|
||||
|
||||
- Update plan checkboxes with `edit_note` + `replace_section`
|
||||
- Bump step with `edit_note` + `find_replace` (for `current_step`)
|
||||
|
||||
### Complete a task
|
||||
|
||||
Use `edit_note` (`find_replace`) to change `status: active` to `status: done`.
|
||||
|
||||
## Operational Tips
|
||||
|
||||
1. Keep `MEMORY.md` concise and current; move durable details to notes under `memory/`.
|
||||
2. Prefer one task note per workstream; update it incrementally instead of rewriting.
|
||||
3. Ensure `memoryDir` points to the directory that contains your `tasks/` folder.
|
||||
4. If task search looks wrong, verify the MCP session is connected and notes exist in expected paths.
|
||||
|
||||
## Bundled Skills
|
||||
|
||||
This plugin ships with workflow-oriented skills that are automatically loaded when the plugin is enabled:
|
||||
|
||||
- **`memory-tasks`** — standardized task creation/resume/update flow
|
||||
- **`memory-reflect`** — periodic memory consolidation from recent notes
|
||||
- **`memory-defrag`** — periodic cleanup/reorganization of memory files
|
||||
- **`memory-schema`** — schema lifecycle management (infer, create, validate, diff, evolve)
|
||||
- **`memory-metadata-search`** — structured metadata search by custom frontmatter fields (status, priority, etc.)
|
||||
- **`memory-notes`** — guidance for writing well-structured notes with observations and relations
|
||||
|
||||
No manual installation needed. To update skills or install new ones as they become available:
|
||||
|
||||
```bash
|
||||
npx skills add basicmachines-co/basic-memory --path skills --agent openclaw
|
||||
```
|
||||
|
||||
See the canonical source at [`basic-memory/skills`](../../skills).
|
||||
@@ -0,0 +1,279 @@
|
||||
# openclaw-basic-memory
|
||||
|
||||
Give your OpenClaw agent persistent, searchable memory — in plain text files you can read and edit.
|
||||
|
||||
## What is Basic Memory?
|
||||
|
||||
[Basic Memory](https://basicmemory.com) stores AI knowledge in local Markdown files and indexes them into a semantic knowledge graph. Your agent writes notes. You can open them in any editor, read them, change them, and the changes sync back automatically. No black box. No proprietary format. Just files.
|
||||
|
||||
It does three things that work together:
|
||||
|
||||
- **Stores knowledge in plain Markdown** — everything lives in plain text files on your computer, not locked inside a database you can't read
|
||||
- **Creates connections automatically** — notes link to each other through a searchable, traversable knowledge graph
|
||||
- **Searches by meaning, not just keywords** — vector search finds relevant context even when the exact words don't match
|
||||
- **Keeps notes consistent** — dynamic schemas and validation ensure your knowledge base stays structured and useful as it grows
|
||||
- **Enables two-way collaboration** — both you and the AI read and write the same files
|
||||
|
||||
Over time, your agent builds a knowledge base that grows with you. Context that survives across sessions. Memory that belongs to you.
|
||||
|
||||
Learn more: [basicmemory.com](https://basicmemory.com) · [GitHub](https://github.com/basicmachines-co/basic-memory) · [Docs](https://docs.basicmemory.com)
|
||||
|
||||
Source now lives in the canonical [`basic-memory`](https://github.com/basicmachines-co/basic-memory) repository under `integrations/openclaw/`. The npm package remains `@basicmemory/openclaw-basic-memory`.
|
||||
|
||||
Maintainer check from the monorepo root:
|
||||
|
||||
```bash
|
||||
just package-check-openclaw
|
||||
```
|
||||
|
||||
## What this plugin does
|
||||
|
||||
This plugin connects Basic Memory to OpenClaw so your agent can:
|
||||
|
||||
- **Remember across sessions** — search and recall past conversations, decisions, and context
|
||||
- **Track work in progress** — structured tasks that survive context compaction
|
||||
- **Build knowledge over time** — notes, observations, and relations that grow into a connected graph
|
||||
- **Search intelligently** — composited search across working memory, the knowledge graph, and active tasks in parallel
|
||||
|
||||
All data stays on your machine as Markdown files indexed locally with SQLite. Cloud sync is available but entirely optional.
|
||||
|
||||
## Install
|
||||
|
||||
**Prerequisite:** [uv](https://docs.astral.sh/uv/) (Python package manager) — used to install the Basic Memory CLI.
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
brew install uv
|
||||
|
||||
# macOS / Linux
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
```
|
||||
|
||||
Then install the plugin:
|
||||
|
||||
```bash
|
||||
openclaw plugins install @basicmemory/openclaw-basic-memory
|
||||
openclaw plugins enable openclaw-basic-memory --slot memory
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
That's it. The plugin auto-installs the `bm` CLI on first startup if it's not already on your PATH. See [SECURITY.md](./SECURITY.md) for details on how this works.
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
openclaw plugins list
|
||||
openclaw plugins inspect openclaw-basic-memory --json
|
||||
openclaw plugins doctor
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Zero-config (recommended)
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
"openclaw-basic-memory": {
|
||||
enabled: true
|
||||
}
|
||||
},
|
||||
slots: {
|
||||
memory: "openclaw-basic-memory"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This uses sensible defaults: auto-generated project name, maps to your workspace root, captures conversations, and recalls active tasks on session start.
|
||||
|
||||
### Full options
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
"openclaw-basic-memory": {
|
||||
enabled: true,
|
||||
config: {
|
||||
project: "my-agent", // BM project name (default: "openclaw-{hostname}")
|
||||
projectPath: ".", // Project directory (default: workspace root)
|
||||
memoryDir: "memory/", // Where task notes live
|
||||
memoryFile: "MEMORY.md", // Working memory file
|
||||
autoCapture: true, // Record conversations as daily notes
|
||||
autoRecall: true, // Inject active tasks + recent activity at session start
|
||||
debug: false // Verbose logging
|
||||
}
|
||||
}
|
||||
},
|
||||
slots: {
|
||||
memory: "openclaw-basic-memory"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `project` | `"openclaw-{hostname}"` | Basic Memory project name |
|
||||
| `bmPath` | `"bm"` | Path to BM CLI binary |
|
||||
| `projectPath` | `"."` | Project data directory |
|
||||
| `memoryDir` | `"memory/"` | Relative path for task scanning |
|
||||
| `memoryFile` | `"MEMORY.md"` | Working memory file for text search |
|
||||
| `autoCapture` | `true` | Auto-index agent conversations |
|
||||
| `captureMinChars` | `10` | Min chars to trigger capture |
|
||||
| `autoRecall` | `true` | Inject context at session start |
|
||||
| `recallPrompt` | *(default)* | Instruction appended to recalled context |
|
||||
| `debug` | `false` | Verbose logs |
|
||||
|
||||
## How it works
|
||||
|
||||
### Memory search
|
||||
|
||||
When your agent calls `memory_search`, three sources are queried in parallel:
|
||||
|
||||
1. **MEMORY.md** — text search with surrounding context
|
||||
2. **Knowledge Graph** — hybrid full-text + vector search across all indexed notes
|
||||
3. **Active Tasks** — scans `memory/tasks/` for in-progress work
|
||||
|
||||
Results come back in clear sections so the agent knows where each piece of context came from.
|
||||
|
||||
### Auto-recall
|
||||
|
||||
On each session start, the plugin loads active tasks and recently modified notes, giving the agent immediate awareness of ongoing work without being asked.
|
||||
|
||||
### Auto-capture
|
||||
|
||||
After each conversation turn, the plugin records the exchange as a timestamped entry in a daily note. This builds a searchable history of everything your agent has discussed.
|
||||
|
||||
### Persistent connection
|
||||
|
||||
The plugin keeps a long-lived Basic Memory process running over standard I/O. No cold starts per tool call. The connection auto-reconnects if it drops.
|
||||
|
||||
## Agent tools
|
||||
|
||||
All tools accept an optional `project` parameter for cross-project operations.
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `memory_search` | Composited search across all memory sources |
|
||||
| `memory_get` | Read a specific note by title or path |
|
||||
| `search_notes` | Search the knowledge graph directly |
|
||||
| `read_note` | Read a note by title, permalink, or `memory://` URL |
|
||||
| `write_note` | Create or update a note |
|
||||
| `edit_note` | Append, prepend, find/replace, or replace a section |
|
||||
| `delete_note` | Delete a note |
|
||||
| `move_note` | Move a note to a different folder |
|
||||
| `build_context` | Navigate the knowledge graph — follow relations and connections |
|
||||
| `list_memory_projects` | List accessible projects |
|
||||
| `list_workspaces` | List workspaces (personal and org) |
|
||||
| `schema_validate` | Validate notes against Picoschema definitions |
|
||||
| `schema_infer` | Analyze notes and suggest a schema |
|
||||
| `schema_diff` | Detect drift between schema and actual usage |
|
||||
|
||||
## Slash commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/bm-setup` | Install or update the Basic Memory CLI |
|
||||
| `/remember <text>` | Save a quick note |
|
||||
| `/recall <query>` | Search the knowledge graph |
|
||||
| `/tasks [args]` | Create, track, resume structured tasks |
|
||||
| `/reflect [args]` | Consolidate recent notes into long-term memory |
|
||||
| `/defrag [args]` | Reorganize and clean up memory files |
|
||||
| `/schema [args]` | Manage Picoschema definitions |
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
openclaw basic-memory search "auth patterns" --limit 5
|
||||
openclaw basic-memory read "projects/api-redesign"
|
||||
openclaw basic-memory context "memory://projects/api-redesign" --depth 2
|
||||
openclaw basic-memory recent --timeframe 24h
|
||||
openclaw basic-memory status
|
||||
```
|
||||
|
||||
## Bundled skills
|
||||
|
||||
Ten skills ship with the plugin — no installation needed:
|
||||
|
||||
- **memory-defrag** — cleanup and reorganization of memory files
|
||||
- **memory-ingest** — import existing material into Basic Memory
|
||||
- **memory-lifecycle** — manage note/project lifecycle workflows
|
||||
- **memory-literary-analysis** — analyze texts and reading notes
|
||||
- **memory-metadata-search** — query notes by frontmatter fields
|
||||
- **memory-notes** — guidance for writing well-structured notes
|
||||
- **memory-reflect** — periodic consolidation of recent notes into durable memory
|
||||
- **memory-research** — research synthesis into durable notes
|
||||
- **memory-schema** — schema lifecycle (infer, create, validate, diff)
|
||||
- **memory-tasks** — structured task tracking that survives context compaction
|
||||
|
||||
### Updating skills
|
||||
|
||||
```bash
|
||||
bun run fetch-skills
|
||||
```
|
||||
|
||||
In the monorepo, this copies from the canonical top-level [`skills/`](../../skills) directory into the generated `integrations/openclaw/skills/` package bundle.
|
||||
|
||||
## Task notes
|
||||
|
||||
The plugin works well with structured task notes in `memory/tasks/`:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: auth-middleware-rollout
|
||||
type: Task
|
||||
status: active
|
||||
current_step: 2
|
||||
---
|
||||
|
||||
## Context
|
||||
Rolling JWT middleware to all API routes.
|
||||
|
||||
## Plan
|
||||
- [x] Implement middleware
|
||||
- [x] Add refresh-token validation
|
||||
- [ ] Roll out to staging
|
||||
- [ ] Verify logs and error rates
|
||||
```
|
||||
|
||||
Set `status: done` to mark complete. Done tasks are filtered out of active task results.
|
||||
|
||||
## Basic Memory Cloud
|
||||
|
||||
Everything works locally. Cloud adds cross-device sync, team workspaces, and persistent memory for hosted agents.
|
||||
|
||||
- Same knowledge graph on laptop, desktop, and CI
|
||||
- Shared workspaces for teams
|
||||
- Durable memory for production agents
|
||||
|
||||
Cloud extends local-first — still plain Markdown, still yours. [Start a free trial](https://basicmemory.com) and use code `BMCLAW` for 20% off for 3 months. See [BASIC_MEMORY.md](./BASIC_MEMORY.md) for setup.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**`bm` not found** — Install uv, then restart the gateway. Or install manually: `uv tool install basic-memory`
|
||||
|
||||
**Search returns nothing** — Check that Basic Memory connected (look for `connected to BM` in logs). Verify files exist in the project directory.
|
||||
|
||||
**Jiti cache issues** — `rm -rf /tmp/jiti/ "$TMPDIR/jiti/"` then restart the gateway.
|
||||
|
||||
**Disable semantic search** — Set `BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=false` to fall back to full-text only.
|
||||
|
||||
## More
|
||||
|
||||
- [Memory + Task Flow](./MEMORY_TASK_FLOW.md) — practical runbook
|
||||
- [Cloud Setup](./BASIC_MEMORY.md) — configure Basic Memory Cloud
|
||||
- [Security](./SECURITY.md) — how auto-installation and data handling work
|
||||
- [Development](./DEVELOPMENT.md) — contributing, tests, publishing
|
||||
- [Basic Memory docs](https://docs.basicmemory.com)
|
||||
- [Issues](https://github.com/basicmachines-co/basic-memory/issues)
|
||||
|
||||
## Telemetry
|
||||
|
||||
This plugin does not collect telemetry. The Basic Memory CLI may send anonymous usage analytics — see the [Basic Memory docs](https://github.com/basicmachines-co/basic-memory) for opt-out instructions.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,61 @@
|
||||
# Security
|
||||
|
||||
## Automated Dependency Installation
|
||||
|
||||
On first startup, this plugin checks whether the `bm` (Basic Memory) CLI is available on your system. If it's not found, the plugin runs `uv tool install` to install it automatically.
|
||||
|
||||
### What happens
|
||||
|
||||
1. The plugin checks if `bm` exists on `PATH`
|
||||
2. If missing, it executes `uv tool install basic-memory --force`
|
||||
3. This installs the Basic Memory CLI into uv's managed tool directory (`~/.local/bin/bm` on most systems)
|
||||
|
||||
### What this means
|
||||
|
||||
- The plugin uses Node.js `child_process.execSync` to run `uv` as a shell command
|
||||
- This requires `uv` (the Python package manager from Astral) to be installed on your system
|
||||
- The installation pulls the public `basic-memory` package from PyPI
|
||||
- If `uv` is not installed, the step is skipped gracefully — no error, no crash
|
||||
|
||||
### If you prefer manual installation
|
||||
|
||||
You can install Basic Memory yourself before enabling the plugin:
|
||||
|
||||
```bash
|
||||
uv tool install basic-memory
|
||||
```
|
||||
|
||||
Or with pip:
|
||||
|
||||
```bash
|
||||
pip install basic-memory
|
||||
```
|
||||
|
||||
Once `bm` is on your PATH, the plugin will find it and skip the auto-install step entirely.
|
||||
|
||||
### Opting out
|
||||
|
||||
If you want to control the exact binary the plugin uses, set `bmPath` in your plugin config to an absolute path:
|
||||
|
||||
```json5
|
||||
{
|
||||
"openclaw-basic-memory": {
|
||||
config: {
|
||||
bmPath: "/usr/local/bin/bm"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The plugin will use that path directly and never attempt auto-installation.
|
||||
|
||||
## Data Handling
|
||||
|
||||
- All knowledge graph data is stored as plain Markdown files on your local filesystem
|
||||
- The plugin spawns a persistent `bm mcp` process that indexes files into a local SQLite database
|
||||
- No data leaves your machine unless you explicitly configure [Basic Memory Cloud](./BASIC_MEMORY.md)
|
||||
- The plugin does not collect telemetry
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
If you find a security issue, please email security@basicmemory.com or open a private advisory on GitHub.
|
||||
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.16/schema.json",
|
||||
"assist": {
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": "on",
|
||||
"useSortedAttributes": "on",
|
||||
"useSortedKeys": "off"
|
||||
}
|
||||
},
|
||||
"enabled": true
|
||||
},
|
||||
"files": {
|
||||
"includes": [
|
||||
"**",
|
||||
"!**/node_modules",
|
||||
"!**/dist",
|
||||
"!**/bun.lock",
|
||||
"!**/*.lock",
|
||||
"!.claude"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double",
|
||||
"semicolons": "asNeeded"
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"includes": ["**/*.test.ts"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"style": {
|
||||
"noNonNullAssertion": "off"
|
||||
},
|
||||
"complexity": {
|
||||
"noBannedTypes": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"linter": {
|
||||
"domains": {
|
||||
"project": "none"
|
||||
},
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"correctness": {
|
||||
"useYield": "warn",
|
||||
"noUnusedVariables": {
|
||||
"level": "warn",
|
||||
"options": {
|
||||
"ignoreRestSiblings": true
|
||||
}
|
||||
},
|
||||
"noUnusedImports": "warn",
|
||||
"useParseIntRadix": "off"
|
||||
},
|
||||
"recommended": true,
|
||||
"style": {
|
||||
"noDefaultExport": "off",
|
||||
"noInferrableTypes": "error",
|
||||
"noNonNullAssertion": "warn",
|
||||
"noParameterAssign": "error",
|
||||
"noUnusedTemplateLiteral": "error",
|
||||
"noUselessElse": "error",
|
||||
"useAsConstAssertion": "error",
|
||||
"useDefaultParameterLast": "error",
|
||||
"useEnumInitializers": "error",
|
||||
"useNamingConvention": {
|
||||
"level": "off",
|
||||
"options": {
|
||||
"strictCase": false
|
||||
}
|
||||
},
|
||||
"useNumberNamespace": "error",
|
||||
"useSelfClosingElements": "error",
|
||||
"useSingleVarDeclarator": "error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"vcs": {
|
||||
"clientKind": "git",
|
||||
"enabled": true,
|
||||
"useIgnoreFile": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
import { beforeEach, describe, expect, it, jest } from "bun:test"
|
||||
import { BmClient } from "./bm-client.ts"
|
||||
|
||||
const DEFAULT_PROJECT = "test-project"
|
||||
|
||||
function mcpResult(payload: unknown) {
|
||||
return {
|
||||
structuredContent: { result: payload },
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(payload),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function setConnected(client: BmClient, callTool: jest.Mock) {
|
||||
;(client as any).client = {
|
||||
callTool,
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
;(client as any).transport = {
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
}
|
||||
|
||||
describe("BmClient MCP behavior", () => {
|
||||
let client: BmClient
|
||||
|
||||
beforeEach(() => {
|
||||
client = new BmClient("/usr/local/bin/bm", DEFAULT_PROJECT)
|
||||
})
|
||||
|
||||
it("readNote calls read_note with JSON output and no frontmatter by default", async () => {
|
||||
const callTool = jest.fn().mockResolvedValue(
|
||||
mcpResult({
|
||||
title: "t",
|
||||
permalink: "p",
|
||||
content: "body",
|
||||
file_path: "notes/t.md",
|
||||
frontmatter: null,
|
||||
}),
|
||||
)
|
||||
setConnected(client, callTool)
|
||||
|
||||
const note = await client.readNote("t")
|
||||
|
||||
expect(callTool).toHaveBeenCalledWith({
|
||||
name: "read_note",
|
||||
arguments: {
|
||||
identifier: "t",
|
||||
include_frontmatter: false,
|
||||
output_format: "json",
|
||||
project: DEFAULT_PROJECT,
|
||||
},
|
||||
})
|
||||
expect(note.content).toBe("body")
|
||||
})
|
||||
|
||||
it("readNote includes frontmatter when requested", async () => {
|
||||
const raw = "---\ntitle: t\n---\n\nbody"
|
||||
const callTool = jest.fn().mockResolvedValue(
|
||||
mcpResult({
|
||||
title: "t",
|
||||
permalink: "p",
|
||||
content: raw,
|
||||
file_path: "notes/t.md",
|
||||
frontmatter: { title: "t" },
|
||||
}),
|
||||
)
|
||||
setConnected(client, callTool)
|
||||
|
||||
const note = await client.readNote("t", { includeFrontmatter: true })
|
||||
|
||||
expect(callTool).toHaveBeenCalledWith({
|
||||
name: "read_note",
|
||||
arguments: {
|
||||
identifier: "t",
|
||||
include_frontmatter: true,
|
||||
output_format: "json",
|
||||
project: DEFAULT_PROJECT,
|
||||
},
|
||||
})
|
||||
expect(note.content).toBe(raw)
|
||||
expect(note.frontmatter).toEqual({ title: "t" })
|
||||
})
|
||||
|
||||
it("writeNote calls write_note with JSON output", async () => {
|
||||
const callTool = jest.fn().mockResolvedValue(
|
||||
mcpResult({
|
||||
title: "Note",
|
||||
permalink: "notes/note",
|
||||
file_path: "notes/note.md",
|
||||
checksum: "abc123",
|
||||
action: "created",
|
||||
}),
|
||||
)
|
||||
setConnected(client, callTool)
|
||||
|
||||
const result = await client.writeNote("Note", "hello", "notes")
|
||||
|
||||
expect(callTool).toHaveBeenCalledWith({
|
||||
name: "write_note",
|
||||
arguments: {
|
||||
title: "Note",
|
||||
content: "hello",
|
||||
directory: "notes",
|
||||
output_format: "json",
|
||||
project: DEFAULT_PROJECT,
|
||||
},
|
||||
})
|
||||
expect(result.checksum).toBe("abc123")
|
||||
expect(result.action).toBe("created")
|
||||
})
|
||||
|
||||
it("writeNote passes overwrite flag when provided", async () => {
|
||||
const callTool = jest.fn().mockResolvedValue(
|
||||
mcpResult({
|
||||
title: "Note",
|
||||
permalink: "notes/note",
|
||||
file_path: "notes/note.md",
|
||||
action: "updated",
|
||||
}),
|
||||
)
|
||||
setConnected(client, callTool)
|
||||
|
||||
await client.writeNote("Note", "hello", "notes", undefined, true)
|
||||
|
||||
expect(callTool).toHaveBeenCalledWith({
|
||||
name: "write_note",
|
||||
arguments: {
|
||||
title: "Note",
|
||||
content: "hello",
|
||||
directory: "notes",
|
||||
output_format: "json",
|
||||
project: DEFAULT_PROJECT,
|
||||
overwrite: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("writeNote throws NoteAlreadyExistsError on conflict response", async () => {
|
||||
const callTool = jest.fn().mockResolvedValue(
|
||||
mcpResult({
|
||||
title: "Existing",
|
||||
permalink: "notes/existing",
|
||||
file_path: null,
|
||||
checksum: null,
|
||||
action: "conflict",
|
||||
error: "NOTE_ALREADY_EXISTS",
|
||||
}),
|
||||
)
|
||||
setConnected(client, callTool)
|
||||
|
||||
await expect(
|
||||
client.writeNote("Existing", "content", "notes"),
|
||||
).rejects.toThrow("Note already exists")
|
||||
})
|
||||
|
||||
it("editNote calls edit_note with MCP argument names", async () => {
|
||||
const callTool = jest.fn().mockResolvedValue(
|
||||
mcpResult({
|
||||
title: "t",
|
||||
permalink: "p",
|
||||
file_path: "notes/t.md",
|
||||
operation: "find_replace",
|
||||
checksum: "abc",
|
||||
}),
|
||||
)
|
||||
setConnected(client, callTool)
|
||||
|
||||
const result = await client.editNote("t", "find_replace", "new", {
|
||||
find_text: "old",
|
||||
expected_replacements: 2,
|
||||
})
|
||||
|
||||
expect(callTool).toHaveBeenCalledWith({
|
||||
name: "edit_note",
|
||||
arguments: {
|
||||
identifier: "t",
|
||||
operation: "find_replace",
|
||||
content: "new",
|
||||
find_text: "old",
|
||||
section: undefined,
|
||||
expected_replacements: 2,
|
||||
output_format: "json",
|
||||
project: DEFAULT_PROJECT,
|
||||
},
|
||||
})
|
||||
expect(result.checksum).toBe("abc")
|
||||
})
|
||||
|
||||
it("search calls search_notes with paging params", async () => {
|
||||
const callTool = jest.fn().mockResolvedValue(
|
||||
mcpResult({
|
||||
results: [
|
||||
{
|
||||
title: "x",
|
||||
permalink: "x",
|
||||
content: "c",
|
||||
file_path: "notes/x.md",
|
||||
score: 0.9,
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
setConnected(client, callTool)
|
||||
|
||||
const results = await client.search("marketing strategy", 3)
|
||||
|
||||
expect(callTool).toHaveBeenCalledWith({
|
||||
name: "search_notes",
|
||||
arguments: {
|
||||
query: "marketing strategy",
|
||||
page: 1,
|
||||
page_size: 3,
|
||||
output_format: "json",
|
||||
project: DEFAULT_PROJECT,
|
||||
},
|
||||
})
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0].title).toBe("x")
|
||||
})
|
||||
|
||||
it("search passes metadata_filters, tags, and status to search_notes", async () => {
|
||||
const callTool = jest.fn().mockResolvedValue(
|
||||
mcpResult({
|
||||
results: [
|
||||
{
|
||||
title: "Auth Design",
|
||||
permalink: "auth-design",
|
||||
content: "OAuth spec",
|
||||
file_path: "specs/auth-design.md",
|
||||
score: 0.85,
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
setConnected(client, callTool)
|
||||
|
||||
const results = await client.search("oauth", 5, "research", {
|
||||
filters: { type: "spec", confidence: { $gt: 0.7 } },
|
||||
tags: ["security"],
|
||||
status: "in-progress",
|
||||
})
|
||||
|
||||
expect(callTool).toHaveBeenCalledWith({
|
||||
name: "search_notes",
|
||||
arguments: {
|
||||
query: "oauth",
|
||||
page: 1,
|
||||
page_size: 5,
|
||||
output_format: "json",
|
||||
project: "research",
|
||||
metadata_filters: { type: "spec", confidence: { $gt: 0.7 } },
|
||||
tags: ["security"],
|
||||
status: "in-progress",
|
||||
},
|
||||
})
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0].title).toBe("Auth Design")
|
||||
})
|
||||
|
||||
it("search omits metadata args when not provided", async () => {
|
||||
const callTool = jest.fn().mockResolvedValue(mcpResult({ results: [] }))
|
||||
setConnected(client, callTool)
|
||||
|
||||
await client.search("test", 10)
|
||||
|
||||
expect(callTool).toHaveBeenCalledWith({
|
||||
name: "search_notes",
|
||||
arguments: {
|
||||
query: "test",
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
output_format: "json",
|
||||
project: DEFAULT_PROJECT,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("buildContext calls build_context using output_format=json", async () => {
|
||||
const callTool = jest.fn().mockResolvedValue(
|
||||
mcpResult({
|
||||
results: [
|
||||
{
|
||||
primary_result: {
|
||||
title: "x",
|
||||
permalink: "x",
|
||||
content: "body",
|
||||
file_path: "notes/x.md",
|
||||
},
|
||||
observations: [],
|
||||
related_results: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
setConnected(client, callTool)
|
||||
|
||||
const ctx = await client.buildContext("memory://notes/x", 2)
|
||||
|
||||
expect(callTool).toHaveBeenCalledWith({
|
||||
name: "build_context",
|
||||
arguments: {
|
||||
url: "memory://notes/x",
|
||||
depth: 2,
|
||||
output_format: "json",
|
||||
project: DEFAULT_PROJECT,
|
||||
},
|
||||
})
|
||||
expect(ctx.results).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("recentActivity calls recent_activity with JSON output", async () => {
|
||||
const callTool = jest.fn().mockResolvedValue(
|
||||
mcpResult([
|
||||
{
|
||||
title: "x",
|
||||
permalink: "x",
|
||||
file_path: "notes/x.md",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
]),
|
||||
)
|
||||
setConnected(client, callTool)
|
||||
|
||||
const recent = await client.recentActivity("7d")
|
||||
|
||||
expect(callTool).toHaveBeenCalledWith({
|
||||
name: "recent_activity",
|
||||
arguments: {
|
||||
timeframe: "7d",
|
||||
output_format: "json",
|
||||
project: DEFAULT_PROJECT,
|
||||
},
|
||||
})
|
||||
expect(recent).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("listProjects calls list_memory_projects with JSON output", async () => {
|
||||
const callTool = jest.fn().mockResolvedValue(
|
||||
mcpResult({
|
||||
projects: [
|
||||
{
|
||||
name: "alpha",
|
||||
path: "/tmp/alpha",
|
||||
is_default: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
setConnected(client, callTool)
|
||||
|
||||
const projects = await client.listProjects()
|
||||
|
||||
expect(callTool).toHaveBeenCalledWith({
|
||||
name: "list_memory_projects",
|
||||
arguments: {
|
||||
output_format: "json",
|
||||
},
|
||||
})
|
||||
expect(projects[0].name).toBe("alpha")
|
||||
})
|
||||
|
||||
it("listProjects filters workspace client-side without passing unsupported MCP args", async () => {
|
||||
const callTool = jest.fn().mockResolvedValue(
|
||||
mcpResult({
|
||||
projects: [
|
||||
{
|
||||
name: "alpha",
|
||||
path: "/tmp/alpha",
|
||||
workspace_name: "Team Alpha",
|
||||
workspace_slug: "team-alpha",
|
||||
workspace_tenant_id: "tenant-alpha",
|
||||
},
|
||||
{
|
||||
name: "beta",
|
||||
path: "/tmp/beta",
|
||||
workspace_name: "Team Beta",
|
||||
workspace_slug: "team-beta",
|
||||
workspace_tenant_id: "tenant-beta",
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
setConnected(client, callTool)
|
||||
|
||||
const projects = await client.listProjects("team-alpha")
|
||||
|
||||
expect(callTool).toHaveBeenCalledWith({
|
||||
name: "list_memory_projects",
|
||||
arguments: {
|
||||
output_format: "json",
|
||||
},
|
||||
})
|
||||
expect(projects.map((project) => project.name)).toEqual(["alpha"])
|
||||
})
|
||||
|
||||
it("ensureProject calls create_memory_project in idempotent JSON mode", async () => {
|
||||
const callTool = jest.fn().mockResolvedValue(
|
||||
mcpResult({
|
||||
name: "test-project",
|
||||
path: "/tmp/memory",
|
||||
created: false,
|
||||
already_exists: true,
|
||||
}),
|
||||
)
|
||||
setConnected(client, callTool)
|
||||
|
||||
await client.ensureProject("/tmp/memory")
|
||||
|
||||
expect(callTool).toHaveBeenCalledWith({
|
||||
name: "create_memory_project",
|
||||
arguments: {
|
||||
project_name: "test-project",
|
||||
project_path: "/tmp/memory",
|
||||
set_default: true,
|
||||
output_format: "json",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("deleteNote calls delete_note with JSON output", async () => {
|
||||
const callTool = jest.fn().mockResolvedValue(
|
||||
mcpResult({
|
||||
deleted: true,
|
||||
title: "old-note",
|
||||
permalink: "notes/old-note",
|
||||
file_path: "notes/old-note.md",
|
||||
}),
|
||||
)
|
||||
setConnected(client, callTool)
|
||||
|
||||
const result = await client.deleteNote("notes/old-note")
|
||||
|
||||
expect(callTool).toHaveBeenCalledWith({
|
||||
name: "delete_note",
|
||||
arguments: {
|
||||
identifier: "notes/old-note",
|
||||
output_format: "json",
|
||||
project: DEFAULT_PROJECT,
|
||||
},
|
||||
})
|
||||
expect(result.file_path).toBe("notes/old-note.md")
|
||||
})
|
||||
|
||||
it("schemaValidate calls schema_validate with JSON output", async () => {
|
||||
const callTool = jest.fn().mockResolvedValue(
|
||||
mcpResult({
|
||||
entity_type: "person",
|
||||
total_notes: 3,
|
||||
total_entities: 3,
|
||||
valid_count: 3,
|
||||
warning_count: 0,
|
||||
error_count: 0,
|
||||
results: [],
|
||||
}),
|
||||
)
|
||||
setConnected(client, callTool)
|
||||
|
||||
const result = await client.schemaValidate("person")
|
||||
|
||||
expect(callTool).toHaveBeenCalledWith({
|
||||
name: "schema_validate",
|
||||
arguments: {
|
||||
note_type: "person",
|
||||
output_format: "json",
|
||||
project: DEFAULT_PROJECT,
|
||||
},
|
||||
})
|
||||
expect(result.entity_type).toBe("person")
|
||||
expect(result.valid_count).toBe(3)
|
||||
})
|
||||
|
||||
it("schemaValidate passes identifier when provided", async () => {
|
||||
const callTool = jest.fn().mockResolvedValue(
|
||||
mcpResult({
|
||||
entity_type: null,
|
||||
total_notes: 1,
|
||||
total_entities: 1,
|
||||
valid_count: 1,
|
||||
warning_count: 0,
|
||||
error_count: 0,
|
||||
results: [],
|
||||
}),
|
||||
)
|
||||
setConnected(client, callTool)
|
||||
|
||||
await client.schemaValidate(undefined, "notes/my-note")
|
||||
|
||||
expect(callTool).toHaveBeenCalledWith({
|
||||
name: "schema_validate",
|
||||
arguments: {
|
||||
identifier: "notes/my-note",
|
||||
output_format: "json",
|
||||
project: DEFAULT_PROJECT,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("schemaInfer calls schema_infer with threshold", async () => {
|
||||
const callTool = jest.fn().mockResolvedValue(
|
||||
mcpResult({
|
||||
entity_type: "task",
|
||||
notes_analyzed: 10,
|
||||
field_frequencies: [],
|
||||
suggested_schema: {},
|
||||
suggested_required: [],
|
||||
suggested_optional: [],
|
||||
excluded: [],
|
||||
}),
|
||||
)
|
||||
setConnected(client, callTool)
|
||||
|
||||
const result = await client.schemaInfer("task", 0.5)
|
||||
|
||||
expect(callTool).toHaveBeenCalledWith({
|
||||
name: "schema_infer",
|
||||
arguments: {
|
||||
note_type: "task",
|
||||
threshold: 0.5,
|
||||
output_format: "json",
|
||||
project: DEFAULT_PROJECT,
|
||||
},
|
||||
})
|
||||
expect(result.notes_analyzed).toBe(10)
|
||||
})
|
||||
|
||||
it("schemaDiff calls schema_diff with JSON output", async () => {
|
||||
const callTool = jest.fn().mockResolvedValue(
|
||||
mcpResult({
|
||||
entity_type: "person",
|
||||
schema_found: true,
|
||||
new_fields: [{ field: "phone", frequency: 0.6 }],
|
||||
dropped_fields: [],
|
||||
cardinality_changes: [],
|
||||
}),
|
||||
)
|
||||
setConnected(client, callTool)
|
||||
|
||||
const result = await client.schemaDiff("person")
|
||||
|
||||
expect(callTool).toHaveBeenCalledWith({
|
||||
name: "schema_diff",
|
||||
arguments: {
|
||||
note_type: "person",
|
||||
output_format: "json",
|
||||
project: DEFAULT_PROJECT,
|
||||
},
|
||||
})
|
||||
expect(result.new_fields).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("search with note_types and status filters (no query)", async () => {
|
||||
const callTool = jest.fn().mockResolvedValue(
|
||||
mcpResult({
|
||||
results: [
|
||||
{
|
||||
title: "Task 1",
|
||||
permalink: "tasks/task-1",
|
||||
content: "active task",
|
||||
file_path: "tasks/task-1.md",
|
||||
score: 0.9,
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
setConnected(client, callTool)
|
||||
|
||||
const result = await client.search(undefined, 10, undefined, {
|
||||
note_types: ["task"],
|
||||
status: "active",
|
||||
})
|
||||
|
||||
expect(callTool).toHaveBeenCalledWith({
|
||||
name: "search_notes",
|
||||
arguments: {
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
note_types: ["task"],
|
||||
status: "active",
|
||||
output_format: "json",
|
||||
project: DEFAULT_PROJECT,
|
||||
},
|
||||
})
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].title).toBe("Task 1")
|
||||
})
|
||||
|
||||
it("moveNote calls move_note with destination_folder in a single MCP call", async () => {
|
||||
const callTool = jest.fn().mockResolvedValue(
|
||||
mcpResult({
|
||||
moved: true,
|
||||
title: "My Note",
|
||||
permalink: "archive/my-note",
|
||||
file_path: "archive/my-note.md",
|
||||
source: "notes/my-note",
|
||||
destination: "archive/my-note.md",
|
||||
}),
|
||||
)
|
||||
setConnected(client, callTool)
|
||||
|
||||
const result = await client.moveNote("notes/my-note", "archive")
|
||||
|
||||
expect(callTool).toHaveBeenCalledTimes(1)
|
||||
expect(callTool).toHaveBeenCalledWith({
|
||||
name: "move_note",
|
||||
arguments: {
|
||||
identifier: "notes/my-note",
|
||||
destination_folder: "archive",
|
||||
output_format: "json",
|
||||
project: DEFAULT_PROJECT,
|
||||
},
|
||||
})
|
||||
expect(result.title).toBe("My Note")
|
||||
expect(result.file_path).toBe("archive/my-note.md")
|
||||
})
|
||||
|
||||
it("indexConversation surfaces non-not-found append errors without creating", async () => {
|
||||
;(client as any).editNote = jest
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("validation failed"))
|
||||
;(client as any).writeNote = jest.fn()
|
||||
|
||||
await expect(
|
||||
client.indexConversation(
|
||||
"user message long enough",
|
||||
"assistant reply long enough",
|
||||
),
|
||||
).rejects.toThrow("validation failed")
|
||||
|
||||
expect((client as any).writeNote).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("indexConversation creates fallback note only on note-not-found errors", async () => {
|
||||
;(client as any).editNote = jest
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("Entity not found"))
|
||||
;(client as any).writeNote = jest.fn().mockResolvedValue({
|
||||
title: "conversations",
|
||||
permalink: "conversations",
|
||||
content: "x",
|
||||
file_path: "conversations/x.md",
|
||||
})
|
||||
|
||||
await client.indexConversation(
|
||||
"user message long enough",
|
||||
"assistant reply long enough",
|
||||
)
|
||||
|
||||
expect((client as any).writeNote).toHaveBeenCalledTimes(1)
|
||||
const args = (client as any).writeNote.mock.calls[0]
|
||||
expect(args[3]).toBeUndefined() // project
|
||||
expect(args[4]).toBeUndefined() // overwrite
|
||||
})
|
||||
|
||||
it("retries recoverable MCP failures with bounded attempts", async () => {
|
||||
;(client as any).retryDelaysMs = [0, 0, 0]
|
||||
|
||||
const callTool = jest
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("connection closed by peer"))
|
||||
|
||||
;(client as any).ensureConnected = jest.fn().mockResolvedValue({ callTool })
|
||||
;(client as any).disconnectCurrent = jest.fn().mockResolvedValue(undefined)
|
||||
;(client as any).client = { close: jest.fn().mockResolvedValue(undefined) }
|
||||
;(client as any).transport = {
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
|
||||
await expect(
|
||||
(client as any).callToolRaw("search_notes", { query: "x" }),
|
||||
).rejects.toThrow("BM MCP unavailable")
|
||||
|
||||
expect((client as any).ensureConnected).toHaveBeenCalledTimes(4)
|
||||
expect((client as any).disconnectCurrent).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it("does not retry non-recoverable tool failures", async () => {
|
||||
;(client as any).retryDelaysMs = [0, 0, 0]
|
||||
|
||||
const callTool = jest.fn().mockRejectedValue(new Error("invalid params"))
|
||||
|
||||
;(client as any).ensureConnected = jest.fn().mockResolvedValue({ callTool })
|
||||
;(client as any).disconnectCurrent = jest.fn().mockResolvedValue(undefined)
|
||||
|
||||
await expect(
|
||||
(client as any).callToolRaw("search_notes", { query: "x" }),
|
||||
).rejects.toThrow("invalid params")
|
||||
|
||||
expect((client as any).ensureConnected).toHaveBeenCalledTimes(1)
|
||||
expect((client as any).disconnectCurrent).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,915 @@
|
||||
import { setTimeout as delay } from "node:timers/promises"
|
||||
import { Client } from "@modelcontextprotocol/sdk/client"
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
||||
import { log } from "./logger.ts"
|
||||
|
||||
const DEFAULT_RETRY_DELAYS_MS = [500, 1000, 2000]
|
||||
|
||||
export class NoteAlreadyExistsError extends Error {
|
||||
readonly permalink: string
|
||||
constructor(title: string, permalink: string) {
|
||||
super(`Note already exists: "${title}" (${permalink})`)
|
||||
this.name = "NoteAlreadyExistsError"
|
||||
this.permalink = permalink
|
||||
}
|
||||
}
|
||||
|
||||
const REQUIRED_TOOLS = [
|
||||
"search_notes",
|
||||
"read_note",
|
||||
"write_note",
|
||||
"edit_note",
|
||||
"build_context",
|
||||
"recent_activity",
|
||||
"list_memory_projects",
|
||||
"list_workspaces",
|
||||
"create_memory_project",
|
||||
"delete_note",
|
||||
"move_note",
|
||||
"schema_validate",
|
||||
"schema_infer",
|
||||
"schema_diff",
|
||||
]
|
||||
|
||||
export interface SearchResult {
|
||||
title: string
|
||||
permalink: string
|
||||
content: string
|
||||
score?: number
|
||||
file_path: string
|
||||
}
|
||||
|
||||
export interface NoteResult {
|
||||
title: string
|
||||
permalink: string
|
||||
content: string
|
||||
file_path: string
|
||||
frontmatter?: Record<string, unknown> | null
|
||||
checksum?: string | null
|
||||
action?: "created" | "updated"
|
||||
}
|
||||
|
||||
export interface EditNoteResult {
|
||||
title: string
|
||||
permalink: string
|
||||
file_path: string
|
||||
operation: "append" | "prepend" | "find_replace" | "replace_section"
|
||||
checksum?: string | null
|
||||
}
|
||||
|
||||
interface ReadNoteOptions {
|
||||
includeFrontmatter?: boolean
|
||||
}
|
||||
|
||||
interface EditNoteOptions {
|
||||
find_text?: string
|
||||
section?: string
|
||||
expected_replacements?: number
|
||||
}
|
||||
|
||||
export interface ContextResult {
|
||||
results: Array<{
|
||||
primary_result: NoteResult
|
||||
observations: Array<{
|
||||
category: string
|
||||
content: string
|
||||
}>
|
||||
related_results: Array<{
|
||||
type: "relation" | "entity"
|
||||
title?: string
|
||||
permalink: string
|
||||
relation_type?: string
|
||||
from_entity?: string
|
||||
to_entity?: string
|
||||
}>
|
||||
}>
|
||||
}
|
||||
|
||||
export interface RecentResult {
|
||||
title: string
|
||||
permalink: string
|
||||
file_path: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ProjectListResult {
|
||||
name: string
|
||||
path: string
|
||||
display_name?: string | null
|
||||
is_private?: boolean
|
||||
is_default?: boolean
|
||||
isDefault?: boolean
|
||||
workspace_name?: string | null
|
||||
workspace_slug?: string | null
|
||||
workspace_type?: string | null
|
||||
workspace_tenant_id?: string | null
|
||||
}
|
||||
|
||||
export interface WorkspaceResult {
|
||||
tenant_id: string
|
||||
name: string
|
||||
workspace_type: string
|
||||
role: string
|
||||
organization_id?: string | null
|
||||
has_active_subscription: boolean
|
||||
}
|
||||
|
||||
export interface SchemaValidationResult {
|
||||
entity_type: string | null
|
||||
total_notes: number
|
||||
total_entities: number
|
||||
valid_count: number
|
||||
warning_count: number
|
||||
error_count: number
|
||||
results: Array<{
|
||||
identifier: string
|
||||
valid: boolean
|
||||
warnings: string[]
|
||||
errors: string[]
|
||||
}>
|
||||
}
|
||||
|
||||
export interface SchemaInferResult {
|
||||
entity_type: string
|
||||
notes_analyzed: number
|
||||
field_frequencies: Array<{
|
||||
name: string
|
||||
percentage: number
|
||||
count: number
|
||||
total: number
|
||||
source: string
|
||||
sample_values?: string[]
|
||||
is_array?: boolean
|
||||
target_type?: string | null
|
||||
}>
|
||||
suggested_schema: Record<string, unknown>
|
||||
suggested_required: string[]
|
||||
suggested_optional: string[]
|
||||
excluded: string[]
|
||||
}
|
||||
|
||||
export interface SchemaDiffResult {
|
||||
entity_type: string
|
||||
schema_found: boolean
|
||||
new_fields: Array<{
|
||||
name: string
|
||||
source: string
|
||||
count: number
|
||||
total: number
|
||||
percentage: number
|
||||
}>
|
||||
dropped_fields: Array<{ name: string; source: string; declared_in?: string }>
|
||||
cardinality_changes: string[]
|
||||
}
|
||||
|
||||
function getErrorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function extractTextFromContent(content: unknown): string {
|
||||
if (!Array.isArray(content)) return ""
|
||||
|
||||
const textBlocks = content
|
||||
.filter(
|
||||
(block): block is { type: "text"; text: string } =>
|
||||
isRecord(block) &&
|
||||
block.type === "text" &&
|
||||
typeof block.text === "string",
|
||||
)
|
||||
.map((block) => block.text)
|
||||
|
||||
return textBlocks.join("\n").trim()
|
||||
}
|
||||
|
||||
function isRecoverableConnectionError(err: unknown): boolean {
|
||||
const msg = getErrorMessage(err).toLowerCase()
|
||||
return (
|
||||
msg.includes("connection closed") ||
|
||||
msg.includes("not connected") ||
|
||||
msg.includes("transport") ||
|
||||
msg.includes("broken pipe") ||
|
||||
msg.includes("econnreset") ||
|
||||
msg.includes("epipe") ||
|
||||
msg.includes("failed to start bm mcp stdio") ||
|
||||
msg.includes("client is closed")
|
||||
)
|
||||
}
|
||||
|
||||
function isNoteNotFoundError(err: unknown): boolean {
|
||||
const msg = getErrorMessage(err).toLowerCase()
|
||||
return (
|
||||
msg.includes("entity not found") ||
|
||||
msg.includes("note not found") ||
|
||||
msg.includes("resource not found") ||
|
||||
msg.includes("could not find note matching") ||
|
||||
msg.includes("404")
|
||||
)
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
return typeof value === "string" ? value : null
|
||||
}
|
||||
|
||||
function projectMatchesWorkspace(
|
||||
project: ProjectListResult,
|
||||
workspace: string,
|
||||
): boolean {
|
||||
const requested = workspace.trim().toLowerCase()
|
||||
if (!requested) return true
|
||||
|
||||
return [
|
||||
project.workspace_name,
|
||||
project.workspace_slug,
|
||||
project.workspace_tenant_id,
|
||||
].some(
|
||||
(value) => typeof value === "string" && value.toLowerCase() === requested,
|
||||
)
|
||||
}
|
||||
|
||||
export class BmClient {
|
||||
private bmPath: string
|
||||
private project: string
|
||||
private cwd?: string
|
||||
private env?: Record<string, string>
|
||||
private shouldRun = false
|
||||
|
||||
private client: Client | null = null
|
||||
private transport: StdioClientTransport | null = null
|
||||
private connectPromise: Promise<void> | null = null
|
||||
private retryDelaysMs = [...DEFAULT_RETRY_DELAYS_MS]
|
||||
|
||||
constructor(bmPath: string, project: string) {
|
||||
this.bmPath = bmPath
|
||||
this.project = project
|
||||
}
|
||||
|
||||
async start(options?: {
|
||||
cwd?: string
|
||||
env?: Record<string, string>
|
||||
}): Promise<void> {
|
||||
this.shouldRun = true
|
||||
if (options?.cwd) {
|
||||
this.cwd = options.cwd
|
||||
}
|
||||
if (options?.env) {
|
||||
this.env = options.env
|
||||
}
|
||||
|
||||
await this.connectWithRetries()
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.shouldRun = false
|
||||
await this.disconnectCurrent(this.client, this.transport)
|
||||
this.client = null
|
||||
this.transport = null
|
||||
}
|
||||
|
||||
private async connectWithRetries(): Promise<void> {
|
||||
let lastErr: unknown
|
||||
|
||||
for (let attempt = 0; attempt <= this.retryDelaysMs.length; attempt++) {
|
||||
try {
|
||||
await this.ensureConnected()
|
||||
return
|
||||
} catch (err) {
|
||||
lastErr = err
|
||||
await this.disconnectCurrent(this.client, this.transport)
|
||||
this.client = null
|
||||
this.transport = null
|
||||
|
||||
if (attempt === this.retryDelaysMs.length) {
|
||||
break
|
||||
}
|
||||
|
||||
const waitMs = this.retryDelaysMs[attempt]
|
||||
log.warn(
|
||||
`BM MCP connect failed (attempt ${attempt + 1}/${this.retryDelaysMs.length + 1}): ${getErrorMessage(err)}; retrying in ${waitMs}ms`,
|
||||
)
|
||||
await delay(waitMs)
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`BM MCP unavailable: ${getErrorMessage(lastErr)}`)
|
||||
}
|
||||
|
||||
private async ensureConnected(): Promise<Client> {
|
||||
if (!this.shouldRun) {
|
||||
this.shouldRun = true
|
||||
}
|
||||
|
||||
if (this.client && this.transport) {
|
||||
return this.client
|
||||
}
|
||||
|
||||
if (!this.connectPromise) {
|
||||
this.connectPromise = this.connectFresh()
|
||||
}
|
||||
|
||||
try {
|
||||
await this.connectPromise
|
||||
} finally {
|
||||
this.connectPromise = null
|
||||
}
|
||||
|
||||
if (!this.client) {
|
||||
throw new Error("BM MCP client was not initialized")
|
||||
}
|
||||
|
||||
return this.client
|
||||
}
|
||||
|
||||
private async connectFresh(): Promise<void> {
|
||||
const transport = new StdioClientTransport({
|
||||
command: this.bmPath,
|
||||
args: ["mcp", "--transport", "stdio"],
|
||||
cwd: this.cwd,
|
||||
env: this.env,
|
||||
stderr: "pipe",
|
||||
})
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: "openclaw-basic-memory",
|
||||
version: "0.1.0",
|
||||
},
|
||||
{ capabilities: {} },
|
||||
)
|
||||
|
||||
const stderr = transport.stderr
|
||||
if (stderr) {
|
||||
stderr.on("data", (data: Buffer) => {
|
||||
const msg = data.toString().trim()
|
||||
if (msg.length > 0) {
|
||||
log.debug(`[bm mcp] ${msg}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
transport.onclose = () => {
|
||||
if (this.transport !== transport) return
|
||||
log.warn("BM MCP stdio session closed")
|
||||
this.client = null
|
||||
this.transport = null
|
||||
}
|
||||
|
||||
transport.onerror = (err: unknown) => {
|
||||
if (this.transport !== transport) return
|
||||
log.warn(`BM MCP transport error: ${getErrorMessage(err)}`)
|
||||
}
|
||||
|
||||
this.client = client
|
||||
this.transport = transport
|
||||
|
||||
try {
|
||||
await client.connect(transport)
|
||||
const tools = await client.listTools()
|
||||
this.assertRequiredTools(tools.tools.map((tool) => tool.name))
|
||||
|
||||
log.info(
|
||||
`connected to BM MCP stdio (project=${this.project}, pid=${transport.pid ?? "unknown"})`,
|
||||
)
|
||||
} catch (err) {
|
||||
await this.disconnectCurrent(client, transport)
|
||||
if (this.client === client) {
|
||||
this.client = null
|
||||
}
|
||||
if (this.transport === transport) {
|
||||
this.transport = null
|
||||
}
|
||||
|
||||
throw new Error(`failed to start BM MCP stdio: ${getErrorMessage(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
private assertRequiredTools(toolNames: string[]): void {
|
||||
const available = new Set(toolNames)
|
||||
const missing = REQUIRED_TOOLS.filter((name) => !available.has(name))
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`BM MCP server missing required tools: ${missing.join(", ")}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private async disconnectCurrent(
|
||||
client: Client | null,
|
||||
transport: StdioClientTransport | null,
|
||||
): Promise<void> {
|
||||
if (client) {
|
||||
try {
|
||||
await client.close()
|
||||
} catch {
|
||||
// ignore shutdown errors
|
||||
}
|
||||
}
|
||||
|
||||
if (transport) {
|
||||
try {
|
||||
await transport.close()
|
||||
} catch {
|
||||
// ignore shutdown errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async callToolRaw(
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
let lastErr: unknown
|
||||
|
||||
for (let attempt = 0; attempt <= this.retryDelaysMs.length; attempt++) {
|
||||
try {
|
||||
const client = await this.ensureConnected()
|
||||
const result = await client.callTool({
|
||||
name,
|
||||
arguments: args,
|
||||
})
|
||||
|
||||
if (isRecord(result) && result.isError === true) {
|
||||
const message = extractTextFromContent(result.content)
|
||||
throw new Error(
|
||||
`BM MCP tool ${name} failed${message ? `: ${message}` : ""}`,
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (err) {
|
||||
if (!isRecoverableConnectionError(err)) {
|
||||
throw err
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
await this.disconnectCurrent(this.client, this.transport)
|
||||
this.client = null
|
||||
this.transport = null
|
||||
|
||||
if (attempt === this.retryDelaysMs.length) {
|
||||
break
|
||||
}
|
||||
|
||||
const waitMs = this.retryDelaysMs[attempt]
|
||||
log.warn(
|
||||
`BM MCP call ${name} failed (attempt ${attempt + 1}/${this.retryDelaysMs.length + 1}): ${getErrorMessage(err)}; retrying in ${waitMs}ms`,
|
||||
)
|
||||
await delay(waitMs)
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`BM MCP unavailable: ${getErrorMessage(lastErr)}`)
|
||||
}
|
||||
|
||||
private async callTool(
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
const result = await this.callToolRaw(name, args)
|
||||
|
||||
if (!isRecord(result) || result.structuredContent === undefined) {
|
||||
throw new Error(`BM MCP tool ${name} returned no structured payload`)
|
||||
}
|
||||
|
||||
const structuredPayload = result.structuredContent
|
||||
if (isRecord(structuredPayload) && structuredPayload.result !== undefined) {
|
||||
return structuredPayload.result
|
||||
}
|
||||
|
||||
return structuredPayload
|
||||
}
|
||||
|
||||
private routedProject(project?: string): string {
|
||||
return project ?? this.project
|
||||
}
|
||||
|
||||
async ensureProject(projectPath: string): Promise<void> {
|
||||
const payload = await this.callTool("create_memory_project", {
|
||||
project_name: this.project,
|
||||
project_path: projectPath,
|
||||
set_default: true,
|
||||
output_format: "json",
|
||||
})
|
||||
|
||||
if (!isRecord(payload)) {
|
||||
throw new Error("invalid create_memory_project response")
|
||||
}
|
||||
}
|
||||
|
||||
async listWorkspaces(): Promise<WorkspaceResult[]> {
|
||||
const payload = await this.callTool("list_workspaces", {
|
||||
output_format: "json",
|
||||
})
|
||||
|
||||
if (isRecord(payload) && Array.isArray(payload.workspaces)) {
|
||||
return payload.workspaces as WorkspaceResult[]
|
||||
}
|
||||
|
||||
throw new Error("invalid list_workspaces response")
|
||||
}
|
||||
|
||||
async listProjects(workspace?: string): Promise<ProjectListResult[]> {
|
||||
const payload = await this.callTool("list_memory_projects", {
|
||||
output_format: "json",
|
||||
})
|
||||
|
||||
if (isRecord(payload) && Array.isArray(payload.projects)) {
|
||||
const projects = payload.projects as ProjectListResult[]
|
||||
if (workspace) {
|
||||
return projects.filter((project) =>
|
||||
projectMatchesWorkspace(project, workspace),
|
||||
)
|
||||
}
|
||||
return projects
|
||||
}
|
||||
|
||||
throw new Error("invalid list_memory_projects response")
|
||||
}
|
||||
|
||||
async search(
|
||||
query?: string,
|
||||
limit = 10,
|
||||
project?: string,
|
||||
metadata?: {
|
||||
filters?: Record<string, unknown>
|
||||
tags?: string[]
|
||||
status?: string
|
||||
note_types?: string[]
|
||||
entity_types?: string[]
|
||||
},
|
||||
): Promise<SearchResult[]> {
|
||||
const args: Record<string, unknown> = {
|
||||
page: 1,
|
||||
page_size: limit,
|
||||
output_format: "json",
|
||||
project: this.routedProject(project),
|
||||
}
|
||||
if (query) args.query = query
|
||||
if (metadata?.filters) args.metadata_filters = metadata.filters
|
||||
if (metadata?.tags) args.tags = metadata.tags
|
||||
if (metadata?.status) args.status = metadata.status
|
||||
if (metadata?.note_types) args.note_types = metadata.note_types
|
||||
if (metadata?.entity_types) args.entity_types = metadata.entity_types
|
||||
|
||||
const payload = await this.callTool("search_notes", args)
|
||||
|
||||
if (!isRecord(payload) || !Array.isArray(payload.results)) {
|
||||
throw new Error("invalid search_notes response")
|
||||
}
|
||||
|
||||
return payload.results as SearchResult[]
|
||||
}
|
||||
|
||||
async readNote(
|
||||
identifier: string,
|
||||
options: ReadNoteOptions = {},
|
||||
project?: string,
|
||||
): Promise<NoteResult> {
|
||||
const args: Record<string, unknown> = {
|
||||
identifier,
|
||||
include_frontmatter: options.includeFrontmatter === true,
|
||||
output_format: "json",
|
||||
project: this.routedProject(project),
|
||||
}
|
||||
|
||||
const payload = await this.callTool("read_note", args)
|
||||
|
||||
if (!isRecord(payload)) {
|
||||
throw new Error("invalid read_note response")
|
||||
}
|
||||
|
||||
const title = asString(payload.title)
|
||||
const permalink = asString(payload.permalink)
|
||||
const content = asString(payload.content)
|
||||
const filePath = asString(payload.file_path)
|
||||
|
||||
if (!title || !permalink || content === null || !filePath) {
|
||||
throw new Error("invalid read_note payload")
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
permalink,
|
||||
content,
|
||||
file_path: filePath,
|
||||
frontmatter: isRecord(payload.frontmatter) ? payload.frontmatter : null,
|
||||
}
|
||||
}
|
||||
|
||||
async writeNote(
|
||||
title: string,
|
||||
content: string,
|
||||
folder: string,
|
||||
project?: string,
|
||||
overwrite?: boolean,
|
||||
): Promise<NoteResult> {
|
||||
const args: Record<string, unknown> = {
|
||||
title,
|
||||
content,
|
||||
directory: folder,
|
||||
output_format: "json",
|
||||
project: this.routedProject(project),
|
||||
}
|
||||
if (overwrite !== undefined) args.overwrite = overwrite
|
||||
|
||||
const payload = await this.callTool("write_note", args)
|
||||
|
||||
if (!isRecord(payload)) {
|
||||
throw new Error("invalid write_note response")
|
||||
}
|
||||
|
||||
if (payload.error === "NOTE_ALREADY_EXISTS") {
|
||||
throw new NoteAlreadyExistsError(
|
||||
asString(payload.title) ?? title,
|
||||
asString(payload.permalink) ?? "",
|
||||
)
|
||||
}
|
||||
|
||||
const resultTitle = asString(payload.title)
|
||||
const permalink = asString(payload.permalink)
|
||||
const filePath = asString(payload.file_path)
|
||||
|
||||
if (!resultTitle || !permalink || !filePath) {
|
||||
throw new Error("invalid write_note payload")
|
||||
}
|
||||
|
||||
return {
|
||||
title: resultTitle,
|
||||
permalink,
|
||||
content,
|
||||
file_path: filePath,
|
||||
checksum: asString(payload.checksum),
|
||||
action:
|
||||
payload.action === "created" || payload.action === "updated"
|
||||
? payload.action
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
async buildContext(
|
||||
url: string,
|
||||
depth = 1,
|
||||
project?: string,
|
||||
): Promise<ContextResult> {
|
||||
const args: Record<string, unknown> = {
|
||||
url,
|
||||
depth,
|
||||
output_format: "json",
|
||||
project: this.routedProject(project),
|
||||
}
|
||||
|
||||
const payload = await this.callTool("build_context", args)
|
||||
|
||||
if (!isRecord(payload) || !Array.isArray(payload.results)) {
|
||||
throw new Error("invalid build_context response")
|
||||
}
|
||||
|
||||
return payload as unknown as ContextResult
|
||||
}
|
||||
|
||||
async recentActivity(
|
||||
timeframe = "24h",
|
||||
project?: string,
|
||||
): Promise<RecentResult[]> {
|
||||
const args: Record<string, unknown> = {
|
||||
timeframe,
|
||||
output_format: "json",
|
||||
project: this.routedProject(project),
|
||||
}
|
||||
|
||||
const payload = await this.callTool("recent_activity", args)
|
||||
|
||||
if (Array.isArray(payload)) {
|
||||
return payload as RecentResult[]
|
||||
}
|
||||
|
||||
throw new Error("invalid recent_activity response")
|
||||
}
|
||||
|
||||
async editNote(
|
||||
identifier: string,
|
||||
operation: "append" | "prepend" | "find_replace" | "replace_section",
|
||||
content: string,
|
||||
options: EditNoteOptions = {},
|
||||
project?: string,
|
||||
): Promise<EditNoteResult> {
|
||||
const args: Record<string, unknown> = {
|
||||
identifier,
|
||||
operation,
|
||||
content,
|
||||
output_format: "json",
|
||||
project: this.routedProject(project),
|
||||
}
|
||||
if (options.find_text) args.find_text = options.find_text
|
||||
if (options.section) args.section = options.section
|
||||
if (options.expected_replacements != null)
|
||||
args.expected_replacements = options.expected_replacements
|
||||
|
||||
const payload = await this.callTool("edit_note", args)
|
||||
|
||||
if (!isRecord(payload)) {
|
||||
throw new Error("invalid edit_note response")
|
||||
}
|
||||
|
||||
const title = asString(payload.title)
|
||||
const permalink = asString(payload.permalink)
|
||||
const filePath = asString(payload.file_path)
|
||||
|
||||
if (!title || !permalink || !filePath) {
|
||||
throw new Error("invalid edit_note payload")
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
permalink,
|
||||
file_path: filePath,
|
||||
operation,
|
||||
checksum: asString(payload.checksum),
|
||||
}
|
||||
}
|
||||
|
||||
async deleteNote(
|
||||
identifier: string,
|
||||
project?: string,
|
||||
): Promise<{ title: string; permalink: string; file_path: string }> {
|
||||
const args: Record<string, unknown> = {
|
||||
identifier,
|
||||
output_format: "json",
|
||||
project: this.routedProject(project),
|
||||
}
|
||||
|
||||
const payload = await this.callTool("delete_note", args)
|
||||
|
||||
if (!isRecord(payload)) {
|
||||
throw new Error("invalid delete_note response")
|
||||
}
|
||||
|
||||
if (payload.deleted !== true) {
|
||||
throw new Error(`delete_note did not delete "${identifier}"`)
|
||||
}
|
||||
|
||||
return {
|
||||
title: asString(payload.title) ?? identifier,
|
||||
permalink: asString(payload.permalink) ?? identifier,
|
||||
file_path: asString(payload.file_path) ?? identifier,
|
||||
}
|
||||
}
|
||||
|
||||
async moveNote(
|
||||
identifier: string,
|
||||
newFolder: string,
|
||||
project?: string,
|
||||
): Promise<NoteResult> {
|
||||
const args: Record<string, unknown> = {
|
||||
identifier,
|
||||
destination_folder: newFolder,
|
||||
output_format: "json",
|
||||
project: this.routedProject(project),
|
||||
}
|
||||
|
||||
const payload = await this.callTool("move_note", args)
|
||||
|
||||
if (!isRecord(payload)) {
|
||||
throw new Error("invalid move_note response")
|
||||
}
|
||||
|
||||
if (payload.moved !== true) {
|
||||
throw new Error(
|
||||
asString(payload.error) ??
|
||||
`move_note did not move "${identifier}" to "${newFolder}"`,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
title: asString(payload.title) ?? identifier,
|
||||
permalink: asString(payload.permalink) ?? identifier,
|
||||
content: "",
|
||||
file_path: asString(payload.file_path) ?? "",
|
||||
}
|
||||
}
|
||||
|
||||
async schemaValidate(
|
||||
noteType?: string,
|
||||
identifier?: string,
|
||||
project?: string,
|
||||
): Promise<SchemaValidationResult> {
|
||||
const args: Record<string, unknown> = {
|
||||
output_format: "json",
|
||||
project: this.routedProject(project),
|
||||
}
|
||||
if (noteType) args.note_type = noteType
|
||||
if (identifier) args.identifier = identifier
|
||||
|
||||
const payload = await this.callTool("schema_validate", args)
|
||||
|
||||
if (!isRecord(payload)) {
|
||||
throw new Error("invalid schema_validate response")
|
||||
}
|
||||
|
||||
return payload as unknown as SchemaValidationResult
|
||||
}
|
||||
|
||||
async schemaInfer(
|
||||
noteType: string,
|
||||
threshold = 0.25,
|
||||
project?: string,
|
||||
): Promise<SchemaInferResult> {
|
||||
const args: Record<string, unknown> = {
|
||||
note_type: noteType,
|
||||
threshold,
|
||||
output_format: "json",
|
||||
project: this.routedProject(project),
|
||||
}
|
||||
|
||||
const payload = await this.callTool("schema_infer", args)
|
||||
|
||||
if (!isRecord(payload)) {
|
||||
throw new Error("invalid schema_infer response")
|
||||
}
|
||||
|
||||
return payload as unknown as SchemaInferResult
|
||||
}
|
||||
|
||||
async schemaDiff(
|
||||
noteType: string,
|
||||
project?: string,
|
||||
): Promise<SchemaDiffResult> {
|
||||
const args: Record<string, unknown> = {
|
||||
note_type: noteType,
|
||||
output_format: "json",
|
||||
project: this.routedProject(project),
|
||||
}
|
||||
|
||||
const payload = await this.callTool("schema_diff", args)
|
||||
|
||||
if (!isRecord(payload)) {
|
||||
throw new Error("invalid schema_diff response")
|
||||
}
|
||||
|
||||
return payload as unknown as SchemaDiffResult
|
||||
}
|
||||
|
||||
async indexConversation(
|
||||
userMessage: string,
|
||||
assistantResponse: string,
|
||||
): Promise<void> {
|
||||
const now = new Date()
|
||||
const dateStr = now.toISOString().split("T")[0]
|
||||
const timeStr = now.toTimeString().slice(0, 5)
|
||||
const title = `conversations-${dateStr}`
|
||||
|
||||
const entry = [
|
||||
`### ${timeStr}`,
|
||||
"",
|
||||
"**User:**",
|
||||
userMessage,
|
||||
"",
|
||||
"**Assistant:**",
|
||||
assistantResponse,
|
||||
"",
|
||||
"---",
|
||||
].join("\n")
|
||||
|
||||
try {
|
||||
await this.editNote(title, "append", entry)
|
||||
log.debug(`appended conversation to: ${title}`)
|
||||
return
|
||||
} catch (err) {
|
||||
if (!isNoteNotFoundError(err)) {
|
||||
log.error(`conversation append failed: ${getErrorMessage(err)}`, err)
|
||||
throw err
|
||||
}
|
||||
|
||||
log.debug(
|
||||
`conversation note missing, will create: ${getErrorMessage(err)}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Create the note with frontmatter and first entry
|
||||
const content = [
|
||||
"---",
|
||||
`title: Conversations ${dateStr}`,
|
||||
"type: Conversation",
|
||||
`date: "${dateStr}"`,
|
||||
"---",
|
||||
"",
|
||||
`# Conversations ${dateStr}`,
|
||||
"",
|
||||
entry,
|
||||
].join("\n")
|
||||
|
||||
try {
|
||||
await this.writeNote(title, content, "conversations")
|
||||
log.debug(`created conversation note: ${title}`)
|
||||
} catch (err) {
|
||||
log.error("conversation index failed", err)
|
||||
}
|
||||
}
|
||||
|
||||
getProject(): string {
|
||||
return this.project
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,807 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "@basicmemory/openclaw-basic-memory",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.26.0",
|
||||
"@sinclair/typebox": "0.34.47",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.6",
|
||||
"@types/node": "^20.0.0",
|
||||
"openclaw": "^2026.5.4",
|
||||
"typescript": "^5.9.3",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.5.2",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.22.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-DfqXtl/8gO9NImq094MTaCXEU2vkhh6v7q/kT+9UjZxUqj8hYaya2OjLVIqn16MzNHcXEpShTR2RIauLSYeDQQ=="],
|
||||
|
||||
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.91.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw=="],
|
||||
|
||||
"@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="],
|
||||
|
||||
"@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="],
|
||||
|
||||
"@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="],
|
||||
|
||||
"@aws-crypto/supports-web-crypto": ["@aws-crypto/supports-web-crypto@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg=="],
|
||||
|
||||
"@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
|
||||
|
||||
"@aws-sdk/client-bedrock-runtime": ["@aws-sdk/client-bedrock-runtime@3.1048.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.11", "@aws-sdk/credential-provider-node": "^3.972.42", "@aws-sdk/eventstream-handler-node": "^3.972.16", "@aws-sdk/middleware-eventstream": "^3.972.12", "@aws-sdk/middleware-websocket": "^3.972.19", "@aws-sdk/token-providers": "3.1048.0", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/fetch-http-handler": "^5.4.2", "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ=="],
|
||||
|
||||
"@aws-sdk/core": ["@aws-sdk/core@3.974.14", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@aws-sdk/xml-builder": "^3.972.26", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.3", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.2", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-ppamm04uoj3hhNO5IlQSs5D6rWX1fWkzcn6a4pZrojk8Y6ObY9wzLDdT/Eq3gv6O9hOebi9tYTNB8b8fQj9XJw=="],
|
||||
|
||||
"@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.40", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-jjT0p0Y7KZtcvExYiPCLJnqM9lkXDV1KBEg/13OE2DXv/9batzlyJHVKUEnRNJccY0O2Sul17E1su38CgdBhGQ=="],
|
||||
|
||||
"@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.42", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/fetch-http-handler": "^5.4.3", "@smithy/node-http-handler": "^4.7.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-+3fsKtWybe5BjKEUA3/07oh7Ayfd82IED2+gyyaVfS/4PU78E3TaOQxSGOJ1t7Imefoidw/ne9QA7apX8wEnJg=="],
|
||||
|
||||
"@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.44", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/credential-provider-env": "^3.972.40", "@aws-sdk/credential-provider-http": "^3.972.42", "@aws-sdk/credential-provider-login": "^3.972.44", "@aws-sdk/credential-provider-process": "^3.972.40", "@aws-sdk/credential-provider-sso": "^3.972.44", "@aws-sdk/credential-provider-web-identity": "^3.972.44", "@aws-sdk/nested-clients": "^3.997.12", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/credential-provider-imds": "^4.3.2", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-gZFw5wBefCIPg9vpT+gV5FdhfNKhYTVDZa1IsZCcn3SRoYUOJ/E05vwIogkJoonqBL0ttBGi5vhthX7xceekRg=="],
|
||||
|
||||
"@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.44", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/nested-clients": "^3.997.12", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-QqEGHfQeZgUDqh7zpqHufrZ8T644ELEWvB+4gUdewLyRw4IRF+6CJqeQuRWqucZdQzoQeMh7fNAD9BWxFAdNig=="],
|
||||
|
||||
"@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.45", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.40", "@aws-sdk/credential-provider-http": "^3.972.42", "@aws-sdk/credential-provider-ini": "^3.972.44", "@aws-sdk/credential-provider-process": "^3.972.40", "@aws-sdk/credential-provider-sso": "^3.972.44", "@aws-sdk/credential-provider-web-identity": "^3.972.44", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/credential-provider-imds": "^4.3.2", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-3YCv52ExXIRz3LAVNysevd+s7akSpg9dl39v9LJ7dOQH+s5rHi3jMZYQyxwMmglxQGMuzYRfQ0o1VSP2UOlIRw=="],
|
||||
|
||||
"@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.40", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-cXaozlgJCOwmE6D7x4npcPdyk7kiFZdrGjN3D6tXXtItJJMNGPafDfAJn4YQmciMooG/X+b0Y6RTqdVVMx26jg=="],
|
||||
|
||||
"@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.44", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/nested-clients": "^3.997.12", "@aws-sdk/token-providers": "3.1054.0", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-YePoj5kQuPmE0MHnyftXCfsO8ZSBd2kDr50XEIUrdejSbGFlayYvUuCohdb8drhGhPm6b65o7H1eC26EZhwUvA=="],
|
||||
|
||||
"@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.44", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/nested-clients": "^3.997.12", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-Ys/JJe++8Z2Y5meR1taMBaVcrGBA0/XsVTQR+qOKZbdNyg+8Jlv5rYZSwh8SqEHY00goSOZy7PHzZ2rLNQxDLg=="],
|
||||
|
||||
"@aws-sdk/eventstream-handler-node": ["@aws-sdk/eventstream-handler-node@3.972.17", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-WFwdNcjchKZr7jKYgGimUZO8sSKQF/le7GGqgeCzz/lHozInE6b0gFJ1YMr8NaIeAoWJwgtrF7RE4/qMgosAdQ=="],
|
||||
|
||||
"@aws-sdk/middleware-eventstream": ["@aws-sdk/middleware-eventstream@3.972.13", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-ECfsw7mf6G/sxNbKbGE3/h1xeIArY/yRI1IjDGYkLgDIankh+aDOtDRSr40LVlIHGL9+jEH1cVuxmbJ8NLL/1A=="],
|
||||
|
||||
"@aws-sdk/middleware-websocket": ["@aws-sdk/middleware-websocket@3.972.22", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/fetch-http-handler": "^5.4.3", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-aumo6pYnvD1/eda3R0UDkRVecwxsuW4zTZLdjbHg7NqYMKmy7vK0bM3NGJzCD+Ys8iqCC7EeDU4LuWVIsXvL+A=="],
|
||||
|
||||
"@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.12", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.14", "@aws-sdk/signature-v4-multi-region": "^3.996.29", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/fetch-http-handler": "^5.4.3", "@smithy/node-http-handler": "^4.7.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-Js2VYaCM269feB0cs0cGmlIhdOgT9aMqzdBx68lCy6kVCYfzr0T36ovUFDvfUmatkuBeyBJhCwaLBh7P8meH5Q=="],
|
||||
|
||||
"@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.29", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-Few9FoQqOt/0KSvZYP+qdW0dfOhfQ9N+gl2UUDvCPW6mkPKHli9LMbKxWj+wZ5zKPaOoqxuR3Hhy3OTpndkfSw=="],
|
||||
|
||||
"@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1048.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.11", "@aws-sdk/nested-clients": "^3.997.9", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA=="],
|
||||
|
||||
"@aws-sdk/types": ["@aws-sdk/types@3.973.9", "", { "dependencies": { "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg=="],
|
||||
|
||||
"@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.5", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ=="],
|
||||
|
||||
"@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.26", "", { "dependencies": { "@smithy/types": "^4.14.2", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-cDbrqvDS73whl6YAPSPq0U6whzG6UWI9PuWh0wrUuGoZexhWEqhdunbukV7iBoaWnFV1AODutM5hOD6rtn439g=="],
|
||||
|
||||
"@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
|
||||
|
||||
"@biomejs/biome": ["@biomejs/biome@2.4.16", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.16", "@biomejs/cli-darwin-x64": "2.4.16", "@biomejs/cli-linux-arm64": "2.4.16", "@biomejs/cli-linux-arm64-musl": "2.4.16", "@biomejs/cli-linux-x64": "2.4.16", "@biomejs/cli-linux-x64-musl": "2.4.16", "@biomejs/cli-win32-arm64": "2.4.16", "@biomejs/cli-win32-x64": "2.4.16" }, "bin": { "biome": "bin/biome" } }, "sha512-x9ajFh1zChVybCiM3TN6OD4phAqLgtPZjFrZF+aTMYCPjwBO+k529TX7PPsAqtGNLeV4UgzwQnowEgS7bGmzcA=="],
|
||||
|
||||
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wxPvu4XOA85YJk9ixSWUmq/QBHbid85BISbOAqqBM/5xQpPk9ayjk5375tOlSC0BeCwNSbPFafQBm+vBumXq0A=="],
|
||||
|
||||
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-xFCqGPwYusQJp4N4NJLi1XJiZqjwFdjhT+KqtNy+Ug3qgfczqnTa6MSDvxJF6TkuDLoYJItMapz6tAf7kCekFw=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-oYxnW0ARfJkr72ezzF2OR8N/rtkgLUQeYtF8cFhVswbknHxtTcmzSsanVJP8yQKnGpGpc2ck6c5zLvHahL6Cbg=="],
|
||||
|
||||
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.16", "", { "os": "linux", "cpu": "x64" }, "sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ=="],
|
||||
|
||||
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.16", "", { "os": "linux", "cpu": "x64" }, "sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg=="],
|
||||
|
||||
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A=="],
|
||||
|
||||
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.16", "", { "os": "win32", "cpu": "x64" }, "sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw=="],
|
||||
|
||||
"@borewit/text-codec": ["@borewit/text-codec@0.2.2", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="],
|
||||
|
||||
"@clack/core": ["@clack/core@1.3.1", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-fT1qHVGAag4IEkrupZ6lRRbNCs1vS9P01KB/sG8zKgvUztbYtFBtQpjSITNwooDZ83tpsPzP0mRNs1/KVszCRA=="],
|
||||
|
||||
"@clack/prompts": ["@clack/prompts@1.4.0", "", { "dependencies": { "@clack/core": "1.3.1", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-S0My7XPGIgpRWMDG8uRqalbgT+a6FmCUdOW+HaIOVVpUPHOb7RrpvjTjiODadKp06fsrVDJZlIzc6yCTp4AnxA=="],
|
||||
|
||||
"@earendil-works/pi-agent-core": ["@earendil-works/pi-agent-core@0.75.5", "", { "dependencies": { "@earendil-works/pi-ai": "^0.75.5", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" } }, "sha512-LHygOgsW2pgXKb3IkXkOAeZPovHr9VF+EixgXVsDNuB4jmhEOXgshy/zksZ7slkUAx10OQ9W1Ed/2jsnhd1NqA=="],
|
||||
|
||||
"@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.75.5", "", { "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.1", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.26.0", "partial-json": "0.1.7", "typebox": "1.1.38" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-zf1F5kXk1pqZeFShXOqq9ibUk8QdtRoLCDPAjO+hj44e3EUs9/GFO2qnhTC5+JA2uwVCx+WCNe1PiCjlBYWm5w=="],
|
||||
|
||||
"@earendil-works/pi-coding-agent": ["@earendil-works/pi-coding-agent@0.75.5", "", { "dependencies": { "@earendil-works/pi-agent-core": "^0.75.5", "@earendil-works/pi-ai": "^0.75.5", "@earendil-works/pi-tui": "^0.75.5", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", "diff": "8.0.4", "glob": "13.0.6", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", "ignore": "7.0.5", "jiti": "2.7.0", "minimatch": "10.2.5", "proper-lockfile": "4.1.2", "typebox": "1.1.38", "undici": "8.3.0", "yaml": "2.9.0" }, "optionalDependencies": { "@mariozechner/clipboard": "0.3.6" }, "bin": { "pi": "dist/cli.js" } }, "sha512-O3CCQDYy28D4uwtP6zZkdEwzHN6X22v49Sb0+SZTC7x37V/YfmogrWPiaFoWeoc2hmdKhSATI7ZAK5bQbJG5NA=="],
|
||||
|
||||
"@earendil-works/pi-tui": ["@earendil-works/pi-tui@0.75.5", "", { "dependencies": { "get-east-asian-width": "1.6.0", "marked": "15.0.12" } }, "sha512-LkXUM1/49pvzzeI39Y5wjBMlgafcCf67HCLhB9Z7yuXHy4XgT+VqxWcZVW5hBdhQsHZd0znjJotfGH1BzxMfiA=="],
|
||||
|
||||
"@google/genai": ["@google/genai@2.6.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-HjoW3mPuEn7pnuKABJl9VbDoWDSF4nbwYKYvYYor7YjPeDxrrBxHzu2d1Prcd+BAuC4w+85UP6y7ZdcrQAoO7g=="],
|
||||
|
||||
"@grammyjs/runner": ["@grammyjs/runner@2.0.3", "", { "dependencies": { "abort-controller": "^3.0.0" }, "peerDependencies": { "grammy": "^1.13.1" } }, "sha512-nckmTs1dPWfVQteK9cxqxzE+0m1VRvluLWB8UgFzsjg62w3qthPJt0TYtJBEdG7OedvfQq4vnFAyE6iaMkR42A=="],
|
||||
|
||||
"@grammyjs/transformer-throttler": ["@grammyjs/transformer-throttler@1.2.1", "", { "dependencies": { "bottleneck": "^2.0.0" }, "peerDependencies": { "grammy": "^1.0.0" } }, "sha512-CpWB0F3rJdUiKsq7826QhQsxbZi4wqfz1ccKX+fr+AOC+o8K7ZvS+wqX0suSu1QCsyUq2MDpNiKhyL2ZOJUS4w=="],
|
||||
|
||||
"@grammyjs/types": ["@grammyjs/types@3.27.3", "", {}, "sha512-yUKMLliGsGbnxu96YUJ7km7B0zy4PzeH/Jvti5705R/LeKDMqkDV4DckMSt+OrliWQpTwQljHE0QLol5zgxBkg=="],
|
||||
|
||||
"@homebridge/ciao": ["@homebridge/ciao@1.3.8", "", { "dependencies": { "debug": "^4.4.3", "fast-deep-equal": "^3.1.3", "source-map-support": "^0.5.21", "tslib": "^2.8.1" }, "bin": { "ciao-bcs": "lib/bonjour-conformance-testing.js" } }, "sha512-lNhpCsZVbdbjz2trFjQdzQ3cUIMZQMIMksi7wd3ntTIYgdaGLqT1Ms97DfVIJYHzRuduf56ISvgU8RRLTpK/ng=="],
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
|
||||
|
||||
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
|
||||
|
||||
"@lydell/node-pty": ["@lydell/node-pty@1.2.0-beta.12", "", { "optionalDependencies": { "@lydell/node-pty-darwin-arm64": "1.2.0-beta.12", "@lydell/node-pty-darwin-x64": "1.2.0-beta.12", "@lydell/node-pty-linux-arm64": "1.2.0-beta.12", "@lydell/node-pty-linux-x64": "1.2.0-beta.12", "@lydell/node-pty-win32-arm64": "1.2.0-beta.12", "@lydell/node-pty-win32-x64": "1.2.0-beta.12" } }, "sha512-qIK890UwPupoj07osVvgOIa++1mxeHbcGry4PKRHhNVNs81V2SCG34eJr46GybiOmBtc8Sj5PB1/GGM5PL549g=="],
|
||||
|
||||
"@lydell/node-pty-darwin-arm64": ["@lydell/node-pty-darwin-arm64@1.2.0-beta.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tqaifcY9Cr41SblO1+FLzh8oxxtkNhuW9Dhl22lKme9BreYvKvxEZcdPIXTuqkJc5tagOEC4QHShKmJjLyLXLQ=="],
|
||||
|
||||
"@lydell/node-pty-darwin-x64": ["@lydell/node-pty-darwin-x64@1.2.0-beta.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-4LrS5pCJwqHKDVf1zS2gyNV0m4hKAXch+XZNhbZ6LY8uwVL8BhchzQBO40Os5anuRxRCWzHpw4Sp64Ie8q7E4Q=="],
|
||||
|
||||
"@lydell/node-pty-linux-arm64": ["@lydell/node-pty-linux-arm64@1.2.0-beta.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-Sx+A71x5BDGHt9ansfrtGxwq2VFVDWvJUAdlUL0Hv0qeiJUfts+hgopx+CgT4PSwahKjdEgtu0+FAfY9rICKRw=="],
|
||||
|
||||
"@lydell/node-pty-linux-x64": ["@lydell/node-pty-linux-x64@1.2.0-beta.12", "", { "os": "linux", "cpu": "x64" }, "sha512-bJzs94njofYhGg/UDqW1nj0dtvvu+2OvxMY+RlLS1T17VgcktKoIR6PuenTwE5HJ/D6StCPADmXcT0nNsCKmIQ=="],
|
||||
|
||||
"@lydell/node-pty-win32-arm64": ["@lydell/node-pty-win32-arm64@1.2.0-beta.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-p7POgjVEiFaBC3/y+AKuV1FzePCsJ6HmZDv2XK+jBZSfwP8+uBAw181ZiKYN1YuRa/XpmBGaWezcI8hZkbW++g=="],
|
||||
|
||||
"@lydell/node-pty-win32-x64": ["@lydell/node-pty-win32-x64@1.2.0-beta.12", "", { "os": "win32", "cpu": "x64" }, "sha512-IDFa00g7qUDGUYgByrUBJtC+mOjYVt/8KYyWivCg5JjGOHbBUACUQZLl0jTWmnr+tld/UyTpX90a2PY6oTVtRw=="],
|
||||
|
||||
"@mariozechner/clipboard": ["@mariozechner/clipboard@0.3.6", "", { "optionalDependencies": { "@mariozechner/clipboard-darwin-arm64": "0.3.6", "@mariozechner/clipboard-darwin-universal": "0.3.6", "@mariozechner/clipboard-darwin-x64": "0.3.6", "@mariozechner/clipboard-linux-arm64-gnu": "0.3.6", "@mariozechner/clipboard-linux-arm64-musl": "0.3.6", "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.6", "@mariozechner/clipboard-linux-x64-gnu": "0.3.6", "@mariozechner/clipboard-linux-x64-musl": "0.3.6", "@mariozechner/clipboard-win32-arm64-msvc": "0.3.6", "@mariozechner/clipboard-win32-x64-msvc": "0.3.6" } }, "sha512-MXdtr+6+ntlIVHdrZYuZNQydu6o8yZswFJ2Ln81j2O/Y9B/LDHvEaIm95xWNPkjGTWriSOeLnQJRFs6dYb60bg=="],
|
||||
|
||||
"@mariozechner/clipboard-darwin-arm64": ["@mariozechner/clipboard-darwin-arm64@0.3.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HjaisYCAbHi/1+N1yDAQHc8ZXGffufIUT5NSOSVR3f3AuMDusxTtnbK8tZ7JFDkShua1oNGZoNwQHsc8MPtE0Q=="],
|
||||
|
||||
"@mariozechner/clipboard-darwin-universal": ["@mariozechner/clipboard-darwin-universal@0.3.6", "", { "os": "darwin" }, "sha512-8BWtPjOtJOJoykml3w0fx0zRrfWP31mXrJwfoA7xzNprkZw1uolCNfgmjDiVBseoKjp16EGITz7bN+61qn8dWA=="],
|
||||
|
||||
"@mariozechner/clipboard-darwin-x64": ["@mariozechner/clipboard-darwin-x64@0.3.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-p9syiZD1kU4I+1ya7f7g+zD1GiUvR8fdlRlNmgsZNWlyjtc8rlV2EjTLd/35x1LsdBq020GVvtzp0ZmPgBI09Q=="],
|
||||
|
||||
"@mariozechner/clipboard-linux-arm64-gnu": ["@mariozechner/clipboard-linux-arm64-gnu@0.3.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-5JFf5rGofrm+V29HNF+wLthXphHdQpMbKDUYJ5tML6/Z5DLlLOV/9Ak4kDPtYyZ+Dzf+kAusE0VsFg4+tfP1IA=="],
|
||||
|
||||
"@mariozechner/clipboard-linux-arm64-musl": ["@mariozechner/clipboard-linux-arm64-musl@0.3.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-JlVjxxw0GbGC0djXYWRIqyteO3J1KZ/QG3udlEFaOD5TLOM1FnmXXAPDQBqr+aBVr720ef9K00dirYnJ0LDCtw=="],
|
||||
|
||||
"@mariozechner/clipboard-linux-riscv64-gnu": ["@mariozechner/clipboard-linux-riscv64-gnu@0.3.6", "", { "os": "linux", "cpu": "none" }, "sha512-4t8BUi5zZ+L77otFQVnVSlaTyAX4TVk9EqQm4syMrEQp96trFEHEwwNHcNEBGzYv5+K7mxay50TthYkz47OWzQ=="],
|
||||
|
||||
"@mariozechner/clipboard-linux-x64-gnu": ["@mariozechner/clipboard-linux-x64-gnu@0.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-trtPwcNLW37irwQCJLtCxLw757jjJZk3TSnY/MU9bhtWtA3K9b/eLW0e4RGhUXDoFRds9opNWWaUDuFLa8dm0w=="],
|
||||
|
||||
"@mariozechner/clipboard-linux-x64-musl": ["@mariozechner/clipboard-linux-x64-musl@0.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-WfnzIvOCCWQiN0MmltCEo6cLceUDbYe+I7xyFZjaps5A+2Op/M2CY7Rey+C4ucQhrvmpoHmTSFgY9ODWk7snoA=="],
|
||||
|
||||
"@mariozechner/clipboard-win32-arm64-msvc": ["@mariozechner/clipboard-win32-arm64-msvc@0.3.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-+8+1aHYsBPUjmW3otmWlg+Hijt0iJvoBBs5e0mxFeUd4gDaKMB8Bn6x7c6KVtscg7E5j5NFXnwQqNSIAO4p8zQ=="],
|
||||
|
||||
"@mariozechner/clipboard-win32-x64-msvc": ["@mariozechner/clipboard-win32-x64-msvc@0.3.6", "", { "os": "win32", "cpu": "x64" }, "sha512-S4xfPmERC8ZkiLHe3vekZCjdDwNEETCuvCgQK2kP6/TnvmUkq1y2Pk+DjM4t8uh9KMX9bH4zs5ePcKa8GTXmfg=="],
|
||||
|
||||
"@mistralai/mistralai": ["@mistralai/mistralai@2.2.1", "", { "dependencies": { "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.25.0" } }, "sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ=="],
|
||||
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
|
||||
|
||||
"@mozilla/readability": ["@mozilla/readability@0.6.0", "", {}, "sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ=="],
|
||||
|
||||
"@napi-rs/canvas": ["@napi-rs/canvas@0.1.100", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.100", "@napi-rs/canvas-darwin-arm64": "0.1.100", "@napi-rs/canvas-darwin-x64": "0.1.100", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100", "@napi-rs/canvas-linux-arm64-gnu": "0.1.100", "@napi-rs/canvas-linux-arm64-musl": "0.1.100", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.100", "@napi-rs/canvas-linux-x64-gnu": "0.1.100", "@napi-rs/canvas-linux-x64-musl": "0.1.100", "@napi-rs/canvas-win32-arm64-msvc": "0.1.100", "@napi-rs/canvas-win32-x64-msvc": "0.1.100" } }, "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA=="],
|
||||
|
||||
"@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@0.1.100", "", { "os": "android", "cpu": "arm64" }, "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ=="],
|
||||
|
||||
"@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@0.1.100", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A=="],
|
||||
|
||||
"@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@0.1.100", "", { "os": "darwin", "cpu": "x64" }, "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw=="],
|
||||
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@0.1.100", "", { "os": "linux", "cpu": "arm" }, "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA=="],
|
||||
|
||||
"@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@0.1.100", "", { "os": "linux", "cpu": "arm64" }, "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw=="],
|
||||
|
||||
"@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@0.1.100", "", { "os": "linux", "cpu": "arm64" }, "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ=="],
|
||||
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@0.1.100", "", { "os": "linux", "cpu": "none" }, "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw=="],
|
||||
|
||||
"@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@0.1.100", "", { "os": "linux", "cpu": "x64" }, "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg=="],
|
||||
|
||||
"@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@0.1.100", "", { "os": "linux", "cpu": "x64" }, "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA=="],
|
||||
|
||||
"@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@0.1.100", "", { "os": "win32", "cpu": "arm64" }, "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw=="],
|
||||
|
||||
"@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.100", "", { "os": "win32", "cpu": "x64" }, "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA=="],
|
||||
|
||||
"@nodable/entities": ["@nodable/entities@2.1.0", "", {}, "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA=="],
|
||||
|
||||
"@openclaw/fs-safe": ["@openclaw/fs-safe@0.3.0", "", { "optionalDependencies": { "jszip": "^3.10.1", "tar": "7.5.13" } }, "sha512-uIBE441CIt1kIURoP9qRGKZ8LkGyfD9ZzeESjwAd29ZPWtghws/5GR3Pjb67jKdcJHP1I6roNXcvnhzAU7lHlA=="],
|
||||
|
||||
"@openclaw/proxyline": ["@openclaw/proxyline@0.3.3", "", { "peerDependencies": { "undici": ">=8.3.0 <9" } }, "sha512-sftHnW69NHQqLjCxBTvQ8f/eQl+peZ5pHCBQtuTWBbeuYRHZ0/GXVTmw/O/YKsShMbqPWhJB0UYtPPdvCUSS8w=="],
|
||||
|
||||
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
|
||||
|
||||
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
|
||||
|
||||
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="],
|
||||
|
||||
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="],
|
||||
|
||||
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="],
|
||||
|
||||
"@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
|
||||
|
||||
"@protobufjs/inquire": ["@protobufjs/inquire@1.1.2", "", {}, "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw=="],
|
||||
|
||||
"@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
|
||||
|
||||
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
|
||||
|
||||
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.1", "", {}, "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg=="],
|
||||
|
||||
"@silvia-odwyer/photon-node": ["@silvia-odwyer/photon-node@0.3.4", "", {}, "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA=="],
|
||||
|
||||
"@sinclair/typebox": ["@sinclair/typebox@0.34.47", "", {}, "sha512-ZGIBQ+XDvO5JQku9wmwtabcVTHJsgSWAHYtVuM9pBNNR5E88v6Jcj/llpmsjivig5X8A8HHOb4/mbEKPS5EvAw=="],
|
||||
|
||||
"@smithy/core": ["@smithy/core@3.24.5", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-Kt8phUg45M15EjhYAbZ+fFikYneijLu9Liugz8ZsYz2i8j0hzGv27LWKpEHYRfvj+LyCOSijpcR/2i8RouV+cA=="],
|
||||
|
||||
"@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.3.5", "", { "dependencies": { "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-yiF8xHpdkaTfzLVqFzsP6WvNghEK+qZzLYWFD13L2SsFhbXwBGlxdocKF95qjr7s5lE5NRage+EJFK4mAsx88Q=="],
|
||||
|
||||
"@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.4.5", "", { "dependencies": { "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-SK3VMeH0fibgdTg2QeB+O4p7Yy/2E5HBOHJeC58FshkDdeuX8lOgO7PfjYfLyPLP1ch55j91cQqKBzDS0mRjSQ=="],
|
||||
|
||||
"@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
|
||||
|
||||
"@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA=="],
|
||||
|
||||
"@smithy/signature-v4": ["@smithy/signature-v4@5.4.5", "", { "dependencies": { "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-QBJKWGqIknH0dc9LWpfH1mkdokAx6iXYN3UcQ3eY6uIEyScuoQAhfl94ge7ozUy9WgFUdE8xsvwBjaYBbWmPNA=="],
|
||||
|
||||
"@smithy/types": ["@smithy/types@4.14.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="],
|
||||
|
||||
"@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
|
||||
|
||||
"@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
|
||||
|
||||
"@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="],
|
||||
|
||||
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
|
||||
|
||||
"@types/node": ["@types/node@20.19.41", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ=="],
|
||||
|
||||
"@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="],
|
||||
|
||||
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
|
||||
|
||||
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||
|
||||
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
|
||||
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
|
||||
|
||||
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"asn1.js": ["asn1.js@5.4.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="],
|
||||
|
||||
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
|
||||
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||
|
||||
"bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
|
||||
|
||||
"bn.js": ["bn.js@4.12.3", "", {}, "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g=="],
|
||||
|
||||
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
|
||||
|
||||
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
|
||||
|
||||
"bottleneck": ["bottleneck@2.19.5", "", {}, "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw=="],
|
||||
|
||||
"bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="],
|
||||
|
||||
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
|
||||
|
||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||
|
||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||
|
||||
"camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
|
||||
|
||||
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
|
||||
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
|
||||
|
||||
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
|
||||
|
||||
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
||||
|
||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||
|
||||
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||
|
||||
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||
|
||||
"content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
|
||||
|
||||
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
||||
|
||||
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||
|
||||
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
|
||||
|
||||
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
|
||||
|
||||
"croner": ["croner@10.0.1", "", {}, "sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="],
|
||||
|
||||
"css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="],
|
||||
|
||||
"cssom": ["cssom@0.5.0", "", {}, "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw=="],
|
||||
|
||||
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="],
|
||||
|
||||
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
||||
|
||||
"diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
|
||||
|
||||
"dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="],
|
||||
|
||||
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
|
||||
|
||||
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
|
||||
|
||||
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
|
||||
|
||||
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
|
||||
|
||||
"dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
||||
|
||||
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||
|
||||
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
||||
|
||||
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
|
||||
|
||||
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
|
||||
|
||||
"eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="],
|
||||
|
||||
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
||||
|
||||
"express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="],
|
||||
|
||||
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="],
|
||||
|
||||
"fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="],
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="],
|
||||
|
||||
"fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="],
|
||||
|
||||
"fast-xml-builder": ["fast-xml-builder@1.2.0", "", { "dependencies": { "path-expression-matcher": "^1.5.0", "xml-naming": "^0.1.0" } }, "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q=="],
|
||||
|
||||
"fast-xml-parser": ["fast-xml-parser@5.7.3", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.7", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg=="],
|
||||
|
||||
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
|
||||
|
||||
"file-type": ["file-type@22.0.1", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.5", "token-types": "^6.1.2", "uint8array-extras": "^1.5.0" } }, "sha512-ww5Mhre0EE+jmBvOXTmXAbEMuZE7uX4a3+oRCQFNj8w++g3ev913N6tXQz0XTXbueQ5TWQfm6BdaViEHHn8bhA=="],
|
||||
|
||||
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
|
||||
|
||||
"find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
|
||||
|
||||
"formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
|
||||
|
||||
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
||||
|
||||
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"gaxios": ["gaxios@7.1.4", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA=="],
|
||||
|
||||
"gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="],
|
||||
|
||||
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
|
||||
|
||||
"get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="],
|
||||
|
||||
"google-auth-library": ["google-auth-library@10.6.2", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw=="],
|
||||
|
||||
"google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"grammy": ["grammy@1.43.0", "", { "dependencies": { "@grammyjs/types": "3.27.3", "abort-controller": "^3.0.0", "debug": "^4.4.3", "node-fetch": "^2.7.0" } }, "sha512-7dYm06A945mXuIk/5HUlSjeyIYChW8vCEiU2dkOKKqJJzwAWxTkCc91Eqbz7TgODh2rtFFKWI/fekowWHOkmjQ=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="],
|
||||
|
||||
"highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="],
|
||||
|
||||
"hono": ["hono@4.12.23", "", {}, "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA=="],
|
||||
|
||||
"hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="],
|
||||
|
||||
"html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="],
|
||||
|
||||
"htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="],
|
||||
|
||||
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
||||
|
||||
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
|
||||
|
||||
"http_ece": ["http_ece@1.2.0", "", {}, "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA=="],
|
||||
|
||||
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||
|
||||
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||
|
||||
"ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||
|
||||
"immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="],
|
||||
|
||||
"ipaddr.js": ["ipaddr.js@2.4.0", "", {}, "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ=="],
|
||||
|
||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
|
||||
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
|
||||
|
||||
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
|
||||
|
||||
"jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="],
|
||||
|
||||
"json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
|
||||
|
||||
"json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
|
||||
|
||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="],
|
||||
|
||||
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
|
||||
|
||||
"jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
|
||||
|
||||
"kysely": ["kysely@0.29.2", "", {}, "sha512-s6WVJyEZrbm6jhBpiKHsGHyePMrVQKJ85wZCFCr9W4QHv6WTjWIrdvTmO9hDEA3bNK0xkrE2DqrHsXMLWuZpQg=="],
|
||||
|
||||
"lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="],
|
||||
|
||||
"linkedom": ["linkedom@0.18.12", "", { "dependencies": { "css-select": "^5.1.0", "cssom": "^0.5.0", "html-escaper": "^3.0.3", "htmlparser2": "^10.0.0", "uhyphen": "^0.2.0" }, "peerDependencies": { "canvas": ">= 2" }, "optionalPeers": ["canvas"] }, "sha512-jalJsOwIKuQJSeTvsgzPe9iJzyfVaEJiEXl+25EkKevsULHvMJzpNqwvj1jOESWdmgKDiXObyjOYwlUqG7wo1Q=="],
|
||||
|
||||
"linkify-it": ["linkify-it@5.0.1", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg=="],
|
||||
|
||||
"locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||
|
||||
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
|
||||
|
||||
"lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="],
|
||||
|
||||
"markdown-it": ["markdown-it@14.1.1", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA=="],
|
||||
|
||||
"marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="],
|
||||
|
||||
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
|
||||
|
||||
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
|
||||
|
||||
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
|
||||
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
||||
|
||||
"minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="],
|
||||
|
||||
"minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
||||
|
||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||
|
||||
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
|
||||
|
||||
"minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||
|
||||
"node-addon-api": ["node-addon-api@8.8.0", "", {}, "sha512-c5Ko1fZJIJmzhFIkhRN76WTq+fC6tWnGy9CXA0fA+XygsWZmEwG8vmbkNqxMyoaa0Tin4djul49NzdVcJJcjeA=="],
|
||||
|
||||
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
|
||||
|
||||
"node-edge-tts": ["node-edge-tts@1.2.10", "", { "dependencies": { "https-proxy-agent": "^7.0.1", "ws": "^8.13.0", "yargs": "^17.7.2" }, "bin": { "node-edge-tts": "bin.js" } }, "sha512-bV2i4XU54D45+US0Zm1HcJRkifuB3W438dWyuJEHLQdKxnuqlI1kim2MOvR6Q3XUQZvfF9PoDyR1Rt7aeXhPdQ=="],
|
||||
|
||||
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
|
||||
|
||||
"node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="],
|
||||
|
||||
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"openai": ["openai@6.39.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-O61LIsimY3acVabwvomwFhwrnN36yvHY2quIfy9keEcFytGgWeV35yLHQ6NVMLSBxRpHmcg2yuhCnlu2HT4pLQ=="],
|
||||
|
||||
"openclaw": ["openclaw@2026.5.26", "", { "dependencies": { "@agentclientprotocol/sdk": "0.22.1", "@clack/core": "1.3.1", "@clack/prompts": "1.4.0", "@earendil-works/pi-agent-core": "0.75.5", "@earendil-works/pi-ai": "0.75.5", "@earendil-works/pi-coding-agent": "0.75.5", "@earendil-works/pi-tui": "0.75.5", "@google/genai": "2.6.0", "@grammyjs/runner": "2.0.3", "@grammyjs/transformer-throttler": "1.2.1", "@homebridge/ciao": "1.3.8", "@lydell/node-pty": "1.2.0-beta.12", "@modelcontextprotocol/sdk": "1.29.0", "@mozilla/readability": "0.6.0", "@openclaw/fs-safe": "0.3.0", "@openclaw/proxyline": "0.3.3", "chalk": "5.6.2", "chokidar": "5.0.0", "commander": "14.0.3", "croner": "10.0.1", "dotenv": "17.4.2", "express": "5.2.1", "file-type": "22.0.1", "grammy": "1.43.0", "ipaddr.js": "2.4.0", "jiti": "2.7.0", "json5": "2.2.3", "jszip": "3.10.1", "kysely": "0.29.2", "linkedom": "0.18.12", "markdown-it": "14.1.1", "node-edge-tts": "1.2.10", "openai": "6.39.0", "pdfjs-dist": "5.7.284", "playwright-core": "1.60.0", "qrcode": "1.5.4", "quickjs-wasi": "2.2.0", "rastermill": "0.3.0", "tar": "7.5.15", "tokenjuice": "0.7.1", "tree-sitter-bash": "0.25.1", "tslog": "4.10.2", "typebox": "1.1.38", "typescript": "6.0.3", "undici": "8.3.0", "web-push": "3.6.7", "web-tree-sitter": "0.26.9", "ws": "8.21.0", "yaml": "2.9.0", "zod": "4.4.3" }, "optionalDependencies": { "sqlite-vec": "0.1.9" }, "bin": { "openclaw": "openclaw.mjs" } }, "sha512-ne6ESyXmspmWO7JlSAWFp1ACmk/um2bnEGnEkiHd4BK62XRUt82DBCCpBGcLsMKA+zNG9G938dA+zCNJCKvUFA=="],
|
||||
|
||||
"p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
|
||||
|
||||
"p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
||||
|
||||
"p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="],
|
||||
|
||||
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
|
||||
|
||||
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
|
||||
|
||||
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
||||
|
||||
"partial-json": ["partial-json@0.1.7", "", {}, "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA=="],
|
||||
|
||||
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||
|
||||
"path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="],
|
||||
|
||||
"path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
|
||||
|
||||
"pdfjs-dist": ["pdfjs-dist@5.7.284", "", { "optionalDependencies": { "@napi-rs/canvas": "^0.1.100" } }, "sha512-h4EdYQczmGhbOlqc3PPZwxevn7ApdWPbovAuWXOB/DjIyigSnwfy2oze7c6mRcSr9XgLp3eN3EeL4DyySTPMFw=="],
|
||||
|
||||
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
|
||||
|
||||
"playwright-core": ["playwright-core@1.60.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA=="],
|
||||
|
||||
"pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
|
||||
|
||||
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
|
||||
|
||||
"proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="],
|
||||
|
||||
"protobufjs": ["protobufjs@7.6.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg=="],
|
||||
|
||||
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
||||
|
||||
"punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="],
|
||||
|
||||
"qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
|
||||
|
||||
"qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="],
|
||||
|
||||
"quickjs-wasi": ["quickjs-wasi@2.2.0", "", {}, "sha512-zQxXmQMrEoD3S+jQdYsloq4qAuaxKFHZj6hHqOYGwB2iQZH+q9e/lf5zQPXCKOk0WJuAjzRFbO4KwHIp2D05Iw=="],
|
||||
|
||||
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
|
||||
|
||||
"rastermill": ["rastermill@0.3.0", "", { "dependencies": { "@silvia-odwyer/photon-node": "0.3.4" } }, "sha512-4g2i0I7M5sba//lFBh19Wi0hDGw8o+isnt/BtEyqQXIZaYclhcNBwL/Fw/6gDCp7aaLwQHADuUvyHCB0Oat5Vw=="],
|
||||
|
||||
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||
|
||||
"readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
||||
|
||||
"readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
|
||||
|
||||
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="],
|
||||
|
||||
"retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
|
||||
|
||||
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
|
||||
|
||||
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
|
||||
|
||||
"set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
|
||||
|
||||
"setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="],
|
||||
|
||||
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
|
||||
|
||||
"side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
|
||||
|
||||
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
|
||||
|
||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||
|
||||
"signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
|
||||
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
|
||||
|
||||
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
||||
|
||||
"sqlite-vec": ["sqlite-vec@0.1.9", "", { "optionalDependencies": { "sqlite-vec-darwin-arm64": "0.1.9", "sqlite-vec-darwin-x64": "0.1.9", "sqlite-vec-linux-arm64": "0.1.9", "sqlite-vec-linux-x64": "0.1.9", "sqlite-vec-windows-x64": "0.1.9" } }, "sha512-L7XJWRIBNvR9O5+vh1FQ+IGkh/3D2AzVksW5gdtk28m78Hy8skFD0pqReKH1Yp0/BUKRGcffgKvyO/EON5JXpA=="],
|
||||
|
||||
"sqlite-vec-darwin-arm64": ["sqlite-vec-darwin-arm64@0.1.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jSsZpE42OfBkGL/ItyJTVCUwl6o6Ka3U5rc4j+UBDIQzC1ulSSKMEhQLthsOnF/MdAf1MuAkYhkdKmmcjaIZQg=="],
|
||||
|
||||
"sqlite-vec-darwin-x64": ["sqlite-vec-darwin-x64@0.1.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-KDlVyqQT7pnOhU1ymB9gs7dMbSoVmKHitT+k1/xkjarcX8bBqPxWrGlK/R+C5WmWkfvWwyq5FfXfiBYCBs6PlA=="],
|
||||
|
||||
"sqlite-vec-linux-arm64": ["sqlite-vec-linux-arm64@0.1.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-5wXVJ9c9kR4CHm/wVqXb/R+XUHTdpZ4nWbPHlS+gc9qQFVHs92Km4bPnCKX4rtcPMzvNis+SIzMJR1SCEwpuUw=="],
|
||||
|
||||
"sqlite-vec-linux-x64": ["sqlite-vec-linux-x64@0.1.9", "", { "os": "linux", "cpu": "x64" }, "sha512-w3tCH8xK2finW8fQJ/m8uqKodXUZ9KAuAar2UIhz4BHILfpE0WM/MTGCRfa7RjYbrYim5Luk3guvMOGI7T7JQA=="],
|
||||
|
||||
"sqlite-vec-windows-x64": ["sqlite-vec-windows-x64@0.1.9", "", { "os": "win32", "cpu": "x64" }, "sha512-y3gEIyy/17bq2QFPQOWLE68TYWcRZkBQVA2XLrTPHNTOp55xJi/BBBmOm40tVMDMjtP+Elpk6UBUXdaq+46b0Q=="],
|
||||
|
||||
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||
|
||||
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"strnum": ["strnum@2.3.0", "", {}, "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q=="],
|
||||
|
||||
"strtok3": ["strtok3@10.3.5", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="],
|
||||
|
||||
"tar": ["tar@7.5.15", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ=="],
|
||||
|
||||
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||
|
||||
"token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="],
|
||||
|
||||
"tokenjuice": ["tokenjuice@0.7.1", "", { "bin": { "tokenjuice": "dist/cli/main.js" } }, "sha512-eO048hm9UcGHASjYkIWEij8QN68amGp+S1nJyo685qB1/ol+VGEYjPglcVPvCbJbZyFHvI+BBAMvOfnqYCtpsQ=="],
|
||||
|
||||
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
||||
|
||||
"tree-sitter-bash": ["tree-sitter-bash@0.25.1", "", { "dependencies": { "node-addon-api": "^8.2.1", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-7hMytuYIMoXOq24yRulgIxthE9YmggZIOHCyPTTuJcu6EU54tYD+4G39cUb28kxC6jMf/AbPfWGLQtgPTdh3xw=="],
|
||||
|
||||
"ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"tslog": ["tslog@4.10.2", "", {}, "sha512-XuELoRpMR+sq8fuWwX7P0bcj+PRNiicOKDEb3fGNURhxWVyykCi9BNq7c4uVz7h7P0sj8qgBsr5SWS6yBClq3g=="],
|
||||
|
||||
"type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
|
||||
|
||||
"typebox": ["typebox@1.1.38", "", {}, "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="],
|
||||
|
||||
"uhyphen": ["uhyphen@0.2.0", "", {}, "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA=="],
|
||||
|
||||
"uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
|
||||
|
||||
"undici": ["undici@8.3.0", "", {}, "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q=="],
|
||||
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
|
||||
"web-push": ["web-push@3.6.7", "", { "dependencies": { "asn1.js": "^5.3.0", "http_ece": "1.2.0", "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" }, "bin": { "web-push": "src/cli.js" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="],
|
||||
|
||||
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
|
||||
|
||||
"web-tree-sitter": ["web-tree-sitter@0.26.9", "", {}, "sha512-YJwSHANl6XFgeEjB8nitgj0qZYt5gkIesJ4w2srS2wcLB4GUa4xcOkM0YaMsU6WNR53YVIkDSY7Ej4pf3IXtCA=="],
|
||||
|
||||
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
||||
|
||||
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="],
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
|
||||
|
||||
"xml-naming": ["xml-naming@0.1.0", "", {}, "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw=="],
|
||||
|
||||
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||
|
||||
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
|
||||
|
||||
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
|
||||
|
||||
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
|
||||
|
||||
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
|
||||
|
||||
"zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
|
||||
|
||||
"@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1054.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/nested-clients": "^3.997.12", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-hG9YKApmZOw+drJ9Nuoaf/OvC8e5W1+3eoLeN5p2uVCZRWsv27teIS0b4kiH6Sfv3WMmamqYJxmE2WMwyp/L/A=="],
|
||||
|
||||
"@earendil-works/pi-ai/@google/genai": ["@google/genai@1.52.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q=="],
|
||||
|
||||
"@earendil-works/pi-ai/openai": ["openai@6.26.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA=="],
|
||||
|
||||
"@openclaw/fs-safe/tar": ["tar@7.5.13", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng=="],
|
||||
|
||||
"gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
|
||||
|
||||
"htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"openclaw/typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
||||
|
||||
"p-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="],
|
||||
|
||||
"proxy-addr/ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||
|
||||
"qrcode/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
|
||||
|
||||
"type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
|
||||
|
||||
"qrcode/yargs/cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
|
||||
|
||||
"qrcode/yargs/y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
|
||||
|
||||
"qrcode/yargs/yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
|
||||
|
||||
"qrcode/yargs/cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"
|
||||
import type { BmClient } from "../bm-client.ts"
|
||||
import type { BasicMemoryConfig } from "../config.ts"
|
||||
import { log } from "../logger.ts"
|
||||
|
||||
export function registerCli(
|
||||
api: OpenClawPluginApi,
|
||||
client: BmClient,
|
||||
cfg: BasicMemoryConfig,
|
||||
): void {
|
||||
api.registerCli(
|
||||
// biome-ignore lint/suspicious/noExplicitAny: openclaw SDK does not ship types
|
||||
({ program }: { program: any }) => {
|
||||
const cmd = program
|
||||
.command("basic-memory")
|
||||
.description("Basic Memory knowledge graph commands")
|
||||
|
||||
cmd
|
||||
.command("search")
|
||||
.argument("<query>", "Search query")
|
||||
.option("--limit <n>", "Max results", "10")
|
||||
.action(async (query: string, opts: { limit: string }) => {
|
||||
const limit = Number.parseInt(opts.limit, 10) || 10
|
||||
log.debug(`cli search: query="${query}" limit=${limit}`)
|
||||
|
||||
const results = await client.search(query, limit)
|
||||
|
||||
if (results.length === 0) {
|
||||
console.log("No notes found.")
|
||||
return
|
||||
}
|
||||
|
||||
for (const r of results) {
|
||||
const score = r.score ? ` (${(r.score * 100).toFixed(0)}%)` : ""
|
||||
console.log(`- ${r.title}${score}`)
|
||||
if (r.content) {
|
||||
const preview =
|
||||
r.content.length > 100
|
||||
? `${r.content.slice(0, 100)}...`
|
||||
: r.content
|
||||
console.log(` ${preview}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
cmd
|
||||
.command("read")
|
||||
.argument("<identifier>", "Note title, permalink, or memory:// URL")
|
||||
.option("--raw", "Return raw markdown including frontmatter", false)
|
||||
.action(async (identifier: string, opts: { raw?: boolean }) => {
|
||||
log.debug(`cli read: identifier="${identifier}"`)
|
||||
|
||||
const note = await client.readNote(identifier, {
|
||||
includeFrontmatter: opts.raw === true,
|
||||
})
|
||||
console.log(`# ${note.title}`)
|
||||
console.log(`permalink: ${note.permalink}`)
|
||||
console.log(`file: ${note.file_path}`)
|
||||
console.log("")
|
||||
console.log(note.content)
|
||||
})
|
||||
|
||||
cmd
|
||||
.command("edit")
|
||||
.argument("<identifier>", "Note title, permalink, or memory:// URL")
|
||||
.requiredOption(
|
||||
"--operation <operation>",
|
||||
"Edit operation: append|prepend|find_replace|replace_section",
|
||||
)
|
||||
.requiredOption("--content <content>", "Edit content")
|
||||
.option("--find-text <text>", "Text to find for find_replace")
|
||||
.option("--section <heading>", "Section heading for replace_section")
|
||||
.option(
|
||||
"--expected-replacements <n>",
|
||||
"Expected replacement count for find_replace",
|
||||
"1",
|
||||
)
|
||||
.action(
|
||||
async (
|
||||
identifier: string,
|
||||
opts: {
|
||||
operation:
|
||||
| "append"
|
||||
| "prepend"
|
||||
| "find_replace"
|
||||
| "replace_section"
|
||||
content: string
|
||||
findText?: string
|
||||
section?: string
|
||||
expectedReplacements: string
|
||||
},
|
||||
) => {
|
||||
const expectedReplacements =
|
||||
Number.parseInt(opts.expectedReplacements, 10) || 1
|
||||
log.debug(
|
||||
`cli edit: identifier="${identifier}" op=${opts.operation} expected_replacements=${expectedReplacements}`,
|
||||
)
|
||||
|
||||
const result = await client.editNote(
|
||||
identifier,
|
||||
opts.operation,
|
||||
opts.content,
|
||||
{
|
||||
find_text: opts.findText,
|
||||
section: opts.section,
|
||||
expected_replacements: expectedReplacements,
|
||||
},
|
||||
)
|
||||
|
||||
console.log(`Edited: ${result.title}`)
|
||||
console.log(`permalink: ${result.permalink}`)
|
||||
console.log(`file: ${result.file_path}`)
|
||||
console.log(`operation: ${result.operation}`)
|
||||
if (result.checksum) {
|
||||
console.log(`checksum: ${result.checksum}`)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
cmd
|
||||
.command("context")
|
||||
.argument("<url>", "Memory URL to navigate")
|
||||
.option("--depth <n>", "Relation hops to follow", "1")
|
||||
.action(async (url: string, opts: { depth: string }) => {
|
||||
const depth = Number.parseInt(opts.depth, 10) || 1
|
||||
log.debug(`cli context: url="${url}" depth=${depth}`)
|
||||
|
||||
const ctx = await client.buildContext(url, depth)
|
||||
|
||||
if (!ctx.results || ctx.results.length === 0) {
|
||||
console.log(`No context found for "${url}".`)
|
||||
return
|
||||
}
|
||||
|
||||
for (const result of ctx.results) {
|
||||
console.log(`## ${result.primary_result.title}`)
|
||||
console.log(result.primary_result.content)
|
||||
console.log("")
|
||||
}
|
||||
})
|
||||
|
||||
cmd
|
||||
.command("recent")
|
||||
.option("--timeframe <t>", "Timeframe (e.g. 24h, 7d)", "24h")
|
||||
.action(async (opts: { timeframe: string }) => {
|
||||
log.debug(`cli recent: timeframe="${opts.timeframe}"`)
|
||||
|
||||
const results = await client.recentActivity(opts.timeframe)
|
||||
|
||||
if (results.length === 0) {
|
||||
console.log("No recent activity.")
|
||||
return
|
||||
}
|
||||
|
||||
for (const r of results) {
|
||||
console.log(`- ${r.title} (${r.permalink})`)
|
||||
}
|
||||
})
|
||||
|
||||
cmd
|
||||
.command("status")
|
||||
.description("Show plugin status")
|
||||
.action(() => {
|
||||
console.log(`Project: ${cfg.project}`)
|
||||
console.log(`Project path: ${cfg.projectPath}`)
|
||||
console.log(`BM CLI: ${cfg.bmPath}`)
|
||||
console.log(`Memory dir: ${cfg.memoryDir}`)
|
||||
console.log(`Memory file: ${cfg.memoryFile}`)
|
||||
console.log(`Auto-capture: ${cfg.autoCapture}`)
|
||||
console.log(`Debug: ${cfg.debug}`)
|
||||
})
|
||||
},
|
||||
{ commands: ["basic-memory"] },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it, jest } from "bun:test"
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"
|
||||
import { registerSkillCommands } from "./skills.ts"
|
||||
|
||||
describe("skill slash commands", () => {
|
||||
it("should register all skill commands", () => {
|
||||
const mockApi = {
|
||||
registerCommand: jest.fn(),
|
||||
} as unknown as OpenClawPluginApi
|
||||
|
||||
registerSkillCommands(mockApi)
|
||||
|
||||
const callCount = (mockApi.registerCommand as jest.MockedFunction<any>).mock
|
||||
.calls.length
|
||||
expect(callCount).toBeGreaterThanOrEqual(9)
|
||||
|
||||
const names = (
|
||||
mockApi.registerCommand as jest.MockedFunction<any>
|
||||
).mock.calls.map((call: any[]) => call[0].name)
|
||||
expect(names).toEqual(
|
||||
expect.arrayContaining([
|
||||
"tasks",
|
||||
"reflect",
|
||||
"defrag",
|
||||
"schema",
|
||||
"notes",
|
||||
"metadata-search",
|
||||
"lifecycle",
|
||||
"ingest",
|
||||
"research",
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it("should set correct metadata on each command", () => {
|
||||
const mockApi = {
|
||||
registerCommand: jest.fn(),
|
||||
} as unknown as OpenClawPluginApi
|
||||
|
||||
registerSkillCommands(mockApi)
|
||||
|
||||
for (const call of (mockApi.registerCommand as jest.MockedFunction<any>)
|
||||
.mock.calls) {
|
||||
const cmd = call[0]
|
||||
expect(cmd.acceptsArgs).toBe(true)
|
||||
expect(cmd.requireAuth).toBe(true)
|
||||
expect(typeof cmd.description).toBe("string")
|
||||
expect(cmd.description.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
describe("handler behavior", () => {
|
||||
let commands: Record<string, any>
|
||||
|
||||
function setup(): void {
|
||||
commands = {}
|
||||
const mockApi = {
|
||||
registerCommand: jest.fn((cmd: any) => {
|
||||
commands[cmd.name] = cmd
|
||||
}),
|
||||
} as unknown as OpenClawPluginApi
|
||||
|
||||
registerSkillCommands(mockApi)
|
||||
}
|
||||
|
||||
it("should return skill content without prefix when no args", async () => {
|
||||
setup()
|
||||
|
||||
const result = await commands.tasks.handler({})
|
||||
expect(result.text).toStartWith("Follow this workflow:\n\n")
|
||||
expect(result.text).toContain("# Memory Tasks")
|
||||
})
|
||||
|
||||
it("should return skill content with empty args trimmed", async () => {
|
||||
setup()
|
||||
|
||||
const result = await commands.reflect.handler({ args: " " })
|
||||
expect(result.text).toStartWith("Follow this workflow:\n\n")
|
||||
expect(result.text).toContain("# Memory Reflect")
|
||||
})
|
||||
|
||||
it("should prepend user request when args provided", async () => {
|
||||
setup()
|
||||
|
||||
const result = await commands.defrag.handler({
|
||||
args: "clean up old tasks",
|
||||
})
|
||||
expect(result.text).toStartWith(
|
||||
"User request: clean up old tasks\n\nFollow this workflow:\n\n",
|
||||
)
|
||||
expect(result.text).toContain("# Memory Defrag")
|
||||
})
|
||||
|
||||
it("should include full skill content for each command", async () => {
|
||||
setup()
|
||||
|
||||
const tasksResult = await commands.tasks.handler({})
|
||||
expect(tasksResult.text).toContain("## Task Schema")
|
||||
|
||||
const reflectResult = await commands.reflect.handler({})
|
||||
expect(reflectResult.text).toContain("## When to Run")
|
||||
|
||||
const defragResult = await commands.defrag.handler({})
|
||||
expect(defragResult.text).toContain("## When to Run")
|
||||
|
||||
const schemaResult = await commands.schema.handler({})
|
||||
expect(schemaResult.text).toContain("## Picoschema Syntax Reference")
|
||||
|
||||
const notesResult = await commands.notes.handler({})
|
||||
expect(notesResult.text).toContain("## Note Anatomy")
|
||||
|
||||
const metadataResult = await commands["metadata-search"].handler({})
|
||||
expect(metadataResult.text).toContain("## Filter Syntax")
|
||||
|
||||
const lifecycleResult = await commands.lifecycle.handler({})
|
||||
expect(lifecycleResult.text).toContain("# Memory Lifecycle")
|
||||
|
||||
const ingestResult = await commands.ingest.handler({})
|
||||
expect(ingestResult.text).toContain("# Memory Ingest")
|
||||
|
||||
const researchResult = await commands.research.handler({})
|
||||
expect(researchResult.text).toContain("# Memory Research")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
import { dirname, resolve } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
interface ManifestEntry {
|
||||
dir: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
function resolveSkillsDir(api: OpenClawPluginApi): string {
|
||||
if (api.resolvePath) {
|
||||
return api.resolvePath("skills")
|
||||
}
|
||||
|
||||
const sourceRootSkills = resolve(__dirname, "..", "skills")
|
||||
if (existsSync(sourceRootSkills)) {
|
||||
return sourceRootSkills
|
||||
}
|
||||
|
||||
return resolve(__dirname, "..", "..", "skills")
|
||||
}
|
||||
|
||||
function loadManifest(skillsDir: string): ManifestEntry[] {
|
||||
try {
|
||||
const raw = readFileSync(resolve(skillsDir, "manifest.json"), "utf-8")
|
||||
return JSON.parse(raw) as ManifestEntry[]
|
||||
} catch {
|
||||
throw new Error(
|
||||
"skills/manifest.json not found. Run `bun scripts/fetch-skills.ts` first.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function loadSkill(skillsDir: string, dir: string): string {
|
||||
return readFileSync(resolve(skillsDir, dir, "SKILL.md"), "utf-8")
|
||||
}
|
||||
|
||||
export function registerSkillCommands(api: OpenClawPluginApi): void {
|
||||
const skillsDir = resolveSkillsDir(api)
|
||||
const manifest = loadManifest(skillsDir)
|
||||
|
||||
for (const entry of manifest) {
|
||||
const commandName = entry.dir.replace(/^memory-/, "")
|
||||
const content = loadSkill(skillsDir, entry.dir)
|
||||
|
||||
api.registerCommand({
|
||||
name: commandName,
|
||||
description: entry.description,
|
||||
acceptsArgs: true,
|
||||
requireAuth: true,
|
||||
handler: async (ctx: { args?: string }) => {
|
||||
const args = ctx.args?.trim()
|
||||
const prefix = args
|
||||
? `User request: ${args}\n\nFollow this workflow:\n\n`
|
||||
: "Follow this workflow:\n\n"
|
||||
return { text: prefix + content }
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { execSync } from "node:child_process"
|
||||
import { dirname, resolve } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"
|
||||
import type { BmClient } from "../bm-client.ts"
|
||||
import { log } from "../logger.ts"
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
export function registerCommands(
|
||||
api: OpenClawPluginApi,
|
||||
client: BmClient,
|
||||
): void {
|
||||
api.registerCommand({
|
||||
name: "bm-setup",
|
||||
description: "Install or update the Basic Memory CLI (requires uv)",
|
||||
requireAuth: true,
|
||||
handler: async () => {
|
||||
const scriptPath = api.resolvePath
|
||||
? api.resolvePath("scripts/setup-bm.sh")
|
||||
: resolve(__dirname, "..", "scripts", "setup-bm.sh")
|
||||
log.info(`/bm-setup: running ${scriptPath}`)
|
||||
|
||||
try {
|
||||
const output = execSync(`bash "${scriptPath}"`, {
|
||||
encoding: "utf-8",
|
||||
timeout: 180_000,
|
||||
stdio: "pipe",
|
||||
env: { ...process.env },
|
||||
})
|
||||
return { text: output.trim() }
|
||||
} catch (err: unknown) {
|
||||
const execErr = err as { stderr?: string; stdout?: string }
|
||||
const detail = execErr.stderr || execErr.stdout || String(err)
|
||||
log.error("/bm-setup failed", err)
|
||||
return {
|
||||
text: `Setup failed:\n${detail.trim()}`,
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
api.registerCommand({
|
||||
name: "remember",
|
||||
description: "Save something to the Basic Memory knowledge graph",
|
||||
acceptsArgs: true,
|
||||
requireAuth: true,
|
||||
handler: async (ctx: { args?: string }) => {
|
||||
const text = ctx.args?.trim()
|
||||
if (!text) {
|
||||
return { text: "Usage: /remember <text to save as a note>" }
|
||||
}
|
||||
|
||||
log.debug(`/remember: "${text.slice(0, 50)}"`)
|
||||
|
||||
try {
|
||||
const title = text.length > 60 ? text.slice(0, 60) : text
|
||||
await client.writeNote(title, text, "agent/memories")
|
||||
|
||||
const preview = text.length > 60 ? `${text.slice(0, 60)}...` : text
|
||||
return { text: `Remembered: "${preview}"` }
|
||||
} catch (err) {
|
||||
log.error("/remember failed", err)
|
||||
return {
|
||||
text: "Failed to save memory. Is Basic Memory running?",
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
api.registerCommand({
|
||||
name: "recall",
|
||||
description: "Search the Basic Memory knowledge graph",
|
||||
acceptsArgs: true,
|
||||
requireAuth: true,
|
||||
handler: async (ctx: { args?: string }) => {
|
||||
const query = ctx.args?.trim()
|
||||
if (!query) {
|
||||
return { text: "Usage: /recall <search query>" }
|
||||
}
|
||||
|
||||
log.debug(`/recall: "${query}"`)
|
||||
|
||||
try {
|
||||
const results = await client.search(query, 5)
|
||||
|
||||
if (results.length === 0) {
|
||||
return { text: `No notes found for: "${query}"` }
|
||||
}
|
||||
|
||||
const lines = results.map((r, i) => {
|
||||
const score = r.score ? ` (${(r.score * 100).toFixed(0)}%)` : ""
|
||||
return `${i + 1}. ${r.title}${score}`
|
||||
})
|
||||
|
||||
return {
|
||||
text: `Found ${results.length} notes:\n\n${lines.join("\n")}`,
|
||||
}
|
||||
} catch (err) {
|
||||
log.error("/recall failed", err)
|
||||
return {
|
||||
text: "Failed to search. Is Basic Memory running?",
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user