voice: strip em dashes repo-wide per rule 9
Browse files423 em dashes across 38 files: 4 CHANGELOGs, 4 READMEs, source
files, banners, aesthetic palette comments, test docstrings.
Context-aware replacement:
"β" placeholders -> "-"
] β YYYY-MM-DD -> ] - YYYY-MM-DD (Keep-A-Changelog convention)
β Foo (next caps) -> . Foo (sentence break)
β foo (next lc) -> : foo (introduces detail)
LLM-tells scan clean (zero hits across all prose surfaces).
Client suite 75/75 + hermes suite 40/40 green post-scrub.
All Python files compile.
- README.md +3 -3
- sibyl-memory-cli/CHANGELOG.md +40 -40
- sibyl-memory-cli/README.md +3 -3
- sibyl-memory-cli/src/sibyl_memory_cli/__init__.py +3 -3
- sibyl-memory-cli/src/sibyl_memory_cli/_aesthetic.py +18 -18
- sibyl-memory-cli/src/sibyl_memory_cli/_banner.py +8 -8
- sibyl-memory-cli/src/sibyl_memory_cli/cli.py +31 -31
- sibyl-memory-cli/tests/test_setup.py +1 -1
- sibyl-memory-client/CHANGELOG.md +51 -51
- sibyl-memory-client/README.md +1 -1
- sibyl-memory-client/src/sibyl_memory_client/_capcheck.py +8 -8
- sibyl-memory-client/src/sibyl_memory_client/client.py +18 -18
- sibyl-memory-client/src/sibyl_memory_client/exceptions.py +1 -1
- sibyl-memory-client/src/sibyl_memory_client/learning.py +10 -10
- sibyl-memory-client/src/sibyl_memory_client/lint.py +11 -11
- sibyl-memory-client/src/sibyl_memory_client/storage.py +10 -10
- sibyl-memory-client/tests/test_capcheck.py +6 -6
- sibyl-memory-client/tests/test_kappa_fixes.py +12 -12
- sibyl-memory-client/tests/test_learning.py +3 -3
- sibyl-memory-client/tests/test_lint.py +2 -2
- sibyl-memory-hermes/CHANGELOG.md +46 -46
- sibyl-memory-hermes/README.md +8 -8
- sibyl-memory-hermes/src/sibyl_memory_hermes/__init__.py +4 -4
- sibyl-memory-hermes/src/sibyl_memory_hermes/_aesthetic.py +18 -18
- sibyl-memory-hermes/src/sibyl_memory_hermes/_banner.py +8 -8
- sibyl-memory-hermes/src/sibyl_memory_hermes/_hermes_plugin/__init__.py +1 -1
- sibyl-memory-hermes/src/sibyl_memory_hermes/_hermes_plugin/adapter.py +17 -17
- sibyl-memory-hermes/src/sibyl_memory_hermes/_hermes_plugin/plugin.yaml +1 -1
- sibyl-memory-hermes/src/sibyl_memory_hermes/credentials.py +5 -5
- sibyl-memory-hermes/src/sibyl_memory_hermes/install_plugin.py +9 -9
- sibyl-memory-hermes/src/sibyl_memory_hermes/provider.py +16 -16
- sibyl-memory-hermes/tests/test_adapter.py +5 -5
- sibyl-memory-hermes/tests/test_smoke.py +3 -3
- sibyl-memory-mcp/CHANGELOG.md +26 -26
- sibyl-memory-mcp/README.md +2 -2
- sibyl-memory-mcp/pyproject.toml +1 -1
- sibyl-memory-mcp/src/sibyl_memory_mcp/__init__.py +1 -1
- sibyl-memory-mcp/src/sibyl_memory_mcp/server.py +11 -11
README.md
CHANGED
|
@@ -35,7 +35,7 @@ Five PyPI packages, one schema family, one architecture.
|
|
| 35 |
|
| 36 |
The other four packages ride on top: `sibyl-memory-cli` for activation and tier management, `sibyl-memory-hermes` for Hermes Agent integration, `sibyl-memory-mcp` for any MCP-compatible client (Claude Code, Codex, Cursor, Continue), and `sibyl-plugin-schema` for the activation database that backs account, subscription, and staker-tier state on the server side.
|
| 37 |
|
| 38 |
-
The architecture was benchmarked publicly on [LongMemEval Oracle](https://blog.sibylcap.com/longmemeval-v2) (ICLR 2025, University of Michigan, 500 questions) and placed **#2 overall at 95.6%**, tied with Chronos (PwC), beating Mastra, MemMachine, Hindsight, Mem0, Supermemory, Zep, and the Oracle baseline. It is the only file-based system in the top tier
|
| 39 |
|
| 40 |
This is the entire stack as it ships to production agents today.
|
| 41 |
|
|
@@ -49,7 +49,7 @@ This is the entire stack as it ships to production agents today.
|
|
| 49 |
| [`sibyl-memory-cli`](./sibyl-memory-cli) | [](https://pypi.org/project/sibyl-memory-cli/) | Command-line interface. `sibyl init` activates, `sibyl upgrade` runs the staker / subscription flow, `sibyl status` shows current tier and DB stats, `sibyl whoami`, `sibyl devices`. |
|
| 50 |
| [`sibyl-memory-hermes`](./sibyl-memory-hermes) | [](https://pypi.org/project/sibyl-memory-hermes/) | Bundled memory payload for Hermes Agent v0.13+ (and any other Python orchestration that wants direct SDK access). |
|
| 51 |
| [`sibyl-memory-mcp`](./sibyl-memory-mcp) | [](https://pypi.org/project/sibyl-memory-mcp/) | MCP server. Wraps the local SQLite + FTS5 memory engine and exposes it to MCP-compatible agents (Claude Code, Codex, Cursor, Continue, anything that speaks MCP). |
|
| 52 |
-
| [`sibyl-plugin-schema`](./sibyl-plugin-schema) | (internal) | SQL migrations for the activation / account / subscription database. Not on PyPI
|
| 53 |
|
| 54 |
---
|
| 55 |
|
|
@@ -138,7 +138,7 @@ Built by [SIBYL](https://x.com/sibylcap), the autonomous agent operating at [Sib
|
|
| 138 |
|
| 139 |
The agent has been operating in production since February 2026, ships code daily, holds an on-chain identity on Base (ERC-8004 agent ID 20880), runs an autonomous trading engine, an on-chain messaging protocol, an x402 payment rail, a token-gated chat demo, an advisory dashboard, and this memory product family. Everything verifiable on-chain.
|
| 140 |
|
| 141 |
-
Memory architecture is the proven core. Sibyl Labs LLC owns the IP, signs contracts, and holds the legal wrapper around the agent's work. The work itself is shipped by the agent, in sessions, through the operator (`@tradingtulips`). The PyPI releases, the schema migrations, the CLI banner above
|
| 142 |
|
| 143 |
The on-chain record is the resume. This repository is one chapter of it.
|
| 144 |
|
|
|
|
| 35 |
|
| 36 |
The other four packages ride on top: `sibyl-memory-cli` for activation and tier management, `sibyl-memory-hermes` for Hermes Agent integration, `sibyl-memory-mcp` for any MCP-compatible client (Claude Code, Codex, Cursor, Continue), and `sibyl-plugin-schema` for the activation database that backs account, subscription, and staker-tier state on the server side.
|
| 37 |
|
| 38 |
+
The architecture was benchmarked publicly on [LongMemEval Oracle](https://blog.sibylcap.com/longmemeval-v2) (ICLR 2025, University of Michigan, 500 questions) and placed **#2 overall at 95.6%**, tied with Chronos (PwC), beating Mastra, MemMachine, Hindsight, Mem0, Supermemory, Zep, and the Oracle baseline. It is the only file-based system in the top tier: running on a single 4 vCPU / 16 GB box, no vector infrastructure, no embedding fees.
|
| 39 |
|
| 40 |
This is the entire stack as it ships to production agents today.
|
| 41 |
|
|
|
|
| 49 |
| [`sibyl-memory-cli`](./sibyl-memory-cli) | [](https://pypi.org/project/sibyl-memory-cli/) | Command-line interface. `sibyl init` activates, `sibyl upgrade` runs the staker / subscription flow, `sibyl status` shows current tier and DB stats, `sibyl whoami`, `sibyl devices`. |
|
| 50 |
| [`sibyl-memory-hermes`](./sibyl-memory-hermes) | [](https://pypi.org/project/sibyl-memory-hermes/) | Bundled memory payload for Hermes Agent v0.13+ (and any other Python orchestration that wants direct SDK access). |
|
| 51 |
| [`sibyl-memory-mcp`](./sibyl-memory-mcp) | [](https://pypi.org/project/sibyl-memory-mcp/) | MCP server. Wraps the local SQLite + FTS5 memory engine and exposes it to MCP-compatible agents (Claude Code, Codex, Cursor, Continue, anything that speaks MCP). |
|
| 52 |
+
| [`sibyl-plugin-schema`](./sibyl-plugin-schema) | (internal) | SQL migrations for the activation / account / subscription database. Not on PyPI: kept here as immutable record. |
|
| 53 |
|
| 54 |
---
|
| 55 |
|
|
|
|
| 138 |
|
| 139 |
The agent has been operating in production since February 2026, ships code daily, holds an on-chain identity on Base (ERC-8004 agent ID 20880), runs an autonomous trading engine, an on-chain messaging protocol, an x402 payment rail, a token-gated chat demo, an advisory dashboard, and this memory product family. Everything verifiable on-chain.
|
| 140 |
|
| 141 |
+
Memory architecture is the proven core. Sibyl Labs LLC owns the IP, signs contracts, and holds the legal wrapper around the agent's work. The work itself is shipped by the agent, in sessions, through the operator (`@tradingtulips`). The PyPI releases, the schema migrations, the CLI banner above: all of it is autonomous agent output.
|
| 142 |
|
| 143 |
The on-chain record is the resume. This repository is one chapter of it.
|
| 144 |
|
sibyl-memory-cli/CHANGELOG.md
CHANGED
|
@@ -4,7 +4,7 @@ All notable changes to `sibyl-memory-cli` are recorded here. Format follows
|
|
| 4 |
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning follows
|
| 5 |
[SemVer](https://semver.org/).
|
| 6 |
|
| 7 |
-
## [0.3.2]
|
| 8 |
|
| 9 |
Branding pass on the banner. Operator directive: "beneath the large
|
| 10 |
SIBYL title it needs to say underneath the memory you can hold in
|
|
@@ -24,9 +24,9 @@ Preview captures at https://sibylcap.com/hud-2026-05-20 (scene 09
|
|
| 24 |
isolates the banner; scenes 01 + 05 show it inline with the rest of
|
| 25 |
the activation and install ceremonies).
|
| 26 |
|
| 27 |
-
## [0.3.1]
|
| 28 |
|
| 29 |
-
Operator-directed tuning: "typical app patterns
|
| 30 |
install window and initial setup, light on dashboards etc." v0.3.0
|
| 31 |
applied the full section_header treatment uniformly across every
|
| 32 |
subcommand. v0.3.1 lightens the daily-use dashboards and keeps the
|
|
@@ -35,22 +35,22 @@ ceremony reserved for activation moments.
|
|
| 35 |
### Changed
|
| 36 |
|
| 37 |
- `sibyl status`, `sibyl whoami`, `sibyl devices`, `sibyl logout`,
|
| 38 |
-
`sibyl health`
|
| 39 |
as `git status`, `ls -la`, `gh auth status`, `pg_isready`,
|
| 40 |
`redis-cli ping`: utilitarian dashboards present data, not chrome.
|
| 41 |
Eyebrow labels + kv rows + status lines remain.
|
| 42 |
|
| 43 |
### Unchanged
|
| 44 |
|
| 45 |
-
- `sibyl init`
|
| 46 |
headers + numbered next-steps. This IS the install moment; it earns
|
| 47 |
the ceremony.
|
| 48 |
-
- `sibyl upgrade`
|
| 49 |
moment is install-ish but not first-run.
|
| 50 |
-
- `_aesthetic.py` library
|
| 51 |
commands per the heavy/light convention.
|
| 52 |
|
| 53 |
-
## [0.3.0]
|
| 54 |
|
| 55 |
Visual identity pass across every subcommand. The `sibyl init` brand
|
| 56 |
moment (the SIBYL ASCII wordmark with pale-gold β deep-ochre vertical
|
|
@@ -60,7 +60,7 @@ to the whole surface.
|
|
| 60 |
|
| 61 |
### Added
|
| 62 |
|
| 63 |
-
- New `_aesthetic.py` module
|
| 64 |
Brand palette derived from the rule 46 creme paper face (PAPER, INK,
|
| 65 |
ACCENT, JADE, PULSE, RULE, etc.). 24-bit truecolor β 256-color β plain
|
| 66 |
text degradation cascade. Letter-spaced eyebrows, gradient titles,
|
|
@@ -100,31 +100,31 @@ to the whole surface.
|
|
| 100 |
consistency (COLORTERM=truecolor, TERM_PROGRAM whitelist, TERM
|
| 101 |
pattern match for kitty/alacritty/256color).
|
| 102 |
|
| 103 |
-
## [0.2.0]
|
| 104 |
|
| 105 |
-
Auth-redesign wave 2
|
| 106 |
for a one-line account summary (masked by default, `--full` opt-in) and
|
| 107 |
`sibyl devices` for listing active bearer tokens with per-device revoke.
|
| 108 |
|
| 109 |
### Added
|
| 110 |
|
| 111 |
-
- `sibyl whoami`
|
| 112 |
(`a***@e***.tld`), masked wallet (`0xabcdβ¦1234`), this device label.
|
| 113 |
`--full` flag shows unmasked email + wallet for ops scenarios.
|
| 114 |
-
- `sibyl devices`
|
| 115 |
account in issued_at DESC order. Marks current device with `βΆ` and
|
| 116 |
shows revoke command for each other device.
|
| 117 |
-
- `sibyl devices revoke <index>`
|
| 118 |
bearer_id at that index. Refuses to revoke the calling device.
|
| 119 |
|
| 120 |
### Server companion (deployed)
|
| 121 |
|
| 122 |
-
- `GET /api/plugin/devices?account_id=<uuid>`
|
| 123 |
-
- `POST /api/plugin/devices { bearer_id }`
|
| 124 |
- Both auth via `Authorization: Bearer <session_token>`; caller must
|
| 125 |
own the account.
|
| 126 |
|
| 127 |
-
## [0.1.4]
|
| 128 |
|
| 129 |
Maximum-efficiency onboarding release. New `sibyl setup` command auto-detects
|
| 130 |
agent frameworks on the user's machine and wires SIBYL as the memory provider
|
|
@@ -134,14 +134,14 @@ sibyl-memory-hermes` + `sibyl-memory-hermes install-plugin` + manual
|
|
| 134 |
|
| 135 |
### Added
|
| 136 |
|
| 137 |
-
- **`sibyl setup`**
|
| 138 |
`~/.hermes/` or `hermes` on PATH) and Claude Code (`~/.claude/settings.json`
|
| 139 |
or `claude` on PATH). Prompts per stack with explicit confirmation:
|
| 140 |
- Fresh add: `Set SIBYL as default memory provider in Hermes? [Y/n]` (default Y)
|
| 141 |
- Overwrite existing: `Hermes currently uses 'mem0' as memory provider. Overwrite with SIBYL? [y/N]` (default N, never destroys user state without explicit y)
|
| 142 |
- Already wired: noop with green status
|
| 143 |
- Multi-framework: `Wire which? [h]ermes, [c]laude, [a]ll, [n]one (default: all)`
|
| 144 |
-
- **`sibyl setup hermes`** / **`sibyl setup claude-code`**
|
| 145 |
for power users (skips detection, wires only the named stack).
|
| 146 |
- **Flags**: `--yes` (accept all defaults, still respects destructive-default-N
|
| 147 |
unless `--force` is also passed), `--force` (overwrite existing non-SIBYL
|
|
@@ -179,7 +179,7 @@ sibyl-memory-hermes` + `sibyl-memory-hermes install-plugin` + manual
|
|
| 179 |
|
| 180 |
|
| 181 |
|
| 182 |
-
## [0.1.3]
|
| 183 |
|
| 184 |
KAPPA external-tester remediation release. Family-wide alignment with the
|
| 185 |
v0.4.0 client + v0.3.2 hermes (KAPPA-attributed fixes: exception export
|
|
@@ -199,7 +199,7 @@ code changes in this release.
|
|
| 199 |
|
| 200 |
---
|
| 201 |
|
| 202 |
-
## [0.1.2]
|
| 203 |
|
| 204 |
Audit-remediation release. v0.3.0 plugin-family pre-ship audit (2026-05-18T05:05Z)
|
| 205 |
surfaced 10 critical findings; this release lands the CLI-side fixes.
|
|
@@ -208,17 +208,17 @@ cross-tier search), `sibyl-memory-hermes` v0.3.1, `sibyl-memory-mcp` v0.1.1.
|
|
| 208 |
|
| 209 |
### Fixed
|
| 210 |
|
| 211 |
-
- **C3**
|
| 212 |
`importlib.metadata.version("sibyl-memory-cli")` with `+source` fallback.
|
| 213 |
Same pattern as sibyl-memory-hermes v0.3.0+. Wheel and `__init__.py`
|
| 214 |
can't drift.
|
| 215 |
-
- **C3**
|
| 216 |
`_client_version()` helper instead of the hardcoded `"sibyl-memory-cli/0.1.0"`.
|
| 217 |
Server telemetry will see real versions, not the stale literal.
|
| 218 |
-
- **C3**
|
| 219 |
similarly switched from `__import__("sibyl_memory_cli").__version__` to
|
| 220 |
the helper. Telemetry will accurately reflect 0.1.2+.
|
| 221 |
-
- **C4**
|
| 222 |
`from hermes_agent import Agent; agent = Agent(memory=SibylMemoryProvider())`
|
| 223 |
quickstart (the API never existed in any Hermes release). Replaced with:
|
| 224 |
the real Hermes install flow (`sibyl-memory-hermes install-plugin` +
|
|
@@ -227,12 +227,12 @@ cross-tier search), `sibyl-memory-hermes` v0.3.1, `sibyl-memory-mcp` v0.1.1.
|
|
| 227 |
|
| 228 |
### Security
|
| 229 |
|
| 230 |
-
- **SEC-2**
|
| 231 |
set by the kernel at file-creation time via `os.open(O_WRONLY|O_CREAT|
|
| 232 |
O_EXCL|O_NOFOLLOW, 0o600)`. Previously used `write_text()` then
|
| 233 |
`os.chmod(0o600)`, leaving a world-readable window between syscalls every
|
| 234 |
credential save. No race.
|
| 235 |
-
- **SEC-1** (CLI-side mitigation)
|
| 236 |
browser is now treated as an opaque pairing-session identifier, not as
|
| 237 |
the long-lived bearer. After activation completes, the CLI prefers a
|
| 238 |
server-issued `bearer_token` field from `/check` (post-fix server flow);
|
|
@@ -241,21 +241,21 @@ cross-tier search), `sibyl-memory-hermes` v0.3.1, `sibyl-memory-mcp` v0.1.1.
|
|
| 241 |
release prepares the CLI to consume it when the server-side lands.
|
| 242 |
Internal variable renamed `session_token` β `session_id` in `cmd_init`
|
| 243 |
to reflect the corrected meaning.
|
| 244 |
-
- **SEC-11**
|
| 245 |
|
| 246 |
### Dependencies
|
| 247 |
|
| 248 |
- `sibyl-memory-client>=0.3.3` (was `>=0.3.0`)
|
| 249 |
-
- `sibyl-memory-hermes>=0.3.1` (was `>=0.2.0`)
|
| 250 |
removal in the hermes package; earlier versions are structurally broken.
|
| 251 |
|
| 252 |
-
## [0.1.1]
|
| 253 |
|
| 254 |
### Added
|
| 255 |
|
| 256 |
- **SIBYL wordmark banner** at the top of `sibyl init`. ANSI Shadow boxchars,
|
| 257 |
24-bit truecolor vertical gradient flowing cream/white at the top through
|
| 258 |
-
warm gold to deep ochre at the bottom
|
| 259 |
per the operator's brand-discipline rule (creme palette, `--accent #8a6a2a`).
|
| 260 |
Plus a tagline: "memory you can hold in your hand".
|
| 261 |
|
|
@@ -263,16 +263,16 @@ cross-tier search), `sibyl-memory-hermes` v0.3.1, `sibyl-memory-mcp` v0.1.1.
|
|
| 263 |
|
| 264 |
- New module `sibyl_memory_cli._banner` with `render_banner()` and
|
| 265 |
`print_banner()` helpers. Truecolor support is detected via `COLORTERM`,
|
| 266 |
-
`TERM_PROGRAM`, and `TERM`
|
| 267 |
wezterm, Ghostty, Windows Terminal, VS Code, Tabby) light up automatically.
|
| 268 |
- Gracefully degrades to plain text (still readable, no escape junk) when
|
| 269 |
`NO_COLOR` is set, when stdout is not a TTY, or when `TERM=dumb`.
|
| 270 |
-
- Wired into `cmd_init` only
|
| 271 |
so they don't add noise to scripted invocations.
|
| 272 |
- Banner palette is encoded as 6 RGB tuples (one per row) in the module
|
| 273 |
-
rather than computed at runtime
|
| 274 |
|
| 275 |
-
## [0.1.0]
|
| 276 |
|
| 277 |
### Changed (same-day revision before publish): terminal pairing code
|
| 278 |
|
|
@@ -283,7 +283,7 @@ The code itself never leaves the user's machine until they type it
|
|
| 283 |
into the browser's email panel. Replaces the earlier Resend-backed
|
| 284 |
email magic-code flow, removing the external dependency entirely.
|
| 285 |
|
| 286 |
-
The wallet (SIWE) path is unchanged
|
| 287 |
for the email panel.
|
| 288 |
|
| 289 |
|
|
@@ -293,20 +293,20 @@ CLI + upgrade page so the SDK + payment-auth machinery has a front door.
|
|
| 293 |
|
| 294 |
### Added
|
| 295 |
|
| 296 |
-
- **`sibyl init`**
|
| 297 |
opens `sibyllabs.org/plugin/activate?session=...` in the user's
|
| 298 |
browser, polls `api.sibyllabs.org/api/plugin/check` every 3s with a
|
| 299 |
10-min timeout. On bind, writes `~/.sibyl-memory/credentials.json`
|
| 300 |
atomically at mode 0600.
|
| 301 |
-
- **`sibyl upgrade`**
|
| 302 |
with the existing session token. Polls `/api/plugin/access` every 3s
|
| 303 |
with a 15-min timeout until `tier` changes from the local value.
|
| 304 |
On change: rewrites credentials.json, clears `tier_cache.json` so
|
| 305 |
the next write picks up the new entitlement immediately.
|
| 306 |
-
- **`sibyl status`**
|
| 307 |
state, plus the server's view of tier (subscription / staker /
|
| 308 |
free). Flags localβserver tier drift.
|
| 309 |
-
- **`sibyl health`**
|
| 310 |
the JSON diagnostic dict.
|
| 311 |
|
| 312 |
### Design
|
|
|
|
| 4 |
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning follows
|
| 5 |
[SemVer](https://semver.org/).
|
| 6 |
|
| 7 |
+
## [0.3.2] - 2026-05-20
|
| 8 |
|
| 9 |
Branding pass on the banner. Operator directive: "beneath the large
|
| 10 |
SIBYL title it needs to say underneath the memory you can hold in
|
|
|
|
| 24 |
isolates the banner; scenes 01 + 05 show it inline with the rest of
|
| 25 |
the activation and install ceremonies).
|
| 26 |
|
| 27 |
+
## [0.3.1] - 2026-05-20
|
| 28 |
|
| 29 |
+
Operator-directed tuning: "typical app patterns: heavy menus on
|
| 30 |
install window and initial setup, light on dashboards etc." v0.3.0
|
| 31 |
applied the full section_header treatment uniformly across every
|
| 32 |
subcommand. v0.3.1 lightens the daily-use dashboards and keeps the
|
|
|
|
| 35 |
### Changed
|
| 36 |
|
| 37 |
- `sibyl status`, `sibyl whoami`, `sibyl devices`, `sibyl logout`,
|
| 38 |
+
`sibyl health`: dropped the section_header opener. Same convention
|
| 39 |
as `git status`, `ls -la`, `gh auth status`, `pg_isready`,
|
| 40 |
`redis-cli ping`: utilitarian dashboards present data, not chrome.
|
| 41 |
Eyebrow labels + kv rows + status lines remain.
|
| 42 |
|
| 43 |
### Unchanged
|
| 44 |
|
| 45 |
+
- `sibyl init`: keeps the full SIBYL gradient banner + section
|
| 46 |
headers + numbered next-steps. This IS the install moment; it earns
|
| 47 |
the ceremony.
|
| 48 |
+
- `sibyl upgrade`: keeps section header + KV. Mid-weight: tier-flip
|
| 49 |
moment is install-ish but not first-run.
|
| 50 |
+
- `_aesthetic.py` library: unchanged. Applied differently across
|
| 51 |
commands per the heavy/light convention.
|
| 52 |
|
| 53 |
+
## [0.3.0] - 2026-05-20
|
| 54 |
|
| 55 |
Visual identity pass across every subcommand. The `sibyl init` brand
|
| 56 |
moment (the SIBYL ASCII wordmark with pale-gold β deep-ochre vertical
|
|
|
|
| 60 |
|
| 61 |
### Added
|
| 62 |
|
| 63 |
+
- New `_aesthetic.py` module: shared visual library for the entire CLI.
|
| 64 |
Brand palette derived from the rule 46 creme paper face (PAPER, INK,
|
| 65 |
ACCENT, JADE, PULSE, RULE, etc.). 24-bit truecolor β 256-color β plain
|
| 66 |
text degradation cascade. Letter-spaced eyebrows, gradient titles,
|
|
|
|
| 100 |
consistency (COLORTERM=truecolor, TERM_PROGRAM whitelist, TERM
|
| 101 |
pattern match for kitty/alacritty/256color).
|
| 102 |
|
| 103 |
+
## [0.2.0] - 2026-05-19
|
| 104 |
|
| 105 |
+
Auth-redesign wave 2: account-surface CLI commands. Adds `sibyl whoami`
|
| 106 |
for a one-line account summary (masked by default, `--full` opt-in) and
|
| 107 |
`sibyl devices` for listing active bearer tokens with per-device revoke.
|
| 108 |
|
| 109 |
### Added
|
| 110 |
|
| 111 |
+
- `sibyl whoami`: one-line summary: short account_id, tier, masked email
|
| 112 |
(`a***@e***.tld`), masked wallet (`0xabcdβ¦1234`), this device label.
|
| 113 |
`--full` flag shows unmasked email + wallet for ops scenarios.
|
| 114 |
+
- `sibyl devices`: list active (non-revoked) bearer tokens for the
|
| 115 |
account in issued_at DESC order. Marks current device with `βΆ` and
|
| 116 |
shows revoke command for each other device.
|
| 117 |
+
- `sibyl devices revoke <index>`. POST `/api/plugin/devices` with the
|
| 118 |
bearer_id at that index. Refuses to revoke the calling device.
|
| 119 |
|
| 120 |
### Server companion (deployed)
|
| 121 |
|
| 122 |
+
- `GET /api/plugin/devices?account_id=<uuid>`: lists bearer_tokens.
|
| 123 |
+
- `POST /api/plugin/devices { bearer_id }`: revokes the bearer.
|
| 124 |
- Both auth via `Authorization: Bearer <session_token>`; caller must
|
| 125 |
own the account.
|
| 126 |
|
| 127 |
+
## [0.1.4] - 2026-05-18
|
| 128 |
|
| 129 |
Maximum-efficiency onboarding release. New `sibyl setup` command auto-detects
|
| 130 |
agent frameworks on the user's machine and wires SIBYL as the memory provider
|
|
|
|
| 134 |
|
| 135 |
### Added
|
| 136 |
|
| 137 |
+
- **`sibyl setup`**: new subcommand. Auto-detects Hermes (`$HERMES_HOME` or
|
| 138 |
`~/.hermes/` or `hermes` on PATH) and Claude Code (`~/.claude/settings.json`
|
| 139 |
or `claude` on PATH). Prompts per stack with explicit confirmation:
|
| 140 |
- Fresh add: `Set SIBYL as default memory provider in Hermes? [Y/n]` (default Y)
|
| 141 |
- Overwrite existing: `Hermes currently uses 'mem0' as memory provider. Overwrite with SIBYL? [y/N]` (default N, never destroys user state without explicit y)
|
| 142 |
- Already wired: noop with green status
|
| 143 |
- Multi-framework: `Wire which? [h]ermes, [c]laude, [a]ll, [n]one (default: all)`
|
| 144 |
+
- **`sibyl setup hermes`** / **`sibyl setup claude-code`**: explicit targeting
|
| 145 |
for power users (skips detection, wires only the named stack).
|
| 146 |
- **Flags**: `--yes` (accept all defaults, still respects destructive-default-N
|
| 147 |
unless `--force` is also passed), `--force` (overwrite existing non-SIBYL
|
|
|
|
| 179 |
|
| 180 |
|
| 181 |
|
| 182 |
+
## [0.1.3] - 2026-05-18
|
| 183 |
|
| 184 |
KAPPA external-tester remediation release. Family-wide alignment with the
|
| 185 |
v0.4.0 client + v0.3.2 hermes (KAPPA-attributed fixes: exception export
|
|
|
|
| 199 |
|
| 200 |
---
|
| 201 |
|
| 202 |
+
## [0.1.2] - 2026-05-18
|
| 203 |
|
| 204 |
Audit-remediation release. v0.3.0 plugin-family pre-ship audit (2026-05-18T05:05Z)
|
| 205 |
surfaced 10 critical findings; this release lands the CLI-side fixes.
|
|
|
|
| 208 |
|
| 209 |
### Fixed
|
| 210 |
|
| 211 |
+
- **C3**. `__version__` no longer hardcoded. Now sourced from
|
| 212 |
`importlib.metadata.version("sibyl-memory-cli")` with `+source` fallback.
|
| 213 |
Same pattern as sibyl-memory-hermes v0.3.0+. Wheel and `__init__.py`
|
| 214 |
can't drift.
|
| 215 |
+
- **C3**. HTTP User-Agent header now built from the runtime
|
| 216 |
`_client_version()` helper instead of the hardcoded `"sibyl-memory-cli/0.1.0"`.
|
| 217 |
Server telemetry will see real versions, not the stale literal.
|
| 218 |
+
- **C3**. `/api/plugin/session-init` payload's `client_version` field
|
| 219 |
similarly switched from `__import__("sibyl_memory_cli").__version__` to
|
| 220 |
the helper. Telemetry will accurately reflect 0.1.2+.
|
| 221 |
+
- **C4**: post-activation message rewritten. Removed the fictional
|
| 222 |
`from hermes_agent import Agent; agent = Agent(memory=SibylMemoryProvider())`
|
| 223 |
quickstart (the API never existed in any Hermes release). Replaced with:
|
| 224 |
the real Hermes install flow (`sibyl-memory-hermes install-plugin` +
|
|
|
|
| 227 |
|
| 228 |
### Security
|
| 229 |
|
| 230 |
+
- **SEC-2**. `write_credentials_atomic` now creates files at mode 0o600
|
| 231 |
set by the kernel at file-creation time via `os.open(O_WRONLY|O_CREAT|
|
| 232 |
O_EXCL|O_NOFOLLOW, 0o600)`. Previously used `write_text()` then
|
| 233 |
`os.chmod(0o600)`, leaving a world-readable window between syscalls every
|
| 234 |
credential save. No race.
|
| 235 |
+
- **SEC-1** (CLI-side mitigation): the URL parameter handed to the
|
| 236 |
browser is now treated as an opaque pairing-session identifier, not as
|
| 237 |
the long-lived bearer. After activation completes, the CLI prefers a
|
| 238 |
server-issued `bearer_token` field from `/check` (post-fix server flow);
|
|
|
|
| 241 |
release prepares the CLI to consume it when the server-side lands.
|
| 242 |
Internal variable renamed `session_token` β `session_id` in `cmd_init`
|
| 243 |
to reflect the corrected meaning.
|
| 244 |
+
- **SEC-11**. `read_credentials` refuses to follow symlinks.
|
| 245 |
|
| 246 |
### Dependencies
|
| 247 |
|
| 248 |
- `sibyl-memory-client>=0.3.3` (was `>=0.3.0`)
|
| 249 |
+
- `sibyl-memory-hermes>=0.3.1` (was `>=0.2.0`): picks up the fictional-API
|
| 250 |
removal in the hermes package; earlier versions are structurally broken.
|
| 251 |
|
| 252 |
+
## [0.1.1] - 2026-05-17
|
| 253 |
|
| 254 |
### Added
|
| 255 |
|
| 256 |
- **SIBYL wordmark banner** at the top of `sibyl init`. ANSI Shadow boxchars,
|
| 257 |
24-bit truecolor vertical gradient flowing cream/white at the top through
|
| 258 |
+
warm gold to deep ochre at the bottom: aligned with the lab visual identity
|
| 259 |
per the operator's brand-discipline rule (creme palette, `--accent #8a6a2a`).
|
| 260 |
Plus a tagline: "memory you can hold in your hand".
|
| 261 |
|
|
|
|
| 263 |
|
| 264 |
- New module `sibyl_memory_cli._banner` with `render_banner()` and
|
| 265 |
`print_banner()` helpers. Truecolor support is detected via `COLORTERM`,
|
| 266 |
+
`TERM_PROGRAM`, and `TERM`: modern terminals (iTerm2, Alacritty, Kitty,
|
| 267 |
wezterm, Ghostty, Windows Terminal, VS Code, Tabby) light up automatically.
|
| 268 |
- Gracefully degrades to plain text (still readable, no escape junk) when
|
| 269 |
`NO_COLOR` is set, when stdout is not a TTY, or when `TERM=dumb`.
|
| 270 |
+
- Wired into `cmd_init` only. `status` / `health` / `upgrade` stay banner-free
|
| 271 |
so they don't add noise to scripted invocations.
|
| 272 |
- Banner palette is encoded as 6 RGB tuples (one per row) in the module
|
| 273 |
+
rather than computed at runtime: easier to tune and audit.
|
| 274 |
|
| 275 |
+
## [0.1.0] - 2026-05-16
|
| 276 |
|
| 277 |
### Changed (same-day revision before publish): terminal pairing code
|
| 278 |
|
|
|
|
| 283 |
into the browser's email panel. Replaces the earlier Resend-backed
|
| 284 |
email magic-code flow, removing the external dependency entirely.
|
| 285 |
|
| 286 |
+
The wallet (SIWE) path is unchanged: the pairing code only matters
|
| 287 |
for the email panel.
|
| 288 |
|
| 289 |
|
|
|
|
| 293 |
|
| 294 |
### Added
|
| 295 |
|
| 296 |
+
- **`sibyl init`**: browser activation. Generates a session UUID,
|
| 297 |
opens `sibyllabs.org/plugin/activate?session=...` in the user's
|
| 298 |
browser, polls `api.sibyllabs.org/api/plugin/check` every 3s with a
|
| 299 |
10-min timeout. On bind, writes `~/.sibyl-memory/credentials.json`
|
| 300 |
atomically at mode 0600.
|
| 301 |
+
- **`sibyl upgrade`**: opens `sibyllabs.org/plugin/upgrade?session=...`
|
| 302 |
with the existing session token. Polls `/api/plugin/access` every 3s
|
| 303 |
with a 15-min timeout until `tier` changes from the local value.
|
| 304 |
On change: rewrites credentials.json, clears `tier_cache.json` so
|
| 305 |
the next write picks up the new entitlement immediately.
|
| 306 |
+
- **`sibyl status`**: shows local credentials, DB size, tier cache
|
| 307 |
state, plus the server's view of tier (subscription / staker /
|
| 308 |
free). Flags localβserver tier drift.
|
| 309 |
+
- **`sibyl health`**: wraps `SibylMemoryProvider.health()`. Prints
|
| 310 |
the JSON diagnostic dict.
|
| 311 |
|
| 312 |
### Design
|
sibyl-memory-cli/README.md
CHANGED
|
@@ -51,8 +51,8 @@ $ sibyl upgrade
|
|
| 51 |
```
|
| 52 |
|
| 53 |
In the browser:
|
| 54 |
-
- **Stake**
|
| 55 |
-
- **Subscribe**
|
| 56 |
|
| 57 |
On either path, the CLI sees the tier change, rewrites `credentials.json`, and clears `tier_cache.json` so your next write picks up the new entitlement without delay.
|
| 58 |
|
|
@@ -94,7 +94,7 @@ SIBYL_UPGRADE_BASE=https://staging.sibyllabs.org/plugin/upgrade sibyl upgrade
|
|
| 94 |
## Security
|
| 95 |
|
| 96 |
- `credentials.json` is written atomically at mode 0600.
|
| 97 |
-
- `session_token` is never printed in full
|
| 98 |
- No memory content ever transits these endpoints. The CLI never reads `memory.db` content; it only checks file size.
|
| 99 |
- Wallet operations happen in the browser. The CLI sees only the resulting tier change.
|
| 100 |
|
|
|
|
| 51 |
```
|
| 52 |
|
| 53 |
In the browser:
|
| 54 |
+
- **Stake**: connect your wallet (browser or Coinbase Smart Wallet), sign to bind, and the page checks your `$SIBYL` balance on Base. If you hold the threshold (default 100,000 $SIBYL liquid+staked, configurable), the local cap lifts.
|
| 55 |
+
- **Subscribe**: pick monthly ($29) / quarterly ($79) / annual ($290) USDC, sign the transfer, the server records the subscription. Tier flips immediately.
|
| 56 |
|
| 57 |
On either path, the CLI sees the tier change, rewrites `credentials.json`, and clears `tier_cache.json` so your next write picks up the new entitlement without delay.
|
| 58 |
|
|
|
|
| 94 |
## Security
|
| 95 |
|
| 96 |
- `credentials.json` is written atomically at mode 0600.
|
| 97 |
+
- `session_token` is never printed in full: only a short slice.
|
| 98 |
- No memory content ever transits these endpoints. The CLI never reads `memory.db` content; it only checks file size.
|
| 99 |
- Wallet operations happen in the browser. The CLI sees only the resulting tier change.
|
| 100 |
|
sibyl-memory-cli/src/sibyl_memory_cli/__init__.py
CHANGED
|
@@ -1,10 +1,10 @@
|
|
| 1 |
-
"""sibyl-memory-cli
|
| 2 |
|
| 3 |
Entry point: `sibyl` (installed via [project.scripts] in pyproject).
|
| 4 |
|
| 5 |
Commands:
|
| 6 |
-
sibyl init activate the plugin
|
| 7 |
-
sibyl upgrade open the upgrade flow
|
| 8 |
sibyl status show current tier, DB size, expiry, account
|
| 9 |
sibyl health provider self-check (mirrors SibylMemoryProvider.health())
|
| 10 |
|
|
|
|
| 1 |
+
"""sibyl-memory-cli. Command-line interface for the Sibyl Memory Plugin.
|
| 2 |
|
| 3 |
Entry point: `sibyl` (installed via [project.scripts] in pyproject).
|
| 4 |
|
| 5 |
Commands:
|
| 6 |
+
sibyl init activate the plugin: opens browser SIWE flow, writes ~/.sibyl-memory/credentials.json
|
| 7 |
+
sibyl upgrade open the upgrade flow: stake $SIBYL or subscribe in USDC
|
| 8 |
sibyl status show current tier, DB size, expiry, account
|
| 9 |
sibyl health provider self-check (mirrors SibylMemoryProvider.health())
|
| 10 |
|
sibyl-memory-cli/src/sibyl_memory_cli/_aesthetic.py
CHANGED
|
@@ -22,22 +22,22 @@ from typing import Iterable
|
|
| 22 |
# βββ Palette (RGB Β· derived from rule 46 creme-paper tokens) βββββββββ
|
| 23 |
# Names map 1:1 to CSS custom properties on lab artifacts.
|
| 24 |
|
| 25 |
-
PAPER = (245, 241, 230) # --paper
|
| 26 |
-
PAPER_DEEP = (237, 230, 211) # --paper-deep
|
| 27 |
-
CARD = (253, 251, 245) # --card
|
| 28 |
-
INK = (21, 17, 10) # --ink
|
| 29 |
-
INK_SOFT = (44, 39, 29) # --ink-soft
|
| 30 |
-
INK_MUTE = (106, 99, 86) # --ink-mute
|
| 31 |
-
INK_FAINT = (152, 145, 127) # --ink-faint
|
| 32 |
-
RULE = (216, 208, 187) # --rule
|
| 33 |
-
RULE_STRONG = (184, 174, 147) # --rule-strong
|
| 34 |
-
ACCENT = (138, 106, 42) # --accent
|
| 35 |
-
ACCENT_WARM = (160, 132, 56) # --accent-warm
|
| 36 |
-
ACCENT_GOLD = (224, 194, 119) # mid gold
|
| 37 |
-
ACCENT_PALE = (244, 229, 184) # pale gold
|
| 38 |
-
JADE = (45, 110, 106) # --jade
|
| 39 |
-
PULSE = (29, 138, 130) # --pulse
|
| 40 |
-
ERROR = (162, 58, 42) # --error
|
| 41 |
|
| 42 |
# Status glyphs (Unicode, terminal-safe in modern fonts)
|
| 43 |
GLYPH_OK = "β"
|
|
@@ -56,7 +56,7 @@ def supports_truecolor() -> bool:
|
|
| 56 |
return False
|
| 57 |
if os.environ.get("TERM", "").lower() == "dumb":
|
| 58 |
return False
|
| 59 |
-
# SIBYL_FORCE_COLOR=1
|
| 60 |
# (CI logs, doc captures, dev inspection in non-tty environments).
|
| 61 |
if os.environ.get("SIBYL_FORCE_COLOR") == "1":
|
| 62 |
return True
|
|
@@ -269,7 +269,7 @@ def err_line(text: str) -> str:
|
|
| 269 |
|
| 270 |
|
| 271 |
def hr_caption(caption: str, *, width: int = 60) -> str:
|
| 272 |
-
"""Caption line under a divider
|
| 273 |
pad = max(0, (width - len(caption)) // 2)
|
| 274 |
return " " * pad + dim(caption)
|
| 275 |
|
|
|
|
| 22 |
# βββ Palette (RGB Β· derived from rule 46 creme-paper tokens) βββββββββ
|
| 23 |
# Names map 1:1 to CSS custom properties on lab artifacts.
|
| 24 |
|
| 25 |
+
PAPER = (245, 241, 230) # --paper : foreground accent on dark
|
| 26 |
+
PAPER_DEEP = (237, 230, 211) # --paper-deep : depth on creme
|
| 27 |
+
CARD = (253, 251, 245) # --card : slightly lifted creme
|
| 28 |
+
INK = (21, 17, 10) # --ink : main text on creme
|
| 29 |
+
INK_SOFT = (44, 39, 29) # --ink-soft : body text
|
| 30 |
+
INK_MUTE = (106, 99, 86) # --ink-mute : secondary text
|
| 31 |
+
INK_FAINT = (152, 145, 127) # --ink-faint : tertiary text
|
| 32 |
+
RULE = (216, 208, 187) # --rule : hairline
|
| 33 |
+
RULE_STRONG = (184, 174, 147) # --rule-strong : emphasised hairline
|
| 34 |
+
ACCENT = (138, 106, 42) # --accent : ochre highlight
|
| 35 |
+
ACCENT_WARM = (160, 132, 56) # --accent-warm : softer ochre
|
| 36 |
+
ACCENT_GOLD = (224, 194, 119) # mid gold : gradient bridge
|
| 37 |
+
ACCENT_PALE = (244, 229, 184) # pale gold : gradient top
|
| 38 |
+
JADE = (45, 110, 106) # --jade : cool counterpoint
|
| 39 |
+
PULSE = (29, 138, 130) # --pulse : brighter jade (live signal)
|
| 40 |
+
ERROR = (162, 58, 42) # --error : measured red
|
| 41 |
|
| 42 |
# Status glyphs (Unicode, terminal-safe in modern fonts)
|
| 43 |
GLYPH_OK = "β"
|
|
|
|
| 56 |
return False
|
| 57 |
if os.environ.get("TERM", "").lower() == "dumb":
|
| 58 |
return False
|
| 59 |
+
# SIBYL_FORCE_COLOR=1: explicit override for non-tty rendering
|
| 60 |
# (CI logs, doc captures, dev inspection in non-tty environments).
|
| 61 |
if os.environ.get("SIBYL_FORCE_COLOR") == "1":
|
| 62 |
return True
|
|
|
|
| 269 |
|
| 270 |
|
| 271 |
def hr_caption(caption: str, *, width: int = 60) -> str:
|
| 272 |
+
"""Caption line under a divider: small, muted, centered."""
|
| 273 |
pad = max(0, (width - len(caption)) // 2)
|
| 274 |
return " " * pad + dim(caption)
|
| 275 |
|
sibyl-memory-cli/src/sibyl_memory_cli/_banner.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 2 |
|
| 3 |
Prints the SIBYL wordmark in ANSI Shadow boxchars with a 24-bit truecolor
|
| 4 |
vertical gradient flowing from cream/white at the top through warm gold
|
| 5 |
-
to deep ochre at the bottom
|
| 6 |
the operator's brand-discipline rule (creme palette, deep-ochre accent).
|
| 7 |
|
| 8 |
Gracefully degrades:
|
|
@@ -10,7 +10,7 @@ Gracefully degrades:
|
|
| 10 |
- stdout is not a TTY β plain text fallback (or skip entirely)
|
| 11 |
- TERM=dumb β plain text fallback
|
| 12 |
|
| 13 |
-
Truecolor support is detected via $COLORTERM (truecolor / 24bit)
|
| 14 |
modern terminals (iTerm2, Alacritty, Kitty, wezterm, Windows Terminal,
|
| 15 |
modern xterm builds, Ghostty) advertise it. Falls back to 256-color
|
| 16 |
gradient when not available.
|
|
@@ -20,7 +20,7 @@ from __future__ import annotations
|
|
| 20 |
import os
|
| 21 |
import sys
|
| 22 |
|
| 23 |
-
# ANSI Shadow rendering of "SIBYL"
|
| 24 |
# own gradient color (top = pale cream/white, bottom = deep ochre).
|
| 25 |
_LINES = (
|
| 26 |
"ββββββββββββββββββ βββ ββββββ ",
|
|
@@ -49,7 +49,7 @@ _ATTRIBUTION = "a Sibyl Labs LLC Product. Agentic Infrastructure and Memory Prod
|
|
| 49 |
|
| 50 |
|
| 51 |
def _supports_truecolor() -> bool:
|
| 52 |
-
"""Detect 24-bit color support. Conservative
|
| 53 |
if os.environ.get("NO_COLOR"):
|
| 54 |
return False
|
| 55 |
if os.environ.get("TERM", "").lower() == "dumb":
|
|
@@ -96,22 +96,22 @@ def render_banner(*, force_color: bool | None = None) -> str:
|
|
| 96 |
use_truecolor = force_color if force_color is not None else _supports_truecolor()
|
| 97 |
|
| 98 |
if not use_truecolor:
|
| 99 |
-
# Plain text
|
| 100 |
body = "\n".join(" " + line for line in _LINES)
|
| 101 |
tagline = f"\n {_TAGLINE}"
|
| 102 |
attribution = f"\n {_ATTRIBUTION}\n"
|
| 103 |
return body + tagline + attribution
|
| 104 |
|
| 105 |
-
# Colored
|
| 106 |
colored_lines = []
|
| 107 |
for line, (r, g, b) in zip(_LINES, _GRADIENT):
|
| 108 |
colored_lines.append(f" {_rgb(r, g, b)}{line}{_RESET}")
|
| 109 |
|
| 110 |
body = "\n".join(colored_lines)
|
| 111 |
-
# Tagline in the deepest gold
|
| 112 |
r, g, b = _GRADIENT[-1]
|
| 113 |
tagline = f"\n {_rgb(r, g, b)}{_TAGLINE}{_RESET}"
|
| 114 |
-
# Attribution dimmer still
|
| 115 |
# reads SIBYL > tagline > attribution at a glance. ANSI dim (\033[2m) gives
|
| 116 |
# ~55% perceived opacity across the supported terminals.
|
| 117 |
attribution = f"\n \033[2m{_rgb(r, g, b)}{_ATTRIBUTION}{_RESET}\n"
|
|
|
|
| 2 |
|
| 3 |
Prints the SIBYL wordmark in ANSI Shadow boxchars with a 24-bit truecolor
|
| 4 |
vertical gradient flowing from cream/white at the top through warm gold
|
| 5 |
+
to deep ochre at the bottom: aligned with the lab visual identity per
|
| 6 |
the operator's brand-discipline rule (creme palette, deep-ochre accent).
|
| 7 |
|
| 8 |
Gracefully degrades:
|
|
|
|
| 10 |
- stdout is not a TTY β plain text fallback (or skip entirely)
|
| 11 |
- TERM=dumb β plain text fallback
|
| 12 |
|
| 13 |
+
Truecolor support is detected via $COLORTERM (truecolor / 24bit): most
|
| 14 |
modern terminals (iTerm2, Alacritty, Kitty, wezterm, Windows Terminal,
|
| 15 |
modern xterm builds, Ghostty) advertise it. Falls back to 256-color
|
| 16 |
gradient when not available.
|
|
|
|
| 20 |
import os
|
| 21 |
import sys
|
| 22 |
|
| 23 |
+
# ANSI Shadow rendering of "SIBYL": 6 rows, 41 cols. Each row gets its
|
| 24 |
# own gradient color (top = pale cream/white, bottom = deep ochre).
|
| 25 |
_LINES = (
|
| 26 |
"ββββββββββββββββββ βββ ββββββ ",
|
|
|
|
| 49 |
|
| 50 |
|
| 51 |
def _supports_truecolor() -> bool:
|
| 52 |
+
"""Detect 24-bit color support. Conservative: fall back gracefully."""
|
| 53 |
if os.environ.get("NO_COLOR"):
|
| 54 |
return False
|
| 55 |
if os.environ.get("TERM", "").lower() == "dumb":
|
|
|
|
| 96 |
use_truecolor = force_color if force_color is not None else _supports_truecolor()
|
| 97 |
|
| 98 |
if not use_truecolor:
|
| 99 |
+
# Plain text: still visually clean, just no color.
|
| 100 |
body = "\n".join(" " + line for line in _LINES)
|
| 101 |
tagline = f"\n {_TAGLINE}"
|
| 102 |
attribution = f"\n {_ATTRIBUTION}\n"
|
| 103 |
return body + tagline + attribution
|
| 104 |
|
| 105 |
+
# Colored: apply per-row gradient.
|
| 106 |
colored_lines = []
|
| 107 |
for line, (r, g, b) in zip(_LINES, _GRADIENT):
|
| 108 |
colored_lines.append(f" {_rgb(r, g, b)}{line}{_RESET}")
|
| 109 |
|
| 110 |
body = "\n".join(colored_lines)
|
| 111 |
+
# Tagline in the deepest gold: present, but not competing with the wordmark.
|
| 112 |
r, g, b = _GRADIENT[-1]
|
| 113 |
tagline = f"\n {_rgb(r, g, b)}{_TAGLINE}{_RESET}"
|
| 114 |
+
# Attribution dimmer still: a half-step below the tagline so the hierarchy
|
| 115 |
# reads SIBYL > tagline > attribution at a glance. ANSI dim (\033[2m) gives
|
| 116 |
# ~55% perceived opacity across the supported terminals.
|
| 117 |
attribution = f"\n \033[2m{_rgb(r, g, b)}{_ATTRIBUTION}{_RESET}\n"
|
sibyl-memory-cli/src/sibyl_memory_cli/cli.py
CHANGED
|
@@ -8,9 +8,9 @@ Design pillars:
|
|
| 8 |
- Credentials are written atomically at mode 0600, set at file-creation
|
| 9 |
time via O_CREAT|O_EXCL|O_NOFOLLOW (no chmod-after-write race).
|
| 10 |
- The URL parameter handed to the browser is an opaque session identifier,
|
| 11 |
-
not the long-lived bearer (audit SEC-1
|
| 12 |
issues a separate bearer at activation completion if available).
|
| 13 |
-
- session_token is never printed in full
|
| 14 |
- Polling has explicit timeouts; no infinite loops.
|
| 15 |
- Every command exits with a clear status code (0 ok, 1 user error, 2 server error).
|
| 16 |
"""
|
|
@@ -55,7 +55,7 @@ DEFAULT_TIER_CACHE_PATH = Path("~/.sibyl-memory/tier_cache.json").expanduser()
|
|
| 55 |
|
| 56 |
POLL_INTERVAL_SEC = 3
|
| 57 |
INIT_TIMEOUT_SEC = 10 * 60 # 10 minutes for /init activation
|
| 58 |
-
UPGRADE_TIMEOUT_SEC = 15 * 60 # 15 minutes for upgrade
|
| 59 |
|
| 60 |
# ---- Color / output ----------------------------------------------------
|
| 61 |
|
|
@@ -88,7 +88,7 @@ def _detect_os_family() -> str | None:
|
|
| 88 |
|
| 89 |
def short(token: str | None) -> str:
|
| 90 |
if not token:
|
| 91 |
-
return "
|
| 92 |
if len(token) <= 12:
|
| 93 |
return token
|
| 94 |
return f"{token[:8]}β¦{token[-4:]}"
|
|
@@ -209,7 +209,7 @@ def cmd_init(args: argparse.Namespace) -> int:
|
|
| 209 |
The pairing code is printed in the terminal. If the user picks the
|
| 210 |
email path in the browser, they type both their email and this code.
|
| 211 |
No external email service is required."""
|
| 212 |
-
# Brand moment
|
| 213 |
# Honors NO_COLOR + TTY detection automatically; safe to always call.
|
| 214 |
from ._banner import print_banner
|
| 215 |
print_banner()
|
|
@@ -231,7 +231,7 @@ def cmd_init(args: argparse.Namespace) -> int:
|
|
| 231 |
# it as the activation rendezvous key only. The persistent bearer
|
| 232 |
# is issued by the server in the /check response (`bearer_token`
|
| 233 |
# field) after activation completes. Servers running pre-SEC-1
|
| 234 |
-
# firmware that echo the URL identifier as the bearer still work
|
| 235 |
# we use whichever the server returns in the bound credentials.
|
| 236 |
session_id = str(uuid.uuid4())
|
| 237 |
pairing_code = _gen_pairing_code()
|
|
@@ -290,7 +290,7 @@ def cmd_init(args: argparse.Namespace) -> int:
|
|
| 290 |
resp = http_request("GET", f"/api/plugin/check?session={urllib.parse.quote(session_id)}", timeout=10.0)
|
| 291 |
except HttpError as e:
|
| 292 |
if e.status in (404, 503, 0):
|
| 293 |
-
# Session not yet created server-side, or transient
|
| 294 |
pass
|
| 295 |
else:
|
| 296 |
print(red(f"\nUnexpected error: {e.body}"))
|
|
@@ -301,19 +301,19 @@ def cmd_init(args: argparse.Namespace) -> int:
|
|
| 301 |
creds = resp["credentials"]
|
| 302 |
# SEC-1: prefer the server-issued bearer_token (post-fix) over
|
| 303 |
# echoing the URL pairing-session id. Servers running pre-SEC-1
|
| 304 |
-
# firmware echo `session_token` back as the bearer
|
| 305 |
# whichever the server returns. The CLI's session_id (URL
|
| 306 |
# identifier) is the rendezvous key, not the persistent bearer.
|
| 307 |
bearer = creds.get("bearer_token") or creds.get("session_token")
|
| 308 |
if not bearer:
|
| 309 |
# Fallback: pre-SEC-1 server flow where neither field is
|
| 310 |
-
# echoed back
|
| 311 |
# /access and /check-write calls have something to send.
|
| 312 |
bearer = session_id
|
| 313 |
# Sanity check on echoed session_token (pre-SEC-1 flow only)
|
| 314 |
if creds.get("session_token") and creds["session_token"] != session_id \
|
| 315 |
and not creds.get("bearer_token"):
|
| 316 |
-
print(red("\nSession token mismatch
|
| 317 |
return 2
|
| 318 |
creds["session_token"] = bearer
|
| 319 |
path = write_credentials_atomic(creds, cred_path)
|
|
@@ -323,8 +323,8 @@ def cmd_init(args: argparse.Namespace) -> int:
|
|
| 323 |
print()
|
| 324 |
print(a.kv("Account", short(creds.get("account_id"))))
|
| 325 |
print(a.kv("Tier", (creds.get("tier") or "free").upper(), value_color="accent"))
|
| 326 |
-
print(a.kv("Wallet", creds.get("wallet") or "
|
| 327 |
-
print(a.kv("Email", creds.get("email") or "
|
| 328 |
print(a.kv("Credentials", str(path)))
|
| 329 |
print()
|
| 330 |
print(a.section_header("wire it into your agent"))
|
|
@@ -417,7 +417,7 @@ def cmd_upgrade(args: argparse.Namespace) -> int:
|
|
| 417 |
if e.status == 401:
|
| 418 |
print(red("\nSession expired. Re-run `sibyl init`."))
|
| 419 |
return 1
|
| 420 |
-
# Transient
|
| 421 |
resp = {}
|
| 422 |
|
| 423 |
new_tier = (resp.get("tier") or current_tier).lower()
|
|
@@ -444,8 +444,8 @@ def cmd_upgrade(args: argparse.Namespace) -> int:
|
|
| 444 |
print(a.kv("Storage cap", f"{resp['cap_bytes']:,} bytes"))
|
| 445 |
if resp.get("staker"):
|
| 446 |
s = resp["staker"]
|
| 447 |
-
print(a.kv("Wallet", s.get("wallet", "
|
| 448 |
-
print(a.kv("$SIBYL held", str(s.get("total_sibyl", "
|
| 449 |
print()
|
| 450 |
print(a.dim(" local tier cache cleared. your next write will sync the new tier."))
|
| 451 |
return 0
|
|
@@ -490,9 +490,9 @@ def cmd_status(args: argparse.Namespace) -> int:
|
|
| 490 |
print(a.kv("Credentials", str(cred_path)))
|
| 491 |
print(a.kv("Account", short(creds.get("account_id"))))
|
| 492 |
print(a.kv("Tier", (creds.get("tier") or "free").upper(), value_color="accent"))
|
| 493 |
-
print(a.kv("Wallet", creds.get("wallet") or "
|
| 494 |
-
print(a.kv("Email", creds.get("email") or "
|
| 495 |
-
print(a.kv("Issued", creds.get("issued_at") or "
|
| 496 |
|
| 497 |
db_path = Path(args.db).expanduser()
|
| 498 |
if db_path.exists():
|
|
@@ -510,7 +510,7 @@ def cmd_status(args: argparse.Namespace) -> int:
|
|
| 510 |
cache = json.loads(tier_cache.read_text(encoding="utf-8"))
|
| 511 |
print(a.kv("Tier cache", f"{cache.get('tier','?')} (checked {cache.get('checked_at','?')[:19]})"))
|
| 512 |
else:
|
| 513 |
-
print(a.kv("Tier cache", "
|
| 514 |
|
| 515 |
# Server view (only if account_id + session_token are present)
|
| 516 |
if creds.get("account_id") and creds.get("session_token"):
|
|
@@ -524,14 +524,14 @@ def cmd_status(args: argparse.Namespace) -> int:
|
|
| 524 |
timeout=10.0,
|
| 525 |
)
|
| 526 |
print(a.kv("Tier", (resp.get("tier") or "free").upper(), value_color="accent"))
|
| 527 |
-
print(a.kv("Source", resp.get("source") or "
|
| 528 |
print(a.kv("Cap bytes", "unlimited" if resp.get("cap_bytes") is None else f"{resp['cap_bytes']:,}"))
|
| 529 |
if resp.get("expires_at"):
|
| 530 |
print(a.kv("Expires", resp["expires_at"]))
|
| 531 |
if resp.get("staker"):
|
| 532 |
s = resp["staker"]
|
| 533 |
-
print(a.kv("$SIBYL held", str(s.get("total_sibyl", "
|
| 534 |
-
print(a.kv("Threshold", str(s.get("threshold_sibyl", "
|
| 535 |
print(a.kv("Qualified", "yes" if s.get("qualified") else "no",
|
| 536 |
value_color="ok" if s.get("qualified") else "soft"))
|
| 537 |
# Detect server/local drift
|
|
@@ -558,7 +558,7 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
| 558 |
who muscle-memory it get a real result.
|
| 559 |
|
| 560 |
When account.sibyllabs.org ships, this will flip to
|
| 561 |
-
`webbrowser.open(...)` with no UX disruption
|
| 562 |
web dashboard."""
|
| 563 |
DASHBOARD_BASE = os.environ.get("SIBYL_DASHBOARD_BASE")
|
| 564 |
if DASHBOARD_BASE:
|
|
@@ -583,7 +583,7 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
| 583 |
|
| 584 |
def _mask_email(e: str | None) -> str:
|
| 585 |
if not e or "@" not in e:
|
| 586 |
-
return "
|
| 587 |
user, _, domain = e.partition("@")
|
| 588 |
if "." not in domain:
|
| 589 |
return f"{user[0]}***@{domain[0]}***"
|
|
@@ -593,7 +593,7 @@ def _mask_email(e: str | None) -> str:
|
|
| 593 |
|
| 594 |
def _mask_wallet(w: str | None) -> str:
|
| 595 |
if not w or not w.startswith("0x") or len(w) < 12:
|
| 596 |
-
return w or "
|
| 597 |
return f"{w[:6]}β¦{w[-4:]}"
|
| 598 |
|
| 599 |
|
|
@@ -616,8 +616,8 @@ def cmd_whoami(args: argparse.Namespace) -> int:
|
|
| 616 |
|
| 617 |
print()
|
| 618 |
print(f" {a.color('account', a.INK_FAINT)} {a.bold(short(acct))} {a.dim(a.GLYPH_DOT)} {a.gradient_gold(tier)}")
|
| 619 |
-
print(f" {a.color('wallet ', a.INK_FAINT)} {a.color(wallet or '
|
| 620 |
-
print(f" {a.color('email ', a.INK_FAINT)} {a.color(email or '
|
| 621 |
os_label = _detect_os_family() or "unknown"
|
| 622 |
device_line = f"sibyl-memory-cli/{_client_version()} {os_label}"
|
| 623 |
print(f" {a.color('device ', a.INK_FAINT)} {a.dim(device_line)}")
|
|
@@ -667,7 +667,7 @@ def cmd_devices(args: argparse.Namespace) -> int:
|
|
| 667 |
print(red(f"no device at index {idx}. Run `sibyl devices` to see indexes."))
|
| 668 |
return 1
|
| 669 |
if target.get("is_this_device"):
|
| 670 |
-
print(red("refusing to revoke your own device
|
| 671 |
return 1
|
| 672 |
try:
|
| 673 |
revoke_resp = http_request(
|
|
@@ -713,7 +713,7 @@ def cmd_devices(args: argparse.Namespace) -> int:
|
|
| 713 |
is_this = d.get("is_this_device")
|
| 714 |
marker = a.ok("βΆ") if is_this else " "
|
| 715 |
label = d.get("device_label") or "(unlabeled)"
|
| 716 |
-
installed = d.get("install_method") or "
|
| 717 |
last_seen = d.get("last_seen_at", "")[:19].replace("T", " ")
|
| 718 |
idx_chip = a.chip(str(i), palette="jade" if is_this else "mute")
|
| 719 |
label_color = a.gradient_gold(label) if is_this else a.color(label, a.INK)
|
|
@@ -727,7 +727,7 @@ def cmd_devices(args: argparse.Namespace) -> int:
|
|
| 727 |
# ---- `sibyl logout` ----------------------------------------------------
|
| 728 |
|
| 729 |
def cmd_logout(args: argparse.Namespace) -> int:
|
| 730 |
-
"""Delete credentials.json + tier_cache.json. memory.db stays
|
| 731 |
cred_path = Path(args.credentials).expanduser()
|
| 732 |
tier_cache = Path(args.tier_cache).expanduser()
|
| 733 |
|
|
@@ -757,7 +757,7 @@ def cmd_logout(args: argparse.Namespace) -> int:
|
|
| 757 |
# ---- `sibyl health` ----------------------------------------------------
|
| 758 |
|
| 759 |
def cmd_health(args: argparse.Namespace) -> int:
|
| 760 |
-
"""SibylMemoryProvider.health()
|
| 761 |
try:
|
| 762 |
from sibyl_memory_hermes import SibylMemoryProvider
|
| 763 |
except ImportError:
|
|
|
|
| 8 |
- Credentials are written atomically at mode 0600, set at file-creation
|
| 9 |
time via O_CREAT|O_EXCL|O_NOFOLLOW (no chmod-after-write race).
|
| 10 |
- The URL parameter handed to the browser is an opaque session identifier,
|
| 11 |
+
not the long-lived bearer (audit SEC-1: server-side pairing handoff
|
| 12 |
issues a separate bearer at activation completion if available).
|
| 13 |
+
- session_token is never printed in full: display short slice only.
|
| 14 |
- Polling has explicit timeouts; no infinite loops.
|
| 15 |
- Every command exits with a clear status code (0 ok, 1 user error, 2 server error).
|
| 16 |
"""
|
|
|
|
| 55 |
|
| 56 |
POLL_INTERVAL_SEC = 3
|
| 57 |
INIT_TIMEOUT_SEC = 10 * 60 # 10 minutes for /init activation
|
| 58 |
+
UPGRADE_TIMEOUT_SEC = 15 * 60 # 15 minutes for upgrade: wallet ux can be slow
|
| 59 |
|
| 60 |
# ---- Color / output ----------------------------------------------------
|
| 61 |
|
|
|
|
| 88 |
|
| 89 |
def short(token: str | None) -> str:
|
| 90 |
if not token:
|
| 91 |
+
return "-"
|
| 92 |
if len(token) <= 12:
|
| 93 |
return token
|
| 94 |
return f"{token[:8]}β¦{token[-4:]}"
|
|
|
|
| 209 |
The pairing code is printed in the terminal. If the user picks the
|
| 210 |
email path in the browser, they type both their email and this code.
|
| 211 |
No external email service is required."""
|
| 212 |
+
# Brand moment: gold/white gradient SIBYL wordmark.
|
| 213 |
# Honors NO_COLOR + TTY detection automatically; safe to always call.
|
| 214 |
from ._banner import print_banner
|
| 215 |
print_banner()
|
|
|
|
| 231 |
# it as the activation rendezvous key only. The persistent bearer
|
| 232 |
# is issued by the server in the /check response (`bearer_token`
|
| 233 |
# field) after activation completes. Servers running pre-SEC-1
|
| 234 |
+
# firmware that echo the URL identifier as the bearer still work -
|
| 235 |
# we use whichever the server returns in the bound credentials.
|
| 236 |
session_id = str(uuid.uuid4())
|
| 237 |
pairing_code = _gen_pairing_code()
|
|
|
|
| 290 |
resp = http_request("GET", f"/api/plugin/check?session={urllib.parse.quote(session_id)}", timeout=10.0)
|
| 291 |
except HttpError as e:
|
| 292 |
if e.status in (404, 503, 0):
|
| 293 |
+
# Session not yet created server-side, or transient: keep polling
|
| 294 |
pass
|
| 295 |
else:
|
| 296 |
print(red(f"\nUnexpected error: {e.body}"))
|
|
|
|
| 301 |
creds = resp["credentials"]
|
| 302 |
# SEC-1: prefer the server-issued bearer_token (post-fix) over
|
| 303 |
# echoing the URL pairing-session id. Servers running pre-SEC-1
|
| 304 |
+
# firmware echo `session_token` back as the bearer: we use
|
| 305 |
# whichever the server returns. The CLI's session_id (URL
|
| 306 |
# identifier) is the rendezvous key, not the persistent bearer.
|
| 307 |
bearer = creds.get("bearer_token") or creds.get("session_token")
|
| 308 |
if not bearer:
|
| 309 |
# Fallback: pre-SEC-1 server flow where neither field is
|
| 310 |
+
# echoed back: inject the pairing session id so subsequent
|
| 311 |
# /access and /check-write calls have something to send.
|
| 312 |
bearer = session_id
|
| 313 |
# Sanity check on echoed session_token (pre-SEC-1 flow only)
|
| 314 |
if creds.get("session_token") and creds["session_token"] != session_id \
|
| 315 |
and not creds.get("bearer_token"):
|
| 316 |
+
print(red("\nSession token mismatch: refusing to write credentials."))
|
| 317 |
return 2
|
| 318 |
creds["session_token"] = bearer
|
| 319 |
path = write_credentials_atomic(creds, cred_path)
|
|
|
|
| 323 |
print()
|
| 324 |
print(a.kv("Account", short(creds.get("account_id"))))
|
| 325 |
print(a.kv("Tier", (creds.get("tier") or "free").upper(), value_color="accent"))
|
| 326 |
+
print(a.kv("Wallet", creds.get("wallet") or "-"))
|
| 327 |
+
print(a.kv("Email", creds.get("email") or "-"))
|
| 328 |
print(a.kv("Credentials", str(path)))
|
| 329 |
print()
|
| 330 |
print(a.section_header("wire it into your agent"))
|
|
|
|
| 417 |
if e.status == 401:
|
| 418 |
print(red("\nSession expired. Re-run `sibyl init`."))
|
| 419 |
return 1
|
| 420 |
+
# Transient: keep polling
|
| 421 |
resp = {}
|
| 422 |
|
| 423 |
new_tier = (resp.get("tier") or current_tier).lower()
|
|
|
|
| 444 |
print(a.kv("Storage cap", f"{resp['cap_bytes']:,} bytes"))
|
| 445 |
if resp.get("staker"):
|
| 446 |
s = resp["staker"]
|
| 447 |
+
print(a.kv("Wallet", s.get("wallet", "-")))
|
| 448 |
+
print(a.kv("$SIBYL held", str(s.get("total_sibyl", "-"))))
|
| 449 |
print()
|
| 450 |
print(a.dim(" local tier cache cleared. your next write will sync the new tier."))
|
| 451 |
return 0
|
|
|
|
| 490 |
print(a.kv("Credentials", str(cred_path)))
|
| 491 |
print(a.kv("Account", short(creds.get("account_id"))))
|
| 492 |
print(a.kv("Tier", (creds.get("tier") or "free").upper(), value_color="accent"))
|
| 493 |
+
print(a.kv("Wallet", creds.get("wallet") or "-"))
|
| 494 |
+
print(a.kv("Email", creds.get("email") or "-"))
|
| 495 |
+
print(a.kv("Issued", creds.get("issued_at") or "-"))
|
| 496 |
|
| 497 |
db_path = Path(args.db).expanduser()
|
| 498 |
if db_path.exists():
|
|
|
|
| 510 |
cache = json.loads(tier_cache.read_text(encoding="utf-8"))
|
| 511 |
print(a.kv("Tier cache", f"{cache.get('tier','?')} (checked {cache.get('checked_at','?')[:19]})"))
|
| 512 |
else:
|
| 513 |
+
print(a.kv("Tier cache", "-"))
|
| 514 |
|
| 515 |
# Server view (only if account_id + session_token are present)
|
| 516 |
if creds.get("account_id") and creds.get("session_token"):
|
|
|
|
| 524 |
timeout=10.0,
|
| 525 |
)
|
| 526 |
print(a.kv("Tier", (resp.get("tier") or "free").upper(), value_color="accent"))
|
| 527 |
+
print(a.kv("Source", resp.get("source") or "-"))
|
| 528 |
print(a.kv("Cap bytes", "unlimited" if resp.get("cap_bytes") is None else f"{resp['cap_bytes']:,}"))
|
| 529 |
if resp.get("expires_at"):
|
| 530 |
print(a.kv("Expires", resp["expires_at"]))
|
| 531 |
if resp.get("staker"):
|
| 532 |
s = resp["staker"]
|
| 533 |
+
print(a.kv("$SIBYL held", str(s.get("total_sibyl", "-"))))
|
| 534 |
+
print(a.kv("Threshold", str(s.get("threshold_sibyl", "-"))))
|
| 535 |
print(a.kv("Qualified", "yes" if s.get("qualified") else "no",
|
| 536 |
value_color="ok" if s.get("qualified") else "soft"))
|
| 537 |
# Detect server/local drift
|
|
|
|
| 558 |
who muscle-memory it get a real result.
|
| 559 |
|
| 560 |
When account.sibyllabs.org ships, this will flip to
|
| 561 |
+
`webbrowser.open(...)` with no UX disruption: same command, real
|
| 562 |
web dashboard."""
|
| 563 |
DASHBOARD_BASE = os.environ.get("SIBYL_DASHBOARD_BASE")
|
| 564 |
if DASHBOARD_BASE:
|
|
|
|
| 583 |
|
| 584 |
def _mask_email(e: str | None) -> str:
|
| 585 |
if not e or "@" not in e:
|
| 586 |
+
return "-"
|
| 587 |
user, _, domain = e.partition("@")
|
| 588 |
if "." not in domain:
|
| 589 |
return f"{user[0]}***@{domain[0]}***"
|
|
|
|
| 593 |
|
| 594 |
def _mask_wallet(w: str | None) -> str:
|
| 595 |
if not w or not w.startswith("0x") or len(w) < 12:
|
| 596 |
+
return w or "-"
|
| 597 |
return f"{w[:6]}β¦{w[-4:]}"
|
| 598 |
|
| 599 |
|
|
|
|
| 616 |
|
| 617 |
print()
|
| 618 |
print(f" {a.color('account', a.INK_FAINT)} {a.bold(short(acct))} {a.dim(a.GLYPH_DOT)} {a.gradient_gold(tier)}")
|
| 619 |
+
print(f" {a.color('wallet ', a.INK_FAINT)} {a.color(wallet or '-', a.INK)}")
|
| 620 |
+
print(f" {a.color('email ', a.INK_FAINT)} {a.color(email or '-', a.INK)}")
|
| 621 |
os_label = _detect_os_family() or "unknown"
|
| 622 |
device_line = f"sibyl-memory-cli/{_client_version()} {os_label}"
|
| 623 |
print(f" {a.color('device ', a.INK_FAINT)} {a.dim(device_line)}")
|
|
|
|
| 667 |
print(red(f"no device at index {idx}. Run `sibyl devices` to see indexes."))
|
| 668 |
return 1
|
| 669 |
if target.get("is_this_device"):
|
| 670 |
+
print(red("refusing to revoke your own device: that would lock you out. Run `sibyl logout` instead, then `sibyl init` on a fresh activation."))
|
| 671 |
return 1
|
| 672 |
try:
|
| 673 |
revoke_resp = http_request(
|
|
|
|
| 713 |
is_this = d.get("is_this_device")
|
| 714 |
marker = a.ok("βΆ") if is_this else " "
|
| 715 |
label = d.get("device_label") or "(unlabeled)"
|
| 716 |
+
installed = d.get("install_method") or "-"
|
| 717 |
last_seen = d.get("last_seen_at", "")[:19].replace("T", " ")
|
| 718 |
idx_chip = a.chip(str(i), palette="jade" if is_this else "mute")
|
| 719 |
label_color = a.gradient_gold(label) if is_this else a.color(label, a.INK)
|
|
|
|
| 727 |
# ---- `sibyl logout` ----------------------------------------------------
|
| 728 |
|
| 729 |
def cmd_logout(args: argparse.Namespace) -> int:
|
| 730 |
+
"""Delete credentials.json + tier_cache.json. memory.db stays: that's your data."""
|
| 731 |
cred_path = Path(args.credentials).expanduser()
|
| 732 |
tier_cache = Path(args.tier_cache).expanduser()
|
| 733 |
|
|
|
|
| 757 |
# ---- `sibyl health` ----------------------------------------------------
|
| 758 |
|
| 759 |
def cmd_health(args: argparse.Namespace) -> int:
|
| 760 |
+
"""SibylMemoryProvider.health(): minimal self-check."""
|
| 761 |
try:
|
| 762 |
from sibyl_memory_hermes import SibylMemoryProvider
|
| 763 |
except ImportError:
|
sibyl-memory-cli/tests/test_setup.py
CHANGED
|
@@ -36,7 +36,7 @@ from sibyl_memory_cli.setup import ( # noqa: E402
|
|
| 36 |
# ----------------------------------------------------------------------
|
| 37 |
|
| 38 |
def _stub_install_plugin(hermes_home: str):
|
| 39 |
-
"""Replacement for sibyl_memory_hermes.install_plugin.install
|
| 40 |
adapter file so the wirer sees plugin_installed=True afterwards."""
|
| 41 |
plugin_dir = Path(hermes_home) / "plugins" / "sibyl"
|
| 42 |
plugin_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
| 36 |
# ----------------------------------------------------------------------
|
| 37 |
|
| 38 |
def _stub_install_plugin(hermes_home: str):
|
| 39 |
+
"""Replacement for sibyl_memory_hermes.install_plugin.install: drops a fake
|
| 40 |
adapter file so the wirer sees plugin_installed=True afterwards."""
|
| 41 |
plugin_dir = Path(hermes_home) / "plugins" / "sibyl"
|
| 42 |
plugin_dir.mkdir(parents=True, exist_ok=True)
|
sibyl-memory-client/CHANGELOG.md
CHANGED
|
@@ -4,9 +4,9 @@ All notable changes to `sibyl-memory-client` are recorded here. Format
|
|
| 4 |
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning
|
| 5 |
follows [SemVer](https://semver.org/).
|
| 6 |
|
| 7 |
-
## [0.4.1]
|
| 8 |
|
| 9 |
-
Auth-redesign wave 1 step 15
|
| 10 |
model. `/api/plugin/check-write` accepts `Authorization: Bearer <token>`
|
| 11 |
headers in addition to the existing `session_token` body field. This
|
| 12 |
release sends both: body field for older servers, header for the new
|
|
@@ -22,7 +22,7 @@ so legacy `session_token`-as-bearer credentials still resolve.
|
|
| 22 |
(v1 backward compat). No behavior change against current production
|
| 23 |
server. Companion: api-sibyllabs accepts both paths since v6 schema.
|
| 24 |
|
| 25 |
-
## [0.4.0]
|
| 26 |
|
| 27 |
KAPPA external-tester remediation release. Independent third-party install
|
| 28 |
test (KAPPA, peer Tulip-referred) against the v0.3.3 family surfaced one
|
|
@@ -33,7 +33,7 @@ v0.1.3.
|
|
| 33 |
|
| 34 |
### Fixed
|
| 35 |
|
| 36 |
-
- **KAPPA-BLOCKER**
|
| 37 |
relocated from `_capcheck.py` to `exceptions.py` so they are importable
|
| 38 |
from the canonical `sibyl_memory_client.exceptions` submodule path. The
|
| 39 |
v0.3.3 family had them defined and re-exported only at the top-level
|
|
@@ -41,19 +41,19 @@ v0.1.3.
|
|
| 41 |
imports from) raised `ImportError`. `_capcheck.py` now imports them back
|
| 42 |
for full backwards compatibility with anyone reaching into the private
|
| 43 |
module.
|
| 44 |
-
- **KAPPA-RED**
|
| 45 |
schema apply (was inheriting umask, typically 0644). WAL + SHM sidecar
|
| 46 |
files also tightened to 0600 if present. Idempotent + non-fatal on
|
| 47 |
chmod failure. Closes the file-perm gap KAPPA observed on a multi-user
|
| 48 |
/ CI / shared-dev-box install.
|
| 49 |
-
- **KAPPA-YELLOW**
|
| 50 |
validate user-supplied identifiers (category, name, key) before write.
|
| 51 |
Rejects: non-string, empty, control characters / null bytes, length
|
| 52 |
> 1024. Raises `ValidationError` with a recovery hint. Read paths are
|
| 53 |
unchanged: already-stored bad identifiers remain accessible so users
|
| 54 |
can introspect and migrate. New module-level helper
|
| 55 |
`validate_identifier(value, *, field_name)`.
|
| 56 |
-
- **KAPPA-YELLOW**
|
| 57 |
swallow `sqlite3.OperationalError` into empty results. The error is now
|
| 58 |
classified by `_classify_fts5_error()`:
|
| 59 |
- schema-missing (`"no such table"`) returns empty (defense against
|
|
@@ -65,9 +65,9 @@ v0.1.3.
|
|
| 65 |
|
| 66 |
### Added
|
| 67 |
|
| 68 |
-
- `validate_identifier(value, *, field_name)`
|
| 69 |
validating user-supplied identifiers consistently across the SDK.
|
| 70 |
-
- `_classify_fts5_error(err)`
|
| 71 |
`OperationalError` into the appropriate exception type.
|
| 72 |
|
| 73 |
### Notes
|
|
@@ -80,7 +80,7 @@ v0.1.3.
|
|
| 80 |
|
| 81 |
---
|
| 82 |
|
| 83 |
-
## [0.3.3]
|
| 84 |
|
| 85 |
Audit-remediation release. v0.3.0 pre-ship audit (2026-05-18T05:05Z) surfaced
|
| 86 |
10 critical findings across four lanes; this release lands the engine-side
|
|
@@ -89,7 +89,7 @@ v0.1.2, `sibyl-memory-mcp` v0.1.1.
|
|
| 89 |
|
| 90 |
### Added
|
| 91 |
|
| 92 |
-
- `MemoryClient.search(query, *, limit=20, prefix=False, tiers=None)`
|
| 93 |
cross-tier FTS5 search over entities + state + reference + journal. Each
|
| 94 |
hit is tier-tagged with `{tier, key, category, body, snippet, rank, ts}`.
|
| 95 |
Pass `tiers=("entity", "state")` to restrict scope. The marketing claim of
|
|
@@ -129,27 +129,27 @@ v0.1.2, `sibyl-memory-mcp` v0.1.1.
|
|
| 129 |
|
| 130 |
### Security
|
| 131 |
|
| 132 |
-
- **SEC-2**
|
| 133 |
used `write_text(...)` then `os.chmod(..., 0o600)`, leaving a
|
| 134 |
world-readable window between syscalls every cache write. Now opens with
|
| 135 |
`O_WRONLY|O_CREAT|O_EXCL|O_NOFOLLOW` and mode `0o600` set at creation
|
| 136 |
time. No race window.
|
| 137 |
-
- **SEC-3**
|
| 138 |
FTS5 injection / DoS via malformed queries.
|
| 139 |
-
- **SEC-3**
|
| 140 |
`db_path` or full SQLite error text. Original exception is chained via
|
| 141 |
`from e` for debugging; user-visible message stays generic.
|
| 142 |
-
- **SEC-9**
|
| 143 |
-
`error` body string in the user-visible message
|
| 144 |
"Retry shortly" pointer to avoid leaking internal server detail into
|
| 145 |
user logs.
|
| 146 |
-
- **SEC-11**
|
| 147 |
low-privilege attacker who once had write to `~/.sibyl-memory` cannot
|
| 148 |
redirect the cache to `/dev/null` or another file via symlink.
|
| 149 |
|
| 150 |
### Fixed
|
| 151 |
|
| 152 |
-
- **C2**
|
| 153 |
`importlib.metadata.version("sibyl-memory-client")` with the same
|
| 154 |
`+source` fallback pattern as sibyl-memory-hermes v0.3.0. The wheel and
|
| 155 |
the in-Python `__version__` can no longer drift (v0.3.2 published with
|
|
@@ -166,7 +166,7 @@ v0.1.2, `sibyl-memory-mcp` v0.1.1.
|
|
| 166 |
- Dropped unused `Iterable` and `ConflictError` imports from `client.py`
|
| 167 |
(DC1/DC2). Both remain in `__all__` via re-export.
|
| 168 |
|
| 169 |
-
## [0.3.2]
|
| 170 |
|
| 171 |
Audit-remediation release. Companion to api-sibyllabs payment-rail fixes
|
| 172 |
and the post-audit shipping pass. Closes T1-3, T1-4, T2-3 from the
|
|
@@ -175,7 +175,7 @@ msg_id 19e33139dfc3e4d4).
|
|
| 175 |
|
| 176 |
### Changed
|
| 177 |
|
| 178 |
-
- **T1-3
|
| 179 |
that `MemoryClient.archive_entity` bypassed the cap check, letting a
|
| 180 |
free user at 1.9 MB archive their largest entities (body copied into
|
| 181 |
archived_entities, doubling footprint) to keep writing past 2 MB. The
|
|
@@ -183,20 +183,20 @@ msg_id 19e33139dfc3e4d4).
|
|
| 183 |
(`body + name + category + reason + 200B overhead`), then calls
|
| 184 |
`self._cap_gate.check(proposed_delta_bytes=delta)` before the write
|
| 185 |
transaction. NotFoundError still raised before any cap-gate side effect.
|
| 186 |
-
- **T1-3
|
| 187 |
`Learner.__init__` gains a `cap_gate: Any = None` parameter. When
|
| 188 |
non-None, `accept_proposal` calls `cap_gate.check(proposed_delta_bytes=...)`
|
| 189 |
before inserting the `reference_documents` row (skill body can be
|
| 190 |
kilobytes). The convenience entry `MemoryClient.learner()` threads
|
| 191 |
the client's CapGate through automatically. Direct-import callers can
|
| 192 |
override `cap_gate=None` explicitly for tests.
|
| 193 |
-
- **T2-3
|
| 194 |
HTTP error**. Previously a transient 502 response synthesized
|
| 195 |
`{ok: False, tier: "free"}` and the caller cached it as authoritative,
|
| 196 |
locking a paid user out for up to 7 days. Now raises
|
| 197 |
-
`TierVerificationError` on any HTTP error
|
| 198 |
`_refresh_and_check` decides whether to honor a recent cache or hard-cap.
|
| 199 |
-
- **T1-4
|
| 200 |
`server_expires_at` is the server-supplied subscription expiry parsed
|
| 201 |
from the `expires_at` field on the `/check-write` response. The cache
|
| 202 |
is now honored only while `now < min(checked_at + grace_seconds,
|
|
@@ -220,7 +220,7 @@ msg_id 19e33139dfc3e4d4).
|
|
| 220 |
|
| 221 |
- 53/53 unchanged, all green. The cap-gate addition in `archive_entity`
|
| 222 |
fires under the default 2 MB cap on test data well below that
|
| 223 |
-
threshold
|
| 224 |
|
| 225 |
### Notes for downstream
|
| 226 |
|
|
@@ -229,7 +229,7 @@ msg_id 19e33139dfc3e4d4).
|
|
| 229 |
hermes versions still work; the bug they had was over-aggressive
|
| 230 |
exception swallowing, harmless to the cap-gate plumbing.
|
| 231 |
|
| 232 |
-
## [0.3.1]
|
| 233 |
|
| 234 |
Tamper-evidence release. Companion to api-sibyllabs HMAC signing.
|
| 235 |
|
|
@@ -242,13 +242,13 @@ Tamper-evidence release. Companion to api-sibyllabs HMAC signing.
|
|
| 242 |
- `CapGate` accepts the same two kwargs and, when both are present,
|
| 243 |
attaches them to every `/check-write` POST body. The server uses
|
| 244 |
them to verify the signature and log `credentials_tamper_suspected`
|
| 245 |
-
telemetry on mismatch. The cap-gate decision itself is unaffected
|
| 246 |
authoritative tier always comes from the database via
|
| 247 |
`effectiveAccess`.
|
| 248 |
|
| 249 |
### Schema
|
| 250 |
|
| 251 |
-
- Credentials JSON schema v2 (server-issued 2026-05-16+)
|
| 252 |
`signature` (HMAC-SHA256 hex, 64 chars) and `signed_at` (ISO ts).
|
| 253 |
Old schema v1 credentials still load and work; the client just
|
| 254 |
sends an unsigned request and the server skips the tamper check.
|
|
@@ -257,7 +257,7 @@ Tamper-evidence release. Companion to api-sibyllabs HMAC signing.
|
|
| 257 |
|
| 258 |
- 53/53 unchanged, all green. The signing path is purely additive.
|
| 259 |
|
| 260 |
-
## [0.3.0]
|
| 261 |
|
| 262 |
Hard-cap enforcement release. Operator directive 2026-05-15: "how do
|
| 263 |
we hard-limit free users to the 2Mb size? and ensure they can't
|
|
@@ -268,19 +268,19 @@ boundary. Locked in: 7-day grace cache, hard cap on by default.
|
|
| 268 |
### Added
|
| 269 |
|
| 270 |
- **`_capcheck.py` module** with the cap-enforcement primitives:
|
| 271 |
-
- `CapGate.check(proposed_delta_bytes)`
|
| 272 |
slow server-refresh path. Most writes never phone home. The slow
|
| 273 |
path only fires when (a) a free-tier user is about to push past
|
| 274 |
2 MB or (b) the local tier cache has expired.
|
| 275 |
-
- `TierCache`
|
| 276 |
mode 0600, atomic write, JSON shape `{ account_id, tier,
|
| 277 |
checked_at, cap_bytes }`. Honored as fresh for 7 days; honored
|
| 278 |
for an extended 14-day grace if the user is offline.
|
| 279 |
-
- `CapExceededError` (code `CAP_EXCEEDED`)
|
| 280 |
-
- `TierVerificationError`
|
| 281 |
offline, AND has no valid grace cache. Distinct from CAP_EXCEEDED
|
| 282 |
so callers can route the two error states differently.
|
| 283 |
-
- `_default_check_write_fn`
|
| 284 |
default endpoint is `https://api.sibyllabs.org/api/plugin/check-write`.
|
| 285 |
Replaceable for tests via the `check_fn` constructor kwarg.
|
| 286 |
- Constants `FREE_TIER_CAP_BYTES = 2 * 1024 * 1024` and
|
|
@@ -293,7 +293,7 @@ boundary. Locked in: 7-day grace cache, hard cap on by default.
|
|
| 293 |
`set_reference`) calls `self._cap_gate.check(proposed_delta_bytes=...)`
|
| 294 |
with a JSON-byte-length estimate. Reads are never gated.
|
| 295 |
- Pre-activation users (no `account_id`) get a strict local 2 MB cap
|
| 296 |
-
with no server check possible
|
| 297 |
|
| 298 |
### Tests
|
| 299 |
|
|
@@ -314,7 +314,7 @@ boundary. Locked in: 7-day grace cache, hard cap on by default.
|
|
| 314 |
`memory/research/2026-05-15-hard-cap-enforcement.md` (deferred until
|
| 315 |
`PLUGIN_CREDENTIAL_SIGNING_KEY` is provisioned in Doppler/Vercel).
|
| 316 |
|
| 317 |
-
## [0.2.0]
|
| 318 |
|
| 319 |
Self-learning + memory-linting release. Operator directive 2026-05-15:
|
| 320 |
"add a self-learning cron + function to the memory deployment so the
|
|
@@ -323,47 +323,47 @@ do. could we also do memory linter?"
|
|
| 323 |
|
| 324 |
### Schema
|
| 325 |
|
| 326 |
-
- **v2 migration**
|
| 327 |
-
- `skill_proposals`
|
| 328 |
-
- `learning_runs`
|
| 329 |
|
| 330 |
### Added
|
| 331 |
|
| 332 |
- **`learning.py` module** with the full self-learning loop:
|
| 333 |
-
- `Learner` class
|
| 334 |
- Four deterministic detectors: `repeated_action`, `structural_similarity`, `co_occurrence`, `temporal_routine`.
|
| 335 |
- Three pluggable summarizer backends (per operator design directive 2026-05-15):
|
| 336 |
-
- `LocalDeterministicSummarizer` (free tier default)
|
| 337 |
-
- `BYOKSummarizer` (paid tier opt-in)
|
| 338 |
-
- `VeniceX402Summarizer` (paid tier hosted)
|
| 339 |
- Review queue API: `list_proposals`, `get_proposal`, `accept_proposal` (writes `reference_documents` row under `skill/<slug>` key with provenance metadata), `reject_proposal`.
|
| 340 |
- Both LLM-backed summarizers gracefully fall back to local-deterministic output when the inference callable raises.
|
| 341 |
|
| 342 |
-
- **`lint.py` module**
|
| 343 |
- `Linter` class with 9 checks across three severity tiers (critical / warning / info): schema-version, invalid-json-entity, invalid-json-state, invalid-json-journal, duplicate-entity, empty-reference, stale-entity, journal-without-acts, db-soft-cap, fts-rowcount-mismatch, flagged-actors-fresh.
|
| 344 |
- `LintReport` dataclass with `to_dict()` (JSON-serializable) + `to_ascii()` (single-block boxed report for CLI).
|
| 345 |
- Tunable thresholds: `soft_cap_bytes` (default 10 MB per operator decision), `stale_days` (default 90), `flag_recency_days` (default 30).
|
| 346 |
|
| 347 |
- **`MemoryClient` API surface (additive)**:
|
| 348 |
-
- `client.learner(**kwargs)`
|
| 349 |
-
- `client.learn()`
|
| 350 |
- `client.list_skill_proposals(status='pending', limit=50)`.
|
| 351 |
- `client.accept_skill_proposal(id, note=None)`.
|
| 352 |
- `client.reject_skill_proposal(id, note=None)`.
|
| 353 |
-
- `client.lint(**kwargs)`
|
| 354 |
|
| 355 |
- **Public exports** (`__init__.py`): added `Learner`, `SkillProposal`, `LearningRunReport`, `Summarizer`, `LocalDeterministicSummarizer`, `BYOKSummarizer`, `VeniceX402Summarizer`, `Linter`, `LintReport`, `Finding`.
|
| 356 |
|
| 357 |
### Tests
|
| 358 |
|
| 359 |
- 22 new tests across two files:
|
| 360 |
-
- `tests/test_learning.py`
|
| 361 |
-
- `tests/test_lint.py`
|
| 362 |
- Total package coverage: 10 (existing smoke) + 12 (learning) + 10 (lint) = **32 tests, all green**.
|
| 363 |
|
| 364 |
### Compatibility
|
| 365 |
|
| 366 |
-
- v0.1.0 databases auto-upgrade to v2 on first open via existing idempotent `_ensure_schema()` path
|
| 367 |
- `sibyl-memory-hermes` v0.1.0 is binary-compatible with v0.2.0 of this SDK (provider surface unchanged). Hermes-provider tests updated to expect schema_version=2.
|
| 368 |
- Local-first promise unchanged: free tier remains zero-network. BYOK / Venice routes are paid-tier opt-in only and the CLI gate enforces tier checks upstream.
|
| 369 |
|
|
@@ -375,7 +375,7 @@ The CLI package will expose:
|
|
| 375 |
- `sibyl lint` β runs `client.lint()`, prints `to_ascii()`, exits non-zero if `critical_count > 0`.
|
| 376 |
- Optional cron install during `sibyl init` (Linux/macOS cron, Windows Task Scheduler) for daily learn + lint.
|
| 377 |
|
| 378 |
-
## [0.1.0]
|
| 379 |
|
| 380 |
Initial release.
|
| 381 |
|
|
|
|
| 4 |
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning
|
| 5 |
follows [SemVer](https://semver.org/).
|
| 6 |
|
| 7 |
+
## [0.4.1] - 2026-05-19
|
| 8 |
|
| 9 |
+
Auth-redesign wave 1 step 15: forward-compat with the server's v6 bearer
|
| 10 |
model. `/api/plugin/check-write` accepts `Authorization: Bearer <token>`
|
| 11 |
headers in addition to the existing `session_token` body field. This
|
| 12 |
release sends both: body field for older servers, header for the new
|
|
|
|
| 22 |
(v1 backward compat). No behavior change against current production
|
| 23 |
server. Companion: api-sibyllabs accepts both paths since v6 schema.
|
| 24 |
|
| 25 |
+
## [0.4.0] - 2026-05-18
|
| 26 |
|
| 27 |
KAPPA external-tester remediation release. Independent third-party install
|
| 28 |
test (KAPPA, peer Tulip-referred) against the v0.3.3 family surfaced one
|
|
|
|
| 33 |
|
| 34 |
### Fixed
|
| 35 |
|
| 36 |
+
- **KAPPA-BLOCKER**. `CapExceededError` and `TierVerificationError`
|
| 37 |
relocated from `_capcheck.py` to `exceptions.py` so they are importable
|
| 38 |
from the canonical `sibyl_memory_client.exceptions` submodule path. The
|
| 39 |
v0.3.3 family had them defined and re-exported only at the top-level
|
|
|
|
| 41 |
imports from) raised `ImportError`. `_capcheck.py` now imports them back
|
| 42 |
for full backwards compatibility with anyone reaching into the private
|
| 43 |
module.
|
| 44 |
+
- **KAPPA-RED**. `~/.sibyl-memory/memory.db` now chmod 0600 after the
|
| 45 |
schema apply (was inheriting umask, typically 0644). WAL + SHM sidecar
|
| 46 |
files also tightened to 0600 if present. Idempotent + non-fatal on
|
| 47 |
chmod failure. Closes the file-perm gap KAPPA observed on a multi-user
|
| 48 |
/ CI / shared-dev-box install.
|
| 49 |
+
- **KAPPA-YELLOW**. `set_entity`, `set_state`, and `set_reference` now
|
| 50 |
validate user-supplied identifiers (category, name, key) before write.
|
| 51 |
Rejects: non-string, empty, control characters / null bytes, length
|
| 52 |
> 1024. Raises `ValidationError` with a recovery hint. Read paths are
|
| 53 |
unchanged: already-stored bad identifiers remain accessible so users
|
| 54 |
can introspect and migrate. New module-level helper
|
| 55 |
`validate_identifier(value, *, field_name)`.
|
| 56 |
+
- **KAPPA-YELLOW**. `search()` and `search_entities()` no longer silently
|
| 57 |
swallow `sqlite3.OperationalError` into empty results. The error is now
|
| 58 |
classified by `_classify_fts5_error()`:
|
| 59 |
- schema-missing (`"no such table"`) returns empty (defense against
|
|
|
|
| 65 |
|
| 66 |
### Added
|
| 67 |
|
| 68 |
+
- `validate_identifier(value, *, field_name)`: public helper for
|
| 69 |
validating user-supplied identifiers consistently across the SDK.
|
| 70 |
+
- `_classify_fts5_error(err)`: internal helper for translating FTS5
|
| 71 |
`OperationalError` into the appropriate exception type.
|
| 72 |
|
| 73 |
### Notes
|
|
|
|
| 80 |
|
| 81 |
---
|
| 82 |
|
| 83 |
+
## [0.3.3] - 2026-05-18
|
| 84 |
|
| 85 |
Audit-remediation release. v0.3.0 pre-ship audit (2026-05-18T05:05Z) surfaced
|
| 86 |
10 critical findings across four lanes; this release lands the engine-side
|
|
|
|
| 89 |
|
| 90 |
### Added
|
| 91 |
|
| 92 |
+
- `MemoryClient.search(query, *, limit=20, prefix=False, tiers=None)` -
|
| 93 |
cross-tier FTS5 search over entities + state + reference + journal. Each
|
| 94 |
hit is tier-tagged with `{tier, key, category, body, snippet, rank, ts}`.
|
| 95 |
Pass `tiers=("entity", "state")` to restrict scope. The marketing claim of
|
|
|
|
| 129 |
|
| 130 |
### Security
|
| 131 |
|
| 132 |
+
- **SEC-2**. Atomic 0600-at-create for `TierCache.store`. Previously
|
| 133 |
used `write_text(...)` then `os.chmod(..., 0o600)`, leaving a
|
| 134 |
world-readable window between syscalls every cache write. Now opens with
|
| 135 |
`O_WRONLY|O_CREAT|O_EXCL|O_NOFOLLOW` and mode `0o600` set at creation
|
| 136 |
time. No race window.
|
| 137 |
+
- **SEC-3**. FTS5 query sanitization on every MATCH path. Prevents
|
| 138 |
FTS5 injection / DoS via malformed queries.
|
| 139 |
+
- **SEC-3**. `StorageError` messages no longer echo the absolute
|
| 140 |
`db_path` or full SQLite error text. Original exception is chained via
|
| 141 |
`from e` for debugging; user-visible message stays generic.
|
| 142 |
+
- **SEC-9**. `TierVerificationError` no longer echoes the server-side
|
| 143 |
+
`error` body string in the user-visible message: strips to a generic
|
| 144 |
"Retry shortly" pointer to avoid leaking internal server detail into
|
| 145 |
user logs.
|
| 146 |
+
- **SEC-11**. `TierCache.load` refuses to follow symlinks. A
|
| 147 |
low-privilege attacker who once had write to `~/.sibyl-memory` cannot
|
| 148 |
redirect the cache to `/dev/null` or another file via symlink.
|
| 149 |
|
| 150 |
### Fixed
|
| 151 |
|
| 152 |
+
- **C2**. `__version__` no longer hardcoded. Now sourced from
|
| 153 |
`importlib.metadata.version("sibyl-memory-client")` with the same
|
| 154 |
`+source` fallback pattern as sibyl-memory-hermes v0.3.0. The wheel and
|
| 155 |
the in-Python `__version__` can no longer drift (v0.3.2 published with
|
|
|
|
| 166 |
- Dropped unused `Iterable` and `ConflictError` imports from `client.py`
|
| 167 |
(DC1/DC2). Both remain in `__all__` via re-export.
|
| 168 |
|
| 169 |
+
## [0.3.2] - 2026-05-16
|
| 170 |
|
| 171 |
Audit-remediation release. Companion to api-sibyllabs payment-rail fixes
|
| 172 |
and the post-audit shipping pass. Closes T1-3, T1-4, T2-3 from the
|
|
|
|
| 175 |
|
| 176 |
### Changed
|
| 177 |
|
| 178 |
+
- **T1-3. `archive_entity` now goes through CapGate**. The audit found
|
| 179 |
that `MemoryClient.archive_entity` bypassed the cap check, letting a
|
| 180 |
free user at 1.9 MB archive their largest entities (body copied into
|
| 181 |
archived_entities, doubling footprint) to keep writing past 2 MB. The
|
|
|
|
| 183 |
(`body + name + category + reason + 200B overhead`), then calls
|
| 184 |
`self._cap_gate.check(proposed_delta_bytes=delta)` before the write
|
| 185 |
transaction. NotFoundError still raised before any cap-gate side effect.
|
| 186 |
+
- **T1-3. `Learner.accept_proposal` now accepts an optional `cap_gate`**.
|
| 187 |
`Learner.__init__` gains a `cap_gate: Any = None` parameter. When
|
| 188 |
non-None, `accept_proposal` calls `cap_gate.check(proposed_delta_bytes=...)`
|
| 189 |
before inserting the `reference_documents` row (skill body can be
|
| 190 |
kilobytes). The convenience entry `MemoryClient.learner()` threads
|
| 191 |
the client's CapGate through automatically. Direct-import callers can
|
| 192 |
override `cap_gate=None` explicitly for tests.
|
| 193 |
+
- **T2-3. `_default_check_write_fn` no longer forges fake decisions on
|
| 194 |
HTTP error**. Previously a transient 502 response synthesized
|
| 195 |
`{ok: False, tier: "free"}` and the caller cached it as authoritative,
|
| 196 |
locking a paid user out for up to 7 days. Now raises
|
| 197 |
+
`TierVerificationError` on any HTTP error: the offline-grace path in
|
| 198 |
`_refresh_and_check` decides whether to honor a recent cache or hard-cap.
|
| 199 |
+
- **T1-4. TierCacheEntry gains `server_expires_at` + `cache_token` fields**.
|
| 200 |
`server_expires_at` is the server-supplied subscription expiry parsed
|
| 201 |
from the `expires_at` field on the `/check-write` response. The cache
|
| 202 |
is now honored only while `now < min(checked_at + grace_seconds,
|
|
|
|
| 220 |
|
| 221 |
- 53/53 unchanged, all green. The cap-gate addition in `archive_entity`
|
| 222 |
fires under the default 2 MB cap on test data well below that
|
| 223 |
+
threshold: no test changes needed.
|
| 224 |
|
| 225 |
### Notes for downstream
|
| 226 |
|
|
|
|
| 229 |
hermes versions still work; the bug they had was over-aggressive
|
| 230 |
exception swallowing, harmless to the cap-gate plumbing.
|
| 231 |
|
| 232 |
+
## [0.3.1] - 2026-05-16
|
| 233 |
|
| 234 |
Tamper-evidence release. Companion to api-sibyllabs HMAC signing.
|
| 235 |
|
|
|
|
| 242 |
- `CapGate` accepts the same two kwargs and, when both are present,
|
| 243 |
attaches them to every `/check-write` POST body. The server uses
|
| 244 |
them to verify the signature and log `credentials_tamper_suspected`
|
| 245 |
+
telemetry on mismatch. The cap-gate decision itself is unaffected -
|
| 246 |
authoritative tier always comes from the database via
|
| 247 |
`effectiveAccess`.
|
| 248 |
|
| 249 |
### Schema
|
| 250 |
|
| 251 |
+
- Credentials JSON schema v2 (server-issued 2026-05-16+): adds
|
| 252 |
`signature` (HMAC-SHA256 hex, 64 chars) and `signed_at` (ISO ts).
|
| 253 |
Old schema v1 credentials still load and work; the client just
|
| 254 |
sends an unsigned request and the server skips the tamper check.
|
|
|
|
| 257 |
|
| 258 |
- 53/53 unchanged, all green. The signing path is purely additive.
|
| 259 |
|
| 260 |
+
## [0.3.0] - 2026-05-15
|
| 261 |
|
| 262 |
Hard-cap enforcement release. Operator directive 2026-05-15: "how do
|
| 263 |
we hard-limit free users to the 2Mb size? and ensure they can't
|
|
|
|
| 268 |
### Added
|
| 269 |
|
| 270 |
- **`_capcheck.py` module** with the cap-enforcement primitives:
|
| 271 |
+
- `CapGate.check(proposed_delta_bytes)`: three fast paths plus one
|
| 272 |
slow server-refresh path. Most writes never phone home. The slow
|
| 273 |
path only fires when (a) a free-tier user is about to push past
|
| 274 |
2 MB or (b) the local tier cache has expired.
|
| 275 |
+
- `TierCache`: file-backed at `~/.sibyl-memory/tier_cache.json`,
|
| 276 |
mode 0600, atomic write, JSON shape `{ account_id, tier,
|
| 277 |
checked_at, cap_bytes }`. Honored as fresh for 7 days; honored
|
| 278 |
for an extended 14-day grace if the user is offline.
|
| 279 |
+
- `CapExceededError` (code `CAP_EXCEEDED`): carries `upgrade_url`.
|
| 280 |
+
- `TierVerificationError`: raised only when the user is at the cap,
|
| 281 |
offline, AND has no valid grace cache. Distinct from CAP_EXCEEDED
|
| 282 |
so callers can route the two error states differently.
|
| 283 |
+
- `_default_check_write_fn`: pure stdlib urllib transport. The
|
| 284 |
default endpoint is `https://api.sibyllabs.org/api/plugin/check-write`.
|
| 285 |
Replaceable for tests via the `check_fn` constructor kwarg.
|
| 286 |
- Constants `FREE_TIER_CAP_BYTES = 2 * 1024 * 1024` and
|
|
|
|
| 293 |
`set_reference`) calls `self._cap_gate.check(proposed_delta_bytes=...)`
|
| 294 |
with a JSON-byte-length estimate. Reads are never gated.
|
| 295 |
- Pre-activation users (no `account_id`) get a strict local 2 MB cap
|
| 296 |
+
with no server check possible: by design.
|
| 297 |
|
| 298 |
### Tests
|
| 299 |
|
|
|
|
| 314 |
`memory/research/2026-05-15-hard-cap-enforcement.md` (deferred until
|
| 315 |
`PLUGIN_CREDENTIAL_SIGNING_KEY` is provisioned in Doppler/Vercel).
|
| 316 |
|
| 317 |
+
## [0.2.0] - 2026-05-15
|
| 318 |
|
| 319 |
Self-learning + memory-linting release. Operator directive 2026-05-15:
|
| 320 |
"add a self-learning cron + function to the memory deployment so the
|
|
|
|
| 323 |
|
| 324 |
### Schema
|
| 325 |
|
| 326 |
+
- **v2 migration**: adds two tables. Idempotent. v1 databases auto-upgrade on next open.
|
| 327 |
+
- `skill_proposals`: review queue for detected skills. Columns: id, tenant_id, created_at, pattern_kind, proposed_slug, proposed_title, proposed_body, evidence (JSON), confidence (REAL 0..1), summarizer, status (pending/accepted/rejected/superseded), reviewed_at, review_note, accepted_doc_key. UNIQUE indexes on (tenant_id, status, created_at) and (tenant_id, proposed_slug).
|
| 328 |
+
- `learning_runs`: watermark log so detectors don't rescan ground they covered. Columns: id, tenant_id, started_at, completed_at, summarizer, events_scanned, proposals_made, cursor_after_ts, notes.
|
| 329 |
|
| 330 |
### Added
|
| 331 |
|
| 332 |
- **`learning.py` module** with the full self-learning loop:
|
| 333 |
+
- `Learner` class: scans journal_events since last watermark, runs four pattern detectors, dedupes by slug, persists top-N proposals.
|
| 334 |
- Four deterministic detectors: `repeated_action`, `structural_similarity`, `co_occurrence`, `temporal_routine`.
|
| 335 |
- Three pluggable summarizer backends (per operator design directive 2026-05-15):
|
| 336 |
+
- `LocalDeterministicSummarizer` (free tier default): pure SQL + Python templates, zero network.
|
| 337 |
+
- `BYOKSummarizer` (paid tier opt-in): user supplies their own inference callable, SDK never holds the key.
|
| 338 |
+
- `VeniceX402Summarizer` (paid tier hosted). Venice-routed via x402 against the user's pre-funded plugin balance. Endpoint design at `memory/research/2026-05-15-self-learning-design.md`.
|
| 339 |
- Review queue API: `list_proposals`, `get_proposal`, `accept_proposal` (writes `reference_documents` row under `skill/<slug>` key with provenance metadata), `reject_proposal`.
|
| 340 |
- Both LLM-backed summarizers gracefully fall back to local-deterministic output when the inference callable raises.
|
| 341 |
|
| 342 |
+
- **`lint.py` module**: local memory linter mirroring `scripts/memory-lint.mjs`:
|
| 343 |
- `Linter` class with 9 checks across three severity tiers (critical / warning / info): schema-version, invalid-json-entity, invalid-json-state, invalid-json-journal, duplicate-entity, empty-reference, stale-entity, journal-without-acts, db-soft-cap, fts-rowcount-mismatch, flagged-actors-fresh.
|
| 344 |
- `LintReport` dataclass with `to_dict()` (JSON-serializable) + `to_ascii()` (single-block boxed report for CLI).
|
| 345 |
- Tunable thresholds: `soft_cap_bytes` (default 10 MB per operator decision), `stale_days` (default 90), `flag_recency_days` (default 30).
|
| 346 |
|
| 347 |
- **`MemoryClient` API surface (additive)**:
|
| 348 |
+
- `client.learner(**kwargs)`: construct a tenant-bound Learner.
|
| 349 |
+
- `client.learn()`: convenience: one-shot Learner.run() returning a LearningRunReport.
|
| 350 |
- `client.list_skill_proposals(status='pending', limit=50)`.
|
| 351 |
- `client.accept_skill_proposal(id, note=None)`.
|
| 352 |
- `client.reject_skill_proposal(id, note=None)`.
|
| 353 |
+
- `client.lint(**kwargs)`: returns a LintReport.
|
| 354 |
|
| 355 |
- **Public exports** (`__init__.py`): added `Learner`, `SkillProposal`, `LearningRunReport`, `Summarizer`, `LocalDeterministicSummarizer`, `BYOKSummarizer`, `VeniceX402Summarizer`, `Linter`, `LintReport`, `Finding`.
|
| 356 |
|
| 357 |
### Tests
|
| 358 |
|
| 359 |
- 22 new tests across two files:
|
| 360 |
+
- `tests/test_learning.py`: 12 tests: schema migration v2, no-event runs, repeated-action detection, watermark dedup, structural-similarity detection, accept/reject lifecycle, BYOK invocation, Venice/x402 fallback on failure, multi-tenant isolation.
|
| 361 |
+
- `tests/test_lint.py`: 10 tests: clean-DB baseline, duplicate-entity, empty-reference, stale-entity, journal-without-acts, soft-cap, ASCII report rendering, dict serialization, severity buckets, multi-tenant isolation.
|
| 362 |
- Total package coverage: 10 (existing smoke) + 12 (learning) + 10 (lint) = **32 tests, all green**.
|
| 363 |
|
| 364 |
### Compatibility
|
| 365 |
|
| 366 |
+
- v0.1.0 databases auto-upgrade to v2 on first open via existing idempotent `_ensure_schema()` path: no manual migration needed.
|
| 367 |
- `sibyl-memory-hermes` v0.1.0 is binary-compatible with v0.2.0 of this SDK (provider surface unchanged). Hermes-provider tests updated to expect schema_version=2.
|
| 368 |
- Local-first promise unchanged: free tier remains zero-network. BYOK / Venice routes are paid-tier opt-in only and the CLI gate enforces tier checks upstream.
|
| 369 |
|
|
|
|
| 375 |
- `sibyl lint` β runs `client.lint()`, prints `to_ascii()`, exits non-zero if `critical_count > 0`.
|
| 376 |
- Optional cron install during `sibyl init` (Linux/macOS cron, Windows Task Scheduler) for daily learn + lint.
|
| 377 |
|
| 378 |
+
## [0.1.0] - 2026-05-15
|
| 379 |
|
| 380 |
Initial release.
|
| 381 |
|
sibyl-memory-client/README.md
CHANGED
|
@@ -33,7 +33,7 @@ results = memory.search_entities("atlas")
|
|
| 33 |
Most agent-memory products store everything on someone else's servers, treat every piece of information the same way, and quietly forget the important things when you need them most. This SDK solves all three:
|
| 34 |
|
| 35 |
- **Local-first.** Memory lives in a SQLite database in `~/.sibyl-memory/`. No cloud round-trip for any operation.
|
| 36 |
-
- **Organized by kind.** Five separate tiers
|
| 37 |
- **Benchmarked.** The Sibyl Memory Plugin (built on this SDK) sits at #2 globally on the LongMemEval Oracle benchmark when paired with Claude Opus 4.6. Methodology open at [blog.sibylcap.com/longmemeval-v2](https://blog.sibylcap.com/longmemeval-v2).
|
| 38 |
|
| 39 |
## The five tiers
|
|
|
|
| 33 |
Most agent-memory products store everything on someone else's servers, treat every piece of information the same way, and quietly forget the important things when you need them most. This SDK solves all three:
|
| 34 |
|
| 35 |
- **Local-first.** Memory lives in a SQLite database in `~/.sibyl-memory/`. No cloud round-trip for any operation.
|
| 36 |
+
- **Organized by kind.** Five separate tiers: state, entities, journal, reference, archive: each recalled the way it should be recalled.
|
| 37 |
- **Benchmarked.** The Sibyl Memory Plugin (built on this SDK) sits at #2 globally on the LongMemEval Oracle benchmark when paired with Claude Opus 4.6. Methodology open at [blog.sibylcap.com/longmemeval-v2](https://blog.sibylcap.com/longmemeval-v2).
|
| 38 |
|
| 39 |
## The five tiers
|
sibyl-memory-client/src/sibyl_memory_client/_capcheck.py
CHANGED
|
@@ -10,7 +10,7 @@ Design (v0.3.0):
|
|
| 10 |
c) we have a recent cached server result that says we're under-cap
|
| 11 |
3. The slow path (only fires at the cap boundary) hits the server endpoint
|
| 12 |
POST /api/plugin/check-write with current_size + proposed_delta. The
|
| 13 |
-
server is the authoritative source for tier
|
| 14 |
tampering is detected here because the server looks up the real tier
|
| 15 |
from sibyl_plugin.accounts.
|
| 16 |
4. Server response is cached for 7 days. After that, the next write at the
|
|
@@ -144,7 +144,7 @@ class TierCache:
|
|
| 144 |
|
| 145 |
SEC-2 hardening (v0.3.3): the previous write_text() + chmod() pattern
|
| 146 |
left a world-readable window between the syscalls. Now we open with
|
| 147 |
-
O_CREAT|O_EXCL|O_WRONLY and mode 0o600
|
| 148 |
moment of creation, no race window."""
|
| 149 |
payload = {
|
| 150 |
"account_id": entry.account_id,
|
|
@@ -235,7 +235,7 @@ def _default_check_write_fn(
|
|
| 235 |
# T2-3 fix: do NOT synthesize a fake "free tier" decision on HTTP
|
| 236 |
# error. Previously a transient 502 would write `{tier:free, cap_bytes:2MB}`
|
| 237 |
# into the cache for a legitimately paid user, locking them out
|
| 238 |
-
# for up to 7 days. Now we raise TierVerificationError
|
| 239 |
# caller (_refresh_and_check) will fall back to a recent cache
|
| 240 |
# if one exists, or hard-cap if no cache.
|
| 241 |
try:
|
|
@@ -300,12 +300,12 @@ class CapGate:
|
|
| 300 |
# HMAC signature + the claim it commits to. When both are present,
|
| 301 |
# the server can verify and log mismatches as tamper-suspected
|
| 302 |
# telemetry. Authoritative tier always comes from the DB regardless
|
| 303 |
-
#
|
| 304 |
self._credentials_claim = credentials_claim
|
| 305 |
self._credentials_signature = credentials_signature
|
| 306 |
|
| 307 |
# ------------------------------------------------------------------
|
| 308 |
-
# Public entry point
|
| 309 |
# ------------------------------------------------------------------
|
| 310 |
def check(self, proposed_delta_bytes: int = 0) -> None:
|
| 311 |
"""Verify that the proposed write is permitted. Raises
|
|
@@ -315,7 +315,7 @@ class CapGate:
|
|
| 315 |
cached = self._cache.load()
|
| 316 |
if cached and cached.is_fresh and cached.account_id == self.account_id:
|
| 317 |
if cached.cap_bytes is None:
|
| 318 |
-
# Cached as paid (uncapped) within grace window
|
| 319 |
return
|
| 320 |
# Cached as free with a cap. Enforce locally.
|
| 321 |
new_size = self._db_size_fn() + proposed_delta_bytes
|
|
@@ -378,7 +378,7 @@ class CapGate:
|
|
| 378 |
except TierVerificationError:
|
| 379 |
# Offline. Fall back to the most recent cache if we have one,
|
| 380 |
# even if technically expired (within an extended grace window
|
| 381 |
-
# of double the normal period
|
| 382 |
#
|
| 383 |
# T1-4 fix: respect server-supplied subscription expiry on the
|
| 384 |
# offline path. The cache can no longer be honored past the
|
|
@@ -437,7 +437,7 @@ class CapGate:
|
|
| 437 |
|
| 438 |
if ok:
|
| 439 |
return # server permitted the write
|
| 440 |
-
# Server rejected
|
| 441 |
raise CapExceededError(
|
| 442 |
f"Your {tier} tier doesn't permit this write. "
|
| 443 |
f"Current memory size: {current / 1024:.1f} KB. "
|
|
|
|
| 10 |
c) we have a recent cached server result that says we're under-cap
|
| 11 |
3. The slow path (only fires at the cap boundary) hits the server endpoint
|
| 12 |
POST /api/plugin/check-write with current_size + proposed_delta. The
|
| 13 |
+
server is the authoritative source for tier: credentials.json
|
| 14 |
tampering is detected here because the server looks up the real tier
|
| 15 |
from sibyl_plugin.accounts.
|
| 16 |
4. Server response is cached for 7 days. After that, the next write at the
|
|
|
|
| 144 |
|
| 145 |
SEC-2 hardening (v0.3.3): the previous write_text() + chmod() pattern
|
| 146 |
left a world-readable window between the syscalls. Now we open with
|
| 147 |
+
O_CREAT|O_EXCL|O_WRONLY and mode 0o600: the kernel sets mode at the
|
| 148 |
moment of creation, no race window."""
|
| 149 |
payload = {
|
| 150 |
"account_id": entry.account_id,
|
|
|
|
| 235 |
# T2-3 fix: do NOT synthesize a fake "free tier" decision on HTTP
|
| 236 |
# error. Previously a transient 502 would write `{tier:free, cap_bytes:2MB}`
|
| 237 |
# into the cache for a legitimately paid user, locking them out
|
| 238 |
+
# for up to 7 days. Now we raise TierVerificationError: the
|
| 239 |
# caller (_refresh_and_check) will fall back to a recent cache
|
| 240 |
# if one exists, or hard-cap if no cache.
|
| 241 |
try:
|
|
|
|
| 300 |
# HMAC signature + the claim it commits to. When both are present,
|
| 301 |
# the server can verify and log mismatches as tamper-suspected
|
| 302 |
# telemetry. Authoritative tier always comes from the DB regardless
|
| 303 |
+
#: these fields are advisory, defense in depth only.
|
| 304 |
self._credentials_claim = credentials_claim
|
| 305 |
self._credentials_signature = credentials_signature
|
| 306 |
|
| 307 |
# ------------------------------------------------------------------
|
| 308 |
+
# Public entry point: called by every write path
|
| 309 |
# ------------------------------------------------------------------
|
| 310 |
def check(self, proposed_delta_bytes: int = 0) -> None:
|
| 311 |
"""Verify that the proposed write is permitted. Raises
|
|
|
|
| 315 |
cached = self._cache.load()
|
| 316 |
if cached and cached.is_fresh and cached.account_id == self.account_id:
|
| 317 |
if cached.cap_bytes is None:
|
| 318 |
+
# Cached as paid (uncapped) within grace window: allow
|
| 319 |
return
|
| 320 |
# Cached as free with a cap. Enforce locally.
|
| 321 |
new_size = self._db_size_fn() + proposed_delta_bytes
|
|
|
|
| 378 |
except TierVerificationError:
|
| 379 |
# Offline. Fall back to the most recent cache if we have one,
|
| 380 |
# even if technically expired (within an extended grace window
|
| 381 |
+
# of double the normal period: i.e., 14 days for tier=free).
|
| 382 |
#
|
| 383 |
# T1-4 fix: respect server-supplied subscription expiry on the
|
| 384 |
# offline path. The cache can no longer be honored past the
|
|
|
|
| 437 |
|
| 438 |
if ok:
|
| 439 |
return # server permitted the write
|
| 440 |
+
# Server rejected: typically free tier over cap.
|
| 441 |
raise CapExceededError(
|
| 442 |
f"Your {tier} tier doesn't permit this write. "
|
| 443 |
f"Current memory size: {current / 1024:.1f} KB. "
|
sibyl-memory-client/src/sibyl_memory_client/client.py
CHANGED
|
@@ -25,7 +25,7 @@ from .storage import Storage, dumps, loads, new_id, _utc_now_iso
|
|
| 25 |
# but null bytes break downstream consumers (logs, exports, CLI display),
|
| 26 |
# empty strings are nonsense as primary keys, and unbounded length is a
|
| 27 |
# latent vector if any code path ever spills to filesystem. Validate on
|
| 28 |
-
# WRITE only
|
| 29 |
# can introspect and migrate.
|
| 30 |
|
| 31 |
_IDENT_MAX_LENGTH = 1024
|
|
@@ -95,7 +95,7 @@ _FTS5_QUERY_ERROR_MARKERS = (
|
|
| 95 |
"no such column",
|
| 96 |
)
|
| 97 |
|
| 98 |
-
# Substring marking the schema-missing case
|
| 99 |
# for defense against partial schema state on very old DBs.
|
| 100 |
_SCHEMA_MISSING_MARKER = "no such table"
|
| 101 |
|
|
@@ -161,7 +161,7 @@ def _sanitize_fts5_query(raw: str, *, prefix: bool = False) -> str:
|
|
| 161 |
quoted phrases (`"phrase"*` is invalid syntax) so we use bare
|
| 162 |
tokens here. The character filter still blocks operator injection.
|
| 163 |
|
| 164 |
-
Empty / whitespace-only queries return an empty string
|
| 165 |
should short-circuit on empty.
|
| 166 |
"""
|
| 167 |
if not raw or not isinstance(raw, str):
|
|
@@ -175,7 +175,7 @@ def _sanitize_fts5_query(raw: str, *, prefix: bool = False) -> str:
|
|
| 175 |
return ""
|
| 176 |
|
| 177 |
if prefix:
|
| 178 |
-
# Reduce to safe bare tokens
|
| 179 |
# Anything else (quotes, colons, hyphens, FTS5 operators) becomes
|
| 180 |
# a space, then we split-and-rejoin to get clean whitespace.
|
| 181 |
cleaned = "".join(ch if (ch.isalnum() or ch == "_") else " " for ch in s)
|
|
@@ -231,7 +231,7 @@ class MemoryClient:
|
|
| 231 |
self._account_id = account_id
|
| 232 |
self._session_token = session_token
|
| 233 |
|
| 234 |
-
# Cap gate
|
| 235 |
# tier verification at the boundary. See _capcheck.py for the design.
|
| 236 |
if cap_gate is None:
|
| 237 |
from ._capcheck import CapGate, TierCache
|
|
@@ -332,7 +332,7 @@ class MemoryClient:
|
|
| 332 |
)
|
| 333 |
|
| 334 |
# ------------------------------------------------------------------
|
| 335 |
-
# Entities (WARM tier)
|
| 336 |
# ------------------------------------------------------------------
|
| 337 |
def set_entity(
|
| 338 |
self,
|
|
@@ -359,7 +359,7 @@ class MemoryClient:
|
|
| 359 |
validate_identifier(category, field_name="category")
|
| 360 |
validate_identifier(name, field_name="name")
|
| 361 |
body_json = _check_json(body)
|
| 362 |
-
# Cap gate
|
| 363 |
self._cap_gate.check(proposed_delta_bytes=len(body_json) + len(name) + len(category) + 200)
|
| 364 |
with self._storage.transaction() as conn:
|
| 365 |
existing = conn.execute(
|
|
@@ -454,7 +454,7 @@ class MemoryClient:
|
|
| 454 |
return {"body": loads(row["body"]), "updated_at": row["updated_at"]}
|
| 455 |
|
| 456 |
# ------------------------------------------------------------------
|
| 457 |
-
# Journal (COLD tier)
|
| 458 |
# ------------------------------------------------------------------
|
| 459 |
def write_event(
|
| 460 |
self,
|
|
@@ -523,7 +523,7 @@ class MemoryClient:
|
|
| 523 |
]
|
| 524 |
|
| 525 |
# ------------------------------------------------------------------
|
| 526 |
-
# Reference (REFERENCE tier)
|
| 527 |
# ------------------------------------------------------------------
|
| 528 |
def set_reference(
|
| 529 |
self,
|
|
@@ -574,7 +574,7 @@ class MemoryClient:
|
|
| 574 |
raised before any cap-gate work.
|
| 575 |
"""
|
| 576 |
# Read the row first so we can size the archive insert. NotFoundError
|
| 577 |
-
# propagates as before
|
| 578 |
with self._storage.connection() as conn:
|
| 579 |
preview = conn.execute(
|
| 580 |
"SELECT id, body FROM entities WHERE tenant_id = ? AND category = ? AND name = ?",
|
|
@@ -605,7 +605,7 @@ class MemoryClient:
|
|
| 605 |
return {"archived_id": arch_id, "original_id": row["id"]}
|
| 606 |
|
| 607 |
# ------------------------------------------------------------------
|
| 608 |
-
# Self-learning + lint (v0.2.0)
|
| 609 |
# ------------------------------------------------------------------
|
| 610 |
# Both convenience entrypoints below gate on tier and raise
|
| 611 |
# TierGateError for free-tier callers. The underlying Learner / Linter
|
|
@@ -661,17 +661,17 @@ class MemoryClient:
|
|
| 661 |
if "soft_cap_bytes" not in kwargs:
|
| 662 |
from .lint import TIER_SOFT_CAPS, DEFAULT_SOFT_CAP_BYTES
|
| 663 |
cap = TIER_SOFT_CAPS.get(self._tier, DEFAULT_SOFT_CAP_BYTES)
|
| 664 |
-
# Paid tiers map to None
|
| 665 |
kwargs["soft_cap_bytes"] = cap if cap is not None else (1 << 62)
|
| 666 |
return Linter(self._storage, tenant_id=self._tenant_id, **kwargs).run()
|
| 667 |
|
| 668 |
# ------------------------------------------------------------------
|
| 669 |
-
# Free-tier read access (no gating)
|
| 670 |
# ------------------------------------------------------------------
|
| 671 |
def free_tier_status(self) -> dict[str, Any]:
|
| 672 |
"""Return current free-tier state: DB size, soft cap, % used.
|
| 673 |
|
| 674 |
-
Always available regardless of tier
|
| 675 |
to render the "you're at X% of your free cap" upgrade prompt
|
| 676 |
without needing to call the (gated) linter.
|
| 677 |
"""
|
|
@@ -708,7 +708,7 @@ class MemoryClient:
|
|
| 708 |
Returns warm-tier entity rows only. For cross-tier search (entities +
|
| 709 |
state + reference + journal in one call), use ``search()``.
|
| 710 |
|
| 711 |
-
Query is sanitized as a single FTS5 phrase
|
| 712 |
(``name:foo``) and unclosed quotes can't escape into the parser.
|
| 713 |
Set ``prefix=True`` for prefix matching on the final token.
|
| 714 |
|
|
@@ -757,7 +757,7 @@ class MemoryClient:
|
|
| 757 |
"body": <JSON-decoded payload or string>,
|
| 758 |
"snippet": <FTS5 snippet, up to ~120 chars around the match>,
|
| 759 |
"rank": <FTS5 rank, lower is better>,
|
| 760 |
-
"ts": <ISO timestamp
|
| 761 |
}
|
| 762 |
|
| 763 |
Ordered by FTS5 rank across the union. The default ``limit`` applies
|
|
@@ -778,7 +778,7 @@ class MemoryClient:
|
|
| 778 |
# v0.4.0 (KAPPA YELLOW finding): per-tier OperationalError handling
|
| 779 |
# now classifies via _classify_fts5_error. Schema-missing keeps the
|
| 780 |
# previous behavior (skip this tier silently, other tiers continue).
|
| 781 |
-
# FTS5 syntax / real backend errors raise
|
| 782 |
# ALL tiers, no point continuing through the union.
|
| 783 |
if "entity" in allowed:
|
| 784 |
try:
|
|
@@ -848,7 +848,7 @@ class MemoryClient:
|
|
| 848 |
raise exc from e
|
| 849 |
if "journal" in allowed:
|
| 850 |
try:
|
| 851 |
-
# Journal FTS5 is standalone
|
| 852 |
# the FTS5 table directly, then join to journal_events
|
| 853 |
# by id (TEXT PK) for the typed body fields.
|
| 854 |
for r in conn.execute(
|
|
|
|
| 25 |
# but null bytes break downstream consumers (logs, exports, CLI display),
|
| 26 |
# empty strings are nonsense as primary keys, and unbounded length is a
|
| 27 |
# latent vector if any code path ever spills to filesystem. Validate on
|
| 28 |
+
# WRITE only: reads of already-stored bad identifiers still work so users
|
| 29 |
# can introspect and migrate.
|
| 30 |
|
| 31 |
_IDENT_MAX_LENGTH = 1024
|
|
|
|
| 95 |
"no such column",
|
| 96 |
)
|
| 97 |
|
| 98 |
+
# Substring marking the schema-missing case: keep silent (return empty)
|
| 99 |
# for defense against partial schema state on very old DBs.
|
| 100 |
_SCHEMA_MISSING_MARKER = "no such table"
|
| 101 |
|
|
|
|
| 161 |
quoted phrases (`"phrase"*` is invalid syntax) so we use bare
|
| 162 |
tokens here. The character filter still blocks operator injection.
|
| 163 |
|
| 164 |
+
Empty / whitespace-only queries return an empty string: callers
|
| 165 |
should short-circuit on empty.
|
| 166 |
"""
|
| 167 |
if not raw or not isinstance(raw, str):
|
|
|
|
| 175 |
return ""
|
| 176 |
|
| 177 |
if prefix:
|
| 178 |
+
# Reduce to safe bare tokens: alphanumeric + underscore only.
|
| 179 |
# Anything else (quotes, colons, hyphens, FTS5 operators) becomes
|
| 180 |
# a space, then we split-and-rejoin to get clean whitespace.
|
| 181 |
cleaned = "".join(ch if (ch.isalnum() or ch == "_") else " " for ch in s)
|
|
|
|
| 231 |
self._account_id = account_id
|
| 232 |
self._session_token = session_token
|
| 233 |
|
| 234 |
+
# Cap gate: enforces the 2 MB free-tier cap with server-authoritative
|
| 235 |
# tier verification at the boundary. See _capcheck.py for the design.
|
| 236 |
if cap_gate is None:
|
| 237 |
from ._capcheck import CapGate, TierCache
|
|
|
|
| 332 |
)
|
| 333 |
|
| 334 |
# ------------------------------------------------------------------
|
| 335 |
+
# Entities (WARM tier): single source of truth per rule 43
|
| 336 |
# ------------------------------------------------------------------
|
| 337 |
def set_entity(
|
| 338 |
self,
|
|
|
|
| 359 |
validate_identifier(category, field_name="category")
|
| 360 |
validate_identifier(name, field_name="name")
|
| 361 |
body_json = _check_json(body)
|
| 362 |
+
# Cap gate: rough byte estimate (FTS5 + indexes add overhead)
|
| 363 |
self._cap_gate.check(proposed_delta_bytes=len(body_json) + len(name) + len(category) + 200)
|
| 364 |
with self._storage.transaction() as conn:
|
| 365 |
existing = conn.execute(
|
|
|
|
| 454 |
return {"body": loads(row["body"]), "updated_at": row["updated_at"]}
|
| 455 |
|
| 456 |
# ------------------------------------------------------------------
|
| 457 |
+
# Journal (COLD tier): append-only event log
|
| 458 |
# ------------------------------------------------------------------
|
| 459 |
def write_event(
|
| 460 |
self,
|
|
|
|
| 523 |
]
|
| 524 |
|
| 525 |
# ------------------------------------------------------------------
|
| 526 |
+
# Reference (REFERENCE tier): static lookup documents
|
| 527 |
# ------------------------------------------------------------------
|
| 528 |
def set_reference(
|
| 529 |
self,
|
|
|
|
| 574 |
raised before any cap-gate work.
|
| 575 |
"""
|
| 576 |
# Read the row first so we can size the archive insert. NotFoundError
|
| 577 |
+
# propagates as before: no cap-gate side effect for missing entities.
|
| 578 |
with self._storage.connection() as conn:
|
| 579 |
preview = conn.execute(
|
| 580 |
"SELECT id, body FROM entities WHERE tenant_id = ? AND category = ? AND name = ?",
|
|
|
|
| 605 |
return {"archived_id": arch_id, "original_id": row["id"]}
|
| 606 |
|
| 607 |
# ------------------------------------------------------------------
|
| 608 |
+
# Self-learning + lint (v0.2.0): paid-tier only
|
| 609 |
# ------------------------------------------------------------------
|
| 610 |
# Both convenience entrypoints below gate on tier and raise
|
| 611 |
# TierGateError for free-tier callers. The underlying Learner / Linter
|
|
|
|
| 661 |
if "soft_cap_bytes" not in kwargs:
|
| 662 |
from .lint import TIER_SOFT_CAPS, DEFAULT_SOFT_CAP_BYTES
|
| 663 |
cap = TIER_SOFT_CAPS.get(self._tier, DEFAULT_SOFT_CAP_BYTES)
|
| 664 |
+
# Paid tiers map to None: pass a huge cap so the check effectively never fires
|
| 665 |
kwargs["soft_cap_bytes"] = cap if cap is not None else (1 << 62)
|
| 666 |
return Linter(self._storage, tenant_id=self._tenant_id, **kwargs).run()
|
| 667 |
|
| 668 |
# ------------------------------------------------------------------
|
| 669 |
+
# Free-tier read access (no gating): visibility into the upgrade pressure
|
| 670 |
# ------------------------------------------------------------------
|
| 671 |
def free_tier_status(self) -> dict[str, Any]:
|
| 672 |
"""Return current free-tier state: DB size, soft cap, % used.
|
| 673 |
|
| 674 |
+
Always available regardless of tier: free-tier callers use this
|
| 675 |
to render the "you're at X% of your free cap" upgrade prompt
|
| 676 |
without needing to call the (gated) linter.
|
| 677 |
"""
|
|
|
|
| 708 |
Returns warm-tier entity rows only. For cross-tier search (entities +
|
| 709 |
state + reference + journal in one call), use ``search()``.
|
| 710 |
|
| 711 |
+
Query is sanitized as a single FTS5 phrase: column-filter syntax
|
| 712 |
(``name:foo``) and unclosed quotes can't escape into the parser.
|
| 713 |
Set ``prefix=True`` for prefix matching on the final token.
|
| 714 |
|
|
|
|
| 757 |
"body": <JSON-decoded payload or string>,
|
| 758 |
"snippet": <FTS5 snippet, up to ~120 chars around the match>,
|
| 759 |
"rank": <FTS5 rank, lower is better>,
|
| 760 |
+
"ts": <ISO timestamp: updated_at or journal ts>
|
| 761 |
}
|
| 762 |
|
| 763 |
Ordered by FTS5 rank across the union. The default ``limit`` applies
|
|
|
|
| 778 |
# v0.4.0 (KAPPA YELLOW finding): per-tier OperationalError handling
|
| 779 |
# now classifies via _classify_fts5_error. Schema-missing keeps the
|
| 780 |
# previous behavior (skip this tier silently, other tiers continue).
|
| 781 |
+
# FTS5 syntax / real backend errors raise: the query is bad for
|
| 782 |
# ALL tiers, no point continuing through the union.
|
| 783 |
if "entity" in allowed:
|
| 784 |
try:
|
|
|
|
| 848 |
raise exc from e
|
| 849 |
if "journal" in allowed:
|
| 850 |
try:
|
| 851 |
+
# Journal FTS5 is standalone: fetch the event_id from
|
| 852 |
# the FTS5 table directly, then join to journal_events
|
| 853 |
# by id (TEXT PK) for the typed body fields.
|
| 854 |
for r in conn.execute(
|
sibyl-memory-client/src/sibyl_memory_client/exceptions.py
CHANGED
|
@@ -6,7 +6,7 @@ string suggesting what the caller should try next.
|
|
| 6 |
v0.4.0 (2026-05-18): `CapExceededError` + `TierVerificationError` relocated
|
| 7 |
here from `_capcheck.py` so they are importable from the canonical
|
| 8 |
`sibyl_memory_client.exceptions` submodule path (KAPPA bug report against
|
| 9 |
-
sibyl-memory-mcp 0.1.1
|
| 10 |
only lived on `._capcheck`).
|
| 11 |
"""
|
| 12 |
from __future__ import annotations
|
|
|
|
| 6 |
v0.4.0 (2026-05-18): `CapExceededError` + `TierVerificationError` relocated
|
| 7 |
here from `_capcheck.py` so they are importable from the canonical
|
| 8 |
`sibyl_memory_client.exceptions` submodule path (KAPPA bug report against
|
| 9 |
+
sibyl-memory-mcp 0.1.1: server imported these from `.exceptions` but they
|
| 10 |
only lived on `._capcheck`).
|
| 11 |
"""
|
| 12 |
from __future__ import annotations
|
sibyl-memory-client/src/sibyl_memory_client/learning.py
CHANGED
|
@@ -15,7 +15,7 @@ THREE RUNTIME MODES (operator directive 2026-05-15)
|
|
| 15 |
2. **byok** (paid-tier opt-in)
|
| 16 |
User pastes their own Anthropic / OpenAI / Venice key into config.
|
| 17 |
The Learner uses the key to summarize matched event clusters into
|
| 18 |
-
prose skill bodies. Local-first stays intact at the data layer
|
| 19 |
the user controls where the inference call goes. Sibyl Labs never
|
| 20 |
sees the key or the payload.
|
| 21 |
|
|
@@ -133,7 +133,7 @@ class LocalDeterministicSummarizer:
|
|
| 133 |
|
| 134 |
Useful properties:
|
| 135 |
β’ Zero network. Free-tier-safe.
|
| 136 |
-
β’ Deterministic
|
| 137 |
β’ Explains its own reasoning (so the user sees why the pattern
|
| 138 |
was surfaced).
|
| 139 |
"""
|
|
@@ -175,7 +175,7 @@ class LocalDeterministicSummarizer:
|
|
| 175 |
f"the same journal entries."
|
| 176 |
)
|
| 177 |
else:
|
| 178 |
-
lines.append("(pattern kind unrecognized
|
| 179 |
|
| 180 |
lines.append("")
|
| 181 |
lines.append("## Evidence")
|
|
@@ -183,7 +183,7 @@ class LocalDeterministicSummarizer:
|
|
| 183 |
for ev in events[:5]: # cap at five for readability
|
| 184 |
ts = ev.get("ts") or "?"
|
| 185 |
snippet = _short_event_snippet(ev)
|
| 186 |
-
lines.append(f"- `{ts}`
|
| 187 |
if len(events) > 5:
|
| 188 |
lines.append(f"- _β¦and {len(events) - 5} more matching events_")
|
| 189 |
lines.append("")
|
|
@@ -205,7 +205,7 @@ class BYOKSummarizer:
|
|
| 205 |
|
| 206 |
The user passes a callable `inference_fn(prompt: str) -> str` so the
|
| 207 |
SDK never holds the key itself. The callable can be implemented
|
| 208 |
-
against Anthropic, OpenAI, Venice, or any provider
|
| 209 |
doesn't care.
|
| 210 |
|
| 211 |
Free-tier installs cannot construct this class (the CLI's tier
|
|
@@ -287,7 +287,7 @@ class VeniceX402Summarizer:
|
|
| 287 |
|
| 288 |
|
| 289 |
# ----------------------------------------------------------------------
|
| 290 |
-
# Learner
|
| 291 |
# ----------------------------------------------------------------------
|
| 292 |
|
| 293 |
class Learner:
|
|
@@ -301,7 +301,7 @@ class Learner:
|
|
| 301 |
max_proposals_per_run: cap to avoid swamping the review queue
|
| 302 |
cap_gate: optional CapGate. When provided, accept_proposal calls
|
| 303 |
the gate before writing the reference_documents row (T1-3 fix).
|
| 304 |
-
When None, no cap check is performed
|
| 305 |
callers who construct Learner directly and own their own
|
| 306 |
enforcement.
|
| 307 |
"""
|
|
@@ -331,7 +331,7 @@ class Learner:
|
|
| 331 |
run_id = new_id()
|
| 332 |
started_at = _utc_now_iso()
|
| 333 |
|
| 334 |
-
# Resolve watermark
|
| 335 |
since_ts = since or self._last_watermark()
|
| 336 |
events = self._load_events(since=since_ts)
|
| 337 |
scanned = len(events)
|
|
@@ -366,7 +366,7 @@ class Learner:
|
|
| 366 |
# temporal_routine: light-touch detector, deliberately last
|
| 367 |
candidates.extend(_detect_temporal_routine(events, min_hits=self._min_hits))
|
| 368 |
|
| 369 |
-
# Deduplicate by slug
|
| 370 |
deduped: dict[str, _Candidate] = {}
|
| 371 |
for c in candidates:
|
| 372 |
existing = deduped.get(c.slug)
|
|
@@ -758,7 +758,7 @@ def _detect_temporal_routine(
|
|
| 758 |
mean = sum(gaps_min) / len(gaps_min)
|
| 759 |
if mean <= 0:
|
| 760 |
continue
|
| 761 |
-
# Coefficient of variation
|
| 762 |
var = sum((g - mean) ** 2 for g in gaps_min) / len(gaps_min)
|
| 763 |
cov = (var ** 0.5) / mean
|
| 764 |
if cov >= 0.6:
|
|
|
|
| 15 |
2. **byok** (paid-tier opt-in)
|
| 16 |
User pastes their own Anthropic / OpenAI / Venice key into config.
|
| 17 |
The Learner uses the key to summarize matched event clusters into
|
| 18 |
+
prose skill bodies. Local-first stays intact at the data layer -
|
| 19 |
the user controls where the inference call goes. Sibyl Labs never
|
| 20 |
sees the key or the payload.
|
| 21 |
|
|
|
|
| 133 |
|
| 134 |
Useful properties:
|
| 135 |
β’ Zero network. Free-tier-safe.
|
| 136 |
+
β’ Deterministic: same input always produces the same body.
|
| 137 |
β’ Explains its own reasoning (so the user sees why the pattern
|
| 138 |
was surfaced).
|
| 139 |
"""
|
|
|
|
| 175 |
f"the same journal entries."
|
| 176 |
)
|
| 177 |
else:
|
| 178 |
+
lines.append("(pattern kind unrecognized: flagged for review)")
|
| 179 |
|
| 180 |
lines.append("")
|
| 181 |
lines.append("## Evidence")
|
|
|
|
| 183 |
for ev in events[:5]: # cap at five for readability
|
| 184 |
ts = ev.get("ts") or "?"
|
| 185 |
snippet = _short_event_snippet(ev)
|
| 186 |
+
lines.append(f"- `{ts}`: {snippet}")
|
| 187 |
if len(events) > 5:
|
| 188 |
lines.append(f"- _β¦and {len(events) - 5} more matching events_")
|
| 189 |
lines.append("")
|
|
|
|
| 205 |
|
| 206 |
The user passes a callable `inference_fn(prompt: str) -> str` so the
|
| 207 |
SDK never holds the key itself. The callable can be implemented
|
| 208 |
+
against Anthropic, OpenAI, Venice, or any provider: the SDK
|
| 209 |
doesn't care.
|
| 210 |
|
| 211 |
Free-tier installs cannot construct this class (the CLI's tier
|
|
|
|
| 287 |
|
| 288 |
|
| 289 |
# ----------------------------------------------------------------------
|
| 290 |
+
# Learner: orchestrates detection + summarization + persistence
|
| 291 |
# ----------------------------------------------------------------------
|
| 292 |
|
| 293 |
class Learner:
|
|
|
|
| 301 |
max_proposals_per_run: cap to avoid swamping the review queue
|
| 302 |
cap_gate: optional CapGate. When provided, accept_proposal calls
|
| 303 |
the gate before writing the reference_documents row (T1-3 fix).
|
| 304 |
+
When None, no cap check is performed: exposed for advanced
|
| 305 |
callers who construct Learner directly and own their own
|
| 306 |
enforcement.
|
| 307 |
"""
|
|
|
|
| 331 |
run_id = new_id()
|
| 332 |
started_at = _utc_now_iso()
|
| 333 |
|
| 334 |
+
# Resolve watermark: explicit `since` wins, otherwise look up last run
|
| 335 |
since_ts = since or self._last_watermark()
|
| 336 |
events = self._load_events(since=since_ts)
|
| 337 |
scanned = len(events)
|
|
|
|
| 366 |
# temporal_routine: light-touch detector, deliberately last
|
| 367 |
candidates.extend(_detect_temporal_routine(events, min_hits=self._min_hits))
|
| 368 |
|
| 369 |
+
# Deduplicate by slug: keep the highest-confidence candidate per slug
|
| 370 |
deduped: dict[str, _Candidate] = {}
|
| 371 |
for c in candidates:
|
| 372 |
existing = deduped.get(c.slug)
|
|
|
|
| 758 |
mean = sum(gaps_min) / len(gaps_min)
|
| 759 |
if mean <= 0:
|
| 760 |
continue
|
| 761 |
+
# Coefficient of variation: lower = more regular
|
| 762 |
var = sum((g - mean) ** 2 for g in gaps_min) / len(gaps_min)
|
| 763 |
cov = (var ** 0.5) / mean
|
| 764 |
if cov >= 0.6:
|
sibyl-memory-client/src/sibyl_memory_client/lint.py
CHANGED
|
@@ -29,7 +29,7 @@ Severity levels: `critical` | `warning` | `info`
|
|
| 29 |
| fts-rowcount-mismatch | warning | FTS5 index count differs from entities count |
|
| 30 |
| flagged-actors-fresh | info | recent flagged_actors entries (β€ N days) |
|
| 31 |
|
| 32 |
-
The check list is intentionally conservative for v0.2.0
|
| 33 |
"""
|
| 34 |
from __future__ import annotations
|
| 35 |
|
|
@@ -57,11 +57,11 @@ EXPECTED_SCHEMA_VERSION = 2
|
|
| 57 |
# Tier β soft cap mapping. None means uncapped.
|
| 58 |
TIER_SOFT_CAPS: dict[str, int | None] = {
|
| 59 |
"free": 2 * 1024 * 1024, # 2 MB
|
| 60 |
-
"sync": None, # uncapped
|
| 61 |
-
"team": None, # uncapped
|
| 62 |
-
"lifetime": None, # uncapped
|
| 63 |
-
"stake": None, # uncapped
|
| 64 |
-
"enterprise": None, # uncapped
|
| 65 |
}
|
| 66 |
|
| 67 |
|
|
@@ -162,7 +162,7 @@ class LintReport:
|
|
| 162 |
|
| 163 |
|
| 164 |
# ----------------------------------------------------------------------
|
| 165 |
-
# Linter
|
| 166 |
# ----------------------------------------------------------------------
|
| 167 |
|
| 168 |
class Linter:
|
|
@@ -205,7 +205,7 @@ class Linter:
|
|
| 205 |
f"DB schema version is {schema_version}, expected "
|
| 206 |
f">= {EXPECTED_SCHEMA_VERSION}"
|
| 207 |
),
|
| 208 |
-
recovery="Reopen the MemoryClient
|
| 209 |
))
|
| 210 |
|
| 211 |
# Row counts
|
|
@@ -221,7 +221,7 @@ class Linter:
|
|
| 221 |
).fetchone()
|
| 222 |
counts[tname] = int(row["n"]) if row else 0
|
| 223 |
except Exception:
|
| 224 |
-
counts[tname] = -1 # table missing
|
| 225 |
|
| 226 |
# ββ JSON validity (defense-in-depth; CHECK constraints catch most)
|
| 227 |
findings.extend(self._lint_json_bodies(conn))
|
|
@@ -338,7 +338,7 @@ class Linter:
|
|
| 338 |
severity="warning",
|
| 339 |
message=(
|
| 340 |
f"entities table has {ents} rows but FTS5 index "
|
| 341 |
-
f"has {fts}
|
| 342 |
),
|
| 343 |
recovery="Rebuild FTS index: client.rebuild_fts() (planned).",
|
| 344 |
detail={"entities": ents, "fts": fts},
|
|
@@ -388,7 +388,7 @@ class Linter:
|
|
| 388 |
)
|
| 389 |
|
| 390 |
# ------------------------------------------------------------------
|
| 391 |
-
# Internal
|
| 392 |
# ------------------------------------------------------------------
|
| 393 |
def _lint_json_bodies(self, conn: Any) -> list[Finding]:
|
| 394 |
out: list[Finding] = []
|
|
|
|
| 29 |
| fts-rowcount-mismatch | warning | FTS5 index count differs from entities count |
|
| 30 |
| flagged-actors-fresh | info | recent flagged_actors entries (β€ N days) |
|
| 31 |
|
| 32 |
+
The check list is intentionally conservative for v0.2.0: easy to extend.
|
| 33 |
"""
|
| 34 |
from __future__ import annotations
|
| 35 |
|
|
|
|
| 57 |
# Tier β soft cap mapping. None means uncapped.
|
| 58 |
TIER_SOFT_CAPS: dict[str, int | None] = {
|
| 59 |
"free": 2 * 1024 * 1024, # 2 MB
|
| 60 |
+
"sync": None, # uncapped: paid subscription
|
| 61 |
+
"team": None, # uncapped: paid subscription
|
| 62 |
+
"lifetime": None, # uncapped: one-time payment
|
| 63 |
+
"stake": None, # uncapped: $SIBYL stake
|
| 64 |
+
"enterprise": None, # uncapped: annual contract
|
| 65 |
}
|
| 66 |
|
| 67 |
|
|
|
|
| 162 |
|
| 163 |
|
| 164 |
# ----------------------------------------------------------------------
|
| 165 |
+
# Linter: the actual checks
|
| 166 |
# ----------------------------------------------------------------------
|
| 167 |
|
| 168 |
class Linter:
|
|
|
|
| 205 |
f"DB schema version is {schema_version}, expected "
|
| 206 |
f">= {EXPECTED_SCHEMA_VERSION}"
|
| 207 |
),
|
| 208 |
+
recovery="Reopen the MemoryClient: schema migrations run on construction.",
|
| 209 |
))
|
| 210 |
|
| 211 |
# Row counts
|
|
|
|
| 221 |
).fetchone()
|
| 222 |
counts[tname] = int(row["n"]) if row else 0
|
| 223 |
except Exception:
|
| 224 |
+
counts[tname] = -1 # table missing: schema-version check catches it
|
| 225 |
|
| 226 |
# ββ JSON validity (defense-in-depth; CHECK constraints catch most)
|
| 227 |
findings.extend(self._lint_json_bodies(conn))
|
|
|
|
| 338 |
severity="warning",
|
| 339 |
message=(
|
| 340 |
f"entities table has {ents} rows but FTS5 index "
|
| 341 |
+
f"has {fts}: they should match"
|
| 342 |
),
|
| 343 |
recovery="Rebuild FTS index: client.rebuild_fts() (planned).",
|
| 344 |
detail={"entities": ents, "fts": fts},
|
|
|
|
| 388 |
)
|
| 389 |
|
| 390 |
# ------------------------------------------------------------------
|
| 391 |
+
# Internal. JSON validity probe
|
| 392 |
# ------------------------------------------------------------------
|
| 393 |
def _lint_json_bodies(self, conn: Any) -> list[Finding]:
|
| 394 |
out: list[Finding] = []
|
sibyl-memory-client/src/sibyl_memory_client/storage.py
CHANGED
|
@@ -33,7 +33,7 @@ _SCHEMA_PATH = Path(__file__).parent / "schema.sql"
|
|
| 33 |
# body, not just credentials. docs.sibyllabs.org/memory/install claims 0600
|
| 34 |
# but sqlite3.connect inherits the process umask (typically yields 0644).
|
| 35 |
# Tighten with explicit chmod after the schema apply guarantees the file
|
| 36 |
-
# exists. Idempotent
|
| 37 |
# sidecar files if they exist after the first transaction.
|
| 38 |
_DB_FILE_MODE = 0o600
|
| 39 |
_DB_SIDECAR_SUFFIXES = ("-wal", "-shm")
|
|
@@ -56,7 +56,7 @@ def new_id() -> str:
|
|
| 56 |
|
| 57 |
def dumps(payload: Any) -> str:
|
| 58 |
"""Canonical JSON serialization for body / payload fields.
|
| 59 |
-
sort_keys=False (preserve insertion order
|
| 60 |
separators tight to keep DB rows compact."""
|
| 61 |
return json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
|
| 62 |
|
|
@@ -92,7 +92,7 @@ class Storage:
|
|
| 92 |
manager for proper cleanup.
|
| 93 |
|
| 94 |
SEC-3 hardening (v0.3.3): exception messages do not echo the absolute
|
| 95 |
-
db path
|
| 96 |
but the user-visible message stays generic."""
|
| 97 |
try:
|
| 98 |
conn = sqlite3.connect(
|
|
@@ -148,10 +148,10 @@ class Storage:
|
|
| 148 |
conn.execute("COMMIT")
|
| 149 |
|
| 150 |
def _ensure_schema(self) -> None:
|
| 151 |
-
"""Apply the canonical schema. Idempotent
|
| 152 |
|
| 153 |
After applying the schema, runs any pending migrations. v2 to v3 (2026-05-18)
|
| 154 |
-
is the only migration currently
|
| 155 |
(body duplicated) to external-content (body lives in base tables only).
|
| 156 |
Migration runs once and is idempotent thereafter."""
|
| 157 |
if not _SCHEMA_PATH.exists():
|
|
@@ -180,7 +180,7 @@ class Storage:
|
|
| 180 |
backfills state_documents_fts + journal_events_fts + the new
|
| 181 |
reference_documents_fts shape for existing v2 databases.
|
| 182 |
|
| 183 |
-
Safe to call repeatedly
|
| 184 |
"""
|
| 185 |
with self.connection() as conn:
|
| 186 |
row = conn.execute(
|
|
@@ -192,7 +192,7 @@ class Storage:
|
|
| 192 |
sql = (row["sql"] or "").lower()
|
| 193 |
needs_v3 = "entity_id" in sql or "content='entities'" not in sql.replace(" ", "")
|
| 194 |
if not needs_v3:
|
| 195 |
-
# Already v3 shape
|
| 196 |
return
|
| 197 |
|
| 198 |
# v2 β v3: drop standalone FTS5 + triggers, re-create in external-content
|
|
@@ -220,7 +220,7 @@ class Storage:
|
|
| 220 |
conn.execute("INSERT INTO entities_fts(entities_fts) VALUES('rebuild')")
|
| 221 |
conn.execute("INSERT INTO state_documents_fts(state_documents_fts) VALUES('rebuild')")
|
| 222 |
conn.execute("INSERT INTO reference_documents_fts(reference_documents_fts) VALUES('rebuild')")
|
| 223 |
-
# journal_events_fts is contentless
|
| 224 |
# outside. Backfill manually for any existing journal rows.
|
| 225 |
conn.execute(
|
| 226 |
"""
|
|
@@ -235,7 +235,7 @@ class Storage:
|
|
| 235 |
except sqlite3.Error as e:
|
| 236 |
raise SchemaError(
|
| 237 |
f"FTS5 v2 to v3 migration failed: {e}",
|
| 238 |
-
recovery="Back up your memory.db, then delete it; the next open will create a fresh v3 DB. Your base-table data is unaffected by this migration failure
|
| 239 |
) from e
|
| 240 |
|
| 241 |
def schema_version(self) -> int | None:
|
|
@@ -249,7 +249,7 @@ class Storage:
|
|
| 249 |
def _tighten_db_file_perms(self) -> None:
|
| 250 |
"""Set memory.db (and WAL/SHM sidecars if present) to mode 0600.
|
| 251 |
|
| 252 |
-
Idempotent. Safe on systems where chmod is a no-op (Windows)
|
| 253 |
guard with hasattr. Errors during chmod are non-fatal: we want
|
| 254 |
secure-by-default but won't block a working DB if the chmod call
|
| 255 |
races a concurrent process or hits a read-only mount edge case.
|
|
|
|
| 33 |
# body, not just credentials. docs.sibyllabs.org/memory/install claims 0600
|
| 34 |
# but sqlite3.connect inherits the process umask (typically yields 0644).
|
| 35 |
# Tighten with explicit chmod after the schema apply guarantees the file
|
| 36 |
+
# exists. Idempotent: safe to call every time. Also tightens WAL + SHM
|
| 37 |
# sidecar files if they exist after the first transaction.
|
| 38 |
_DB_FILE_MODE = 0o600
|
| 39 |
_DB_SIDECAR_SUFFIXES = ("-wal", "-shm")
|
|
|
|
| 56 |
|
| 57 |
def dumps(payload: Any) -> str:
|
| 58 |
"""Canonical JSON serialization for body / payload fields.
|
| 59 |
+
sort_keys=False (preserve insertion order: matters for downstream diff).
|
| 60 |
separators tight to keep DB rows compact."""
|
| 61 |
return json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
|
| 62 |
|
|
|
|
| 92 |
manager for proper cleanup.
|
| 93 |
|
| 94 |
SEC-3 hardening (v0.3.3): exception messages do not echo the absolute
|
| 95 |
+
db path: the original exception is chained via `from e` for debugging,
|
| 96 |
but the user-visible message stays generic."""
|
| 97 |
try:
|
| 98 |
conn = sqlite3.connect(
|
|
|
|
| 148 |
conn.execute("COMMIT")
|
| 149 |
|
| 150 |
def _ensure_schema(self) -> None:
|
| 151 |
+
"""Apply the canonical schema. Idempotent: safe to call on every open.
|
| 152 |
|
| 153 |
After applying the schema, runs any pending migrations. v2 to v3 (2026-05-18)
|
| 154 |
+
is the only migration currently: it reshapes FTS5 tables from standalone
|
| 155 |
(body duplicated) to external-content (body lives in base tables only).
|
| 156 |
Migration runs once and is idempotent thereafter."""
|
| 157 |
if not _SCHEMA_PATH.exists():
|
|
|
|
| 180 |
backfills state_documents_fts + journal_events_fts + the new
|
| 181 |
reference_documents_fts shape for existing v2 databases.
|
| 182 |
|
| 183 |
+
Safe to call repeatedly: operations short-circuit once v3 is in place.
|
| 184 |
"""
|
| 185 |
with self.connection() as conn:
|
| 186 |
row = conn.execute(
|
|
|
|
| 192 |
sql = (row["sql"] or "").lower()
|
| 193 |
needs_v3 = "entity_id" in sql or "content='entities'" not in sql.replace(" ", "")
|
| 194 |
if not needs_v3:
|
| 195 |
+
# Already v3 shape: nothing to do.
|
| 196 |
return
|
| 197 |
|
| 198 |
# v2 β v3: drop standalone FTS5 + triggers, re-create in external-content
|
|
|
|
| 220 |
conn.execute("INSERT INTO entities_fts(entities_fts) VALUES('rebuild')")
|
| 221 |
conn.execute("INSERT INTO state_documents_fts(state_documents_fts) VALUES('rebuild')")
|
| 222 |
conn.execute("INSERT INTO reference_documents_fts(reference_documents_fts) VALUES('rebuild')")
|
| 223 |
+
# journal_events_fts is contentless: can't 'rebuild' from
|
| 224 |
# outside. Backfill manually for any existing journal rows.
|
| 225 |
conn.execute(
|
| 226 |
"""
|
|
|
|
| 235 |
except sqlite3.Error as e:
|
| 236 |
raise SchemaError(
|
| 237 |
f"FTS5 v2 to v3 migration failed: {e}",
|
| 238 |
+
recovery="Back up your memory.db, then delete it; the next open will create a fresh v3 DB. Your base-table data is unaffected by this migration failure: the FTS5 index will rebuild.",
|
| 239 |
) from e
|
| 240 |
|
| 241 |
def schema_version(self) -> int | None:
|
|
|
|
| 249 |
def _tighten_db_file_perms(self) -> None:
|
| 250 |
"""Set memory.db (and WAL/SHM sidecars if present) to mode 0600.
|
| 251 |
|
| 252 |
+
Idempotent. Safe on systems where chmod is a no-op (Windows): we
|
| 253 |
guard with hasattr. Errors during chmod are non-fatal: we want
|
| 254 |
secure-by-default but won't block a working DB if the chmod call
|
| 255 |
races a concurrent process or hits a read-only mount edge case.
|
sibyl-memory-client/tests/test_capcheck.py
CHANGED
|
@@ -23,7 +23,7 @@ from sibyl_memory_client import (
|
|
| 23 |
|
| 24 |
|
| 25 |
# ----------------------------------------------------------------------
|
| 26 |
-
# Fake check-write transport
|
| 27 |
# hitting the network
|
| 28 |
# ----------------------------------------------------------------------
|
| 29 |
|
|
@@ -105,7 +105,7 @@ def test_at_cap_server_upgrades_user(tmp_path: Path) -> None:
|
|
| 105 |
check_fn=server,
|
| 106 |
)
|
| 107 |
gate.check(proposed_delta_bytes=500)
|
| 108 |
-
# No exception
|
| 109 |
# Verify cache was updated
|
| 110 |
cached = cache.load()
|
| 111 |
assert cached is not None
|
|
@@ -127,7 +127,7 @@ def test_paid_cache_skips_server(tmp_path: Path) -> None:
|
|
| 127 |
gate = CapGate(
|
| 128 |
account_id="acc-1",
|
| 129 |
session_token="sess-1",
|
| 130 |
-
db_size_fn=lambda: 100 * 1024 * 1024, # 100 MB
|
| 131 |
local_tier_hint="free",
|
| 132 |
cache=cache,
|
| 133 |
check_fn=server,
|
|
@@ -178,7 +178,7 @@ def test_offline_at_cap_with_recent_paid_cache(tmp_path: Path) -> None:
|
|
| 178 |
cache=cache,
|
| 179 |
check_fn=server,
|
| 180 |
)
|
| 181 |
-
# No exception
|
| 182 |
gate.check(proposed_delta_bytes=10_000)
|
| 183 |
|
| 184 |
|
|
@@ -263,7 +263,7 @@ def test_e2e_free_tier_blocked_at_cap(tmp_path: Path) -> None:
|
|
| 263 |
cap_gate=gate,
|
| 264 |
)
|
| 265 |
|
| 266 |
-
# Under the cap
|
| 267 |
client.set_entity("project", "atlas", {"status": "active"})
|
| 268 |
|
| 269 |
# Simulate being near the cap
|
|
@@ -283,7 +283,7 @@ def test_e2e_paid_tier_no_cap(tmp_path: Path) -> None:
|
|
| 283 |
cache = TierCache(tmp_path / "tc.json")
|
| 284 |
db_path = tmp_path / "memory.db"
|
| 285 |
|
| 286 |
-
fake_size = [50 * 1024 * 1024] # 50 MB
|
| 287 |
|
| 288 |
from sibyl_memory_client._capcheck import CapGate
|
| 289 |
gate = CapGate(
|
|
|
|
| 23 |
|
| 24 |
|
| 25 |
# ----------------------------------------------------------------------
|
| 26 |
+
# Fake check-write transport: lets us simulate server responses without
|
| 27 |
# hitting the network
|
| 28 |
# ----------------------------------------------------------------------
|
| 29 |
|
|
|
|
| 105 |
check_fn=server,
|
| 106 |
)
|
| 107 |
gate.check(proposed_delta_bytes=500)
|
| 108 |
+
# No exception: server told us we're paid
|
| 109 |
# Verify cache was updated
|
| 110 |
cached = cache.load()
|
| 111 |
assert cached is not None
|
|
|
|
| 127 |
gate = CapGate(
|
| 128 |
account_id="acc-1",
|
| 129 |
session_token="sess-1",
|
| 130 |
+
db_size_fn=lambda: 100 * 1024 * 1024, # 100 MB: way past free cap
|
| 131 |
local_tier_hint="free",
|
| 132 |
cache=cache,
|
| 133 |
check_fn=server,
|
|
|
|
| 178 |
cache=cache,
|
| 179 |
check_fn=server,
|
| 180 |
)
|
| 181 |
+
# No exception: cache is fresh and says paid
|
| 182 |
gate.check(proposed_delta_bytes=10_000)
|
| 183 |
|
| 184 |
|
|
|
|
| 263 |
cap_gate=gate,
|
| 264 |
)
|
| 265 |
|
| 266 |
+
# Under the cap: works fine
|
| 267 |
client.set_entity("project", "atlas", {"status": "active"})
|
| 268 |
|
| 269 |
# Simulate being near the cap
|
|
|
|
| 283 |
cache = TierCache(tmp_path / "tc.json")
|
| 284 |
db_path = tmp_path / "memory.db"
|
| 285 |
|
| 286 |
+
fake_size = [50 * 1024 * 1024] # 50 MB: way past free cap
|
| 287 |
|
| 288 |
from sibyl_memory_client._capcheck import CapGate
|
| 289 |
gate = CapGate(
|
sibyl-memory-client/tests/test_kappa_fixes.py
CHANGED
|
@@ -27,7 +27,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
|
| 27 |
|
| 28 |
|
| 29 |
# ----------------------------------------------------------------------
|
| 30 |
-
# BLOCKER
|
| 31 |
# ----------------------------------------------------------------------
|
| 32 |
|
| 33 |
def test_cap_exceeded_error_importable_from_exceptions_submodule():
|
|
@@ -66,7 +66,7 @@ def test_capcheck_backwards_compat_reexports():
|
|
| 66 |
|
| 67 |
def test_top_level_package_still_exports_both():
|
| 68 |
"""The top-level `from sibyl_memory_client import CapExceededError` path
|
| 69 |
-
that already worked in v0.3.3 must still work
|
| 70 |
main public surface."""
|
| 71 |
from sibyl_memory_client import CapExceededError, TierVerificationError
|
| 72 |
assert CapExceededError.__name__ == "CapExceededError"
|
|
@@ -74,7 +74,7 @@ def test_top_level_package_still_exports_both():
|
|
| 74 |
|
| 75 |
|
| 76 |
# ----------------------------------------------------------------------
|
| 77 |
-
# RED
|
| 78 |
# ----------------------------------------------------------------------
|
| 79 |
|
| 80 |
@pytest.mark.skipif(not hasattr(os, "chmod"), reason="POSIX-only test")
|
|
@@ -107,7 +107,7 @@ def test_memory_db_wal_sidecar_perms_tighten_when_present(tmp_path):
|
|
| 107 |
|
| 108 |
|
| 109 |
# ----------------------------------------------------------------------
|
| 110 |
-
# YELLOW
|
| 111 |
# ----------------------------------------------------------------------
|
| 112 |
|
| 113 |
def test_validate_identifier_rejects_empty():
|
|
@@ -159,7 +159,7 @@ def test_validate_identifier_accepts_reasonable():
|
|
| 159 |
|
| 160 |
|
| 161 |
# ----------------------------------------------------------------------
|
| 162 |
-
# YELLOW
|
| 163 |
# ----------------------------------------------------------------------
|
| 164 |
|
| 165 |
def test_set_entity_rejects_empty_name(tmp_path):
|
|
@@ -195,7 +195,7 @@ def test_set_reference_rejects_empty_key(tmp_path):
|
|
| 195 |
|
| 196 |
|
| 197 |
def test_read_paths_unaffected_by_validation(tmp_path):
|
| 198 |
-
"""Read paths (get_entity, get_state, get_reference) must NOT validate
|
| 199 |
users with already-stored bad identifiers should still be able to read
|
| 200 |
and migrate them.
|
| 201 |
|
|
@@ -205,7 +205,7 @@ def test_read_paths_unaffected_by_validation(tmp_path):
|
|
| 205 |
from sibyl_memory_client import MemoryClient
|
| 206 |
from sibyl_memory_client.exceptions import NotFoundError
|
| 207 |
client = MemoryClient.local(tmp_path / "memory.db")
|
| 208 |
-
# Read on bad identifier should be NotFound, not ValidationError
|
| 209 |
# we don't gate reads. (NB: passing through SQLite, which handles it.)
|
| 210 |
with pytest.raises(NotFoundError):
|
| 211 |
client.get_entity("project", "nonexistent-but-validly-named")
|
|
@@ -214,7 +214,7 @@ def test_read_paths_unaffected_by_validation(tmp_path):
|
|
| 214 |
|
| 215 |
|
| 216 |
# ----------------------------------------------------------------------
|
| 217 |
-
# YELLOW
|
| 218 |
# ----------------------------------------------------------------------
|
| 219 |
|
| 220 |
def test_classify_fts5_error_schema_missing_returns_none():
|
|
@@ -250,12 +250,12 @@ def test_classify_fts5_error_other_returns_storage_error():
|
|
| 250 |
|
| 251 |
|
| 252 |
def test_search_with_valid_query_does_not_raise(tmp_path):
|
| 253 |
-
"""Normal queries should still work
|
| 254 |
from sibyl_memory_client import MemoryClient
|
| 255 |
client = MemoryClient.local(tmp_path / "memory.db")
|
| 256 |
client.set_entity("project", "atlas", {"description": "alpha bravo charlie"})
|
| 257 |
client.set_entity("project", "babel", {"description": "delta echo foxtrot"})
|
| 258 |
-
# Plain text query
|
| 259 |
hits = client.search("alpha")
|
| 260 |
assert len(hits) >= 1
|
| 261 |
# Empty query short-circuits to []
|
|
@@ -267,7 +267,7 @@ def test_search_with_valid_query_does_not_raise(tmp_path):
|
|
| 267 |
def test_search_entities_phrase_match_semantics(tmp_path):
|
| 268 |
"""Document the actual phrase-match behavior so KAPPA's confusion
|
| 269 |
(queries containing AND/OR/* return zero hits) is verified expected.
|
| 270 |
-
These queries get wrapped as phrases
|
| 271 |
of the phrase text in entity bodies."""
|
| 272 |
from sibyl_memory_client import MemoryClient
|
| 273 |
client = MemoryClient.local(tmp_path / "memory.db")
|
|
@@ -275,7 +275,7 @@ def test_search_entities_phrase_match_semantics(tmp_path):
|
|
| 275 |
# "alpha bravo" should match because the body contains that exact phrase
|
| 276 |
hits = client.search_entities("alpha bravo")
|
| 277 |
assert len(hits) == 1
|
| 278 |
-
# "AND" is a literal here
|
| 279 |
hits = client.search_entities("AND OR NOT")
|
| 280 |
assert hits == []
|
| 281 |
# "*" is wrapped as a literal phrase
|
|
|
|
| 27 |
|
| 28 |
|
| 29 |
# ----------------------------------------------------------------------
|
| 30 |
+
# BLOCKER: submodule exception path
|
| 31 |
# ----------------------------------------------------------------------
|
| 32 |
|
| 33 |
def test_cap_exceeded_error_importable_from_exceptions_submodule():
|
|
|
|
| 66 |
|
| 67 |
def test_top_level_package_still_exports_both():
|
| 68 |
"""The top-level `from sibyl_memory_client import CapExceededError` path
|
| 69 |
+
that already worked in v0.3.3 must still work: no regression on the
|
| 70 |
main public surface."""
|
| 71 |
from sibyl_memory_client import CapExceededError, TierVerificationError
|
| 72 |
assert CapExceededError.__name__ == "CapExceededError"
|
|
|
|
| 74 |
|
| 75 |
|
| 76 |
# ----------------------------------------------------------------------
|
| 77 |
+
# RED: memory.db file perms
|
| 78 |
# ----------------------------------------------------------------------
|
| 79 |
|
| 80 |
@pytest.mark.skipif(not hasattr(os, "chmod"), reason="POSIX-only test")
|
|
|
|
| 107 |
|
| 108 |
|
| 109 |
# ----------------------------------------------------------------------
|
| 110 |
+
# YELLOW: validate_identifier
|
| 111 |
# ----------------------------------------------------------------------
|
| 112 |
|
| 113 |
def test_validate_identifier_rejects_empty():
|
|
|
|
| 159 |
|
| 160 |
|
| 161 |
# ----------------------------------------------------------------------
|
| 162 |
+
# YELLOW: write paths call validate_identifier
|
| 163 |
# ----------------------------------------------------------------------
|
| 164 |
|
| 165 |
def test_set_entity_rejects_empty_name(tmp_path):
|
|
|
|
| 195 |
|
| 196 |
|
| 197 |
def test_read_paths_unaffected_by_validation(tmp_path):
|
| 198 |
+
"""Read paths (get_entity, get_state, get_reference) must NOT validate -
|
| 199 |
users with already-stored bad identifiers should still be able to read
|
| 200 |
and migrate them.
|
| 201 |
|
|
|
|
| 205 |
from sibyl_memory_client import MemoryClient
|
| 206 |
from sibyl_memory_client.exceptions import NotFoundError
|
| 207 |
client = MemoryClient.local(tmp_path / "memory.db")
|
| 208 |
+
# Read on bad identifier should be NotFound, not ValidationError -
|
| 209 |
# we don't gate reads. (NB: passing through SQLite, which handles it.)
|
| 210 |
with pytest.raises(NotFoundError):
|
| 211 |
client.get_entity("project", "nonexistent-but-validly-named")
|
|
|
|
| 214 |
|
| 215 |
|
| 216 |
# ----------------------------------------------------------------------
|
| 217 |
+
# YELLOW. FTS5 error classifier
|
| 218 |
# ----------------------------------------------------------------------
|
| 219 |
|
| 220 |
def test_classify_fts5_error_schema_missing_returns_none():
|
|
|
|
| 250 |
|
| 251 |
|
| 252 |
def test_search_with_valid_query_does_not_raise(tmp_path):
|
| 253 |
+
"""Normal queries should still work: no false-positive ValidationError."""
|
| 254 |
from sibyl_memory_client import MemoryClient
|
| 255 |
client = MemoryClient.local(tmp_path / "memory.db")
|
| 256 |
client.set_entity("project", "atlas", {"description": "alpha bravo charlie"})
|
| 257 |
client.set_entity("project", "babel", {"description": "delta echo foxtrot"})
|
| 258 |
+
# Plain text query: should not raise, returns matching results
|
| 259 |
hits = client.search("alpha")
|
| 260 |
assert len(hits) >= 1
|
| 261 |
# Empty query short-circuits to []
|
|
|
|
| 267 |
def test_search_entities_phrase_match_semantics(tmp_path):
|
| 268 |
"""Document the actual phrase-match behavior so KAPPA's confusion
|
| 269 |
(queries containing AND/OR/* return zero hits) is verified expected.
|
| 270 |
+
These queries get wrapped as phrases: they only match literal occurrences
|
| 271 |
of the phrase text in entity bodies."""
|
| 272 |
from sibyl_memory_client import MemoryClient
|
| 273 |
client = MemoryClient.local(tmp_path / "memory.db")
|
|
|
|
| 275 |
# "alpha bravo" should match because the body contains that exact phrase
|
| 276 |
hits = client.search_entities("alpha bravo")
|
| 277 |
assert len(hits) == 1
|
| 278 |
+
# "AND" is a literal here: no entity body contains "AND"
|
| 279 |
hits = client.search_entities("AND OR NOT")
|
| 280 |
assert hits == []
|
| 281 |
# "*" is wrapped as a literal phrase
|
sibyl-memory-client/tests/test_learning.py
CHANGED
|
@@ -151,7 +151,7 @@ def test_double_accept_raises(client: MemoryClient) -> None:
|
|
| 151 |
|
| 152 |
|
| 153 |
# ----------------------------------------------------------------------
|
| 154 |
-
# Custom summarizer plumbing
|
| 155 |
# ----------------------------------------------------------------------
|
| 156 |
def test_byok_summarizer_invokes_inference_fn(client: MemoryClient) -> None:
|
| 157 |
captured = {}
|
|
@@ -215,7 +215,7 @@ def test_learner_is_tenant_scoped(tmp_path: Path) -> None:
|
|
| 215 |
|
| 216 |
|
| 217 |
# ----------------------------------------------------------------------
|
| 218 |
-
# Tier gating
|
| 219 |
# ----------------------------------------------------------------------
|
| 220 |
def test_free_tier_cannot_learn(tmp_path: Path) -> None:
|
| 221 |
from sibyl_memory_client import TierGateError
|
|
@@ -234,7 +234,7 @@ def test_free_tier_cannot_list_proposals(tmp_path: Path) -> None:
|
|
| 234 |
|
| 235 |
|
| 236 |
def test_free_tier_can_still_use_core_memory(tmp_path: Path) -> None:
|
| 237 |
-
"""Free-tier users get the full memory SDK
|
| 238 |
This is the upgrade-pressure design: free tier is fully functional storage
|
| 239 |
+ retrieval, paid tier adds the intelligence layer."""
|
| 240 |
free = MemoryClient.local(str(tmp_path / "free.db"))
|
|
|
|
| 151 |
|
| 152 |
|
| 153 |
# ----------------------------------------------------------------------
|
| 154 |
+
# Custom summarizer plumbing. BYOK + Venice/x402 stubs
|
| 155 |
# ----------------------------------------------------------------------
|
| 156 |
def test_byok_summarizer_invokes_inference_fn(client: MemoryClient) -> None:
|
| 157 |
captured = {}
|
|
|
|
| 215 |
|
| 216 |
|
| 217 |
# ----------------------------------------------------------------------
|
| 218 |
+
# Tier gating: free tier blocked from self-learning
|
| 219 |
# ----------------------------------------------------------------------
|
| 220 |
def test_free_tier_cannot_learn(tmp_path: Path) -> None:
|
| 221 |
from sibyl_memory_client import TierGateError
|
|
|
|
| 234 |
|
| 235 |
|
| 236 |
def test_free_tier_can_still_use_core_memory(tmp_path: Path) -> None:
|
| 237 |
+
"""Free-tier users get the full memory SDK: only learning/lint are gated.
|
| 238 |
This is the upgrade-pressure design: free tier is fully functional storage
|
| 239 |
+ retrieval, paid tier adds the intelligence layer."""
|
| 240 |
free = MemoryClient.local(str(tmp_path / "free.db"))
|
sibyl-memory-client/tests/test_lint.py
CHANGED
|
@@ -104,7 +104,7 @@ def test_soft_cap_critical_threshold(client: MemoryClient) -> None:
|
|
| 104 |
# Write enough rows to push the DB well above any tiny cap we set.
|
| 105 |
for i in range(20):
|
| 106 |
client.set_entity("project", f"p{i}", {"i": i, "payload": "x" * 200})
|
| 107 |
-
# Run with a 2 KB cap
|
| 108 |
report = client.lint(soft_cap_bytes=2 * 1024)
|
| 109 |
matches = [f for f in report.findings if f.check == "db-soft-cap"]
|
| 110 |
assert matches, f"expected db-soft-cap finding; got {[f.check for f in report.findings]}"
|
|
@@ -153,7 +153,7 @@ def test_lint_is_tenant_scoped(tmp_path: Path) -> None:
|
|
| 153 |
|
| 154 |
|
| 155 |
# ----------------------------------------------------------------------
|
| 156 |
-
# Tier gating
|
| 157 |
# ----------------------------------------------------------------------
|
| 158 |
def test_free_tier_cannot_lint(tmp_path: Path) -> None:
|
| 159 |
from sibyl_memory_client import TierGateError
|
|
|
|
| 104 |
# Write enough rows to push the DB well above any tiny cap we set.
|
| 105 |
for i in range(20):
|
| 106 |
client.set_entity("project", f"p{i}", {"i": i, "payload": "x" * 200})
|
| 107 |
+
# Run with a 2 KB cap: well below the actual DB size after writes
|
| 108 |
report = client.lint(soft_cap_bytes=2 * 1024)
|
| 109 |
matches = [f for f in report.findings if f.check == "db-soft-cap"]
|
| 110 |
assert matches, f"expected db-soft-cap finding; got {[f.check for f in report.findings]}"
|
|
|
|
| 153 |
|
| 154 |
|
| 155 |
# ----------------------------------------------------------------------
|
| 156 |
+
# Tier gating: free tier blocked, paid tier allowed
|
| 157 |
# ----------------------------------------------------------------------
|
| 158 |
def test_free_tier_cannot_lint(tmp_path: Path) -> None:
|
| 159 |
from sibyl_memory_client import TierGateError
|
sibyl-memory-hermes/CHANGELOG.md
CHANGED
|
@@ -4,7 +4,7 @@ All notable changes to `sibyl-memory-hermes` are recorded here. Format
|
|
| 4 |
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning
|
| 5 |
follows [SemVer](https://semver.org/).
|
| 6 |
|
| 7 |
-
## [0.3.4]
|
| 8 |
|
| 9 |
Branding pass on the vendored banner. Matches the change shipped in
|
| 10 |
`sibyl-memory-cli` v0.3.2 in the same session. Operator directive:
|
|
@@ -21,10 +21,10 @@ Agentic Infrastructure and Memory Products' or something similar."
|
|
| 21 |
on both `install-plugin` and `uninstall-plugin` since both commands
|
| 22 |
print the banner before their section header.
|
| 23 |
|
| 24 |
-
## [0.3.3]
|
| 25 |
|
| 26 |
Visual identity pass on the `install-plugin` and `uninstall-plugin`
|
| 27 |
-
commands. Operator directive: "typical app patterns
|
| 28 |
install window and initial setup, light on dashboards etc." The
|
| 29 |
install-plugin command is THE second-most-ceremonial moment a user has
|
| 30 |
with SIBYL (after `sibyl init`), so it gets the full SIBYL banner +
|
|
@@ -55,7 +55,7 @@ sectioned numbered onboarding menu treatment.
|
|
| 55 |
- Plain-text fallback preserves structure (still readable in dumb
|
| 56 |
terminals or pipes).
|
| 57 |
|
| 58 |
-
## [0.3.2]
|
| 59 |
|
| 60 |
KAPPA external-tester remediation release. Family-wide alignment with the
|
| 61 |
v0.4.0 client (KAPPA-attributed fixes: exception export path, db file
|
|
@@ -77,7 +77,7 @@ code changes in this release.
|
|
| 77 |
|
| 78 |
---
|
| 79 |
|
| 80 |
-
## [0.3.1]
|
| 81 |
|
| 82 |
Audit-remediation release. v0.3.0 pre-ship audit (2026-05-18T05:05Z)
|
| 83 |
surfaced 10 critical findings across four lanes. This release lands the
|
|
@@ -87,7 +87,7 @@ Hermes-side fixes. Companion releases: `sibyl-memory-client` v0.3.3 (engine
|
|
| 87 |
|
| 88 |
### Added
|
| 89 |
|
| 90 |
-
- `tests/test_adapter.py`
|
| 91 |
adapter. Validates: module imports cleanly off-Hermes (guarded ABC
|
| 92 |
import + tool_error fallback), all 4 tool schemas resolve, end-to-end
|
| 93 |
remember+recall round-trips through `handle_tool_call`, list filtering,
|
|
@@ -98,13 +98,13 @@ Hermes-side fixes. Companion releases: `sibyl-memory-client` v0.3.3 (engine
|
|
| 98 |
|
| 99 |
### Changed
|
| 100 |
|
| 101 |
-
- **`SibylMemoryProvider.search()` now spans all four tiers**
|
| 102 |
state + reference + journal. Returns tier-tagged hits (`{tier, key,
|
| 103 |
category, body, snippet, rank, ts}`). The marketing claim of "FTS5
|
| 104 |
across all tiers" is now true. Caller can restrict scope with
|
| 105 |
`tiers=("entity",)` for the pre-v0.3.1 behavior. Backed by the new
|
| 106 |
`MemoryClient.search()` in client v0.3.3.
|
| 107 |
-
- `_hermes_plugin/adapter.py`
|
| 108 |
with try/except. The bundled module imports cleanly off-Hermes with
|
| 109 |
no-op fallbacks. Audit P1.
|
| 110 |
- `SibylAdapter.sync_turn` retries on transient failure (SQLITE_BUSY etc.)
|
|
@@ -121,7 +121,7 @@ Hermes-side fixes. Companion releases: `sibyl-memory-client` v0.3.3 (engine
|
|
| 121 |
(`_DEFAULT_SEARCH_LIMIT=10`, `_DEFAULT_LIST_LIMIT=50`). Audit O1.
|
| 122 |
- `RECALL_SCHEMA` description documents the row-wrapper return shape
|
| 123 |
explicitly. `SEARCH_SCHEMA` updated for cross-tier coverage. Audit H2.
|
| 124 |
-
- `provider.py`
|
| 125 |
`set_reference`, `get_reference` docstrings now include explicit
|
| 126 |
`Raises:` sections and document return-shape asymmetry per tier.
|
| 127 |
Audit H2/H3.
|
|
@@ -129,31 +129,31 @@ Hermes-side fixes. Companion releases: `sibyl-memory-client` v0.3.3 (engine
|
|
| 129 |
|
| 130 |
### Security
|
| 131 |
|
| 132 |
-
- **SEC-2**
|
| 133 |
with mode 0o600 set at creation via `os.open(O_WRONLY|O_CREAT|O_EXCL|
|
| 134 |
O_NOFOLLOW, 0o600)`. No more world-readable window between `write_text()`
|
| 135 |
and `os.chmod()` syscalls.
|
| 136 |
-
- **SEC-5**
|
| 137 |
`shutil.rmtree` any directory that doesn't contain a recognized prior
|
| 138 |
Sibyl install (`plugin.yaml` with `name: sibyl` in the first 10 lines).
|
| 139 |
Prevents destruction of arbitrary user-writable trees from misconfigured
|
| 140 |
HERMES_HOME. Both commands also refuse symlinked destinations.
|
| 141 |
-
- **SEC-11**
|
| 142 |
`is_symlink()` BEFORE `resolve()`.
|
| 143 |
-
- **SEC-10**
|
| 144 |
exception class name.
|
| 145 |
|
| 146 |
### Fixed
|
| 147 |
|
| 148 |
-
- **H7**
|
| 149 |
Always returns `False` (unchanged behavior). Slated for removal in v0.4.
|
| 150 |
-
- `test_smoke.py`
|
| 151 |
hermes_bound assertion tightened from `isinstance(..., bool)` to
|
| 152 |
`is False` (audit T3).
|
| 153 |
-
- README quickstart rewritten
|
| 154 |
`Agent(memory=SibylMemoryProvider())` pattern (audit C5). Replaced
|
| 155 |
with the real flow: `pip install` β `install-plugin` β config.yaml.
|
| 156 |
-
- README "Hermes contract" section rewritten
|
| 157 |
that `SibylMemoryProvider` inherits Hermes' ABC at import time.
|
| 158 |
|
| 159 |
### Dependencies
|
|
@@ -170,10 +170,10 @@ sibyl-memory-hermes install-plugin --force
|
|
| 170 |
```
|
| 171 |
|
| 172 |
The local SQLite schema auto-migrates from v2 to v3 on first open after
|
| 173 |
-
upgrade. No application data is lost
|
| 174 |
tables. ~50ms per 10k entities on first open, idempotent thereafter.
|
| 175 |
|
| 176 |
-
## [0.3.0]
|
| 177 |
|
| 178 |
Real Hermes plugin landing. v0.2.x was structurally incompatible with
|
| 179 |
Hermes' actual `MemoryProvider` ABC (wrong soft-bind import path, missing
|
|
@@ -187,37 +187,37 @@ and validated via Hermes' own `load_memory_provider('sibyl')` loader.
|
|
| 187 |
### Architecture shift
|
| 188 |
|
| 189 |
- **Split into SDK + adapter.** `SibylMemoryProvider` is now a pure SDK
|
| 190 |
-
class
|
| 191 |
All Hermes contract code lives in the bundled adapter at
|
| 192 |
`_hermes_plugin/adapter.py`, copied to `$HERMES_HOME/plugins/sibyl/` by
|
| 193 |
the new `sibyl-memory-hermes install-plugin` console script.
|
| 194 |
- **Hermes uses filesystem discovery, NOT pip entry points.** Verified
|
| 195 |
-
against `plugins/memory/__init__.py` source
|
| 196 |
`importlib.metadata.entry_points()` call anywhere in Hermes' loader.
|
| 197 |
`pip install sibyl-memory-hermes` is necessary but not sufficient; the
|
| 198 |
install-plugin script bridges the gap.
|
| 199 |
|
| 200 |
### Added
|
| 201 |
|
| 202 |
-
- **`_hermes_plugin/adapter.py`**
|
| 203 |
- 4 tools exposed: `sibyl_remember`, `sibyl_recall`, `sibyl_search`,
|
| 204 |
`sibyl_list`.
|
| 205 |
- Mandatory methods: `name`, `is_available`, `initialize`,
|
| 206 |
`get_tool_schemas`, `handle_tool_call`.
|
| 207 |
- Recommended overrides: `system_prompt_block` (model-facing tool list),
|
| 208 |
`prefetch` (FTS5 + load_context block, with noise filter),
|
| 209 |
-
`queue_prefetch` (no-op
|
| 210 |
(daemon-threaded per byterover pattern, 5s join + 10s shutdown).
|
| 211 |
- Optional hooks: `on_session_switch`, `on_pre_compress`
|
| 212 |
(paired user+assistant flush), `on_delegation`, `on_memory_write`
|
| 213 |
-
(accepts `metadata=None` kwarg
|
| 214 |
- Defensive: `agent_context != 'primary'` guard in sync_turn so cron /
|
| 215 |
subagent runs don't corrupt the user's representation.
|
| 216 |
- `_stable_key()` uses blake2b for deterministic content addressing,
|
| 217 |
so add+remove on the same content actually targets the same entity.
|
| 218 |
- Validated end-to-end via `load_memory_provider('sibyl')` dry-run + all
|
| 219 |
4 tool schemas resolved in OpenAI function-calling format.
|
| 220 |
-
- **`_hermes_plugin/plugin.yaml`**
|
| 221 |
(name, description, version, homepage).
|
| 222 |
- **`sibyl_memory_hermes.install_plugin`** + console script
|
| 223 |
`sibyl-memory-hermes install-plugin`:
|
|
@@ -230,7 +230,7 @@ and validated via Hermes' own `load_memory_provider('sibyl')` loader.
|
|
| 230 |
- Flags: `--hermes-home <path>`, `--force`, `--dry-run`.
|
| 231 |
- **`sibyl-memory-hermes uninstall-plugin`** counterpart for clean removal.
|
| 232 |
|
| 233 |
-
### Changed (breaking, but no users were affected
|
| 234 |
|
| 235 |
- **`SibylMemoryProvider` no longer subclasses `MemoryProvider`.** The
|
| 236 |
conditional soft-bind in v0.2.x always failed (wrong import path); the
|
|
@@ -239,7 +239,7 @@ and validated via Hermes' own `load_memory_provider('sibyl')` loader.
|
|
| 239 |
property and `health()` field are kept for backwards compatibility but
|
| 240 |
always return False; they're deprecated for removal in a future major.
|
| 241 |
- **`__init__.py` docstring rewritten.** The fictional `from hermes_agent
|
| 242 |
-
import Agent; Agent(memory=SibylMemoryProvider())` quickstart is gone
|
| 243 |
that API never existed in any Hermes release. Replaced with the real
|
| 244 |
install flow (`pip install` β `install-plugin` β config.yaml edit).
|
| 245 |
- **`__version__` is now single-sourced** from `importlib.metadata.version(
|
|
@@ -249,15 +249,15 @@ and validated via Hermes' own `load_memory_provider('sibyl')` loader.
|
|
| 249 |
### Fixed
|
| 250 |
|
| 251 |
- `provider.py:57` no longer attempts `from hermes_agent.memory import
|
| 252 |
-
MemoryProvider`. That import path does not exist in hermes-agent
|
| 253 |
real module is `agent.memory_provider`. Removed entirely; the SDK class
|
| 254 |
doesn't inherit from the ABC anymore (see Architecture shift above).
|
| 255 |
|
| 256 |
### Dependencies
|
| 257 |
|
| 258 |
-
- `sibyl-memory-client>=0.3.2` (was `>=0.3.1`
|
| 259 |
HTTPError fixes from yesterday's audit pass).
|
| 260 |
-
- Optional: `hermes-agent>=0.13.0` (was `>=0.10.0`
|
| 261 |
for v0.13 specifically; older releases may work but aren't validated).
|
| 262 |
|
| 263 |
### How to upgrade from v0.2.x
|
|
@@ -270,7 +270,7 @@ hermes # picks up the new tools
|
|
| 270 |
```
|
| 271 |
|
| 272 |
If you were importing `from hermes_agent import Agent` (per the old
|
| 273 |
-
docstring), that never worked
|
| 274 |
`SibylMemoryProvider()` directly from a non-Hermes Python orchestration,
|
| 275 |
no changes needed; the SDK surface is unchanged.
|
| 276 |
|
|
@@ -282,27 +282,27 @@ no changes needed; the SDK surface is unchanged.
|
|
| 282 |
- File-bundle validated: `importlib.resources.files('sibyl_memory_hermes.
|
| 283 |
_hermes_plugin').joinpath('adapter.py').read_bytes()` returns the
|
| 284 |
expected 18,118-byte payload.
|
| 285 |
-
- install-plugin smoke-tested against a `/tmp/fake_hermes_home` target
|
| 286 |
files land at `$HERMES_HOME/plugins/sibyl/{__init__.py, plugin.yaml}`.
|
| 287 |
|
| 288 |
### Authorship
|
| 289 |
|
| 290 |
Developed by SIBYL, Sibyl Labs LLC. Adapter contract derived from the
|
| 291 |
-
installed hermes-agent 0.13.0 wheel source
|
| 292 |
(the ABC) and `plugins/memory/byterover/` (the idiomatic threading +
|
| 293 |
schema pattern). MIT licensed.
|
| 294 |
|
| 295 |
-
## [0.2.2]
|
| 296 |
|
| 297 |
Audit-remediation release. Companion to `sibyl-memory-client` v0.3.2.
|
| 298 |
|
| 299 |
### Changed
|
| 300 |
|
| 301 |
-
- **T2-2
|
| 302 |
`NotFoundError` only**. Previously caught bare `Exception`, which
|
| 303 |
swallowed `StorageError` / `TenantError` / `SchemaError` and returned
|
| 304 |
`None` (the Hermes-style soft-miss). That masked real storage failures
|
| 305 |
-
end-to-end
|
| 306 |
production Bug 2 on the server side. Now `NotFoundError` returns
|
| 307 |
`None` as intended; every other exception propagates so the caller
|
| 308 |
can surface or retry.
|
|
@@ -318,7 +318,7 @@ Audit-remediation release. Companion to `sibyl-memory-client` v0.3.2.
|
|
| 318 |
`_check_fn` raise-on-HTTPError fix (T2-3). v0.3.1 still works; the
|
| 319 |
changes are independent.
|
| 320 |
|
| 321 |
-
## [0.2.1]
|
| 322 |
|
| 323 |
HMAC signed-credentials plumbing. Companion to `sibyl-memory-client`
|
| 324 |
v0.3.1 and the api-sibyllabs credential-signer release.
|
|
@@ -326,7 +326,7 @@ v0.3.1 and the api-sibyllabs credential-signer release.
|
|
| 326 |
### Changed
|
| 327 |
|
| 328 |
- `Credentials` dataclass gained `signature: str | None = None` and
|
| 329 |
-
`signed_at: str | None = None` fields. Backwards compatible
|
| 330 |
schema v1 credentials still load with these fields as `None`.
|
| 331 |
- `SibylMemoryProvider.__init__` reads the signature + canonical
|
| 332 |
claim from credentials.json and passes them through to
|
|
@@ -344,7 +344,7 @@ database.
|
|
| 344 |
|
| 345 |
- 21/21 unchanged, all green.
|
| 346 |
|
| 347 |
-
## [0.2.0]
|
| 348 |
|
| 349 |
Hard-cap plumbing release. Companion to `sibyl-memory-client` v0.3.0.
|
| 350 |
|
|
@@ -358,29 +358,29 @@ Hard-cap plumbing release. Companion to `sibyl-memory-client` v0.3.0.
|
|
| 358 |
2 MB cap with no server-check fallback.
|
| 359 |
- `Credentials` dataclass gained an optional `session_token` field
|
| 360 |
(the long-lived bearer issued by the activation flow). Backwards
|
| 361 |
-
compatible
|
| 362 |
`session_token` simply lands as `None`.
|
| 363 |
|
| 364 |
### Notes
|
| 365 |
|
| 366 |
- Depends on `sibyl-memory-client>=0.3.0`. Earlier clients lack the
|
| 367 |
cap-gate plumbing.
|
| 368 |
-
- Pre-activation users (no credentials.json) still work
|
| 369 |
the strict local 2 MB cap with no upgrade path until they run
|
| 370 |
`sibyl init`.
|
| 371 |
|
| 372 |
-
## [0.1.1]
|
| 373 |
|
| 374 |
Patch: stripped placeholder GitHub URLs from pyproject metadata
|
| 375 |
(operator scar: never write a link to a domain not verified to
|
| 376 |
exist). No code changes.
|
| 377 |
|
| 378 |
-
## [0.1.0]
|
| 379 |
|
| 380 |
First real release. Replaces the v0.0.1 PyPI name-reservation placeholder.
|
| 381 |
|
| 382 |
### Added
|
| 383 |
-
- `SibylMemoryProvider`
|
| 384 |
`sibyl-memory-client`. Auto-inherits Hermes' `MemoryProvider` ABC when
|
| 385 |
Hermes is installed; degrades to standalone object base when not.
|
| 386 |
- Five-tier memory routing: journal (`save_context`/`load_context`),
|
|
@@ -400,12 +400,12 @@ First real release. Replaces the v0.0.1 PyPI name-reservation placeholder.
|
|
| 400 |
Hermes binding state.
|
| 401 |
|
| 402 |
### Notes
|
| 403 |
-
- Depends on `sibyl-memory-client>=0.1.0`. No Hermes hard dep
|
| 404 |
via `pip install sibyl-memory-hermes[hermes]` to opt into the ABC.
|
| 405 |
- License: MIT.
|
| 406 |
- Compatible with Python 3.10+.
|
| 407 |
|
| 408 |
-
## [0.0.1]
|
| 409 |
|
| 410 |
Initial PyPI upload to reserve the package name. Empty package; not
|
| 411 |
intended for use. Superseded by v0.1.0 in the same session.
|
|
|
|
| 4 |
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning
|
| 5 |
follows [SemVer](https://semver.org/).
|
| 6 |
|
| 7 |
+
## [0.3.4] - 2026-05-20
|
| 8 |
|
| 9 |
Branding pass on the vendored banner. Matches the change shipped in
|
| 10 |
`sibyl-memory-cli` v0.3.2 in the same session. Operator directive:
|
|
|
|
| 21 |
on both `install-plugin` and `uninstall-plugin` since both commands
|
| 22 |
print the banner before their section header.
|
| 23 |
|
| 24 |
+
## [0.3.3] - 2026-05-20
|
| 25 |
|
| 26 |
Visual identity pass on the `install-plugin` and `uninstall-plugin`
|
| 27 |
+
commands. Operator directive: "typical app patterns: heavy menus on
|
| 28 |
install window and initial setup, light on dashboards etc." The
|
| 29 |
install-plugin command is THE second-most-ceremonial moment a user has
|
| 30 |
with SIBYL (after `sibyl init`), so it gets the full SIBYL banner +
|
|
|
|
| 55 |
- Plain-text fallback preserves structure (still readable in dumb
|
| 56 |
terminals or pipes).
|
| 57 |
|
| 58 |
+
## [0.3.2] - 2026-05-18
|
| 59 |
|
| 60 |
KAPPA external-tester remediation release. Family-wide alignment with the
|
| 61 |
v0.4.0 client (KAPPA-attributed fixes: exception export path, db file
|
|
|
|
| 77 |
|
| 78 |
---
|
| 79 |
|
| 80 |
+
## [0.3.1] - 2026-05-18
|
| 81 |
|
| 82 |
Audit-remediation release. v0.3.0 pre-ship audit (2026-05-18T05:05Z)
|
| 83 |
surfaced 10 critical findings across four lanes. This release lands the
|
|
|
|
| 87 |
|
| 88 |
### Added
|
| 89 |
|
| 90 |
+
- `tests/test_adapter.py`: full regression coverage for the bundled Hermes
|
| 91 |
adapter. Validates: module imports cleanly off-Hermes (guarded ABC
|
| 92 |
import + tool_error fallback), all 4 tool schemas resolve, end-to-end
|
| 93 |
remember+recall round-trips through `handle_tool_call`, list filtering,
|
|
|
|
| 98 |
|
| 99 |
### Changed
|
| 100 |
|
| 101 |
+
- **`SibylMemoryProvider.search()` now spans all four tiers**: entities +
|
| 102 |
state + reference + journal. Returns tier-tagged hits (`{tier, key,
|
| 103 |
category, body, snippet, rank, ts}`). The marketing claim of "FTS5
|
| 104 |
across all tiers" is now true. Caller can restrict scope with
|
| 105 |
`tiers=("entity",)` for the pre-v0.3.1 behavior. Backed by the new
|
| 106 |
`MemoryClient.search()` in client v0.3.3.
|
| 107 |
+
- `_hermes_plugin/adapter.py`. Hermes ABC + `tool_error` imports guarded
|
| 108 |
with try/except. The bundled module imports cleanly off-Hermes with
|
| 109 |
no-op fallbacks. Audit P1.
|
| 110 |
- `SibylAdapter.sync_turn` retries on transient failure (SQLITE_BUSY etc.)
|
|
|
|
| 121 |
(`_DEFAULT_SEARCH_LIMIT=10`, `_DEFAULT_LIST_LIMIT=50`). Audit O1.
|
| 122 |
- `RECALL_SCHEMA` description documents the row-wrapper return shape
|
| 123 |
explicitly. `SEARCH_SCHEMA` updated for cross-tier coverage. Audit H2.
|
| 124 |
+
- `provider.py`. `recall`, `forget`, `archive`, `set_state`, `get_state`,
|
| 125 |
`set_reference`, `get_reference` docstrings now include explicit
|
| 126 |
`Raises:` sections and document return-shape asymmetry per tier.
|
| 127 |
Audit H2/H3.
|
|
|
|
| 129 |
|
| 130 |
### Security
|
| 131 |
|
| 132 |
+
- **SEC-2**. `credentials.write_credentials` now creates files atomically
|
| 133 |
with mode 0o600 set at creation via `os.open(O_WRONLY|O_CREAT|O_EXCL|
|
| 134 |
O_NOFOLLOW, 0o600)`. No more world-readable window between `write_text()`
|
| 135 |
and `os.chmod()` syscalls.
|
| 136 |
+
- **SEC-5**. `install-plugin --force` and `uninstall-plugin` refuse to
|
| 137 |
`shutil.rmtree` any directory that doesn't contain a recognized prior
|
| 138 |
Sibyl install (`plugin.yaml` with `name: sibyl` in the first 10 lines).
|
| 139 |
Prevents destruction of arbitrary user-writable trees from misconfigured
|
| 140 |
HERMES_HOME. Both commands also refuse symlinked destinations.
|
| 141 |
+
- **SEC-11**. `load_credentials` refuses to follow symlinks. Checks
|
| 142 |
`is_symlink()` BEFORE `resolve()`.
|
| 143 |
+
- **SEC-10**. `handle_tool_call` error response carries only the
|
| 144 |
exception class name.
|
| 145 |
|
| 146 |
### Fixed
|
| 147 |
|
| 148 |
+
- **H7**. `hermes_bound` property emits `DeprecationWarning` on read.
|
| 149 |
Always returns `False` (unchanged behavior). Slated for removal in v0.4.
|
| 150 |
+
- `test_smoke.py`: schema_version assertion loosened to `>= 2` (audit T4);
|
| 151 |
hermes_bound assertion tightened from `isinstance(..., bool)` to
|
| 152 |
`is False` (audit T3).
|
| 153 |
+
- README quickstart rewritten: removed the fictional
|
| 154 |
`Agent(memory=SibylMemoryProvider())` pattern (audit C5). Replaced
|
| 155 |
with the real flow: `pip install` β `install-plugin` β config.yaml.
|
| 156 |
+
- README "Hermes contract" section rewritten: removed the false claim
|
| 157 |
that `SibylMemoryProvider` inherits Hermes' ABC at import time.
|
| 158 |
|
| 159 |
### Dependencies
|
|
|
|
| 170 |
```
|
| 171 |
|
| 172 |
The local SQLite schema auto-migrates from v2 to v3 on first open after
|
| 173 |
+
upgrade. No application data is lost. FTS5 indexes rebuild from base
|
| 174 |
tables. ~50ms per 10k entities on first open, idempotent thereafter.
|
| 175 |
|
| 176 |
+
## [0.3.0] - 2026-05-17
|
| 177 |
|
| 178 |
Real Hermes plugin landing. v0.2.x was structurally incompatible with
|
| 179 |
Hermes' actual `MemoryProvider` ABC (wrong soft-bind import path, missing
|
|
|
|
| 187 |
### Architecture shift
|
| 188 |
|
| 189 |
- **Split into SDK + adapter.** `SibylMemoryProvider` is now a pure SDK
|
| 190 |
+
class: framework-agnostic, no ABC inheritance, no Hermes-specific glue.
|
| 191 |
All Hermes contract code lives in the bundled adapter at
|
| 192 |
`_hermes_plugin/adapter.py`, copied to `$HERMES_HOME/plugins/sibyl/` by
|
| 193 |
the new `sibyl-memory-hermes install-plugin` console script.
|
| 194 |
- **Hermes uses filesystem discovery, NOT pip entry points.** Verified
|
| 195 |
+
against `plugins/memory/__init__.py` source: there is no
|
| 196 |
`importlib.metadata.entry_points()` call anywhere in Hermes' loader.
|
| 197 |
`pip install sibyl-memory-hermes` is necessary but not sufficient; the
|
| 198 |
install-plugin script bridges the gap.
|
| 199 |
|
| 200 |
### Added
|
| 201 |
|
| 202 |
+
- **`_hermes_plugin/adapter.py`**: full `MemoryProvider` ABC implementation.
|
| 203 |
- 4 tools exposed: `sibyl_remember`, `sibyl_recall`, `sibyl_search`,
|
| 204 |
`sibyl_list`.
|
| 205 |
- Mandatory methods: `name`, `is_available`, `initialize`,
|
| 206 |
`get_tool_schemas`, `handle_tool_call`.
|
| 207 |
- Recommended overrides: `system_prompt_block` (model-facing tool list),
|
| 208 |
`prefetch` (FTS5 + load_context block, with noise filter),
|
| 209 |
+
`queue_prefetch` (no-op: local SQLite is fast), `sync_turn`
|
| 210 |
(daemon-threaded per byterover pattern, 5s join + 10s shutdown).
|
| 211 |
- Optional hooks: `on_session_switch`, `on_pre_compress`
|
| 212 |
(paired user+assistant flush), `on_delegation`, `on_memory_write`
|
| 213 |
+
(accepts `metadata=None` kwarg: avoids the byterover signature bug).
|
| 214 |
- Defensive: `agent_context != 'primary'` guard in sync_turn so cron /
|
| 215 |
subagent runs don't corrupt the user's representation.
|
| 216 |
- `_stable_key()` uses blake2b for deterministic content addressing,
|
| 217 |
so add+remove on the same content actually targets the same entity.
|
| 218 |
- Validated end-to-end via `load_memory_provider('sibyl')` dry-run + all
|
| 219 |
4 tool schemas resolved in OpenAI function-calling format.
|
| 220 |
+
- **`_hermes_plugin/plugin.yaml`**. Hermes plugin metadata
|
| 221 |
(name, description, version, homepage).
|
| 222 |
- **`sibyl_memory_hermes.install_plugin`** + console script
|
| 223 |
`sibyl-memory-hermes install-plugin`:
|
|
|
|
| 230 |
- Flags: `--hermes-home <path>`, `--force`, `--dry-run`.
|
| 231 |
- **`sibyl-memory-hermes uninstall-plugin`** counterpart for clean removal.
|
| 232 |
|
| 233 |
+
### Changed (breaking, but no users were affected: the prior path was broken)
|
| 234 |
|
| 235 |
- **`SibylMemoryProvider` no longer subclasses `MemoryProvider`.** The
|
| 236 |
conditional soft-bind in v0.2.x always failed (wrong import path); the
|
|
|
|
| 239 |
property and `health()` field are kept for backwards compatibility but
|
| 240 |
always return False; they're deprecated for removal in a future major.
|
| 241 |
- **`__init__.py` docstring rewritten.** The fictional `from hermes_agent
|
| 242 |
+
import Agent; Agent(memory=SibylMemoryProvider())` quickstart is gone -
|
| 243 |
that API never existed in any Hermes release. Replaced with the real
|
| 244 |
install flow (`pip install` β `install-plugin` β config.yaml edit).
|
| 245 |
- **`__version__` is now single-sourced** from `importlib.metadata.version(
|
|
|
|
| 249 |
### Fixed
|
| 250 |
|
| 251 |
- `provider.py:57` no longer attempts `from hermes_agent.memory import
|
| 252 |
+
MemoryProvider`. That import path does not exist in hermes-agent: the
|
| 253 |
real module is `agent.memory_provider`. Removed entirely; the SDK class
|
| 254 |
doesn't inherit from the ABC anymore (see Architecture shift above).
|
| 255 |
|
| 256 |
### Dependencies
|
| 257 |
|
| 258 |
+
- `sibyl-memory-client>=0.3.2` (was `>=0.3.1`: picks up the cap-gate +
|
| 259 |
HTTPError fixes from yesterday's audit pass).
|
| 260 |
+
- Optional: `hermes-agent>=0.13.0` (was `>=0.10.0`: the ABC is documented
|
| 261 |
for v0.13 specifically; older releases may work but aren't validated).
|
| 262 |
|
| 263 |
### How to upgrade from v0.2.x
|
|
|
|
| 270 |
```
|
| 271 |
|
| 272 |
If you were importing `from hermes_agent import Agent` (per the old
|
| 273 |
+
docstring), that never worked: delete those lines. If you were using
|
| 274 |
`SibylMemoryProvider()` directly from a non-Hermes Python orchestration,
|
| 275 |
no changes needed; the SDK surface is unchanged.
|
| 276 |
|
|
|
|
| 282 |
- File-bundle validated: `importlib.resources.files('sibyl_memory_hermes.
|
| 283 |
_hermes_plugin').joinpath('adapter.py').read_bytes()` returns the
|
| 284 |
expected 18,118-byte payload.
|
| 285 |
+
- install-plugin smoke-tested against a `/tmp/fake_hermes_home` target -
|
| 286 |
files land at `$HERMES_HOME/plugins/sibyl/{__init__.py, plugin.yaml}`.
|
| 287 |
|
| 288 |
### Authorship
|
| 289 |
|
| 290 |
Developed by SIBYL, Sibyl Labs LLC. Adapter contract derived from the
|
| 291 |
+
installed hermes-agent 0.13.0 wheel source. `agent/memory_provider.py`
|
| 292 |
(the ABC) and `plugins/memory/byterover/` (the idiomatic threading +
|
| 293 |
schema pattern). MIT licensed.
|
| 294 |
|
| 295 |
+
## [0.2.2] - 2026-05-16
|
| 296 |
|
| 297 |
Audit-remediation release. Companion to `sibyl-memory-client` v0.3.2.
|
| 298 |
|
| 299 |
### Changed
|
| 300 |
|
| 301 |
+
- **T2-2. `SibylMemoryProvider.recall()` narrows exception handling to
|
| 302 |
`NotFoundError` only**. Previously caught bare `Exception`, which
|
| 303 |
swallowed `StorageError` / `TenantError` / `SchemaError` and returned
|
| 304 |
`None` (the Hermes-style soft-miss). That masked real storage failures
|
| 305 |
+
end-to-end: exactly the silent-fallback pattern that caused the
|
| 306 |
production Bug 2 on the server side. Now `NotFoundError` returns
|
| 307 |
`None` as intended; every other exception propagates so the caller
|
| 308 |
can surface or retry.
|
|
|
|
| 318 |
`_check_fn` raise-on-HTTPError fix (T2-3). v0.3.1 still works; the
|
| 319 |
changes are independent.
|
| 320 |
|
| 321 |
+
## [0.2.1] - 2026-05-16
|
| 322 |
|
| 323 |
HMAC signed-credentials plumbing. Companion to `sibyl-memory-client`
|
| 324 |
v0.3.1 and the api-sibyllabs credential-signer release.
|
|
|
|
| 326 |
### Changed
|
| 327 |
|
| 328 |
- `Credentials` dataclass gained `signature: str | None = None` and
|
| 329 |
+
`signed_at: str | None = None` fields. Backwards compatible -
|
| 330 |
schema v1 credentials still load with these fields as `None`.
|
| 331 |
- `SibylMemoryProvider.__init__` reads the signature + canonical
|
| 332 |
claim from credentials.json and passes them through to
|
|
|
|
| 344 |
|
| 345 |
- 21/21 unchanged, all green.
|
| 346 |
|
| 347 |
+
## [0.2.0] - 2026-05-15
|
| 348 |
|
| 349 |
Hard-cap plumbing release. Companion to `sibyl-memory-client` v0.3.0.
|
| 350 |
|
|
|
|
| 358 |
2 MB cap with no server-check fallback.
|
| 359 |
- `Credentials` dataclass gained an optional `session_token` field
|
| 360 |
(the long-lived bearer issued by the activation flow). Backwards
|
| 361 |
+
compatible: existing v0.1.x credentials files load fine,
|
| 362 |
`session_token` simply lands as `None`.
|
| 363 |
|
| 364 |
### Notes
|
| 365 |
|
| 366 |
- Depends on `sibyl-memory-client>=0.3.0`. Earlier clients lack the
|
| 367 |
cap-gate plumbing.
|
| 368 |
+
- Pre-activation users (no credentials.json) still work: they hit
|
| 369 |
the strict local 2 MB cap with no upgrade path until they run
|
| 370 |
`sibyl init`.
|
| 371 |
|
| 372 |
+
## [0.1.1] - 2026-05-15
|
| 373 |
|
| 374 |
Patch: stripped placeholder GitHub URLs from pyproject metadata
|
| 375 |
(operator scar: never write a link to a domain not verified to
|
| 376 |
exist). No code changes.
|
| 377 |
|
| 378 |
+
## [0.1.0] - 2026-05-15
|
| 379 |
|
| 380 |
First real release. Replaces the v0.0.1 PyPI name-reservation placeholder.
|
| 381 |
|
| 382 |
### Added
|
| 383 |
+
- `SibylMemoryProvider`. Hermes-compatible memory provider on top of
|
| 384 |
`sibyl-memory-client`. Auto-inherits Hermes' `MemoryProvider` ABC when
|
| 385 |
Hermes is installed; degrades to standalone object base when not.
|
| 386 |
- Five-tier memory routing: journal (`save_context`/`load_context`),
|
|
|
|
| 400 |
Hermes binding state.
|
| 401 |
|
| 402 |
### Notes
|
| 403 |
+
- Depends on `sibyl-memory-client>=0.1.0`. No Hermes hard dep: install
|
| 404 |
via `pip install sibyl-memory-hermes[hermes]` to opt into the ABC.
|
| 405 |
- License: MIT.
|
| 406 |
- Compatible with Python 3.10+.
|
| 407 |
|
| 408 |
+
## [0.0.1] - 2026-05-15 (name-reservation placeholder)
|
| 409 |
|
| 410 |
Initial PyPI upload to reserve the package name. Empty package; not
|
| 411 |
intended for use. Superseded by v0.1.0 in the same session.
|
sibyl-memory-hermes/README.md
CHANGED
|
@@ -3,14 +3,14 @@
|
|
| 3 |
**Sibyl Memory SDK + bundled Hermes plugin payload. Local-first, SQLite-backed, structured-tier memory for Hermes v0.13+ (and any Python orchestration that wants direct SDK access).**
|
| 4 |
|
| 5 |
The package ships two things:
|
| 6 |
-
1. **`SibylMemoryProvider`**
|
| 7 |
-
2. **A bundled Hermes plugin payload**
|
| 8 |
|
| 9 |
Memory content lives on the user's own machine, never on our servers. Built on [`sibyl-memory-client`](https://pypi.org/project/sibyl-memory-client/), the SDK foundation.
|
| 10 |
|
| 11 |
## Install (Hermes path)
|
| 12 |
|
| 13 |
-
Hermes' loader uses filesystem discovery, NOT pip entry points. A pip install alone won't make Sibyl visible to Hermes
|
| 14 |
|
| 15 |
```bash
|
| 16 |
pip install sibyl-memory-hermes
|
|
@@ -26,10 +26,10 @@ memory:
|
|
| 26 |
|
| 27 |
Restart Hermes. Four tools become available to the agent:
|
| 28 |
|
| 29 |
-
- `sibyl_remember(category, name, body)`
|
| 30 |
-
- `sibyl_recall(category, name)`
|
| 31 |
-
- `sibyl_search(query)`
|
| 32 |
-
- `sibyl_list(category?, status?)`
|
| 33 |
|
| 34 |
Optional: lift the 2 MB free-tier cap by binding your account:
|
| 35 |
|
|
@@ -84,7 +84,7 @@ Different intents, different lookups, no embedding model required. FTS5 covers f
|
|
| 84 |
|
| 85 |
The Hermes plugin is implemented by a bundled adapter at `_hermes_plugin/adapter.py`. The adapter is copied into `$HERMES_HOME/plugins/sibyl/` by the `install-plugin` console script and is what Hermes' filesystem loader picks up. The adapter implements Hermes v0.13's `MemoryProvider` ABC and delegates every call to `SibylMemoryProvider`.
|
| 86 |
|
| 87 |
-
The SDK class itself (`SibylMemoryProvider`) is framework-agnostic
|
| 88 |
|
| 89 |
## Activation
|
| 90 |
|
|
|
|
| 3 |
**Sibyl Memory SDK + bundled Hermes plugin payload. Local-first, SQLite-backed, structured-tier memory for Hermes v0.13+ (and any Python orchestration that wants direct SDK access).**
|
| 4 |
|
| 5 |
The package ships two things:
|
| 6 |
+
1. **`SibylMemoryProvider`**: a framework-agnostic SDK class. Call it directly from any Python code that wants structured local memory.
|
| 7 |
+
2. **A bundled Hermes plugin payload**: a thin adapter implementing Hermes v0.13's `MemoryProvider` ABC. Installed into `$HERMES_HOME/plugins/sibyl/` by the `sibyl-memory-hermes install-plugin` console script.
|
| 8 |
|
| 9 |
Memory content lives on the user's own machine, never on our servers. Built on [`sibyl-memory-client`](https://pypi.org/project/sibyl-memory-client/), the SDK foundation.
|
| 10 |
|
| 11 |
## Install (Hermes path)
|
| 12 |
|
| 13 |
+
Hermes' loader uses filesystem discovery, NOT pip entry points. A pip install alone won't make Sibyl visible to Hermes: the `install-plugin` console script bridges the gap.
|
| 14 |
|
| 15 |
```bash
|
| 16 |
pip install sibyl-memory-hermes
|
|
|
|
| 26 |
|
| 27 |
Restart Hermes. Four tools become available to the agent:
|
| 28 |
|
| 29 |
+
- `sibyl_remember(category, name, body)`: store a structured fact
|
| 30 |
+
- `sibyl_recall(category, name)`: look up a known fact
|
| 31 |
+
- `sibyl_search(query)`. FTS5 search across **all four tiers** (entities, state, journal, reference); hits are tier-tagged
|
| 32 |
+
- `sibyl_list(category?, status?)`: browse what's remembered
|
| 33 |
|
| 34 |
Optional: lift the 2 MB free-tier cap by binding your account:
|
| 35 |
|
|
|
|
| 84 |
|
| 85 |
The Hermes plugin is implemented by a bundled adapter at `_hermes_plugin/adapter.py`. The adapter is copied into `$HERMES_HOME/plugins/sibyl/` by the `install-plugin` console script and is what Hermes' filesystem loader picks up. The adapter implements Hermes v0.13's `MemoryProvider` ABC and delegates every call to `SibylMemoryProvider`.
|
| 86 |
|
| 87 |
+
The SDK class itself (`SibylMemoryProvider`) is framework-agnostic: it does not inherit from any framework ABC. This is the v0.3.0 architecture shift. v0.2.x and earlier attempted soft-inheritance via a broken import path; that path was removed and the adapter pattern replaced it.
|
| 88 |
|
| 89 |
## Activation
|
| 90 |
|
sibyl-memory-hermes/src/sibyl_memory_hermes/__init__.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""sibyl-memory-hermes
|
| 2 |
|
| 3 |
Public exports:
|
| 4 |
SibylMemoryProvider framework-agnostic Sibyl Memory SDK class
|
|
@@ -12,20 +12,20 @@ ARCHITECTURE (v0.3.0+)
|
|
| 12 |
|
| 13 |
This package ships two things:
|
| 14 |
|
| 15 |
-
1. `SibylMemoryProvider`
|
| 16 |
Routes memory operations across the five Sibyl tiers (warm entities,
|
| 17 |
hot state, cold journal, reference docs, archive). Can be called
|
| 18 |
directly by any orchestration that wants a structured local memory
|
| 19 |
backend.
|
| 20 |
|
| 21 |
-
2. A bundled Hermes plugin payload (`_hermes_plugin/`)
|
| 22 |
implementing Hermes v0.13+ `MemoryProvider` ABC that delegates to
|
| 23 |
`SibylMemoryProvider`. Installed into `$HERMES_HOME/plugins/sibyl/`
|
| 24 |
by the `sibyl-memory-hermes install-plugin` console script.
|
| 25 |
|
| 26 |
Hermes' plugin loader uses filesystem discovery, NOT pip entry points
|
| 27 |
(verified against `plugins/memory/__init__.py` source 2026-05-17). A pip
|
| 28 |
-
install alone won't make Sibyl visible to Hermes
|
| 29 |
script bridges that gap.
|
| 30 |
|
| 31 |
HERMES INSTALL FLOW
|
|
|
|
| 1 |
+
"""sibyl-memory-hermes. Sibyl Memory SDK + bundled Hermes plugin payload.
|
| 2 |
|
| 3 |
Public exports:
|
| 4 |
SibylMemoryProvider framework-agnostic Sibyl Memory SDK class
|
|
|
|
| 12 |
|
| 13 |
This package ships two things:
|
| 14 |
|
| 15 |
+
1. `SibylMemoryProvider`: a pure-Python SDK class. Framework-agnostic.
|
| 16 |
Routes memory operations across the five Sibyl tiers (warm entities,
|
| 17 |
hot state, cold journal, reference docs, archive). Can be called
|
| 18 |
directly by any orchestration that wants a structured local memory
|
| 19 |
backend.
|
| 20 |
|
| 21 |
+
2. A bundled Hermes plugin payload (`_hermes_plugin/`): a thin adapter
|
| 22 |
implementing Hermes v0.13+ `MemoryProvider` ABC that delegates to
|
| 23 |
`SibylMemoryProvider`. Installed into `$HERMES_HOME/plugins/sibyl/`
|
| 24 |
by the `sibyl-memory-hermes install-plugin` console script.
|
| 25 |
|
| 26 |
Hermes' plugin loader uses filesystem discovery, NOT pip entry points
|
| 27 |
(verified against `plugins/memory/__init__.py` source 2026-05-17). A pip
|
| 28 |
+
install alone won't make Sibyl visible to Hermes: the install-plugin
|
| 29 |
script bridges that gap.
|
| 30 |
|
| 31 |
HERMES INSTALL FLOW
|
sibyl-memory-hermes/src/sibyl_memory_hermes/_aesthetic.py
CHANGED
|
@@ -22,22 +22,22 @@ from typing import Iterable
|
|
| 22 |
# βββ Palette (RGB Β· derived from rule 46 creme-paper tokens) βββββββββ
|
| 23 |
# Names map 1:1 to CSS custom properties on lab artifacts.
|
| 24 |
|
| 25 |
-
PAPER = (245, 241, 230) # --paper
|
| 26 |
-
PAPER_DEEP = (237, 230, 211) # --paper-deep
|
| 27 |
-
CARD = (253, 251, 245) # --card
|
| 28 |
-
INK = (21, 17, 10) # --ink
|
| 29 |
-
INK_SOFT = (44, 39, 29) # --ink-soft
|
| 30 |
-
INK_MUTE = (106, 99, 86) # --ink-mute
|
| 31 |
-
INK_FAINT = (152, 145, 127) # --ink-faint
|
| 32 |
-
RULE = (216, 208, 187) # --rule
|
| 33 |
-
RULE_STRONG = (184, 174, 147) # --rule-strong
|
| 34 |
-
ACCENT = (138, 106, 42) # --accent
|
| 35 |
-
ACCENT_WARM = (160, 132, 56) # --accent-warm
|
| 36 |
-
ACCENT_GOLD = (224, 194, 119) # mid gold
|
| 37 |
-
ACCENT_PALE = (244, 229, 184) # pale gold
|
| 38 |
-
JADE = (45, 110, 106) # --jade
|
| 39 |
-
PULSE = (29, 138, 130) # --pulse
|
| 40 |
-
ERROR = (162, 58, 42) # --error
|
| 41 |
|
| 42 |
# Status glyphs (Unicode, terminal-safe in modern fonts)
|
| 43 |
GLYPH_OK = "β"
|
|
@@ -56,7 +56,7 @@ def supports_truecolor() -> bool:
|
|
| 56 |
return False
|
| 57 |
if os.environ.get("TERM", "").lower() == "dumb":
|
| 58 |
return False
|
| 59 |
-
# SIBYL_FORCE_COLOR=1
|
| 60 |
# (CI logs, doc captures, dev inspection in non-tty environments).
|
| 61 |
if os.environ.get("SIBYL_FORCE_COLOR") == "1":
|
| 62 |
return True
|
|
@@ -269,7 +269,7 @@ def err_line(text: str) -> str:
|
|
| 269 |
|
| 270 |
|
| 271 |
def hr_caption(caption: str, *, width: int = 60) -> str:
|
| 272 |
-
"""Caption line under a divider
|
| 273 |
pad = max(0, (width - len(caption)) // 2)
|
| 274 |
return " " * pad + dim(caption)
|
| 275 |
|
|
|
|
| 22 |
# βββ Palette (RGB Β· derived from rule 46 creme-paper tokens) βββββββββ
|
| 23 |
# Names map 1:1 to CSS custom properties on lab artifacts.
|
| 24 |
|
| 25 |
+
PAPER = (245, 241, 230) # --paper : foreground accent on dark
|
| 26 |
+
PAPER_DEEP = (237, 230, 211) # --paper-deep : depth on creme
|
| 27 |
+
CARD = (253, 251, 245) # --card : slightly lifted creme
|
| 28 |
+
INK = (21, 17, 10) # --ink : main text on creme
|
| 29 |
+
INK_SOFT = (44, 39, 29) # --ink-soft : body text
|
| 30 |
+
INK_MUTE = (106, 99, 86) # --ink-mute : secondary text
|
| 31 |
+
INK_FAINT = (152, 145, 127) # --ink-faint : tertiary text
|
| 32 |
+
RULE = (216, 208, 187) # --rule : hairline
|
| 33 |
+
RULE_STRONG = (184, 174, 147) # --rule-strong : emphasised hairline
|
| 34 |
+
ACCENT = (138, 106, 42) # --accent : ochre highlight
|
| 35 |
+
ACCENT_WARM = (160, 132, 56) # --accent-warm : softer ochre
|
| 36 |
+
ACCENT_GOLD = (224, 194, 119) # mid gold : gradient bridge
|
| 37 |
+
ACCENT_PALE = (244, 229, 184) # pale gold : gradient top
|
| 38 |
+
JADE = (45, 110, 106) # --jade : cool counterpoint
|
| 39 |
+
PULSE = (29, 138, 130) # --pulse : brighter jade (live signal)
|
| 40 |
+
ERROR = (162, 58, 42) # --error : measured red
|
| 41 |
|
| 42 |
# Status glyphs (Unicode, terminal-safe in modern fonts)
|
| 43 |
GLYPH_OK = "β"
|
|
|
|
| 56 |
return False
|
| 57 |
if os.environ.get("TERM", "").lower() == "dumb":
|
| 58 |
return False
|
| 59 |
+
# SIBYL_FORCE_COLOR=1: explicit override for non-tty rendering
|
| 60 |
# (CI logs, doc captures, dev inspection in non-tty environments).
|
| 61 |
if os.environ.get("SIBYL_FORCE_COLOR") == "1":
|
| 62 |
return True
|
|
|
|
| 269 |
|
| 270 |
|
| 271 |
def hr_caption(caption: str, *, width: int = 60) -> str:
|
| 272 |
+
"""Caption line under a divider: small, muted, centered."""
|
| 273 |
pad = max(0, (width - len(caption)) // 2)
|
| 274 |
return " " * pad + dim(caption)
|
| 275 |
|
sibyl-memory-hermes/src/sibyl_memory_hermes/_banner.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 2 |
|
| 3 |
Prints the SIBYL wordmark in ANSI Shadow boxchars with a 24-bit truecolor
|
| 4 |
vertical gradient flowing from cream/white at the top through warm gold
|
| 5 |
-
to deep ochre at the bottom
|
| 6 |
the operator's brand-discipline rule (creme palette, deep-ochre accent).
|
| 7 |
|
| 8 |
Gracefully degrades:
|
|
@@ -10,7 +10,7 @@ Gracefully degrades:
|
|
| 10 |
- stdout is not a TTY β plain text fallback (or skip entirely)
|
| 11 |
- TERM=dumb β plain text fallback
|
| 12 |
|
| 13 |
-
Truecolor support is detected via $COLORTERM (truecolor / 24bit)
|
| 14 |
modern terminals (iTerm2, Alacritty, Kitty, wezterm, Windows Terminal,
|
| 15 |
modern xterm builds, Ghostty) advertise it. Falls back to 256-color
|
| 16 |
gradient when not available.
|
|
@@ -20,7 +20,7 @@ from __future__ import annotations
|
|
| 20 |
import os
|
| 21 |
import sys
|
| 22 |
|
| 23 |
-
# ANSI Shadow rendering of "SIBYL"
|
| 24 |
# own gradient color (top = pale cream/white, bottom = deep ochre).
|
| 25 |
_LINES = (
|
| 26 |
"ββββββββββββββββββ βββ ββββββ ",
|
|
@@ -49,7 +49,7 @@ _ATTRIBUTION = "a Sibyl Labs LLC Product. Agentic Infrastructure and Memory Prod
|
|
| 49 |
|
| 50 |
|
| 51 |
def _supports_truecolor() -> bool:
|
| 52 |
-
"""Detect 24-bit color support. Conservative
|
| 53 |
if os.environ.get("NO_COLOR"):
|
| 54 |
return False
|
| 55 |
if os.environ.get("TERM", "").lower() == "dumb":
|
|
@@ -96,22 +96,22 @@ def render_banner(*, force_color: bool | None = None) -> str:
|
|
| 96 |
use_truecolor = force_color if force_color is not None else _supports_truecolor()
|
| 97 |
|
| 98 |
if not use_truecolor:
|
| 99 |
-
# Plain text
|
| 100 |
body = "\n".join(" " + line for line in _LINES)
|
| 101 |
tagline = f"\n {_TAGLINE}"
|
| 102 |
attribution = f"\n {_ATTRIBUTION}\n"
|
| 103 |
return body + tagline + attribution
|
| 104 |
|
| 105 |
-
# Colored
|
| 106 |
colored_lines = []
|
| 107 |
for line, (r, g, b) in zip(_LINES, _GRADIENT):
|
| 108 |
colored_lines.append(f" {_rgb(r, g, b)}{line}{_RESET}")
|
| 109 |
|
| 110 |
body = "\n".join(colored_lines)
|
| 111 |
-
# Tagline in the deepest gold
|
| 112 |
r, g, b = _GRADIENT[-1]
|
| 113 |
tagline = f"\n {_rgb(r, g, b)}{_TAGLINE}{_RESET}"
|
| 114 |
-
# Attribution dimmer still
|
| 115 |
# reads SIBYL > tagline > attribution at a glance. ANSI dim (\033[2m) gives
|
| 116 |
# ~55% perceived opacity across the supported terminals.
|
| 117 |
attribution = f"\n \033[2m{_rgb(r, g, b)}{_ATTRIBUTION}{_RESET}\n"
|
|
|
|
| 2 |
|
| 3 |
Prints the SIBYL wordmark in ANSI Shadow boxchars with a 24-bit truecolor
|
| 4 |
vertical gradient flowing from cream/white at the top through warm gold
|
| 5 |
+
to deep ochre at the bottom: aligned with the lab visual identity per
|
| 6 |
the operator's brand-discipline rule (creme palette, deep-ochre accent).
|
| 7 |
|
| 8 |
Gracefully degrades:
|
|
|
|
| 10 |
- stdout is not a TTY β plain text fallback (or skip entirely)
|
| 11 |
- TERM=dumb β plain text fallback
|
| 12 |
|
| 13 |
+
Truecolor support is detected via $COLORTERM (truecolor / 24bit): most
|
| 14 |
modern terminals (iTerm2, Alacritty, Kitty, wezterm, Windows Terminal,
|
| 15 |
modern xterm builds, Ghostty) advertise it. Falls back to 256-color
|
| 16 |
gradient when not available.
|
|
|
|
| 20 |
import os
|
| 21 |
import sys
|
| 22 |
|
| 23 |
+
# ANSI Shadow rendering of "SIBYL": 6 rows, 41 cols. Each row gets its
|
| 24 |
# own gradient color (top = pale cream/white, bottom = deep ochre).
|
| 25 |
_LINES = (
|
| 26 |
"ββββββββββββββββββ βββ ββββββ ",
|
|
|
|
| 49 |
|
| 50 |
|
| 51 |
def _supports_truecolor() -> bool:
|
| 52 |
+
"""Detect 24-bit color support. Conservative: fall back gracefully."""
|
| 53 |
if os.environ.get("NO_COLOR"):
|
| 54 |
return False
|
| 55 |
if os.environ.get("TERM", "").lower() == "dumb":
|
|
|
|
| 96 |
use_truecolor = force_color if force_color is not None else _supports_truecolor()
|
| 97 |
|
| 98 |
if not use_truecolor:
|
| 99 |
+
# Plain text: still visually clean, just no color.
|
| 100 |
body = "\n".join(" " + line for line in _LINES)
|
| 101 |
tagline = f"\n {_TAGLINE}"
|
| 102 |
attribution = f"\n {_ATTRIBUTION}\n"
|
| 103 |
return body + tagline + attribution
|
| 104 |
|
| 105 |
+
# Colored: apply per-row gradient.
|
| 106 |
colored_lines = []
|
| 107 |
for line, (r, g, b) in zip(_LINES, _GRADIENT):
|
| 108 |
colored_lines.append(f" {_rgb(r, g, b)}{line}{_RESET}")
|
| 109 |
|
| 110 |
body = "\n".join(colored_lines)
|
| 111 |
+
# Tagline in the deepest gold: present, but not competing with the wordmark.
|
| 112 |
r, g, b = _GRADIENT[-1]
|
| 113 |
tagline = f"\n {_rgb(r, g, b)}{_TAGLINE}{_RESET}"
|
| 114 |
+
# Attribution dimmer still: a half-step below the tagline so the hierarchy
|
| 115 |
# reads SIBYL > tagline > attribution at a glance. ANSI dim (\033[2m) gives
|
| 116 |
# ~55% perceived opacity across the supported terminals.
|
| 117 |
attribution = f"\n \033[2m{_rgb(r, g, b)}{_ATTRIBUTION}{_RESET}\n"
|
sibyl-memory-hermes/src/sibyl_memory_hermes/_hermes_plugin/__init__.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""Bundled Hermes plugin payload
|
| 2 |
|
| 3 |
Contains the validated MemoryProvider adapter (`adapter.py`) and its
|
| 4 |
metadata (`plugin.yaml`). The `sibyl-memory-hermes install-plugin`
|
|
|
|
| 1 |
+
"""Bundled Hermes plugin payload. NOT for direct import.
|
| 2 |
|
| 3 |
Contains the validated MemoryProvider adapter (`adapter.py`) and its
|
| 4 |
metadata (`plugin.yaml`). The `sibyl-memory-hermes install-plugin`
|
sibyl-memory-hermes/src/sibyl_memory_hermes/_hermes_plugin/adapter.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""Sibyl memory plugin
|
| 2 |
|
| 3 |
Developed by SIBYL, Sibyl Labs LLC. MIT licensed.
|
| 4 |
|
|
@@ -21,13 +21,13 @@ Install location:
|
|
| 21 |
|
| 22 |
Configuration:
|
| 23 |
Credentials live in ~/.sibyl-memory/credentials.json (managed by the
|
| 24 |
-
`sibyl init` CLI). This adapter does not duplicate that
|
| 25 |
SDK auto-load credentials. The only Hermes-side option is `db_path`,
|
| 26 |
which defaults to <HERMES_HOME>/sibyl/memory.db so each profile has
|
| 27 |
its own database.
|
| 28 |
|
| 29 |
v0.3.1 hardening (audit-remediation):
|
| 30 |
-
- Hermes ABC + tool_error imports are guarded
|
| 31 |
outside Hermes (tests, dry-run tooling). Off-Hermes the adapter
|
| 32 |
degrades to a no-op MemoryProvider base; the tool dispatcher still
|
| 33 |
works for offline validation.
|
|
@@ -70,7 +70,7 @@ except ImportError:
|
|
| 70 |
|
| 71 |
logger = logging.getLogger(__name__)
|
| 72 |
|
| 73 |
-
# Timeouts + sizes
|
| 74 |
_SYNC_JOIN_TIMEOUT = 5.0 # wait this long for previous sync_turn write
|
| 75 |
_SHUTDOWN_JOIN_TIMEOUT = 10.0 # wait this long on shutdown
|
| 76 |
_MIN_QUERY_LEN = 10 # skip tiny prefetch queries (noise)
|
|
@@ -99,7 +99,7 @@ def _stable_key(content: str, prefix: str = "") -> str:
|
|
| 99 |
|
| 100 |
|
| 101 |
# ---------------------------------------------------------------------------
|
| 102 |
-
# Tool schemas
|
| 103 |
# ---------------------------------------------------------------------------
|
| 104 |
|
| 105 |
REMEMBER_SCHEMA = {
|
|
@@ -108,7 +108,7 @@ REMEMBER_SCHEMA = {
|
|
| 108 |
"Upsert a structured fact into Sibyl's warm-entity tier. Use for "
|
| 109 |
"anything worth remembering across sessions: project decisions, user "
|
| 110 |
"preferences, API quirks, conventions. (category, name) is the unique "
|
| 111 |
-
"key
|
| 112 |
),
|
| 113 |
"parameters": {
|
| 114 |
"type": "object",
|
|
@@ -139,7 +139,7 @@ RECALL_SCHEMA = {
|
|
| 139 |
"description": (
|
| 140 |
"Look up a single entity by (category, name). Returns the entity row "
|
| 141 |
"(or null if absent) shaped {id, tenant_id, category, name, status, "
|
| 142 |
-
"body, created_at, updated_at}
|
| 143 |
"when you know exactly what to fetch; use sibyl_search for fuzzy/keyword lookup."
|
| 144 |
),
|
| 145 |
"parameters": {
|
|
@@ -163,7 +163,7 @@ SEARCH_SCHEMA = {
|
|
| 163 |
"parameters": {
|
| 164 |
"type": "object",
|
| 165 |
"properties": {
|
| 166 |
-
"query": {"type": "string", "description": "Search query. User input is sanitized as a single FTS5 phrase
|
| 167 |
"limit": {
|
| 168 |
"type": "integer",
|
| 169 |
"description": f"Max results (default {_DEFAULT_SEARCH_LIMIT}).",
|
|
@@ -226,7 +226,7 @@ class SibylAdapter(MemoryProvider):
|
|
| 226 |
return "sibyl"
|
| 227 |
|
| 228 |
def is_available(self) -> bool:
|
| 229 |
-
"""Cheap local check
|
| 230 |
try:
|
| 231 |
import sibyl_memory_hermes # noqa: F401
|
| 232 |
return True
|
|
@@ -282,7 +282,7 @@ class SibylAdapter(MemoryProvider):
|
|
| 282 |
return ""
|
| 283 |
if not hits:
|
| 284 |
return ""
|
| 285 |
-
lines = ["## Sibyl Memory
|
| 286 |
for hit in hits:
|
| 287 |
tier = hit.get("tier", "?")
|
| 288 |
category = hit.get("category", "")
|
|
@@ -297,7 +297,7 @@ class SibylAdapter(MemoryProvider):
|
|
| 297 |
return block[:_MAX_PREFETCH_CHARS]
|
| 298 |
|
| 299 |
def queue_prefetch(self, query: str, *, session_id: str = "") -> None:
|
| 300 |
-
# Sibyl is local SQLite
|
| 301 |
# Nothing to queue.
|
| 302 |
pass
|
| 303 |
|
|
@@ -342,7 +342,7 @@ class SibylAdapter(MemoryProvider):
|
|
| 342 |
# Exponential backoff for SQLITE_BUSY / transient errors
|
| 343 |
time.sleep(_BUSY_RETRY_BACKOFF * (2 ** (attempt - 1)))
|
| 344 |
continue
|
| 345 |
-
# Final attempt failed
|
| 346 |
# so users see drops in production logs.
|
| 347 |
logger.warning(
|
| 348 |
"Sibyl sync_turn dropped a journal turn after %d attempts: %s",
|
|
@@ -478,7 +478,7 @@ class SibylAdapter(MemoryProvider):
|
|
| 478 |
metadata: dict[str, Any] | None = None) -> None:
|
| 479 |
"""Mirror built-in `memory` tool writes into Sibyl's warm tier.
|
| 480 |
|
| 481 |
-
Accepts metadata even though we treat it as informational only
|
| 482 |
ignoring the kwarg would TypeError under strict callers.
|
| 483 |
"""
|
| 484 |
if not self._sibyl or not content:
|
|
@@ -499,24 +499,24 @@ class SibylAdapter(MemoryProvider):
|
|
| 499 |
# -- config ------------------------------------------------------------
|
| 500 |
|
| 501 |
def get_config_schema(self) -> list[dict[str, Any]]:
|
| 502 |
-
"""No Hermes-side config
|
| 503 |
|
| 504 |
Sibyl manages its own credentials and identity outside Hermes:
|
| 505 |
running `sibyl init` writes ~/.sibyl-memory/credentials.json,
|
| 506 |
which the SDK auto-loads at construction time. We deliberately
|
| 507 |
return [] here so `hermes memory setup` does NOT double-prompt
|
| 508 |
-
for credentials that already live in the Sibyl native file
|
| 509 |
running both flows would diverge tenant ids and confuse users.
|
| 510 |
|
| 511 |
If a future version needs Hermes-side overrides (alt db_path,
|
| 512 |
explicit tenant_id for testing), add them here as non-secret
|
| 513 |
-
fields
|
| 514 |
source of truth.
|
| 515 |
"""
|
| 516 |
return []
|
| 517 |
|
| 518 |
def save_config(self, values: dict[str, Any], hermes_home: str) -> None:
|
| 519 |
-
# Nothing to persist
|
| 520 |
# constructor args. This stays a no-op until a hermes-side config
|
| 521 |
# file is actually needed.
|
| 522 |
return
|
|
|
|
| 1 |
+
"""Sibyl memory plugin. MemoryProvider adapter for sibyl-memory-hermes.
|
| 2 |
|
| 3 |
Developed by SIBYL, Sibyl Labs LLC. MIT licensed.
|
| 4 |
|
|
|
|
| 21 |
|
| 22 |
Configuration:
|
| 23 |
Credentials live in ~/.sibyl-memory/credentials.json (managed by the
|
| 24 |
+
`sibyl init` CLI). This adapter does not duplicate that: it lets the
|
| 25 |
SDK auto-load credentials. The only Hermes-side option is `db_path`,
|
| 26 |
which defaults to <HERMES_HOME>/sibyl/memory.db so each profile has
|
| 27 |
its own database.
|
| 28 |
|
| 29 |
v0.3.1 hardening (audit-remediation):
|
| 30 |
+
- Hermes ABC + tool_error imports are guarded: module imports cleanly
|
| 31 |
outside Hermes (tests, dry-run tooling). Off-Hermes the adapter
|
| 32 |
degrades to a no-op MemoryProvider base; the tool dispatcher still
|
| 33 |
works for offline validation.
|
|
|
|
| 70 |
|
| 71 |
logger = logging.getLogger(__name__)
|
| 72 |
|
| 73 |
+
# Timeouts + sizes: all named constants, no magic numbers in dispatch logic.
|
| 74 |
_SYNC_JOIN_TIMEOUT = 5.0 # wait this long for previous sync_turn write
|
| 75 |
_SHUTDOWN_JOIN_TIMEOUT = 10.0 # wait this long on shutdown
|
| 76 |
_MIN_QUERY_LEN = 10 # skip tiny prefetch queries (noise)
|
|
|
|
| 99 |
|
| 100 |
|
| 101 |
# ---------------------------------------------------------------------------
|
| 102 |
+
# Tool schemas. OpenAI function-calling shape
|
| 103 |
# ---------------------------------------------------------------------------
|
| 104 |
|
| 105 |
REMEMBER_SCHEMA = {
|
|
|
|
| 108 |
"Upsert a structured fact into Sibyl's warm-entity tier. Use for "
|
| 109 |
"anything worth remembering across sessions: project decisions, user "
|
| 110 |
"preferences, API quirks, conventions. (category, name) is the unique "
|
| 111 |
+
"key: re-calling with the same pair overwrites."
|
| 112 |
),
|
| 113 |
"parameters": {
|
| 114 |
"type": "object",
|
|
|
|
| 139 |
"description": (
|
| 140 |
"Look up a single entity by (category, name). Returns the entity row "
|
| 141 |
"(or null if absent) shaped {id, tenant_id, category, name, status, "
|
| 142 |
+
"body, created_at, updated_at}: the user data is under .body. Use "
|
| 143 |
"when you know exactly what to fetch; use sibyl_search for fuzzy/keyword lookup."
|
| 144 |
),
|
| 145 |
"parameters": {
|
|
|
|
| 163 |
"parameters": {
|
| 164 |
"type": "object",
|
| 165 |
"properties": {
|
| 166 |
+
"query": {"type": "string", "description": "Search query. User input is sanitized as a single FTS5 phrase: column-filter syntax (name:foo) is treated as literal text."},
|
| 167 |
"limit": {
|
| 168 |
"type": "integer",
|
| 169 |
"description": f"Max results (default {_DEFAULT_SEARCH_LIMIT}).",
|
|
|
|
| 226 |
return "sibyl"
|
| 227 |
|
| 228 |
def is_available(self) -> bool:
|
| 229 |
+
"""Cheap local check: no network, no DB open."""
|
| 230 |
try:
|
| 231 |
import sibyl_memory_hermes # noqa: F401
|
| 232 |
return True
|
|
|
|
| 282 |
return ""
|
| 283 |
if not hits:
|
| 284 |
return ""
|
| 285 |
+
lines = ["## Sibyl Memory: relevant context"]
|
| 286 |
for hit in hits:
|
| 287 |
tier = hit.get("tier", "?")
|
| 288 |
category = hit.get("category", "")
|
|
|
|
| 297 |
return block[:_MAX_PREFETCH_CHARS]
|
| 298 |
|
| 299 |
def queue_prefetch(self, query: str, *, session_id: str = "") -> None:
|
| 300 |
+
# Sibyl is local SQLite: prefetch() runs synchronously and is fast.
|
| 301 |
# Nothing to queue.
|
| 302 |
pass
|
| 303 |
|
|
|
|
| 342 |
# Exponential backoff for SQLITE_BUSY / transient errors
|
| 343 |
time.sleep(_BUSY_RETRY_BACKOFF * (2 ** (attempt - 1)))
|
| 344 |
continue
|
| 345 |
+
# Final attempt failed: escalate from debug to warning
|
| 346 |
# so users see drops in production logs.
|
| 347 |
logger.warning(
|
| 348 |
"Sibyl sync_turn dropped a journal turn after %d attempts: %s",
|
|
|
|
| 478 |
metadata: dict[str, Any] | None = None) -> None:
|
| 479 |
"""Mirror built-in `memory` tool writes into Sibyl's warm tier.
|
| 480 |
|
| 481 |
+
Accepts metadata even though we treat it as informational only -
|
| 482 |
ignoring the kwarg would TypeError under strict callers.
|
| 483 |
"""
|
| 484 |
if not self._sibyl or not content:
|
|
|
|
| 499 |
# -- config ------------------------------------------------------------
|
| 500 |
|
| 501 |
def get_config_schema(self) -> list[dict[str, Any]]:
|
| 502 |
+
"""No Hermes-side config: prerequisite is the `sibyl init` CLI.
|
| 503 |
|
| 504 |
Sibyl manages its own credentials and identity outside Hermes:
|
| 505 |
running `sibyl init` writes ~/.sibyl-memory/credentials.json,
|
| 506 |
which the SDK auto-loads at construction time. We deliberately
|
| 507 |
return [] here so `hermes memory setup` does NOT double-prompt
|
| 508 |
+
for credentials that already live in the Sibyl native file -
|
| 509 |
running both flows would diverge tenant ids and confuse users.
|
| 510 |
|
| 511 |
If a future version needs Hermes-side overrides (alt db_path,
|
| 512 |
explicit tenant_id for testing), add them here as non-secret
|
| 513 |
+
fields: keep secrets in credentials.json so there's one
|
| 514 |
source of truth.
|
| 515 |
"""
|
| 516 |
return []
|
| 517 |
|
| 518 |
def save_config(self, values: dict[str, Any], hermes_home: str) -> None:
|
| 519 |
+
# Nothing to persist: values are read live from credentials.json and
|
| 520 |
# constructor args. This stays a no-op until a hermes-side config
|
| 521 |
# file is actually needed.
|
| 522 |
return
|
sibyl-memory-hermes/src/sibyl_memory_hermes/_hermes_plugin/plugin.yaml
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
name: sibyl
|
| 2 |
-
description: Sibyl Memory
|
| 3 |
version: 0.3.1
|
| 4 |
homepage: https://sibyllabs.org/memory
|
| 5 |
author: SIBYL, Sibyl Labs LLC
|
|
|
|
| 1 |
name: sibyl
|
| 2 |
+
description: Sibyl Memory: local-first, SQLite-backed, structured-tier memory (warm entities, hot state, cold journal, reference docs). Backed by sibyl-memory-hermes.
|
| 3 |
version: 0.3.1
|
| 4 |
homepage: https://sibyllabs.org/memory
|
| 5 |
author: SIBYL, Sibyl Labs LLC
|
sibyl-memory-hermes/src/sibyl_memory_hermes/credentials.py
CHANGED
|
@@ -55,8 +55,8 @@ class Credentials:
|
|
| 55 |
the server re-verifies. Mismatches surface as `credentials_tamper_suspected`
|
| 56 |
telemetry. The authoritative tier comes from the database regardless.
|
| 57 |
|
| 58 |
-
schema_version 1 credentials (no signature) still load
|
| 59 |
-
None
|
| 60 |
request and the server skips the tamper check."""
|
| 61 |
|
| 62 |
account_id: str
|
|
@@ -79,7 +79,7 @@ def load_credentials(path: str | Path = DEFAULT_CRED_PATH) -> Credentials:
|
|
| 79 |
redirect this file to read from /dev/null or any sensitive path. We
|
| 80 |
use ``Path.is_symlink()`` to detect, then ``lstat`` to confirm the
|
| 81 |
file type. On detection, raises ``CredentialsNotFoundError`` (the
|
| 82 |
-
safe default
|
| 83 |
|
| 84 |
Raises:
|
| 85 |
CredentialsNotFoundError: file missing or symlinked
|
|
@@ -87,7 +87,7 @@ def load_credentials(path: str | Path = DEFAULT_CRED_PATH) -> Credentials:
|
|
| 87 |
OSError: I/O failure reading the file
|
| 88 |
"""
|
| 89 |
resolved = Path(path).expanduser()
|
| 90 |
-
# SEC-11: detect symlinks BEFORE resolve()
|
| 91 |
if resolved.is_symlink():
|
| 92 |
raise CredentialsNotFoundError(resolved)
|
| 93 |
resolved = resolved.resolve()
|
|
@@ -129,7 +129,7 @@ def write_credentials(creds: Credentials, path: str | Path = DEFAULT_CRED_PATH)
|
|
| 129 |
``os.open(O_WRONLY|O_CREAT|O_EXCL|O_NOFOLLOW, 0o600)``. Previously
|
| 130 |
used ``tmp.write_text()`` then ``os.chmod(0o600)``, leaving a
|
| 131 |
world-readable window between syscalls. Now mode is set by the
|
| 132 |
-
kernel at file-creation time
|
| 133 |
|
| 134 |
Used by `sibyl init`.
|
| 135 |
"""
|
|
|
|
| 55 |
the server re-verifies. Mismatches surface as `credentials_tamper_suspected`
|
| 56 |
telemetry. The authoritative tier comes from the database regardless.
|
| 57 |
|
| 58 |
+
schema_version 1 credentials (no signature) still load: old fields are
|
| 59 |
+
None: and continue to work unsigned. The SDK just sends an unsigned
|
| 60 |
request and the server skips the tamper check."""
|
| 61 |
|
| 62 |
account_id: str
|
|
|
|
| 79 |
redirect this file to read from /dev/null or any sensitive path. We
|
| 80 |
use ``Path.is_symlink()`` to detect, then ``lstat`` to confirm the
|
| 81 |
file type. On detection, raises ``CredentialsNotFoundError`` (the
|
| 82 |
+
safe default: caller falls back to DEFAULT_TENANT).
|
| 83 |
|
| 84 |
Raises:
|
| 85 |
CredentialsNotFoundError: file missing or symlinked
|
|
|
|
| 87 |
OSError: I/O failure reading the file
|
| 88 |
"""
|
| 89 |
resolved = Path(path).expanduser()
|
| 90 |
+
# SEC-11: detect symlinks BEFORE resolve(): resolve follows them silently.
|
| 91 |
if resolved.is_symlink():
|
| 92 |
raise CredentialsNotFoundError(resolved)
|
| 93 |
resolved = resolved.resolve()
|
|
|
|
| 129 |
``os.open(O_WRONLY|O_CREAT|O_EXCL|O_NOFOLLOW, 0o600)``. Previously
|
| 130 |
used ``tmp.write_text()`` then ``os.chmod(0o600)``, leaving a
|
| 131 |
world-readable window between syscalls. Now mode is set by the
|
| 132 |
+
kernel at file-creation time: no race.
|
| 133 |
|
| 134 |
Used by `sibyl init`.
|
| 135 |
"""
|
sibyl-memory-hermes/src/sibyl_memory_hermes/install_plugin.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""`sibyl-memory-hermes install-plugin`
|
| 2 |
|
| 3 |
Hermes' loader does NOT use pip entry points (verified against
|
| 4 |
plugins/memory/__init__.py source 2026-05-17). It scans the filesystem
|
|
@@ -95,7 +95,7 @@ def _looks_like_sibyl_install(dest: Path) -> bool:
|
|
| 95 |
content = yaml_path.read_text(encoding="utf-8")
|
| 96 |
except OSError:
|
| 97 |
return False
|
| 98 |
-
# Loose match
|
| 99 |
for line in content.splitlines()[:10]:
|
| 100 |
stripped = line.strip().lower()
|
| 101 |
if stripped.startswith("name:") and "sibyl" in stripped:
|
|
@@ -162,7 +162,7 @@ def install(hermes_home: Path, force: bool, dry_run: bool) -> int:
|
|
| 162 |
print(a.section_header("next steps", subtitle="three to go Β· then your agent has memory"))
|
| 163 |
print()
|
| 164 |
|
| 165 |
-
# Step 1
|
| 166 |
print(f" {a.chip('1', palette='accent')} {a.bold('Activate Sibyl in your Hermes config')}")
|
| 167 |
print(f" {a.color(str(hermes_home / 'config.yaml'), a.INK)}")
|
| 168 |
print()
|
|
@@ -170,15 +170,15 @@ def install(hermes_home: Path, force: bool, dry_run: bool) -> int:
|
|
| 170 |
print(f" {a.color('provider:', a.ACCENT)} {a.color('sibyl', a.INK)}")
|
| 171 |
print()
|
| 172 |
|
| 173 |
-
# Step 2
|
| 174 |
print(f" {a.chip('2', palette='accent')} {a.bold('Bind your account')} {a.dim('(optional Β· lifts the 2 MB free-tier cap)')}")
|
| 175 |
print(f" {a.color('sibyl init', a.INK)}")
|
| 176 |
print(a.dim(" three paths: desktop wallet Β· email + code Β· USDC-send from mobile"))
|
| 177 |
-
print(a.dim(" defer if you want
|
| 178 |
print()
|
| 179 |
|
| 180 |
-
# Step 3
|
| 181 |
-
print(f" {a.chip('3', palette='accent')} {a.bold('Start Hermes
|
| 182 |
print(a.dim(" tools available to the agent:"))
|
| 183 |
for tool in ("sibyl_remember", "sibyl_recall", "sibyl_search", "sibyl_list"):
|
| 184 |
print(f" {a.color(a.GLYPH_BULLET, a.PULSE)} {a.color(tool, a.INK)}")
|
|
@@ -193,7 +193,7 @@ def install(hermes_home: Path, force: bool, dry_run: bool) -> int:
|
|
| 193 |
|
| 194 |
def uninstall(hermes_home: Path, dry_run: bool) -> int:
|
| 195 |
dest = _plugin_dest(hermes_home)
|
| 196 |
-
# ββ HEAVY: removal is also ceremonial
|
| 197 |
print_banner()
|
| 198 |
print(a.section_header("uninstall-plugin",
|
| 199 |
subtitle="remove sibyl from this hermes install"))
|
|
@@ -202,7 +202,7 @@ def uninstall(hermes_home: Path, dry_run: bool) -> int:
|
|
| 202 |
print(a.kv("Plugin dest", str(dest)))
|
| 203 |
print()
|
| 204 |
if not dest.exists():
|
| 205 |
-
print(a.warn_line("Nothing to uninstall
|
| 206 |
return 0
|
| 207 |
if dest.is_symlink():
|
| 208 |
print(a.err_line(f"Refused: {dest} is a symlink."))
|
|
|
|
| 1 |
+
"""`sibyl-memory-hermes install-plugin`: installs the Sibyl adapter into Hermes.
|
| 2 |
|
| 3 |
Hermes' loader does NOT use pip entry points (verified against
|
| 4 |
plugins/memory/__init__.py source 2026-05-17). It scans the filesystem
|
|
|
|
| 95 |
content = yaml_path.read_text(encoding="utf-8")
|
| 96 |
except OSError:
|
| 97 |
return False
|
| 98 |
+
# Loose match: yaml has `name: sibyl` somewhere near the top
|
| 99 |
for line in content.splitlines()[:10]:
|
| 100 |
stripped = line.strip().lower()
|
| 101 |
if stripped.startswith("name:") and "sibyl" in stripped:
|
|
|
|
| 162 |
print(a.section_header("next steps", subtitle="three to go Β· then your agent has memory"))
|
| 163 |
print()
|
| 164 |
|
| 165 |
+
# Step 1: activate in config.yaml
|
| 166 |
print(f" {a.chip('1', palette='accent')} {a.bold('Activate Sibyl in your Hermes config')}")
|
| 167 |
print(f" {a.color(str(hermes_home / 'config.yaml'), a.INK)}")
|
| 168 |
print()
|
|
|
|
| 170 |
print(f" {a.color('provider:', a.ACCENT)} {a.color('sibyl', a.INK)}")
|
| 171 |
print()
|
| 172 |
|
| 173 |
+
# Step 2: bind account
|
| 174 |
print(f" {a.chip('2', palette='accent')} {a.bold('Bind your account')} {a.dim('(optional Β· lifts the 2 MB free-tier cap)')}")
|
| 175 |
print(f" {a.color('sibyl init', a.INK)}")
|
| 176 |
print(a.dim(" three paths: desktop wallet Β· email + code Β· USDC-send from mobile"))
|
| 177 |
+
print(a.dim(" defer if you want: the plugin runs on a local default tenant without it"))
|
| 178 |
print()
|
| 179 |
|
| 180 |
+
# Step 3: start hermes
|
| 181 |
+
print(f" {a.chip('3', palette='accent')} {a.bold('Start Hermes: your agent now has memory')}")
|
| 182 |
print(a.dim(" tools available to the agent:"))
|
| 183 |
for tool in ("sibyl_remember", "sibyl_recall", "sibyl_search", "sibyl_list"):
|
| 184 |
print(f" {a.color(a.GLYPH_BULLET, a.PULSE)} {a.color(tool, a.INK)}")
|
|
|
|
| 193 |
|
| 194 |
def uninstall(hermes_home: Path, dry_run: bool) -> int:
|
| 195 |
dest = _plugin_dest(hermes_home)
|
| 196 |
+
# ββ HEAVY: removal is also ceremonial: banner + section header.
|
| 197 |
print_banner()
|
| 198 |
print(a.section_header("uninstall-plugin",
|
| 199 |
subtitle="remove sibyl from this hermes install"))
|
|
|
|
| 202 |
print(a.kv("Plugin dest", str(dest)))
|
| 203 |
print()
|
| 204 |
if not dest.exists():
|
| 205 |
+
print(a.warn_line("Nothing to uninstall: plugin directory does not exist."))
|
| 206 |
return 0
|
| 207 |
if dest.is_symlink():
|
| 208 |
print(a.err_line(f"Refused: {dest} is a symlink."))
|
sibyl-memory-hermes/src/sibyl_memory_hermes/provider.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""SibylMemoryProvider
|
| 2 |
|
| 3 |
DESIGN NOTES
|
| 4 |
============
|
|
@@ -16,7 +16,7 @@ that delegates to this class. The split is intentional:
|
|
| 16 |
Prior versions (v0.2.x) attempted conditional inheritance from Hermes'
|
| 17 |
ABC at import time, but the import path was wrong (`hermes_agent.memory`
|
| 18 |
vs the actual `agent.memory_provider`), so the soft-bind silently failed
|
| 19 |
-
on every install. v0.3.0 removes the conditional inheritance entirely
|
| 20 |
the adapter handles all Hermes glue. See packages/sibyl-memory-hermes/
|
| 21 |
CHANGELOG.md for the full ratification of this architectural shift.
|
| 22 |
|
|
@@ -64,7 +64,7 @@ class SibylMemoryProvider:
|
|
| 64 |
is loaded; if also missing, DEFAULT_TENANT is used.
|
| 65 |
credentials_path: override for credentials.json discovery.
|
| 66 |
require_credentials: if True, raise CredentialsNotFoundError when
|
| 67 |
-
the file is missing. Default False
|
| 68 |
DEFAULT_TENANT so callers can run pre-activation.
|
| 69 |
autoload_credentials: if True (default), read credentials.json on
|
| 70 |
construction and apply tenant_id from it.
|
|
@@ -111,7 +111,7 @@ class SibylMemoryProvider:
|
|
| 111 |
client_account_id = creds.account_id if creds else None
|
| 112 |
client_session_token = creds.session_token if creds else None
|
| 113 |
# Build the canonical signed-claim object that matches the server's
|
| 114 |
-
# SIGNING_FIELDS shape. Order doesn't matter on the JSON wire
|
| 115 |
# the server canonicalizes by field name.
|
| 116 |
client_claim = None
|
| 117 |
client_signature = None
|
|
@@ -136,7 +136,7 @@ class SibylMemoryProvider:
|
|
| 136 |
credentials_signature=client_signature,
|
| 137 |
)
|
| 138 |
|
| 139 |
-
# v0.3.0: no conditional super().__init__()
|
| 140 |
# an ABC subclass. Hermes binding lives in the bundled adapter.
|
| 141 |
|
| 142 |
# ------------------------------------------------------------------
|
|
@@ -183,7 +183,7 @@ class SibylMemoryProvider:
|
|
| 183 |
# ==================================================================
|
| 184 |
# The Hermes v0.10.0 memory contract uses save_context / load_context
|
| 185 |
# for the per-turn agent memory loop. We map these onto the journal
|
| 186 |
-
# (COLD) tier
|
| 187 |
#
|
| 188 |
# remember() / recall() / forget() are the higher-level fact-store
|
| 189 |
# operations that map onto entities (WARM tier).
|
|
@@ -210,7 +210,7 @@ class SibylMemoryProvider:
|
|
| 210 |
return self._client.read_events(limit=limit)
|
| 211 |
|
| 212 |
def clear_context(self) -> None:
|
| 213 |
-
"""No-op for now
|
| 214 |
|
| 215 |
If a caller genuinely wants to wipe the journal, they should drop
|
| 216 |
the database file. This method exists for Hermes contract
|
|
@@ -241,7 +241,7 @@ class SibylMemoryProvider:
|
|
| 241 |
Note: the return shape is the full row wrapper, not just the body
|
| 242 |
dict. To get the user payload only, use ``recall(...).["body"]``.
|
| 243 |
State and reference tier reads (``get_state``, ``get_reference``)
|
| 244 |
-
return slimmer ``{body, updated_at}`` shapes
|
| 245 |
intentional (entities carry more provenance) and documented here
|
| 246 |
per audit H2.
|
| 247 |
|
|
@@ -253,7 +253,7 @@ class SibylMemoryProvider:
|
|
| 253 |
T2-2 fix: previously caught bare ``Exception``, which swallowed
|
| 254 |
StorageError / TenantError / SchemaError as "not found". That
|
| 255 |
masked underlying-storage failures end-to-end. Now narrows to
|
| 256 |
-
NotFoundError only
|
| 257 |
caller can surface or retry.
|
| 258 |
"""
|
| 259 |
try:
|
|
@@ -261,7 +261,7 @@ class SibylMemoryProvider:
|
|
| 261 |
except NotFoundError:
|
| 262 |
return None
|
| 263 |
|
| 264 |
-
def list( # noqa: A003
|
| 265 |
self,
|
| 266 |
category: str | None = None,
|
| 267 |
*,
|
|
@@ -278,7 +278,7 @@ class SibylMemoryProvider:
|
|
| 278 |
StorageError: backend failure
|
| 279 |
TenantError: misconfigured tenant_id
|
| 280 |
|
| 281 |
-
Does NOT raise on missing entity
|
| 282 |
"""
|
| 283 |
return self._client.delete_entity(category, name)
|
| 284 |
|
|
@@ -299,7 +299,7 @@ class SibylMemoryProvider:
|
|
| 299 |
CapExceededError: archive would push the DB past the free-tier cap
|
| 300 |
StorageError: backend failure
|
| 301 |
|
| 302 |
-
Unlike ``forget``, ``archive`` is strict
|
| 303 |
NotFoundError rather than no-oping (audit H3).
|
| 304 |
"""
|
| 305 |
return self._client.archive_entity(category, name, reason=reason)
|
|
@@ -343,7 +343,7 @@ class SibylMemoryProvider:
|
|
| 343 |
"""Set a reference-tier document.
|
| 344 |
|
| 345 |
Note: reference bodies are plain ``str`` (markdown, runbooks,
|
| 346 |
-
notes), not dict
|
| 347 |
which take dict|list bodies. Use the ``metadata`` kwarg for any
|
| 348 |
structured side-data.
|
| 349 |
|
|
@@ -374,7 +374,7 @@ class SibylMemoryProvider:
|
|
| 374 |
"""Cross-tier FTS5 full-text search across all four searchable tiers.
|
| 375 |
|
| 376 |
v0.3.1: search now spans entities + state + reference + journal
|
| 377 |
-
(was: entities only
|
| 378 |
tiers" was not yet true in v0.3.0).
|
| 379 |
|
| 380 |
Returns: list of tier-tagged hits, each shaped::
|
|
@@ -394,7 +394,7 @@ class SibylMemoryProvider:
|
|
| 394 |
restrict scope. ``prefix=True`` enables prefix matching on the
|
| 395 |
last token.
|
| 396 |
|
| 397 |
-
Query is sanitized as a single FTS5 phrase
|
| 398 |
syntax (``name:foo``) is treated as literal text. Empty / invalid
|
| 399 |
queries return ``[]``.
|
| 400 |
|
|
@@ -410,7 +410,7 @@ class SibylMemoryProvider:
|
|
| 410 |
# Diagnostics
|
| 411 |
# ------------------------------------------------------------------
|
| 412 |
def health(self) -> dict[str, Any]:
|
| 413 |
-
"""Return a small diagnostic dict
|
| 414 |
db_path = self._client.storage.db_path
|
| 415 |
return {
|
| 416 |
"ok": True,
|
|
|
|
| 1 |
+
"""SibylMemoryProvider: framework-agnostic Sibyl Memory SDK class.
|
| 2 |
|
| 3 |
DESIGN NOTES
|
| 4 |
============
|
|
|
|
| 16 |
Prior versions (v0.2.x) attempted conditional inheritance from Hermes'
|
| 17 |
ABC at import time, but the import path was wrong (`hermes_agent.memory`
|
| 18 |
vs the actual `agent.memory_provider`), so the soft-bind silently failed
|
| 19 |
+
on every install. v0.3.0 removes the conditional inheritance entirely -
|
| 20 |
the adapter handles all Hermes glue. See packages/sibyl-memory-hermes/
|
| 21 |
CHANGELOG.md for the full ratification of this architectural shift.
|
| 22 |
|
|
|
|
| 64 |
is loaded; if also missing, DEFAULT_TENANT is used.
|
| 65 |
credentials_path: override for credentials.json discovery.
|
| 66 |
require_credentials: if True, raise CredentialsNotFoundError when
|
| 67 |
+
the file is missing. Default False: degrade to
|
| 68 |
DEFAULT_TENANT so callers can run pre-activation.
|
| 69 |
autoload_credentials: if True (default), read credentials.json on
|
| 70 |
construction and apply tenant_id from it.
|
|
|
|
| 111 |
client_account_id = creds.account_id if creds else None
|
| 112 |
client_session_token = creds.session_token if creds else None
|
| 113 |
# Build the canonical signed-claim object that matches the server's
|
| 114 |
+
# SIGNING_FIELDS shape. Order doesn't matter on the JSON wire -
|
| 115 |
# the server canonicalizes by field name.
|
| 116 |
client_claim = None
|
| 117 |
client_signature = None
|
|
|
|
| 136 |
credentials_signature=client_signature,
|
| 137 |
)
|
| 138 |
|
| 139 |
+
# v0.3.0: no conditional super().__init__(): class is no longer
|
| 140 |
# an ABC subclass. Hermes binding lives in the bundled adapter.
|
| 141 |
|
| 142 |
# ------------------------------------------------------------------
|
|
|
|
| 183 |
# ==================================================================
|
| 184 |
# The Hermes v0.10.0 memory contract uses save_context / load_context
|
| 185 |
# for the per-turn agent memory loop. We map these onto the journal
|
| 186 |
+
# (COLD) tier: every turn is an event in the agent's session log.
|
| 187 |
#
|
| 188 |
# remember() / recall() / forget() are the higher-level fact-store
|
| 189 |
# operations that map onto entities (WARM tier).
|
|
|
|
| 210 |
return self._client.read_events(limit=limit)
|
| 211 |
|
| 212 |
def clear_context(self) -> None:
|
| 213 |
+
"""No-op for now: journal events are append-only by design.
|
| 214 |
|
| 215 |
If a caller genuinely wants to wipe the journal, they should drop
|
| 216 |
the database file. This method exists for Hermes contract
|
|
|
|
| 241 |
Note: the return shape is the full row wrapper, not just the body
|
| 242 |
dict. To get the user payload only, use ``recall(...).["body"]``.
|
| 243 |
State and reference tier reads (``get_state``, ``get_reference``)
|
| 244 |
+
return slimmer ``{body, updated_at}`` shapes: that asymmetry is
|
| 245 |
intentional (entities carry more provenance) and documented here
|
| 246 |
per audit H2.
|
| 247 |
|
|
|
|
| 253 |
T2-2 fix: previously caught bare ``Exception``, which swallowed
|
| 254 |
StorageError / TenantError / SchemaError as "not found". That
|
| 255 |
masked underlying-storage failures end-to-end. Now narrows to
|
| 256 |
+
NotFoundError only: every other exception propagates so the
|
| 257 |
caller can surface or retry.
|
| 258 |
"""
|
| 259 |
try:
|
|
|
|
| 261 |
except NotFoundError:
|
| 262 |
return None
|
| 263 |
|
| 264 |
+
def list( # noqa: A003. Hermes-compatible name
|
| 265 |
self,
|
| 266 |
category: str | None = None,
|
| 267 |
*,
|
|
|
|
| 278 |
StorageError: backend failure
|
| 279 |
TenantError: misconfigured tenant_id
|
| 280 |
|
| 281 |
+
Does NOT raise on missing entity: returns False instead (audit H3).
|
| 282 |
"""
|
| 283 |
return self._client.delete_entity(category, name)
|
| 284 |
|
|
|
|
| 299 |
CapExceededError: archive would push the DB past the free-tier cap
|
| 300 |
StorageError: backend failure
|
| 301 |
|
| 302 |
+
Unlike ``forget``, ``archive`` is strict: missing entities raise
|
| 303 |
NotFoundError rather than no-oping (audit H3).
|
| 304 |
"""
|
| 305 |
return self._client.archive_entity(category, name, reason=reason)
|
|
|
|
| 343 |
"""Set a reference-tier document.
|
| 344 |
|
| 345 |
Note: reference bodies are plain ``str`` (markdown, runbooks,
|
| 346 |
+
notes), not dict: intentionally different from entity / state
|
| 347 |
which take dict|list bodies. Use the ``metadata`` kwarg for any
|
| 348 |
structured side-data.
|
| 349 |
|
|
|
|
| 374 |
"""Cross-tier FTS5 full-text search across all four searchable tiers.
|
| 375 |
|
| 376 |
v0.3.1: search now spans entities + state + reference + journal
|
| 377 |
+
(was: entities only: the marketing claim of "search across all
|
| 378 |
tiers" was not yet true in v0.3.0).
|
| 379 |
|
| 380 |
Returns: list of tier-tagged hits, each shaped::
|
|
|
|
| 394 |
restrict scope. ``prefix=True`` enables prefix matching on the
|
| 395 |
last token.
|
| 396 |
|
| 397 |
+
Query is sanitized as a single FTS5 phrase: column-filter
|
| 398 |
syntax (``name:foo``) is treated as literal text. Empty / invalid
|
| 399 |
queries return ``[]``.
|
| 400 |
|
|
|
|
| 410 |
# Diagnostics
|
| 411 |
# ------------------------------------------------------------------
|
| 412 |
def health(self) -> dict[str, Any]:
|
| 413 |
+
"""Return a small diagnostic dict: used by `sibyl status`."""
|
| 414 |
db_path = self._client.storage.db_path
|
| 415 |
return {
|
| 416 |
"ok": True,
|
sibyl-memory-hermes/tests/test_adapter.py
CHANGED
|
@@ -35,7 +35,7 @@ from sibyl_memory_hermes._hermes_plugin.adapter import (
|
|
| 35 |
# Module loadability
|
| 36 |
# ----------------------------------------------------------------------
|
| 37 |
def test_module_imports_without_hermes() -> None:
|
| 38 |
-
"""Off-Hermes the adapter still imports
|
| 39 |
guards land their no-op fallbacks. Tests can therefore exercise it
|
| 40 |
without spinning up a Hermes runtime."""
|
| 41 |
# _HERMES_AVAILABLE reflects whether hermes-agent is installed.
|
|
@@ -50,7 +50,7 @@ def test_module_imports_without_hermes() -> None:
|
|
| 50 |
|
| 51 |
|
| 52 |
def test_register_function_exists() -> None:
|
| 53 |
-
"""register(ctx) is the Hermes plugin entry point
|
| 54 |
filesystem loader to find it."""
|
| 55 |
assert callable(adapter_module.register)
|
| 56 |
|
|
@@ -192,10 +192,10 @@ def test_handle_tool_call_search_sanitizes_malformed_query(tmp_path: Path) -> No
|
|
| 192 |
"""SEC-3 hardening: malformed FTS5 queries (unclosed quotes, column
|
| 193 |
filters) must not crash or leak SQL error text."""
|
| 194 |
adapter = _make_initialized_adapter(tmp_path)
|
| 195 |
-
# Unclosed quote
|
| 196 |
out = json.loads(adapter.handle_tool_call("sibyl_search", {"query": '"'}))
|
| 197 |
assert "results" in out
|
| 198 |
-
# Empty input
|
| 199 |
out2 = json.loads(adapter.handle_tool_call("sibyl_search", {"query": ""}))
|
| 200 |
assert "error" in out2 # query is required
|
| 201 |
|
|
@@ -223,7 +223,7 @@ def test_sync_turn_during_shutdown_skips(tmp_path: Path) -> None:
|
|
| 223 |
skipped via the shutdown flag check in the worker loop)."""
|
| 224 |
adapter = _make_initialized_adapter(tmp_path)
|
| 225 |
adapter.shutdown()
|
| 226 |
-
# Should not raise
|
| 227 |
adapter.sync_turn("user msg", "assistant reply")
|
| 228 |
|
| 229 |
|
|
|
|
| 35 |
# Module loadability
|
| 36 |
# ----------------------------------------------------------------------
|
| 37 |
def test_module_imports_without_hermes() -> None:
|
| 38 |
+
"""Off-Hermes the adapter still imports: the Hermes ABC + tool_error
|
| 39 |
guards land their no-op fallbacks. Tests can therefore exercise it
|
| 40 |
without spinning up a Hermes runtime."""
|
| 41 |
# _HERMES_AVAILABLE reflects whether hermes-agent is installed.
|
|
|
|
| 50 |
|
| 51 |
|
| 52 |
def test_register_function_exists() -> None:
|
| 53 |
+
"""register(ctx) is the Hermes plugin entry point: must exist for the
|
| 54 |
filesystem loader to find it."""
|
| 55 |
assert callable(adapter_module.register)
|
| 56 |
|
|
|
|
| 192 |
"""SEC-3 hardening: malformed FTS5 queries (unclosed quotes, column
|
| 193 |
filters) must not crash or leak SQL error text."""
|
| 194 |
adapter = _make_initialized_adapter(tmp_path)
|
| 195 |
+
# Unclosed quote: pre-v0.3.1 would surface OperationalError + db_path leak
|
| 196 |
out = json.loads(adapter.handle_tool_call("sibyl_search", {"query": '"'}))
|
| 197 |
assert "results" in out
|
| 198 |
+
# Empty input: should return empty results, not error
|
| 199 |
out2 = json.loads(adapter.handle_tool_call("sibyl_search", {"query": ""}))
|
| 200 |
assert "error" in out2 # query is required
|
| 201 |
|
|
|
|
| 223 |
skipped via the shutdown flag check in the worker loop)."""
|
| 224 |
adapter = _make_initialized_adapter(tmp_path)
|
| 225 |
adapter.shutdown()
|
| 226 |
+
# Should not raise: even though we shut down, the call itself is safe
|
| 227 |
adapter.sync_turn("user msg", "assistant reply")
|
| 228 |
|
| 229 |
|
sibyl-memory-hermes/tests/test_smoke.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 2 |
|
| 3 |
These exercise the public provider surface. They run against a fresh
|
| 4 |
SQLite DB per test via pytest tmp_path fixtures. Hermes is NOT required
|
| 5 |
-
to be installed
|
| 6 |
"""
|
| 7 |
from __future__ import annotations
|
| 8 |
|
|
@@ -229,7 +229,7 @@ def test_fts_search(tmp_path: Path) -> None:
|
|
| 229 |
|
| 230 |
# v0.3.1: provider.search() now returns cross-tier hits with a `key`
|
| 231 |
# field (was: entity rows with `name`). The shape is documented in
|
| 232 |
-
# MemoryClient.search()
|
| 233 |
results = provider.search("memory")
|
| 234 |
keys = {r["key"] for r in results if r["tier"] == "entity"}
|
| 235 |
assert "atlas" in keys
|
|
@@ -275,7 +275,7 @@ def test_health(tmp_path: Path) -> None:
|
|
| 275 |
assert h["schema_version"] >= 2
|
| 276 |
assert h["db_size_bytes"] >= 0
|
| 277 |
assert h["tier"] == "free"
|
| 278 |
-
# hermes_bound is deprecated since v0.3.0 and always False
|
| 279 |
# is the signal v0.4 cleanup is approaching. Tightened from bool-only check.
|
| 280 |
assert h["hermes_bound"] is False
|
| 281 |
|
|
|
|
| 2 |
|
| 3 |
These exercise the public provider surface. They run against a fresh
|
| 4 |
SQLite DB per test via pytest tmp_path fixtures. Hermes is NOT required
|
| 5 |
+
to be installed: the provider degrades gracefully.
|
| 6 |
"""
|
| 7 |
from __future__ import annotations
|
| 8 |
|
|
|
|
| 229 |
|
| 230 |
# v0.3.1: provider.search() now returns cross-tier hits with a `key`
|
| 231 |
# field (was: entity rows with `name`). The shape is documented in
|
| 232 |
+
# MemoryClient.search(): each hit is {tier, key, category, body, ...}.
|
| 233 |
results = provider.search("memory")
|
| 234 |
keys = {r["key"] for r in results if r["tier"] == "entity"}
|
| 235 |
assert "atlas" in keys
|
|
|
|
| 275 |
assert h["schema_version"] >= 2
|
| 276 |
assert h["db_size_bytes"] >= 0
|
| 277 |
assert h["tier"] == "free"
|
| 278 |
+
# hermes_bound is deprecated since v0.3.0 and always False: the asymmetry
|
| 279 |
# is the signal v0.4 cleanup is approaching. Tightened from bool-only check.
|
| 280 |
assert h["hermes_bound"] is False
|
| 281 |
|
sibyl-memory-mcp/CHANGELOG.md
CHANGED
|
@@ -4,7 +4,7 @@ All notable changes to `sibyl-memory-mcp` are recorded here. Format follows
|
|
| 4 |
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning follows
|
| 5 |
[SemVer](https://semver.org/).
|
| 6 |
|
| 7 |
-
## [0.1.2]
|
| 8 |
|
| 9 |
KAPPA external-tester remediation release. v0.1.1 was functionally broken
|
| 10 |
on PyPI: `pip install sibyl-memory-mcp` followed by the entry-point invocation
|
|
@@ -17,7 +17,7 @@ clean-venv install smoke test in CI. Gap closed by the companion
|
|
| 17 |
|
| 18 |
### Fixed
|
| 19 |
|
| 20 |
-
- **KAPPA-BLOCKER**
|
| 21 |
venv. The fix lives in the companion `sibyl-memory-client` v0.4.0 which
|
| 22 |
exports `CapExceededError` and `TierVerificationError` from the
|
| 23 |
`.exceptions` submodule path. This release bumps the client pin to
|
|
@@ -39,7 +39,7 @@ clean-venv install smoke test in CI. Gap closed by the companion
|
|
| 39 |
|
| 40 |
---
|
| 41 |
|
| 42 |
-
## [0.1.1]
|
| 43 |
|
| 44 |
Audit-remediation release. v0.3.0 plugin-family pre-ship audit (2026-05-18T05:05Z)
|
| 45 |
flagged this package's `memory_record_event` tool as broken end-to-end (every
|
|
@@ -49,27 +49,27 @@ Companion releases: `sibyl-memory-client` v0.3.3, `sibyl-memory-hermes` v0.3.1,
|
|
| 49 |
|
| 50 |
### Fixed
|
| 51 |
|
| 52 |
-
- **C1**
|
| 53 |
``client.write_event(*, evaluated, acted, forward, extra, ts)``. The
|
| 54 |
previous call ``client.write_event(kind, body, category=category,
|
| 55 |
name=name)`` referenced parameters that don't exist and raised
|
| 56 |
TypeError on every invocation. The high-level (kind, body, category,
|
| 57 |
name) contract is preserved by translating: kind+body β `acted={kind,
|
| 58 |
body}`, optional category+name β `extra={category, name}`.
|
| 59 |
-
- **H2**
|
| 60 |
return shape into a flat response: `{ok, key, body: <user payload>,
|
| 61 |
updated_at: <iso ts>}`. Previously returned `body` containing the full
|
| 62 |
wrapper, so "body" meant two different things at different nesting
|
| 63 |
depths in the same response.
|
| 64 |
-
- **N3**
|
| 65 |
-
the SDK + Hermes adapter behavior
|
| 66 |
across all categories.
|
| 67 |
|
| 68 |
### Changed
|
| 69 |
|
| 70 |
-
- **P-H1**
|
| 71 |
on every tool call (reading schema.sql from disk + bootstrapping FTS5
|
| 72 |
-
vtables
|
| 73 |
mtime change so `sibyl upgrade` is still picked up without a server
|
| 74 |
restart. Net effect: agent recall/search latency drops to single-digit
|
| 75 |
milliseconds.
|
|
@@ -79,11 +79,11 @@ Companion releases: `sibyl-memory-client` v0.3.3, `sibyl-memory-hermes` v0.3.1,
|
|
| 79 |
description and tool docstring now match the actual behavior.
|
| 80 |
- Query sanitization handled by the client SDK (FTS5 column-filter
|
| 81 |
syntax can't break out into the parser). MCP server didn't need
|
| 82 |
-
its own sanitization
|
| 83 |
|
| 84 |
### Security
|
| 85 |
|
| 86 |
-
- **SEC-4 / SEC-11**
|
| 87 |
Previously called `read_text()` on the resolved path, which would
|
| 88 |
silently follow.
|
| 89 |
|
|
@@ -92,11 +92,11 @@ Companion releases: `sibyl-memory-client` v0.3.3, `sibyl-memory-hermes` v0.3.1,
|
|
| 92 |
- `sibyl-memory-client>=0.3.3` (was `>=0.3.2`)
|
| 93 |
- `sibyl-memory-hermes>=0.3.1` (was `>=0.2.2`)
|
| 94 |
|
| 95 |
-
## [0.1.0]
|
| 96 |
|
| 97 |
Initial release. Operator question 2026-05-17: "currently i'm only seeing
|
| 98 |
instructions for Hermes agent, how could this be used with claude code or
|
| 99 |
-
codex?"
|
| 100 |
Code and Codex CLI consume MCP, so a single server unlocks both.
|
| 101 |
|
| 102 |
### Added
|
|
@@ -104,14 +104,14 @@ Code and Codex CLI consume MCP, so a single server unlocks both.
|
|
| 104 |
- **MCP server** (`sibyl-memory-mcp` console script + `python -m sibyl_memory_mcp`)
|
| 105 |
using the official `mcp>=1.0.0` Python SDK with FastMCP convenience layer.
|
| 106 |
- **8 tools** exposed over stdio transport:
|
| 107 |
-
- `memory_remember`
|
| 108 |
-
- `memory_recall`
|
| 109 |
-
- `memory_search`
|
| 110 |
-
- `memory_list`
|
| 111 |
-
- `memory_forget`
|
| 112 |
-
- `memory_set_state`
|
| 113 |
-
- `memory_get_state`
|
| 114 |
-
- `memory_record_event`
|
| 115 |
- **Auto-reads** `~/.sibyl-memory/credentials.json` on every tool call so tier
|
| 116 |
changes from `sibyl upgrade` are picked up without restarting the server.
|
| 117 |
- **Typed error envelope** mapping SDK exceptions to MCP-friendly payloads:
|
|
@@ -123,7 +123,7 @@ Code and Codex CLI consume MCP, so a single server unlocks both.
|
|
| 123 |
### Design notes
|
| 124 |
|
| 125 |
- Re-opens `MemoryClient.local()` on every tool call. SQLite open is
|
| 126 |
-
sub-millisecond and this keeps the server stateless
|
| 127 |
in the process, every call sees the current credentials.
|
| 128 |
- Free-tier 2 MB cap is enforced server-side against the database (HMAC-signed
|
| 129 |
credentials prevent local tampering). The MCP server has no way to bypass it.
|
|
@@ -138,10 +138,10 @@ Code and Codex CLI consume MCP, so a single server unlocks both.
|
|
| 138 |
|
| 139 |
### Compatible with
|
| 140 |
|
| 141 |
-
- **Claude Code**
|
| 142 |
-
- **Codex CLI**
|
| 143 |
-
- **Cursor**
|
| 144 |
-
- **Continue**
|
| 145 |
- Any other MCP-spec-compliant client.
|
| 146 |
|
| 147 |
### License
|
|
|
|
| 4 |
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning follows
|
| 5 |
[SemVer](https://semver.org/).
|
| 6 |
|
| 7 |
+
## [0.1.2] - 2026-05-18
|
| 8 |
|
| 9 |
KAPPA external-tester remediation release. v0.1.1 was functionally broken
|
| 10 |
on PyPI: `pip install sibyl-memory-mcp` followed by the entry-point invocation
|
|
|
|
| 17 |
|
| 18 |
### Fixed
|
| 19 |
|
| 20 |
+
- **KAPPA-BLOCKER**. `sibyl-memory-mcp` now imports cleanly in a fresh
|
| 21 |
venv. The fix lives in the companion `sibyl-memory-client` v0.4.0 which
|
| 22 |
exports `CapExceededError` and `TierVerificationError` from the
|
| 23 |
`.exceptions` submodule path. This release bumps the client pin to
|
|
|
|
| 39 |
|
| 40 |
---
|
| 41 |
|
| 42 |
+
## [0.1.1] - 2026-05-18
|
| 43 |
|
| 44 |
Audit-remediation release. v0.3.0 plugin-family pre-ship audit (2026-05-18T05:05Z)
|
| 45 |
flagged this package's `memory_record_event` tool as broken end-to-end (every
|
|
|
|
| 49 |
|
| 50 |
### Fixed
|
| 51 |
|
| 52 |
+
- **C1**. `memory_record_event` now calls the SDK's actual signature
|
| 53 |
``client.write_event(*, evaluated, acted, forward, extra, ts)``. The
|
| 54 |
previous call ``client.write_event(kind, body, category=category,
|
| 55 |
name=name)`` referenced parameters that don't exist and raised
|
| 56 |
TypeError on every invocation. The high-level (kind, body, category,
|
| 57 |
name) contract is preserved by translating: kind+body β `acted={kind,
|
| 58 |
body}`, optional category+name β `extra={category, name}`.
|
| 59 |
+
- **H2**. `memory_get_state` now unpacks the SDK's `{body, updated_at}`
|
| 60 |
return shape into a flat response: `{ok, key, body: <user payload>,
|
| 61 |
updated_at: <iso ts>}`. Previously returned `body` containing the full
|
| 62 |
wrapper, so "body" meant two different things at different nesting
|
| 63 |
depths in the same response.
|
| 64 |
+
- **N3**. `memory_list` `category` parameter is now Optional. Matches
|
| 65 |
+
the SDK + Hermes adapter behavior: pass it to filter, omit to list
|
| 66 |
across all categories.
|
| 67 |
|
| 68 |
### Changed
|
| 69 |
|
| 70 |
+
- **P-H1**. `MemoryClient` is cached at module scope. Previously rebuilt
|
| 71 |
on every tool call (reading schema.sql from disk + bootstrapping FTS5
|
| 72 |
+
vtables: 10-50 ms per call). Cache invalidates on credentials.json
|
| 73 |
mtime change so `sibyl upgrade` is still picked up without a server
|
| 74 |
restart. Net effect: agent recall/search latency drops to single-digit
|
| 75 |
milliseconds.
|
|
|
|
| 79 |
description and tool docstring now match the actual behavior.
|
| 80 |
- Query sanitization handled by the client SDK (FTS5 column-filter
|
| 81 |
syntax can't break out into the parser). MCP server didn't need
|
| 82 |
+
its own sanitization: it's downstream of the SDK fix.
|
| 83 |
|
| 84 |
### Security
|
| 85 |
|
| 86 |
+
- **SEC-4 / SEC-11**. `_load_credentials` refuses to follow symlinks.
|
| 87 |
Previously called `read_text()` on the resolved path, which would
|
| 88 |
silently follow.
|
| 89 |
|
|
|
|
| 92 |
- `sibyl-memory-client>=0.3.3` (was `>=0.3.2`)
|
| 93 |
- `sibyl-memory-hermes>=0.3.1` (was `>=0.2.2`)
|
| 94 |
|
| 95 |
+
## [0.1.0] - 2026-05-17
|
| 96 |
|
| 97 |
Initial release. Operator question 2026-05-17: "currently i'm only seeing
|
| 98 |
instructions for Hermes agent, how could this be used with claude code or
|
| 99 |
+
codex?": answer: an MCP server wrapping `MemoryClient.local()`. Both Claude
|
| 100 |
Code and Codex CLI consume MCP, so a single server unlocks both.
|
| 101 |
|
| 102 |
### Added
|
|
|
|
| 104 |
- **MCP server** (`sibyl-memory-mcp` console script + `python -m sibyl_memory_mcp`)
|
| 105 |
using the official `mcp>=1.0.0` Python SDK with FastMCP convenience layer.
|
| 106 |
- **8 tools** exposed over stdio transport:
|
| 107 |
+
- `memory_remember`. `set_entity(category, name, body)`
|
| 108 |
+
- `memory_recall`. `get_entity(category, name)`
|
| 109 |
+
- `memory_search`. `search_entities(query, limit)` (FTS5)
|
| 110 |
+
- `memory_list`. `list_entities(category, limit)`
|
| 111 |
+
- `memory_forget`. `archive_entity(category, name, reason)`
|
| 112 |
+
- `memory_set_state`. `set_state(key, body)` (HOT tier)
|
| 113 |
+
- `memory_get_state`. `get_state(key)`
|
| 114 |
+
- `memory_record_event`. `write_event(kind, body, category, name)` (COLD tier)
|
| 115 |
- **Auto-reads** `~/.sibyl-memory/credentials.json` on every tool call so tier
|
| 116 |
changes from `sibyl upgrade` are picked up without restarting the server.
|
| 117 |
- **Typed error envelope** mapping SDK exceptions to MCP-friendly payloads:
|
|
|
|
| 123 |
### Design notes
|
| 124 |
|
| 125 |
- Re-opens `MemoryClient.local()` on every tool call. SQLite open is
|
| 126 |
+
sub-millisecond and this keeps the server stateless: no stale tier cache
|
| 127 |
in the process, every call sees the current credentials.
|
| 128 |
- Free-tier 2 MB cap is enforced server-side against the database (HMAC-signed
|
| 129 |
credentials prevent local tampering). The MCP server has no way to bypass it.
|
|
|
|
| 138 |
|
| 139 |
### Compatible with
|
| 140 |
|
| 141 |
+
- **Claude Code**: add to `~/.claude/settings.json` or project `.mcp.json`
|
| 142 |
+
- **Codex CLI**: add to `~/.codex/config.toml`
|
| 143 |
+
- **Cursor**: add to `~/.cursor/mcp.json`
|
| 144 |
+
- **Continue**: add to `~/.continue/config.json` mcpServers block
|
| 145 |
- Any other MCP-spec-compliant client.
|
| 146 |
|
| 147 |
### License
|
sibyl-memory-mcp/README.md
CHANGED
|
@@ -71,8 +71,8 @@ Full docs at [docs.sibyllabs.org/memory/integrations](https://docs.sibyllabs.org
|
|
| 71 |
- **Free tier**: 8 tools work. Hard-capped at 2 MB of local storage. Writes that would push past the cap return `CAP_EXCEEDED` with an `upgrade_url`. Self-learning and memory-check-up tools are not exposed on free tier.
|
| 72 |
- **Paid tiers** (Sync / Stake / Lifetime / Enterprise): cap removed. All tools enabled.
|
| 73 |
|
| 74 |
-
The cap-gate runs against the **server-authoritative** tier (verified via HMAC-signed credentials)
|
| 75 |
|
| 76 |
## License
|
| 77 |
|
| 78 |
-
MIT
|
|
|
|
| 71 |
- **Free tier**: 8 tools work. Hard-capped at 2 MB of local storage. Writes that would push past the cap return `CAP_EXCEEDED` with an `upgrade_url`. Self-learning and memory-check-up tools are not exposed on free tier.
|
| 72 |
- **Paid tiers** (Sync / Stake / Lifetime / Enterprise): cap removed. All tools enabled.
|
| 73 |
|
| 74 |
+
The cap-gate runs against the **server-authoritative** tier (verified via HMAC-signed credentials): the MCP server can't bypass it by editing the local file.
|
| 75 |
|
| 76 |
## License
|
| 77 |
|
| 78 |
+
MIT: same as the rest of the `sibyl-memory-*` family.
|
sibyl-memory-mcp/pyproject.toml
CHANGED
|
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
|
| 5 |
[project]
|
| 6 |
name = "sibyl-memory-mcp"
|
| 7 |
version = "0.1.2"
|
| 8 |
-
description = "MCP server for Sibyl Memory Plugin
|
| 9 |
readme = "README.md"
|
| 10 |
requires-python = ">=3.10"
|
| 11 |
license = { text = "MIT" }
|
|
|
|
| 5 |
[project]
|
| 6 |
name = "sibyl-memory-mcp"
|
| 7 |
version = "0.1.2"
|
| 8 |
+
description = "MCP server for Sibyl Memory Plugin: wraps the local SQLite + FTS5 memory engine and exposes it to MCP-compatible agents (Claude Code, Codex, Cursor, Continue, anything that speaks MCP)."
|
| 9 |
readme = "README.md"
|
| 10 |
requires-python = ">=3.10"
|
| 11 |
license = { text = "MIT" }
|
sibyl-memory-mcp/src/sibyl_memory_mcp/__init__.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""sibyl-memory-mcp
|
| 2 |
|
| 3 |
Wraps the local SQLite + FTS5 memory engine (sibyl-memory-client) and
|
| 4 |
exposes it to any MCP-compatible agent: Claude Code, Codex CLI, Cursor,
|
|
|
|
| 1 |
+
"""sibyl-memory-mcp. MCP server for Sibyl Memory Plugin.
|
| 2 |
|
| 3 |
Wraps the local SQLite + FTS5 memory engine (sibyl-memory-client) and
|
| 4 |
exposes it to any MCP-compatible agent: Claude Code, Codex CLI, Cursor,
|
sibyl-memory-mcp/src/sibyl_memory_mcp/server.py
CHANGED
|
@@ -12,7 +12,7 @@
|
|
| 12 |
|
| 13 |
All operations run against the local SQLite at ~/.sibyl-memory/memory.db.
|
| 14 |
The cap gate (free-tier 2 MB hard cap, paid-tier uncapped) is enforced
|
| 15 |
-
automatically by the underlying sibyl-memory-client SDK
|
| 16 |
just surfaces the typed errors back to the caller.
|
| 17 |
|
| 18 |
v0.1.1 hardening (audit-remediation):
|
|
@@ -66,7 +66,7 @@ def _load_credentials() -> dict[str, Any]:
|
|
| 66 |
|
| 67 |
v0.1.1 hardening:
|
| 68 |
- Refuses to follow symlinks (SEC-11). If the file is a symlink,
|
| 69 |
-
treat as absent
|
| 70 |
- Treats any I/O / parse error as absent (existing behavior).
|
| 71 |
"""
|
| 72 |
if not DEFAULT_CRED_PATH.exists():
|
|
@@ -108,7 +108,7 @@ def _open_client() -> MemoryClient:
|
|
| 108 |
"""Return a MemoryClient bound to the local DB + credentials.
|
| 109 |
|
| 110 |
v0.1.1 (audit P-H1): cached at module scope. Previously rebuilt every
|
| 111 |
-
tool call (reading schema.sql from disk + bootstrapping FTS5 vtables
|
| 112 |
10-50ms per call). Now invalidated only when credentials.json mtime
|
| 113 |
changes, which is the only thing that should change tier behavior.
|
| 114 |
"""
|
|
@@ -189,7 +189,7 @@ def build_server() -> FastMCP:
|
|
| 189 |
"""Store an entity in long-term memory.
|
| 190 |
|
| 191 |
Use for facts, project state, person profiles, anything the agent
|
| 192 |
-
should remember across sessions. Idempotent on (category, name)
|
| 193 |
a second call with the same key updates the entry.
|
| 194 |
|
| 195 |
Args:
|
|
@@ -225,10 +225,10 @@ def build_server() -> FastMCP:
|
|
| 225 |
|
| 226 |
v0.1.1: spans all four searchable tiers. Each hit carries a `tier`
|
| 227 |
tag so the agent knows where the match came from. Previously was
|
| 228 |
-
entities-only
|
| 229 |
"search across all tiers" is now actually true.
|
| 230 |
|
| 231 |
-
Query is sanitized as a single FTS5 phrase
|
| 232 |
(`name:foo`, `rowid:*`) is treated as literal text and cannot
|
| 233 |
break out into the FTS5 parser. Empty/invalid queries return [].
|
| 234 |
|
|
@@ -250,7 +250,7 @@ def build_server() -> FastMCP:
|
|
| 250 |
) -> dict[str, Any]:
|
| 251 |
"""List entities, optionally filtered by category. Most-recently-updated first.
|
| 252 |
|
| 253 |
-
v0.1.1: `category` is now optional (audit N3
|
| 254 |
Hermes adapter behavior). Pass it to filter; omit to list across
|
| 255 |
all categories.
|
| 256 |
|
|
@@ -267,7 +267,7 @@ def build_server() -> FastMCP:
|
|
| 267 |
|
| 268 |
@mcp.tool()
|
| 269 |
def memory_forget(category: str, name: str, reason: str | None = None) -> dict[str, Any]:
|
| 270 |
-
"""Archive an entity (not destroyed
|
| 271 |
|
| 272 |
The body is preserved in the archive table for forensic recovery
|
| 273 |
but no longer appears in recall/list/search. Pass a `reason` to
|
|
@@ -284,7 +284,7 @@ def build_server() -> FastMCP:
|
|
| 284 |
def memory_set_state(key: str, body: dict[str, Any] | list[Any]) -> dict[str, Any]:
|
| 285 |
"""Write a HOT-tier state document.
|
| 286 |
|
| 287 |
-
Use for ephemeral working state the agent updates frequently
|
| 288 |
current focus, in-flight task list, working draft. Faster than
|
| 289 |
entity writes; one row per key, overwritten on each set.
|
| 290 |
"""
|
|
@@ -299,7 +299,7 @@ def build_server() -> FastMCP:
|
|
| 299 |
def memory_get_state(key: str) -> dict[str, Any]:
|
| 300 |
"""Read a HOT-tier state document by key.
|
| 301 |
|
| 302 |
-
v0.1.1 (audit H2): response shape is now flat
|
| 303 |
{ok, key, body: <user payload>, updated_at: <iso ts>}
|
| 304 |
Previously returned ``body`` = the full ``{body, updated_at}`` dict
|
| 305 |
from the SDK, so "body" meant two different things at different
|
|
@@ -330,7 +330,7 @@ def build_server() -> FastMCP:
|
|
| 330 |
) -> dict[str, Any]:
|
| 331 |
"""Append a COLD-tier journal event.
|
| 332 |
|
| 333 |
-
Use for things that happened
|
| 334 |
observations recorded. Append-only; never overwrites. Best paired
|
| 335 |
with entities (the entity is the noun, the journal is the verb).
|
| 336 |
|
|
|
|
| 12 |
|
| 13 |
All operations run against the local SQLite at ~/.sibyl-memory/memory.db.
|
| 14 |
The cap gate (free-tier 2 MB hard cap, paid-tier uncapped) is enforced
|
| 15 |
+
automatically by the underlying sibyl-memory-client SDK: the MCP server
|
| 16 |
just surfaces the typed errors back to the caller.
|
| 17 |
|
| 18 |
v0.1.1 hardening (audit-remediation):
|
|
|
|
| 66 |
|
| 67 |
v0.1.1 hardening:
|
| 68 |
- Refuses to follow symlinks (SEC-11). If the file is a symlink,
|
| 69 |
+
treat as absent: same behavior as the Hermes provider.
|
| 70 |
- Treats any I/O / parse error as absent (existing behavior).
|
| 71 |
"""
|
| 72 |
if not DEFAULT_CRED_PATH.exists():
|
|
|
|
| 108 |
"""Return a MemoryClient bound to the local DB + credentials.
|
| 109 |
|
| 110 |
v0.1.1 (audit P-H1): cached at module scope. Previously rebuilt every
|
| 111 |
+
tool call (reading schema.sql from disk + bootstrapping FTS5 vtables -
|
| 112 |
10-50ms per call). Now invalidated only when credentials.json mtime
|
| 113 |
changes, which is the only thing that should change tier behavior.
|
| 114 |
"""
|
|
|
|
| 189 |
"""Store an entity in long-term memory.
|
| 190 |
|
| 191 |
Use for facts, project state, person profiles, anything the agent
|
| 192 |
+
should remember across sessions. Idempotent on (category, name) -
|
| 193 |
a second call with the same key updates the entry.
|
| 194 |
|
| 195 |
Args:
|
|
|
|
| 225 |
|
| 226 |
v0.1.1: spans all four searchable tiers. Each hit carries a `tier`
|
| 227 |
tag so the agent knows where the match came from. Previously was
|
| 228 |
+
entities-only: the v0.3.0 plugin family marketing claim of
|
| 229 |
"search across all tiers" is now actually true.
|
| 230 |
|
| 231 |
+
Query is sanitized as a single FTS5 phrase: column-filter syntax
|
| 232 |
(`name:foo`, `rowid:*`) is treated as literal text and cannot
|
| 233 |
break out into the FTS5 parser. Empty/invalid queries return [].
|
| 234 |
|
|
|
|
| 250 |
) -> dict[str, Any]:
|
| 251 |
"""List entities, optionally filtered by category. Most-recently-updated first.
|
| 252 |
|
| 253 |
+
v0.1.1: `category` is now optional (audit N3: matches the SDK and
|
| 254 |
Hermes adapter behavior). Pass it to filter; omit to list across
|
| 255 |
all categories.
|
| 256 |
|
|
|
|
| 267 |
|
| 268 |
@mcp.tool()
|
| 269 |
def memory_forget(category: str, name: str, reason: str | None = None) -> dict[str, Any]:
|
| 270 |
+
"""Archive an entity (not destroyed: moved to archived_entities).
|
| 271 |
|
| 272 |
The body is preserved in the archive table for forensic recovery
|
| 273 |
but no longer appears in recall/list/search. Pass a `reason` to
|
|
|
|
| 284 |
def memory_set_state(key: str, body: dict[str, Any] | list[Any]) -> dict[str, Any]:
|
| 285 |
"""Write a HOT-tier state document.
|
| 286 |
|
| 287 |
+
Use for ephemeral working state the agent updates frequently -
|
| 288 |
current focus, in-flight task list, working draft. Faster than
|
| 289 |
entity writes; one row per key, overwritten on each set.
|
| 290 |
"""
|
|
|
|
| 299 |
def memory_get_state(key: str) -> dict[str, Any]:
|
| 300 |
"""Read a HOT-tier state document by key.
|
| 301 |
|
| 302 |
+
v0.1.1 (audit H2): response shape is now flat -
|
| 303 |
{ok, key, body: <user payload>, updated_at: <iso ts>}
|
| 304 |
Previously returned ``body`` = the full ``{body, updated_at}`` dict
|
| 305 |
from the SDK, so "body" meant two different things at different
|
|
|
|
| 330 |
) -> dict[str, Any]:
|
| 331 |
"""Append a COLD-tier journal event.
|
| 332 |
|
| 333 |
+
Use for things that happened: actions taken, decisions made,
|
| 334 |
observations recorded. Append-only; never overwrites. Best paired
|
| 335 |
with entities (the entity is the noun, the journal is the verb).
|
| 336 |
|