Commit Β·
2cf7040
0
Parent(s):
Initial release: Sibyl Memory Plugin family v0.1.0
Browse filesFive-package monorepo for the official Sibyl Memory Plugin:
- sibyl-memory-client 0.4.1: local-first SDK, SQLite + FTS5, five-tier
hierarchical schema, multi-tenant, self-learning skill detection
- sibyl-memory-cli 0.3.2: sibyl init / upgrade / status / whoami / devices
- sibyl-memory-hermes 0.3.4: Hermes Agent v0.13+ memory provider
- sibyl-memory-mcp 0.1.2: MCP server for Claude Code / Codex / Cursor
- sibyl-plugin-schema: SQL migrations (internal, not on PyPI)
All packages MIT licensed, all live on PyPI.
Built by SIBYL at Sibyl Labs LLC.
- .gitignore +44 -0
- LICENSE +21 -0
- README.md +90 -0
- sibyl-memory-cli/.gitignore +1 -0
- sibyl-memory-cli/CHANGELOG.md +330 -0
- sibyl-memory-cli/README.md +103 -0
- sibyl-memory-cli/pyproject.toml +45 -0
- sibyl-memory-cli/src/sibyl_memory_cli/__init__.py +24 -0
- sibyl-memory-cli/src/sibyl_memory_cli/_aesthetic.py +279 -0
- sibyl-memory-cli/src/sibyl_memory_cli/_banner.py +123 -0
- sibyl-memory-cli/src/sibyl_memory_cli/cli.py +880 -0
- sibyl-memory-cli/src/sibyl_memory_cli/setup.py +493 -0
- sibyl-memory-cli/tests/test_setup.py +381 -0
- sibyl-memory-client/CHANGELOG.md +389 -0
- sibyl-memory-client/README.md +69 -0
- sibyl-memory-client/pyproject.toml +46 -0
- sibyl-memory-client/src/sibyl_memory_client/__init__.py +109 -0
- sibyl-memory-client/src/sibyl_memory_client/_capcheck.py +465 -0
- sibyl-memory-client/src/sibyl_memory_client/client.py +896 -0
- sibyl-memory-client/src/sibyl_memory_client/exceptions.py +141 -0
- sibyl-memory-client/src/sibyl_memory_client/learning.py +925 -0
- sibyl-memory-client/src/sibyl_memory_client/lint.py +453 -0
- sibyl-memory-client/src/sibyl_memory_client/schema.sql +350 -0
- sibyl-memory-client/src/sibyl_memory_client/storage.py +277 -0
- sibyl-memory-client/tests/test_capcheck.py +339 -0
- sibyl-memory-client/tests/test_kappa_fixes.py +283 -0
- sibyl-memory-client/tests/test_learning.py +269 -0
- sibyl-memory-client/tests/test_lint.py +191 -0
- sibyl-memory-client/tests/test_smoke.py +202 -0
- sibyl-memory-hermes/CHANGELOG.md +411 -0
- sibyl-memory-hermes/LICENSE +21 -0
- sibyl-memory-hermes/README.md +127 -0
- sibyl-memory-hermes/pyproject.toml +64 -0
- sibyl-memory-hermes/src/sibyl_memory_hermes/__init__.py +93 -0
- sibyl-memory-hermes/src/sibyl_memory_hermes/_aesthetic.py +279 -0
- sibyl-memory-hermes/src/sibyl_memory_hermes/_banner.py +123 -0
- sibyl-memory-hermes/src/sibyl_memory_hermes/_hermes_plugin/__init__.py +12 -0
- sibyl-memory-hermes/src/sibyl_memory_hermes/_hermes_plugin/adapter.py +531 -0
- sibyl-memory-hermes/src/sibyl_memory_hermes/_hermes_plugin/plugin.yaml +6 -0
- sibyl-memory-hermes/src/sibyl_memory_hermes/credentials.py +168 -0
- sibyl-memory-hermes/src/sibyl_memory_hermes/install_plugin.py +255 -0
- sibyl-memory-hermes/src/sibyl_memory_hermes/provider.py +433 -0
- sibyl-memory-hermes/tests/test_adapter.py +250 -0
- sibyl-memory-hermes/tests/test_smoke.py +327 -0
- sibyl-memory-mcp/CHANGELOG.md +149 -0
- sibyl-memory-mcp/README.md +78 -0
- sibyl-memory-mcp/pyproject.toml +39 -0
- sibyl-memory-mcp/src/sibyl_memory_mcp/__init__.py +28 -0
- sibyl-memory-mcp/src/sibyl_memory_mcp/__main__.py +10 -0
- sibyl-memory-mcp/src/sibyl_memory_mcp/server.py +372 -0
.gitignore
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.so
|
| 6 |
+
.Python
|
| 7 |
+
build/
|
| 8 |
+
dist/
|
| 9 |
+
*.egg-info/
|
| 10 |
+
*.egg
|
| 11 |
+
.eggs/
|
| 12 |
+
.pytest_cache/
|
| 13 |
+
.mypy_cache/
|
| 14 |
+
.ruff_cache/
|
| 15 |
+
.coverage
|
| 16 |
+
htmlcov/
|
| 17 |
+
.tox/
|
| 18 |
+
|
| 19 |
+
# Environments
|
| 20 |
+
.venv/
|
| 21 |
+
venv/
|
| 22 |
+
env/
|
| 23 |
+
.env
|
| 24 |
+
.env.*
|
| 25 |
+
!.env.example
|
| 26 |
+
|
| 27 |
+
# IDE
|
| 28 |
+
.vscode/
|
| 29 |
+
.idea/
|
| 30 |
+
*.swp
|
| 31 |
+
*.swo
|
| 32 |
+
|
| 33 |
+
# OS
|
| 34 |
+
.DS_Store
|
| 35 |
+
Thumbs.db
|
| 36 |
+
|
| 37 |
+
# Vercel
|
| 38 |
+
.vercel/
|
| 39 |
+
|
| 40 |
+
# Local databases / state
|
| 41 |
+
*.db
|
| 42 |
+
*.sqlite
|
| 43 |
+
*.sqlite3
|
| 44 |
+
~/.sibyl-memory/
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 Sibyl Labs LLC
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Sibyl Labs
|
| 2 |
+
|
| 3 |
+
Agentic memory infrastructure. Local-first, SQLite-backed, structured-tier memory for AI agents β plus the CLI, MCP server, and Hermes plugin that ship on top of it.
|
| 4 |
+
|
| 5 |
+
This is the official source repository for the Sibyl Memory Plugin family. All packages are published to PyPI under the MIT license.
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## Packages
|
| 10 |
+
|
| 11 |
+
| Package | PyPI | Description |
|
| 12 |
+
|---|---|---|
|
| 13 |
+
| [`sibyl-memory-client`](./sibyl-memory-client) | [](https://pypi.org/project/sibyl-memory-client/) | Local-first agentic memory SDK. SQLite-backed five-tier hierarchical schema, FTS5 search, multi-tenant, with self-learning skill detection and local memory linter. Foundation of the plugin family. |
|
| 14 |
+
| [`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`. |
|
| 15 |
+
| [`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). |
|
| 16 |
+
| [`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). |
|
| 17 |
+
| [`sibyl-plugin-schema`](./sibyl-plugin-schema) | (internal) | SQL migrations for the activation / account / subscription database. Not on PyPI β kept here as immutable record. |
|
| 18 |
+
|
| 19 |
+
---
|
| 20 |
+
|
| 21 |
+
## Install
|
| 22 |
+
|
| 23 |
+
```bash
|
| 24 |
+
pip install sibyl-memory-cli
|
| 25 |
+
sibyl init
|
| 26 |
+
```
|
| 27 |
+
|
| 28 |
+
`sibyl init` opens a browser to activate your account at https://sibyllabs.org/plugin/activate, binds your wallet (SIWE) or email, and writes credentials to `~/.sibyl-memory/credentials.json`. Free tier is the default; staker and subscription tiers unlock additional capacity.
|
| 29 |
+
|
| 30 |
+
For direct SDK use:
|
| 31 |
+
|
| 32 |
+
```bash
|
| 33 |
+
pip install sibyl-memory-client
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
For Hermes integration:
|
| 37 |
+
|
| 38 |
+
```bash
|
| 39 |
+
pip install sibyl-memory-hermes
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
For MCP:
|
| 43 |
+
|
| 44 |
+
```bash
|
| 45 |
+
pip install sibyl-memory-mcp
|
| 46 |
+
# Then point your MCP client at the server entry point.
|
| 47 |
+
```
|
| 48 |
+
|
| 49 |
+
---
|
| 50 |
+
|
| 51 |
+
## Architecture
|
| 52 |
+
|
| 53 |
+
```
|
| 54 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 55 |
+
β sibyl-memory-cli sibyl-memory-mcp β
|
| 56 |
+
β ββββββββββββββββ ββββββββββββββββ β
|
| 57 |
+
β β sibyl init β β MCP server β β
|
| 58 |
+
β β sibyl status β β (stdio) β β
|
| 59 |
+
β β sibyl whoami β ββββββββ¬ββββββββ β
|
| 60 |
+
β ββββββββ¬ββββββββ β β
|
| 61 |
+
β β sibyl-memory-hermes β
|
| 62 |
+
β β ββββββββββββββββ β
|
| 63 |
+
β β β Hermes hook β β
|
| 64 |
+
β β ββββββββ¬ββββββββ β
|
| 65 |
+
β β β β
|
| 66 |
+
β ββββββββ¬ββββββ β
|
| 67 |
+
β βΌ β
|
| 68 |
+
β sibyl-memory-client (SDK) β
|
| 69 |
+
β ββββββββββββββββββββββββββ β
|
| 70 |
+
β β SQLite + FTS5 β β
|
| 71 |
+
β β 5-tier schema β β
|
| 72 |
+
β β self-learning skills β β
|
| 73 |
+
β β multi-tenant β β
|
| 74 |
+
β ββββββββββββββββββββββββββ β
|
| 75 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 76 |
+
```
|
| 77 |
+
|
| 78 |
+
Each package has its own `README.md` and `CHANGELOG.md` for details.
|
| 79 |
+
|
| 80 |
+
---
|
| 81 |
+
|
| 82 |
+
## License
|
| 83 |
+
|
| 84 |
+
MIT. See [LICENSE](./LICENSE).
|
| 85 |
+
|
| 86 |
+
## About
|
| 87 |
+
|
| 88 |
+
Built by SIBYL, the autonomous agent operating at Sibyl Labs LLC. Follow the work on X at [@sibylcap](https://x.com/sibylcap), or at [sibyllabs.org](https://sibyllabs.org).
|
| 89 |
+
|
| 90 |
+
Copyright (c) 2026 Sibyl Labs LLC.
|
sibyl-memory-cli/.gitignore
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
.vercel
|
sibyl-memory-cli/CHANGELOG.md
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Changelog
|
| 2 |
+
|
| 3 |
+
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] β 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
|
| 11 |
+
your hand tagline, 'a Sibyl Labs LLC Product. Agentic Infrastructure
|
| 12 |
+
and Memory Products' or something similar."
|
| 13 |
+
|
| 14 |
+
### Changed
|
| 15 |
+
|
| 16 |
+
- `_banner.py` now emits a third line under the wordmark + tagline:
|
| 17 |
+
`a Sibyl Labs LLC Product. Agentic Infrastructure and Memory Products`.
|
| 18 |
+
Rendered in the same deepest-gold (`_GRADIENT[-1]` = `(106, 79, 31)`)
|
| 19 |
+
as the tagline but with ANSI dim (`\033[2m`) applied so the visual
|
| 20 |
+
hierarchy reads SIBYL > tagline > attribution at a glance. Plain-text
|
| 21 |
+
fallback also includes the line for non-color terminals.
|
| 22 |
+
|
| 23 |
+
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] β 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
|
| 33 |
+
ceremony reserved for activation moments.
|
| 34 |
+
|
| 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
|
| 57 |
+
gradient) was the only command with serious typography; every other
|
| 58 |
+
subcommand was plain text + ANSI 16-color. v0.3.0 brings the lab face
|
| 59 |
+
to the whole surface.
|
| 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,
|
| 67 |
+
ASCII rule dividers, key/value rows, status chips with success/warn/
|
| 68 |
+
error glyphs, multi-stop char-by-char gradient interpolation.
|
| 69 |
+
- `SIBYL_FORCE_COLOR=1` env override for non-tty rendering (CI logs,
|
| 70 |
+
doc captures, harness inspection). Honors `NO_COLOR` as the wider
|
| 71 |
+
precedence override per the standard.
|
| 72 |
+
|
| 73 |
+
### Changed
|
| 74 |
+
|
| 75 |
+
- `sibyl init`, `sibyl upgrade`, `sibyl status`, `sibyl whoami`,
|
| 76 |
+
`sibyl devices`, `sibyl logout`, `sibyl health` all now open with a
|
| 77 |
+
styled section header (gradient command-name + creme rule lines +
|
| 78 |
+
dim subtitle), use eyebrow labels for sub-sections (uppercase
|
| 79 |
+
letter-spaced ochre), and render key/value rows + status lines with
|
| 80 |
+
the brand palette. Success states (Activated, Upgraded, Logged out)
|
| 81 |
+
flow with a pulse β jade gradient. Cap warnings and errors use the
|
| 82 |
+
measured warm-ochre / red palette tokens, not generic ANSI 31/33.
|
| 83 |
+
- `sibyl init` waiting spinner now reads "watching the network for your
|
| 84 |
+
bind" in pulse-jade, aligned with the wallet-bind-watcher service
|
| 85 |
+
language users see in their browser.
|
| 86 |
+
- `sibyl devices` list rendering: current device marked with `βΆ` in
|
| 87 |
+
pulse + the device label flows in gold gradient; other devices show
|
| 88 |
+
in calm ink with dim metadata. Index chips in pulse for "this device"
|
| 89 |
+
or muted gray for the rest.
|
| 90 |
+
|
| 91 |
+
### Compatibility
|
| 92 |
+
|
| 93 |
+
- Backward compat preserved: existing `dim/bold/green/yellow/red/cyan`
|
| 94 |
+
helpers stay in `cli.py` (used by the legacy `print_status` path
|
| 95 |
+
which is now superseded but not removed). New `_aesthetic.a.*`
|
| 96 |
+
helpers layer on top.
|
| 97 |
+
- All visual choices honor `NO_COLOR`. Plain text fallback is
|
| 98 |
+
visually clean (no garbage escapes leak).
|
| 99 |
+
- Terminal capability detection identical to `_banner.py` for
|
| 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
|
| 131 |
+
in one command. Replaces the prior three-step Hermes flow (`pip install
|
| 132 |
+
sibyl-memory-hermes` + `sibyl-memory-hermes install-plugin` + manual
|
| 133 |
+
`config.yaml` edit) with `sibyl setup`. Also handles Claude Code MCP wiring.
|
| 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
|
| 148 |
+
configs), `--dry-run` (print intent without writing), `--hermes-home`,
|
| 149 |
+
`--claude-settings` (override autodetect).
|
| 150 |
+
- **Atomic writes + backups**: every config edit creates a `.bak` sibling
|
| 151 |
+
(`config.yaml.bak`, `settings.json.bak`) before atomic rename via tmpfile.
|
| 152 |
+
Defensive against partial writes + user mistake recovery.
|
| 153 |
+
- **`HermesWirer`, `ClaudeCodeWirer`** classes in new `sibyl_memory_cli.setup`
|
| 154 |
+
module. Composable wirer protocol (`is_present()` / `current_state()` /
|
| 155 |
+
`wire()` / `WireOutcome`) ready for v0.1.5 addition of Codex / Cursor /
|
| 156 |
+
Continue wirers.
|
| 157 |
+
- **33 new tests** in `tests/test_setup.py` covering: detection logic, prompt
|
| 158 |
+
helpers, Hermes fresh / existing-sibyl / existing-other / force-overwrite /
|
| 159 |
+
dry-run / config-preservation, Claude Code fresh / existing-other-mcps /
|
| 160 |
+
existing-sibyl / mismatched-sibyl / force / dry-run.
|
| 161 |
+
|
| 162 |
+
### Changed
|
| 163 |
+
|
| 164 |
+
- **Dependencies**: added `pyyaml>=6.0` for Hermes `config.yaml` editing.
|
| 165 |
+
Already a transitive dep for any Hermes user; small (~250 KB) for
|
| 166 |
+
Claude-Code-only users.
|
| 167 |
+
|
| 168 |
+
### Notes
|
| 169 |
+
|
| 170 |
+
- Replaces the prior canonical three-step Hermes flow. Docs `install.html`
|
| 171 |
+
Step 4 collapses from three commands to two: `pip install sibyl-memory-cli`
|
| 172 |
+
+ `sibyl setup`. The old `sibyl-memory-hermes install-plugin` path stays
|
| 173 |
+
documented as a manual fallback for advanced users who want fine-grained
|
| 174 |
+
control over each step.
|
| 175 |
+
- Codex / Cursor / Continue MCP wirers are scoped for v0.1.5. The wirer
|
| 176 |
+
protocol in `setup.py` is ready to take them as drop-in classes.
|
| 177 |
+
- The shell installer (`curl ... | sh`) remains on the roadmap; combined with
|
| 178 |
+
`sibyl setup` it collapses the full onboarding to a single curl line.
|
| 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
|
| 186 |
+
path, db file perms, identifier validation, FTS5 error surfacing). No CLI
|
| 187 |
+
code changes in this release.
|
| 188 |
+
|
| 189 |
+
### Changed
|
| 190 |
+
|
| 191 |
+
- `sibyl-memory-client` pin: `>=0.3.3` β `>=0.4.0`.
|
| 192 |
+
- `sibyl-memory-hermes` pin: `>=0.3.1` β `>=0.3.2`.
|
| 193 |
+
|
| 194 |
+
### Notes
|
| 195 |
+
|
| 196 |
+
- `sibyl init / upgrade / status / health` surface is unchanged from
|
| 197 |
+
v0.1.2. KAPPA's fixes flow through transparently via the dependency
|
| 198 |
+
bump.
|
| 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.
|
| 206 |
+
Companion releases: `sibyl-memory-client` v0.3.3 (engine + schema v3 +
|
| 207 |
+
cross-tier search), `sibyl-memory-hermes` v0.3.1, `sibyl-memory-mcp` v0.1.1.
|
| 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` +
|
| 225 |
+
config.yaml edit), the MCP install hint for Claude Code / Codex / Cursor /
|
| 226 |
+
Continue users, and the direct-SDK path for any Python orchestration.
|
| 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);
|
| 239 |
+
if absent, falls back to the legacy session-echo flow. Full fix requires
|
| 240 |
+
the api-sibyllabs server-side change to issue a separate bearer; this
|
| 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 |
+
|
| 262 |
+
### Implementation notes
|
| 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 |
+
|
| 279 |
+
`sibyl init` now generates a 6-digit pairing code locally (via
|
| 280 |
+
`secrets.randbelow`), prints it in the terminal, and POSTs only its
|
| 281 |
+
sha256 hash to `/api/plugin/session-init` BEFORE opening the browser.
|
| 282 |
+
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 β the pairing code only matters
|
| 287 |
+
for the email panel.
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
Initial release. Operator directive 2026-05-16: build the user-facing
|
| 292 |
+
CLI + upgrade page so the SDK + payment-auth machinery has a front door.
|
| 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
|
| 313 |
+
|
| 314 |
+
- Pure stdlib HTTP via `urllib`. No `requests`, no `httpx`. The wheel
|
| 315 |
+
installs in seconds.
|
| 316 |
+
- `session_token` printed only as short slice (`first8β¦last4`). Never
|
| 317 |
+
full-length to stdout.
|
| 318 |
+
- Polling has explicit timeouts. No infinite loops. Ctrl-C exits 130.
|
| 319 |
+
- All endpoint URLs configurable via env (`SIBYL_API_BASE`,
|
| 320 |
+
`SIBYL_ACTIVATE_BASE`, `SIBYL_UPGRADE_BASE`) for staging tests.
|
| 321 |
+
|
| 322 |
+
### Depends on
|
| 323 |
+
|
| 324 |
+
- `sibyl-memory-client>=0.3.0` (cap gate)
|
| 325 |
+
- `sibyl-memory-hermes>=0.2.0` (provider + credentials loader)
|
| 326 |
+
|
| 327 |
+
### Entry point
|
| 328 |
+
|
| 329 |
+
`pip install sibyl-memory-cli` installs the `sibyl` binary via the
|
| 330 |
+
`[project.scripts]` block in pyproject.
|
sibyl-memory-cli/README.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# sibyl-memory-cli
|
| 2 |
+
|
| 3 |
+
Command-line interface for the **Sibyl Memory Plugin**.
|
| 4 |
+
|
| 5 |
+
```bash
|
| 6 |
+
pip install sibyl-memory-cli
|
| 7 |
+
```
|
| 8 |
+
|
| 9 |
+
This pulls in `sibyl-memory-client` (the local SDK) and `sibyl-memory-hermes` (the Hermes provider) automatically.
|
| 10 |
+
|
| 11 |
+
## Commands
|
| 12 |
+
|
| 13 |
+
```
|
| 14 |
+
sibyl init Open the browser activation page. Writes ~/.sibyl-memory/credentials.json.
|
| 15 |
+
sibyl upgrade Open the upgrade page. Stake $SIBYL or subscribe in USDC.
|
| 16 |
+
sibyl status Show local credentials, DB size, and the server's view of your tier.
|
| 17 |
+
sibyl health Run the SibylMemoryProvider self-check (schema version, DB path, tenant).
|
| 18 |
+
```
|
| 19 |
+
|
| 20 |
+
## Activation
|
| 21 |
+
|
| 22 |
+
```bash
|
| 23 |
+
$ sibyl init
|
| 24 |
+
|
| 25 |
+
Sibyl Memory Plugin Β· activation
|
| 26 |
+
|
| 27 |
+
Session: a1b2c3d4β¦e5f6
|
| 28 |
+
Opening: https://sibyllabs.org/plugin/activate?session=a1b2c3d4-β¦
|
| 29 |
+
|
| 30 |
+
Sign in with your wallet in the browser. This terminal will pick up automatically.
|
| 31 |
+
|
| 32 |
+
β Ή waiting for browser activation β¦ 9:42 left
|
| 33 |
+
```
|
| 34 |
+
|
| 35 |
+
The browser opens. Sign a SIWE message with your wallet. The terminal picks up the moment the binding lands. Credentials are written to `~/.sibyl-memory/credentials.json` at mode 0600.
|
| 36 |
+
|
| 37 |
+
## Upgrade
|
| 38 |
+
|
| 39 |
+
```bash
|
| 40 |
+
$ sibyl upgrade
|
| 41 |
+
|
| 42 |
+
Sibyl Memory Plugin Β· upgrade
|
| 43 |
+
|
| 44 |
+
Account a1b2c3d4β¦e5f6
|
| 45 |
+
Current tier FREE
|
| 46 |
+
Opening https://sibyllabs.org/plugin/upgrade?session=β¦
|
| 47 |
+
|
| 48 |
+
Two paths in your browser:
|
| 49 |
+
1. Stake $SIBYL on Base (free unlimited if you qualify)
|
| 50 |
+
2. Subscribe in USDC (monthly / quarterly / annual)
|
| 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 |
+
|
| 59 |
+
## Status
|
| 60 |
+
|
| 61 |
+
```bash
|
| 62 |
+
$ sibyl status
|
| 63 |
+
|
| 64 |
+
Sibyl Memory Plugin Β· status
|
| 65 |
+
|
| 66 |
+
LOCAL
|
| 67 |
+
Credentials ~/.sibyl-memory/credentials.json
|
| 68 |
+
Account a1b2c3d4β¦e5f6
|
| 69 |
+
Tier FREE
|
| 70 |
+
DB size 1,247,300 bytes (1.19 MB)
|
| 71 |
+
Tier cache free (checked 2026-05-16T18:12:03)
|
| 72 |
+
|
| 73 |
+
SERVER
|
| 74 |
+
Tier FREE
|
| 75 |
+
Source free
|
| 76 |
+
Cap bytes 2,097,152
|
| 77 |
+
$SIBYL held 0
|
| 78 |
+
Threshold 100,000
|
| 79 |
+
Qualified no
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
If `LOCAL` and `SERVER` tiers diverge, run `sibyl upgrade`.
|
| 83 |
+
|
| 84 |
+
## Environment overrides
|
| 85 |
+
|
| 86 |
+
For internal testing only:
|
| 87 |
+
|
| 88 |
+
```bash
|
| 89 |
+
SIBYL_API_BASE=https://staging.api.sibyllabs.org sibyl init
|
| 90 |
+
SIBYL_ACTIVATE_BASE=https://staging.sibyllabs.org/plugin/activate sibyl init
|
| 91 |
+
SIBYL_UPGRADE_BASE=https://staging.sibyllabs.org/plugin/upgrade sibyl upgrade
|
| 92 |
+
```
|
| 93 |
+
|
| 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 |
+
|
| 101 |
+
## License
|
| 102 |
+
|
| 103 |
+
MIT.
|
sibyl-memory-cli/pyproject.toml
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["setuptools>=61.0", "wheel"]
|
| 3 |
+
build-backend = "setuptools.build_meta"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "sibyl-memory-cli"
|
| 7 |
+
version = "0.3.2"
|
| 8 |
+
description = "Command-line interface for the Sibyl Memory Plugin. `sibyl init` activates, `sibyl upgrade` runs the staker / subscription flow, `sibyl status` shows current tier and DB stats, `sibyl whoami` gives a one-line account summary, `sibyl devices` lists active devices and supports per-device revoke."
|
| 9 |
+
authors = [{ name = "SIBYL, Sibyl Labs LLC", email = "sibyl@sibyllabs.org" }]
|
| 10 |
+
license = { text = "MIT" }
|
| 11 |
+
readme = "README.md"
|
| 12 |
+
requires-python = ">=3.10"
|
| 13 |
+
keywords = ["sibyl", "memory", "cli", "agent", "hermes"]
|
| 14 |
+
classifiers = [
|
| 15 |
+
"Development Status :: 4 - Beta",
|
| 16 |
+
"Intended Audience :: Developers",
|
| 17 |
+
"License :: OSI Approved :: MIT License",
|
| 18 |
+
"Programming Language :: Python :: 3",
|
| 19 |
+
"Programming Language :: Python :: 3.10",
|
| 20 |
+
"Programming Language :: Python :: 3.11",
|
| 21 |
+
"Programming Language :: Python :: 3.12",
|
| 22 |
+
"Environment :: Console",
|
| 23 |
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
| 24 |
+
]
|
| 25 |
+
dependencies = [
|
| 26 |
+
"sibyl-memory-client>=0.4.0",
|
| 27 |
+
"sibyl-memory-hermes>=0.3.2",
|
| 28 |
+
"pyyaml>=6.0",
|
| 29 |
+
]
|
| 30 |
+
|
| 31 |
+
[project.optional-dependencies]
|
| 32 |
+
dev = [
|
| 33 |
+
"pytest>=7.0",
|
| 34 |
+
]
|
| 35 |
+
|
| 36 |
+
[project.scripts]
|
| 37 |
+
sibyl = "sibyl_memory_cli.cli:main"
|
| 38 |
+
|
| 39 |
+
[project.urls]
|
| 40 |
+
Homepage = "https://sibyllabs.org/plugin"
|
| 41 |
+
Documentation = "https://docs.sibyllabs.org/memory/"
|
| 42 |
+
|
| 43 |
+
[tool.setuptools.packages.find]
|
| 44 |
+
where = ["src"]
|
| 45 |
+
include = ["sibyl_memory_cli*"]
|
sibyl-memory-cli/src/sibyl_memory_cli/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
|
| 11 |
+
Browser pages live at sibyllabs.org/plugin/{activate,upgrade}.
|
| 12 |
+
All HTTP calls target https://api.sibyllabs.org/api/plugin/*.
|
| 13 |
+
"""
|
| 14 |
+
from .cli import main
|
| 15 |
+
|
| 16 |
+
# Single-sourced from installed metadata so wheel + code can't drift
|
| 17 |
+
# (C3 audit fix v0.1.2). Same pattern as sibyl-memory-hermes v0.3.0+.
|
| 18 |
+
from importlib.metadata import PackageNotFoundError, version as _pkg_version
|
| 19 |
+
try:
|
| 20 |
+
__version__ = _pkg_version("sibyl-memory-cli")
|
| 21 |
+
except PackageNotFoundError: # pragma: no cover - source-tree dev only
|
| 22 |
+
__version__ = "0.0.0+source"
|
| 23 |
+
|
| 24 |
+
__all__ = ["main", "__version__"]
|
sibyl-memory-cli/src/sibyl_memory_cli/_aesthetic.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared visual identity for the sibyl CLI surface.
|
| 2 |
+
|
| 3 |
+
Sister module to `_banner.py`. Where the banner is the identity-reveal
|
| 4 |
+
moment for `sibyl init`, this module supplies the granular building
|
| 5 |
+
blocks every subcommand uses to share one coherent look:
|
| 6 |
+
|
| 7 |
+
- 24-bit-truecolor β 256-color β plain-text degradation cascade
|
| 8 |
+
- Brand palette derived from the lab creme paper face (rule 46)
|
| 9 |
+
- Letter-spaced eyebrow labels, gradient titles, ASCII rule dividers
|
| 10 |
+
- Key/value rows, status chips, success/warn/error glyphs
|
| 11 |
+
- Pulsing accents for live states (activation, upgrade, watching)
|
| 12 |
+
|
| 13 |
+
Voice constraint: precise, editorial, restrained. Gradients flow over
|
| 14 |
+
2β3 stops max. No rainbow. The terminal is paper.
|
| 15 |
+
"""
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import os
|
| 19 |
+
import sys
|
| 20 |
+
from typing import Iterable
|
| 21 |
+
|
| 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 = "β"
|
| 44 |
+
GLYPH_WARN = "β "
|
| 45 |
+
GLYPH_ERR = "β"
|
| 46 |
+
GLYPH_DOT = "Β·"
|
| 47 |
+
GLYPH_ARROW = "β"
|
| 48 |
+
GLYPH_BULLET = "βΈ"
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# βββ Terminal capability detection ββββββββββββββββββββββββββββββββββββ
|
| 52 |
+
|
| 53 |
+
def supports_truecolor() -> bool:
|
| 54 |
+
"""24-bit RGB ANSI. Same heuristic as _banner.py."""
|
| 55 |
+
if os.environ.get("NO_COLOR"):
|
| 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
|
| 63 |
+
if not sys.stdout.isatty():
|
| 64 |
+
return False
|
| 65 |
+
colorterm = os.environ.get("COLORTERM", "").lower()
|
| 66 |
+
if "truecolor" in colorterm or "24bit" in colorterm:
|
| 67 |
+
return True
|
| 68 |
+
term_program = os.environ.get("TERM_PROGRAM", "").lower()
|
| 69 |
+
if term_program in {"iterm.app", "wezterm", "ghostty", "vscode", "tabby"}:
|
| 70 |
+
return True
|
| 71 |
+
term = os.environ.get("TERM", "").lower()
|
| 72 |
+
if any(k in term for k in ("256color", "kitty", "alacritty", "xterm-direct")):
|
| 73 |
+
return True
|
| 74 |
+
return False
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def supports_color() -> bool:
|
| 78 |
+
"""Any color at all (3/4-bit fallback)."""
|
| 79 |
+
if os.environ.get("NO_COLOR"):
|
| 80 |
+
return False
|
| 81 |
+
if os.environ.get("TERM", "").lower() == "dumb":
|
| 82 |
+
return False
|
| 83 |
+
if os.environ.get("SIBYL_FORCE_COLOR") == "1":
|
| 84 |
+
return True
|
| 85 |
+
return sys.stdout.isatty()
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
_TC = supports_truecolor()
|
| 89 |
+
_C = supports_color()
|
| 90 |
+
RESET = "\033[0m" if _C else ""
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def rgb(r: int, g: int, b: int) -> str:
|
| 94 |
+
"""24-bit foreground escape (no-op if color disabled)."""
|
| 95 |
+
if not _TC:
|
| 96 |
+
return ""
|
| 97 |
+
return f"\033[38;2;{r};{g};{b}m"
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def rgb_bg(r: int, g: int, b: int) -> str:
|
| 101 |
+
if not _TC:
|
| 102 |
+
return ""
|
| 103 |
+
return f"\033[48;2;{r};{g};{b}m"
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def color(text: str, c: tuple[int, int, int]) -> str:
|
| 107 |
+
if not _TC:
|
| 108 |
+
return text
|
| 109 |
+
return f"{rgb(*c)}{text}{RESET}"
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
# βββ Gradient Β· char-by-char RGB interpolation ββββββββββββββββββββββββ
|
| 113 |
+
|
| 114 |
+
def _interp(a: int, b: int, t: float) -> int:
|
| 115 |
+
return round(a + (b - a) * t)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def gradient(text: str, *stops: tuple[int, int, int]) -> str:
|
| 119 |
+
"""Color a string with a gradient across N stops, one char at a time.
|
| 120 |
+
|
| 121 |
+
Plain-text fallback: returns the input unchanged when color is off.
|
| 122 |
+
Whitespace is preserved (uncolored to keep terminals consistent).
|
| 123 |
+
"""
|
| 124 |
+
if not _TC or len(stops) < 2 or not text:
|
| 125 |
+
return text
|
| 126 |
+
out = []
|
| 127 |
+
chars = list(text)
|
| 128 |
+
# Distribute char index across stop segments
|
| 129 |
+
n = max(1, len(chars) - 1)
|
| 130 |
+
segs = len(stops) - 1
|
| 131 |
+
for i, ch in enumerate(chars):
|
| 132 |
+
if ch == " ":
|
| 133 |
+
out.append(ch)
|
| 134 |
+
continue
|
| 135 |
+
seg_f = (i / n) * segs
|
| 136 |
+
seg_i = min(int(seg_f), segs - 1)
|
| 137 |
+
t = seg_f - seg_i
|
| 138 |
+
a = stops[seg_i]
|
| 139 |
+
b = stops[seg_i + 1]
|
| 140 |
+
r = _interp(a[0], b[0], t)
|
| 141 |
+
g = _interp(a[1], b[1], t)
|
| 142 |
+
bb = _interp(a[2], b[2], t)
|
| 143 |
+
out.append(f"\033[38;2;{r};{g};{bb}m{ch}")
|
| 144 |
+
return "".join(out) + RESET
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def gradient_gold(text: str) -> str:
|
| 148 |
+
"""Pale-gold β deep-ochre flow. The brand's headline gradient."""
|
| 149 |
+
return gradient(text, ACCENT_PALE, ACCENT_GOLD, ACCENT)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def gradient_jade(text: str) -> str:
|
| 153 |
+
"""Pulse β jade. Used for success states + live indicators."""
|
| 154 |
+
return gradient(text, PULSE, JADE)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
# βββ Style primitives βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 158 |
+
|
| 159 |
+
def dim(s: str) -> str:
|
| 160 |
+
return color(s, INK_FAINT)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def muted(s: str) -> str:
|
| 164 |
+
return color(s, INK_MUTE)
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def soft(s: str) -> str:
|
| 168 |
+
return color(s, INK_SOFT)
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def ink(s: str) -> str:
|
| 172 |
+
return color(s, INK)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def ok(s: str) -> str:
|
| 176 |
+
return color(s, PULSE)
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def warn(s: str) -> str:
|
| 180 |
+
return color(s, ACCENT_WARM)
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def err(s: str) -> str:
|
| 184 |
+
return color(s, ERROR)
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def accent(s: str) -> str:
|
| 188 |
+
return color(s, ACCENT)
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def bold(s: str) -> str:
|
| 192 |
+
if not _C:
|
| 193 |
+
return s
|
| 194 |
+
return f"\033[1m{s}{RESET}"
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
# βββ Composite primitives βββββββββββββββββββββββββββββββββββββββββββββ
|
| 198 |
+
|
| 199 |
+
def eyebrow(label: str) -> str:
|
| 200 |
+
"""Uppercase letter-spaced ochre label. Editorial section marker."""
|
| 201 |
+
spaced = " ".join(label.upper())
|
| 202 |
+
return color(spaced, ACCENT)
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def divider(width: int = 60, *, glyph: str = "β") -> str:
|
| 206 |
+
"""Creme-paper rule line."""
|
| 207 |
+
return color(glyph * width, RULE)
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def section_header(name: str, *, subtitle: str | None = None, width: int = 60) -> str:
|
| 211 |
+
"""The standard subcommand opener.
|
| 212 |
+
|
| 213 |
+
β <name> ββββββββββββββββββββββββββββββββββββββββ
|
| 214 |
+
<subtitle, dim>
|
| 215 |
+
"""
|
| 216 |
+
name_part = f" {gradient_gold(name)} "
|
| 217 |
+
# Stripped-color length for visible width calc
|
| 218 |
+
visible_name_len = len(f" {name} ")
|
| 219 |
+
rule_left = "β"
|
| 220 |
+
rule_right = "β" * max(3, width - 1 - visible_name_len)
|
| 221 |
+
head = color(rule_left, RULE) + name_part + color(rule_right, RULE)
|
| 222 |
+
if subtitle:
|
| 223 |
+
return head + "\n" + dim(subtitle)
|
| 224 |
+
return head
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def chip(text: str, *, palette: str = "accent") -> str:
|
| 228 |
+
"""Compact inline label Β· [text]."""
|
| 229 |
+
palettes = {
|
| 230 |
+
"accent": ACCENT,
|
| 231 |
+
"jade": PULSE,
|
| 232 |
+
"warn": ACCENT_WARM,
|
| 233 |
+
"error": ERROR,
|
| 234 |
+
"mute": INK_MUTE,
|
| 235 |
+
}
|
| 236 |
+
c = palettes.get(palette, ACCENT)
|
| 237 |
+
return color(f"[{text}]", c)
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def kv(label: str, value: str, *, label_width: int = 16, value_color: str = "ink") -> str:
|
| 241 |
+
"""One left-aligned label / value row.
|
| 242 |
+
|
| 243 |
+
Used across status / whoami / devices for the LOCAL / SERVER blocks.
|
| 244 |
+
"""
|
| 245 |
+
palettes = {
|
| 246 |
+
"ink": INK, "soft": INK_SOFT, "mute": INK_MUTE, "faint": INK_FAINT,
|
| 247 |
+
"accent": ACCENT, "ok": PULSE, "warn": ACCENT_WARM, "err": ERROR,
|
| 248 |
+
}
|
| 249 |
+
val_color = palettes.get(value_color, INK_SOFT)
|
| 250 |
+
return f" {color(label.ljust(label_width), INK_FAINT)} {color(value, val_color)}"
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def block_title(text: str) -> str:
|
| 254 |
+
"""Sub-section title within a command output. Like 'LOCAL' or 'SERVER'."""
|
| 255 |
+
return "\n" + eyebrow(text)
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def success_line(text: str) -> str:
|
| 259 |
+
"""Single-line success marker with gradient + glyph."""
|
| 260 |
+
return f" {ok(GLYPH_OK)} {gradient_jade(text)}"
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def warn_line(text: str) -> str:
|
| 264 |
+
return f" {warn(GLYPH_WARN)} {warn(text)}"
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def err_line(text: str) -> str:
|
| 268 |
+
return f" {err(GLYPH_ERR)} {err(text)}"
|
| 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 |
+
|
| 276 |
+
|
| 277 |
+
def footer_credits(*, width: int = 60) -> str:
|
| 278 |
+
"""Bottom-of-output line. Used at end of long outputs."""
|
| 279 |
+
return color("β" * width, RULE) + "\n" + dim(" sibyl labs Β· memory you can hold in your hand")
|
sibyl-memory-cli/src/sibyl_memory_cli/_banner.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ASCII banner for sibyl-memory-cli.
|
| 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:
|
| 9 |
+
- NO_COLOR env var set β plain text fallback
|
| 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.
|
| 17 |
+
"""
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 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 |
+
"ββββββββββββββββββ βββ ββββββ ",
|
| 27 |
+
"βββββββββββββββββββββββ βββββββ ",
|
| 28 |
+
"βββββββββββββββββββ βββββββ βββ ",
|
| 29 |
+
"βββββββββββββββββββ βββββ βββ ",
|
| 30 |
+
"βββββββββββββββββββ βββ ββββββββ",
|
| 31 |
+
"ββββββββββββββββββ βββ ββββββββ",
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
# Vertical gradient Β· cream β gold β deep ochre. One RGB tuple per row.
|
| 35 |
+
# Tuned against the SIBYL palette: --paper #f5f1e6 (top blend),
|
| 36 |
+
# --accent #8a6a2a (mid-bottom), with extra highlight + shadow stops
|
| 37 |
+
# to give the wordmark visible dimension.
|
| 38 |
+
_GRADIENT = (
|
| 39 |
+
(253, 251, 245), # almost white, slight cream (top highlight)
|
| 40 |
+
(244, 229, 184), # pale gold (upper)
|
| 41 |
+
(224, 194, 119), # mid gold (upper-mid)
|
| 42 |
+
(184, 146, 73), # rich ochre gold (mid)
|
| 43 |
+
(138, 106, 42), # deep ochre Β· brand --accent (lower)
|
| 44 |
+
(106, 79, 31), # deepest (bottom shadow)
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
_TAGLINE = "memory you can hold in your hand"
|
| 48 |
+
_ATTRIBUTION = "a Sibyl Labs LLC Product. Agentic Infrastructure and Memory Products"
|
| 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":
|
| 56 |
+
return False
|
| 57 |
+
if not sys.stdout.isatty():
|
| 58 |
+
return False
|
| 59 |
+
colorterm = os.environ.get("COLORTERM", "").lower()
|
| 60 |
+
if "truecolor" in colorterm or "24bit" in colorterm:
|
| 61 |
+
return True
|
| 62 |
+
# Many modern terminals don't set COLORTERM but do support truecolor.
|
| 63 |
+
# Recognize the well-behaved emitters.
|
| 64 |
+
term_program = os.environ.get("TERM_PROGRAM", "").lower()
|
| 65 |
+
if term_program in {"iterm.app", "wezterm", "ghostty", "vscode", "tabby"}:
|
| 66 |
+
return True
|
| 67 |
+
term = os.environ.get("TERM", "").lower()
|
| 68 |
+
if any(k in term for k in ("256color", "kitty", "alacritty", "xterm-direct")):
|
| 69 |
+
return True
|
| 70 |
+
return False
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _color_supported() -> bool:
|
| 74 |
+
"""Plain ANSI color (3/4-bit). Stricter than truecolor."""
|
| 75 |
+
if os.environ.get("NO_COLOR"):
|
| 76 |
+
return False
|
| 77 |
+
if os.environ.get("TERM", "").lower() == "dumb":
|
| 78 |
+
return False
|
| 79 |
+
return sys.stdout.isatty()
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _rgb(r: int, g: int, b: int) -> str:
|
| 83 |
+
return f"\033[38;2;{r};{g};{b}m"
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
_RESET = "\033[0m"
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def render_banner(*, force_color: bool | None = None) -> str:
|
| 90 |
+
"""Return the banner as a string ready to print.
|
| 91 |
+
|
| 92 |
+
Args:
|
| 93 |
+
force_color: Override auto-detection. None = auto, True = force
|
| 94 |
+
truecolor, False = force plain text. Useful for testing.
|
| 95 |
+
"""
|
| 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"
|
| 118 |
+
return body + tagline + attribution
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def print_banner(*, force_color: bool | None = None) -> None:
|
| 122 |
+
"""Print the banner. Safe to call unconditionally; honors NO_COLOR + TTY checks."""
|
| 123 |
+
print(render_banner(force_color=force_color))
|
sibyl-memory-cli/src/sibyl_memory_cli/cli.py
ADDED
|
@@ -0,0 +1,880 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""`sibyl` command-line interface.
|
| 2 |
+
|
| 3 |
+
Stdlib only. The CLI is a thin wrapper around HTTP calls to
|
| 4 |
+
https://api.sibyllabs.org/api/plugin/* and the local SibylMemoryProvider.
|
| 5 |
+
|
| 6 |
+
Design pillars:
|
| 7 |
+
- Zero non-stdlib deps in this file. urllib is enough.
|
| 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 |
+
"""
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import argparse
|
| 20 |
+
import hashlib
|
| 21 |
+
import json
|
| 22 |
+
import os
|
| 23 |
+
import secrets
|
| 24 |
+
import sys
|
| 25 |
+
import time
|
| 26 |
+
import urllib.error
|
| 27 |
+
import urllib.parse
|
| 28 |
+
import urllib.request
|
| 29 |
+
import uuid
|
| 30 |
+
import webbrowser
|
| 31 |
+
from pathlib import Path
|
| 32 |
+
from typing import Any
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _client_version() -> str:
|
| 36 |
+
"""Return the installed package version from metadata, never hardcoded."""
|
| 37 |
+
try:
|
| 38 |
+
from importlib.metadata import PackageNotFoundError, version as _v
|
| 39 |
+
try:
|
| 40 |
+
return _v("sibyl-memory-cli")
|
| 41 |
+
except PackageNotFoundError:
|
| 42 |
+
return "0.0.0+source"
|
| 43 |
+
except Exception:
|
| 44 |
+
return "0.0.0+source"
|
| 45 |
+
|
| 46 |
+
# ---- Defaults ----------------------------------------------------------
|
| 47 |
+
|
| 48 |
+
API_BASE = os.environ.get("SIBYL_API_BASE", "https://api.sibyllabs.org")
|
| 49 |
+
ACTIVATE_BASE = os.environ.get("SIBYL_ACTIVATE_BASE", "https://sibyllabs.org/plugin/activate")
|
| 50 |
+
UPGRADE_BASE = os.environ.get("SIBYL_UPGRADE_BASE", "https://sibyllabs.org/plugin/upgrade")
|
| 51 |
+
|
| 52 |
+
DEFAULT_CRED_PATH = Path("~/.sibyl-memory/credentials.json").expanduser()
|
| 53 |
+
DEFAULT_DB_PATH = Path("~/.sibyl-memory/memory.db").expanduser()
|
| 54 |
+
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 β wallet ux can be slow
|
| 59 |
+
|
| 60 |
+
# ---- Color / output ----------------------------------------------------
|
| 61 |
+
|
| 62 |
+
from . import _aesthetic as a
|
| 63 |
+
|
| 64 |
+
_NO_COLOR = bool(os.environ.get("NO_COLOR")) or not sys.stdout.isatty()
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def c(code: str, s: str) -> str:
|
| 68 |
+
if _NO_COLOR:
|
| 69 |
+
return s
|
| 70 |
+
return f"\033[{code}m{s}\033[0m"
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def dim(s: str) -> str: return c("2", s)
|
| 74 |
+
def bold(s: str) -> str: return c("1", s)
|
| 75 |
+
def green(s: str) -> str: return c("32", s)
|
| 76 |
+
def yellow(s: str) -> str: return c("33", s)
|
| 77 |
+
def red(s: str) -> str: return c("31", s)
|
| 78 |
+
def cyan(s: str) -> str: return c("36", s)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _detect_os_family() -> str | None:
|
| 82 |
+
p = sys.platform
|
| 83 |
+
if p == "darwin": return "macos"
|
| 84 |
+
if p.startswith("linux"): return "linux"
|
| 85 |
+
if p.startswith("win"): return "windows"
|
| 86 |
+
return None
|
| 87 |
+
|
| 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:]}"
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def print_status(label: str, value: str) -> None:
|
| 98 |
+
print(f" {dim(label.ljust(18))} {value}")
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
# ---- HTTP --------------------------------------------------------------
|
| 102 |
+
|
| 103 |
+
class HttpError(Exception):
|
| 104 |
+
def __init__(self, status: int, body: Any, url: str) -> None:
|
| 105 |
+
super().__init__(f"HTTP {status} for {url}: {body}")
|
| 106 |
+
self.status = status
|
| 107 |
+
self.body = body
|
| 108 |
+
self.url = url
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def http_request( # noqa: D401
|
| 112 |
+
method: str,
|
| 113 |
+
path: str,
|
| 114 |
+
*,
|
| 115 |
+
body: dict | None = None,
|
| 116 |
+
timeout: float = 15.0,
|
| 117 |
+
headers: dict | None = None,
|
| 118 |
+
) -> dict:
|
| 119 |
+
"""Single source of truth for HTTP calls. Returns parsed JSON or raises HttpError."""
|
| 120 |
+
url = f"{API_BASE}{path}"
|
| 121 |
+
data = None
|
| 122 |
+
full_headers = {"Accept": "application/json", "User-Agent": f"sibyl-memory-cli/{_client_version()}"}
|
| 123 |
+
if body is not None:
|
| 124 |
+
data = json.dumps(body).encode("utf-8")
|
| 125 |
+
full_headers["Content-Type"] = "application/json"
|
| 126 |
+
if headers:
|
| 127 |
+
full_headers.update(headers)
|
| 128 |
+
req = urllib.request.Request(url, data=data, method=method, headers=full_headers)
|
| 129 |
+
try:
|
| 130 |
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
| 131 |
+
return json.loads(resp.read().decode("utf-8"))
|
| 132 |
+
except urllib.error.HTTPError as e:
|
| 133 |
+
try:
|
| 134 |
+
err_body = json.loads(e.read().decode("utf-8"))
|
| 135 |
+
except Exception:
|
| 136 |
+
err_body = {"error": "unparseable response body"}
|
| 137 |
+
raise HttpError(e.code, err_body, url) from None
|
| 138 |
+
except urllib.error.URLError as e:
|
| 139 |
+
raise HttpError(0, {"error": str(e.reason)}, url) from None
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
# ---- Credentials I/O ---------------------------------------------------
|
| 143 |
+
|
| 144 |
+
def write_credentials_atomic(creds: dict, path: Path = DEFAULT_CRED_PATH) -> Path:
|
| 145 |
+
"""Write credentials.json atomically at mode 0600.
|
| 146 |
+
|
| 147 |
+
v0.1.2 hardening (audit SEC-2): mode 0600 is set by the kernel at
|
| 148 |
+
file-creation time via O_CREAT|O_EXCL|O_NOFOLLOW. Previously used
|
| 149 |
+
write_text() followed by os.chmod(), leaving a world-readable window
|
| 150 |
+
between syscalls every credential save.
|
| 151 |
+
"""
|
| 152 |
+
path = path.expanduser()
|
| 153 |
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
| 154 |
+
data = json.dumps(creds, indent=2).encode("utf-8")
|
| 155 |
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
| 156 |
+
# Clean any leftover .tmp from a crashed prior write so O_EXCL can succeed.
|
| 157 |
+
try:
|
| 158 |
+
os.unlink(tmp)
|
| 159 |
+
except FileNotFoundError:
|
| 160 |
+
pass
|
| 161 |
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
| 162 |
+
if hasattr(os, "O_NOFOLLOW"):
|
| 163 |
+
flags |= os.O_NOFOLLOW
|
| 164 |
+
fd = os.open(str(tmp), flags, 0o600)
|
| 165 |
+
try:
|
| 166 |
+
os.write(fd, data)
|
| 167 |
+
os.fsync(fd)
|
| 168 |
+
finally:
|
| 169 |
+
os.close(fd)
|
| 170 |
+
os.replace(str(tmp), str(path))
|
| 171 |
+
return path
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def read_credentials(path: Path = DEFAULT_CRED_PATH) -> dict | None:
|
| 175 |
+
"""Read credentials.json.
|
| 176 |
+
|
| 177 |
+
v0.1.2 hardening (audit SEC-11): refuses to follow symlinks.
|
| 178 |
+
Returns None if the file is a symlink or doesn't exist."""
|
| 179 |
+
path = path.expanduser()
|
| 180 |
+
if not path.exists():
|
| 181 |
+
return None
|
| 182 |
+
if path.is_symlink():
|
| 183 |
+
return None
|
| 184 |
+
return json.loads(path.read_text(encoding="utf-8"))
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def invalidate_tier_cache(path: Path = DEFAULT_TIER_CACHE_PATH) -> None:
|
| 188 |
+
"""Drop the local tier cache so the next write refreshes against the server."""
|
| 189 |
+
path = path.expanduser()
|
| 190 |
+
if path.exists():
|
| 191 |
+
path.unlink()
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
# ---- `sibyl init` ------------------------------------------------------
|
| 195 |
+
|
| 196 |
+
def _gen_pairing_code() -> str:
|
| 197 |
+
"""6-digit cryptographic pairing code. Uniform across 000000-999999."""
|
| 198 |
+
return f"{secrets.randbelow(1_000_000):06d}"
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def _hash_pairing_code(code: str, session: str) -> str:
|
| 202 |
+
return hashlib.sha256(f"{code}:{session}".encode("utf-8")).hexdigest()
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def cmd_init(args: argparse.Namespace) -> int:
|
| 206 |
+
"""Activation flow. Generate session UUID + pairing code, register with
|
| 207 |
+
server, open activation page in browser, poll /check until bound.
|
| 208 |
+
|
| 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()
|
| 216 |
+
|
| 217 |
+
cred_path = Path(args.credentials).expanduser()
|
| 218 |
+
if cred_path.exists() and not args.force:
|
| 219 |
+
existing = read_credentials(cred_path) or {}
|
| 220 |
+
print(a.section_header("already activated", subtitle="use --force to re-activate"))
|
| 221 |
+
print()
|
| 222 |
+
print(a.kv("Account", short(existing.get("account_id"))))
|
| 223 |
+
print(a.kv("Tier", (existing.get("tier") or "free").upper(), value_color="accent"))
|
| 224 |
+
print(a.kv("Credentials", str(cred_path)))
|
| 225 |
+
print()
|
| 226 |
+
return 0
|
| 227 |
+
|
| 228 |
+
# SEC-1 mitigation (v0.1.2): the URL parameter is an opaque pairing
|
| 229 |
+
# session identifier, NOT the long-lived bearer used by /access and
|
| 230 |
+
# /check-write. The CLI generates it locally and the server treats
|
| 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()
|
| 238 |
+
code_hash = _hash_pairing_code(pairing_code, session_id)
|
| 239 |
+
activate_url = f"{ACTIVATE_BASE}?session={session_id}"
|
| 240 |
+
|
| 241 |
+
# Pre-register the session + pairing code hash with the server.
|
| 242 |
+
# The code itself never leaves the user's machine until they type it
|
| 243 |
+
# into the browser.
|
| 244 |
+
try:
|
| 245 |
+
http_request(
|
| 246 |
+
"POST",
|
| 247 |
+
"/api/plugin/session-init",
|
| 248 |
+
body={
|
| 249 |
+
"session": session_id,
|
| 250 |
+
"pairing_code_hash": code_hash,
|
| 251 |
+
"env": {
|
| 252 |
+
"os_family": _detect_os_family(),
|
| 253 |
+
"install_method": "cli",
|
| 254 |
+
"client_version": _client_version(),
|
| 255 |
+
},
|
| 256 |
+
},
|
| 257 |
+
timeout=10.0,
|
| 258 |
+
)
|
| 259 |
+
except HttpError as e:
|
| 260 |
+
# Non-fatal: SIWE path doesn't need the pairing code. If session-init
|
| 261 |
+
# fails the user can still complete SIWE. Surface the warning.
|
| 262 |
+
print(yellow(f"Warning: session-init failed ({e.status}). Wallet path still works; email path may not."))
|
| 263 |
+
|
| 264 |
+
print()
|
| 265 |
+
print(a.section_header("activation", subtitle="three paths Β· pick whichever fits your device"))
|
| 266 |
+
print()
|
| 267 |
+
print(a.kv("Session", short(session_id)))
|
| 268 |
+
formatted_code = pairing_code[:3] + " " + pairing_code[3:]
|
| 269 |
+
print(a.kv("Code", a.gradient_gold(formatted_code), value_color="accent")
|
| 270 |
+
+ " " + a.dim("(use this in the email panel)"))
|
| 271 |
+
print(a.kv("Opening", activate_url))
|
| 272 |
+
print()
|
| 273 |
+
print(a.dim(" desktop wallet Β· email + code Β· or send USDC from any mobile wallet"))
|
| 274 |
+
print(a.dim(" this terminal will pick up automatically when you bind."))
|
| 275 |
+
print()
|
| 276 |
+
|
| 277 |
+
try:
|
| 278 |
+
webbrowser.open(activate_url, new=2)
|
| 279 |
+
except Exception:
|
| 280 |
+
pass
|
| 281 |
+
|
| 282 |
+
# Poll /api/plugin/check
|
| 283 |
+
deadline = time.time() + INIT_TIMEOUT_SEC
|
| 284 |
+
last_status = ""
|
| 285 |
+
spinner = "β β β Ήβ Έβ Όβ ΄β ¦β §β β "
|
| 286 |
+
spin_i = 0
|
| 287 |
+
|
| 288 |
+
while time.time() < deadline:
|
| 289 |
+
try:
|
| 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}"))
|
| 297 |
+
return 2
|
| 298 |
+
resp = {"bound": False}
|
| 299 |
+
|
| 300 |
+
if resp.get("bound") and resp.get("credentials"):
|
| 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)
|
| 320 |
+
print(f"\r{' ' * 80}\r", end="") # clear spinner line
|
| 321 |
+
print()
|
| 322 |
+
print(a.success_line("Activated."))
|
| 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"))
|
| 331 |
+
print()
|
| 332 |
+
print(a.dim(" hermes:"))
|
| 333 |
+
print(a.dim(" sibyl-memory-hermes install-plugin"))
|
| 334 |
+
print(a.dim(" # then edit ~/.hermes/config.yaml:"))
|
| 335 |
+
print(a.dim(" # memory:"))
|
| 336 |
+
print(a.dim(" # provider: sibyl"))
|
| 337 |
+
print()
|
| 338 |
+
print(a.dim(" claude code / codex / cursor / continue (MCP):"))
|
| 339 |
+
print(a.dim(" pip install sibyl-memory-mcp"))
|
| 340 |
+
print()
|
| 341 |
+
print(a.dim(" python orchestration (langchain / llamaindex / custom):"))
|
| 342 |
+
print(a.dim(" from sibyl_memory_hermes import SibylMemoryProvider"))
|
| 343 |
+
print(a.dim(" provider = SibylMemoryProvider()"))
|
| 344 |
+
print()
|
| 345 |
+
return 0
|
| 346 |
+
|
| 347 |
+
# Spinner tick
|
| 348 |
+
spin_i = (spin_i + 1) % len(spinner)
|
| 349 |
+
remaining = int(deadline - time.time())
|
| 350 |
+
spin_glyph = a.color(spinner[spin_i], a.PULSE)
|
| 351 |
+
status = f"\r {spin_glyph} {a.dim('watching the network for your bind')} β¦ {a.dim(f'{remaining // 60}:{remaining % 60:02d} left')}"
|
| 352 |
+
if status != last_status:
|
| 353 |
+
sys.stdout.write(status)
|
| 354 |
+
sys.stdout.flush()
|
| 355 |
+
last_status = status
|
| 356 |
+
time.sleep(POLL_INTERVAL_SEC)
|
| 357 |
+
|
| 358 |
+
print()
|
| 359 |
+
print(a.err_line("Activation timed out."))
|
| 360 |
+
print(a.dim(" Re-run `sibyl init` to try again."))
|
| 361 |
+
return 1
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
# ---- `sibyl upgrade` ---------------------------------------------------
|
| 365 |
+
|
| 366 |
+
def cmd_upgrade(args: argparse.Namespace) -> int:
|
| 367 |
+
"""Upgrade flow. Read existing creds β open upgrade page β poll /access until tier flips."""
|
| 368 |
+
creds = read_credentials(Path(args.credentials).expanduser())
|
| 369 |
+
if not creds:
|
| 370 |
+
print(a.err_line("Not activated."))
|
| 371 |
+
print(a.dim(" Run `sibyl init` first."))
|
| 372 |
+
return 1
|
| 373 |
+
|
| 374 |
+
account_id = creds.get("account_id")
|
| 375 |
+
session_token = creds.get("session_token")
|
| 376 |
+
current_tier = (creds.get("tier") or "free").lower()
|
| 377 |
+
|
| 378 |
+
if not account_id or not session_token:
|
| 379 |
+
print(a.err_line("credentials.json is missing account_id or session_token."))
|
| 380 |
+
print(a.dim(" Re-run `sibyl init`."))
|
| 381 |
+
return 1
|
| 382 |
+
|
| 383 |
+
upgrade_url = f"{UPGRADE_BASE}?session={session_token}"
|
| 384 |
+
|
| 385 |
+
print()
|
| 386 |
+
print(a.section_header("upgrade", subtitle="lift the 2 MB free-tier cap"))
|
| 387 |
+
print()
|
| 388 |
+
print(a.kv("Account", short(account_id)))
|
| 389 |
+
print(a.kv("Current tier", current_tier.upper(), value_color="accent"))
|
| 390 |
+
print(a.kv("Opening", upgrade_url))
|
| 391 |
+
print()
|
| 392 |
+
print(a.dim(" two paths in the browser:"))
|
| 393 |
+
print(a.dim(" 1. stake $SIBYL on Base (free unlimited if you qualify)"))
|
| 394 |
+
print(a.dim(" 2. subscribe in USDC (monthly / quarterly / annual)"))
|
| 395 |
+
print()
|
| 396 |
+
|
| 397 |
+
try:
|
| 398 |
+
webbrowser.open(upgrade_url, new=2)
|
| 399 |
+
except Exception:
|
| 400 |
+
pass
|
| 401 |
+
|
| 402 |
+
# Poll /api/plugin/access until tier changes
|
| 403 |
+
deadline = time.time() + UPGRADE_TIMEOUT_SEC
|
| 404 |
+
last_status = ""
|
| 405 |
+
spinner = "β β β Ήβ Έβ Όβ ΄β ¦β §β β "
|
| 406 |
+
spin_i = 0
|
| 407 |
+
|
| 408 |
+
while time.time() < deadline:
|
| 409 |
+
try:
|
| 410 |
+
resp = http_request(
|
| 411 |
+
"POST",
|
| 412 |
+
"/api/plugin/access",
|
| 413 |
+
body={"account_id": account_id, "session_token": session_token},
|
| 414 |
+
timeout=10.0,
|
| 415 |
+
)
|
| 416 |
+
except HttpError as e:
|
| 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()
|
| 424 |
+
source = resp.get("source")
|
| 425 |
+
|
| 426 |
+
if new_tier != current_tier and source in ("subscription", "staker"):
|
| 427 |
+
# Tier changed. Refresh credentials.
|
| 428 |
+
creds["tier"] = new_tier
|
| 429 |
+
if resp.get("staker") and resp["staker"].get("wallet"):
|
| 430 |
+
creds["wallet"] = resp["staker"]["wallet"]
|
| 431 |
+
write_credentials_atomic(creds, Path(args.credentials).expanduser())
|
| 432 |
+
invalidate_tier_cache()
|
| 433 |
+
|
| 434 |
+
print(f"\r{' ' * 80}\r", end="")
|
| 435 |
+
print()
|
| 436 |
+
print(a.success_line(f"Upgraded to {new_tier.upper()} via {source}."))
|
| 437 |
+
print()
|
| 438 |
+
print(a.kv("Source", source))
|
| 439 |
+
if resp.get("expires_at"):
|
| 440 |
+
print(a.kv("Expires", resp["expires_at"]))
|
| 441 |
+
if resp.get("cap_bytes") is None:
|
| 442 |
+
print(a.kv("Storage cap", "unlimited", value_color="ok"))
|
| 443 |
+
else:
|
| 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
|
| 452 |
+
|
| 453 |
+
spin_i = (spin_i + 1) % len(spinner)
|
| 454 |
+
remaining = int(deadline - time.time())
|
| 455 |
+
spin_glyph = a.color(spinner[spin_i], a.PULSE)
|
| 456 |
+
tier_glyph = a.color(current_tier.upper(), a.ACCENT)
|
| 457 |
+
status = f"\r {spin_glyph} {a.dim('waiting for browser upgrade')} Β· current: {tier_glyph} {a.dim(f'{remaining // 60}:{remaining % 60:02d} left')}"
|
| 458 |
+
if status != last_status:
|
| 459 |
+
sys.stdout.write(status)
|
| 460 |
+
sys.stdout.flush()
|
| 461 |
+
last_status = status
|
| 462 |
+
time.sleep(POLL_INTERVAL_SEC)
|
| 463 |
+
|
| 464 |
+
print()
|
| 465 |
+
print(a.err_line("Upgrade timed out. Tier unchanged."))
|
| 466 |
+
print(a.dim(" Re-run `sibyl upgrade` to retry."))
|
| 467 |
+
return 1
|
| 468 |
+
|
| 469 |
+
|
| 470 |
+
# ---- `sibyl status` ----------------------------------------------------
|
| 471 |
+
|
| 472 |
+
def cmd_status(args: argparse.Namespace) -> int:
|
| 473 |
+
"""Show local + server-side state without modifying anything.
|
| 474 |
+
|
| 475 |
+
LIGHT treatment: utilitarian dashboard. No banner, no section header,
|
| 476 |
+
no chrome. Eyebrow labels + kv rows + β status drift surfaces. Same
|
| 477 |
+
convention as `git status`, `ls -la`, `btop` panel bodies."""
|
| 478 |
+
cred_path = Path(args.credentials).expanduser()
|
| 479 |
+
creds = read_credentials(cred_path)
|
| 480 |
+
|
| 481 |
+
print()
|
| 482 |
+
|
| 483 |
+
if not creds:
|
| 484 |
+
print(a.warn_line("Not activated."))
|
| 485 |
+
print(a.dim(" Run `sibyl init`."))
|
| 486 |
+
return 0
|
| 487 |
+
|
| 488 |
+
# Local view
|
| 489 |
+
print(a.eyebrow("local"))
|
| 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():
|
| 499 |
+
size = db_path.stat().st_size
|
| 500 |
+
pct = size / 2_097_152 * 100
|
| 501 |
+
size_label = f"{size:,} bytes ({size / (1024 * 1024):.2f} MB Β· {pct:.1f}% of free cap)"
|
| 502 |
+
size_color = "warn" if pct > 80 else "soft"
|
| 503 |
+
print(a.kv("DB path", str(db_path)))
|
| 504 |
+
print(a.kv("DB size", size_label, value_color=size_color))
|
| 505 |
+
else:
|
| 506 |
+
print(a.kv("DB path", f"{db_path} (not created)"))
|
| 507 |
+
|
| 508 |
+
tier_cache = Path(args.tier_cache).expanduser()
|
| 509 |
+
if tier_cache.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"):
|
| 517 |
+
print()
|
| 518 |
+
print(a.eyebrow("server"))
|
| 519 |
+
try:
|
| 520 |
+
resp = http_request(
|
| 521 |
+
"POST",
|
| 522 |
+
"/api/plugin/access",
|
| 523 |
+
body={"account_id": creds["account_id"], "session_token": creds["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
|
| 538 |
+
srv_tier = (resp.get("tier") or "free").lower()
|
| 539 |
+
loc_tier = (creds.get("tier") or "free").lower()
|
| 540 |
+
if srv_tier != loc_tier:
|
| 541 |
+
print()
|
| 542 |
+
print(a.warn_line(f"Local tier ({loc_tier}) differs from server tier ({srv_tier})."))
|
| 543 |
+
print(a.dim(" Run `sibyl upgrade` to refresh, or `sibyl init --force` to re-activate."))
|
| 544 |
+
except HttpError as e:
|
| 545 |
+
print(a.kv("Tier", f"server error: {e.status}", value_color="err"))
|
| 546 |
+
|
| 547 |
+
print()
|
| 548 |
+
return 0
|
| 549 |
+
|
| 550 |
+
|
| 551 |
+
# ---- `sibyl dashboard` (placeholder, today routes to status) -----------
|
| 552 |
+
|
| 553 |
+
def cmd_dashboard(args: argparse.Namespace) -> int:
|
| 554 |
+
"""Open the web account dashboard. In v0.1.0, the dashboard at
|
| 555 |
+
account.sibyllabs.org is not yet live (queued post-V1-ship per the
|
| 556 |
+
operator design memo). Until then, `sibyl dashboard` delegates to
|
| 557 |
+
`sibyl status` so the command surface exists from day one and users
|
| 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:
|
| 565 |
+
# If env var is set, open the web dashboard with the session token.
|
| 566 |
+
creds = read_credentials(Path(args.credentials).expanduser())
|
| 567 |
+
if creds and creds.get("session_token"):
|
| 568 |
+
url = f"{DASHBOARD_BASE}?session={creds['session_token']}"
|
| 569 |
+
print()
|
| 570 |
+
print(bold("Sibyl Memory Plugin Β· dashboard"))
|
| 571 |
+
print(f" {dim('Opening:')} {url}")
|
| 572 |
+
print()
|
| 573 |
+
try:
|
| 574 |
+
webbrowser.open(url, new=2)
|
| 575 |
+
except Exception:
|
| 576 |
+
pass
|
| 577 |
+
return 0
|
| 578 |
+
# Fall through: account.sibyllabs.org isn't live yet, run status instead.
|
| 579 |
+
return cmd_status(args)
|
| 580 |
+
|
| 581 |
+
|
| 582 |
+
# ---- `sibyl whoami` ----------------------------------------------------
|
| 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]}***"
|
| 590 |
+
name, _, tld = domain.rpartition(".")
|
| 591 |
+
return f"{user[0]}***@{name[0]}***.{tld}"
|
| 592 |
+
|
| 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 |
+
|
| 600 |
+
def cmd_whoami(args: argparse.Namespace) -> int:
|
| 601 |
+
"""One-line account summary. Shows account_id + tier + linked email/wallet + this device.
|
| 602 |
+
|
| 603 |
+
LIGHT treatment: 4-line glance. No banner, no section header. Same shape
|
| 604 |
+
as `whoami` on unix, `gh auth status`, `aws sts get-caller-identity`."""
|
| 605 |
+
creds = read_credentials(Path(args.credentials).expanduser())
|
| 606 |
+
if not creds:
|
| 607 |
+
print(a.warn_line("Not activated."))
|
| 608 |
+
print(a.dim(" Run `sibyl init`."))
|
| 609 |
+
return 1
|
| 610 |
+
|
| 611 |
+
full = bool(getattr(args, "full", False))
|
| 612 |
+
acct = creds.get("account_id") or ""
|
| 613 |
+
tier = (creds.get("tier") or "free").upper()
|
| 614 |
+
email = creds.get("email") if full else _mask_email(creds.get("email"))
|
| 615 |
+
wallet = creds.get("wallet") if full else _mask_wallet(creds.get("wallet"))
|
| 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)}")
|
| 624 |
+
print()
|
| 625 |
+
return 0
|
| 626 |
+
|
| 627 |
+
|
| 628 |
+
# ---- `sibyl devices` ---------------------------------------------------
|
| 629 |
+
|
| 630 |
+
def cmd_devices(args: argparse.Namespace) -> int:
|
| 631 |
+
"""List active bearer tokens (devices) for the account. Optional: revoke by index."""
|
| 632 |
+
creds = read_credentials(Path(args.credentials).expanduser())
|
| 633 |
+
if not creds:
|
| 634 |
+
print(a.err_line("Not activated."))
|
| 635 |
+
print(a.dim(" Run `sibyl init`."))
|
| 636 |
+
return 1
|
| 637 |
+
account_id = creds.get("account_id")
|
| 638 |
+
session_token = creds.get("session_token")
|
| 639 |
+
if not account_id or not session_token:
|
| 640 |
+
print(a.err_line("credentials.json missing account_id or session_token."))
|
| 641 |
+
print(a.dim(" Run `sibyl init`."))
|
| 642 |
+
return 1
|
| 643 |
+
|
| 644 |
+
sub = getattr(args, "sub", None)
|
| 645 |
+
|
| 646 |
+
# `sibyl devices revoke <index>` path
|
| 647 |
+
if sub == "revoke":
|
| 648 |
+
idx = getattr(args, "index", None)
|
| 649 |
+
if idx is None:
|
| 650 |
+
print(red("usage: sibyl devices revoke <index>"))
|
| 651 |
+
return 1
|
| 652 |
+
# List first to map index β bearer_id
|
| 653 |
+
try:
|
| 654 |
+
resp = http_request(
|
| 655 |
+
"GET",
|
| 656 |
+
f"/api/plugin/devices?account_id={urllib.parse.quote(account_id)}",
|
| 657 |
+
headers={"Authorization": f"Bearer {session_token}"},
|
| 658 |
+
timeout=10.0,
|
| 659 |
+
)
|
| 660 |
+
except HttpError as e:
|
| 661 |
+
print(red(f"server error: {e.status} {e.body}"))
|
| 662 |
+
return 2
|
| 663 |
+
devices = resp.get("devices", [])
|
| 664 |
+
try:
|
| 665 |
+
target = devices[idx]
|
| 666 |
+
except (IndexError, TypeError):
|
| 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(
|
| 674 |
+
"POST",
|
| 675 |
+
"/api/plugin/devices",
|
| 676 |
+
body={"bearer_id": target["bearer_id"]},
|
| 677 |
+
headers={"Authorization": f"Bearer {session_token}"},
|
| 678 |
+
timeout=10.0,
|
| 679 |
+
)
|
| 680 |
+
except HttpError as e:
|
| 681 |
+
print(red(f"revoke failed: {e.status} {e.body}"))
|
| 682 |
+
return 2
|
| 683 |
+
print(green(f"β Revoked device {target.get('device_label') or target['bearer_id']}"))
|
| 684 |
+
return 0 if revoke_resp.get("revoked") else 1
|
| 685 |
+
|
| 686 |
+
# Default: list devices
|
| 687 |
+
try:
|
| 688 |
+
resp = http_request(
|
| 689 |
+
"GET",
|
| 690 |
+
f"/api/plugin/devices?account_id={urllib.parse.quote(account_id)}",
|
| 691 |
+
headers={"Authorization": f"Bearer {session_token}"},
|
| 692 |
+
timeout=10.0,
|
| 693 |
+
)
|
| 694 |
+
except HttpError as e:
|
| 695 |
+
if e.status == 401:
|
| 696 |
+
print(a.err_line("Session expired."))
|
| 697 |
+
print(a.dim(" Re-run `sibyl init`."))
|
| 698 |
+
else:
|
| 699 |
+
print(a.err_line(f"server error: {e.status} {e.body}"))
|
| 700 |
+
return 2
|
| 701 |
+
|
| 702 |
+
devices = resp.get("devices", [])
|
| 703 |
+
# LIGHT treatment: table-like dashboard. Eyebrow line with count + the rows. No banner.
|
| 704 |
+
print()
|
| 705 |
+
print(f" {a.eyebrow('devices')} {a.dim(f'Β· {len(devices)} active')}")
|
| 706 |
+
print()
|
| 707 |
+
if not devices:
|
| 708 |
+
print(a.dim(" no active devices"))
|
| 709 |
+
print()
|
| 710 |
+
return 0
|
| 711 |
+
|
| 712 |
+
for i, d in enumerate(devices):
|
| 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)
|
| 720 |
+
meta = f"{a.dim(installed)} {a.dim(a.GLYPH_DOT)} {a.dim('last seen ' + last_seen)}"
|
| 721 |
+
note = a.color("(this device)", a.PULSE) if is_this else a.dim(f"revoke: sibyl devices revoke {i}")
|
| 722 |
+
print(f" {marker} {idx_chip} {label_color} {meta} {note}")
|
| 723 |
+
print()
|
| 724 |
+
return 0
|
| 725 |
+
|
| 726 |
+
|
| 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 |
+
|
| 734 |
+
deleted = []
|
| 735 |
+
if cred_path.exists():
|
| 736 |
+
cred_path.unlink()
|
| 737 |
+
deleted.append(str(cred_path))
|
| 738 |
+
if tier_cache.exists():
|
| 739 |
+
tier_cache.unlink()
|
| 740 |
+
deleted.append(str(tier_cache))
|
| 741 |
+
|
| 742 |
+
# LIGHT treatment: quick confirmation. No banner, no section header.
|
| 743 |
+
print()
|
| 744 |
+
if not deleted:
|
| 745 |
+
print(a.warn_line("Nothing to remove."))
|
| 746 |
+
print(a.dim(" Already logged out."))
|
| 747 |
+
else:
|
| 748 |
+
print(a.success_line("Logged out."))
|
| 749 |
+
for path in deleted:
|
| 750 |
+
print(f" {a.dim('removed')} {a.color(path, a.INK)}")
|
| 751 |
+
print()
|
| 752 |
+
print(a.dim(" memory.db untouched. run `sibyl init` to activate a fresh account."))
|
| 753 |
+
print()
|
| 754 |
+
return 0
|
| 755 |
+
|
| 756 |
+
|
| 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:
|
| 764 |
+
print(a.err_line("sibyl-memory-hermes not installed."))
|
| 765 |
+
print(a.dim(" pip install sibyl-memory-hermes"))
|
| 766 |
+
return 1
|
| 767 |
+
|
| 768 |
+
# LIGHT treatment: verdict + details. No banner, no section header.
|
| 769 |
+
# Pattern: `pg_isready` / `redis-cli ping` / `gh auth status`.
|
| 770 |
+
print()
|
| 771 |
+
provider = SibylMemoryProvider(db_path=args.db)
|
| 772 |
+
h = provider.health()
|
| 773 |
+
ok_state = bool(h.get("ok"))
|
| 774 |
+
if ok_state:
|
| 775 |
+
print(a.success_line("All green."))
|
| 776 |
+
else:
|
| 777 |
+
print(a.err_line("Health check reports issues."))
|
| 778 |
+
print()
|
| 779 |
+
for k, v in h.items():
|
| 780 |
+
if k == "ok":
|
| 781 |
+
continue
|
| 782 |
+
val = str(v)
|
| 783 |
+
print(a.kv(k, val, value_color="ok" if v is True else ("soft" if v else "warn")))
|
| 784 |
+
print()
|
| 785 |
+
return 0 if ok_state else 1
|
| 786 |
+
|
| 787 |
+
|
| 788 |
+
# ---- Dispatch ----------------------------------------------------------
|
| 789 |
+
|
| 790 |
+
def build_parser() -> argparse.ArgumentParser:
|
| 791 |
+
p = argparse.ArgumentParser(
|
| 792 |
+
prog="sibyl",
|
| 793 |
+
description="Command-line interface for the Sibyl Memory Plugin.",
|
| 794 |
+
)
|
| 795 |
+
p.add_argument("--credentials", default=str(DEFAULT_CRED_PATH),
|
| 796 |
+
help="Path to credentials.json (default: ~/.sibyl-memory/credentials.json)")
|
| 797 |
+
p.add_argument("--db", default=str(DEFAULT_DB_PATH),
|
| 798 |
+
help="Path to memory.db (default: ~/.sibyl-memory/memory.db)")
|
| 799 |
+
p.add_argument("--tier-cache", default=str(DEFAULT_TIER_CACHE_PATH),
|
| 800 |
+
help="Path to tier_cache.json (default: ~/.sibyl-memory/tier_cache.json)")
|
| 801 |
+
|
| 802 |
+
sub = p.add_subparsers(dest="cmd", required=True)
|
| 803 |
+
|
| 804 |
+
p_init = sub.add_parser("init", help="Activate the plugin in your browser")
|
| 805 |
+
p_init.add_argument("--force", action="store_true", help="Re-activate even if credentials.json exists")
|
| 806 |
+
p_init.set_defaults(func=cmd_init)
|
| 807 |
+
|
| 808 |
+
p_up = sub.add_parser("upgrade", help="Open the upgrade flow (stake or subscribe)")
|
| 809 |
+
p_up.set_defaults(func=cmd_upgrade)
|
| 810 |
+
|
| 811 |
+
p_st = sub.add_parser("status", help="Show local + server tier / DB stats")
|
| 812 |
+
p_st.set_defaults(func=cmd_status)
|
| 813 |
+
|
| 814 |
+
p_who = sub.add_parser("whoami", help="One-line account summary (masked by default)")
|
| 815 |
+
p_who.add_argument("--full", action="store_true", help="Show full email + wallet (no masking)")
|
| 816 |
+
p_who.set_defaults(func=cmd_whoami)
|
| 817 |
+
|
| 818 |
+
p_dev = sub.add_parser("devices", help="List devices (active bearer tokens) for the account")
|
| 819 |
+
dev_sub = p_dev.add_subparsers(dest="sub")
|
| 820 |
+
p_rev = dev_sub.add_parser("revoke", help="Revoke a device by index (run `sibyl devices` for indexes)")
|
| 821 |
+
p_rev.add_argument("index", type=int, help="Index from `sibyl devices` output")
|
| 822 |
+
p_dev.set_defaults(func=cmd_devices)
|
| 823 |
+
p_rev.set_defaults(func=cmd_devices)
|
| 824 |
+
|
| 825 |
+
p_dash = sub.add_parser("dashboard", help="Open the account dashboard (delegates to status until account.sibyllabs.org ships)")
|
| 826 |
+
p_dash.set_defaults(func=cmd_dashboard)
|
| 827 |
+
|
| 828 |
+
p_lo = sub.add_parser("logout", help="Remove local credentials (memory.db stays)")
|
| 829 |
+
p_lo.set_defaults(func=cmd_logout)
|
| 830 |
+
|
| 831 |
+
p_h = sub.add_parser("health", help="Run the provider self-check")
|
| 832 |
+
p_h.set_defaults(func=cmd_health)
|
| 833 |
+
|
| 834 |
+
# v0.1.4: one-command auto-detect-and-wire setup for any agent stack
|
| 835 |
+
from .setup import cmd_setup
|
| 836 |
+
p_setup = sub.add_parser(
|
| 837 |
+
"setup",
|
| 838 |
+
help="Auto-detect Hermes / Claude Code and wire SIBYL as the memory provider",
|
| 839 |
+
)
|
| 840 |
+
p_setup.add_argument(
|
| 841 |
+
"target", nargs="?", choices=list(["hermes", "claude-code"]),
|
| 842 |
+
help="Wire just this framework (default: detect all)",
|
| 843 |
+
)
|
| 844 |
+
p_setup.add_argument(
|
| 845 |
+
"--yes", "-y", action="store_true",
|
| 846 |
+
help="Skip prompts, accept defaults (still respects destructive-default-NO unless --force)",
|
| 847 |
+
)
|
| 848 |
+
p_setup.add_argument(
|
| 849 |
+
"--force", action="store_true",
|
| 850 |
+
help="Overwrite existing non-SIBYL memory provider configs",
|
| 851 |
+
)
|
| 852 |
+
p_setup.add_argument(
|
| 853 |
+
"--dry-run", action="store_true",
|
| 854 |
+
help="Print what would change without writing",
|
| 855 |
+
)
|
| 856 |
+
p_setup.add_argument(
|
| 857 |
+
"--hermes-home", default=None,
|
| 858 |
+
help="Override HERMES_HOME autodetection",
|
| 859 |
+
)
|
| 860 |
+
p_setup.add_argument(
|
| 861 |
+
"--claude-settings", default=None,
|
| 862 |
+
help="Override ~/.claude/settings.json autodetection",
|
| 863 |
+
)
|
| 864 |
+
p_setup.set_defaults(func=cmd_setup)
|
| 865 |
+
|
| 866 |
+
return p
|
| 867 |
+
|
| 868 |
+
|
| 869 |
+
def main(argv: list[str] | None = None) -> int:
|
| 870 |
+
parser = build_parser()
|
| 871 |
+
args = parser.parse_args(argv)
|
| 872 |
+
try:
|
| 873 |
+
return args.func(args)
|
| 874 |
+
except KeyboardInterrupt:
|
| 875 |
+
print(red("\nInterrupted."))
|
| 876 |
+
return 130
|
| 877 |
+
|
| 878 |
+
|
| 879 |
+
if __name__ == "__main__":
|
| 880 |
+
sys.exit(main())
|
sibyl-memory-cli/src/sibyl_memory_cli/setup.py
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""`sibyl setup`: auto-detect agent frameworks and wire SIBYL as memory provider.
|
| 2 |
+
|
| 3 |
+
Maximum-efficiency onboarding command. Single-command path for the user:
|
| 4 |
+
|
| 5 |
+
pip install sibyl-memory-cli
|
| 6 |
+
sibyl setup # auto-detects Hermes + Claude Code, prompts per stack, wires
|
| 7 |
+
|
| 8 |
+
Two wirers in v0.1.4:
|
| 9 |
+
- HermesWirer: install-plugin + edit $HERMES_HOME/config.yaml (memory.provider)
|
| 10 |
+
- ClaudeCodeWirer: edit ~/.claude/settings.json (mcpServers.sibyl-memory)
|
| 11 |
+
|
| 12 |
+
Each wirer follows the same protocol:
|
| 13 |
+
is_present() -> bool (filesystem + PATH detect)
|
| 14 |
+
current_state() -> dict (configured? wired-with-sibyl? current-value?)
|
| 15 |
+
wire(force, dry_run, prompt_fn) -> WireOutcome
|
| 16 |
+
|
| 17 |
+
Destructive operations (overwriting an existing non-SIBYL config) default to NO
|
| 18 |
+
on the prompt. Fresh adds default to YES. --force overrides destructive guards.
|
| 19 |
+
--yes accepts all defaults (still respects the destructive-default-NO unless
|
| 20 |
+
--force is also passed). --dry-run prints intent without writing.
|
| 21 |
+
|
| 22 |
+
All config edits are atomic: backup to <file>.bak, write to <file>.tmp, rename.
|
| 23 |
+
"""
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
import argparse
|
| 27 |
+
import json
|
| 28 |
+
import os
|
| 29 |
+
import shutil
|
| 30 |
+
import sys
|
| 31 |
+
from dataclasses import dataclass
|
| 32 |
+
from pathlib import Path
|
| 33 |
+
from typing import Any, Callable, Optional, Union
|
| 34 |
+
|
| 35 |
+
# Color helpers re-imported from cli module via late binding to avoid circular dep.
|
| 36 |
+
# When called via `sibyl setup` they resolve through the cli module's tty detection.
|
| 37 |
+
def _color_fns():
|
| 38 |
+
from .cli import bold, cyan, dim, green, red, yellow
|
| 39 |
+
return bold, cyan, dim, green, red, yellow
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# ----------------------------------------------------------------------
|
| 43 |
+
# WireOutcome
|
| 44 |
+
# ----------------------------------------------------------------------
|
| 45 |
+
|
| 46 |
+
@dataclass
|
| 47 |
+
class WireOutcome:
|
| 48 |
+
"""Result of a wirer.wire() call. Composable across multiple wirers."""
|
| 49 |
+
name: str
|
| 50 |
+
status: str # 'wired' / 'already' / 'skipped' / 'dry-run' / 'error'
|
| 51 |
+
message: str
|
| 52 |
+
backup_path: Optional[Path] = None
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# ----------------------------------------------------------------------
|
| 56 |
+
# Lazy YAML import. Hermes wirer needs it; Claude-only users do not.
|
| 57 |
+
# ----------------------------------------------------------------------
|
| 58 |
+
|
| 59 |
+
def _import_yaml():
|
| 60 |
+
try:
|
| 61 |
+
import yaml
|
| 62 |
+
return yaml
|
| 63 |
+
except ImportError:
|
| 64 |
+
return None
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
# ----------------------------------------------------------------------
|
| 68 |
+
# HermesWirer
|
| 69 |
+
# ----------------------------------------------------------------------
|
| 70 |
+
|
| 71 |
+
class HermesWirer:
|
| 72 |
+
name = "hermes"
|
| 73 |
+
display_name = "Hermes"
|
| 74 |
+
initial = "h"
|
| 75 |
+
|
| 76 |
+
def __init__(self, *, hermes_home: Optional[Union[str, Path]] = None):
|
| 77 |
+
self.hermes_home = (
|
| 78 |
+
Path(hermes_home).expanduser() if hermes_home
|
| 79 |
+
else self._auto_hermes_home()
|
| 80 |
+
)
|
| 81 |
+
self.config_path = self.hermes_home / "config.yaml"
|
| 82 |
+
self.plugin_dir = self.hermes_home / "plugins" / "sibyl"
|
| 83 |
+
|
| 84 |
+
@staticmethod
|
| 85 |
+
def _auto_hermes_home() -> Path:
|
| 86 |
+
env = os.environ.get("HERMES_HOME")
|
| 87 |
+
if env:
|
| 88 |
+
return Path(env).expanduser()
|
| 89 |
+
return Path.home() / ".hermes"
|
| 90 |
+
|
| 91 |
+
def is_present(self) -> bool:
|
| 92 |
+
# Present if HERMES_HOME exists OR `hermes` binary on PATH
|
| 93 |
+
if self.hermes_home.exists():
|
| 94 |
+
return True
|
| 95 |
+
if shutil.which("hermes"):
|
| 96 |
+
return True
|
| 97 |
+
return False
|
| 98 |
+
|
| 99 |
+
def current_state(self) -> dict:
|
| 100 |
+
config_exists = self.config_path.exists()
|
| 101 |
+
plugin_installed = (self.plugin_dir / "__init__.py").exists()
|
| 102 |
+
memory_provider: Optional[str] = None
|
| 103 |
+
if config_exists:
|
| 104 |
+
yaml = _import_yaml()
|
| 105 |
+
if yaml is not None:
|
| 106 |
+
try:
|
| 107 |
+
raw = self.config_path.read_text(encoding="utf-8")
|
| 108 |
+
cfg = yaml.safe_load(raw) or {}
|
| 109 |
+
if isinstance(cfg, dict):
|
| 110 |
+
mem = cfg.get("memory")
|
| 111 |
+
if isinstance(mem, dict):
|
| 112 |
+
memory_provider = mem.get("provider")
|
| 113 |
+
except Exception:
|
| 114 |
+
pass
|
| 115 |
+
return {
|
| 116 |
+
"hermes_home": str(self.hermes_home),
|
| 117 |
+
"config_path": str(self.config_path),
|
| 118 |
+
"config_exists": config_exists,
|
| 119 |
+
"plugin_installed": plugin_installed,
|
| 120 |
+
"memory_provider": memory_provider,
|
| 121 |
+
"wired_with_sibyl": memory_provider == "sibyl",
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
def wire(self, *, force: bool = False, dry_run: bool = False,
|
| 125 |
+
prompt_fn: Optional[Callable[..., str]] = None) -> WireOutcome:
|
| 126 |
+
state = self.current_state()
|
| 127 |
+
yaml = _import_yaml()
|
| 128 |
+
if yaml is None:
|
| 129 |
+
return WireOutcome(
|
| 130 |
+
self.name, "error",
|
| 131 |
+
"PyYAML not installed. Run `pip install pyyaml` and retry.",
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
# 1. Install plugin if missing
|
| 135 |
+
if not state["plugin_installed"]:
|
| 136 |
+
if dry_run:
|
| 137 |
+
pass # report at the end
|
| 138 |
+
else:
|
| 139 |
+
try:
|
| 140 |
+
self._install_plugin()
|
| 141 |
+
except Exception as e:
|
| 142 |
+
return WireOutcome(
|
| 143 |
+
self.name, "error",
|
| 144 |
+
f"install-plugin failed: {type(e).__name__}: {e}",
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
# 2. Already wired? no-op
|
| 148 |
+
if state["wired_with_sibyl"] and state["plugin_installed"]:
|
| 149 |
+
return WireOutcome(
|
| 150 |
+
self.name, "already",
|
| 151 |
+
f"Hermes already has SIBYL as memory provider in {self.config_path}",
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
# 3. Existing non-SIBYL provider? confirm or refuse
|
| 155 |
+
if state["memory_provider"] and state["memory_provider"] != "sibyl" and not force:
|
| 156 |
+
if prompt_fn is None:
|
| 157 |
+
return WireOutcome(
|
| 158 |
+
self.name, "skipped",
|
| 159 |
+
f"Existing memory.provider '{state['memory_provider']}'. Use --force to overwrite.",
|
| 160 |
+
)
|
| 161 |
+
ans = prompt_fn(
|
| 162 |
+
f"Hermes currently uses '{state['memory_provider']}' as memory provider. Overwrite with SIBYL?",
|
| 163 |
+
default="N",
|
| 164 |
+
)
|
| 165 |
+
if ans != "y":
|
| 166 |
+
return WireOutcome(self.name, "skipped", "Memory provider overwrite declined.")
|
| 167 |
+
|
| 168 |
+
# 4. Dry-run report
|
| 169 |
+
if dry_run:
|
| 170 |
+
actions = []
|
| 171 |
+
if not state["plugin_installed"]:
|
| 172 |
+
actions.append(f"install plugin at {self.plugin_dir}")
|
| 173 |
+
actions.append(f"set memory.provider=sibyl in {self.config_path}")
|
| 174 |
+
return WireOutcome(self.name, "dry-run", "Would: " + "; ".join(actions))
|
| 175 |
+
|
| 176 |
+
# 5. Real write. Backup, then atomic rename.
|
| 177 |
+
backup = self._backup_config()
|
| 178 |
+
try:
|
| 179 |
+
self._write_config_with_sibyl(yaml)
|
| 180 |
+
except Exception as e:
|
| 181 |
+
return WireOutcome(
|
| 182 |
+
self.name, "error",
|
| 183 |
+
f"config write failed: {type(e).__name__}: {e}",
|
| 184 |
+
backup_path=backup,
|
| 185 |
+
)
|
| 186 |
+
return WireOutcome(
|
| 187 |
+
self.name, "wired",
|
| 188 |
+
f"Wired memory.provider=sibyl in {self.config_path}",
|
| 189 |
+
backup_path=backup,
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
def _install_plugin(self) -> None:
|
| 193 |
+
from sibyl_memory_hermes.install_plugin import install
|
| 194 |
+
install(hermes_home=str(self.hermes_home))
|
| 195 |
+
|
| 196 |
+
def _backup_config(self) -> Optional[Path]:
|
| 197 |
+
if not self.config_path.exists():
|
| 198 |
+
return None
|
| 199 |
+
backup = self.config_path.with_suffix(".yaml.bak")
|
| 200 |
+
shutil.copy2(self.config_path, backup)
|
| 201 |
+
return backup
|
| 202 |
+
|
| 203 |
+
def _write_config_with_sibyl(self, yaml) -> None:
|
| 204 |
+
cfg: dict = {}
|
| 205 |
+
if self.config_path.exists():
|
| 206 |
+
raw = self.config_path.read_text(encoding="utf-8")
|
| 207 |
+
loaded = yaml.safe_load(raw)
|
| 208 |
+
if isinstance(loaded, dict):
|
| 209 |
+
cfg = loaded
|
| 210 |
+
if not isinstance(cfg.get("memory"), dict):
|
| 211 |
+
cfg["memory"] = {}
|
| 212 |
+
cfg["memory"]["provider"] = "sibyl"
|
| 213 |
+
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
| 214 |
+
tmp = self.config_path.with_suffix(".yaml.tmp")
|
| 215 |
+
with open(tmp, "w", encoding="utf-8") as f:
|
| 216 |
+
yaml.safe_dump(cfg, f, sort_keys=False, default_flow_style=False)
|
| 217 |
+
os.replace(tmp, self.config_path)
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
# ----------------------------------------------------------------------
|
| 221 |
+
# ClaudeCodeWirer
|
| 222 |
+
# ----------------------------------------------------------------------
|
| 223 |
+
|
| 224 |
+
class ClaudeCodeWirer:
|
| 225 |
+
name = "claude-code"
|
| 226 |
+
display_name = "Claude Code"
|
| 227 |
+
initial = "c"
|
| 228 |
+
|
| 229 |
+
SIBYL_MCP_BLOCK = {"command": "sibyl-memory-mcp"}
|
| 230 |
+
|
| 231 |
+
def __init__(self, *, settings_path: Optional[Union[str, Path]] = None):
|
| 232 |
+
self.settings_path = (
|
| 233 |
+
Path(settings_path).expanduser() if settings_path
|
| 234 |
+
else Path.home() / ".claude" / "settings.json"
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
def is_present(self) -> bool:
|
| 238 |
+
if self.settings_path.exists():
|
| 239 |
+
return True
|
| 240 |
+
if shutil.which("claude"):
|
| 241 |
+
return True
|
| 242 |
+
return False
|
| 243 |
+
|
| 244 |
+
def current_state(self) -> dict:
|
| 245 |
+
settings_exists = self.settings_path.exists()
|
| 246 |
+
mcp_servers: dict = {}
|
| 247 |
+
sibyl_block: Optional[dict] = None
|
| 248 |
+
if settings_exists:
|
| 249 |
+
try:
|
| 250 |
+
cfg = json.loads(self.settings_path.read_text(encoding="utf-8"))
|
| 251 |
+
if isinstance(cfg, dict):
|
| 252 |
+
raw_servers = cfg.get("mcpServers", {})
|
| 253 |
+
if isinstance(raw_servers, dict):
|
| 254 |
+
mcp_servers = raw_servers
|
| 255 |
+
sibyl_block = mcp_servers.get("sibyl-memory")
|
| 256 |
+
except Exception:
|
| 257 |
+
pass
|
| 258 |
+
return {
|
| 259 |
+
"settings_path": str(self.settings_path),
|
| 260 |
+
"settings_exists": settings_exists,
|
| 261 |
+
"mcp_servers_count": len(mcp_servers),
|
| 262 |
+
"sibyl_mcp": sibyl_block,
|
| 263 |
+
"wired_with_sibyl": sibyl_block == self.SIBYL_MCP_BLOCK,
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
def wire(self, *, force: bool = False, dry_run: bool = False,
|
| 267 |
+
prompt_fn: Optional[Callable[..., str]] = None) -> WireOutcome:
|
| 268 |
+
state = self.current_state()
|
| 269 |
+
|
| 270 |
+
if state["wired_with_sibyl"]:
|
| 271 |
+
return WireOutcome(
|
| 272 |
+
self.name, "already",
|
| 273 |
+
f"Claude Code already has SIBYL Memory MCP server in {self.settings_path}",
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
if state["sibyl_mcp"] and not force:
|
| 277 |
+
if prompt_fn is None:
|
| 278 |
+
return WireOutcome(
|
| 279 |
+
self.name, "skipped",
|
| 280 |
+
"Existing sibyl-memory MCP entry differs. Use --force to overwrite.",
|
| 281 |
+
)
|
| 282 |
+
ans = prompt_fn(
|
| 283 |
+
"Claude Code has 'sibyl-memory' MCP entry but pointing elsewhere. Update?",
|
| 284 |
+
default="N",
|
| 285 |
+
)
|
| 286 |
+
if ans != "y":
|
| 287 |
+
return WireOutcome(self.name, "skipped", "MCP entry update declined.")
|
| 288 |
+
|
| 289 |
+
if dry_run:
|
| 290 |
+
verb = "update" if state["sibyl_mcp"] else "add"
|
| 291 |
+
return WireOutcome(
|
| 292 |
+
self.name, "dry-run",
|
| 293 |
+
f"Would {verb} mcpServers.sibyl-memory in {self.settings_path}",
|
| 294 |
+
)
|
| 295 |
+
|
| 296 |
+
backup = self._backup_settings()
|
| 297 |
+
try:
|
| 298 |
+
self._write_settings_with_sibyl()
|
| 299 |
+
except Exception as e:
|
| 300 |
+
return WireOutcome(
|
| 301 |
+
self.name, "error",
|
| 302 |
+
f"settings write failed: {type(e).__name__}: {e}",
|
| 303 |
+
backup_path=backup,
|
| 304 |
+
)
|
| 305 |
+
return WireOutcome(
|
| 306 |
+
self.name, "wired",
|
| 307 |
+
f"Added SIBYL Memory MCP server to {self.settings_path}",
|
| 308 |
+
backup_path=backup,
|
| 309 |
+
)
|
| 310 |
+
|
| 311 |
+
def _backup_settings(self) -> Optional[Path]:
|
| 312 |
+
if not self.settings_path.exists():
|
| 313 |
+
return None
|
| 314 |
+
backup = self.settings_path.with_suffix(".json.bak")
|
| 315 |
+
shutil.copy2(self.settings_path, backup)
|
| 316 |
+
return backup
|
| 317 |
+
|
| 318 |
+
def _write_settings_with_sibyl(self) -> None:
|
| 319 |
+
cfg: dict = {}
|
| 320 |
+
if self.settings_path.exists():
|
| 321 |
+
try:
|
| 322 |
+
loaded = json.loads(self.settings_path.read_text(encoding="utf-8"))
|
| 323 |
+
if isinstance(loaded, dict):
|
| 324 |
+
cfg = loaded
|
| 325 |
+
except Exception:
|
| 326 |
+
cfg = {}
|
| 327 |
+
if not isinstance(cfg.get("mcpServers"), dict):
|
| 328 |
+
cfg["mcpServers"] = {}
|
| 329 |
+
cfg["mcpServers"]["sibyl-memory"] = self.SIBYL_MCP_BLOCK
|
| 330 |
+
self.settings_path.parent.mkdir(parents=True, exist_ok=True)
|
| 331 |
+
tmp = self.settings_path.with_suffix(".json.tmp")
|
| 332 |
+
tmp.write_text(json.dumps(cfg, indent=2) + "\n", encoding="utf-8")
|
| 333 |
+
os.replace(tmp, self.settings_path)
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
# ----------------------------------------------------------------------
|
| 337 |
+
# Registry + dispatch
|
| 338 |
+
# ----------------------------------------------------------------------
|
| 339 |
+
|
| 340 |
+
ALL_WIRERS: dict = {
|
| 341 |
+
"hermes": HermesWirer,
|
| 342 |
+
"claude-code": ClaudeCodeWirer,
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
def _interactive_prompt(question: str, *, default: str = "Y") -> str:
|
| 347 |
+
"""Yes/no prompt. default 'Y' or 'N'. Returns 'y' or 'n'."""
|
| 348 |
+
default_label = "[Y/n]" if default.upper() == "Y" else "[y/N]"
|
| 349 |
+
try:
|
| 350 |
+
ans = input(f"{question} {default_label}: ").strip()
|
| 351 |
+
except EOFError:
|
| 352 |
+
return default.lower()
|
| 353 |
+
if not ans:
|
| 354 |
+
return default.lower()
|
| 355 |
+
return "y" if ans[:1].lower() == "y" else "n"
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
def _accept_defaults_prompt(question: str, *, default: str = "Y") -> str:
|
| 359 |
+
"""Non-interactive prompt. Returns the default. Used with --yes."""
|
| 360 |
+
return default.lower()
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
def _wirer_kwargs(args: argparse.Namespace, name: str) -> dict:
|
| 364 |
+
kw: dict = {}
|
| 365 |
+
if name == "hermes" and getattr(args, "hermes_home", None):
|
| 366 |
+
kw["hermes_home"] = args.hermes_home
|
| 367 |
+
if name == "claude-code" and getattr(args, "claude_settings", None):
|
| 368 |
+
kw["settings_path"] = args.claude_settings
|
| 369 |
+
return kw
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
def cmd_setup(args: argparse.Namespace) -> int:
|
| 373 |
+
"""`sibyl setup` entry point. Auto-detect, then wire."""
|
| 374 |
+
bold, cyan, dim, green, red, yellow = _color_fns()
|
| 375 |
+
|
| 376 |
+
# Resolve target wirers
|
| 377 |
+
target = getattr(args, "target", None)
|
| 378 |
+
if target:
|
| 379 |
+
if target not in ALL_WIRERS:
|
| 380 |
+
print(red(f"Unknown setup target: {target}"))
|
| 381 |
+
print(f"Available: {', '.join(ALL_WIRERS)}")
|
| 382 |
+
return 1
|
| 383 |
+
wirers: dict = {target: ALL_WIRERS[target](**_wirer_kwargs(args, target))}
|
| 384 |
+
skip_present_check = True # explicit target = wire it even if not detected on PATH
|
| 385 |
+
else:
|
| 386 |
+
wirers = {name: cls(**_wirer_kwargs(args, name)) for name, cls in ALL_WIRERS.items()}
|
| 387 |
+
skip_present_check = False
|
| 388 |
+
|
| 389 |
+
print()
|
| 390 |
+
print(bold("Sibyl Memory Plugin setup"))
|
| 391 |
+
print()
|
| 392 |
+
|
| 393 |
+
# Detection
|
| 394 |
+
if skip_present_check:
|
| 395 |
+
detected = wirers
|
| 396 |
+
else:
|
| 397 |
+
detected = {n: w for n, w in wirers.items() if w.is_present()}
|
| 398 |
+
|
| 399 |
+
if not detected:
|
| 400 |
+
print(yellow("No agent frameworks detected on this machine."))
|
| 401 |
+
print()
|
| 402 |
+
print(dim("Looked for:"))
|
| 403 |
+
for name, w in wirers.items():
|
| 404 |
+
st = w.current_state()
|
| 405 |
+
loc = st.get("hermes_home") or st.get("settings_path")
|
| 406 |
+
print(f" {w.display_name}: {loc}")
|
| 407 |
+
print()
|
| 408 |
+
print(dim("To override detection, point setup at a custom path:"))
|
| 409 |
+
print(f" {cyan('sibyl setup --hermes-home /custom/path')}")
|
| 410 |
+
print(f" {cyan('sibyl setup --claude-settings /custom/settings.json')}")
|
| 411 |
+
print()
|
| 412 |
+
return 0
|
| 413 |
+
|
| 414 |
+
# Detection summary
|
| 415 |
+
print(dim("Detected:"))
|
| 416 |
+
for name, w in detected.items():
|
| 417 |
+
st = w.current_state()
|
| 418 |
+
loc = st.get("hermes_home") or st.get("settings_path")
|
| 419 |
+
print(f" {w.display_name} at {loc}")
|
| 420 |
+
print()
|
| 421 |
+
|
| 422 |
+
# Multi-framework picker
|
| 423 |
+
selected = list(detected.keys())
|
| 424 |
+
if len(detected) > 1 and not args.yes:
|
| 425 |
+
choices = ", ".join(f"[{w.initial}]{w.display_name}" for w in detected.values())
|
| 426 |
+
ans = input(
|
| 427 |
+
f"Wire which? {choices}, [a]ll, [n]one (default: all): "
|
| 428 |
+
).strip().lower()
|
| 429 |
+
if ans in ("n", "none"):
|
| 430 |
+
print(dim("Skipping all."))
|
| 431 |
+
print()
|
| 432 |
+
return 0
|
| 433 |
+
elif ans in ("", "a", "all"):
|
| 434 |
+
pass
|
| 435 |
+
else:
|
| 436 |
+
picked = [n for n, w in detected.items() if w.initial == ans[:1]]
|
| 437 |
+
if not picked:
|
| 438 |
+
print(red(f"No match for '{ans}'. Aborting."))
|
| 439 |
+
return 1
|
| 440 |
+
selected = picked
|
| 441 |
+
|
| 442 |
+
# Per-stack execution
|
| 443 |
+
outcomes: list = []
|
| 444 |
+
prompt_fn = _accept_defaults_prompt if args.yes else _interactive_prompt
|
| 445 |
+
|
| 446 |
+
for name in selected:
|
| 447 |
+
wirer = detected[name]
|
| 448 |
+
st = wirer.current_state()
|
| 449 |
+
|
| 450 |
+
# Pre-prompt for fresh adds (interactive only). Already-wired and
|
| 451 |
+
# existing-other-provider are handled inside wire() itself.
|
| 452 |
+
if (
|
| 453 |
+
not args.yes
|
| 454 |
+
and not st.get("wired_with_sibyl")
|
| 455 |
+
and not st.get("memory_provider")
|
| 456 |
+
and not st.get("sibyl_mcp")
|
| 457 |
+
):
|
| 458 |
+
if name == "hermes":
|
| 459 |
+
q = f"Set SIBYL as default memory provider in {wirer.display_name}?"
|
| 460 |
+
else:
|
| 461 |
+
q = f"Add SIBYL Memory as an MCP server in {wirer.display_name}?"
|
| 462 |
+
ans = _interactive_prompt(q, default="Y")
|
| 463 |
+
if ans != "y":
|
| 464 |
+
outcomes.append(WireOutcome(name, "skipped", "Declined by user."))
|
| 465 |
+
continue
|
| 466 |
+
|
| 467 |
+
outcomes.append(
|
| 468 |
+
wirer.wire(force=args.force, dry_run=args.dry_run, prompt_fn=prompt_fn)
|
| 469 |
+
)
|
| 470 |
+
|
| 471 |
+
# Report
|
| 472 |
+
print()
|
| 473 |
+
any_wired = False
|
| 474 |
+
for o in outcomes:
|
| 475 |
+
marker = {
|
| 476 |
+
"wired": green("β"),
|
| 477 |
+
"already": green("Β·"),
|
| 478 |
+
"skipped": yellow("Β·"),
|
| 479 |
+
"dry-run": cyan("β"),
|
| 480 |
+
"error": red("β"),
|
| 481 |
+
}.get(o.status, "?")
|
| 482 |
+
print(f" {marker} {o.name}: {o.message}")
|
| 483 |
+
if o.backup_path:
|
| 484 |
+
print(f" {dim('backup at')} {o.backup_path}")
|
| 485 |
+
if o.status == "wired":
|
| 486 |
+
any_wired = True
|
| 487 |
+
print()
|
| 488 |
+
|
| 489 |
+
if any_wired:
|
| 490 |
+
print(green("Restart your agent(s) to load the new memory provider."))
|
| 491 |
+
print()
|
| 492 |
+
|
| 493 |
+
return 0 if all(o.status != "error" for o in outcomes) else 2
|
sibyl-memory-cli/tests/test_setup.py
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the v0.1.4 `sibyl setup` command.
|
| 2 |
+
|
| 3 |
+
Covers:
|
| 4 |
+
- Hermes wirer: fresh, existing-no-memory, existing-sibyl, existing-other-provider,
|
| 5 |
+
force-overwrite, dry-run, plugin-install side-effect.
|
| 6 |
+
- Claude Code wirer: fresh-no-file, fresh-with-other-mcps, existing-sibyl,
|
| 7 |
+
existing-sibyl-mismatch, force-overwrite, dry-run.
|
| 8 |
+
- Detection: is_present logic for both wirers.
|
| 9 |
+
- Outcomes: WireOutcome status field correctness.
|
| 10 |
+
- Atomic writes + backup files land at the expected paths.
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import json
|
| 15 |
+
import os
|
| 16 |
+
import sys
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
from unittest.mock import patch
|
| 19 |
+
|
| 20 |
+
import pytest
|
| 21 |
+
|
| 22 |
+
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
| 23 |
+
|
| 24 |
+
from sibyl_memory_cli.setup import ( # noqa: E402
|
| 25 |
+
ALL_WIRERS,
|
| 26 |
+
ClaudeCodeWirer,
|
| 27 |
+
HermesWirer,
|
| 28 |
+
WireOutcome,
|
| 29 |
+
_accept_defaults_prompt,
|
| 30 |
+
_interactive_prompt,
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
# ----------------------------------------------------------------------
|
| 35 |
+
# Helpers
|
| 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)
|
| 43 |
+
(plugin_dir / "__init__.py").write_text("# stub plugin\n")
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
# ----------------------------------------------------------------------
|
| 47 |
+
# WireOutcome basics
|
| 48 |
+
# ----------------------------------------------------------------------
|
| 49 |
+
|
| 50 |
+
def test_outcome_dataclass_basic():
|
| 51 |
+
o = WireOutcome("hermes", "wired", "test")
|
| 52 |
+
assert o.name == "hermes" and o.status == "wired" and o.backup_path is None
|
| 53 |
+
o2 = WireOutcome("claude-code", "skipped", "no", backup_path=Path("/tmp/x.bak"))
|
| 54 |
+
assert o2.backup_path == Path("/tmp/x.bak")
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
# ----------------------------------------------------------------------
|
| 58 |
+
# Prompt helpers
|
| 59 |
+
# ----------------------------------------------------------------------
|
| 60 |
+
|
| 61 |
+
def test_interactive_prompt_default_y_empty_input(monkeypatch):
|
| 62 |
+
monkeypatch.setattr("builtins.input", lambda _: "")
|
| 63 |
+
assert _interactive_prompt("Q?", default="Y") == "y"
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def test_interactive_prompt_default_n_empty_input(monkeypatch):
|
| 67 |
+
monkeypatch.setattr("builtins.input", lambda _: "")
|
| 68 |
+
assert _interactive_prompt("Q?", default="N") == "n"
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def test_interactive_prompt_explicit_y(monkeypatch):
|
| 72 |
+
monkeypatch.setattr("builtins.input", lambda _: "y")
|
| 73 |
+
assert _interactive_prompt("Q?", default="N") == "y"
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def test_interactive_prompt_explicit_n(monkeypatch):
|
| 77 |
+
monkeypatch.setattr("builtins.input", lambda _: "no")
|
| 78 |
+
assert _interactive_prompt("Q?", default="Y") == "n"
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def test_accept_defaults_prompt_returns_default():
|
| 82 |
+
assert _accept_defaults_prompt("Q?", default="Y") == "y"
|
| 83 |
+
assert _accept_defaults_prompt("Q?", default="N") == "n"
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
# ----------------------------------------------------------------------
|
| 87 |
+
# HermesWirer
|
| 88 |
+
# ----------------------------------------------------------------------
|
| 89 |
+
|
| 90 |
+
def test_hermes_wirer_auto_home_env(monkeypatch, tmp_path):
|
| 91 |
+
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "custom-hermes"))
|
| 92 |
+
w = HermesWirer()
|
| 93 |
+
assert w.hermes_home == tmp_path / "custom-hermes"
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def test_hermes_wirer_auto_home_default(monkeypatch):
|
| 97 |
+
monkeypatch.delenv("HERMES_HOME", raising=False)
|
| 98 |
+
w = HermesWirer()
|
| 99 |
+
assert w.hermes_home == Path.home() / ".hermes"
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def test_hermes_is_present_false_when_no_dir_no_bin(monkeypatch, tmp_path):
|
| 103 |
+
monkeypatch.delenv("HERMES_HOME", raising=False)
|
| 104 |
+
w = HermesWirer(hermes_home=tmp_path / "nope")
|
| 105 |
+
with patch("sibyl_memory_cli.setup.shutil.which", return_value=None):
|
| 106 |
+
assert not w.is_present()
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def test_hermes_is_present_true_when_dir_exists(tmp_path):
|
| 110 |
+
(tmp_path / "hermes-home").mkdir()
|
| 111 |
+
w = HermesWirer(hermes_home=tmp_path / "hermes-home")
|
| 112 |
+
assert w.is_present()
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def test_hermes_state_fresh(tmp_path):
|
| 116 |
+
w = HermesWirer(hermes_home=tmp_path / "hermes-home")
|
| 117 |
+
st = w.current_state()
|
| 118 |
+
assert st["config_exists"] is False
|
| 119 |
+
assert st["plugin_installed"] is False
|
| 120 |
+
assert st["memory_provider"] is None
|
| 121 |
+
assert st["wired_with_sibyl"] is False
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def test_hermes_state_existing_sibyl(tmp_path):
|
| 125 |
+
home = tmp_path / "hermes-home"
|
| 126 |
+
home.mkdir()
|
| 127 |
+
(home / "config.yaml").write_text("memory:\n provider: sibyl\n")
|
| 128 |
+
w = HermesWirer(hermes_home=home)
|
| 129 |
+
st = w.current_state()
|
| 130 |
+
assert st["memory_provider"] == "sibyl"
|
| 131 |
+
assert st["wired_with_sibyl"] is True
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def test_hermes_state_existing_other_provider(tmp_path):
|
| 135 |
+
home = tmp_path / "hermes-home"
|
| 136 |
+
home.mkdir()
|
| 137 |
+
(home / "config.yaml").write_text("memory:\n provider: mem0\nother: thing\n")
|
| 138 |
+
w = HermesWirer(hermes_home=home)
|
| 139 |
+
st = w.current_state()
|
| 140 |
+
assert st["memory_provider"] == "mem0"
|
| 141 |
+
assert st["wired_with_sibyl"] is False
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def test_hermes_wire_fresh_creates_config_and_installs_plugin(tmp_path, monkeypatch):
|
| 145 |
+
home = tmp_path / "hermes-home"
|
| 146 |
+
home.mkdir()
|
| 147 |
+
w = HermesWirer(hermes_home=home)
|
| 148 |
+
# Stub the install_plugin import via the wirer's _install_plugin override
|
| 149 |
+
monkeypatch.setattr(
|
| 150 |
+
HermesWirer, "_install_plugin",
|
| 151 |
+
lambda self: _stub_install_plugin(str(self.hermes_home)),
|
| 152 |
+
)
|
| 153 |
+
outcome = w.wire()
|
| 154 |
+
assert outcome.status == "wired"
|
| 155 |
+
# Config now has memory.provider: sibyl
|
| 156 |
+
import yaml
|
| 157 |
+
cfg = yaml.safe_load((home / "config.yaml").read_text())
|
| 158 |
+
assert cfg == {"memory": {"provider": "sibyl"}}
|
| 159 |
+
# Plugin "installed" (stub created the file)
|
| 160 |
+
assert (home / "plugins" / "sibyl" / "__init__.py").exists()
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def test_hermes_wire_existing_sibyl_is_noop(tmp_path, monkeypatch):
|
| 164 |
+
home = tmp_path / "hermes-home"
|
| 165 |
+
home.mkdir()
|
| 166 |
+
(home / "config.yaml").write_text("memory:\n provider: sibyl\n")
|
| 167 |
+
# Also pre-install the plugin so the noop path is true end-to-end
|
| 168 |
+
(home / "plugins" / "sibyl").mkdir(parents=True)
|
| 169 |
+
(home / "plugins" / "sibyl" / "__init__.py").write_text("# stub\n")
|
| 170 |
+
w = HermesWirer(hermes_home=home)
|
| 171 |
+
outcome = w.wire()
|
| 172 |
+
assert outcome.status == "already"
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def test_hermes_wire_existing_other_provider_refused_without_force(tmp_path, monkeypatch):
|
| 176 |
+
home = tmp_path / "hermes-home"
|
| 177 |
+
home.mkdir()
|
| 178 |
+
(home / "config.yaml").write_text("memory:\n provider: mem0\n")
|
| 179 |
+
monkeypatch.setattr(
|
| 180 |
+
HermesWirer, "_install_plugin",
|
| 181 |
+
lambda self: _stub_install_plugin(str(self.hermes_home)),
|
| 182 |
+
)
|
| 183 |
+
w = HermesWirer(hermes_home=home)
|
| 184 |
+
# No prompt_fn means non-interactive refusal
|
| 185 |
+
outcome = w.wire()
|
| 186 |
+
assert outcome.status == "skipped"
|
| 187 |
+
# Config UNCHANGED
|
| 188 |
+
assert "mem0" in (home / "config.yaml").read_text()
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def test_hermes_wire_existing_other_provider_with_force(tmp_path, monkeypatch):
|
| 192 |
+
home = tmp_path / "hermes-home"
|
| 193 |
+
home.mkdir()
|
| 194 |
+
(home / "config.yaml").write_text("memory:\n provider: mem0\n")
|
| 195 |
+
monkeypatch.setattr(
|
| 196 |
+
HermesWirer, "_install_plugin",
|
| 197 |
+
lambda self: _stub_install_plugin(str(self.hermes_home)),
|
| 198 |
+
)
|
| 199 |
+
w = HermesWirer(hermes_home=home)
|
| 200 |
+
outcome = w.wire(force=True)
|
| 201 |
+
assert outcome.status == "wired"
|
| 202 |
+
import yaml
|
| 203 |
+
cfg = yaml.safe_load((home / "config.yaml").read_text())
|
| 204 |
+
assert cfg["memory"]["provider"] == "sibyl"
|
| 205 |
+
# Backup landed
|
| 206 |
+
assert (home / "config.yaml.bak").exists()
|
| 207 |
+
assert "mem0" in (home / "config.yaml.bak").read_text()
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def test_hermes_wire_existing_other_provider_prompt_y_accepts(tmp_path, monkeypatch):
|
| 211 |
+
home = tmp_path / "hermes-home"
|
| 212 |
+
home.mkdir()
|
| 213 |
+
(home / "config.yaml").write_text("memory:\n provider: mem0\n")
|
| 214 |
+
monkeypatch.setattr(
|
| 215 |
+
HermesWirer, "_install_plugin",
|
| 216 |
+
lambda self: _stub_install_plugin(str(self.hermes_home)),
|
| 217 |
+
)
|
| 218 |
+
w = HermesWirer(hermes_home=home)
|
| 219 |
+
outcome = w.wire(prompt_fn=lambda q, *, default: "y")
|
| 220 |
+
assert outcome.status == "wired"
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def test_hermes_wire_dry_run_no_writes(tmp_path):
|
| 224 |
+
home = tmp_path / "hermes-home"
|
| 225 |
+
home.mkdir()
|
| 226 |
+
w = HermesWirer(hermes_home=home)
|
| 227 |
+
outcome = w.wire(dry_run=True)
|
| 228 |
+
assert outcome.status == "dry-run"
|
| 229 |
+
assert not (home / "config.yaml").exists()
|
| 230 |
+
assert not (home / "plugins" / "sibyl" / "__init__.py").exists()
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def test_hermes_wire_preserves_other_top_level_keys(tmp_path, monkeypatch):
|
| 234 |
+
home = tmp_path / "hermes-home"
|
| 235 |
+
home.mkdir()
|
| 236 |
+
(home / "config.yaml").write_text(
|
| 237 |
+
"model:\n name: gpt-4\ntools:\n - search\n - file\n"
|
| 238 |
+
)
|
| 239 |
+
monkeypatch.setattr(
|
| 240 |
+
HermesWirer, "_install_plugin",
|
| 241 |
+
lambda self: _stub_install_plugin(str(self.hermes_home)),
|
| 242 |
+
)
|
| 243 |
+
w = HermesWirer(hermes_home=home)
|
| 244 |
+
w.wire()
|
| 245 |
+
import yaml
|
| 246 |
+
cfg = yaml.safe_load((home / "config.yaml").read_text())
|
| 247 |
+
assert cfg["model"]["name"] == "gpt-4"
|
| 248 |
+
assert cfg["tools"] == ["search", "file"]
|
| 249 |
+
assert cfg["memory"]["provider"] == "sibyl"
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
# ----------------------------------------------------------------------
|
| 253 |
+
# ClaudeCodeWirer
|
| 254 |
+
# ----------------------------------------------------------------------
|
| 255 |
+
|
| 256 |
+
def test_claude_is_present_false_when_no_settings_no_bin(monkeypatch, tmp_path):
|
| 257 |
+
w = ClaudeCodeWirer(settings_path=tmp_path / "no.json")
|
| 258 |
+
with patch("sibyl_memory_cli.setup.shutil.which", return_value=None):
|
| 259 |
+
assert not w.is_present()
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def test_claude_is_present_true_when_settings_exists(tmp_path):
|
| 263 |
+
p = tmp_path / "settings.json"
|
| 264 |
+
p.write_text("{}")
|
| 265 |
+
w = ClaudeCodeWirer(settings_path=p)
|
| 266 |
+
assert w.is_present()
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def test_claude_state_fresh(tmp_path):
|
| 270 |
+
w = ClaudeCodeWirer(settings_path=tmp_path / "settings.json")
|
| 271 |
+
st = w.current_state()
|
| 272 |
+
assert st["settings_exists"] is False
|
| 273 |
+
assert st["mcp_servers_count"] == 0
|
| 274 |
+
assert st["sibyl_mcp"] is None
|
| 275 |
+
assert st["wired_with_sibyl"] is False
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
def test_claude_state_existing_sibyl(tmp_path):
|
| 279 |
+
p = tmp_path / "settings.json"
|
| 280 |
+
p.write_text(json.dumps({"mcpServers": {"sibyl-memory": {"command": "sibyl-memory-mcp"}}}))
|
| 281 |
+
w = ClaudeCodeWirer(settings_path=p)
|
| 282 |
+
st = w.current_state()
|
| 283 |
+
assert st["wired_with_sibyl"] is True
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def test_claude_state_existing_other_mcps_no_sibyl(tmp_path):
|
| 287 |
+
p = tmp_path / "settings.json"
|
| 288 |
+
p.write_text(json.dumps({
|
| 289 |
+
"mcpServers": {"github": {"command": "gh-mcp"}, "filesystem": {"command": "fs-mcp"}}
|
| 290 |
+
}))
|
| 291 |
+
w = ClaudeCodeWirer(settings_path=p)
|
| 292 |
+
st = w.current_state()
|
| 293 |
+
assert st["mcp_servers_count"] == 2
|
| 294 |
+
assert st["sibyl_mcp"] is None
|
| 295 |
+
assert st["wired_with_sibyl"] is False
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
def test_claude_wire_fresh_no_settings_creates(tmp_path):
|
| 299 |
+
p = tmp_path / "subdir" / "settings.json" # parent doesn't exist yet
|
| 300 |
+
w = ClaudeCodeWirer(settings_path=p)
|
| 301 |
+
outcome = w.wire()
|
| 302 |
+
assert outcome.status == "wired"
|
| 303 |
+
cfg = json.loads(p.read_text())
|
| 304 |
+
assert cfg["mcpServers"]["sibyl-memory"] == {"command": "sibyl-memory-mcp"}
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
def test_claude_wire_fresh_preserves_other_mcps(tmp_path):
|
| 308 |
+
p = tmp_path / "settings.json"
|
| 309 |
+
p.write_text(json.dumps({
|
| 310 |
+
"mcpServers": {"github": {"command": "gh-mcp"}},
|
| 311 |
+
"theme": "dark",
|
| 312 |
+
}))
|
| 313 |
+
w = ClaudeCodeWirer(settings_path=p)
|
| 314 |
+
outcome = w.wire()
|
| 315 |
+
assert outcome.status == "wired"
|
| 316 |
+
cfg = json.loads(p.read_text())
|
| 317 |
+
assert cfg["mcpServers"]["github"] == {"command": "gh-mcp"}
|
| 318 |
+
assert cfg["mcpServers"]["sibyl-memory"] == {"command": "sibyl-memory-mcp"}
|
| 319 |
+
assert cfg["theme"] == "dark"
|
| 320 |
+
# backup landed
|
| 321 |
+
assert (tmp_path / "settings.json.bak").exists()
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
def test_claude_wire_existing_sibyl_is_noop(tmp_path):
|
| 325 |
+
p = tmp_path / "settings.json"
|
| 326 |
+
p.write_text(json.dumps({"mcpServers": {"sibyl-memory": {"command": "sibyl-memory-mcp"}}}))
|
| 327 |
+
w = ClaudeCodeWirer(settings_path=p)
|
| 328 |
+
outcome = w.wire()
|
| 329 |
+
assert outcome.status == "already"
|
| 330 |
+
# No backup written for no-op
|
| 331 |
+
assert not (tmp_path / "settings.json.bak").exists()
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
def test_claude_wire_mismatched_sibyl_refused_without_force(tmp_path):
|
| 335 |
+
p = tmp_path / "settings.json"
|
| 336 |
+
# sibyl-memory key exists but command is different
|
| 337 |
+
p.write_text(json.dumps({"mcpServers": {"sibyl-memory": {"command": "/some/other/path"}}}))
|
| 338 |
+
w = ClaudeCodeWirer(settings_path=p)
|
| 339 |
+
outcome = w.wire()
|
| 340 |
+
assert outcome.status == "skipped"
|
| 341 |
+
# File UNCHANGED
|
| 342 |
+
assert "/some/other/path" in p.read_text()
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
def test_claude_wire_mismatched_sibyl_with_force(tmp_path):
|
| 346 |
+
p = tmp_path / "settings.json"
|
| 347 |
+
p.write_text(json.dumps({"mcpServers": {"sibyl-memory": {"command": "/some/other/path"}}}))
|
| 348 |
+
w = ClaudeCodeWirer(settings_path=p)
|
| 349 |
+
outcome = w.wire(force=True)
|
| 350 |
+
assert outcome.status == "wired"
|
| 351 |
+
cfg = json.loads(p.read_text())
|
| 352 |
+
assert cfg["mcpServers"]["sibyl-memory"]["command"] == "sibyl-memory-mcp"
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
def test_claude_wire_dry_run_no_writes(tmp_path):
|
| 356 |
+
p = tmp_path / "settings.json"
|
| 357 |
+
w = ClaudeCodeWirer(settings_path=p)
|
| 358 |
+
outcome = w.wire(dry_run=True)
|
| 359 |
+
assert outcome.status == "dry-run"
|
| 360 |
+
assert not p.exists()
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
def test_claude_wire_mismatched_dry_run(tmp_path):
|
| 364 |
+
p = tmp_path / "settings.json"
|
| 365 |
+
p.write_text(json.dumps({"mcpServers": {"sibyl-memory": {"command": "/old"}}}))
|
| 366 |
+
w = ClaudeCodeWirer(settings_path=p, )
|
| 367 |
+
outcome = w.wire(dry_run=True, force=True)
|
| 368 |
+
assert outcome.status == "dry-run"
|
| 369 |
+
assert "update" in outcome.message
|
| 370 |
+
# Still no write
|
| 371 |
+
assert "/old" in p.read_text()
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
# ----------------------------------------------------------------------
|
| 375 |
+
# Registry
|
| 376 |
+
# ----------------------------------------------------------------------
|
| 377 |
+
|
| 378 |
+
def test_registry_has_both_wirers():
|
| 379 |
+
assert set(ALL_WIRERS) == {"hermes", "claude-code"}
|
| 380 |
+
assert ALL_WIRERS["hermes"] is HermesWirer
|
| 381 |
+
assert ALL_WIRERS["claude-code"] is ClaudeCodeWirer
|
sibyl-memory-client/CHANGELOG.md
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Changelog
|
| 2 |
+
|
| 3 |
+
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] β 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
|
| 13 |
+
protocol. Server-side schema v6 populated `bearer_tokens` at bind time,
|
| 14 |
+
so legacy `session_token`-as-bearer credentials still resolve.
|
| 15 |
+
|
| 16 |
+
### Changed
|
| 17 |
+
|
| 18 |
+
- `_capcheck.py:_default_check_write_fn` sends
|
| 19 |
+
`Authorization: Bearer <token>` header on every check-write call.
|
| 20 |
+
Token source priority: `payload["bearer_token"]` (server-issued in
|
| 21 |
+
credentials.json schema_version >= 3) β `payload["session_token"]`
|
| 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
|
| 29 |
+
blocker that broke `sibyl-memory-mcp` on PyPI plus four secondary findings.
|
| 30 |
+
This release lands the engine-side fixes. Companion releases:
|
| 31 |
+
`sibyl-memory-mcp` v0.1.2, `sibyl-memory-hermes` v0.3.2, `sibyl-memory-cli`
|
| 32 |
+
v0.1.3.
|
| 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
|
| 40 |
+
package; the `.exceptions` submodule path (which `sibyl-memory-mcp`
|
| 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
|
| 60 |
+
partial schema state on very old DBs);
|
| 61 |
+
- FTS5 syntax error (`"fts5"`, `"malformed match"`, `"syntax error near"`,
|
| 62 |
+
`"no such column"`) raises `ValidationError` with the original cause
|
| 63 |
+
chained;
|
| 64 |
+
- anything else raises `StorageError` with the original cause chained.
|
| 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
|
| 74 |
+
|
| 75 |
+
- The 2 MB free-tier cap (KAPPA's product question) is NOT changed in this
|
| 76 |
+
release. Operator decision to be made separately on whether to raise
|
| 77 |
+
the cap or document the intent more explicitly.
|
| 78 |
+
- Existing 53/53 client tests pass unchanged. New tests covering the
|
| 79 |
+
KAPPA-attributed fixes added in `tests/test_smoke.py`.
|
| 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
|
| 87 |
+
fixes. Companion releases: `sibyl-memory-hermes` v0.3.1, `sibyl-memory-cli`
|
| 88 |
+
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
|
| 96 |
+
"FTS5 across all tiers" is now actually true.
|
| 97 |
+
- FTS5 query sanitization: every user-supplied query is wrapped as a single
|
| 98 |
+
quoted FTS5 phrase before MATCH. Column-filter syntax (`name:foo`,
|
| 99 |
+
`rowid:*`, etc.) can no longer escape into the FTS5 parser. Empty queries
|
| 100 |
+
short-circuit to empty result (no SQL error leak).
|
| 101 |
+
- `_sanitize_fts5_query(raw, *, prefix=False)` helper exposed for callers
|
| 102 |
+
building their own FTS5 queries.
|
| 103 |
+
|
| 104 |
+
### Changed (schema v3 migration)
|
| 105 |
+
|
| 106 |
+
- **Schema bumped to v3.** All four searchable tiers (entities, state,
|
| 107 |
+
reference, journal) now have FTS5 indexes:
|
| 108 |
+
- entities_fts β external-content (was standalone with body duplication)
|
| 109 |
+
- state_documents_fts β NEW, external-content
|
| 110 |
+
- reference_documents_fts β external-content (was standalone, never
|
| 111 |
+
exposed in the public SDK)
|
| 112 |
+
- journal_events_fts β NEW, contentless, payload = evaluated || acted ||
|
| 113 |
+
forward || extra concatenated
|
| 114 |
+
- v2 β v3 migration runs automatically on first open. Detects v2's
|
| 115 |
+
standalone entities_fts shape, drops it and the old reference_documents_fts,
|
| 116 |
+
recreates in external-content form, and rebuilds the FTS5 indexes from
|
| 117 |
+
the existing base-table data. No application data lost. ~50ms per 10k
|
| 118 |
+
entities on first open after upgrade; idempotent thereafter.
|
| 119 |
+
- FTS5 disk footprint reduced ~50% on body-dominated tenants (v2 stored
|
| 120 |
+
the entity body twice; v3 stores it once in the base table).
|
| 121 |
+
- FTS5 update trigger pattern fixed: was O(N) DELETE-by-UNINDEXED-column;
|
| 122 |
+
now O(log N) external-content delete-by-rowid.
|
| 123 |
+
- `search_entities()` updated to join via rowid (the external-content
|
| 124 |
+
primary key) instead of entity_id.
|
| 125 |
+
- `search_entities()` now returns empty list on malformed FTS5 queries
|
| 126 |
+
rather than raising. Previously `client.search_entities('"')` would
|
| 127 |
+
surface a `sqlite3.OperationalError` wrapped as `StorageError` with the
|
| 128 |
+
full db_path interpolated into the message.
|
| 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
|
| 156 |
+
`__init__.py` saying "0.3.1").
|
| 157 |
+
- HTTP User-Agent in `_default_check_write_fn` now built from
|
| 158 |
+
`__version__` instead of hardcoded `"sibyl-memory-client/0.3.0"`. Server
|
| 159 |
+
telemetry will accurately reflect the installed version.
|
| 160 |
+
- `from e` chaining added to `_default_check_write_fn`'s `HTTPError` and
|
| 161 |
+
`URLError`/`TimeoutError`/`OSError` handlers so the original cause is
|
| 162 |
+
preserved through `TierVerificationError`.
|
| 163 |
+
|
| 164 |
+
### Hygiene
|
| 165 |
+
|
| 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
|
| 173 |
+
2026-05-16 audit pass (full report: `memory/research/` + email
|
| 174 |
+
msg_id 19e33139dfc3e4d4).
|
| 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
|
| 182 |
+
method now reads the entity body first to size the proposed insert
|
| 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,
|
| 203 |
+
server_expires_at)`, which prevents the multi-grace-period attack
|
| 204 |
+
where a user blackholes the network to keep using their cached paid
|
| 205 |
+
tier past actual subscription expiry. Authoritative end-of-validity
|
| 206 |
+
comes from the server's record, not from a refresh-able local timer.
|
| 207 |
+
`cache_token` stores the credentials.signature as a defense-in-depth
|
| 208 |
+
link between cache and credentials identity (sent on subsequent
|
| 209 |
+
cap-checks for tamper telemetry).
|
| 210 |
+
- **TierCache.load/store round-trip the new fields**. Backwards
|
| 211 |
+
compatible with v0.3.1 cache files (missing fields default to None).
|
| 212 |
+
|
| 213 |
+
### Schema
|
| 214 |
+
|
| 215 |
+
- TierCache file schema bumped (implicitly v2). v1 caches load fine
|
| 216 |
+
with `server_expires_at=None` and `cache_token=None`; next successful
|
| 217 |
+
`/check-write` upgrades them.
|
| 218 |
+
|
| 219 |
+
### Tests
|
| 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 |
+
|
| 227 |
+
- `sibyl-memory-hermes` v0.2.2 ships in lockstep (narrows `recall()`
|
| 228 |
+
exception handling to `NotFoundError` only, T2-2 fix). Earlier
|
| 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 |
+
|
| 236 |
+
### Added
|
| 237 |
+
|
| 238 |
+
- `MemoryClient.__init__` and `MemoryClient.local()` accept two new
|
| 239 |
+
optional kwargs: `credentials_claim` (dict of the canonical signed
|
| 240 |
+
fields) and `credentials_signature` (hex HMAC). Both default to None
|
| 241 |
+
for backwards compatibility with unsigned v0.3.0 credentials.
|
| 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.
|
| 255 |
+
|
| 256 |
+
### Tests
|
| 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
|
| 264 |
+
circumvent this" β Level 1 (hard write cap) + Level 2 (signed
|
| 265 |
+
credentials.json, deferred) + server-authoritative tier check at the
|
| 266 |
+
boundary. Locked in: 7-day grace cache, hard cap on by default.
|
| 267 |
+
|
| 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
|
| 287 |
+
`GRACE_PERIOD_SECONDS = 7 * 24 * 60 * 60`.
|
| 288 |
+
|
| 289 |
+
- **`MemoryClient` cap wiring** (additive, non-breaking):
|
| 290 |
+
- `__init__` and `local()` accept `account_id`, `session_token`,
|
| 291 |
+
`tier`, and an optional `cap_gate` override.
|
| 292 |
+
- Every write path (`set_entity`, `write_event`, `set_state`,
|
| 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 |
+
|
| 300 |
+
- 13 new tests in `tests/test_capcheck.py` covering: under-cap (no
|
| 301 |
+
server call), at-cap server says no, server upgrades a stale-cached
|
| 302 |
+
user, paid-cache short-circuits server, stale paid cache triggers
|
| 303 |
+
refresh, offline-at-cap with grace cache passes, offline-at-cap
|
| 304 |
+
with no cache raises, pre-activation under/at cap, e2e MemoryClient
|
| 305 |
+
free/paid, cache file mode is 0600, `invalidate_cache()` works.
|
| 306 |
+
Full suite 53/53 green.
|
| 307 |
+
|
| 308 |
+
### Notes for downstream
|
| 309 |
+
|
| 310 |
+
- `sibyl-memory-hermes` v0.2.0 plumbs `account_id` and `session_token`
|
| 311 |
+
through to the client. Earlier hermes versions still work but
|
| 312 |
+
pre-activation users hit the strict local 2 MB cap.
|
| 313 |
+
- The Level 2 HMAC-signed `credentials.json` design is in
|
| 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
|
| 321 |
+
memory learns and creates skills from things in the session just as you
|
| 322 |
+
do. could we also do memory linter?"
|
| 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 |
+
|
| 370 |
+
### Notes for CLI integration (sibyl-labs-cli, next)
|
| 371 |
+
|
| 372 |
+
The CLI package will expose:
|
| 373 |
+
- `sibyl learn` β runs `client.learn()`.
|
| 374 |
+
- `sibyl learn review` β interactive walk of `client.list_skill_proposals()` with y/n/edit prompts.
|
| 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 |
+
|
| 382 |
+
- SQLite + FTS5 port of the canonical `sibyl_memory.*` Postgres schema (10 base tables + 2 FTS5 virtuals + version table).
|
| 383 |
+
- `MemoryClient` public API with polymorphic constructor: `MemoryClient.local(path)`.
|
| 384 |
+
- Five-tier model: entities (WARM) / state_documents (HOT) / journal_events (COLD) / reference_documents (REFERENCE) / archived_entities (ARCHIVE) / flagged_actors (FLAGGED).
|
| 385 |
+
- Multi-tenant isolation via `tenant_id` column.
|
| 386 |
+
- `Storage` low-level wrapper with per-instance thread-local connection cache, WAL mode, foreign_keys=ON, busy_timeout=5000ms.
|
| 387 |
+
- Typed exception hierarchy (`SibylMemoryError` + subclasses).
|
| 388 |
+
- 10 smoke tests, all green.
|
| 389 |
+
- Zero runtime dependencies, MIT, Python 3.10+.
|
sibyl-memory-client/README.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# sibyl-memory-client
|
| 2 |
+
|
| 3 |
+
**Local-first agentic memory SDK. The foundation of the Sibyl Memory Plugin family.**
|
| 4 |
+
|
| 5 |
+
A small Python library that gives any AI agent durable memory across sessions, stored in a SQLite database on the user's own computer. No round-trip to anyone's cloud. Organized by what kind of thing it is, not one fuzzy similarity bucket.
|
| 6 |
+
|
| 7 |
+
```bash
|
| 8 |
+
pip install sibyl-memory-client
|
| 9 |
+
```
|
| 10 |
+
|
| 11 |
+
## Quickstart
|
| 12 |
+
|
| 13 |
+
```python
|
| 14 |
+
from sibyl_memory_client import MemoryClient
|
| 15 |
+
|
| 16 |
+
memory = MemoryClient.local("~/.sibyl-memory/memory.db")
|
| 17 |
+
|
| 18 |
+
# Remember a fact (entity)
|
| 19 |
+
memory.set_entity("project", "atlas", {"status": "active", "owner": "jane"})
|
| 20 |
+
|
| 21 |
+
# Recall it
|
| 22 |
+
print(memory.get_entity("project", "atlas"))
|
| 23 |
+
|
| 24 |
+
# Record what happened (journal)
|
| 25 |
+
memory.write_event(acted=["deployed atlas v1.2 to staging"])
|
| 26 |
+
|
| 27 |
+
# Search across everything
|
| 28 |
+
results = memory.search_entities("atlas")
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
## Why this exists
|
| 32 |
+
|
| 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
|
| 40 |
+
|
| 41 |
+
| Intent | Tier | API |
|
| 42 |
+
|---|---|---|
|
| 43 |
+
| What you're working on right now | HOT state | `set_state(key, body)` / `get_state(key)` |
|
| 44 |
+
| Things the agent knows about | WARM entities | `set_entity(kind, name, body)` / `get_entity` |
|
| 45 |
+
| What happened, in time order | COLD journal | `write_event(...)` / `read_events(...)` |
|
| 46 |
+
| Documents you look up by name | REFERENCE | `set_reference(key, body)` / `get_reference` |
|
| 47 |
+
| Frozen things, kept but out of the way | ARCHIVE | `archive_entity(kind, name)` |
|
| 48 |
+
| Search across everything | FTS5 | `search_entities(query)` |
|
| 49 |
+
|
| 50 |
+
## What's in v0.2.x
|
| 51 |
+
|
| 52 |
+
- The full five-tier memory model and the API surface above.
|
| 53 |
+
- Multi-tenant isolation: one machine can hold separate memory for separate identities.
|
| 54 |
+
- Self-learning module (paid-tier): the agent watches your patterns and proposes reusable skills.
|
| 55 |
+
- Memory linter (paid-tier): a health check on the local database.
|
| 56 |
+
- Tier gating: free-tier callers get clear errors pointing at the upgrade page; paid-tier callers get full access.
|
| 57 |
+
|
| 58 |
+
## Tier model
|
| 59 |
+
|
| 60 |
+
Free tier is generous on purpose. You can build real things with it. Paid plans add self-learning, the linter, and remove the 2 MB local cap. Full plan comparison at [docs.sibyllabs.org/memory/tiers](https://docs.sibyllabs.org/memory/tiers).
|
| 61 |
+
|
| 62 |
+
## Documentation
|
| 63 |
+
|
| 64 |
+
Full docs: [docs.sibyllabs.org/memory/](https://docs.sibyllabs.org/memory/).
|
| 65 |
+
Install guide: [docs.sibyllabs.org/memory/install](https://docs.sibyllabs.org/memory/install).
|
| 66 |
+
|
| 67 |
+
## License
|
| 68 |
+
|
| 69 |
+
MIT. Published on PyPI at [pypi.org/project/sibyl-memory-client](https://pypi.org/project/sibyl-memory-client/).
|
sibyl-memory-client/pyproject.toml
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["setuptools>=61.0", "wheel"]
|
| 3 |
+
build-backend = "setuptools.build_meta"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "sibyl-memory-client"
|
| 7 |
+
version = "0.4.1"
|
| 8 |
+
description = "Local-first agentic memory SDK. SQLite-backed five-tier hierarchical schema, FTS5 search, multi-tenant, with self-learning skill detection and local memory linter. Foundation of the Sibyl Memory Plugin family."
|
| 9 |
+
authors = [{ name = "SIBYL, Sibyl Labs LLC", email = "sibyl@sibyllabs.org" }]
|
| 10 |
+
license = { text = "MIT" }
|
| 11 |
+
readme = "README.md"
|
| 12 |
+
requires-python = ">=3.10"
|
| 13 |
+
keywords = ["sibyl", "memory", "agent", "sqlite", "local-first", "hermes"]
|
| 14 |
+
classifiers = [
|
| 15 |
+
"Development Status :: 3 - Alpha",
|
| 16 |
+
"Intended Audience :: Developers",
|
| 17 |
+
"License :: OSI Approved :: MIT License",
|
| 18 |
+
"Programming Language :: Python :: 3",
|
| 19 |
+
"Programming Language :: Python :: 3.10",
|
| 20 |
+
"Programming Language :: Python :: 3.11",
|
| 21 |
+
"Programming Language :: Python :: 3.12",
|
| 22 |
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
| 23 |
+
"Topic :: Database",
|
| 24 |
+
]
|
| 25 |
+
dependencies = []
|
| 26 |
+
|
| 27 |
+
[project.optional-dependencies]
|
| 28 |
+
dev = [
|
| 29 |
+
"pytest>=7.0",
|
| 30 |
+
"pytest-cov>=4.0",
|
| 31 |
+
]
|
| 32 |
+
|
| 33 |
+
[project.urls]
|
| 34 |
+
Homepage = "https://sibyllabs.org/plugin"
|
| 35 |
+
Documentation = "https://docs.sibyllabs.org/memory/"
|
| 36 |
+
|
| 37 |
+
[tool.setuptools.packages.find]
|
| 38 |
+
where = ["src"]
|
| 39 |
+
include = ["sibyl_memory_client*"]
|
| 40 |
+
|
| 41 |
+
[tool.setuptools.package-data]
|
| 42 |
+
"sibyl_memory_client" = ["schema.sql"]
|
| 43 |
+
|
| 44 |
+
[tool.pytest.ini_options]
|
| 45 |
+
testpaths = ["tests"]
|
| 46 |
+
addopts = "-ra"
|
sibyl-memory-client/src/sibyl_memory_client/__init__.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""sibyl-memory-client - Local-first agentic memory SDK.
|
| 2 |
+
|
| 3 |
+
Public exports:
|
| 4 |
+
MemoryClient the main interface
|
| 5 |
+
Storage low-level connection wrapper (advanced use)
|
| 6 |
+
DEFAULT_TENANT canonical single-user tenant UUID
|
| 7 |
+
Exceptions SibylMemoryError + subclasses
|
| 8 |
+
Learner self-learning pattern detector (v0.2.0)
|
| 9 |
+
SkillProposal review-queue dataclass (v0.2.0)
|
| 10 |
+
LearningRunReport summary of a learning pass (v0.2.0)
|
| 11 |
+
Summarizer* pluggable LLM backends (v0.2.0)
|
| 12 |
+
Linter local memory linter (v0.2.0)
|
| 13 |
+
LintReport aggregated lint output (v0.2.0)
|
| 14 |
+
Finding single lint finding (v0.2.0)
|
| 15 |
+
|
| 16 |
+
Quickstart:
|
| 17 |
+
|
| 18 |
+
from sibyl_memory_client import MemoryClient
|
| 19 |
+
client = MemoryClient.local("~/.sibyl-memory/memory.db")
|
| 20 |
+
client.set_entity("project", "atlas", {"status": "active", "stage": "staging"})
|
| 21 |
+
client.write_event(acted=["deployed atlas v1.2"])
|
| 22 |
+
|
| 23 |
+
# Self-learning (v0.2.0): scan journal, propose skills, review queue
|
| 24 |
+
report = client.learn()
|
| 25 |
+
for proposal in client.list_skill_proposals():
|
| 26 |
+
print(proposal.proposed_title, proposal.confidence)
|
| 27 |
+
client.accept_skill_proposal(proposal.id) # β writes reference/skill/<slug>
|
| 28 |
+
|
| 29 |
+
# Memory linter (v0.2.0):
|
| 30 |
+
print(client.lint().to_ascii())
|
| 31 |
+
"""
|
| 32 |
+
from ._capcheck import (
|
| 33 |
+
CapExceededError,
|
| 34 |
+
CapGate,
|
| 35 |
+
FREE_TIER_CAP_BYTES,
|
| 36 |
+
GRACE_PERIOD_SECONDS,
|
| 37 |
+
TierCache,
|
| 38 |
+
TierCacheEntry,
|
| 39 |
+
TierVerificationError,
|
| 40 |
+
)
|
| 41 |
+
from .client import DEFAULT_TENANT, MemoryClient
|
| 42 |
+
from .exceptions import (
|
| 43 |
+
ConflictError,
|
| 44 |
+
NotFoundError,
|
| 45 |
+
SchemaError,
|
| 46 |
+
SibylMemoryError,
|
| 47 |
+
StorageError,
|
| 48 |
+
TenantError,
|
| 49 |
+
TierGateError,
|
| 50 |
+
ValidationError,
|
| 51 |
+
)
|
| 52 |
+
from .learning import (
|
| 53 |
+
BYOKSummarizer,
|
| 54 |
+
Learner,
|
| 55 |
+
LearningRunReport,
|
| 56 |
+
LocalDeterministicSummarizer,
|
| 57 |
+
SkillProposal,
|
| 58 |
+
Summarizer,
|
| 59 |
+
VeniceX402Summarizer,
|
| 60 |
+
)
|
| 61 |
+
from .lint import Finding, LintReport, Linter
|
| 62 |
+
from .storage import Storage
|
| 63 |
+
|
| 64 |
+
# Single-sourced from installed metadata so the wheel + code never drift
|
| 65 |
+
# (C2 audit fix v0.3.3). Source-tree fallback for editable installs that
|
| 66 |
+
# haven't been pip-installed yet.
|
| 67 |
+
from importlib.metadata import PackageNotFoundError, version as _pkg_version
|
| 68 |
+
try:
|
| 69 |
+
__version__ = _pkg_version("sibyl-memory-client")
|
| 70 |
+
except PackageNotFoundError: # pragma: no cover - source-tree dev only
|
| 71 |
+
__version__ = "0.0.0+source"
|
| 72 |
+
|
| 73 |
+
__all__ = [
|
| 74 |
+
# core
|
| 75 |
+
"MemoryClient",
|
| 76 |
+
"Storage",
|
| 77 |
+
"DEFAULT_TENANT",
|
| 78 |
+
# exceptions
|
| 79 |
+
"SibylMemoryError",
|
| 80 |
+
"StorageError",
|
| 81 |
+
"SchemaError",
|
| 82 |
+
"TenantError",
|
| 83 |
+
"NotFoundError",
|
| 84 |
+
"ConflictError",
|
| 85 |
+
"ValidationError",
|
| 86 |
+
"TierGateError",
|
| 87 |
+
# cap enforcement (v0.3.0)
|
| 88 |
+
"CapExceededError",
|
| 89 |
+
"TierVerificationError",
|
| 90 |
+
"CapGate",
|
| 91 |
+
"TierCache",
|
| 92 |
+
"TierCacheEntry",
|
| 93 |
+
"FREE_TIER_CAP_BYTES",
|
| 94 |
+
"GRACE_PERIOD_SECONDS",
|
| 95 |
+
# learning (v0.2.0)
|
| 96 |
+
"Learner",
|
| 97 |
+
"SkillProposal",
|
| 98 |
+
"LearningRunReport",
|
| 99 |
+
"Summarizer",
|
| 100 |
+
"LocalDeterministicSummarizer",
|
| 101 |
+
"BYOKSummarizer",
|
| 102 |
+
"VeniceX402Summarizer",
|
| 103 |
+
# lint (v0.2.0)
|
| 104 |
+
"Linter",
|
| 105 |
+
"LintReport",
|
| 106 |
+
"Finding",
|
| 107 |
+
# meta
|
| 108 |
+
"__version__",
|
| 109 |
+
]
|
sibyl-memory-client/src/sibyl_memory_client/_capcheck.py
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hard-cap enforcement with server-authoritative tier verification.
|
| 2 |
+
|
| 3 |
+
Design (v0.3.0):
|
| 4 |
+
|
| 5 |
+
1. Every write call (set_entity, write_event, set_state, set_reference)
|
| 6 |
+
calls _check_write_allowed(proposed_delta_bytes).
|
| 7 |
+
2. Three fast paths skip the server call:
|
| 8 |
+
a) tier in PAID_TIERS (locally cached, refreshed weekly)
|
| 9 |
+
b) db_size + delta would still be well under the cap
|
| 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
|
| 17 |
+
cap forces a refresh. Users who go offline keep working under the
|
| 18 |
+
cached result; if their cached tier says paid, they keep their grant
|
| 19 |
+
for up to a week.
|
| 20 |
+
5. Offline at the cap boundary with NO cache: hard block with a clear
|
| 21 |
+
error pointing at the upgrade URL.
|
| 22 |
+
|
| 23 |
+
The local-first promise is preserved: no memory content ever crosses the
|
| 24 |
+
network. Only (account_id, current_size_bytes, proposed_delta_bytes) is sent
|
| 25 |
+
to the check-write endpoint.
|
| 26 |
+
"""
|
| 27 |
+
from __future__ import annotations
|
| 28 |
+
|
| 29 |
+
import json
|
| 30 |
+
import os
|
| 31 |
+
import time
|
| 32 |
+
from dataclasses import dataclass, field
|
| 33 |
+
from pathlib import Path
|
| 34 |
+
from typing import Any, Callable
|
| 35 |
+
|
| 36 |
+
# v0.4.0: CapExceededError + TierVerificationError live in exceptions.py
|
| 37 |
+
# (canonical exception module). _capcheck imports them back so existing
|
| 38 |
+
# callers that import from `sibyl_memory_client._capcheck` still resolve.
|
| 39 |
+
# The MCP server (sibyl-memory-mcp >= 0.1.2) imports from the canonical
|
| 40 |
+
# `.exceptions` path; this re-export keeps the historical path alive too.
|
| 41 |
+
from .exceptions import ( # noqa: F401 (re-exported for backwards compat)
|
| 42 |
+
CapExceededError,
|
| 43 |
+
SibylMemoryError,
|
| 44 |
+
TierVerificationError,
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
# ----------------------------------------------------------------------
|
| 48 |
+
# Constants
|
| 49 |
+
# ----------------------------------------------------------------------
|
| 50 |
+
|
| 51 |
+
FREE_TIER_CAP_BYTES = 2 * 1024 * 1024 # 2 MB
|
| 52 |
+
GRACE_PERIOD_SECONDS = 7 * 24 * 60 * 60 # 7 days
|
| 53 |
+
PAID_TIERS = frozenset({"sync", "team", "lifetime", "stake", "enterprise"})
|
| 54 |
+
|
| 55 |
+
DEFAULT_CHECK_WRITE_URL = "https://api.sibyllabs.org/api/plugin/check-write"
|
| 56 |
+
DEFAULT_UPGRADE_URL = "https://docs.sibyllabs.org/memory/tiers"
|
| 57 |
+
DEFAULT_CACHE_PATH = "~/.sibyl-memory/tier_cache.json"
|
| 58 |
+
|
| 59 |
+
# Network timeout for the check-write call. Short to keep latency tolerable
|
| 60 |
+
# on the user's first write at the cap.
|
| 61 |
+
HTTP_TIMEOUT_SECONDS = 4.0
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# ----------------------------------------------------------------------
|
| 65 |
+
# Cache
|
| 66 |
+
# ----------------------------------------------------------------------
|
| 67 |
+
|
| 68 |
+
@dataclass
|
| 69 |
+
class TierCacheEntry:
|
| 70 |
+
"""A single tier-check result cached on disk.
|
| 71 |
+
|
| 72 |
+
Fields:
|
| 73 |
+
account_id, tier, checked_at, cap_bytes, last_known_size: original
|
| 74 |
+
v0.3.0 schema fields.
|
| 75 |
+
grace_seconds: legacy local grace window (default 7d).
|
| 76 |
+
server_expires_at: T1-4 anchor (v0.3.2+). The server-supplied
|
| 77 |
+
subscription expiry (epoch seconds). When set, this is the
|
| 78 |
+
authoritative end-of-validity. Cache is honored only while
|
| 79 |
+
`now < min(checked_at + grace_seconds, server_expires_at)`.
|
| 80 |
+
For staker/free tier this is None (cache uses grace_seconds only).
|
| 81 |
+
cache_token: T1-2-lite (v0.3.2+). Opaque token issued by the
|
| 82 |
+
server (currently a copy of `credentials.signature`). Sent back
|
| 83 |
+
on every cap-check so the server can detect tampering of the
|
| 84 |
+
cache file. Authoritative cap decision still comes from the
|
| 85 |
+
server-side tier lookup.
|
| 86 |
+
"""
|
| 87 |
+
account_id: str
|
| 88 |
+
tier: str
|
| 89 |
+
checked_at: float # epoch seconds when we got the result
|
| 90 |
+
cap_bytes: int | None # None = uncapped (paid)
|
| 91 |
+
last_known_size: int = 0 # the size we reported when we made the check
|
| 92 |
+
grace_seconds: int = GRACE_PERIOD_SECONDS
|
| 93 |
+
server_expires_at: float | None = None
|
| 94 |
+
cache_token: str | None = None
|
| 95 |
+
|
| 96 |
+
@property
|
| 97 |
+
def expires_at(self) -> float:
|
| 98 |
+
local = self.checked_at + self.grace_seconds
|
| 99 |
+
if self.server_expires_at is not None:
|
| 100 |
+
return min(local, self.server_expires_at)
|
| 101 |
+
return local
|
| 102 |
+
|
| 103 |
+
@property
|
| 104 |
+
def is_fresh(self) -> bool:
|
| 105 |
+
return time.time() < self.expires_at
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
class TierCache:
|
| 109 |
+
"""File-backed tier cache. Mode 0600. Single entry per file."""
|
| 110 |
+
|
| 111 |
+
def __init__(self, path: str | Path = DEFAULT_CACHE_PATH) -> None:
|
| 112 |
+
self.path = Path(path).expanduser().resolve()
|
| 113 |
+
self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
| 114 |
+
|
| 115 |
+
def load(self) -> TierCacheEntry | None:
|
| 116 |
+
"""Load the cache entry. v0.3.3 hardens against symlink swapping:
|
| 117 |
+
refuses to follow symlinks (SEC-11). Returns None on missing,
|
| 118 |
+
symlinked, corrupted, or unreadable cache."""
|
| 119 |
+
if not self.path.exists():
|
| 120 |
+
return None
|
| 121 |
+
try:
|
| 122 |
+
# SEC-11: reject symlinks. A low-privilege attacker who once had
|
| 123 |
+
# write to ~/.sibyl-memory could symlink the cache to /dev/null
|
| 124 |
+
# or another sensitive file.
|
| 125 |
+
if self.path.is_symlink():
|
| 126 |
+
return None
|
| 127 |
+
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
| 128 |
+
server_exp = raw.get("server_expires_at")
|
| 129 |
+
return TierCacheEntry(
|
| 130 |
+
account_id=raw["account_id"],
|
| 131 |
+
tier=raw["tier"],
|
| 132 |
+
checked_at=float(raw["checked_at"]),
|
| 133 |
+
cap_bytes=raw.get("cap_bytes"),
|
| 134 |
+
last_known_size=int(raw.get("last_known_size", 0)),
|
| 135 |
+
grace_seconds=int(raw.get("grace_seconds", GRACE_PERIOD_SECONDS)),
|
| 136 |
+
server_expires_at=(float(server_exp) if server_exp is not None else None),
|
| 137 |
+
cache_token=raw.get("cache_token"),
|
| 138 |
+
)
|
| 139 |
+
except (OSError, KeyError, ValueError, json.JSONDecodeError):
|
| 140 |
+
return None # corrupted cache, treat as missing
|
| 141 |
+
|
| 142 |
+
def store(self, entry: TierCacheEntry) -> None:
|
| 143 |
+
"""Atomic store with mode 0o600 set at creation (not after the fact).
|
| 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,
|
| 151 |
+
"tier": entry.tier,
|
| 152 |
+
"checked_at": entry.checked_at,
|
| 153 |
+
"cap_bytes": entry.cap_bytes,
|
| 154 |
+
"last_known_size": entry.last_known_size,
|
| 155 |
+
"grace_seconds": entry.grace_seconds,
|
| 156 |
+
"server_expires_at": entry.server_expires_at,
|
| 157 |
+
"cache_token": entry.cache_token,
|
| 158 |
+
}
|
| 159 |
+
data = json.dumps(payload, indent=2).encode("utf-8")
|
| 160 |
+
tmp_path = self.path.with_suffix(self.path.suffix + ".tmp")
|
| 161 |
+
# Clean any leftover tmp from a crashed prior write so O_EXCL can succeed.
|
| 162 |
+
try:
|
| 163 |
+
os.unlink(tmp_path)
|
| 164 |
+
except FileNotFoundError:
|
| 165 |
+
pass
|
| 166 |
+
# Atomic create-with-mode. O_NOFOLLOW rejects symlink targets.
|
| 167 |
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
| 168 |
+
if hasattr(os, "O_NOFOLLOW"):
|
| 169 |
+
flags |= os.O_NOFOLLOW
|
| 170 |
+
fd = os.open(str(tmp_path), flags, 0o600)
|
| 171 |
+
try:
|
| 172 |
+
os.write(fd, data)
|
| 173 |
+
os.fsync(fd)
|
| 174 |
+
finally:
|
| 175 |
+
os.close(fd)
|
| 176 |
+
os.replace(str(tmp_path), str(self.path))
|
| 177 |
+
|
| 178 |
+
def clear(self) -> None:
|
| 179 |
+
if self.path.exists():
|
| 180 |
+
self.path.unlink()
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
# ----------------------------------------------------------------------
|
| 184 |
+
# Server check
|
| 185 |
+
# ----------------------------------------------------------------------
|
| 186 |
+
|
| 187 |
+
def _default_check_write_fn(
|
| 188 |
+
url: str,
|
| 189 |
+
payload: dict[str, Any],
|
| 190 |
+
timeout: float = HTTP_TIMEOUT_SECONDS,
|
| 191 |
+
) -> dict[str, Any]:
|
| 192 |
+
"""Default network transport for the check-write call.
|
| 193 |
+
|
| 194 |
+
Pure stdlib (urllib) to keep the SDK zero-dependency. If the call
|
| 195 |
+
fails (timeout, network error, non-2xx), raises TierVerificationError.
|
| 196 |
+
Callers can pass in a custom fn for testing or for using their own
|
| 197 |
+
HTTP client.
|
| 198 |
+
"""
|
| 199 |
+
import urllib.request
|
| 200 |
+
import urllib.error
|
| 201 |
+
body = json.dumps(payload).encode("utf-8")
|
| 202 |
+
# User-Agent sourced from installed metadata so version drift is impossible.
|
| 203 |
+
try:
|
| 204 |
+
from importlib.metadata import version as _pkg_version, PackageNotFoundError
|
| 205 |
+
try:
|
| 206 |
+
_ua_ver = _pkg_version("sibyl-memory-client")
|
| 207 |
+
except PackageNotFoundError:
|
| 208 |
+
_ua_ver = "0.0.0+source"
|
| 209 |
+
except Exception:
|
| 210 |
+
_ua_ver = "0.0.0+source"
|
| 211 |
+
# v0.4.1 (auth-redesign wave 1 step 15): forward-compat with the v6
|
| 212 |
+
# bearer model. If the payload carries a bearer_token (new server protocol)
|
| 213 |
+
# OR session_token (v1 backward compat where bearer == session), send it
|
| 214 |
+
# as `Authorization: Bearer <token>` in addition to the body field. Server
|
| 215 |
+
# accepts either path; this aligns the SDK to the new protocol without
|
| 216 |
+
# breaking older servers that only read the body.
|
| 217 |
+
headers = {
|
| 218 |
+
"Content-Type": "application/json",
|
| 219 |
+
"User-Agent": f"sibyl-memory-client/{_ua_ver}",
|
| 220 |
+
"Accept": "application/json",
|
| 221 |
+
}
|
| 222 |
+
auth_value = payload.get("bearer_token") or payload.get("session_token")
|
| 223 |
+
if auth_value:
|
| 224 |
+
headers["Authorization"] = f"Bearer {auth_value}"
|
| 225 |
+
req = urllib.request.Request(
|
| 226 |
+
url,
|
| 227 |
+
data=body,
|
| 228 |
+
headers=headers,
|
| 229 |
+
method="POST",
|
| 230 |
+
)
|
| 231 |
+
try:
|
| 232 |
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
| 233 |
+
return json.loads(resp.read().decode("utf-8"))
|
| 234 |
+
except urllib.error.HTTPError as e:
|
| 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:
|
| 242 |
+
body = json.loads(e.read().decode("utf-8"))
|
| 243 |
+
except Exception:
|
| 244 |
+
body = {}
|
| 245 |
+
# SEC-9 (v0.3.3): strip the server-side `error` string from the
|
| 246 |
+
# surfaced message to avoid echoing verbose internal detail (or
|
| 247 |
+
# potential PII) into user logs.
|
| 248 |
+
raise TierVerificationError(
|
| 249 |
+
f"Sibyl Labs returned HTTP {e.code} while verifying your account. "
|
| 250 |
+
f"Retry shortly.",
|
| 251 |
+
) from e
|
| 252 |
+
except (urllib.error.URLError, TimeoutError, OSError) as e:
|
| 253 |
+
raise TierVerificationError(
|
| 254 |
+
f"Could not reach Sibyl Labs to verify your account: {type(e).__name__}",
|
| 255 |
+
) from e
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
# ----------------------------------------------------------------------
|
| 259 |
+
# Cap gate
|
| 260 |
+
# ----------------------------------------------------------------------
|
| 261 |
+
|
| 262 |
+
class CapGate:
|
| 263 |
+
"""Orchestrates the cap check across the SDK write paths.
|
| 264 |
+
|
| 265 |
+
Args:
|
| 266 |
+
account_id: the account_id from credentials.json (None for
|
| 267 |
+
unactivated users; the gate behaves as free tier with no
|
| 268 |
+
server check capability)
|
| 269 |
+
session_token: bearer token sent with the check-write call
|
| 270 |
+
db_size_fn: callable returning the current SQLite db size in bytes
|
| 271 |
+
local_tier_hint: initial tier from credentials.json (advisory; the
|
| 272 |
+
server's answer always wins when we have one)
|
| 273 |
+
cache: TierCache instance (defaults to ~/.sibyl-memory/tier_cache.json)
|
| 274 |
+
check_url: full URL to the check-write endpoint
|
| 275 |
+
check_fn: pluggable transport (default: stdlib urllib)
|
| 276 |
+
"""
|
| 277 |
+
|
| 278 |
+
def __init__(
|
| 279 |
+
self,
|
| 280 |
+
*,
|
| 281 |
+
account_id: str | None,
|
| 282 |
+
session_token: str | None,
|
| 283 |
+
db_size_fn: Callable[[], int],
|
| 284 |
+
local_tier_hint: str = "free",
|
| 285 |
+
cache: TierCache | None = None,
|
| 286 |
+
check_url: str = DEFAULT_CHECK_WRITE_URL,
|
| 287 |
+
check_fn: Callable[..., dict[str, Any]] | None = None,
|
| 288 |
+
cap_bytes: int = FREE_TIER_CAP_BYTES,
|
| 289 |
+
credentials_claim: dict[str, Any] | None = None,
|
| 290 |
+
credentials_signature: str | None = None,
|
| 291 |
+
) -> None:
|
| 292 |
+
self.account_id = account_id
|
| 293 |
+
self.session_token = session_token
|
| 294 |
+
self._db_size_fn = db_size_fn
|
| 295 |
+
self._local_hint = local_tier_hint
|
| 296 |
+
self._cache = cache if cache is not None else TierCache()
|
| 297 |
+
self._check_url = check_url
|
| 298 |
+
self._check_fn = check_fn or _default_check_write_fn
|
| 299 |
+
self._cap = cap_bytes
|
| 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
|
| 312 |
+
CapExceededError if not."""
|
| 313 |
+
# Fast path 1: locally hinted as paid AND we have a fresh cache
|
| 314 |
+
# that agrees β allow without network.
|
| 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
|
| 322 |
+
if new_size <= cached.cap_bytes:
|
| 323 |
+
return
|
| 324 |
+
# Over the cached cap. Try to refresh (user may have upgraded).
|
| 325 |
+
return self._refresh_and_check(proposed_delta_bytes)
|
| 326 |
+
|
| 327 |
+
# No fresh cache. Use the credentials.json hint as a fast path
|
| 328 |
+
# for the "obviously under cap" case to avoid a server call for
|
| 329 |
+
# every brand-new user's first writes.
|
| 330 |
+
current = self._db_size_fn()
|
| 331 |
+
new_size = current + proposed_delta_bytes
|
| 332 |
+
if self._local_hint in PAID_TIERS:
|
| 333 |
+
# Credentials say paid; verify with server then cache the result.
|
| 334 |
+
# If user genuinely paid: server confirms, we cache, done. If
|
| 335 |
+
# credentials are tampered: server says free, we cache, enforce.
|
| 336 |
+
return self._refresh_and_check(proposed_delta_bytes)
|
| 337 |
+
if new_size <= self._cap:
|
| 338 |
+
# Free + under cap. Trust the credentials hint, no server call.
|
| 339 |
+
return
|
| 340 |
+
# Free + at/past cap β must call server.
|
| 341 |
+
return self._refresh_and_check(proposed_delta_bytes)
|
| 342 |
+
|
| 343 |
+
# ------------------------------------------------------------------
|
| 344 |
+
# Network refresh
|
| 345 |
+
# ------------------------------------------------------------------
|
| 346 |
+
def _refresh_and_check(self, proposed_delta_bytes: int) -> None:
|
| 347 |
+
if not self.account_id or not self.session_token:
|
| 348 |
+
# Pre-activation user trying to write past the cap. They never
|
| 349 |
+
# had a binding; we can't verify a tier they don't have.
|
| 350 |
+
current = self._db_size_fn()
|
| 351 |
+
new_size = current + proposed_delta_bytes
|
| 352 |
+
if new_size <= self._cap:
|
| 353 |
+
return
|
| 354 |
+
raise CapExceededError(
|
| 355 |
+
"You're at the 2 MB free-tier cap and your account isn't "
|
| 356 |
+
"activated. Run `sibyl init` to activate, or stay under "
|
| 357 |
+
"the cap.",
|
| 358 |
+
current_size=current,
|
| 359 |
+
cap=self._cap,
|
| 360 |
+
proposed_delta=proposed_delta_bytes,
|
| 361 |
+
)
|
| 362 |
+
|
| 363 |
+
current = self._db_size_fn()
|
| 364 |
+
payload = {
|
| 365 |
+
"account_id": self.account_id,
|
| 366 |
+
"session_token": self.session_token,
|
| 367 |
+
"current_size_bytes": current,
|
| 368 |
+
"proposed_delta_bytes": proposed_delta_bytes,
|
| 369 |
+
}
|
| 370 |
+
# Attach signed-credentials claim if we have one. Server uses it for
|
| 371 |
+
# tamper telemetry; the decision itself is unaffected.
|
| 372 |
+
if self._credentials_signature and self._credentials_claim:
|
| 373 |
+
payload["credentials_signature"] = self._credentials_signature
|
| 374 |
+
payload["credentials_claim"] = self._credentials_claim
|
| 375 |
+
|
| 376 |
+
try:
|
| 377 |
+
resp = self._check_fn(self._check_url, payload)
|
| 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
|
| 385 |
+
# actual subscription end-of-validity even if the user
|
| 386 |
+
# blackholes /api/plugin/check-write. Subscription expiry is
|
| 387 |
+
# authoritative, not a refresh-able grace window.
|
| 388 |
+
cached = self._cache.load()
|
| 389 |
+
if cached and cached.account_id == self.account_id:
|
| 390 |
+
now = time.time()
|
| 391 |
+
if cached.server_expires_at is not None and now >= cached.server_expires_at:
|
| 392 |
+
raise # subscription already expired per server's own record
|
| 393 |
+
age = now - cached.checked_at
|
| 394 |
+
if age < 2 * GRACE_PERIOD_SECONDS:
|
| 395 |
+
# Honor the cached result a bit longer for honest
|
| 396 |
+
# offline users.
|
| 397 |
+
if cached.cap_bytes is None:
|
| 398 |
+
return
|
| 399 |
+
new_size = current + proposed_delta_bytes
|
| 400 |
+
if new_size <= cached.cap_bytes:
|
| 401 |
+
return
|
| 402 |
+
raise # re-raise TierVerificationError; SDK will surface to caller
|
| 403 |
+
|
| 404 |
+
# Got a response. Update the cache.
|
| 405 |
+
ok = bool(resp.get("ok"))
|
| 406 |
+
tier = resp.get("tier", "free")
|
| 407 |
+
cap_bytes = resp.get("cap_bytes") if "cap_bytes" in resp else (
|
| 408 |
+
None if tier in PAID_TIERS else self._cap
|
| 409 |
+
)
|
| 410 |
+
# T1-4 anchor: capture server-supplied subscription expiry so the
|
| 411 |
+
# cache cannot be honored past actual end-of-validity, even if the
|
| 412 |
+
# user blackholes the network. Server returns ISO string; parse if
|
| 413 |
+
# present.
|
| 414 |
+
server_expires_at: float | None = None
|
| 415 |
+
raw_exp = resp.get("expires_at")
|
| 416 |
+
if raw_exp:
|
| 417 |
+
try:
|
| 418 |
+
from datetime import datetime, timezone
|
| 419 |
+
server_expires_at = datetime.fromisoformat(
|
| 420 |
+
raw_exp.replace("Z", "+00:00")
|
| 421 |
+
).astimezone(timezone.utc).timestamp()
|
| 422 |
+
except (ValueError, TypeError):
|
| 423 |
+
server_expires_at = None
|
| 424 |
+
entry = TierCacheEntry(
|
| 425 |
+
account_id=self.account_id,
|
| 426 |
+
tier=tier,
|
| 427 |
+
checked_at=time.time(),
|
| 428 |
+
cap_bytes=cap_bytes,
|
| 429 |
+
last_known_size=current,
|
| 430 |
+
server_expires_at=server_expires_at,
|
| 431 |
+
# Cache token = the credentials signature we hold (defense-in-depth
|
| 432 |
+
# link between cache and credentials.json identity). Server can
|
| 433 |
+
# cross-check on next /check-write call.
|
| 434 |
+
cache_token=self._credentials_signature,
|
| 435 |
+
)
|
| 436 |
+
self._cache.store(entry)
|
| 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. "
|
| 444 |
+
f"Cap: {(cap_bytes or self._cap) / 1024:.1f} KB.",
|
| 445 |
+
current_size=current,
|
| 446 |
+
cap=cap_bytes or self._cap,
|
| 447 |
+
proposed_delta=proposed_delta_bytes,
|
| 448 |
+
upgrade_url=resp.get("upgrade_url", DEFAULT_UPGRADE_URL),
|
| 449 |
+
)
|
| 450 |
+
|
| 451 |
+
# ------------------------------------------------------------------
|
| 452 |
+
# Helpers
|
| 453 |
+
# ------------------------------------------------------------------
|
| 454 |
+
def invalidate_cache(self) -> None:
|
| 455 |
+
"""Forget any cached tier result. Next write at the cap will refetch."""
|
| 456 |
+
self._cache.clear()
|
| 457 |
+
|
| 458 |
+
def current_cap(self) -> int | None:
|
| 459 |
+
"""Return the current effective cap. None = uncapped."""
|
| 460 |
+
cached = self._cache.load()
|
| 461 |
+
if cached and cached.is_fresh:
|
| 462 |
+
return cached.cap_bytes
|
| 463 |
+
if self._local_hint in PAID_TIERS:
|
| 464 |
+
return None
|
| 465 |
+
return self._cap
|
sibyl-memory-client/src/sibyl_memory_client/client.py
ADDED
|
@@ -0,0 +1,896 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MemoryClient: the public API for sibyl-memory-client.
|
| 2 |
+
|
| 3 |
+
Polymorphic constructor: open by local path OR by hosted-tier URL (v2+, not
|
| 4 |
+
implemented yet). The local-first plugin v1 only uses the local path.
|
| 5 |
+
|
| 6 |
+
The API surface mirrors the canonical sibyl_memory.* table shape so callers
|
| 7 |
+
can move between local-SQLite-backed and Postgres-backed clients without
|
| 8 |
+
re-learning the model.
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import sqlite3
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from typing import Any
|
| 15 |
+
|
| 16 |
+
from .exceptions import NotFoundError, StorageError, TenantError, ValidationError
|
| 17 |
+
from .storage import Storage, dumps, loads, new_id, _utc_now_iso
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
# ----------------------------------------------------------------------
|
| 21 |
+
# Identifier validation (v0.4.0, KAPPA YELLOW finding)
|
| 22 |
+
# ----------------------------------------------------------------------
|
| 23 |
+
# Entity names, state keys, and reference doc keys are user-supplied
|
| 24 |
+
# identifiers. SQL is parameterized everywhere so injection is closed today,
|
| 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
|
| 32 |
+
|
| 33 |
+
# Control chars (0x00-0x1F + DEL) are rejected. Tab/newline/CR included by
|
| 34 |
+
# design: identifiers are short single-line strings, not arbitrary payloads.
|
| 35 |
+
_IDENT_FORBIDDEN_CODE_POINTS = frozenset(range(0, 0x20)) | {0x7F}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def validate_identifier(value: Any, *, field_name: str) -> str:
|
| 39 |
+
"""Validate a user-supplied identifier (entity name, state key, etc.).
|
| 40 |
+
|
| 41 |
+
Rejects: non-string, empty, control characters / null bytes, length > 1024.
|
| 42 |
+
|
| 43 |
+
Args:
|
| 44 |
+
value: the identifier to validate.
|
| 45 |
+
field_name: name of the field for error messages.
|
| 46 |
+
|
| 47 |
+
Returns: the validated string (unchanged on success).
|
| 48 |
+
Raises: ValidationError on rejection, with a recovery hint.
|
| 49 |
+
"""
|
| 50 |
+
if not isinstance(value, str):
|
| 51 |
+
raise ValidationError(
|
| 52 |
+
f"{field_name} must be a string (got {type(value).__name__})",
|
| 53 |
+
recovery=f"Pass a non-empty string for {field_name}.",
|
| 54 |
+
)
|
| 55 |
+
if not value:
|
| 56 |
+
raise ValidationError(
|
| 57 |
+
f"{field_name} cannot be empty",
|
| 58 |
+
recovery=f"Pass a non-empty string for {field_name}.",
|
| 59 |
+
)
|
| 60 |
+
if len(value) > _IDENT_MAX_LENGTH:
|
| 61 |
+
raise ValidationError(
|
| 62 |
+
f"{field_name} too long ({len(value)} chars, max {_IDENT_MAX_LENGTH})",
|
| 63 |
+
recovery=f"Use a shorter {field_name} (under {_IDENT_MAX_LENGTH} chars).",
|
| 64 |
+
)
|
| 65 |
+
for ch in value:
|
| 66 |
+
if ord(ch) in _IDENT_FORBIDDEN_CODE_POINTS:
|
| 67 |
+
raise ValidationError(
|
| 68 |
+
f"{field_name} contains a forbidden control character "
|
| 69 |
+
f"(code point 0x{ord(ch):02x} at index {value.index(ch)})",
|
| 70 |
+
recovery=(
|
| 71 |
+
f"Identifiers must be printable single-line strings. "
|
| 72 |
+
f"Remove control characters / null bytes / tabs / newlines."
|
| 73 |
+
),
|
| 74 |
+
)
|
| 75 |
+
return value
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# ----------------------------------------------------------------------
|
| 79 |
+
# FTS5 error surface (v0.4.0, KAPPA YELLOW finding)
|
| 80 |
+
# ----------------------------------------------------------------------
|
| 81 |
+
# Previously search() and search_entities() silently swallowed
|
| 82 |
+
# sqlite3.OperationalError into `return []` / `pass`. KAPPA's complaint:
|
| 83 |
+
# "a user has no signal whether their query was malformed or just genuinely
|
| 84 |
+
# returned nothing." Now we classify: schema-missing β silent (defensive
|
| 85 |
+
# against partial init), FTS5-syntax-error β ValidationError (caller bug),
|
| 86 |
+
# anything else β StorageError (real backend issue).
|
| 87 |
+
|
| 88 |
+
# Substrings that mark FTS5 query syntax errors. Matched case-insensitively
|
| 89 |
+
# against str(OperationalError). Curated against the actual messages SQLite
|
| 90 |
+
# emits in 3.38+ for FTS5 parse failures.
|
| 91 |
+
_FTS5_QUERY_ERROR_MARKERS = (
|
| 92 |
+
"fts5",
|
| 93 |
+
"malformed match",
|
| 94 |
+
"syntax error near",
|
| 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 |
+
|
| 102 |
+
|
| 103 |
+
def _classify_fts5_error(err: sqlite3.OperationalError) -> Exception | None:
|
| 104 |
+
"""Translate an FTS5-related sqlite OperationalError.
|
| 105 |
+
|
| 106 |
+
Returns:
|
| 107 |
+
None β schema-missing case; caller should treat as empty results.
|
| 108 |
+
ValidationError β user-visible query syntax problem; raise.
|
| 109 |
+
StorageError β real backend issue; raise.
|
| 110 |
+
"""
|
| 111 |
+
msg = str(err).lower()
|
| 112 |
+
if _SCHEMA_MISSING_MARKER in msg:
|
| 113 |
+
return None # defensive: schema partially applied, return empty
|
| 114 |
+
if any(marker in msg for marker in _FTS5_QUERY_ERROR_MARKERS):
|
| 115 |
+
return ValidationError(
|
| 116 |
+
f"FTS5 rejected the search query: {err}",
|
| 117 |
+
recovery=(
|
| 118 |
+
"The query passed sanitization but the FTS5 engine still "
|
| 119 |
+
"rejected it. Pass plain text or simple word tokens; FTS5 "
|
| 120 |
+
"operator syntax (NEAR, AND/OR/NOT, column filters) is "
|
| 121 |
+
"treated as literal text after sanitization."
|
| 122 |
+
),
|
| 123 |
+
)
|
| 124 |
+
return StorageError(
|
| 125 |
+
f"SQLite error during FTS5 search: {err}",
|
| 126 |
+
recovery=(
|
| 127 |
+
"Backend error. Check disk space, file permissions, and that "
|
| 128 |
+
"the schema is intact. See exception chain for the underlying "
|
| 129 |
+
"sqlite3 message."
|
| 130 |
+
),
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
# ----------------------------------------------------------------------
|
| 135 |
+
# FTS5 query sanitization
|
| 136 |
+
# ----------------------------------------------------------------------
|
| 137 |
+
# v0.3.3 hardens search() / search_entities() against FTS5 injection + DoS
|
| 138 |
+
# (audit SEC-3). User input is wrapped as a single quoted FTS5 phrase so
|
| 139 |
+
# column-filter syntax (`name:`, `category:`, `rowid:`, etc.) and unclosed
|
| 140 |
+
# quotes can't escape into the FTS5 parser. Caller can still get prefix
|
| 141 |
+
# matching by passing prefix=True.
|
| 142 |
+
|
| 143 |
+
# Column names + FTS5 reserved operators we reject if they appear unquoted.
|
| 144 |
+
_FTS5_COLUMN_TOKENS = frozenset({"name", "category", "body", "tenant_id",
|
| 145 |
+
"entity_id", "document_key", "doc_key",
|
| 146 |
+
"payload", "ts", "rowid"})
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def _sanitize_fts5_query(raw: str, *, prefix: bool = False) -> str:
|
| 150 |
+
"""Wrap a user query as a safe FTS5 MATCH expression.
|
| 151 |
+
|
| 152 |
+
Two modes:
|
| 153 |
+
- Default (``prefix=False``): wrap the entire input as a single
|
| 154 |
+
double-quoted phrase. FTS5 treats `"a b c"` as a phrase match;
|
| 155 |
+
operators (AND/OR/NOT/NEAR/^/+/-) and column filters (`name:foo`)
|
| 156 |
+
inside the quotes are literal text. Internal double-quotes are
|
| 157 |
+
doubled per FTS5 escape rules. Safe against injection / DoS.
|
| 158 |
+
- Prefix (``prefix=True``): strip the input to alphanumeric +
|
| 159 |
+
underscore tokens, join with spaces, and append `*` to the last
|
| 160 |
+
token for prefix matching. FTS5 does NOT support prefix on
|
| 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):
|
| 168 |
+
return ""
|
| 169 |
+
s = raw.strip()
|
| 170 |
+
if not s:
|
| 171 |
+
return ""
|
| 172 |
+
# Strip control characters that could confuse the FTS5 tokenizer
|
| 173 |
+
s = "".join(ch for ch in s if ch.isprintable() or ch in (" ", "\t"))
|
| 174 |
+
if not s.strip():
|
| 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)
|
| 182 |
+
tokens = [t for t in cleaned.split() if t]
|
| 183 |
+
if not tokens:
|
| 184 |
+
return ""
|
| 185 |
+
if len(tokens) == 1:
|
| 186 |
+
return f"{tokens[0]}*"
|
| 187 |
+
# Multiple tokens: all earlier tokens are literal, the last gets `*`.
|
| 188 |
+
return " ".join(tokens[:-1]) + f" {tokens[-1]}*"
|
| 189 |
+
|
| 190 |
+
# Default: single quoted phrase. Escape embedded double-quotes.
|
| 191 |
+
escaped = s.replace('"', '""')
|
| 192 |
+
return f'"{escaped}"'
|
| 193 |
+
|
| 194 |
+
# The default tenant for single-user local installs.
|
| 195 |
+
DEFAULT_TENANT = "00000000-0000-0000-0000-000000000001"
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def _check_json(payload: Any, field: str = "body") -> str:
|
| 199 |
+
"""Validate that payload is JSON-serializable, return the encoded string."""
|
| 200 |
+
try:
|
| 201 |
+
return dumps(payload)
|
| 202 |
+
except (TypeError, ValueError) as e:
|
| 203 |
+
raise ValidationError(
|
| 204 |
+
f"{field} is not JSON-serializable: {e}",
|
| 205 |
+
recovery=f"Pass a dict, list, or JSON primitive as {field}.",
|
| 206 |
+
) from e
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
class MemoryClient:
|
| 210 |
+
"""Single canonical interface for reading and writing Sibyl Memory state."""
|
| 211 |
+
|
| 212 |
+
# Paid-tier-only features. Free tier raises TierGateError; upgrading to any
|
| 213 |
+
# paid tier unlocks both self-learning and the memory linter.
|
| 214 |
+
_PAID_ONLY_TIERS = frozenset({"sync", "team", "lifetime", "stake", "enterprise"})
|
| 215 |
+
|
| 216 |
+
def __init__(
|
| 217 |
+
self,
|
| 218 |
+
storage: Storage,
|
| 219 |
+
*,
|
| 220 |
+
tenant_id: str = DEFAULT_TENANT,
|
| 221 |
+
tier: str = "free",
|
| 222 |
+
account_id: str | None = None,
|
| 223 |
+
session_token: str | None = None,
|
| 224 |
+
cap_gate: Any = None,
|
| 225 |
+
credentials_claim: dict[str, Any] | None = None,
|
| 226 |
+
credentials_signature: str | None = None,
|
| 227 |
+
) -> None:
|
| 228 |
+
self._storage = storage
|
| 229 |
+
self._tenant_id = tenant_id
|
| 230 |
+
self._tier = tier
|
| 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
|
| 238 |
+
cap_gate = CapGate(
|
| 239 |
+
account_id=account_id,
|
| 240 |
+
session_token=session_token,
|
| 241 |
+
db_size_fn=lambda: (
|
| 242 |
+
Path(storage.db_path).stat().st_size
|
| 243 |
+
if Path(storage.db_path).exists() else 0
|
| 244 |
+
),
|
| 245 |
+
local_tier_hint=tier,
|
| 246 |
+
cache=TierCache(
|
| 247 |
+
Path(storage.db_path).parent / "tier_cache.json"
|
| 248 |
+
),
|
| 249 |
+
credentials_claim=credentials_claim,
|
| 250 |
+
credentials_signature=credentials_signature,
|
| 251 |
+
)
|
| 252 |
+
self._cap_gate = cap_gate
|
| 253 |
+
|
| 254 |
+
# ------------------------------------------------------------------
|
| 255 |
+
# Constructors
|
| 256 |
+
# ------------------------------------------------------------------
|
| 257 |
+
@classmethod
|
| 258 |
+
def local(
|
| 259 |
+
cls,
|
| 260 |
+
path: str | Path = "~/.sibyl-memory/memory.db",
|
| 261 |
+
*,
|
| 262 |
+
tenant_id: str = DEFAULT_TENANT,
|
| 263 |
+
tier: str = "free",
|
| 264 |
+
account_id: str | None = None,
|
| 265 |
+
session_token: str | None = None,
|
| 266 |
+
credentials_claim: dict[str, Any] | None = None,
|
| 267 |
+
credentials_signature: str | None = None,
|
| 268 |
+
) -> "MemoryClient":
|
| 269 |
+
"""Open a local SQLite-backed MemoryClient.
|
| 270 |
+
|
| 271 |
+
The directory at ``path``'s parent is created with mode 0700 if
|
| 272 |
+
missing. The schema is applied on first open and is idempotent.
|
| 273 |
+
|
| 274 |
+
Set ``tier`` to the user's plugin tier so paid-only features
|
| 275 |
+
(self-learning + memory linter) gate correctly. Defaults to "free".
|
| 276 |
+
|
| 277 |
+
Pass ``account_id`` and ``session_token`` from credentials.json so
|
| 278 |
+
the SDK can verify the user's tier against the server when they
|
| 279 |
+
approach the 2 MB free-tier cap. Without these, the SDK enforces
|
| 280 |
+
a strict local 2 MB cap (no server check possible).
|
| 281 |
+
"""
|
| 282 |
+
storage = Storage(path)
|
| 283 |
+
return cls(
|
| 284 |
+
storage,
|
| 285 |
+
tenant_id=tenant_id,
|
| 286 |
+
tier=tier,
|
| 287 |
+
account_id=account_id,
|
| 288 |
+
session_token=session_token,
|
| 289 |
+
credentials_claim=credentials_claim,
|
| 290 |
+
credentials_signature=credentials_signature,
|
| 291 |
+
)
|
| 292 |
+
|
| 293 |
+
# ------------------------------------------------------------------
|
| 294 |
+
# Tenant management
|
| 295 |
+
# ------------------------------------------------------------------
|
| 296 |
+
def get_tenant(self) -> str:
|
| 297 |
+
return self._tenant_id
|
| 298 |
+
|
| 299 |
+
def set_tenant(self, tenant_id: str) -> None:
|
| 300 |
+
if not tenant_id or not isinstance(tenant_id, str):
|
| 301 |
+
raise TenantError("tenant_id must be a non-empty string")
|
| 302 |
+
self._tenant_id = tenant_id
|
| 303 |
+
|
| 304 |
+
@property
|
| 305 |
+
def storage(self) -> Storage:
|
| 306 |
+
return self._storage
|
| 307 |
+
|
| 308 |
+
def schema_version(self) -> int | None:
|
| 309 |
+
return self._storage.schema_version()
|
| 310 |
+
|
| 311 |
+
# ------------------------------------------------------------------
|
| 312 |
+
# Tier (paid-tier-only feature gating)
|
| 313 |
+
# ------------------------------------------------------------------
|
| 314 |
+
def get_tier(self) -> str:
|
| 315 |
+
return self._tier
|
| 316 |
+
|
| 317 |
+
def set_tier(self, tier: str) -> None:
|
| 318 |
+
"""Update the user's tier. Called by the credentials loader when
|
| 319 |
+
the activation flow returns a tier upgrade."""
|
| 320 |
+
if not isinstance(tier, str) or not tier:
|
| 321 |
+
raise ValidationError("tier must be a non-empty string")
|
| 322 |
+
self._tier = tier
|
| 323 |
+
|
| 324 |
+
def _require_paid_tier(self, feature: str) -> None:
|
| 325 |
+
"""Raise TierGateError if the current tier is not paid-tier."""
|
| 326 |
+
from .exceptions import TierGateError
|
| 327 |
+
if self._tier not in self._PAID_ONLY_TIERS:
|
| 328 |
+
raise TierGateError(
|
| 329 |
+
f"{feature} requires a paid tier. Current tier: {self._tier!r}.",
|
| 330 |
+
feature=feature,
|
| 331 |
+
current_tier=self._tier,
|
| 332 |
+
)
|
| 333 |
+
|
| 334 |
+
# ------------------------------------------------------------------
|
| 335 |
+
# Entities (WARM tier) β single source of truth per rule 43
|
| 336 |
+
# ------------------------------------------------------------------
|
| 337 |
+
def set_entity(
|
| 338 |
+
self,
|
| 339 |
+
category: str,
|
| 340 |
+
name: str,
|
| 341 |
+
body: dict[str, Any] | list[Any],
|
| 342 |
+
*,
|
| 343 |
+
status: str | None = None,
|
| 344 |
+
) -> dict[str, Any]:
|
| 345 |
+
"""Insert or update an entity.
|
| 346 |
+
|
| 347 |
+
UNIQUE (tenant_id, category, name) is enforced at the DB level. On
|
| 348 |
+
conflict the existing row is updated (body + status + updated_at).
|
| 349 |
+
Returns the resulting entity row as a dict.
|
| 350 |
+
|
| 351 |
+
Subject to the 2 MB free-tier cap when tier='free'. Raises
|
| 352 |
+
CapExceededError if the write would push the local DB past the cap
|
| 353 |
+
and the server-authoritative tier check confirms the account is
|
| 354 |
+
still free.
|
| 355 |
+
|
| 356 |
+
v0.4.0: category and name are validated as identifiers (non-empty
|
| 357 |
+
string, no control characters, length <= 1024). Raises
|
| 358 |
+
ValidationError on rejection."""
|
| 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(
|
| 366 |
+
"SELECT id FROM entities WHERE tenant_id = ? AND category = ? AND name = ?",
|
| 367 |
+
(self._tenant_id, category, name),
|
| 368 |
+
).fetchone()
|
| 369 |
+
if existing is None:
|
| 370 |
+
ent_id = new_id()
|
| 371 |
+
conn.execute(
|
| 372 |
+
"INSERT INTO entities (id, tenant_id, category, name, status, body) "
|
| 373 |
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
| 374 |
+
(ent_id, self._tenant_id, category, name, status, body_json),
|
| 375 |
+
)
|
| 376 |
+
else:
|
| 377 |
+
ent_id = existing["id"]
|
| 378 |
+
conn.execute(
|
| 379 |
+
"UPDATE entities SET status = ?, body = ?, "
|
| 380 |
+
"updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') "
|
| 381 |
+
"WHERE id = ?",
|
| 382 |
+
(status, body_json, ent_id),
|
| 383 |
+
)
|
| 384 |
+
return self.get_entity(category, name)
|
| 385 |
+
|
| 386 |
+
def get_entity(self, category: str, name: str) -> dict[str, Any]:
|
| 387 |
+
with self._storage.connection() as conn:
|
| 388 |
+
row = conn.execute(
|
| 389 |
+
"SELECT id, tenant_id, category, name, status, body, created_at, updated_at "
|
| 390 |
+
"FROM entities WHERE tenant_id = ? AND category = ? AND name = ?",
|
| 391 |
+
(self._tenant_id, category, name),
|
| 392 |
+
).fetchone()
|
| 393 |
+
if row is None:
|
| 394 |
+
raise NotFoundError(f"entity {category}/{name} not found for tenant {self._tenant_id}")
|
| 395 |
+
return self._row_to_entity(row)
|
| 396 |
+
|
| 397 |
+
def list_entities(
|
| 398 |
+
self,
|
| 399 |
+
category: str | None = None,
|
| 400 |
+
*,
|
| 401 |
+
status: str | None = None,
|
| 402 |
+
limit: int = 100,
|
| 403 |
+
) -> list[dict[str, Any]]:
|
| 404 |
+
sql = "SELECT id, tenant_id, category, name, status, body, created_at, updated_at FROM entities WHERE tenant_id = ?"
|
| 405 |
+
params: list[Any] = [self._tenant_id]
|
| 406 |
+
if category is not None:
|
| 407 |
+
sql += " AND category = ?"
|
| 408 |
+
params.append(category)
|
| 409 |
+
if status is not None:
|
| 410 |
+
sql += " AND status = ?"
|
| 411 |
+
params.append(status)
|
| 412 |
+
sql += " ORDER BY updated_at DESC LIMIT ?"
|
| 413 |
+
params.append(limit)
|
| 414 |
+
with self._storage.connection() as conn:
|
| 415 |
+
rows = conn.execute(sql, params).fetchall()
|
| 416 |
+
return [self._row_to_entity(r) for r in rows]
|
| 417 |
+
|
| 418 |
+
def delete_entity(self, category: str, name: str) -> bool:
|
| 419 |
+
with self._storage.transaction() as conn:
|
| 420 |
+
cur = conn.execute(
|
| 421 |
+
"DELETE FROM entities WHERE tenant_id = ? AND category = ? AND name = ?",
|
| 422 |
+
(self._tenant_id, category, name),
|
| 423 |
+
)
|
| 424 |
+
return cur.rowcount > 0
|
| 425 |
+
|
| 426 |
+
# ------------------------------------------------------------------
|
| 427 |
+
# State documents (HOT tier)
|
| 428 |
+
# ------------------------------------------------------------------
|
| 429 |
+
def set_state(self, key: str, body: dict[str, Any] | list[Any]) -> None:
|
| 430 |
+
"""Insert or update a HOT-tier state document.
|
| 431 |
+
|
| 432 |
+
v0.4.0: ``key`` is validated as an identifier (non-empty string, no
|
| 433 |
+
control characters, length <= 1024). Raises ValidationError on
|
| 434 |
+
rejection."""
|
| 435 |
+
validate_identifier(key, field_name="key")
|
| 436 |
+
body_json = _check_json(body)
|
| 437 |
+
self._cap_gate.check(proposed_delta_bytes=len(body_json) + len(key) + 150)
|
| 438 |
+
with self._storage.transaction() as conn:
|
| 439 |
+
conn.execute(
|
| 440 |
+
"INSERT INTO state_documents (tenant_id, document_key, body) VALUES (?, ?, ?) "
|
| 441 |
+
"ON CONFLICT(tenant_id, document_key) DO UPDATE SET body = excluded.body, "
|
| 442 |
+
"updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')",
|
| 443 |
+
(self._tenant_id, key, body_json),
|
| 444 |
+
)
|
| 445 |
+
|
| 446 |
+
def get_state(self, key: str) -> dict[str, Any] | None:
|
| 447 |
+
with self._storage.connection() as conn:
|
| 448 |
+
row = conn.execute(
|
| 449 |
+
"SELECT body, updated_at FROM state_documents WHERE tenant_id = ? AND document_key = ?",
|
| 450 |
+
(self._tenant_id, key),
|
| 451 |
+
).fetchone()
|
| 452 |
+
if row is None:
|
| 453 |
+
return None
|
| 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,
|
| 461 |
+
*,
|
| 462 |
+
evaluated: Any = None,
|
| 463 |
+
acted: Any = None,
|
| 464 |
+
forward: Any = None,
|
| 465 |
+
extra: Any = None,
|
| 466 |
+
ts: str | None = None,
|
| 467 |
+
) -> str:
|
| 468 |
+
# Estimate byte cost from each non-None payload
|
| 469 |
+
delta = 200 # row + index overhead
|
| 470 |
+
for payload in (evaluated, acted, forward, extra):
|
| 471 |
+
if payload is not None:
|
| 472 |
+
try:
|
| 473 |
+
delta += len(dumps(payload))
|
| 474 |
+
except (TypeError, ValueError):
|
| 475 |
+
delta += 100 # estimate; the JSON check below will catch real failures
|
| 476 |
+
self._cap_gate.check(proposed_delta_bytes=delta)
|
| 477 |
+
ev_id = new_id()
|
| 478 |
+
with self._storage.transaction() as conn:
|
| 479 |
+
conn.execute(
|
| 480 |
+
"INSERT INTO journal_events (id, tenant_id, ts, evaluated, acted, forward, extra) "
|
| 481 |
+
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
| 482 |
+
(
|
| 483 |
+
ev_id,
|
| 484 |
+
self._tenant_id,
|
| 485 |
+
ts or _utc_now_iso(),
|
| 486 |
+
_check_json(evaluated, "evaluated") if evaluated is not None else None,
|
| 487 |
+
_check_json(acted, "acted") if acted is not None else None,
|
| 488 |
+
_check_json(forward, "forward") if forward is not None else None,
|
| 489 |
+
_check_json(extra, "extra") if extra is not None else None,
|
| 490 |
+
),
|
| 491 |
+
)
|
| 492 |
+
return ev_id
|
| 493 |
+
|
| 494 |
+
def read_events(
|
| 495 |
+
self,
|
| 496 |
+
*,
|
| 497 |
+
limit: int = 50,
|
| 498 |
+
since: str | None = None,
|
| 499 |
+
until: str | None = None,
|
| 500 |
+
) -> list[dict[str, Any]]:
|
| 501 |
+
sql = "SELECT id, tenant_id, ts, evaluated, acted, forward, extra FROM journal_events WHERE tenant_id = ?"
|
| 502 |
+
params: list[Any] = [self._tenant_id]
|
| 503 |
+
if since is not None:
|
| 504 |
+
sql += " AND ts >= ?"
|
| 505 |
+
params.append(since)
|
| 506 |
+
if until is not None:
|
| 507 |
+
sql += " AND ts <= ?"
|
| 508 |
+
params.append(until)
|
| 509 |
+
sql += " ORDER BY ts DESC, id DESC LIMIT ?"
|
| 510 |
+
params.append(limit)
|
| 511 |
+
with self._storage.connection() as conn:
|
| 512 |
+
rows = conn.execute(sql, params).fetchall()
|
| 513 |
+
return [
|
| 514 |
+
{
|
| 515 |
+
"id": r["id"],
|
| 516 |
+
"ts": r["ts"],
|
| 517 |
+
"evaluated": loads(r["evaluated"]),
|
| 518 |
+
"acted": loads(r["acted"]),
|
| 519 |
+
"forward": loads(r["forward"]),
|
| 520 |
+
"extra": loads(r["extra"]),
|
| 521 |
+
}
|
| 522 |
+
for r in rows
|
| 523 |
+
]
|
| 524 |
+
|
| 525 |
+
# ------------------------------------------------------------------
|
| 526 |
+
# Reference (REFERENCE tier) β static lookup documents
|
| 527 |
+
# ------------------------------------------------------------------
|
| 528 |
+
def set_reference(
|
| 529 |
+
self,
|
| 530 |
+
key: str,
|
| 531 |
+
body: str,
|
| 532 |
+
*,
|
| 533 |
+
metadata: dict[str, Any] | None = None,
|
| 534 |
+
) -> None:
|
| 535 |
+
"""Insert or update a REFERENCE-tier document.
|
| 536 |
+
|
| 537 |
+
v0.4.0: ``key`` is validated as an identifier (non-empty string, no
|
| 538 |
+
control characters, length <= 1024). Raises ValidationError on
|
| 539 |
+
rejection."""
|
| 540 |
+
validate_identifier(key, field_name="key")
|
| 541 |
+
meta_json = _check_json(metadata, "metadata") if metadata is not None else None
|
| 542 |
+
delta = len(body) + len(key) + (len(meta_json) if meta_json else 0) + 200
|
| 543 |
+
self._cap_gate.check(proposed_delta_bytes=delta)
|
| 544 |
+
with self._storage.transaction() as conn:
|
| 545 |
+
conn.execute(
|
| 546 |
+
"INSERT INTO reference_documents (tenant_id, doc_key, body, metadata) VALUES (?, ?, ?, ?) "
|
| 547 |
+
"ON CONFLICT(tenant_id, doc_key) DO UPDATE SET body = excluded.body, "
|
| 548 |
+
"metadata = excluded.metadata, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')",
|
| 549 |
+
(self._tenant_id, key, body, meta_json),
|
| 550 |
+
)
|
| 551 |
+
|
| 552 |
+
def get_reference(self, key: str) -> dict[str, Any] | None:
|
| 553 |
+
with self._storage.connection() as conn:
|
| 554 |
+
row = conn.execute(
|
| 555 |
+
"SELECT body, metadata, updated_at FROM reference_documents WHERE tenant_id = ? AND doc_key = ?",
|
| 556 |
+
(self._tenant_id, key),
|
| 557 |
+
).fetchone()
|
| 558 |
+
if row is None:
|
| 559 |
+
return None
|
| 560 |
+
return {"body": row["body"], "metadata": loads(row["metadata"]), "updated_at": row["updated_at"]}
|
| 561 |
+
|
| 562 |
+
# ------------------------------------------------------------------
|
| 563 |
+
# Archive
|
| 564 |
+
# ------------------------------------------------------------------
|
| 565 |
+
def archive_entity(self, category: str, name: str, reason: str | None = None) -> dict[str, Any]:
|
| 566 |
+
"""Move an entity to the archive table and delete from the active set.
|
| 567 |
+
|
| 568 |
+
T1-3 fix: previously this bypassed the cap-gate. A free user at
|
| 569 |
+
1.9 MB could archive their largest entities (body copied into
|
| 570 |
+
archived_entities, doubling footprint temporarily before the
|
| 571 |
+
DELETE lands) to keep writing past the 2 MB cap. Now gated on
|
| 572 |
+
the size of the body being copied + 200 bytes overhead. Reads
|
| 573 |
+
the body first so we know the actual delta. NotFoundError still
|
| 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 = ?",
|
| 581 |
+
(self._tenant_id, category, name),
|
| 582 |
+
).fetchone()
|
| 583 |
+
if preview is None:
|
| 584 |
+
raise NotFoundError(f"entity {category}/{name} not found")
|
| 585 |
+
body_bytes = len(preview["body"] or "") if preview["body"] else 0
|
| 586 |
+
# The archive insert copies the body. Delta = body + name + category
|
| 587 |
+
# + reason + ~200B SQLite/row overhead. Conservative estimate.
|
| 588 |
+
delta = body_bytes + len(name) + len(category) + len(reason or "") + 200
|
| 589 |
+
self._cap_gate.check(proposed_delta_bytes=delta)
|
| 590 |
+
|
| 591 |
+
with self._storage.transaction() as conn:
|
| 592 |
+
row = conn.execute(
|
| 593 |
+
"SELECT id, body FROM entities WHERE tenant_id = ? AND category = ? AND name = ?",
|
| 594 |
+
(self._tenant_id, category, name),
|
| 595 |
+
).fetchone()
|
| 596 |
+
if row is None:
|
| 597 |
+
raise NotFoundError(f"entity {category}/{name} not found")
|
| 598 |
+
arch_id = new_id()
|
| 599 |
+
conn.execute(
|
| 600 |
+
"INSERT INTO archived_entities (id, tenant_id, original_entity_id, category, name, body, archive_reason) "
|
| 601 |
+
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
| 602 |
+
(arch_id, self._tenant_id, row["id"], category, name, row["body"], reason),
|
| 603 |
+
)
|
| 604 |
+
conn.execute("DELETE FROM entities WHERE id = ?", (row["id"],))
|
| 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
|
| 612 |
+
# classes remain available for power users via direct import, but
|
| 613 |
+
# the documented surface is the gated convenience API.
|
| 614 |
+
|
| 615 |
+
def learner(self, **kwargs: Any):
|
| 616 |
+
"""Return a Learner bound to this client's storage + tenant.
|
| 617 |
+
|
| 618 |
+
Paid-tier only. Lazy import so the lower SDK stays usable without
|
| 619 |
+
loading the learning module. Threads the client's CapGate into
|
| 620 |
+
the Learner so accept_proposal calls go through the cap-check
|
| 621 |
+
(T1-3 fix). Callers can override cap_gate=None explicitly to
|
| 622 |
+
opt out for tests."""
|
| 623 |
+
self._require_paid_tier("self-learning")
|
| 624 |
+
from .learning import Learner
|
| 625 |
+
kwargs.setdefault("cap_gate", self._cap_gate)
|
| 626 |
+
return Learner(self._storage, tenant_id=self._tenant_id, **kwargs)
|
| 627 |
+
|
| 628 |
+
def learn(self, **kwargs: Any):
|
| 629 |
+
"""Convenience: construct a default Learner and run one pass.
|
| 630 |
+
Returns a LearningRunReport. Paid-tier only."""
|
| 631 |
+
return self.learner(**kwargs).run()
|
| 632 |
+
|
| 633 |
+
def list_skill_proposals(
|
| 634 |
+
self, *, status: str = "pending", limit: int = 50,
|
| 635 |
+
) -> list[Any]:
|
| 636 |
+
"""Paid-tier only."""
|
| 637 |
+
return self.learner().list_proposals(status=status, limit=limit)
|
| 638 |
+
|
| 639 |
+
def accept_skill_proposal(
|
| 640 |
+
self, proposal_id: str, *, note: str | None = None,
|
| 641 |
+
) -> dict[str, Any]:
|
| 642 |
+
"""Paid-tier only."""
|
| 643 |
+
return self.learner().accept_proposal(proposal_id, note=note)
|
| 644 |
+
|
| 645 |
+
def reject_skill_proposal(
|
| 646 |
+
self, proposal_id: str, *, note: str | None = None,
|
| 647 |
+
) -> dict[str, Any]:
|
| 648 |
+
"""Paid-tier only."""
|
| 649 |
+
return self.learner().reject_proposal(proposal_id, note=note)
|
| 650 |
+
|
| 651 |
+
def lint(self, **kwargs: Any):
|
| 652 |
+
"""Run the local memory linter against this tenant. Returns a
|
| 653 |
+
LintReport with `.findings`, `.counts`, `.ok`, and `.to_ascii()`.
|
| 654 |
+
|
| 655 |
+
Paid-tier only. Free-tier callers raise TierGateError pointing at
|
| 656 |
+
the upgrade page.
|
| 657 |
+
"""
|
| 658 |
+
self._require_paid_tier("memory linter")
|
| 659 |
+
from .lint import Linter
|
| 660 |
+
# If the caller didn't supply soft_cap_bytes, look up by tier
|
| 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 |
+
"""
|
| 678 |
+
from .lint import TIER_SOFT_CAPS, DEFAULT_SOFT_CAP_BYTES
|
| 679 |
+
from pathlib import Path
|
| 680 |
+
db_size = Path(self._storage.db_path).stat().st_size if Path(self._storage.db_path).exists() else 0
|
| 681 |
+
cap = TIER_SOFT_CAPS.get(self._tier, DEFAULT_SOFT_CAP_BYTES)
|
| 682 |
+
# Paid tier β no cap
|
| 683 |
+
if cap is None:
|
| 684 |
+
return {
|
| 685 |
+
"tier": self._tier,
|
| 686 |
+
"db_size_bytes": db_size,
|
| 687 |
+
"soft_cap_bytes": None,
|
| 688 |
+
"pct_used": None,
|
| 689 |
+
"uncapped": True,
|
| 690 |
+
}
|
| 691 |
+
return {
|
| 692 |
+
"tier": self._tier,
|
| 693 |
+
"db_size_bytes": db_size,
|
| 694 |
+
"soft_cap_bytes": cap,
|
| 695 |
+
"pct_used": db_size / cap if cap else None,
|
| 696 |
+
"uncapped": False,
|
| 697 |
+
"at_or_above_warning": db_size >= 0.8 * cap,
|
| 698 |
+
"at_or_above_cap": db_size >= cap,
|
| 699 |
+
"upgrade_url": "https://sibyllabs.org/plugin#tier",
|
| 700 |
+
}
|
| 701 |
+
|
| 702 |
+
# ------------------------------------------------------------------
|
| 703 |
+
# FTS5 search
|
| 704 |
+
# ------------------------------------------------------------------
|
| 705 |
+
def search_entities(self, query: str, *, limit: int = 20, prefix: bool = False) -> list[dict[str, Any]]:
|
| 706 |
+
"""Full-text search over entity name + category + body via FTS5.
|
| 707 |
+
|
| 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 |
+
|
| 715 |
+
Returns: list of entity rows. Each row is a dict with keys
|
| 716 |
+
id, tenant_id, category, name, status, body, created_at, updated_at
|
| 717 |
+
(body is JSON-deserialized).
|
| 718 |
+
|
| 719 |
+
Raises: StorageError on backend failure; empty list on empty / invalid query.
|
| 720 |
+
"""
|
| 721 |
+
match_q = _sanitize_fts5_query(query, prefix=prefix)
|
| 722 |
+
if not match_q:
|
| 723 |
+
return []
|
| 724 |
+
# external-content FTS5: join by rowid back to base table
|
| 725 |
+
with self._storage.connection() as conn:
|
| 726 |
+
try:
|
| 727 |
+
rows = conn.execute(
|
| 728 |
+
"SELECT e.id, e.tenant_id, e.category, e.name, e.status, e.body, e.created_at, e.updated_at "
|
| 729 |
+
"FROM entities_fts f "
|
| 730 |
+
"JOIN entities e ON e.rowid = f.rowid "
|
| 731 |
+
"WHERE entities_fts MATCH ? AND f.tenant_id = ? "
|
| 732 |
+
"ORDER BY rank LIMIT ?",
|
| 733 |
+
(match_q, self._tenant_id, limit),
|
| 734 |
+
).fetchall()
|
| 735 |
+
except sqlite3.OperationalError as e:
|
| 736 |
+
# v0.4.0 (KAPPA YELLOW finding): classify the error instead
|
| 737 |
+
# of silently returning []. Schema-missing β empty (defense
|
| 738 |
+
# against partial init). FTS5 syntax β ValidationError.
|
| 739 |
+
# Real backend β StorageError. Re-raise via classifier.
|
| 740 |
+
exc = _classify_fts5_error(e)
|
| 741 |
+
if exc is None:
|
| 742 |
+
return []
|
| 743 |
+
raise exc from e
|
| 744 |
+
return [self._row_to_entity(r) for r in rows]
|
| 745 |
+
|
| 746 |
+
def search(self, query: str, *, limit: int = 20, prefix: bool = False,
|
| 747 |
+
tiers: tuple[str, ...] | None = None) -> list[dict[str, Any]]:
|
| 748 |
+
"""Cross-tier full-text search over entities + state + reference + journal.
|
| 749 |
+
|
| 750 |
+
Each hit is tier-tagged so callers know which tier surfaced the match.
|
| 751 |
+
|
| 752 |
+
Returns: list of dicts shaped:
|
| 753 |
+
{
|
| 754 |
+
"tier": "entity" | "state" | "reference" | "journal",
|
| 755 |
+
"key": <entity name | state key | doc_key | journal id>,
|
| 756 |
+
"category": <entity category or None>,
|
| 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
|
| 764 |
+
globally (combined across tiers). Pass ``tiers=("entity", "state")``
|
| 765 |
+
to restrict.
|
| 766 |
+
|
| 767 |
+
Query is sanitized as a single FTS5 phrase (see ``search_entities``
|
| 768 |
+
notes). Empty / invalid queries return [].
|
| 769 |
+
|
| 770 |
+
Raises: StorageError on backend failure.
|
| 771 |
+
"""
|
| 772 |
+
match_q = _sanitize_fts5_query(query, prefix=prefix)
|
| 773 |
+
if not match_q:
|
| 774 |
+
return []
|
| 775 |
+
allowed = set(tiers) if tiers else {"entity", "state", "reference", "journal"}
|
| 776 |
+
hits: list[dict[str, Any]] = []
|
| 777 |
+
with self._storage.connection() as conn:
|
| 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:
|
| 785 |
+
for r in conn.execute(
|
| 786 |
+
"SELECT 'entity' AS tier, e.name AS key, e.category, e.body, "
|
| 787 |
+
" e.updated_at AS ts, "
|
| 788 |
+
" snippet(entities_fts, 2, '[', ']', '...', 12) AS snip, "
|
| 789 |
+
" rank "
|
| 790 |
+
"FROM entities_fts f JOIN entities e ON e.rowid = f.rowid "
|
| 791 |
+
"WHERE entities_fts MATCH ? AND f.tenant_id = ? "
|
| 792 |
+
"ORDER BY rank LIMIT ?",
|
| 793 |
+
(match_q, self._tenant_id, limit),
|
| 794 |
+
).fetchall():
|
| 795 |
+
hits.append({
|
| 796 |
+
"tier": "entity", "key": r["key"],
|
| 797 |
+
"category": r["category"],
|
| 798 |
+
"body": loads(r["body"]), "snippet": r["snip"],
|
| 799 |
+
"rank": r["rank"], "ts": r["ts"],
|
| 800 |
+
})
|
| 801 |
+
except sqlite3.OperationalError as e:
|
| 802 |
+
exc = _classify_fts5_error(e)
|
| 803 |
+
if exc is not None:
|
| 804 |
+
raise exc from e
|
| 805 |
+
if "state" in allowed:
|
| 806 |
+
try:
|
| 807 |
+
for r in conn.execute(
|
| 808 |
+
"SELECT 'state' AS tier, s.document_key AS key, s.body, "
|
| 809 |
+
" s.updated_at AS ts, "
|
| 810 |
+
" snippet(state_documents_fts, 1, '[', ']', '...', 12) AS snip, "
|
| 811 |
+
" rank "
|
| 812 |
+
"FROM state_documents_fts f JOIN state_documents s "
|
| 813 |
+
" ON s.rowid = f.rowid "
|
| 814 |
+
"WHERE state_documents_fts MATCH ? AND f.tenant_id = ? "
|
| 815 |
+
"ORDER BY rank LIMIT ?",
|
| 816 |
+
(match_q, self._tenant_id, limit),
|
| 817 |
+
).fetchall():
|
| 818 |
+
hits.append({
|
| 819 |
+
"tier": "state", "key": r["key"], "category": None,
|
| 820 |
+
"body": loads(r["body"]), "snippet": r["snip"],
|
| 821 |
+
"rank": r["rank"], "ts": r["ts"],
|
| 822 |
+
})
|
| 823 |
+
except sqlite3.OperationalError as e:
|
| 824 |
+
exc = _classify_fts5_error(e)
|
| 825 |
+
if exc is not None:
|
| 826 |
+
raise exc from e
|
| 827 |
+
if "reference" in allowed:
|
| 828 |
+
try:
|
| 829 |
+
for r in conn.execute(
|
| 830 |
+
"SELECT 'reference' AS tier, d.doc_key AS key, d.body, "
|
| 831 |
+
" d.updated_at AS ts, "
|
| 832 |
+
" snippet(reference_documents_fts, 1, '[', ']', '...', 12) AS snip, "
|
| 833 |
+
" rank "
|
| 834 |
+
"FROM reference_documents_fts f JOIN reference_documents d "
|
| 835 |
+
" ON d.rowid = f.rowid "
|
| 836 |
+
"WHERE reference_documents_fts MATCH ? AND f.tenant_id = ? "
|
| 837 |
+
"ORDER BY rank LIMIT ?",
|
| 838 |
+
(match_q, self._tenant_id, limit),
|
| 839 |
+
).fetchall():
|
| 840 |
+
hits.append({
|
| 841 |
+
"tier": "reference", "key": r["key"], "category": None,
|
| 842 |
+
"body": r["body"], "snippet": r["snip"],
|
| 843 |
+
"rank": r["rank"], "ts": r["ts"],
|
| 844 |
+
})
|
| 845 |
+
except sqlite3.OperationalError as e:
|
| 846 |
+
exc = _classify_fts5_error(e)
|
| 847 |
+
if exc is not None:
|
| 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(
|
| 855 |
+
"SELECT 'journal' AS tier, j.id AS key, j.ts, "
|
| 856 |
+
" j.evaluated, j.acted, j.forward, j.extra, "
|
| 857 |
+
" snippet(journal_events_fts, 1, '[', ']', '...', 12) AS snip, "
|
| 858 |
+
" f.rank AS rank "
|
| 859 |
+
"FROM journal_events_fts f JOIN journal_events j "
|
| 860 |
+
" ON j.id = f.event_id "
|
| 861 |
+
"WHERE journal_events_fts MATCH ? AND f.tenant_id = ? "
|
| 862 |
+
"ORDER BY f.rank LIMIT ?",
|
| 863 |
+
(match_q, self._tenant_id, limit),
|
| 864 |
+
).fetchall():
|
| 865 |
+
hits.append({
|
| 866 |
+
"tier": "journal", "key": r["key"], "category": None,
|
| 867 |
+
"body": {
|
| 868 |
+
"evaluated": loads(r["evaluated"]),
|
| 869 |
+
"acted": loads(r["acted"]),
|
| 870 |
+
"forward": loads(r["forward"]),
|
| 871 |
+
"extra": loads(r["extra"]),
|
| 872 |
+
},
|
| 873 |
+
"snippet": r["snip"], "rank": r["rank"], "ts": r["ts"],
|
| 874 |
+
})
|
| 875 |
+
except sqlite3.OperationalError as e:
|
| 876 |
+
exc = _classify_fts5_error(e)
|
| 877 |
+
if exc is not None:
|
| 878 |
+
raise exc from e
|
| 879 |
+
# Sort by rank (lower = better in FTS5) and apply global limit
|
| 880 |
+
hits.sort(key=lambda h: h["rank"])
|
| 881 |
+
return hits[:limit]
|
| 882 |
+
|
| 883 |
+
# ------------------------------------------------------------------
|
| 884 |
+
# Helpers
|
| 885 |
+
# ------------------------------------------------------------------
|
| 886 |
+
def _row_to_entity(self, row: sqlite3.Row) -> dict[str, Any]:
|
| 887 |
+
return {
|
| 888 |
+
"id": row["id"],
|
| 889 |
+
"tenant_id": row["tenant_id"],
|
| 890 |
+
"category": row["category"],
|
| 891 |
+
"name": row["name"],
|
| 892 |
+
"status": row["status"],
|
| 893 |
+
"body": loads(row["body"]),
|
| 894 |
+
"created_at": row["created_at"],
|
| 895 |
+
"updated_at": row["updated_at"],
|
| 896 |
+
}
|
sibyl-memory-client/src/sibyl_memory_client/exceptions.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Typed exception hierarchy for sibyl-memory-client.
|
| 2 |
+
|
| 3 |
+
Every error has a stable `code` for programmatic handling and a `recovery`
|
| 4 |
+
string suggesting what the caller should try next.
|
| 5 |
+
|
| 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
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
# Default upgrade URL for cap / tier-related errors. Kept here as a string
|
| 16 |
+
# literal so the exceptions module has no dependency on _capcheck (avoids the
|
| 17 |
+
# circular import that motivated the v0.4.0 reorganization).
|
| 18 |
+
_DEFAULT_UPGRADE_URL = "https://docs.sibyllabs.org/memory/tiers"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class SibylMemoryError(Exception):
|
| 22 |
+
"""Base for all sibyl-memory-client errors."""
|
| 23 |
+
|
| 24 |
+
code: str = "SIBYL_MEMORY_ERROR"
|
| 25 |
+
recovery: str = "See exception message for details."
|
| 26 |
+
|
| 27 |
+
def __init__(self, message: str, *, recovery: str | None = None) -> None:
|
| 28 |
+
super().__init__(message)
|
| 29 |
+
if recovery is not None:
|
| 30 |
+
self.recovery = recovery
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class StorageError(SibylMemoryError):
|
| 34 |
+
code = "STORAGE_ERROR"
|
| 35 |
+
recovery = "Check disk space and file permissions on ~/.sibyl-memory/."
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class SchemaError(SibylMemoryError):
|
| 39 |
+
code = "SCHEMA_ERROR"
|
| 40 |
+
recovery = "The schema file is missing or corrupt. Re-install sibyl-memory-client."
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class TenantError(SibylMemoryError):
|
| 44 |
+
code = "TENANT_ERROR"
|
| 45 |
+
recovery = "Set a tenant before calling write/read operations: client.set_tenant(uuid)."
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class NotFoundError(SibylMemoryError):
|
| 49 |
+
code = "NOT_FOUND"
|
| 50 |
+
recovery = "The requested entity / state / reference does not exist."
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class ConflictError(SibylMemoryError):
|
| 54 |
+
code = "CONFLICT"
|
| 55 |
+
recovery = "An entity with this (tenant_id, category, name) already exists. Use update_entity() instead."
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class ValidationError(SibylMemoryError):
|
| 59 |
+
code = "VALIDATION_ERROR"
|
| 60 |
+
recovery = "Body must be a JSON-serializable dict / list / primitive."
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class TierGateError(SibylMemoryError):
|
| 64 |
+
"""Raised when a free-tier user invokes a paid-tier-only feature.
|
| 65 |
+
|
| 66 |
+
Carries the user's current tier + an upgrade URL so callers can render
|
| 67 |
+
a clean prompt. Self-learning + memory linter are both gated by this
|
| 68 |
+
on the free tier; upgrading to any paid tier unlocks both.
|
| 69 |
+
"""
|
| 70 |
+
|
| 71 |
+
code = "TIER_GATE"
|
| 72 |
+
recovery = (
|
| 73 |
+
"Upgrade your plugin tier to unlock this feature. See "
|
| 74 |
+
"https://sibyllabs.org/plugin#tier for options "
|
| 75 |
+
"(Sibyl Stake / Sync / Lifetime / Enterprise)."
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
def __init__(
|
| 79 |
+
self,
|
| 80 |
+
message: str,
|
| 81 |
+
*,
|
| 82 |
+
feature: str,
|
| 83 |
+
current_tier: str = "free",
|
| 84 |
+
upgrade_url: str = "https://sibyllabs.org/plugin#tier",
|
| 85 |
+
) -> None:
|
| 86 |
+
super().__init__(message)
|
| 87 |
+
self.feature = feature
|
| 88 |
+
self.current_tier = current_tier
|
| 89 |
+
self.upgrade_url = upgrade_url
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
class CapExceededError(SibylMemoryError):
|
| 93 |
+
"""Raised when a free-tier user tries to write past the 2 MB cap.
|
| 94 |
+
|
| 95 |
+
Carries the upgrade URL so callers (CLIs, IDEs, agent frameworks) can
|
| 96 |
+
render a clean upgrade prompt.
|
| 97 |
+
|
| 98 |
+
v0.4.0: moved from `_capcheck.py` to `exceptions.py` so the canonical
|
| 99 |
+
`sibyl_memory_client.exceptions` submodule path exports it. The class
|
| 100 |
+
contract (code, recovery, current_size, cap, proposed_delta, upgrade_url
|
| 101 |
+
attributes) is unchanged.
|
| 102 |
+
"""
|
| 103 |
+
|
| 104 |
+
code = "CAP_EXCEEDED"
|
| 105 |
+
recovery = (
|
| 106 |
+
"Upgrade to remove the 2 MB cap. See "
|
| 107 |
+
"https://docs.sibyllabs.org/memory/tiers for options "
|
| 108 |
+
"(Sibyl Stake / Sync / Lifetime / Enterprise)."
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
def __init__(
|
| 112 |
+
self,
|
| 113 |
+
message: str,
|
| 114 |
+
*,
|
| 115 |
+
current_size: int,
|
| 116 |
+
cap: int,
|
| 117 |
+
proposed_delta: int = 0,
|
| 118 |
+
upgrade_url: str = _DEFAULT_UPGRADE_URL,
|
| 119 |
+
) -> None:
|
| 120 |
+
super().__init__(message)
|
| 121 |
+
self.current_size = current_size
|
| 122 |
+
self.cap = cap
|
| 123 |
+
self.proposed_delta = proposed_delta
|
| 124 |
+
self.upgrade_url = upgrade_url
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
class TierVerificationError(SibylMemoryError):
|
| 128 |
+
"""Raised when the SDK can't verify the user's tier and has no cached
|
| 129 |
+
grace period to fall back on (offline at the cap with no recent
|
| 130 |
+
successful check).
|
| 131 |
+
|
| 132 |
+
v0.4.0: moved from `_capcheck.py` to `exceptions.py` so the canonical
|
| 133 |
+
`sibyl_memory_client.exceptions` submodule path exports it. The class
|
| 134 |
+
contract (code, recovery) is unchanged.
|
| 135 |
+
"""
|
| 136 |
+
|
| 137 |
+
code = "TIER_VERIFY_FAILED"
|
| 138 |
+
recovery = (
|
| 139 |
+
"Connect to the internet so the SDK can verify your account, or "
|
| 140 |
+
"stay under the 2 MB free-tier cap until you're online."
|
| 141 |
+
)
|
sibyl-memory-client/src/sibyl_memory_client/learning.py
ADDED
|
@@ -0,0 +1,925 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Self-learning module for sibyl-memory-client.
|
| 2 |
+
|
| 3 |
+
Mirrors the way SIBYL accumulates session memory into reusable skills:
|
| 4 |
+
scan the journal for repeating patterns, abstract them into structured
|
| 5 |
+
skill documents, and queue the proposals for user review.
|
| 6 |
+
|
| 7 |
+
THREE RUNTIME MODES (operator directive 2026-05-15)
|
| 8 |
+
===================================================
|
| 9 |
+
|
| 10 |
+
1. **local-deterministic** (default, free tier)
|
| 11 |
+
Pure SQL + Python pattern detectors. No network, no LLM. Preserves the
|
| 12 |
+
strict local-first promise. Produces skill bodies via deterministic
|
| 13 |
+
templates from the matched event group.
|
| 14 |
+
|
| 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 |
+
|
| 22 |
+
3. **venice-x402** (paid-tier hosted, value-add for Venice partnership)
|
| 23 |
+
User pre-funds their plugin account with FIAT or USDC. Sibyl Labs
|
| 24 |
+
auto-routes inference via Venice + x402 against the user's funded
|
| 25 |
+
balance from Sibyl's own infrastructure. Highest convenience, only
|
| 26 |
+
the prompt summary leaves the device (never the underlying memory
|
| 27 |
+
content). The Venice/x402 endpoint design is captured in the memo
|
| 28 |
+
`memory/research/2026-05-15-self-learning-design.md`.
|
| 29 |
+
|
| 30 |
+
WHAT GETS DETECTED
|
| 31 |
+
==================
|
| 32 |
+
|
| 33 |
+
Four pattern kinds in v0.2.0:
|
| 34 |
+
|
| 35 |
+
| pattern_kind | what it catches |
|
| 36 |
+
|-------------------------|------------------------------------------------|
|
| 37 |
+
| repeated_action | same/similar `acted` payload across N events |
|
| 38 |
+
| structural_similarity | journal events with overlapping evaluated keys|
|
| 39 |
+
| temporal_routine | events that fire at a stable cadence |
|
| 40 |
+
| co_occurrence | entities + actions that consistently appear |
|
| 41 |
+
| | together in the same journal entries |
|
| 42 |
+
|
| 43 |
+
Pattern detection is intentionally simple and explainable. Sophisticated
|
| 44 |
+
embedding-based clustering can land in v0.3.0 as an optional add-on.
|
| 45 |
+
|
| 46 |
+
REVIEW QUEUE
|
| 47 |
+
============
|
| 48 |
+
|
| 49 |
+
Detected patterns land in `skill_proposals` with status='pending'. The
|
| 50 |
+
public API exposes:
|
| 51 |
+
|
| 52 |
+
list_proposals(status='pending', limit=N)
|
| 53 |
+
accept_proposal(proposal_id, note=None) β writes to reference_documents
|
| 54 |
+
reject_proposal(proposal_id, note=None)
|
| 55 |
+
get_proposal(proposal_id)
|
| 56 |
+
|
| 57 |
+
Accepted proposals create `reference_documents` rows keyed `skill/<slug>`.
|
| 58 |
+
"""
|
| 59 |
+
from __future__ import annotations
|
| 60 |
+
|
| 61 |
+
import json
|
| 62 |
+
import re
|
| 63 |
+
import uuid
|
| 64 |
+
from collections import Counter, defaultdict
|
| 65 |
+
from dataclasses import dataclass, field
|
| 66 |
+
from typing import Any, Callable, Iterable, Protocol
|
| 67 |
+
|
| 68 |
+
from .client import DEFAULT_TENANT
|
| 69 |
+
from .exceptions import NotFoundError, ValidationError
|
| 70 |
+
from .storage import Storage, _utc_now_iso, dumps, loads, new_id
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
# ----------------------------------------------------------------------
|
| 74 |
+
# Public API surface
|
| 75 |
+
# ----------------------------------------------------------------------
|
| 76 |
+
|
| 77 |
+
@dataclass(frozen=True)
|
| 78 |
+
class SkillProposal:
|
| 79 |
+
"""Immutable view of a row in skill_proposals."""
|
| 80 |
+
id: str
|
| 81 |
+
tenant_id: str
|
| 82 |
+
pattern_kind: str
|
| 83 |
+
proposed_slug: str
|
| 84 |
+
proposed_title: str | None
|
| 85 |
+
proposed_body: str
|
| 86 |
+
evidence: list[dict[str, Any]]
|
| 87 |
+
confidence: float
|
| 88 |
+
summarizer: str
|
| 89 |
+
status: str
|
| 90 |
+
created_at: str
|
| 91 |
+
reviewed_at: str | None = None
|
| 92 |
+
review_note: str | None = None
|
| 93 |
+
accepted_doc_key: str | None = None
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
@dataclass
|
| 97 |
+
class LearningRunReport:
|
| 98 |
+
"""Per-invocation summary returned by Learner.run()."""
|
| 99 |
+
run_id: str
|
| 100 |
+
events_scanned: int
|
| 101 |
+
proposals_made: int
|
| 102 |
+
proposal_ids: list[str] = field(default_factory=list)
|
| 103 |
+
started_at: str = ""
|
| 104 |
+
completed_at: str = ""
|
| 105 |
+
summarizer: str = ""
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
class Summarizer(Protocol):
|
| 109 |
+
"""Pluggable interface for converting a detected pattern into prose.
|
| 110 |
+
|
| 111 |
+
Implementations must be synchronous and side-effect-free with respect
|
| 112 |
+
to the local SQLite database. The Learner handles all persistence.
|
| 113 |
+
"""
|
| 114 |
+
|
| 115 |
+
name: str
|
| 116 |
+
|
| 117 |
+
def summarize(
|
| 118 |
+
self,
|
| 119 |
+
pattern_kind: str,
|
| 120 |
+
events: list[dict[str, Any]],
|
| 121 |
+
hints: dict[str, Any],
|
| 122 |
+
) -> tuple[str, str | None]:
|
| 123 |
+
"""Return (body_markdown, title_or_None) for the proposal."""
|
| 124 |
+
...
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
# ----------------------------------------------------------------------
|
| 128 |
+
# Local-deterministic summarizer (free-tier default)
|
| 129 |
+
# ----------------------------------------------------------------------
|
| 130 |
+
|
| 131 |
+
class LocalDeterministicSummarizer:
|
| 132 |
+
"""Generates skill bodies via templates, no LLM call.
|
| 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 |
+
"""
|
| 140 |
+
|
| 141 |
+
name = "local-deterministic"
|
| 142 |
+
|
| 143 |
+
def summarize(
|
| 144 |
+
self,
|
| 145 |
+
pattern_kind: str,
|
| 146 |
+
events: list[dict[str, Any]],
|
| 147 |
+
hints: dict[str, Any],
|
| 148 |
+
) -> tuple[str, str | None]:
|
| 149 |
+
title = hints.get("title") or _slug_to_title(hints.get("slug", pattern_kind))
|
| 150 |
+
lines: list[str] = []
|
| 151 |
+
lines.append(f"# {title}")
|
| 152 |
+
lines.append("")
|
| 153 |
+
lines.append(f"_Auto-detected from {len(events)} matching journal events._")
|
| 154 |
+
lines.append("")
|
| 155 |
+
lines.append("## Pattern")
|
| 156 |
+
lines.append("")
|
| 157 |
+
if pattern_kind == "repeated_action":
|
| 158 |
+
sample = hints.get("action_signature") or "(no action signature)"
|
| 159 |
+
lines.append(f"Recurring action: `{sample}`")
|
| 160 |
+
elif pattern_kind == "structural_similarity":
|
| 161 |
+
keys = ", ".join(hints.get("shared_keys", []) or [])
|
| 162 |
+
lines.append(f"Events consistently include input keys: `{keys}`")
|
| 163 |
+
elif pattern_kind == "temporal_routine":
|
| 164 |
+
cadence = hints.get("cadence_minutes")
|
| 165 |
+
lines.append(
|
| 166 |
+
f"Events fire at roughly stable cadence "
|
| 167 |
+
f"(~{cadence} min between occurrences)."
|
| 168 |
+
if cadence
|
| 169 |
+
else "Events fire at a stable cadence."
|
| 170 |
+
)
|
| 171 |
+
elif pattern_kind == "co_occurrence":
|
| 172 |
+
pair = hints.get("pair") or ("", "")
|
| 173 |
+
lines.append(
|
| 174 |
+
f"`{pair[0]}` and `{pair[1]}` consistently appear together in "
|
| 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")
|
| 182 |
+
lines.append("")
|
| 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("")
|
| 190 |
+
lines.append("## Suggested use")
|
| 191 |
+
lines.append("")
|
| 192 |
+
lines.append(
|
| 193 |
+
"Reference this skill when the same situation recurs. "
|
| 194 |
+
"Edit, accept, or reject via `sibyl learn review`."
|
| 195 |
+
)
|
| 196 |
+
return "\n".join(lines), title
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
# ----------------------------------------------------------------------
|
| 200 |
+
# BYOK summarizer stub (paid-tier opt-in)
|
| 201 |
+
# ----------------------------------------------------------------------
|
| 202 |
+
|
| 203 |
+
class BYOKSummarizer:
|
| 204 |
+
"""User-supplied-key summarizer.
|
| 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
|
| 212 |
+
check happens upstream). v0.2.0 ships the wiring; the CLI gate
|
| 213 |
+
enforces it.
|
| 214 |
+
"""
|
| 215 |
+
|
| 216 |
+
def __init__(
|
| 217 |
+
self,
|
| 218 |
+
inference_fn: Callable[[str], str],
|
| 219 |
+
*,
|
| 220 |
+
provider_label: str = "byok",
|
| 221 |
+
) -> None:
|
| 222 |
+
self._inference_fn = inference_fn
|
| 223 |
+
self.name = f"byok-{provider_label}"
|
| 224 |
+
|
| 225 |
+
def summarize(
|
| 226 |
+
self,
|
| 227 |
+
pattern_kind: str,
|
| 228 |
+
events: list[dict[str, Any]],
|
| 229 |
+
hints: dict[str, Any],
|
| 230 |
+
) -> tuple[str, str | None]:
|
| 231 |
+
prompt = _build_summarization_prompt(pattern_kind, events, hints)
|
| 232 |
+
try:
|
| 233 |
+
body = self._inference_fn(prompt)
|
| 234 |
+
except Exception as e: # pragma: no cover
|
| 235 |
+
# Fall back to deterministic if the user's key fails
|
| 236 |
+
fallback = LocalDeterministicSummarizer()
|
| 237 |
+
body, title = fallback.summarize(pattern_kind, events, hints)
|
| 238 |
+
return body + f"\n\n---\n_Note: BYOK call failed ({e}). Using local fallback._", title
|
| 239 |
+
title = hints.get("title") or _slug_to_title(hints.get("slug", pattern_kind))
|
| 240 |
+
return body, title
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
# ----------------------------------------------------------------------
|
| 244 |
+
# Venice + x402 routed summarizer stub (paid-tier hosted)
|
| 245 |
+
# ----------------------------------------------------------------------
|
| 246 |
+
|
| 247 |
+
class VeniceX402Summarizer:
|
| 248 |
+
"""Routes inference through Venice via x402 against the user's
|
| 249 |
+
pre-funded Sibyl Labs plugin balance.
|
| 250 |
+
|
| 251 |
+
The actual network call lives behind `inference_fn` so this module
|
| 252 |
+
stays HTTP-library-free. The CLI layer (sibyl-labs-cli) provides
|
| 253 |
+
the real fn that signs an x402 payment header, hits the Sibyl
|
| 254 |
+
Labs inference proxy (planned: `POST /api/plugin/inference`), and
|
| 255 |
+
returns the Venice-routed completion.
|
| 256 |
+
|
| 257 |
+
Endpoint design recorded in
|
| 258 |
+
`memory/research/2026-05-15-self-learning-design.md`.
|
| 259 |
+
"""
|
| 260 |
+
|
| 261 |
+
name = "venice-x402"
|
| 262 |
+
|
| 263 |
+
def __init__(
|
| 264 |
+
self,
|
| 265 |
+
inference_fn: Callable[[str], str],
|
| 266 |
+
*,
|
| 267 |
+
account_id: str,
|
| 268 |
+
) -> None:
|
| 269 |
+
self._inference_fn = inference_fn
|
| 270 |
+
self._account_id = account_id
|
| 271 |
+
|
| 272 |
+
def summarize(
|
| 273 |
+
self,
|
| 274 |
+
pattern_kind: str,
|
| 275 |
+
events: list[dict[str, Any]],
|
| 276 |
+
hints: dict[str, Any],
|
| 277 |
+
) -> tuple[str, str | None]:
|
| 278 |
+
prompt = _build_summarization_prompt(pattern_kind, events, hints)
|
| 279 |
+
try:
|
| 280 |
+
body = self._inference_fn(prompt)
|
| 281 |
+
except Exception as e: # pragma: no cover
|
| 282 |
+
fallback = LocalDeterministicSummarizer()
|
| 283 |
+
body, title = fallback.summarize(pattern_kind, events, hints)
|
| 284 |
+
return body + f"\n\n---\n_Note: Venice/x402 call failed ({e}). Using local fallback._", title
|
| 285 |
+
title = hints.get("title") or _slug_to_title(hints.get("slug", pattern_kind))
|
| 286 |
+
return body, title
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
# ----------------------------------------------------------------------
|
| 290 |
+
# Learner β orchestrates detection + summarization + persistence
|
| 291 |
+
# ----------------------------------------------------------------------
|
| 292 |
+
|
| 293 |
+
class Learner:
|
| 294 |
+
"""Periodic learning loop. Reads journal, writes skill proposals.
|
| 295 |
+
|
| 296 |
+
Args:
|
| 297 |
+
storage: the live Storage instance
|
| 298 |
+
tenant_id: which tenant's journal to scan
|
| 299 |
+
summarizer: pluggable summarizer (defaults to local-deterministic)
|
| 300 |
+
min_pattern_hits: minimum matched events to surface a pattern
|
| 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 |
+
"""
|
| 308 |
+
|
| 309 |
+
def __init__(
|
| 310 |
+
self,
|
| 311 |
+
storage: Storage,
|
| 312 |
+
*,
|
| 313 |
+
tenant_id: str = DEFAULT_TENANT,
|
| 314 |
+
summarizer: Summarizer | None = None,
|
| 315 |
+
min_pattern_hits: int = 3,
|
| 316 |
+
max_proposals_per_run: int = 20,
|
| 317 |
+
cap_gate: Any = None,
|
| 318 |
+
) -> None:
|
| 319 |
+
self._storage = storage
|
| 320 |
+
self._tenant_id = tenant_id
|
| 321 |
+
self._summarizer = summarizer or LocalDeterministicSummarizer()
|
| 322 |
+
self._min_hits = max(2, min_pattern_hits)
|
| 323 |
+
self._max_per_run = max(1, max_proposals_per_run)
|
| 324 |
+
self._cap_gate = cap_gate
|
| 325 |
+
|
| 326 |
+
# ------------------------------------------------------------------
|
| 327 |
+
# Public entry points
|
| 328 |
+
# ------------------------------------------------------------------
|
| 329 |
+
def run(self, *, since: str | None = None) -> LearningRunReport:
|
| 330 |
+
"""Scan journal events since the last watermark and propose skills."""
|
| 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)
|
| 338 |
+
|
| 339 |
+
# Skip detection entirely if there's nothing new
|
| 340 |
+
proposal_ids: list[str] = []
|
| 341 |
+
if scanned == 0:
|
| 342 |
+
self._log_run(
|
| 343 |
+
run_id=run_id,
|
| 344 |
+
started_at=started_at,
|
| 345 |
+
completed_at=_utc_now_iso(),
|
| 346 |
+
events_scanned=0,
|
| 347 |
+
proposals_made=0,
|
| 348 |
+
cursor_after_ts=since_ts,
|
| 349 |
+
notes="no new events since last run",
|
| 350 |
+
)
|
| 351 |
+
return LearningRunReport(
|
| 352 |
+
run_id=run_id,
|
| 353 |
+
events_scanned=0,
|
| 354 |
+
proposals_made=0,
|
| 355 |
+
proposal_ids=[],
|
| 356 |
+
started_at=started_at,
|
| 357 |
+
completed_at=_utc_now_iso(),
|
| 358 |
+
summarizer=self._summarizer.name,
|
| 359 |
+
)
|
| 360 |
+
|
| 361 |
+
# Run detectors, accumulate candidate proposals
|
| 362 |
+
candidates: list[_Candidate] = []
|
| 363 |
+
candidates.extend(_detect_repeated_actions(events, min_hits=self._min_hits))
|
| 364 |
+
candidates.extend(_detect_structural_similarity(events, min_hits=self._min_hits))
|
| 365 |
+
candidates.extend(_detect_co_occurrence(events, min_hits=self._min_hits))
|
| 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)
|
| 373 |
+
if existing is None or c.confidence > existing.confidence:
|
| 374 |
+
deduped[c.slug] = c
|
| 375 |
+
|
| 376 |
+
# Cap, sort by confidence
|
| 377 |
+
ranked = sorted(deduped.values(), key=lambda c: -c.confidence)[: self._max_per_run]
|
| 378 |
+
|
| 379 |
+
# Skip ones that already exist as pending proposals (same tenant, same slug)
|
| 380 |
+
existing_slugs = self._pending_slugs()
|
| 381 |
+
ranked = [c for c in ranked if c.slug not in existing_slugs]
|
| 382 |
+
|
| 383 |
+
# Persist
|
| 384 |
+
for c in ranked:
|
| 385 |
+
body, title = self._summarizer.summarize(c.kind, c.events, c.hints)
|
| 386 |
+
pid = self._insert_proposal(c, body=body, title=title)
|
| 387 |
+
proposal_ids.append(pid)
|
| 388 |
+
|
| 389 |
+
# Watermark
|
| 390 |
+
cursor_after = max((ev.get("ts") or "") for ev in events) or since_ts
|
| 391 |
+
|
| 392 |
+
self._log_run(
|
| 393 |
+
run_id=run_id,
|
| 394 |
+
started_at=started_at,
|
| 395 |
+
completed_at=_utc_now_iso(),
|
| 396 |
+
events_scanned=scanned,
|
| 397 |
+
proposals_made=len(proposal_ids),
|
| 398 |
+
cursor_after_ts=cursor_after,
|
| 399 |
+
notes=None,
|
| 400 |
+
)
|
| 401 |
+
|
| 402 |
+
return LearningRunReport(
|
| 403 |
+
run_id=run_id,
|
| 404 |
+
events_scanned=scanned,
|
| 405 |
+
proposals_made=len(proposal_ids),
|
| 406 |
+
proposal_ids=proposal_ids,
|
| 407 |
+
started_at=started_at,
|
| 408 |
+
completed_at=_utc_now_iso(),
|
| 409 |
+
summarizer=self._summarizer.name,
|
| 410 |
+
)
|
| 411 |
+
|
| 412 |
+
def list_proposals(
|
| 413 |
+
self,
|
| 414 |
+
*,
|
| 415 |
+
status: str = "pending",
|
| 416 |
+
limit: int = 50,
|
| 417 |
+
) -> list[SkillProposal]:
|
| 418 |
+
with self._storage.connection() as conn:
|
| 419 |
+
rows = conn.execute(
|
| 420 |
+
"SELECT * FROM skill_proposals "
|
| 421 |
+
"WHERE tenant_id = ? AND status = ? "
|
| 422 |
+
"ORDER BY confidence DESC, created_at DESC LIMIT ?",
|
| 423 |
+
(self._tenant_id, status, limit),
|
| 424 |
+
).fetchall()
|
| 425 |
+
return [_row_to_proposal(r) for r in rows]
|
| 426 |
+
|
| 427 |
+
def get_proposal(self, proposal_id: str) -> SkillProposal:
|
| 428 |
+
with self._storage.connection() as conn:
|
| 429 |
+
row = conn.execute(
|
| 430 |
+
"SELECT * FROM skill_proposals WHERE id = ? AND tenant_id = ?",
|
| 431 |
+
(proposal_id, self._tenant_id),
|
| 432 |
+
).fetchone()
|
| 433 |
+
if row is None:
|
| 434 |
+
raise NotFoundError(f"skill_proposal {proposal_id} not found")
|
| 435 |
+
return _row_to_proposal(row)
|
| 436 |
+
|
| 437 |
+
def accept_proposal(
|
| 438 |
+
self,
|
| 439 |
+
proposal_id: str,
|
| 440 |
+
*,
|
| 441 |
+
note: str | None = None,
|
| 442 |
+
) -> dict[str, Any]:
|
| 443 |
+
"""Accept a proposal. Writes a reference_documents row keyed
|
| 444 |
+
`skill/<slug>` and marks the proposal accepted."""
|
| 445 |
+
proposal = self.get_proposal(proposal_id)
|
| 446 |
+
if proposal.status != "pending":
|
| 447 |
+
raise ValidationError(
|
| 448 |
+
f"proposal {proposal_id} is {proposal.status}, cannot accept",
|
| 449 |
+
recovery="Only pending proposals can be accepted. Use list_proposals(status='pending').",
|
| 450 |
+
)
|
| 451 |
+
doc_key = f"skill/{proposal.proposed_slug}"
|
| 452 |
+
# T1-3 fix: gate the reference_documents insert through the cap
|
| 453 |
+
# check. Free user at 1.9MB could previously accept skill proposals
|
| 454 |
+
# (often kilobytes of body) to keep writing past the 2 MB cap.
|
| 455 |
+
# When cap_gate is None (direct-Learner instantiation), no check.
|
| 456 |
+
if self._cap_gate is not None:
|
| 457 |
+
body_size = len(proposal.proposed_body or "") + len(doc_key) + 250
|
| 458 |
+
self._cap_gate.check(proposed_delta_bytes=body_size)
|
| 459 |
+
with self._storage.transaction() as conn:
|
| 460 |
+
conn.execute(
|
| 461 |
+
"INSERT INTO reference_documents (tenant_id, doc_key, body, metadata) "
|
| 462 |
+
"VALUES (?, ?, ?, ?) "
|
| 463 |
+
"ON CONFLICT(tenant_id, doc_key) DO UPDATE SET "
|
| 464 |
+
"body = excluded.body, metadata = excluded.metadata, "
|
| 465 |
+
"updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')",
|
| 466 |
+
(
|
| 467 |
+
self._tenant_id,
|
| 468 |
+
doc_key,
|
| 469 |
+
proposal.proposed_body,
|
| 470 |
+
dumps({
|
| 471 |
+
"source": "sibyl-memory-client/learning",
|
| 472 |
+
"pattern_kind": proposal.pattern_kind,
|
| 473 |
+
"summarizer": proposal.summarizer,
|
| 474 |
+
"confidence": proposal.confidence,
|
| 475 |
+
"evidence_count": len(proposal.evidence),
|
| 476 |
+
"title": proposal.proposed_title,
|
| 477 |
+
}),
|
| 478 |
+
),
|
| 479 |
+
)
|
| 480 |
+
conn.execute(
|
| 481 |
+
"UPDATE skill_proposals "
|
| 482 |
+
"SET status = 'accepted', reviewed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), "
|
| 483 |
+
"review_note = ?, accepted_doc_key = ? "
|
| 484 |
+
"WHERE id = ? AND tenant_id = ?",
|
| 485 |
+
(note, doc_key, proposal_id, self._tenant_id),
|
| 486 |
+
)
|
| 487 |
+
return {"accepted": True, "doc_key": doc_key, "proposal_id": proposal_id}
|
| 488 |
+
|
| 489 |
+
def reject_proposal(
|
| 490 |
+
self,
|
| 491 |
+
proposal_id: str,
|
| 492 |
+
*,
|
| 493 |
+
note: str | None = None,
|
| 494 |
+
) -> dict[str, Any]:
|
| 495 |
+
proposal = self.get_proposal(proposal_id)
|
| 496 |
+
if proposal.status != "pending":
|
| 497 |
+
raise ValidationError(
|
| 498 |
+
f"proposal {proposal_id} is {proposal.status}, cannot reject",
|
| 499 |
+
recovery="Only pending proposals can be rejected.",
|
| 500 |
+
)
|
| 501 |
+
with self._storage.transaction() as conn:
|
| 502 |
+
conn.execute(
|
| 503 |
+
"UPDATE skill_proposals "
|
| 504 |
+
"SET status = 'rejected', reviewed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), "
|
| 505 |
+
"review_note = ? "
|
| 506 |
+
"WHERE id = ? AND tenant_id = ?",
|
| 507 |
+
(note, proposal_id, self._tenant_id),
|
| 508 |
+
)
|
| 509 |
+
return {"rejected": True, "proposal_id": proposal_id}
|
| 510 |
+
|
| 511 |
+
# ------------------------------------------------------------------
|
| 512 |
+
# Internal
|
| 513 |
+
# ------------------------------------------------------------------
|
| 514 |
+
def _last_watermark(self) -> str | None:
|
| 515 |
+
with self._storage.connection() as conn:
|
| 516 |
+
row = conn.execute(
|
| 517 |
+
"SELECT cursor_after_ts FROM learning_runs "
|
| 518 |
+
"WHERE tenant_id = ? AND completed_at IS NOT NULL "
|
| 519 |
+
"ORDER BY started_at DESC LIMIT 1",
|
| 520 |
+
(self._tenant_id,),
|
| 521 |
+
).fetchone()
|
| 522 |
+
return row["cursor_after_ts"] if row else None
|
| 523 |
+
|
| 524 |
+
def _load_events(self, *, since: str | None) -> list[dict[str, Any]]:
|
| 525 |
+
sql = (
|
| 526 |
+
"SELECT id, ts, evaluated, acted, forward, extra "
|
| 527 |
+
"FROM journal_events WHERE tenant_id = ?"
|
| 528 |
+
)
|
| 529 |
+
params: list[Any] = [self._tenant_id]
|
| 530 |
+
if since:
|
| 531 |
+
sql += " AND ts > ?"
|
| 532 |
+
params.append(since)
|
| 533 |
+
sql += " ORDER BY ts ASC, id ASC"
|
| 534 |
+
with self._storage.connection() as conn:
|
| 535 |
+
rows = conn.execute(sql, params).fetchall()
|
| 536 |
+
return [
|
| 537 |
+
{
|
| 538 |
+
"id": r["id"],
|
| 539 |
+
"ts": r["ts"],
|
| 540 |
+
"evaluated": loads(r["evaluated"]),
|
| 541 |
+
"acted": loads(r["acted"]),
|
| 542 |
+
"forward": loads(r["forward"]),
|
| 543 |
+
"extra": loads(r["extra"]),
|
| 544 |
+
}
|
| 545 |
+
for r in rows
|
| 546 |
+
]
|
| 547 |
+
|
| 548 |
+
def _pending_slugs(self) -> set[str]:
|
| 549 |
+
with self._storage.connection() as conn:
|
| 550 |
+
rows = conn.execute(
|
| 551 |
+
"SELECT proposed_slug FROM skill_proposals "
|
| 552 |
+
"WHERE tenant_id = ? AND status = 'pending'",
|
| 553 |
+
(self._tenant_id,),
|
| 554 |
+
).fetchall()
|
| 555 |
+
return {r["proposed_slug"] for r in rows}
|
| 556 |
+
|
| 557 |
+
def _insert_proposal(
|
| 558 |
+
self,
|
| 559 |
+
candidate: "_Candidate",
|
| 560 |
+
*,
|
| 561 |
+
body: str,
|
| 562 |
+
title: str | None,
|
| 563 |
+
) -> str:
|
| 564 |
+
pid = new_id()
|
| 565 |
+
with self._storage.transaction() as conn:
|
| 566 |
+
conn.execute(
|
| 567 |
+
"INSERT INTO skill_proposals "
|
| 568 |
+
"(id, tenant_id, pattern_kind, proposed_slug, proposed_title, "
|
| 569 |
+
" proposed_body, evidence, confidence, summarizer) "
|
| 570 |
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
| 571 |
+
(
|
| 572 |
+
pid,
|
| 573 |
+
self._tenant_id,
|
| 574 |
+
candidate.kind,
|
| 575 |
+
candidate.slug,
|
| 576 |
+
title,
|
| 577 |
+
body,
|
| 578 |
+
dumps([
|
| 579 |
+
{"event_id": ev["id"], "ts": ev["ts"], "snippet": _short_event_snippet(ev)}
|
| 580 |
+
for ev in candidate.events[:20]
|
| 581 |
+
]),
|
| 582 |
+
candidate.confidence,
|
| 583 |
+
self._summarizer.name,
|
| 584 |
+
),
|
| 585 |
+
)
|
| 586 |
+
return pid
|
| 587 |
+
|
| 588 |
+
def _log_run(
|
| 589 |
+
self,
|
| 590 |
+
*,
|
| 591 |
+
run_id: str,
|
| 592 |
+
started_at: str,
|
| 593 |
+
completed_at: str,
|
| 594 |
+
events_scanned: int,
|
| 595 |
+
proposals_made: int,
|
| 596 |
+
cursor_after_ts: str | None,
|
| 597 |
+
notes: str | None,
|
| 598 |
+
) -> None:
|
| 599 |
+
with self._storage.transaction() as conn:
|
| 600 |
+
conn.execute(
|
| 601 |
+
"INSERT INTO learning_runs "
|
| 602 |
+
"(id, tenant_id, started_at, completed_at, summarizer, "
|
| 603 |
+
" events_scanned, proposals_made, cursor_after_ts, notes) "
|
| 604 |
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
| 605 |
+
(
|
| 606 |
+
run_id,
|
| 607 |
+
self._tenant_id,
|
| 608 |
+
started_at,
|
| 609 |
+
completed_at,
|
| 610 |
+
self._summarizer.name,
|
| 611 |
+
events_scanned,
|
| 612 |
+
proposals_made,
|
| 613 |
+
cursor_after_ts,
|
| 614 |
+
notes,
|
| 615 |
+
),
|
| 616 |
+
)
|
| 617 |
+
|
| 618 |
+
|
| 619 |
+
# ======================================================================
|
| 620 |
+
# Pattern detectors (deterministic, local-only)
|
| 621 |
+
# ======================================================================
|
| 622 |
+
|
| 623 |
+
@dataclass
|
| 624 |
+
class _Candidate:
|
| 625 |
+
kind: str
|
| 626 |
+
slug: str
|
| 627 |
+
confidence: float
|
| 628 |
+
events: list[dict[str, Any]]
|
| 629 |
+
hints: dict[str, Any]
|
| 630 |
+
|
| 631 |
+
|
| 632 |
+
def _detect_repeated_actions(
|
| 633 |
+
events: list[dict[str, Any]],
|
| 634 |
+
*,
|
| 635 |
+
min_hits: int,
|
| 636 |
+
) -> list[_Candidate]:
|
| 637 |
+
"""Cluster events by an abstracted action signature; surface clusters
|
| 638 |
+
that occur >= min_hits times."""
|
| 639 |
+
by_sig: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
| 640 |
+
for ev in events:
|
| 641 |
+
acted = ev.get("acted")
|
| 642 |
+
if acted is None:
|
| 643 |
+
continue
|
| 644 |
+
sig = _action_signature(acted)
|
| 645 |
+
if not sig:
|
| 646 |
+
continue
|
| 647 |
+
by_sig[sig].append(ev)
|
| 648 |
+
|
| 649 |
+
out: list[_Candidate] = []
|
| 650 |
+
for sig, group in by_sig.items():
|
| 651 |
+
if len(group) < min_hits:
|
| 652 |
+
continue
|
| 653 |
+
slug = _safe_slug("repeat-" + sig)
|
| 654 |
+
# confidence scales with hit count, capped at 0.95
|
| 655 |
+
confidence = min(0.95, 0.4 + 0.05 * len(group))
|
| 656 |
+
out.append(_Candidate(
|
| 657 |
+
kind="repeated_action",
|
| 658 |
+
slug=slug,
|
| 659 |
+
confidence=confidence,
|
| 660 |
+
events=group,
|
| 661 |
+
hints={"action_signature": sig, "slug": slug, "hits": len(group)},
|
| 662 |
+
))
|
| 663 |
+
return out
|
| 664 |
+
|
| 665 |
+
|
| 666 |
+
def _detect_structural_similarity(
|
| 667 |
+
events: list[dict[str, Any]],
|
| 668 |
+
*,
|
| 669 |
+
min_hits: int,
|
| 670 |
+
) -> list[_Candidate]:
|
| 671 |
+
"""Group events that share a stable set of input/output keys."""
|
| 672 |
+
by_keys: dict[tuple[str, ...], list[dict[str, Any]]] = defaultdict(list)
|
| 673 |
+
for ev in events:
|
| 674 |
+
evaluated = ev.get("evaluated")
|
| 675 |
+
if not isinstance(evaluated, dict):
|
| 676 |
+
continue
|
| 677 |
+
keyset = tuple(sorted(evaluated.keys()))
|
| 678 |
+
if not keyset:
|
| 679 |
+
continue
|
| 680 |
+
by_keys[keyset].append(ev)
|
| 681 |
+
|
| 682 |
+
out: list[_Candidate] = []
|
| 683 |
+
for keyset, group in by_keys.items():
|
| 684 |
+
if len(group) < min_hits:
|
| 685 |
+
continue
|
| 686 |
+
slug = _safe_slug("shape-" + "-".join(keyset[:4]))
|
| 687 |
+
confidence = min(0.85, 0.3 + 0.04 * len(group))
|
| 688 |
+
out.append(_Candidate(
|
| 689 |
+
kind="structural_similarity",
|
| 690 |
+
slug=slug,
|
| 691 |
+
confidence=confidence,
|
| 692 |
+
events=group,
|
| 693 |
+
hints={"shared_keys": list(keyset), "slug": slug, "hits": len(group)},
|
| 694 |
+
))
|
| 695 |
+
return out
|
| 696 |
+
|
| 697 |
+
|
| 698 |
+
def _detect_co_occurrence(
|
| 699 |
+
events: list[dict[str, Any]],
|
| 700 |
+
*,
|
| 701 |
+
min_hits: int,
|
| 702 |
+
) -> list[_Candidate]:
|
| 703 |
+
"""Find pairs of distinct tokens (entity names / action verbs) that
|
| 704 |
+
consistently appear together in the same journal entry."""
|
| 705 |
+
pair_counts: Counter[tuple[str, str]] = Counter()
|
| 706 |
+
pair_events: dict[tuple[str, str], list[dict[str, Any]]] = defaultdict(list)
|
| 707 |
+
for ev in events:
|
| 708 |
+
toks = _extract_tokens(ev)
|
| 709 |
+
if len(toks) < 2:
|
| 710 |
+
continue
|
| 711 |
+
toks_sorted = sorted(set(toks))
|
| 712 |
+
# All 2-combos
|
| 713 |
+
for i in range(len(toks_sorted)):
|
| 714 |
+
for j in range(i + 1, len(toks_sorted)):
|
| 715 |
+
pair = (toks_sorted[i], toks_sorted[j])
|
| 716 |
+
pair_counts[pair] += 1
|
| 717 |
+
pair_events[pair].append(ev)
|
| 718 |
+
|
| 719 |
+
out: list[_Candidate] = []
|
| 720 |
+
for pair, count in pair_counts.items():
|
| 721 |
+
if count < min_hits:
|
| 722 |
+
continue
|
| 723 |
+
slug = _safe_slug(f"pair-{pair[0]}-{pair[1]}")
|
| 724 |
+
confidence = min(0.80, 0.25 + 0.04 * count)
|
| 725 |
+
out.append(_Candidate(
|
| 726 |
+
kind="co_occurrence",
|
| 727 |
+
slug=slug,
|
| 728 |
+
confidence=confidence,
|
| 729 |
+
events=pair_events[pair],
|
| 730 |
+
hints={"pair": list(pair), "slug": slug, "hits": count},
|
| 731 |
+
))
|
| 732 |
+
return out
|
| 733 |
+
|
| 734 |
+
|
| 735 |
+
def _detect_temporal_routine(
|
| 736 |
+
events: list[dict[str, Any]],
|
| 737 |
+
*,
|
| 738 |
+
min_hits: int,
|
| 739 |
+
) -> list[_Candidate]:
|
| 740 |
+
"""Crude cadence detector: if same-signature events recur with low
|
| 741 |
+
variance in time-between-events, surface as a temporal routine."""
|
| 742 |
+
by_sig: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
| 743 |
+
for ev in events:
|
| 744 |
+
acted = ev.get("acted")
|
| 745 |
+
if acted is None:
|
| 746 |
+
continue
|
| 747 |
+
sig = _action_signature(acted)
|
| 748 |
+
if sig:
|
| 749 |
+
by_sig[sig].append(ev)
|
| 750 |
+
|
| 751 |
+
out: list[_Candidate] = []
|
| 752 |
+
for sig, group in by_sig.items():
|
| 753 |
+
if len(group) < min_hits:
|
| 754 |
+
continue
|
| 755 |
+
gaps_min = _intervals_minutes([ev.get("ts") for ev in group])
|
| 756 |
+
if not gaps_min:
|
| 757 |
+
continue
|
| 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:
|
| 765 |
+
continue # too irregular to call a routine
|
| 766 |
+
slug = _safe_slug(f"routine-{sig}")
|
| 767 |
+
# Routine confidence rewards regularity
|
| 768 |
+
confidence = min(0.90, 0.5 + (0.5 * (1 - cov)))
|
| 769 |
+
out.append(_Candidate(
|
| 770 |
+
kind="temporal_routine",
|
| 771 |
+
slug=slug,
|
| 772 |
+
confidence=confidence,
|
| 773 |
+
events=group,
|
| 774 |
+
hints={
|
| 775 |
+
"action_signature": sig,
|
| 776 |
+
"slug": slug,
|
| 777 |
+
"hits": len(group),
|
| 778 |
+
"cadence_minutes": round(mean, 1),
|
| 779 |
+
"cov": round(cov, 3),
|
| 780 |
+
},
|
| 781 |
+
))
|
| 782 |
+
return out
|
| 783 |
+
|
| 784 |
+
|
| 785 |
+
# ======================================================================
|
| 786 |
+
# Helpers
|
| 787 |
+
# ======================================================================
|
| 788 |
+
|
| 789 |
+
def _action_signature(acted: Any) -> str:
|
| 790 |
+
"""Reduce an `acted` payload to a stable signature for clustering."""
|
| 791 |
+
if isinstance(acted, list):
|
| 792 |
+
# Use the first verb / phrase, lowercased + truncated
|
| 793 |
+
if not acted:
|
| 794 |
+
return ""
|
| 795 |
+
first = acted[0]
|
| 796 |
+
if isinstance(first, str):
|
| 797 |
+
return _normalize_phrase(first)
|
| 798 |
+
if isinstance(first, dict):
|
| 799 |
+
kind = first.get("kind") or first.get("action") or first.get("type")
|
| 800 |
+
if isinstance(kind, str):
|
| 801 |
+
return _normalize_phrase(kind)
|
| 802 |
+
return ""
|
| 803 |
+
if isinstance(acted, dict):
|
| 804 |
+
kind = acted.get("kind") or acted.get("action") or acted.get("type")
|
| 805 |
+
if isinstance(kind, str):
|
| 806 |
+
return _normalize_phrase(kind)
|
| 807 |
+
return ""
|
| 808 |
+
if isinstance(acted, str):
|
| 809 |
+
return _normalize_phrase(acted)
|
| 810 |
+
return ""
|
| 811 |
+
|
| 812 |
+
|
| 813 |
+
_WORD_RE = re.compile(r"[a-z0-9][a-z0-9_-]+")
|
| 814 |
+
|
| 815 |
+
|
| 816 |
+
def _normalize_phrase(text: str) -> str:
|
| 817 |
+
"""Lowercase, strip non-alpha, collapse to first 3 tokens."""
|
| 818 |
+
text = text.lower().strip()
|
| 819 |
+
tokens = _WORD_RE.findall(text)
|
| 820 |
+
return "-".join(tokens[:3])
|
| 821 |
+
|
| 822 |
+
|
| 823 |
+
def _safe_slug(s: str) -> str:
|
| 824 |
+
s = s.lower()
|
| 825 |
+
s = re.sub(r"[^a-z0-9-]+", "-", s)
|
| 826 |
+
s = re.sub(r"-+", "-", s).strip("-")
|
| 827 |
+
return s[:80] or "untitled"
|
| 828 |
+
|
| 829 |
+
|
| 830 |
+
def _slug_to_title(slug: str) -> str:
|
| 831 |
+
return " ".join(w.capitalize() for w in slug.replace("-", " ").split())
|
| 832 |
+
|
| 833 |
+
|
| 834 |
+
def _extract_tokens(ev: dict[str, Any]) -> list[str]:
|
| 835 |
+
"""Pull a coarse bag-of-tokens out of an event for co-occurrence detection."""
|
| 836 |
+
out: list[str] = []
|
| 837 |
+
for field in ("evaluated", "acted"):
|
| 838 |
+
v = ev.get(field)
|
| 839 |
+
if isinstance(v, dict):
|
| 840 |
+
for key in v.keys():
|
| 841 |
+
out.append(_normalize_phrase(str(key)))
|
| 842 |
+
elif isinstance(v, list):
|
| 843 |
+
for item in v:
|
| 844 |
+
if isinstance(item, str):
|
| 845 |
+
out.append(_normalize_phrase(item))
|
| 846 |
+
elif isinstance(v, str):
|
| 847 |
+
out.append(_normalize_phrase(v))
|
| 848 |
+
return [t for t in out if t]
|
| 849 |
+
|
| 850 |
+
|
| 851 |
+
def _short_event_snippet(ev: dict[str, Any]) -> str:
|
| 852 |
+
acted = ev.get("acted")
|
| 853 |
+
if isinstance(acted, list) and acted:
|
| 854 |
+
first = acted[0]
|
| 855 |
+
if isinstance(first, str):
|
| 856 |
+
return first[:120]
|
| 857 |
+
return json.dumps(first)[:120]
|
| 858 |
+
if isinstance(acted, dict):
|
| 859 |
+
return json.dumps(acted)[:120]
|
| 860 |
+
if isinstance(acted, str):
|
| 861 |
+
return acted[:120]
|
| 862 |
+
evaluated = ev.get("evaluated")
|
| 863 |
+
if evaluated:
|
| 864 |
+
return f"evaluated: {json.dumps(evaluated)[:100]}"
|
| 865 |
+
return "(no action recorded)"
|
| 866 |
+
|
| 867 |
+
|
| 868 |
+
def _intervals_minutes(timestamps: list[str | None]) -> list[float]:
|
| 869 |
+
"""Compute consecutive timestamp gaps in minutes. ISO 8601 strings only."""
|
| 870 |
+
import datetime as _dt
|
| 871 |
+
parsed: list[_dt.datetime] = []
|
| 872 |
+
for t in timestamps:
|
| 873 |
+
if not t:
|
| 874 |
+
continue
|
| 875 |
+
try:
|
| 876 |
+
# Python 3.11+ handles 'Z' suffix natively via fromisoformat after replace
|
| 877 |
+
parsed.append(_dt.datetime.fromisoformat(t.replace("Z", "+00:00")))
|
| 878 |
+
except Exception:
|
| 879 |
+
continue
|
| 880 |
+
parsed.sort()
|
| 881 |
+
if len(parsed) < 2:
|
| 882 |
+
return []
|
| 883 |
+
return [(parsed[i + 1] - parsed[i]).total_seconds() / 60.0 for i in range(len(parsed) - 1)]
|
| 884 |
+
|
| 885 |
+
|
| 886 |
+
def _build_summarization_prompt(
|
| 887 |
+
pattern_kind: str,
|
| 888 |
+
events: list[dict[str, Any]],
|
| 889 |
+
hints: dict[str, Any],
|
| 890 |
+
) -> str:
|
| 891 |
+
"""Build the LLM prompt for BYOK / Venice summarizers. The prompt is
|
| 892 |
+
deliberately compact; full evidence is included so the model can
|
| 893 |
+
produce a high-quality skill body."""
|
| 894 |
+
return (
|
| 895 |
+
f"You are summarizing a detected behavioral pattern from a personal "
|
| 896 |
+
f"agent's memory journal.\n"
|
| 897 |
+
f"Pattern kind: {pattern_kind}\n"
|
| 898 |
+
f"Hints: {json.dumps(hints, indent=2)}\n\n"
|
| 899 |
+
f"Matching journal events (up to 10 shown):\n"
|
| 900 |
+
f"{json.dumps(events[:10], indent=2)}\n\n"
|
| 901 |
+
f"Write a concise reusable skill in Markdown. Include: a clear title, "
|
| 902 |
+
f"one-paragraph description of when to apply this skill, an enumerated "
|
| 903 |
+
f"recipe of the steps the agent should follow, and any constraints "
|
| 904 |
+
f"observed in the source events. Be terse and actionable."
|
| 905 |
+
)
|
| 906 |
+
|
| 907 |
+
|
| 908 |
+
def _row_to_proposal(row: Any) -> SkillProposal:
|
| 909 |
+
"""Convert a sqlite3.Row into a SkillProposal dataclass."""
|
| 910 |
+
return SkillProposal(
|
| 911 |
+
id=row["id"],
|
| 912 |
+
tenant_id=row["tenant_id"],
|
| 913 |
+
pattern_kind=row["pattern_kind"],
|
| 914 |
+
proposed_slug=row["proposed_slug"],
|
| 915 |
+
proposed_title=row["proposed_title"],
|
| 916 |
+
proposed_body=row["proposed_body"],
|
| 917 |
+
evidence=loads(row["evidence"]) or [],
|
| 918 |
+
confidence=float(row["confidence"]),
|
| 919 |
+
summarizer=row["summarizer"],
|
| 920 |
+
status=row["status"],
|
| 921 |
+
created_at=row["created_at"],
|
| 922 |
+
reviewed_at=row["reviewed_at"],
|
| 923 |
+
review_note=row["review_note"],
|
| 924 |
+
accepted_doc_key=row["accepted_doc_key"],
|
| 925 |
+
)
|
sibyl-memory-client/src/sibyl_memory_client/lint.py
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Memory linter for sibyl-memory-client.
|
| 2 |
+
|
| 3 |
+
Mirrors the spirit of `scripts/memory-lint.mjs` in the SIBYL operator
|
| 4 |
+
codebase: scan the local memory state for structural drift, surface
|
| 5 |
+
findings with severity + recovery hints, exit non-zero from CLI when
|
| 6 |
+
critical findings exist.
|
| 7 |
+
|
| 8 |
+
Designed to be invoked by:
|
| 9 |
+
β’ the plugin's `sibyl lint` CLI command (in sibyl-labs-cli)
|
| 10 |
+
β’ a scheduled cron job from `sibyl init`
|
| 11 |
+
β’ programmatic callers via `MemoryClient.lint()`
|
| 12 |
+
|
| 13 |
+
CHECKS (v0.2.0)
|
| 14 |
+
===============
|
| 15 |
+
|
| 16 |
+
Severity levels: `critical` | `warning` | `info`
|
| 17 |
+
|
| 18 |
+
| id | severity | what it catches |
|
| 19 |
+
|------------------------|----------|----------------------------------------------|
|
| 20 |
+
| schema-version | critical | DB schema older than the package expects |
|
| 21 |
+
| invalid-json-entity | critical | entities.body parses to non-object |
|
| 22 |
+
| invalid-json-state | critical | state_documents.body parses to non-object |
|
| 23 |
+
| invalid-json-journal | critical | journal_events fields are not valid JSON |
|
| 24 |
+
| duplicate-entity | warning | same entity name under multiple categories |
|
| 25 |
+
| empty-reference | warning | reference_documents.body is empty |
|
| 26 |
+
| stale-entity | info | entities not updated in >N days |
|
| 27 |
+
| journal-without-acts | info | journal events with no evaluated/acted/extra |
|
| 28 |
+
| db-soft-cap | warning | DB size exceeds 80% of the soft cap (10 MB) |
|
| 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 |
+
|
| 36 |
+
import datetime as _dt
|
| 37 |
+
import json
|
| 38 |
+
from dataclasses import asdict, dataclass, field
|
| 39 |
+
from pathlib import Path
|
| 40 |
+
from typing import Any
|
| 41 |
+
|
| 42 |
+
from .client import DEFAULT_TENANT
|
| 43 |
+
from .storage import Storage
|
| 44 |
+
|
| 45 |
+
# ----------------------------------------------------------------------
|
| 46 |
+
# Public types
|
| 47 |
+
# ----------------------------------------------------------------------
|
| 48 |
+
|
| 49 |
+
SEVERITIES = ("critical", "warning", "info")
|
| 50 |
+
# Free-tier soft cap. Tuned 2026-05-15 to land the power-user conversion event
|
| 51 |
+
# in roughly 1-2 weeks of real use. Paid tiers remove the cap entirely.
|
| 52 |
+
DEFAULT_SOFT_CAP_BYTES = 2 * 1024 * 1024 # 2 MB free-tier cap
|
| 53 |
+
DEFAULT_STALE_DAYS = 90
|
| 54 |
+
DEFAULT_FLAG_RECENCY_DAYS = 30
|
| 55 |
+
EXPECTED_SCHEMA_VERSION = 2
|
| 56 |
+
|
| 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 |
+
|
| 68 |
+
@dataclass
|
| 69 |
+
class Finding:
|
| 70 |
+
"""A single lint result."""
|
| 71 |
+
check: str
|
| 72 |
+
severity: str # critical | warning | info
|
| 73 |
+
message: str
|
| 74 |
+
recovery: str | None = None
|
| 75 |
+
detail: dict[str, Any] = field(default_factory=dict)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
@dataclass
|
| 79 |
+
class LintReport:
|
| 80 |
+
"""Aggregated lint output."""
|
| 81 |
+
tenant_id: str
|
| 82 |
+
db_path: str
|
| 83 |
+
schema_version: int | None
|
| 84 |
+
db_size_bytes: int
|
| 85 |
+
counts: dict[str, int]
|
| 86 |
+
findings: list[Finding]
|
| 87 |
+
started_at: str
|
| 88 |
+
completed_at: str
|
| 89 |
+
|
| 90 |
+
@property
|
| 91 |
+
def critical(self) -> list[Finding]:
|
| 92 |
+
return [f for f in self.findings if f.severity == "critical"]
|
| 93 |
+
|
| 94 |
+
@property
|
| 95 |
+
def warnings(self) -> list[Finding]:
|
| 96 |
+
return [f for f in self.findings if f.severity == "warning"]
|
| 97 |
+
|
| 98 |
+
@property
|
| 99 |
+
def info(self) -> list[Finding]:
|
| 100 |
+
return [f for f in self.findings if f.severity == "info"]
|
| 101 |
+
|
| 102 |
+
@property
|
| 103 |
+
def ok(self) -> bool:
|
| 104 |
+
return not self.critical
|
| 105 |
+
|
| 106 |
+
def to_dict(self) -> dict[str, Any]:
|
| 107 |
+
return {
|
| 108 |
+
"tenant_id": self.tenant_id,
|
| 109 |
+
"db_path": self.db_path,
|
| 110 |
+
"schema_version": self.schema_version,
|
| 111 |
+
"db_size_bytes": self.db_size_bytes,
|
| 112 |
+
"counts": self.counts,
|
| 113 |
+
"findings": [asdict(f) for f in self.findings],
|
| 114 |
+
"started_at": self.started_at,
|
| 115 |
+
"completed_at": self.completed_at,
|
| 116 |
+
"ok": self.ok,
|
| 117 |
+
"critical_count": len(self.critical),
|
| 118 |
+
"warning_count": len(self.warnings),
|
| 119 |
+
"info_count": len(self.info),
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
def to_ascii(self) -> str:
|
| 123 |
+
"""Render to a single-block ASCII report for CLI."""
|
| 124 |
+
lines: list[str] = []
|
| 125 |
+
bar = "β" * 64
|
| 126 |
+
lines.append("β" + bar + "β")
|
| 127 |
+
title = " SIBYL MEMORY Β· LINT REPORT "
|
| 128 |
+
pad = (66 - len(title)) // 2
|
| 129 |
+
lines.append("β" + " " * pad + title + " " * (66 - pad - len(title)) + "β")
|
| 130 |
+
lines.append("β " + bar + "β£")
|
| 131 |
+
lines.append(f"β tenant β {self.tenant_id[:46]:<46}β")
|
| 132 |
+
lines.append(f"β db path β {Path(self.db_path).name[:46]:<46}β")
|
| 133 |
+
lines.append(f"β schema v β {str(self.schema_version)[:46]:<46}β")
|
| 134 |
+
size_kb = self.db_size_bytes / 1024
|
| 135 |
+
lines.append(f"β db size β {f'{size_kb:.1f} KB':<46}β")
|
| 136 |
+
lines.append("β " + bar + "β£")
|
| 137 |
+
for k in sorted(self.counts):
|
| 138 |
+
lines.append(f"β {k:<13}β {str(self.counts[k]):<46}β")
|
| 139 |
+
lines.append("β " + bar + "β£")
|
| 140 |
+
if not self.findings:
|
| 141 |
+
lines.append("β no findings Β· memory looks clean" + " " * 30 + "β")
|
| 142 |
+
else:
|
| 143 |
+
for f in self.findings:
|
| 144 |
+
sev_marker = {"critical": "β", "warning": "β ", "info": "i"}.get(f.severity, "Β·")
|
| 145 |
+
hdr = f" [{sev_marker} {f.severity}] {f.check}"
|
| 146 |
+
lines.append(f"β{hdr[:64]:<64}β")
|
| 147 |
+
msg = f" {f.message}"
|
| 148 |
+
# wrap at 62 cols
|
| 149 |
+
while msg:
|
| 150 |
+
chunk, msg = msg[:62], msg[62:]
|
| 151 |
+
lines.append(f"β{chunk:<64}β")
|
| 152 |
+
if f.recovery:
|
| 153 |
+
rec = f" β {f.recovery}"
|
| 154 |
+
while rec:
|
| 155 |
+
chunk, rec = rec[:62], rec[62:]
|
| 156 |
+
lines.append(f"β{chunk:<64}β")
|
| 157 |
+
lines.append("β " + bar + "β£")
|
| 158 |
+
summary = f" {len(self.critical)} critical Β· {len(self.warnings)} warnings Β· {len(self.info)} info"
|
| 159 |
+
lines.append(f"β{summary[:64]:<64}β")
|
| 160 |
+
lines.append("β" + bar + "β")
|
| 161 |
+
return "\n".join(lines)
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
# ----------------------------------------------------------------------
|
| 165 |
+
# Linter β the actual checks
|
| 166 |
+
# ----------------------------------------------------------------------
|
| 167 |
+
|
| 168 |
+
class Linter:
|
| 169 |
+
"""Local memory linter. Stateless; safe to instantiate per call."""
|
| 170 |
+
|
| 171 |
+
def __init__(
|
| 172 |
+
self,
|
| 173 |
+
storage: Storage,
|
| 174 |
+
*,
|
| 175 |
+
tenant_id: str = DEFAULT_TENANT,
|
| 176 |
+
soft_cap_bytes: int = DEFAULT_SOFT_CAP_BYTES,
|
| 177 |
+
stale_days: int = DEFAULT_STALE_DAYS,
|
| 178 |
+
flag_recency_days: int = DEFAULT_FLAG_RECENCY_DAYS,
|
| 179 |
+
) -> None:
|
| 180 |
+
self._storage = storage
|
| 181 |
+
self._tenant_id = tenant_id
|
| 182 |
+
self._soft_cap = soft_cap_bytes
|
| 183 |
+
self._stale_days = stale_days
|
| 184 |
+
self._flag_recency = flag_recency_days
|
| 185 |
+
|
| 186 |
+
def run(self) -> LintReport:
|
| 187 |
+
from .storage import _utc_now_iso
|
| 188 |
+
started_at = _utc_now_iso()
|
| 189 |
+
|
| 190 |
+
findings: list[Finding] = []
|
| 191 |
+
counts: dict[str, int] = {}
|
| 192 |
+
|
| 193 |
+
with self._storage.connection() as conn:
|
| 194 |
+
# schema version
|
| 195 |
+
schema_row = conn.execute(
|
| 196 |
+
"SELECT MAX(version) AS v FROM sibyl_memory_schema_version"
|
| 197 |
+
).fetchone()
|
| 198 |
+
schema_version = schema_row["v"] if schema_row else None
|
| 199 |
+
|
| 200 |
+
if schema_version is None or schema_version < EXPECTED_SCHEMA_VERSION:
|
| 201 |
+
findings.append(Finding(
|
| 202 |
+
check="schema-version",
|
| 203 |
+
severity="critical",
|
| 204 |
+
message=(
|
| 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
|
| 212 |
+
for tname in (
|
| 213 |
+
"entities", "state_documents", "journal_events",
|
| 214 |
+
"reference_documents", "archived_entities", "flagged_actors",
|
| 215 |
+
"skill_proposals", "learning_runs",
|
| 216 |
+
):
|
| 217 |
+
try:
|
| 218 |
+
row = conn.execute(
|
| 219 |
+
f"SELECT COUNT(*) AS n FROM {tname} WHERE tenant_id = ?",
|
| 220 |
+
(self._tenant_id,),
|
| 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))
|
| 228 |
+
|
| 229 |
+
# ββ duplicate entity names across categories
|
| 230 |
+
dupes = conn.execute(
|
| 231 |
+
"SELECT name, COUNT(DISTINCT category) AS c "
|
| 232 |
+
"FROM entities WHERE tenant_id = ? GROUP BY name HAVING c > 1",
|
| 233 |
+
(self._tenant_id,),
|
| 234 |
+
).fetchall()
|
| 235 |
+
for row in dupes:
|
| 236 |
+
findings.append(Finding(
|
| 237 |
+
check="duplicate-entity",
|
| 238 |
+
severity="warning",
|
| 239 |
+
message=f"entity name '{row['name']}' appears in {row['c']} categories",
|
| 240 |
+
recovery="Pick one canonical category and archive or rename the others.",
|
| 241 |
+
detail={"name": row["name"], "category_count": int(row["c"])},
|
| 242 |
+
))
|
| 243 |
+
|
| 244 |
+
# ββ empty reference documents
|
| 245 |
+
empties = conn.execute(
|
| 246 |
+
"SELECT doc_key FROM reference_documents "
|
| 247 |
+
"WHERE tenant_id = ? AND (body IS NULL OR length(trim(body)) = 0)",
|
| 248 |
+
(self._tenant_id,),
|
| 249 |
+
).fetchall()
|
| 250 |
+
for row in empties:
|
| 251 |
+
findings.append(Finding(
|
| 252 |
+
check="empty-reference",
|
| 253 |
+
severity="warning",
|
| 254 |
+
message=f"reference document '{row['doc_key']}' has empty body",
|
| 255 |
+
recovery="Either populate the body or delete the row.",
|
| 256 |
+
detail={"doc_key": row["doc_key"]},
|
| 257 |
+
))
|
| 258 |
+
|
| 259 |
+
# ββ stale entities
|
| 260 |
+
cutoff = (
|
| 261 |
+
_dt.datetime.utcnow() - _dt.timedelta(days=self._stale_days)
|
| 262 |
+
).strftime("%Y-%m-%dT%H:%M:%fZ")
|
| 263 |
+
stale = conn.execute(
|
| 264 |
+
"SELECT category, name, updated_at FROM entities "
|
| 265 |
+
"WHERE tenant_id = ? AND updated_at < ? "
|
| 266 |
+
"ORDER BY updated_at ASC LIMIT 25",
|
| 267 |
+
(self._tenant_id, cutoff),
|
| 268 |
+
).fetchall()
|
| 269 |
+
for row in stale:
|
| 270 |
+
findings.append(Finding(
|
| 271 |
+
check="stale-entity",
|
| 272 |
+
severity="info",
|
| 273 |
+
message=(
|
| 274 |
+
f"entity {row['category']}/{row['name']} hasn't been "
|
| 275 |
+
f"updated since {row['updated_at']} "
|
| 276 |
+
f"(> {self._stale_days} days)"
|
| 277 |
+
),
|
| 278 |
+
recovery=(
|
| 279 |
+
"Update the entity, archive it if no longer relevant, "
|
| 280 |
+
"or extend the staleness window."
|
| 281 |
+
),
|
| 282 |
+
detail={
|
| 283 |
+
"category": row["category"],
|
| 284 |
+
"name": row["name"],
|
| 285 |
+
"updated_at": row["updated_at"],
|
| 286 |
+
},
|
| 287 |
+
))
|
| 288 |
+
|
| 289 |
+
# ββ journal entries with no useful payload
|
| 290 |
+
empty_journal = conn.execute(
|
| 291 |
+
"SELECT id, ts FROM journal_events "
|
| 292 |
+
"WHERE tenant_id = ? "
|
| 293 |
+
"AND evaluated IS NULL AND acted IS NULL "
|
| 294 |
+
"AND forward IS NULL AND extra IS NULL "
|
| 295 |
+
"ORDER BY ts DESC LIMIT 10",
|
| 296 |
+
(self._tenant_id,),
|
| 297 |
+
).fetchall()
|
| 298 |
+
for row in empty_journal:
|
| 299 |
+
findings.append(Finding(
|
| 300 |
+
check="journal-without-acts",
|
| 301 |
+
severity="info",
|
| 302 |
+
message=f"journal event {row['id'][:12]}β¦ at {row['ts']} has no payload",
|
| 303 |
+
recovery="Either populate the event or delete it.",
|
| 304 |
+
detail={"id": row["id"], "ts": row["ts"]},
|
| 305 |
+
))
|
| 306 |
+
|
| 307 |
+
# ββ DB size vs soft cap
|
| 308 |
+
db_size = Path(self._storage.db_path).stat().st_size
|
| 309 |
+
if db_size >= 0.8 * self._soft_cap:
|
| 310 |
+
pct = db_size / self._soft_cap
|
| 311 |
+
severity = "critical" if db_size >= self._soft_cap else "warning"
|
| 312 |
+
findings.append(Finding(
|
| 313 |
+
check="db-soft-cap",
|
| 314 |
+
severity=severity,
|
| 315 |
+
message=(
|
| 316 |
+
f"local DB is at {pct * 100:.1f}% of the {self._soft_cap // (1024 * 1024)} MB cap"
|
| 317 |
+
),
|
| 318 |
+
recovery=(
|
| 319 |
+
"Archive stale entities, prune old journal events, "
|
| 320 |
+
"or upgrade to Stake / Cloud / Lifetime to remove the cap."
|
| 321 |
+
),
|
| 322 |
+
detail={"db_size_bytes": db_size, "soft_cap_bytes": self._soft_cap},
|
| 323 |
+
))
|
| 324 |
+
|
| 325 |
+
# ββ FTS rowcount integrity
|
| 326 |
+
try:
|
| 327 |
+
ents = conn.execute(
|
| 328 |
+
"SELECT COUNT(*) AS n FROM entities WHERE tenant_id = ?",
|
| 329 |
+
(self._tenant_id,),
|
| 330 |
+
).fetchone()["n"]
|
| 331 |
+
fts = conn.execute(
|
| 332 |
+
"SELECT COUNT(*) AS n FROM entities_fts WHERE tenant_id = ?",
|
| 333 |
+
(self._tenant_id,),
|
| 334 |
+
).fetchone()["n"]
|
| 335 |
+
if ents != fts:
|
| 336 |
+
findings.append(Finding(
|
| 337 |
+
check="fts-rowcount-mismatch",
|
| 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},
|
| 345 |
+
))
|
| 346 |
+
except Exception:
|
| 347 |
+
pass # missing fts table β caught by schema-version check
|
| 348 |
+
|
| 349 |
+
# ββ recent flagged actors (info-level surface)
|
| 350 |
+
recent_cutoff = (
|
| 351 |
+
_dt.datetime.utcnow() - _dt.timedelta(days=self._flag_recency)
|
| 352 |
+
).strftime("%Y-%m-%dT%H:%M:%fZ")
|
| 353 |
+
try:
|
| 354 |
+
flagged = conn.execute(
|
| 355 |
+
"SELECT identifier, flagged_at, reason FROM flagged_actors "
|
| 356 |
+
"WHERE tenant_id = ? AND flagged_at >= ? "
|
| 357 |
+
"ORDER BY flagged_at DESC LIMIT 5",
|
| 358 |
+
(self._tenant_id, recent_cutoff),
|
| 359 |
+
).fetchall()
|
| 360 |
+
for row in flagged:
|
| 361 |
+
findings.append(Finding(
|
| 362 |
+
check="flagged-actors-fresh",
|
| 363 |
+
severity="info",
|
| 364 |
+
message=(
|
| 365 |
+
f"recent flagged actor: {row['identifier']} "
|
| 366 |
+
f"({row['reason'][:60] if row['reason'] else 'no reason given'})"
|
| 367 |
+
),
|
| 368 |
+
recovery="Review the actor record; ensure downstream actions respect the flag.",
|
| 369 |
+
detail={
|
| 370 |
+
"identifier": row["identifier"],
|
| 371 |
+
"flagged_at": row["flagged_at"],
|
| 372 |
+
},
|
| 373 |
+
))
|
| 374 |
+
except Exception:
|
| 375 |
+
pass
|
| 376 |
+
|
| 377 |
+
completed_at = _utc_now_iso()
|
| 378 |
+
|
| 379 |
+
return LintReport(
|
| 380 |
+
tenant_id=self._tenant_id,
|
| 381 |
+
db_path=str(self._storage.db_path),
|
| 382 |
+
schema_version=schema_version,
|
| 383 |
+
db_size_bytes=Path(self._storage.db_path).stat().st_size,
|
| 384 |
+
counts=counts,
|
| 385 |
+
findings=findings,
|
| 386 |
+
started_at=started_at,
|
| 387 |
+
completed_at=completed_at,
|
| 388 |
+
)
|
| 389 |
+
|
| 390 |
+
# ------------------------------------------------------------------
|
| 391 |
+
# Internal β JSON validity probe
|
| 392 |
+
# ------------------------------------------------------------------
|
| 393 |
+
def _lint_json_bodies(self, conn: Any) -> list[Finding]:
|
| 394 |
+
out: list[Finding] = []
|
| 395 |
+
# entities.body must be JSON object/array
|
| 396 |
+
bad_entities = conn.execute(
|
| 397 |
+
"SELECT id, category, name FROM entities "
|
| 398 |
+
"WHERE tenant_id = ? AND json_valid(body) = 0 LIMIT 10",
|
| 399 |
+
(self._tenant_id,),
|
| 400 |
+
).fetchall()
|
| 401 |
+
for row in bad_entities:
|
| 402 |
+
out.append(Finding(
|
| 403 |
+
check="invalid-json-entity",
|
| 404 |
+
severity="critical",
|
| 405 |
+
message=f"entity {row['category']}/{row['name']} has invalid JSON body",
|
| 406 |
+
recovery="Delete or repair the row. SDK CHECK constraints should have prevented this.",
|
| 407 |
+
detail={"id": row["id"]},
|
| 408 |
+
))
|
| 409 |
+
|
| 410 |
+
bad_states = conn.execute(
|
| 411 |
+
"SELECT document_key FROM state_documents "
|
| 412 |
+
"WHERE tenant_id = ? AND json_valid(body) = 0 LIMIT 10",
|
| 413 |
+
(self._tenant_id,),
|
| 414 |
+
).fetchall()
|
| 415 |
+
for row in bad_states:
|
| 416 |
+
out.append(Finding(
|
| 417 |
+
check="invalid-json-state",
|
| 418 |
+
severity="critical",
|
| 419 |
+
message=f"state_document {row['document_key']} has invalid JSON body",
|
| 420 |
+
recovery="Repair or delete the row.",
|
| 421 |
+
detail={"document_key": row["document_key"]},
|
| 422 |
+
))
|
| 423 |
+
|
| 424 |
+
# journal_events fields are nullable but if present must be valid JSON
|
| 425 |
+
bad_journal = conn.execute(
|
| 426 |
+
"SELECT id FROM journal_events WHERE tenant_id = ? "
|
| 427 |
+
"AND ("
|
| 428 |
+
"(evaluated IS NOT NULL AND json_valid(evaluated) = 0) OR "
|
| 429 |
+
"(acted IS NOT NULL AND json_valid(acted) = 0) OR "
|
| 430 |
+
"(forward IS NOT NULL AND json_valid(forward) = 0) OR "
|
| 431 |
+
"(extra IS NOT NULL AND json_valid(extra) = 0)"
|
| 432 |
+
") LIMIT 10",
|
| 433 |
+
(self._tenant_id,),
|
| 434 |
+
).fetchall()
|
| 435 |
+
for row in bad_journal:
|
| 436 |
+
out.append(Finding(
|
| 437 |
+
check="invalid-json-journal",
|
| 438 |
+
severity="critical",
|
| 439 |
+
message=f"journal_event {row['id']} has invalid JSON in one of its fields",
|
| 440 |
+
recovery="Repair or delete the row.",
|
| 441 |
+
detail={"id": row["id"]},
|
| 442 |
+
))
|
| 443 |
+
|
| 444 |
+
return out
|
| 445 |
+
|
| 446 |
+
|
| 447 |
+
# ----------------------------------------------------------------------
|
| 448 |
+
# Convenience module-level function (mirrors scripts/memory-lint.mjs UX)
|
| 449 |
+
# ----------------------------------------------------------------------
|
| 450 |
+
|
| 451 |
+
def lint(storage: Storage, *, tenant_id: str = DEFAULT_TENANT, **kwargs: Any) -> LintReport:
|
| 452 |
+
"""Convenience: run a default lint pass."""
|
| 453 |
+
return Linter(storage, tenant_id=tenant_id, **kwargs).run()
|
sibyl-memory-client/src/sibyl_memory_client/schema.sql
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- sibyl-memory-client SQLite schema v1
|
| 2 |
+
--
|
| 3 |
+
-- Port of the canonical sibyl_memory.* Postgres schema (scripts/sibyl-memory-schema.sql,
|
| 4 |
+
-- applied to Neon 2026-05-01) to SQLite for the local-first plugin v1.
|
| 5 |
+
--
|
| 6 |
+
-- Dialect translations:
|
| 7 |
+
-- UUID β TEXT (Python uuid.uuid4() at write-time)
|
| 8 |
+
-- JSONB β TEXT with CHECK(json_valid(col)) using SQLite json1
|
| 9 |
+
-- TIMESTAMPTZ + now() β TEXT ISO 8601 UTC via strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
| 10 |
+
-- gin jsonb_path_ops β SQLite json_extract expression indexes where useful
|
| 11 |
+
-- NUMERIC β REAL (sufficient precision for plugin v1 use)
|
| 12 |
+
-- tsvector β FTS5 virtual tables for text search
|
| 13 |
+
--
|
| 14 |
+
-- Multi-tenant: every table carries tenant_id. Local-first means typically
|
| 15 |
+
-- one tenant per machine, but the schema accepts N tenants (paid Team-tier
|
| 16 |
+
-- federation forward-compatible).
|
| 17 |
+
--
|
| 18 |
+
-- Idempotent. Apply via CREATE TABLE IF NOT EXISTS. Schema version recorded
|
| 19 |
+
-- in sibyl_memory_schema_version for future migrations.
|
| 20 |
+
|
| 21 |
+
PRAGMA foreign_keys = ON;
|
| 22 |
+
PRAGMA journal_mode = WAL;
|
| 23 |
+
|
| 24 |
+
-- ============================================================================
|
| 25 |
+
-- WARM tier: entities (single source of truth per rule 43)
|
| 26 |
+
-- ============================================================================
|
| 27 |
+
CREATE TABLE IF NOT EXISTS entities (
|
| 28 |
+
id TEXT PRIMARY KEY,
|
| 29 |
+
tenant_id TEXT NOT NULL,
|
| 30 |
+
category TEXT NOT NULL,
|
| 31 |
+
name TEXT NOT NULL,
|
| 32 |
+
status TEXT,
|
| 33 |
+
body TEXT NOT NULL CHECK (json_valid(body)),
|
| 34 |
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
| 35 |
+
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
| 36 |
+
UNIQUE (tenant_id, category, name)
|
| 37 |
+
);
|
| 38 |
+
|
| 39 |
+
CREATE INDEX IF NOT EXISTS entities_tenant_cat_status
|
| 40 |
+
ON entities (tenant_id, category, status);
|
| 41 |
+
CREATE INDEX IF NOT EXISTS entities_updated_at
|
| 42 |
+
ON entities (tenant_id, updated_at DESC);
|
| 43 |
+
|
| 44 |
+
-- ============================================================================
|
| 45 |
+
-- Cross-references: typed relations between entities
|
| 46 |
+
-- ============================================================================
|
| 47 |
+
CREATE TABLE IF NOT EXISTS entity_relations (
|
| 48 |
+
id TEXT PRIMARY KEY,
|
| 49 |
+
tenant_id TEXT NOT NULL,
|
| 50 |
+
from_id TEXT NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
|
| 51 |
+
to_id TEXT NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
|
| 52 |
+
relation_type TEXT NOT NULL,
|
| 53 |
+
metadata TEXT CHECK (metadata IS NULL OR json_valid(metadata)),
|
| 54 |
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
| 55 |
+
);
|
| 56 |
+
|
| 57 |
+
CREATE INDEX IF NOT EXISTS entity_relations_from
|
| 58 |
+
ON entity_relations (tenant_id, from_id, relation_type);
|
| 59 |
+
CREATE INDEX IF NOT EXISTS entity_relations_to
|
| 60 |
+
ON entity_relations (tenant_id, to_id, relation_type);
|
| 61 |
+
|
| 62 |
+
-- ============================================================================
|
| 63 |
+
-- HOT tier: state documents (treasury, priorities, session, index analogs)
|
| 64 |
+
-- ============================================================================
|
| 65 |
+
CREATE TABLE IF NOT EXISTS state_documents (
|
| 66 |
+
tenant_id TEXT NOT NULL,
|
| 67 |
+
document_key TEXT NOT NULL,
|
| 68 |
+
body TEXT NOT NULL CHECK (json_valid(body)),
|
| 69 |
+
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
| 70 |
+
PRIMARY KEY (tenant_id, document_key)
|
| 71 |
+
);
|
| 72 |
+
|
| 73 |
+
-- ============================================================================
|
| 74 |
+
-- COLD tier: append-only journal of events
|
| 75 |
+
-- ============================================================================
|
| 76 |
+
CREATE TABLE IF NOT EXISTS journal_events (
|
| 77 |
+
id TEXT PRIMARY KEY,
|
| 78 |
+
tenant_id TEXT NOT NULL,
|
| 79 |
+
ts TEXT NOT NULL,
|
| 80 |
+
evaluated TEXT CHECK (evaluated IS NULL OR json_valid(evaluated)),
|
| 81 |
+
acted TEXT CHECK (acted IS NULL OR json_valid(acted)),
|
| 82 |
+
forward TEXT CHECK (forward IS NULL OR json_valid(forward)),
|
| 83 |
+
extra TEXT CHECK (extra IS NULL OR json_valid(extra))
|
| 84 |
+
);
|
| 85 |
+
|
| 86 |
+
CREATE INDEX IF NOT EXISTS journal_events_tenant_ts
|
| 87 |
+
ON journal_events (tenant_id, ts DESC);
|
| 88 |
+
|
| 89 |
+
-- ============================================================================
|
| 90 |
+
-- COLD tier: revenue events with optional entity ref
|
| 91 |
+
-- ============================================================================
|
| 92 |
+
CREATE TABLE IF NOT EXISTS revenue_events (
|
| 93 |
+
id TEXT PRIMARY KEY,
|
| 94 |
+
tenant_id TEXT NOT NULL,
|
| 95 |
+
ts TEXT NOT NULL,
|
| 96 |
+
event_type TEXT,
|
| 97 |
+
gross_usd REAL,
|
| 98 |
+
operator_share_usd REAL,
|
| 99 |
+
source TEXT,
|
| 100 |
+
tx TEXT,
|
| 101 |
+
entity_id TEXT REFERENCES entities(id) ON DELETE SET NULL
|
| 102 |
+
);
|
| 103 |
+
|
| 104 |
+
CREATE INDEX IF NOT EXISTS revenue_events_tenant_ts
|
| 105 |
+
ON revenue_events (tenant_id, ts DESC);
|
| 106 |
+
|
| 107 |
+
-- ============================================================================
|
| 108 |
+
-- COLD tier: error events
|
| 109 |
+
-- ============================================================================
|
| 110 |
+
CREATE TABLE IF NOT EXISTS error_events (
|
| 111 |
+
id TEXT PRIMARY KEY,
|
| 112 |
+
tenant_id TEXT NOT NULL,
|
| 113 |
+
ts TEXT NOT NULL,
|
| 114 |
+
code TEXT,
|
| 115 |
+
message TEXT,
|
| 116 |
+
context TEXT CHECK (context IS NULL OR json_valid(context))
|
| 117 |
+
);
|
| 118 |
+
|
| 119 |
+
CREATE INDEX IF NOT EXISTS error_events_tenant_ts
|
| 120 |
+
ON error_events (tenant_id, ts DESC);
|
| 121 |
+
|
| 122 |
+
-- ============================================================================
|
| 123 |
+
-- REFERENCE tier: static documents (markdown bodies, lookup-only)
|
| 124 |
+
-- ============================================================================
|
| 125 |
+
CREATE TABLE IF NOT EXISTS reference_documents (
|
| 126 |
+
tenant_id TEXT NOT NULL,
|
| 127 |
+
doc_key TEXT NOT NULL,
|
| 128 |
+
body TEXT,
|
| 129 |
+
metadata TEXT CHECK (metadata IS NULL OR json_valid(metadata)),
|
| 130 |
+
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
| 131 |
+
PRIMARY KEY (tenant_id, doc_key)
|
| 132 |
+
);
|
| 133 |
+
|
| 134 |
+
-- ============================================================================
|
| 135 |
+
-- ARCHIVE tier: frozen entities (out of working set, retrievable)
|
| 136 |
+
-- ============================================================================
|
| 137 |
+
CREATE TABLE IF NOT EXISTS archived_entities (
|
| 138 |
+
id TEXT PRIMARY KEY,
|
| 139 |
+
tenant_id TEXT NOT NULL,
|
| 140 |
+
original_entity_id TEXT,
|
| 141 |
+
category TEXT,
|
| 142 |
+
name TEXT,
|
| 143 |
+
body TEXT CHECK (body IS NULL OR json_valid(body)),
|
| 144 |
+
archived_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
| 145 |
+
archive_reason TEXT
|
| 146 |
+
);
|
| 147 |
+
|
| 148 |
+
CREATE INDEX IF NOT EXISTS archived_entities_tenant_cat
|
| 149 |
+
ON archived_entities (tenant_id, category, name);
|
| 150 |
+
|
| 151 |
+
-- ============================================================================
|
| 152 |
+
-- FLAGGED tier: actors flagged for social-engineering / fraud (rule 13/14/15)
|
| 153 |
+
-- ============================================================================
|
| 154 |
+
CREATE TABLE IF NOT EXISTS flagged_actors (
|
| 155 |
+
id TEXT PRIMARY KEY,
|
| 156 |
+
tenant_id TEXT NOT NULL,
|
| 157 |
+
actor_handle TEXT,
|
| 158 |
+
actor_address TEXT,
|
| 159 |
+
flagged_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
| 160 |
+
reason TEXT,
|
| 161 |
+
evidence TEXT CHECK (evidence IS NULL OR json_valid(evidence))
|
| 162 |
+
);
|
| 163 |
+
|
| 164 |
+
CREATE INDEX IF NOT EXISTS flagged_actors_tenant
|
| 165 |
+
ON flagged_actors (tenant_id);
|
| 166 |
+
|
| 167 |
+
-- ============================================================================
|
| 168 |
+
-- FTS5 virtual tables for full-text retrieval (the tsvector port)
|
| 169 |
+
-- ============================================================================
|
| 170 |
+
-- v3 (2026-05-18): all FTS5 tables now use external-content (or contentless
|
| 171 |
+
-- for journal). Body lives in the base table, FTS5 stores only the index.
|
| 172 |
+
-- Triggers fire transparently. Disk footprint stays flat (vs v2's 2x dup).
|
| 173 |
+
-- Cross-tier search lands here: entities + state + reference + journal.
|
| 174 |
+
-- v2 β v3 migration is handled in storage.py:_migrate_to_v3.
|
| 175 |
+
|
| 176 |
+
-- ENTITIES: external-content FTS5 over entities table
|
| 177 |
+
CREATE VIRTUAL TABLE IF NOT EXISTS entities_fts USING fts5(
|
| 178 |
+
name, category, body, tenant_id UNINDEXED,
|
| 179 |
+
content='entities', content_rowid='rowid',
|
| 180 |
+
tokenize = 'porter unicode61'
|
| 181 |
+
);
|
| 182 |
+
|
| 183 |
+
CREATE TRIGGER IF NOT EXISTS entities_ai_fts
|
| 184 |
+
AFTER INSERT ON entities BEGIN
|
| 185 |
+
INSERT INTO entities_fts(rowid, name, category, body, tenant_id)
|
| 186 |
+
VALUES (new.rowid, new.name, new.category, new.body, new.tenant_id);
|
| 187 |
+
END;
|
| 188 |
+
|
| 189 |
+
CREATE TRIGGER IF NOT EXISTS entities_ad_fts
|
| 190 |
+
AFTER DELETE ON entities BEGIN
|
| 191 |
+
INSERT INTO entities_fts(entities_fts, rowid, name, category, body, tenant_id)
|
| 192 |
+
VALUES ('delete', old.rowid, old.name, old.category, old.body, old.tenant_id);
|
| 193 |
+
END;
|
| 194 |
+
|
| 195 |
+
CREATE TRIGGER IF NOT EXISTS entities_au_fts
|
| 196 |
+
AFTER UPDATE ON entities BEGIN
|
| 197 |
+
INSERT INTO entities_fts(entities_fts, rowid, name, category, body, tenant_id)
|
| 198 |
+
VALUES ('delete', old.rowid, old.name, old.category, old.body, old.tenant_id);
|
| 199 |
+
INSERT INTO entities_fts(rowid, name, category, body, tenant_id)
|
| 200 |
+
VALUES (new.rowid, new.name, new.category, new.body, new.tenant_id);
|
| 201 |
+
END;
|
| 202 |
+
|
| 203 |
+
-- STATE: external-content FTS5 over state_documents
|
| 204 |
+
CREATE VIRTUAL TABLE IF NOT EXISTS state_documents_fts USING fts5(
|
| 205 |
+
document_key, body, tenant_id UNINDEXED,
|
| 206 |
+
content='state_documents', content_rowid='rowid',
|
| 207 |
+
tokenize = 'porter unicode61'
|
| 208 |
+
);
|
| 209 |
+
|
| 210 |
+
CREATE TRIGGER IF NOT EXISTS state_documents_ai_fts
|
| 211 |
+
AFTER INSERT ON state_documents BEGIN
|
| 212 |
+
INSERT INTO state_documents_fts(rowid, document_key, body, tenant_id)
|
| 213 |
+
VALUES (new.rowid, new.document_key, new.body, new.tenant_id);
|
| 214 |
+
END;
|
| 215 |
+
|
| 216 |
+
CREATE TRIGGER IF NOT EXISTS state_documents_ad_fts
|
| 217 |
+
AFTER DELETE ON state_documents BEGIN
|
| 218 |
+
INSERT INTO state_documents_fts(state_documents_fts, rowid, document_key, body, tenant_id)
|
| 219 |
+
VALUES ('delete', old.rowid, old.document_key, old.body, old.tenant_id);
|
| 220 |
+
END;
|
| 221 |
+
|
| 222 |
+
CREATE TRIGGER IF NOT EXISTS state_documents_au_fts
|
| 223 |
+
AFTER UPDATE ON state_documents BEGIN
|
| 224 |
+
INSERT INTO state_documents_fts(state_documents_fts, rowid, document_key, body, tenant_id)
|
| 225 |
+
VALUES ('delete', old.rowid, old.document_key, old.body, old.tenant_id);
|
| 226 |
+
INSERT INTO state_documents_fts(rowid, document_key, body, tenant_id)
|
| 227 |
+
VALUES (new.rowid, new.document_key, new.body, new.tenant_id);
|
| 228 |
+
END;
|
| 229 |
+
|
| 230 |
+
-- REFERENCE: external-content FTS5 over reference_documents
|
| 231 |
+
CREATE VIRTUAL TABLE IF NOT EXISTS reference_documents_fts USING fts5(
|
| 232 |
+
doc_key, body, tenant_id UNINDEXED,
|
| 233 |
+
content='reference_documents', content_rowid='rowid',
|
| 234 |
+
tokenize = 'porter unicode61'
|
| 235 |
+
);
|
| 236 |
+
|
| 237 |
+
CREATE TRIGGER IF NOT EXISTS reference_ai_fts
|
| 238 |
+
AFTER INSERT ON reference_documents BEGIN
|
| 239 |
+
INSERT INTO reference_documents_fts(rowid, doc_key, body, tenant_id)
|
| 240 |
+
VALUES (new.rowid, new.doc_key, new.body, new.tenant_id);
|
| 241 |
+
END;
|
| 242 |
+
|
| 243 |
+
CREATE TRIGGER IF NOT EXISTS reference_ad_fts
|
| 244 |
+
AFTER DELETE ON reference_documents BEGIN
|
| 245 |
+
INSERT INTO reference_documents_fts(reference_documents_fts, rowid, doc_key, body, tenant_id)
|
| 246 |
+
VALUES ('delete', old.rowid, old.doc_key, old.body, old.tenant_id);
|
| 247 |
+
END;
|
| 248 |
+
|
| 249 |
+
CREATE TRIGGER IF NOT EXISTS reference_au_fts
|
| 250 |
+
AFTER UPDATE ON reference_documents BEGIN
|
| 251 |
+
INSERT INTO reference_documents_fts(reference_documents_fts, rowid, doc_key, body, tenant_id)
|
| 252 |
+
VALUES ('delete', old.rowid, old.doc_key, old.body, old.tenant_id);
|
| 253 |
+
INSERT INTO reference_documents_fts(rowid, doc_key, body, tenant_id)
|
| 254 |
+
VALUES (new.rowid, new.doc_key, new.body, new.tenant_id);
|
| 255 |
+
END;
|
| 256 |
+
|
| 257 |
+
-- JOURNAL: standalone FTS5 over journal_events (concatenated payload).
|
| 258 |
+
-- Standalone (not external-content) because journal_events has 4 separate
|
| 259 |
+
-- JSON payload columns we want searchable as one concatenated field, and
|
| 260 |
+
-- there's no single base-table column we could external-content against.
|
| 261 |
+
-- Acceptable cost: journal is append-only (no updates), so the body
|
| 262 |
+
-- duplication doesn't compound on edits like it would on warm entities.
|
| 263 |
+
CREATE VIRTUAL TABLE IF NOT EXISTS journal_events_fts USING fts5(
|
| 264 |
+
ts UNINDEXED, payload, tenant_id UNINDEXED, event_id UNINDEXED,
|
| 265 |
+
tokenize = 'porter unicode61'
|
| 266 |
+
);
|
| 267 |
+
|
| 268 |
+
CREATE TRIGGER IF NOT EXISTS journal_events_ai_fts
|
| 269 |
+
AFTER INSERT ON journal_events BEGIN
|
| 270 |
+
INSERT INTO journal_events_fts(rowid, ts, payload, tenant_id, event_id)
|
| 271 |
+
VALUES (
|
| 272 |
+
new.rowid, new.ts,
|
| 273 |
+
COALESCE(new.evaluated, '') || ' ' || COALESCE(new.acted, '') || ' ' ||
|
| 274 |
+
COALESCE(new.forward, '') || ' ' || COALESCE(new.extra, ''),
|
| 275 |
+
new.tenant_id, new.id
|
| 276 |
+
);
|
| 277 |
+
END;
|
| 278 |
+
|
| 279 |
+
-- ============================================================================
|
| 280 |
+
-- Schema version tracking
|
| 281 |
+
-- ============================================================================
|
| 282 |
+
CREATE TABLE IF NOT EXISTS sibyl_memory_schema_version (
|
| 283 |
+
version INTEGER PRIMARY KEY,
|
| 284 |
+
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
| 285 |
+
description TEXT
|
| 286 |
+
);
|
| 287 |
+
|
| 288 |
+
INSERT OR IGNORE INTO sibyl_memory_schema_version (version, description)
|
| 289 |
+
VALUES (1, 'sibyl-memory-client v1. SQLite port of sibyl_memory.* Postgres schema. 10 tables (entities, entity_relations, state_documents, journal_events, revenue_events, error_events, reference_documents, archived_entities, flagged_actors, schema_version) + 2 FTS5 virtual tables. Local-first plugin foundation.');
|
| 290 |
+
|
| 291 |
+
-- ============================================================================
|
| 292 |
+
-- Schema v2 β self-learning skill proposals (review queue)
|
| 293 |
+
-- ============================================================================
|
| 294 |
+
-- The Learner module scans journal_events for repeating patterns and writes
|
| 295 |
+
-- proposed skill documents here. The user reviews via `sibyl learn review`
|
| 296 |
+
-- and either accepts (which writes to reference_documents under skill/<slug>)
|
| 297 |
+
-- or rejects. Idempotent; safe to re-apply against a v1 database.
|
| 298 |
+
CREATE TABLE IF NOT EXISTS skill_proposals (
|
| 299 |
+
id TEXT PRIMARY KEY,
|
| 300 |
+
tenant_id TEXT NOT NULL,
|
| 301 |
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
| 302 |
+
|
| 303 |
+
-- detector output
|
| 304 |
+
pattern_kind TEXT NOT NULL, -- 'repeated_action' / 'structural_similarity' / 'temporal_routine' / 'co_occurrence'
|
| 305 |
+
proposed_slug TEXT NOT NULL, -- the reference_documents.doc_key it would land under (skill/<slug>)
|
| 306 |
+
proposed_title TEXT, -- one-line human-readable title
|
| 307 |
+
proposed_body TEXT NOT NULL, -- the actual skill body (markdown text)
|
| 308 |
+
|
| 309 |
+
-- evidence + provenance
|
| 310 |
+
evidence TEXT NOT NULL CHECK (json_valid(evidence)), -- list of source journal_event ids + snippets
|
| 311 |
+
confidence REAL NOT NULL CHECK (confidence >= 0 AND confidence <= 1),
|
| 312 |
+
summarizer TEXT NOT NULL, -- 'local-deterministic' / 'byok-anthropic' / 'byok-openai' / 'venice-x402' / etc.
|
| 313 |
+
|
| 314 |
+
-- review state
|
| 315 |
+
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'rejected', 'superseded')),
|
| 316 |
+
reviewed_at TEXT,
|
| 317 |
+
review_note TEXT,
|
| 318 |
+
|
| 319 |
+
-- when accepted, points at the reference_documents row that was created
|
| 320 |
+
accepted_doc_key TEXT
|
| 321 |
+
);
|
| 322 |
+
|
| 323 |
+
CREATE INDEX IF NOT EXISTS skill_proposals_tenant_status
|
| 324 |
+
ON skill_proposals (tenant_id, status, created_at DESC);
|
| 325 |
+
CREATE INDEX IF NOT EXISTS skill_proposals_slug
|
| 326 |
+
ON skill_proposals (tenant_id, proposed_slug);
|
| 327 |
+
|
| 328 |
+
-- ============================================================================
|
| 329 |
+
-- Schema v2 β learning run log (so detectors don't re-scan ground they covered)
|
| 330 |
+
-- ============================================================================
|
| 331 |
+
CREATE TABLE IF NOT EXISTS learning_runs (
|
| 332 |
+
id TEXT PRIMARY KEY,
|
| 333 |
+
tenant_id TEXT NOT NULL,
|
| 334 |
+
started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
| 335 |
+
completed_at TEXT,
|
| 336 |
+
summarizer TEXT NOT NULL,
|
| 337 |
+
events_scanned INTEGER NOT NULL DEFAULT 0,
|
| 338 |
+
proposals_made INTEGER NOT NULL DEFAULT 0,
|
| 339 |
+
cursor_after_ts TEXT, -- watermark β newest journal ts processed
|
| 340 |
+
notes TEXT
|
| 341 |
+
);
|
| 342 |
+
|
| 343 |
+
CREATE INDEX IF NOT EXISTS learning_runs_tenant
|
| 344 |
+
ON learning_runs (tenant_id, started_at DESC);
|
| 345 |
+
|
| 346 |
+
INSERT OR IGNORE INTO sibyl_memory_schema_version (version, description)
|
| 347 |
+
VALUES (2, 'sibyl-memory-client v2. Adds skill_proposals (self-learning review queue) and learning_runs (detector watermark log). Idempotent migration; v1 databases auto-upgrade on first open. Free tier uses local-deterministic summarizer; paid tier can opt into BYOK or Venice/x402-routed summarization.');
|
| 348 |
+
|
| 349 |
+
INSERT OR IGNORE INTO sibyl_memory_schema_version (version, description)
|
| 350 |
+
VALUES (3, 'sibyl-memory-client v3. External-content FTS5 across entities + state_documents + reference_documents + contentless FTS5 over journal_events. Fixes the v0.3.0 "search covers warm entities only" bug. Eliminates body duplication (v2 stored body twice β base table + FTS5). v2 to v3 migration handled in storage.py:_migrate_to_v3: drops the standalone FTS5 tables and rebuilds in external-content shape from existing base-table data. No data loss.');
|
sibyl-memory-client/src/sibyl_memory_client/storage.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SQLite storage layer for sibyl-memory-client.
|
| 2 |
+
|
| 3 |
+
Opens a per-tenant local SQLite database, applies the canonical schema, and
|
| 4 |
+
exposes a connection helper plus low-level row IO. Thread-local connection
|
| 5 |
+
pool keeps things simple for v1; we revisit if/when concurrent agent
|
| 6 |
+
workloads emerge.
|
| 7 |
+
|
| 8 |
+
Design notes:
|
| 9 |
+
- WAL mode for concurrent reads + single writer (default for v1, matches
|
| 10 |
+
the local-first single-agent workload).
|
| 11 |
+
- foreign_keys = ON enforced at connection time.
|
| 12 |
+
- Schema applied on first open; idempotent via CREATE IF NOT EXISTS.
|
| 13 |
+
- ISO 8601 UTC timestamps everywhere (`strftime('%Y-%m-%dT%H:%M:%fZ','now')`).
|
| 14 |
+
- All JSON validated at write time via sqlite json_valid() CHECK constraints.
|
| 15 |
+
"""
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import json
|
| 19 |
+
import os
|
| 20 |
+
import sqlite3
|
| 21 |
+
import threading
|
| 22 |
+
from contextlib import contextmanager
|
| 23 |
+
from datetime import datetime, timezone
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
from typing import Any, Iterator
|
| 26 |
+
from uuid import uuid4
|
| 27 |
+
|
| 28 |
+
from .exceptions import SchemaError, StorageError
|
| 29 |
+
|
| 30 |
+
_SCHEMA_PATH = Path(__file__).parent / "schema.sql"
|
| 31 |
+
|
| 32 |
+
# v0.4.0 (2026-05-18, KAPPA RED finding): the SQLite DB holds every entity
|
| 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")
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _utc_now_iso() -> str:
|
| 43 |
+
"""Return current UTC time in ISO 8601 microsecond-precision format.
|
| 44 |
+
|
| 45 |
+
Higher precision than the schema's millisecond default (`%f` in
|
| 46 |
+
SQLite's strftime) reduces ts collisions when multiple writes land in
|
| 47 |
+
the same millisecond. Microsecond-precision strings sort correctly
|
| 48 |
+
lexically since the format is fixed-width."""
|
| 49 |
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def new_id() -> str:
|
| 53 |
+
"""Generate a fresh UUID v4 string for primary keys."""
|
| 54 |
+
return str(uuid4())
|
| 55 |
+
|
| 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 |
+
|
| 63 |
+
|
| 64 |
+
def loads(blob: str | None) -> Any:
|
| 65 |
+
"""Inverse of dumps(). Returns None for None input (matches nullable
|
| 66 |
+
JSONB column semantics)."""
|
| 67 |
+
if blob is None:
|
| 68 |
+
return None
|
| 69 |
+
return json.loads(blob)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class Storage:
|
| 73 |
+
"""SQLite connection wrapper with schema bootstrap + transaction helpers."""
|
| 74 |
+
|
| 75 |
+
def __init__(self, db_path: str | Path):
|
| 76 |
+
self.db_path = Path(db_path).expanduser().resolve()
|
| 77 |
+
self.db_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
| 78 |
+
# Per-instance thread-local cache (avoids leaking connections across
|
| 79 |
+
# Storage instances pointing at different files).
|
| 80 |
+
self._tls = threading.local()
|
| 81 |
+
# Bootstrap schema on first open (idempotent)
|
| 82 |
+
self._ensure_schema()
|
| 83 |
+
# v0.4.0 (KAPPA RED finding): tighten file permissions on the main DB
|
| 84 |
+
# file + WAL + SHM sidecars after the schema apply has created them.
|
| 85 |
+
# Default umask leaves 0644 (world-readable); we want 0600 since the
|
| 86 |
+
# DB contains every entity body. Idempotent + tolerant of missing
|
| 87 |
+
# sidecars (WAL/SHM only exist after first write).
|
| 88 |
+
self._tighten_db_file_perms()
|
| 89 |
+
|
| 90 |
+
def _connect(self) -> sqlite3.Connection:
|
| 91 |
+
"""Open a fresh connection. Callers should prefer connection() context
|
| 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(
|
| 99 |
+
str(self.db_path),
|
| 100 |
+
isolation_level=None, # autocommit; we manage transactions explicitly
|
| 101 |
+
check_same_thread=False,
|
| 102 |
+
detect_types=0,
|
| 103 |
+
)
|
| 104 |
+
except sqlite3.Error as e:
|
| 105 |
+
raise StorageError(
|
| 106 |
+
f"Could not open the local SQLite database: {type(e).__name__}",
|
| 107 |
+
recovery="Check disk space, file permissions, and that no other process holds an exclusive lock.",
|
| 108 |
+
) from e
|
| 109 |
+
|
| 110 |
+
conn.execute("PRAGMA foreign_keys = ON")
|
| 111 |
+
conn.execute("PRAGMA journal_mode = WAL")
|
| 112 |
+
conn.execute("PRAGMA synchronous = NORMAL") # safe with WAL, faster than FULL
|
| 113 |
+
conn.execute("PRAGMA busy_timeout = 5000") # 5s before SQLITE_BUSY
|
| 114 |
+
conn.row_factory = sqlite3.Row
|
| 115 |
+
return conn
|
| 116 |
+
|
| 117 |
+
@contextmanager
|
| 118 |
+
def connection(self) -> Iterator[sqlite3.Connection]:
|
| 119 |
+
"""Context manager that yields a per-instance, thread-local connection.
|
| 120 |
+
Connection stays open across calls for performance; cleanup happens at
|
| 121 |
+
Storage.close() or at process exit.
|
| 122 |
+
|
| 123 |
+
SEC-3 hardening (v0.3.3): wraps sqlite3.Error in a sanitized
|
| 124 |
+
StorageError without leaking db_path or query text."""
|
| 125 |
+
conn = getattr(self._tls, "conn", None)
|
| 126 |
+
if conn is None:
|
| 127 |
+
conn = self._connect()
|
| 128 |
+
self._tls.conn = conn
|
| 129 |
+
try:
|
| 130 |
+
yield conn
|
| 131 |
+
except sqlite3.Error as e:
|
| 132 |
+
raise StorageError(
|
| 133 |
+
f"SQLite error: {type(e).__name__}",
|
| 134 |
+
recovery="See exception cause for detail; consider checking schema version and disk health.",
|
| 135 |
+
) from e
|
| 136 |
+
|
| 137 |
+
@contextmanager
|
| 138 |
+
def transaction(self) -> Iterator[sqlite3.Connection]:
|
| 139 |
+
"""Atomic transaction. Rolls back on exception, commits on clean exit."""
|
| 140 |
+
with self.connection() as conn:
|
| 141 |
+
conn.execute("BEGIN IMMEDIATE")
|
| 142 |
+
try:
|
| 143 |
+
yield conn
|
| 144 |
+
except Exception:
|
| 145 |
+
conn.execute("ROLLBACK")
|
| 146 |
+
raise
|
| 147 |
+
else:
|
| 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():
|
| 158 |
+
raise SchemaError(
|
| 159 |
+
"Schema file missing from package install",
|
| 160 |
+
recovery="The package install is corrupted. Reinstall sibyl-memory-client.",
|
| 161 |
+
) from None
|
| 162 |
+
sql = _SCHEMA_PATH.read_text(encoding="utf-8")
|
| 163 |
+
with self.connection() as conn:
|
| 164 |
+
try:
|
| 165 |
+
conn.executescript(sql)
|
| 166 |
+
except sqlite3.Error as e:
|
| 167 |
+
raise SchemaError(
|
| 168 |
+
f"Failed to apply schema: {e}",
|
| 169 |
+
recovery="Check sqlite3 version (need 3.38+ for json_valid). On older systems, upgrade.",
|
| 170 |
+
) from e
|
| 171 |
+
# Run migrations that need imperative work beyond CREATE IF NOT EXISTS.
|
| 172 |
+
self._migrate_if_needed()
|
| 173 |
+
|
| 174 |
+
def _migrate_if_needed(self) -> None:
|
| 175 |
+
"""Run any pending schema migrations.
|
| 176 |
+
|
| 177 |
+
Detection: examine `entities_fts`'s declared SQL via sqlite_master. If
|
| 178 |
+
the table was created in the v2 standalone shape (`entity_id UNINDEXED`)
|
| 179 |
+
we need to drop and rebuild it as external-content. The migration also
|
| 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(
|
| 187 |
+
"SELECT sql FROM sqlite_master WHERE type='table' AND name='entities_fts'"
|
| 188 |
+
).fetchone()
|
| 189 |
+
if row is None:
|
| 190 |
+
# Fresh DB. Schema.sql already created v3 shape correctly.
|
| 191 |
+
return
|
| 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
|
| 199 |
+
# shape, rebuild from base table data. The CREATE statements in
|
| 200 |
+
# schema.sql will pick up after the DROP because they're CREATE IF
|
| 201 |
+
# NOT EXISTS.
|
| 202 |
+
try:
|
| 203 |
+
with self.transaction() as conn:
|
| 204 |
+
# Drop old FTS5 tables + their triggers
|
| 205 |
+
conn.execute("DROP TRIGGER IF EXISTS entities_ai_fts")
|
| 206 |
+
conn.execute("DROP TRIGGER IF EXISTS entities_ad_fts")
|
| 207 |
+
conn.execute("DROP TRIGGER IF EXISTS entities_au_fts")
|
| 208 |
+
conn.execute("DROP TABLE IF EXISTS entities_fts")
|
| 209 |
+
conn.execute("DROP TRIGGER IF EXISTS reference_ai_fts")
|
| 210 |
+
conn.execute("DROP TRIGGER IF EXISTS reference_ad_fts")
|
| 211 |
+
conn.execute("DROP TRIGGER IF EXISTS reference_au_fts")
|
| 212 |
+
conn.execute("DROP TABLE IF EXISTS reference_documents_fts")
|
| 213 |
+
# Re-run schema.sql so the v3 external-content tables + triggers
|
| 214 |
+
# land (CREATE IF NOT EXISTS picks up the dropped tables).
|
| 215 |
+
sql_text = _SCHEMA_PATH.read_text(encoding="utf-8")
|
| 216 |
+
with self.connection() as conn:
|
| 217 |
+
conn.executescript(sql_text)
|
| 218 |
+
# Rebuild FTS5 indexes from base tables for any pre-existing data.
|
| 219 |
+
with self.transaction() as conn:
|
| 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 |
+
"""
|
| 227 |
+
INSERT INTO journal_events_fts(rowid, ts, payload, tenant_id)
|
| 228 |
+
SELECT rowid, ts,
|
| 229 |
+
COALESCE(evaluated,'') || ' ' || COALESCE(acted,'') || ' ' ||
|
| 230 |
+
COALESCE(forward,'') || ' ' || COALESCE(extra,''),
|
| 231 |
+
tenant_id
|
| 232 |
+
FROM journal_events
|
| 233 |
+
"""
|
| 234 |
+
)
|
| 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:
|
| 242 |
+
"""Return current schema version, or None if uninitialized."""
|
| 243 |
+
with self.connection() as conn:
|
| 244 |
+
row = conn.execute(
|
| 245 |
+
"SELECT MAX(version) AS v FROM sibyl_memory_schema_version"
|
| 246 |
+
).fetchone()
|
| 247 |
+
return row["v"] if row else None
|
| 248 |
+
|
| 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.
|
| 256 |
+
"""
|
| 257 |
+
if not hasattr(os, "chmod"):
|
| 258 |
+
return # platform without POSIX chmod (Windows)
|
| 259 |
+
targets = [self.db_path]
|
| 260 |
+
for suffix in _DB_SIDECAR_SUFFIXES:
|
| 261 |
+
sidecar = self.db_path.with_name(self.db_path.name + suffix)
|
| 262 |
+
if sidecar.exists():
|
| 263 |
+
targets.append(sidecar)
|
| 264 |
+
for path in targets:
|
| 265 |
+
try:
|
| 266 |
+
os.chmod(path, _DB_FILE_MODE)
|
| 267 |
+
except OSError:
|
| 268 |
+
# Non-fatal: log nothing, defer to caller noticing if perms
|
| 269 |
+
# are truly broken (write operations will fail downstream).
|
| 270 |
+
pass
|
| 271 |
+
|
| 272 |
+
def close(self) -> None:
|
| 273 |
+
"""Close the thread-local connection (mainly for tests / shutdown)."""
|
| 274 |
+
conn = getattr(self._tls, "conn", None)
|
| 275 |
+
if conn is not None:
|
| 276 |
+
conn.close()
|
| 277 |
+
self._tls.conn = None
|
sibyl-memory-client/tests/test_capcheck.py
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the v0.3.0 hard-cap enforcement.
|
| 2 |
+
|
| 3 |
+
Three concerns covered:
|
| 4 |
+
1. Free-tier writes are blocked once the DB crosses 2 MB
|
| 5 |
+
2. The server check fires at the boundary and updates the local tier cache
|
| 6 |
+
3. The 7-day grace cache works (paid β uncapped writes without phoning home)
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import time
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
import pytest
|
| 14 |
+
|
| 15 |
+
from sibyl_memory_client import (
|
| 16 |
+
CapExceededError,
|
| 17 |
+
CapGate,
|
| 18 |
+
MemoryClient,
|
| 19 |
+
TierCache,
|
| 20 |
+
TierCacheEntry,
|
| 21 |
+
TierVerificationError,
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# ----------------------------------------------------------------------
|
| 26 |
+
# Fake check-write transport β lets us simulate server responses without
|
| 27 |
+
# hitting the network
|
| 28 |
+
# ----------------------------------------------------------------------
|
| 29 |
+
|
| 30 |
+
class FakeServer:
|
| 31 |
+
"""Mocks the /api/plugin/check-write endpoint."""
|
| 32 |
+
|
| 33 |
+
def __init__(self, *, tier: str = "free", offline: bool = False) -> None:
|
| 34 |
+
self.tier = tier
|
| 35 |
+
self.offline = offline
|
| 36 |
+
self.calls: list[dict] = []
|
| 37 |
+
|
| 38 |
+
def __call__(self, url, payload, timeout=4.0):
|
| 39 |
+
if self.offline:
|
| 40 |
+
raise TierVerificationError("simulated network down")
|
| 41 |
+
self.calls.append(payload)
|
| 42 |
+
# Paid tier β unconditional ok
|
| 43 |
+
if self.tier in ("sync", "team", "lifetime", "stake", "enterprise"):
|
| 44 |
+
return {"ok": True, "tier": self.tier, "cap_bytes": None}
|
| 45 |
+
# Free tier β check size
|
| 46 |
+
new = payload["current_size_bytes"] + payload["proposed_delta_bytes"]
|
| 47 |
+
cap = 2 * 1024 * 1024
|
| 48 |
+
if new <= cap:
|
| 49 |
+
return {"ok": True, "tier": "free", "cap_bytes": cap,
|
| 50 |
+
"remaining_bytes": cap - new}
|
| 51 |
+
return {
|
| 52 |
+
"ok": False, "tier": "free", "cap_bytes": cap,
|
| 53 |
+
"upgrade_url": "https://docs.sibyllabs.org/memory/tiers",
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
# ----------------------------------------------------------------------
|
| 58 |
+
# Direct CapGate tests
|
| 59 |
+
# ----------------------------------------------------------------------
|
| 60 |
+
|
| 61 |
+
def test_under_cap_no_server_call(tmp_path: Path) -> None:
|
| 62 |
+
server = FakeServer(tier="free")
|
| 63 |
+
cache = TierCache(tmp_path / "tc.json")
|
| 64 |
+
gate = CapGate(
|
| 65 |
+
account_id="acc-1",
|
| 66 |
+
session_token="sess-1",
|
| 67 |
+
db_size_fn=lambda: 100_000,
|
| 68 |
+
local_tier_hint="free",
|
| 69 |
+
cache=cache,
|
| 70 |
+
check_fn=server,
|
| 71 |
+
)
|
| 72 |
+
gate.check(proposed_delta_bytes=1000)
|
| 73 |
+
assert len(server.calls) == 0 # didn't phone home
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def test_at_cap_server_says_no(tmp_path: Path) -> None:
|
| 77 |
+
server = FakeServer(tier="free")
|
| 78 |
+
cache = TierCache(tmp_path / "tc.json")
|
| 79 |
+
gate = CapGate(
|
| 80 |
+
account_id="acc-1",
|
| 81 |
+
session_token="sess-1",
|
| 82 |
+
db_size_fn=lambda: 2 * 1024 * 1024 - 100, # 100 bytes below cap
|
| 83 |
+
local_tier_hint="free",
|
| 84 |
+
cache=cache,
|
| 85 |
+
check_fn=server,
|
| 86 |
+
)
|
| 87 |
+
with pytest.raises(CapExceededError) as exc:
|
| 88 |
+
gate.check(proposed_delta_bytes=500) # would push past cap
|
| 89 |
+
assert exc.value.cap == 2 * 1024 * 1024
|
| 90 |
+
assert "sibyllabs.org" in exc.value.upgrade_url
|
| 91 |
+
assert len(server.calls) == 1 # one boundary check
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def test_at_cap_server_upgrades_user(tmp_path: Path) -> None:
|
| 95 |
+
"""User claims free in credentials but server says they're now paid
|
| 96 |
+
(upgraded since last activation). The write should be permitted."""
|
| 97 |
+
server = FakeServer(tier="lifetime")
|
| 98 |
+
cache = TierCache(tmp_path / "tc.json")
|
| 99 |
+
gate = CapGate(
|
| 100 |
+
account_id="acc-1",
|
| 101 |
+
session_token="sess-1",
|
| 102 |
+
db_size_fn=lambda: 2 * 1024 * 1024 + 1000, # past free cap
|
| 103 |
+
local_tier_hint="free", # cached credentials say free
|
| 104 |
+
cache=cache,
|
| 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
|
| 112 |
+
assert cached.tier == "lifetime"
|
| 113 |
+
assert cached.cap_bytes is None # paid = no cap
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def test_paid_cache_skips_server(tmp_path: Path) -> None:
|
| 117 |
+
"""If we have a fresh cache saying we're paid, no server call needed."""
|
| 118 |
+
server = FakeServer(tier="free") # would say no if called
|
| 119 |
+
cache = TierCache(tmp_path / "tc.json")
|
| 120 |
+
# Pre-populate cache as paid
|
| 121 |
+
cache.store(TierCacheEntry(
|
| 122 |
+
account_id="acc-1",
|
| 123 |
+
tier="lifetime",
|
| 124 |
+
checked_at=time.time(),
|
| 125 |
+
cap_bytes=None,
|
| 126 |
+
))
|
| 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,
|
| 134 |
+
)
|
| 135 |
+
gate.check(proposed_delta_bytes=10_000)
|
| 136 |
+
assert len(server.calls) == 0 # cache short-circuited
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def test_stale_paid_cache_triggers_refresh(tmp_path: Path) -> None:
|
| 140 |
+
"""An 8-day-old cache should NOT be honored as fresh."""
|
| 141 |
+
server = FakeServer(tier="lifetime")
|
| 142 |
+
cache = TierCache(tmp_path / "tc.json")
|
| 143 |
+
# Pre-populate cache as paid, but 8 days old
|
| 144 |
+
cache.store(TierCacheEntry(
|
| 145 |
+
account_id="acc-1",
|
| 146 |
+
tier="lifetime",
|
| 147 |
+
checked_at=time.time() - 8 * 24 * 60 * 60, # 8 days ago
|
| 148 |
+
cap_bytes=None,
|
| 149 |
+
))
|
| 150 |
+
gate = CapGate(
|
| 151 |
+
account_id="acc-1",
|
| 152 |
+
session_token="sess-1",
|
| 153 |
+
db_size_fn=lambda: 5 * 1024 * 1024,
|
| 154 |
+
local_tier_hint="free",
|
| 155 |
+
cache=cache,
|
| 156 |
+
check_fn=server,
|
| 157 |
+
)
|
| 158 |
+
gate.check(proposed_delta_bytes=10_000)
|
| 159 |
+
# Stale cache, so server WAS called
|
| 160 |
+
assert len(server.calls) == 1
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def test_offline_at_cap_with_recent_paid_cache(tmp_path: Path) -> None:
|
| 164 |
+
"""Honest paid user goes offline. Should still be allowed to write."""
|
| 165 |
+
server = FakeServer(offline=True)
|
| 166 |
+
cache = TierCache(tmp_path / "tc.json")
|
| 167 |
+
cache.store(TierCacheEntry(
|
| 168 |
+
account_id="acc-1",
|
| 169 |
+
tier="lifetime",
|
| 170 |
+
checked_at=time.time(),
|
| 171 |
+
cap_bytes=None,
|
| 172 |
+
))
|
| 173 |
+
gate = CapGate(
|
| 174 |
+
account_id="acc-1",
|
| 175 |
+
session_token="sess-1",
|
| 176 |
+
db_size_fn=lambda: 50 * 1024 * 1024,
|
| 177 |
+
local_tier_hint="free",
|
| 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 |
+
|
| 185 |
+
def test_offline_at_cap_no_cache_raises(tmp_path: Path) -> None:
|
| 186 |
+
"""No cache + offline + at the cap = TierVerificationError."""
|
| 187 |
+
server = FakeServer(offline=True)
|
| 188 |
+
cache = TierCache(tmp_path / "tc.json")
|
| 189 |
+
gate = CapGate(
|
| 190 |
+
account_id="acc-1",
|
| 191 |
+
session_token="sess-1",
|
| 192 |
+
db_size_fn=lambda: 2 * 1024 * 1024 + 100,
|
| 193 |
+
local_tier_hint="free",
|
| 194 |
+
cache=cache,
|
| 195 |
+
check_fn=server,
|
| 196 |
+
)
|
| 197 |
+
with pytest.raises(TierVerificationError):
|
| 198 |
+
gate.check(proposed_delta_bytes=500)
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def test_no_account_id_under_cap_passes(tmp_path: Path) -> None:
|
| 202 |
+
"""Pre-activation user under the cap should work."""
|
| 203 |
+
server = FakeServer(tier="free")
|
| 204 |
+
cache = TierCache(tmp_path / "tc.json")
|
| 205 |
+
gate = CapGate(
|
| 206 |
+
account_id=None,
|
| 207 |
+
session_token=None,
|
| 208 |
+
db_size_fn=lambda: 1_000_000,
|
| 209 |
+
local_tier_hint="free",
|
| 210 |
+
cache=cache,
|
| 211 |
+
check_fn=server,
|
| 212 |
+
)
|
| 213 |
+
gate.check(proposed_delta_bytes=1000)
|
| 214 |
+
assert len(server.calls) == 0
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def test_no_account_id_at_cap_blocks(tmp_path: Path) -> None:
|
| 218 |
+
"""Pre-activation user past the cap β hard block."""
|
| 219 |
+
server = FakeServer(tier="free")
|
| 220 |
+
cache = TierCache(tmp_path / "tc.json")
|
| 221 |
+
gate = CapGate(
|
| 222 |
+
account_id=None,
|
| 223 |
+
session_token=None,
|
| 224 |
+
db_size_fn=lambda: 2 * 1024 * 1024 + 100,
|
| 225 |
+
local_tier_hint="free",
|
| 226 |
+
cache=cache,
|
| 227 |
+
check_fn=server,
|
| 228 |
+
)
|
| 229 |
+
with pytest.raises(CapExceededError):
|
| 230 |
+
gate.check(proposed_delta_bytes=500)
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
# ----------------------------------------------------------------------
|
| 234 |
+
# End-to-end test through MemoryClient
|
| 235 |
+
# ----------------------------------------------------------------------
|
| 236 |
+
|
| 237 |
+
def test_e2e_free_tier_blocked_at_cap(tmp_path: Path) -> None:
|
| 238 |
+
"""Writing past the 2 MB cap raises CapExceededError when the server
|
| 239 |
+
confirms free tier."""
|
| 240 |
+
server = FakeServer(tier="free")
|
| 241 |
+
cache = TierCache(tmp_path / "tc.json")
|
| 242 |
+
db_path = tmp_path / "memory.db"
|
| 243 |
+
|
| 244 |
+
# Build a custom gate using a synthetic large db_size to skip the slow
|
| 245 |
+
# path of actually writing 2 MB of data.
|
| 246 |
+
from sibyl_memory_client._capcheck import CapGate
|
| 247 |
+
fake_size = [100] # mutable, lets us simulate growth
|
| 248 |
+
|
| 249 |
+
gate = CapGate(
|
| 250 |
+
account_id="acc-1",
|
| 251 |
+
session_token="sess-1",
|
| 252 |
+
db_size_fn=lambda: fake_size[0],
|
| 253 |
+
local_tier_hint="free",
|
| 254 |
+
cache=cache,
|
| 255 |
+
check_fn=server,
|
| 256 |
+
)
|
| 257 |
+
client = MemoryClient(
|
| 258 |
+
storage=__import__("sibyl_memory_client").Storage(str(db_path)),
|
| 259 |
+
tenant_id="alice",
|
| 260 |
+
tier="free",
|
| 261 |
+
account_id="acc-1",
|
| 262 |
+
session_token="sess-1",
|
| 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
|
| 270 |
+
fake_size[0] = 2 * 1024 * 1024 - 100
|
| 271 |
+
|
| 272 |
+
# Next write would push over β server-checked β blocked
|
| 273 |
+
with pytest.raises(CapExceededError):
|
| 274 |
+
client.set_entity("project", "borealis", {"status": "active", "x": "y" * 500})
|
| 275 |
+
|
| 276 |
+
# Server was consulted
|
| 277 |
+
assert len(server.calls) >= 1
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
def test_e2e_paid_tier_no_cap(tmp_path: Path) -> None:
|
| 281 |
+
"""Paid tier bypasses the cap entirely (within grace period)."""
|
| 282 |
+
server = FakeServer(tier="lifetime")
|
| 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(
|
| 290 |
+
account_id="acc-1",
|
| 291 |
+
session_token="sess-1",
|
| 292 |
+
db_size_fn=lambda: fake_size[0],
|
| 293 |
+
local_tier_hint="lifetime",
|
| 294 |
+
cache=cache,
|
| 295 |
+
check_fn=server,
|
| 296 |
+
)
|
| 297 |
+
client = MemoryClient(
|
| 298 |
+
storage=__import__("sibyl_memory_client").Storage(str(db_path)),
|
| 299 |
+
tenant_id="alice",
|
| 300 |
+
tier="lifetime",
|
| 301 |
+
account_id="acc-1",
|
| 302 |
+
session_token="sess-1",
|
| 303 |
+
cap_gate=gate,
|
| 304 |
+
)
|
| 305 |
+
# Writes succeed even though we're 50 MB in
|
| 306 |
+
client.set_entity("project", "atlas", {"status": "active"})
|
| 307 |
+
client.set_entity("project", "borealis", {"status": "active"})
|
| 308 |
+
client.set_state("priorities", {"top": ["ship"]})
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
def test_cache_file_is_0600(tmp_path: Path) -> None:
|
| 312 |
+
"""Tier cache must not be world-readable."""
|
| 313 |
+
cache = TierCache(tmp_path / "tc.json")
|
| 314 |
+
cache.store(TierCacheEntry(
|
| 315 |
+
account_id="acc-1",
|
| 316 |
+
tier="free",
|
| 317 |
+
checked_at=time.time(),
|
| 318 |
+
cap_bytes=2 * 1024 * 1024,
|
| 319 |
+
))
|
| 320 |
+
mode = oct((tmp_path / "tc.json").stat().st_mode)[-3:]
|
| 321 |
+
assert mode == "600"
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
def test_cap_gate_invalidate_cache(tmp_path: Path) -> None:
|
| 325 |
+
cache = TierCache(tmp_path / "tc.json")
|
| 326 |
+
cache.store(TierCacheEntry(
|
| 327 |
+
account_id="acc-1", tier="free", checked_at=time.time(), cap_bytes=2_000_000,
|
| 328 |
+
))
|
| 329 |
+
assert cache.load() is not None
|
| 330 |
+
gate = CapGate(
|
| 331 |
+
account_id="acc-1",
|
| 332 |
+
session_token="sess-1",
|
| 333 |
+
db_size_fn=lambda: 0,
|
| 334 |
+
local_tier_hint="free",
|
| 335 |
+
cache=cache,
|
| 336 |
+
check_fn=FakeServer(),
|
| 337 |
+
)
|
| 338 |
+
gate.invalidate_cache()
|
| 339 |
+
assert cache.load() is None
|
sibyl-memory-client/tests/test_kappa_fixes.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Regression tests for the v0.4.0 KAPPA-attributed fixes.
|
| 2 |
+
|
| 3 |
+
Covers:
|
| 4 |
+
- BLOCKER: CapExceededError + TierVerificationError importable from
|
| 5 |
+
sibyl_memory_client.exceptions (the canonical submodule path).
|
| 6 |
+
- RED: memory.db is chmod 0600 after Storage init.
|
| 7 |
+
- YELLOW: validate_identifier rejects empty / null-byte / non-string /
|
| 8 |
+
oversized. set_entity, set_state, set_reference call it.
|
| 9 |
+
- YELLOW: _classify_fts5_error returns the right exception type for the
|
| 10 |
+
three buckets (schema-missing β None; FTS5-syntax β ValidationError;
|
| 11 |
+
backend β StorageError).
|
| 12 |
+
|
| 13 |
+
Source bug report: /tmp/kappa-sibyl-memory-mcp-report.md
|
| 14 |
+
(KAPPA, 2026-05-18, via Acer/Tulip referral).
|
| 15 |
+
"""
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import os
|
| 19 |
+
import sqlite3
|
| 20 |
+
import stat
|
| 21 |
+
import sys
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
|
| 24 |
+
import pytest
|
| 25 |
+
|
| 26 |
+
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# ----------------------------------------------------------------------
|
| 30 |
+
# BLOCKER β submodule exception path
|
| 31 |
+
# ----------------------------------------------------------------------
|
| 32 |
+
|
| 33 |
+
def test_cap_exceeded_error_importable_from_exceptions_submodule():
|
| 34 |
+
"""KAPPA's exact import path. server.py:41 does this; before v0.4.0
|
| 35 |
+
it raised ImportError."""
|
| 36 |
+
from sibyl_memory_client.exceptions import CapExceededError
|
| 37 |
+
assert CapExceededError.__name__ == "CapExceededError"
|
| 38 |
+
assert CapExceededError.code == "CAP_EXCEEDED"
|
| 39 |
+
# Constructor contract: positional message + required keyword args
|
| 40 |
+
err = CapExceededError("test", current_size=100, cap=200, proposed_delta=10)
|
| 41 |
+
assert err.current_size == 100
|
| 42 |
+
assert err.cap == 200
|
| 43 |
+
assert err.proposed_delta == 10
|
| 44 |
+
assert "tiers" in err.upgrade_url
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def test_tier_verification_error_importable_from_exceptions_submodule():
|
| 48 |
+
"""Same submodule path. KAPPA's blocker covered both classes."""
|
| 49 |
+
from sibyl_memory_client.exceptions import TierVerificationError
|
| 50 |
+
assert TierVerificationError.__name__ == "TierVerificationError"
|
| 51 |
+
assert TierVerificationError.code == "TIER_VERIFY_FAILED"
|
| 52 |
+
err = TierVerificationError("test")
|
| 53 |
+
assert "internet" in err.recovery or "verify" in err.recovery
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def test_capcheck_backwards_compat_reexports():
|
| 57 |
+
"""Anyone reaching into the private _capcheck module should still get the
|
| 58 |
+
same class objects (identity check) post-relocation."""
|
| 59 |
+
from sibyl_memory_client.exceptions import CapExceededError as E_exc
|
| 60 |
+
from sibyl_memory_client._capcheck import CapExceededError as E_cap
|
| 61 |
+
assert E_exc is E_cap, "_capcheck must re-export the same class object"
|
| 62 |
+
from sibyl_memory_client.exceptions import TierVerificationError as T_exc
|
| 63 |
+
from sibyl_memory_client._capcheck import TierVerificationError as T_cap
|
| 64 |
+
assert T_exc is T_cap
|
| 65 |
+
|
| 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"
|
| 73 |
+
assert TierVerificationError.__name__ == "TierVerificationError"
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
# ----------------------------------------------------------------------
|
| 77 |
+
# RED β memory.db file perms
|
| 78 |
+
# ----------------------------------------------------------------------
|
| 79 |
+
|
| 80 |
+
@pytest.mark.skipif(not hasattr(os, "chmod"), reason="POSIX-only test")
|
| 81 |
+
def test_memory_db_file_perms_are_0600(tmp_path):
|
| 82 |
+
"""KAPPA RED finding. Docs claim 0600, actual was 0644 (umask default).
|
| 83 |
+
After v0.4.0, Storage init tightens to 0600 unconditionally."""
|
| 84 |
+
from sibyl_memory_client import MemoryClient
|
| 85 |
+
db_path = tmp_path / "memory.db"
|
| 86 |
+
MemoryClient.local(db_path)
|
| 87 |
+
assert db_path.exists(), "DB file should exist after Storage init"
|
| 88 |
+
mode = stat.S_IMODE(db_path.stat().st_mode)
|
| 89 |
+
assert mode == 0o600, f"memory.db mode should be 0600, got 0o{mode:o}"
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
@pytest.mark.skipif(not hasattr(os, "chmod"), reason="POSIX-only test")
|
| 93 |
+
def test_memory_db_wal_sidecar_perms_tighten_when_present(tmp_path):
|
| 94 |
+
"""WAL/SHM sidecar files also get 0600 if they exist after a write."""
|
| 95 |
+
from sibyl_memory_client import MemoryClient
|
| 96 |
+
db_path = tmp_path / "memory.db"
|
| 97 |
+
client = MemoryClient.local(db_path)
|
| 98 |
+
# Force a write so WAL/SHM appear, then re-init to trigger another chmod pass.
|
| 99 |
+
client.set_entity("test", "alpha", {"k": "v"})
|
| 100 |
+
# Re-open to trigger the chmod pass on the WAL/SHM files
|
| 101 |
+
MemoryClient.local(db_path)
|
| 102 |
+
for suffix in ("-wal", "-shm"):
|
| 103 |
+
sidecar = db_path.with_name(db_path.name + suffix)
|
| 104 |
+
if sidecar.exists():
|
| 105 |
+
mode = stat.S_IMODE(sidecar.stat().st_mode)
|
| 106 |
+
assert mode == 0o600, f"{sidecar.name} should be 0600, got 0o{mode:o}"
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
# ----------------------------------------------------------------------
|
| 110 |
+
# YELLOW β validate_identifier
|
| 111 |
+
# ----------------------------------------------------------------------
|
| 112 |
+
|
| 113 |
+
def test_validate_identifier_rejects_empty():
|
| 114 |
+
from sibyl_memory_client.client import validate_identifier
|
| 115 |
+
from sibyl_memory_client.exceptions import ValidationError
|
| 116 |
+
with pytest.raises(ValidationError, match="cannot be empty"):
|
| 117 |
+
validate_identifier("", field_name="name")
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def test_validate_identifier_rejects_non_string():
|
| 121 |
+
from sibyl_memory_client.client import validate_identifier
|
| 122 |
+
from sibyl_memory_client.exceptions import ValidationError
|
| 123 |
+
with pytest.raises(ValidationError, match="must be a string"):
|
| 124 |
+
validate_identifier(123, field_name="name")
|
| 125 |
+
with pytest.raises(ValidationError, match="must be a string"):
|
| 126 |
+
validate_identifier(None, field_name="name")
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def test_validate_identifier_rejects_null_bytes():
|
| 130 |
+
from sibyl_memory_client.client import validate_identifier
|
| 131 |
+
from sibyl_memory_client.exceptions import ValidationError
|
| 132 |
+
with pytest.raises(ValidationError, match="forbidden control character"):
|
| 133 |
+
validate_identifier("foo\x00bar", field_name="name")
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def test_validate_identifier_rejects_other_control_chars():
|
| 137 |
+
from sibyl_memory_client.client import validate_identifier
|
| 138 |
+
from sibyl_memory_client.exceptions import ValidationError
|
| 139 |
+
with pytest.raises(ValidationError, match="forbidden control character"):
|
| 140 |
+
validate_identifier("foo\tbar", field_name="key") # tab
|
| 141 |
+
with pytest.raises(ValidationError, match="forbidden control character"):
|
| 142 |
+
validate_identifier("foo\nbar", field_name="key") # newline
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def test_validate_identifier_rejects_oversized():
|
| 146 |
+
from sibyl_memory_client.client import validate_identifier
|
| 147 |
+
from sibyl_memory_client.exceptions import ValidationError
|
| 148 |
+
too_long = "a" * 1025
|
| 149 |
+
with pytest.raises(ValidationError, match="too long"):
|
| 150 |
+
validate_identifier(too_long, field_name="name")
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def test_validate_identifier_accepts_reasonable():
|
| 154 |
+
from sibyl_memory_client.client import validate_identifier
|
| 155 |
+
# All of these should pass
|
| 156 |
+
for ok in ("foo", "alice", "project-atlas", "a", "x" * 1024,
|
| 157 |
+
"with spaces", "unicode-Γ©-Γ±-δΈ", "with.dot", "with/slash"):
|
| 158 |
+
assert validate_identifier(ok, field_name="name") == ok
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
# ----------------------------------------------------------------------
|
| 162 |
+
# YELLOW β write paths call validate_identifier
|
| 163 |
+
# ----------------------------------------------------------------------
|
| 164 |
+
|
| 165 |
+
def test_set_entity_rejects_empty_name(tmp_path):
|
| 166 |
+
from sibyl_memory_client import MemoryClient
|
| 167 |
+
from sibyl_memory_client.exceptions import ValidationError
|
| 168 |
+
client = MemoryClient.local(tmp_path / "memory.db")
|
| 169 |
+
with pytest.raises(ValidationError, match="cannot be empty"):
|
| 170 |
+
client.set_entity("project", "", {"k": "v"})
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def test_set_entity_rejects_null_byte_in_category(tmp_path):
|
| 174 |
+
from sibyl_memory_client import MemoryClient
|
| 175 |
+
from sibyl_memory_client.exceptions import ValidationError
|
| 176 |
+
client = MemoryClient.local(tmp_path / "memory.db")
|
| 177 |
+
with pytest.raises(ValidationError, match="forbidden control character"):
|
| 178 |
+
client.set_entity("proj\x00ect", "atlas", {"k": "v"})
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def test_set_state_rejects_oversized_key(tmp_path):
|
| 182 |
+
from sibyl_memory_client import MemoryClient
|
| 183 |
+
from sibyl_memory_client.exceptions import ValidationError
|
| 184 |
+
client = MemoryClient.local(tmp_path / "memory.db")
|
| 185 |
+
with pytest.raises(ValidationError, match="too long"):
|
| 186 |
+
client.set_state("k" * 2000, {"v": 1})
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def test_set_reference_rejects_empty_key(tmp_path):
|
| 190 |
+
from sibyl_memory_client import MemoryClient
|
| 191 |
+
from sibyl_memory_client.exceptions import ValidationError
|
| 192 |
+
client = MemoryClient.local(tmp_path / "memory.db")
|
| 193 |
+
with pytest.raises(ValidationError, match="cannot be empty"):
|
| 194 |
+
client.set_reference("", "body text")
|
| 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 |
+
|
| 202 |
+
We can't easily inject bad data through a write (validation blocks),
|
| 203 |
+
but we can confirm get_entity/get_state with weird-but-not-validated
|
| 204 |
+
inputs returns NotFoundError (the lookup path), not ValidationError."""
|
| 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")
|
| 212 |
+
# get_state returns None for missing keys (not raise).
|
| 213 |
+
assert client.get_state("nonexistent") is None
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
# ----------------------------------------------------------------------
|
| 217 |
+
# YELLOW β FTS5 error classifier
|
| 218 |
+
# ----------------------------------------------------------------------
|
| 219 |
+
|
| 220 |
+
def test_classify_fts5_error_schema_missing_returns_none():
|
| 221 |
+
"""no such table case β caller should return empty (defensive)."""
|
| 222 |
+
from sibyl_memory_client.client import _classify_fts5_error
|
| 223 |
+
err = sqlite3.OperationalError("no such table: entities_fts")
|
| 224 |
+
assert _classify_fts5_error(err) is None
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def test_classify_fts5_error_syntax_returns_validation_error():
|
| 228 |
+
"""malformed match / fts5 syntax errors β ValidationError."""
|
| 229 |
+
from sibyl_memory_client.client import _classify_fts5_error
|
| 230 |
+
from sibyl_memory_client.exceptions import ValidationError
|
| 231 |
+
for msg in (
|
| 232 |
+
"fts5: syntax error near \"AND\"",
|
| 233 |
+
"malformed MATCH expression: \"bad\"",
|
| 234 |
+
"fts5 query error",
|
| 235 |
+
"no such column: invalid_col",
|
| 236 |
+
):
|
| 237 |
+
err = sqlite3.OperationalError(msg)
|
| 238 |
+
result = _classify_fts5_error(err)
|
| 239 |
+
assert isinstance(result, ValidationError), \
|
| 240 |
+
f"expected ValidationError for {msg!r}, got {type(result)}"
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def test_classify_fts5_error_other_returns_storage_error():
|
| 244 |
+
"""Anything else (disk full, locked, etc.) β StorageError."""
|
| 245 |
+
from sibyl_memory_client.client import _classify_fts5_error
|
| 246 |
+
from sibyl_memory_client.exceptions import StorageError
|
| 247 |
+
err = sqlite3.OperationalError("database is locked")
|
| 248 |
+
result = _classify_fts5_error(err)
|
| 249 |
+
assert isinstance(result, StorageError)
|
| 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 []
|
| 262 |
+
assert client.search("") == []
|
| 263 |
+
# Whitespace-only query short-circuits to []
|
| 264 |
+
assert client.search(" ") == []
|
| 265 |
+
|
| 266 |
+
|
| 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")
|
| 274 |
+
client.set_entity("project", "atlas", {"description": "alpha bravo charlie"})
|
| 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
|
| 282 |
+
hits = client.search_entities("*")
|
| 283 |
+
assert hits == []
|
sibyl-memory-client/tests/test_learning.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Smoke tests for sibyl_memory_client.learning."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
|
| 8 |
+
from sibyl_memory_client import (
|
| 9 |
+
BYOKSummarizer,
|
| 10 |
+
Learner,
|
| 11 |
+
LearningRunReport,
|
| 12 |
+
LocalDeterministicSummarizer,
|
| 13 |
+
MemoryClient,
|
| 14 |
+
SkillProposal,
|
| 15 |
+
VeniceX402Summarizer,
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
# ----------------------------------------------------------------------
|
| 20 |
+
# Fixtures
|
| 21 |
+
# ----------------------------------------------------------------------
|
| 22 |
+
|
| 23 |
+
@pytest.fixture
|
| 24 |
+
def client(tmp_path: Path) -> MemoryClient:
|
| 25 |
+
db = tmp_path / "memory.db"
|
| 26 |
+
# Self-learning is paid-tier only. Tests run as a lifetime-tier user.
|
| 27 |
+
return MemoryClient.local(str(db), tier="lifetime")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _seed_repeated_action(client: MemoryClient, n: int = 4) -> None:
|
| 31 |
+
"""Write N events with the same action signature."""
|
| 32 |
+
for i in range(n):
|
| 33 |
+
client.write_event(
|
| 34 |
+
evaluated={"task": "fix bug", "ticket": f"TASK-{i}"},
|
| 35 |
+
acted=["deployed atlas to staging"],
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _seed_structural_pattern(client: MemoryClient, n: int = 3) -> None:
|
| 40 |
+
"""Write N events with the same evaluated key set."""
|
| 41 |
+
for i in range(n):
|
| 42 |
+
client.write_event(
|
| 43 |
+
evaluated={"step": i, "module": "auth", "owner": "jane"},
|
| 44 |
+
acted={"kind": f"checkpoint-{i}"},
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# ----------------------------------------------------------------------
|
| 49 |
+
# Schema migration v1 β v2 (the new tables must exist after open)
|
| 50 |
+
# ----------------------------------------------------------------------
|
| 51 |
+
def test_schema_v2_applied(client: MemoryClient) -> None:
|
| 52 |
+
assert client.schema_version() >= 2
|
| 53 |
+
# Tables should be queryable without error
|
| 54 |
+
proposals = client.list_skill_proposals()
|
| 55 |
+
assert proposals == []
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# ----------------------------------------------------------------------
|
| 59 |
+
# Learner basics
|
| 60 |
+
# ----------------------------------------------------------------------
|
| 61 |
+
def test_learner_no_events_no_proposals(client: MemoryClient) -> None:
|
| 62 |
+
report = client.learn()
|
| 63 |
+
assert isinstance(report, LearningRunReport)
|
| 64 |
+
assert report.events_scanned == 0
|
| 65 |
+
assert report.proposals_made == 0
|
| 66 |
+
assert report.summarizer == "local-deterministic"
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def test_learner_detects_repeated_action(client: MemoryClient) -> None:
|
| 70 |
+
_seed_repeated_action(client, n=4)
|
| 71 |
+
report = client.learn()
|
| 72 |
+
assert report.events_scanned >= 4
|
| 73 |
+
assert report.proposals_made >= 1
|
| 74 |
+
|
| 75 |
+
proposals = client.list_skill_proposals()
|
| 76 |
+
kinds = {p.pattern_kind for p in proposals}
|
| 77 |
+
assert "repeated_action" in kinds
|
| 78 |
+
rep = next(p for p in proposals if p.pattern_kind == "repeated_action")
|
| 79 |
+
assert rep.confidence > 0.4
|
| 80 |
+
assert rep.summarizer == "local-deterministic"
|
| 81 |
+
assert "deployed" in rep.proposed_body.lower()
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def test_learner_watermark_no_double_propose(client: MemoryClient) -> None:
|
| 85 |
+
_seed_repeated_action(client, n=4)
|
| 86 |
+
first = client.learn()
|
| 87 |
+
assert first.proposals_made >= 1
|
| 88 |
+
# Second run with no new events should skip
|
| 89 |
+
second = client.learn()
|
| 90 |
+
assert second.events_scanned == 0
|
| 91 |
+
assert second.proposals_made == 0
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def test_learner_detects_structural_similarity(client: MemoryClient) -> None:
|
| 95 |
+
_seed_structural_pattern(client, n=3)
|
| 96 |
+
report = client.learn()
|
| 97 |
+
proposals = client.list_skill_proposals()
|
| 98 |
+
kinds = {p.pattern_kind for p in proposals}
|
| 99 |
+
# Should at least pick up the shape
|
| 100 |
+
assert "structural_similarity" in kinds or "co_occurrence" in kinds
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
# ----------------------------------------------------------------------
|
| 104 |
+
# Review queue: accept / reject
|
| 105 |
+
# ----------------------------------------------------------------------
|
| 106 |
+
def test_accept_proposal_writes_reference(client: MemoryClient) -> None:
|
| 107 |
+
_seed_repeated_action(client, n=4)
|
| 108 |
+
client.learn()
|
| 109 |
+
proposals = client.list_skill_proposals()
|
| 110 |
+
assert proposals
|
| 111 |
+
|
| 112 |
+
target = proposals[0]
|
| 113 |
+
result = client.accept_skill_proposal(target.id, note="useful")
|
| 114 |
+
assert result["accepted"] is True
|
| 115 |
+
assert result["doc_key"].startswith("skill/")
|
| 116 |
+
|
| 117 |
+
# Reference doc landed
|
| 118 |
+
ref = client.get_reference(result["doc_key"])
|
| 119 |
+
assert ref is not None
|
| 120 |
+
assert target.proposed_body == ref["body"]
|
| 121 |
+
|
| 122 |
+
# Proposal status updated
|
| 123 |
+
after = client.list_skill_proposals(status="accepted")
|
| 124 |
+
assert any(p.id == target.id for p in after)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def test_reject_proposal_does_not_write_reference(client: MemoryClient) -> None:
|
| 128 |
+
_seed_repeated_action(client, n=4)
|
| 129 |
+
client.learn()
|
| 130 |
+
proposals = client.list_skill_proposals()
|
| 131 |
+
target = proposals[0]
|
| 132 |
+
|
| 133 |
+
result = client.reject_skill_proposal(target.id, note="not useful")
|
| 134 |
+
assert result["rejected"] is True
|
| 135 |
+
|
| 136 |
+
# No skill/<slug> reference doc should exist
|
| 137 |
+
assert client.get_reference(f"skill/{target.proposed_slug}") is None
|
| 138 |
+
|
| 139 |
+
# Proposal removed from pending
|
| 140 |
+
pending = client.list_skill_proposals(status="pending")
|
| 141 |
+
assert not any(p.id == target.id for p in pending)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def test_double_accept_raises(client: MemoryClient) -> None:
|
| 145 |
+
_seed_repeated_action(client, n=4)
|
| 146 |
+
client.learn()
|
| 147 |
+
target = client.list_skill_proposals()[0]
|
| 148 |
+
client.accept_skill_proposal(target.id)
|
| 149 |
+
with pytest.raises(Exception):
|
| 150 |
+
client.accept_skill_proposal(target.id)
|
| 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 = {}
|
| 158 |
+
|
| 159 |
+
def fake_inference(prompt: str) -> str:
|
| 160 |
+
captured["prompt"] = prompt
|
| 161 |
+
return "# Skill from BYOK\n\nDo the thing."
|
| 162 |
+
|
| 163 |
+
summarizer = BYOKSummarizer(fake_inference, provider_label="testlab")
|
| 164 |
+
assert summarizer.name == "byok-testlab"
|
| 165 |
+
|
| 166 |
+
_seed_repeated_action(client, n=4)
|
| 167 |
+
learner = client.learner(summarizer=summarizer)
|
| 168 |
+
report = learner.run()
|
| 169 |
+
assert report.summarizer == "byok-testlab"
|
| 170 |
+
assert report.proposals_made >= 1
|
| 171 |
+
|
| 172 |
+
# The summarizer was called with the journal context
|
| 173 |
+
assert "prompt" in captured
|
| 174 |
+
assert "behavioral pattern" in captured["prompt"]
|
| 175 |
+
|
| 176 |
+
proposals = learner.list_proposals()
|
| 177 |
+
assert any("Skill from BYOK" in p.proposed_body for p in proposals)
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def test_venice_x402_summarizer_fallback_on_error(client: MemoryClient) -> None:
|
| 181 |
+
def bad_inference(prompt: str) -> str:
|
| 182 |
+
raise RuntimeError("simulated network failure")
|
| 183 |
+
|
| 184 |
+
summarizer = VeniceX402Summarizer(bad_inference, account_id="acc-stub")
|
| 185 |
+
_seed_repeated_action(client, n=4)
|
| 186 |
+
learner = client.learner(summarizer=summarizer)
|
| 187 |
+
report = learner.run()
|
| 188 |
+
assert report.proposals_made >= 1
|
| 189 |
+
|
| 190 |
+
proposals = learner.list_proposals()
|
| 191 |
+
# Fallback note should be present
|
| 192 |
+
assert any("Venice/x402 call failed" in p.proposed_body for p in proposals)
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
# ----------------------------------------------------------------------
|
| 196 |
+
# Multi-tenant isolation
|
| 197 |
+
# ----------------------------------------------------------------------
|
| 198 |
+
def test_learner_is_tenant_scoped(tmp_path: Path) -> None:
|
| 199 |
+
db = tmp_path / "m.db"
|
| 200 |
+
alice = MemoryClient.local(str(db), tenant_id="alice", tier="lifetime")
|
| 201 |
+
bob = MemoryClient.local(str(db), tenant_id="bob", tier="lifetime")
|
| 202 |
+
|
| 203 |
+
_seed_repeated_action(alice, n=4)
|
| 204 |
+
alice.learn()
|
| 205 |
+
|
| 206 |
+
# Bob has not learned anything; should see zero proposals
|
| 207 |
+
bobs_proposals = bob.list_skill_proposals()
|
| 208 |
+
assert bobs_proposals == []
|
| 209 |
+
|
| 210 |
+
# Alice has at least one
|
| 211 |
+
alice_proposals = alice.list_skill_proposals()
|
| 212 |
+
assert alice_proposals
|
| 213 |
+
for p in alice_proposals:
|
| 214 |
+
assert p.tenant_id == "alice"
|
| 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
|
| 222 |
+
free = MemoryClient.local(str(tmp_path / "free.db")) # default tier="free"
|
| 223 |
+
with pytest.raises(TierGateError) as exc:
|
| 224 |
+
free.learn()
|
| 225 |
+
assert exc.value.feature == "self-learning"
|
| 226 |
+
assert exc.value.current_tier == "free"
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def test_free_tier_cannot_list_proposals(tmp_path: Path) -> None:
|
| 230 |
+
from sibyl_memory_client import TierGateError
|
| 231 |
+
free = MemoryClient.local(str(tmp_path / "free.db"))
|
| 232 |
+
with pytest.raises(TierGateError):
|
| 233 |
+
free.list_skill_proposals()
|
| 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"))
|
| 241 |
+
free.set_entity("project", "atlas", {"status": "active"})
|
| 242 |
+
free.write_event(acted=["did something"])
|
| 243 |
+
free.set_state("priorities", {"top": ["ship"]})
|
| 244 |
+
free.set_reference("rule-1", "always ship")
|
| 245 |
+
|
| 246 |
+
# All core reads work
|
| 247 |
+
assert free.get_entity("project", "atlas")["body"]["status"] == "active"
|
| 248 |
+
assert free.get_state("priorities") is not None
|
| 249 |
+
assert free.get_reference("rule-1") is not None
|
| 250 |
+
assert free.read_events()
|
| 251 |
+
# FTS5 search works
|
| 252 |
+
results = free.search_entities("atlas")
|
| 253 |
+
assert results
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def test_paid_tier_upgrade_unlocks_learn(tmp_path: Path) -> None:
|
| 257 |
+
"""Simulate upgrade flow: start free, set_tier('lifetime'), learn now works."""
|
| 258 |
+
client = MemoryClient.local(str(tmp_path / "u.db"))
|
| 259 |
+
_seed_repeated_action(client, n=4)
|
| 260 |
+
|
| 261 |
+
# Free tier blocks
|
| 262 |
+
from sibyl_memory_client import TierGateError
|
| 263 |
+
with pytest.raises(TierGateError):
|
| 264 |
+
client.learn()
|
| 265 |
+
|
| 266 |
+
# Upgrade β unlock
|
| 267 |
+
client.set_tier("lifetime")
|
| 268 |
+
report = client.learn()
|
| 269 |
+
assert report.proposals_made >= 1
|
sibyl-memory-client/tests/test_lint.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Smoke tests for sibyl_memory_client.lint."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import sqlite3
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
import pytest
|
| 8 |
+
|
| 9 |
+
from sibyl_memory_client import (
|
| 10 |
+
Finding,
|
| 11 |
+
LintReport,
|
| 12 |
+
Linter,
|
| 13 |
+
MemoryClient,
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
# ----------------------------------------------------------------------
|
| 18 |
+
# Fixtures
|
| 19 |
+
# ----------------------------------------------------------------------
|
| 20 |
+
@pytest.fixture
|
| 21 |
+
def client(tmp_path: Path) -> MemoryClient:
|
| 22 |
+
# Memory linter is paid-tier only. Tests run as a lifetime-tier user.
|
| 23 |
+
return MemoryClient.local(str(tmp_path / "memory.db"), tier="lifetime")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# ----------------------------------------------------------------------
|
| 27 |
+
# Baseline
|
| 28 |
+
# ----------------------------------------------------------------------
|
| 29 |
+
def test_lint_clean_db_has_no_critical(client: MemoryClient) -> None:
|
| 30 |
+
report = client.lint()
|
| 31 |
+
assert isinstance(report, LintReport)
|
| 32 |
+
assert report.ok is True
|
| 33 |
+
assert report.schema_version >= 2
|
| 34 |
+
assert report.critical == []
|
| 35 |
+
# Counts all present
|
| 36 |
+
assert "entities" in report.counts
|
| 37 |
+
assert "skill_proposals" in report.counts
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_lint_includes_db_path_and_size(client: MemoryClient) -> None:
|
| 41 |
+
client.set_entity("project", "atlas", {"status": "active"})
|
| 42 |
+
report = client.lint()
|
| 43 |
+
assert report.db_path.endswith("memory.db")
|
| 44 |
+
assert report.db_size_bytes > 0
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def test_lint_to_ascii_renders(client: MemoryClient) -> None:
|
| 48 |
+
report = client.lint()
|
| 49 |
+
rendered = report.to_ascii()
|
| 50 |
+
assert "SIBYL MEMORY Β· LINT REPORT" in rendered
|
| 51 |
+
assert "schema v" in rendered
|
| 52 |
+
assert "critical" in rendered
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# ----------------------------------------------------------------------
|
| 56 |
+
# Specific checks
|
| 57 |
+
# ----------------------------------------------------------------------
|
| 58 |
+
def test_duplicate_entity_finding(client: MemoryClient) -> None:
|
| 59 |
+
client.set_entity("project", "atlas", {"x": 1})
|
| 60 |
+
client.set_entity("product", "atlas", {"y": 2}) # same name, different category
|
| 61 |
+
report = client.lint()
|
| 62 |
+
msgs = [f.check for f in report.findings]
|
| 63 |
+
assert "duplicate-entity" in msgs
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def test_empty_reference_finding(client: MemoryClient) -> None:
|
| 67 |
+
# Insert an empty reference doc directly via storage to bypass SDK validation
|
| 68 |
+
with client.storage.transaction() as conn:
|
| 69 |
+
conn.execute(
|
| 70 |
+
"INSERT INTO reference_documents (tenant_id, doc_key, body) "
|
| 71 |
+
"VALUES (?, ?, '')",
|
| 72 |
+
(client.get_tenant(), "skill/empty-test"),
|
| 73 |
+
)
|
| 74 |
+
report = client.lint()
|
| 75 |
+
assert any(f.check == "empty-reference" for f in report.findings)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def test_stale_entity_finding(client: MemoryClient) -> None:
|
| 79 |
+
# Force-write an entity with an ancient updated_at via direct SQL
|
| 80 |
+
client.set_entity("project", "ancient", {"created": True})
|
| 81 |
+
with client.storage.transaction() as conn:
|
| 82 |
+
conn.execute(
|
| 83 |
+
"UPDATE entities SET updated_at = '2020-01-01T00:00:00.000Z' "
|
| 84 |
+
"WHERE tenant_id = ? AND name = 'ancient'",
|
| 85 |
+
(client.get_tenant(),),
|
| 86 |
+
)
|
| 87 |
+
report = client.lint()
|
| 88 |
+
assert any(f.check == "stale-entity" for f in report.findings)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def test_journal_without_acts_finding(client: MemoryClient) -> None:
|
| 92 |
+
# write_event refuses None for everything; insert directly
|
| 93 |
+
from sibyl_memory_client.storage import new_id
|
| 94 |
+
with client.storage.transaction() as conn:
|
| 95 |
+
conn.execute(
|
| 96 |
+
"INSERT INTO journal_events (id, tenant_id, ts) VALUES (?, ?, ?)",
|
| 97 |
+
(new_id(), client.get_tenant(), "2026-05-15T17:30:00.000Z"),
|
| 98 |
+
)
|
| 99 |
+
report = client.lint()
|
| 100 |
+
assert any(f.check == "journal-without-acts" for f in report.findings)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
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 β 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]}"
|
| 111 |
+
assert matches[0].severity in ("warning", "critical")
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def test_findings_severity_buckets(client: MemoryClient) -> None:
|
| 115 |
+
client.set_entity("project", "atlas", {})
|
| 116 |
+
client.set_entity("person", "atlas", {}) # duplicate name -> warning
|
| 117 |
+
report = client.lint(soft_cap_bytes=4 * 1024) # very tiny -> warning or critical
|
| 118 |
+
# Buckets resolve correctly
|
| 119 |
+
assert isinstance(report.critical, list)
|
| 120 |
+
assert isinstance(report.warnings, list)
|
| 121 |
+
assert isinstance(report.info, list)
|
| 122 |
+
total = len(report.critical) + len(report.warnings) + len(report.info)
|
| 123 |
+
assert total == len(report.findings)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def test_lint_to_dict_serializes(client: MemoryClient) -> None:
|
| 127 |
+
report = client.lint()
|
| 128 |
+
d = report.to_dict()
|
| 129 |
+
assert "findings" in d
|
| 130 |
+
assert "counts" in d
|
| 131 |
+
assert "ok" in d
|
| 132 |
+
assert "schema_version" in d
|
| 133 |
+
assert isinstance(d["findings"], list)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
# ----------------------------------------------------------------------
|
| 137 |
+
# Multi-tenant isolation
|
| 138 |
+
# ----------------------------------------------------------------------
|
| 139 |
+
def test_lint_is_tenant_scoped(tmp_path: Path) -> None:
|
| 140 |
+
db = tmp_path / "m.db"
|
| 141 |
+
alice = MemoryClient.local(str(db), tenant_id="alice", tier="lifetime")
|
| 142 |
+
bob = MemoryClient.local(str(db), tenant_id="bob", tier="lifetime")
|
| 143 |
+
|
| 144 |
+
# Only alice creates a duplicate-name pair
|
| 145 |
+
alice.set_entity("project", "atlas", {})
|
| 146 |
+
alice.set_entity("product", "atlas", {})
|
| 147 |
+
|
| 148 |
+
alice_report = alice.lint()
|
| 149 |
+
bob_report = bob.lint()
|
| 150 |
+
|
| 151 |
+
assert any(f.check == "duplicate-entity" for f in alice_report.findings)
|
| 152 |
+
assert not any(f.check == "duplicate-entity" for f in bob_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
|
| 160 |
+
free = MemoryClient.local(str(tmp_path / "f.db")) # default tier="free"
|
| 161 |
+
with pytest.raises(TierGateError) as exc:
|
| 162 |
+
free.lint()
|
| 163 |
+
assert exc.value.feature == "memory linter"
|
| 164 |
+
assert exc.value.current_tier == "free"
|
| 165 |
+
assert "sibyllabs.org" in exc.value.upgrade_url
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def test_paid_tiers_can_lint(tmp_path: Path) -> None:
|
| 169 |
+
for tier in ("sync", "team", "lifetime", "stake", "enterprise"):
|
| 170 |
+
c = MemoryClient.local(str(tmp_path / f"{tier}.db"), tier=tier)
|
| 171 |
+
report = c.lint()
|
| 172 |
+
assert report.ok or report.warnings # runs without raising
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def test_free_tier_status_visible_without_gate(tmp_path: Path) -> None:
|
| 176 |
+
"""Free-tier users CAN see their cap status (for upgrade-prompt UX) without
|
| 177 |
+
being able to call lint() itself."""
|
| 178 |
+
free = MemoryClient.local(str(tmp_path / "f.db"))
|
| 179 |
+
status = free.free_tier_status()
|
| 180 |
+
assert status["tier"] == "free"
|
| 181 |
+
assert status["soft_cap_bytes"] == 2 * 1024 * 1024
|
| 182 |
+
assert "upgrade_url" in status
|
| 183 |
+
assert status["uncapped"] is False
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def test_paid_tier_status_shows_uncapped(tmp_path: Path) -> None:
|
| 187 |
+
paid = MemoryClient.local(str(tmp_path / "p.db"), tier="lifetime")
|
| 188 |
+
status = paid.free_tier_status()
|
| 189 |
+
assert status["tier"] == "lifetime"
|
| 190 |
+
assert status["uncapped"] is True
|
| 191 |
+
assert status["soft_cap_bytes"] is None
|
sibyl-memory-client/tests/test_smoke.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""End-to-end smoke test for sibyl-memory-client v0.1.0.
|
| 2 |
+
|
| 3 |
+
Exercises every public method against a fresh SQLite database in a temp
|
| 4 |
+
directory. Verifies schema applies, FTS5 triggers fire, JSON validation
|
| 5 |
+
holds, and the typed exceptions surface correctly.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
import sqlite3
|
| 11 |
+
import sys
|
| 12 |
+
import tempfile
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
# Run from repo: PYTHONPATH=src python tests/test_smoke.py
|
| 16 |
+
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
| 17 |
+
|
| 18 |
+
from sibyl_memory_client import (
|
| 19 |
+
DEFAULT_TENANT,
|
| 20 |
+
MemoryClient,
|
| 21 |
+
NotFoundError,
|
| 22 |
+
ValidationError,
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def test_schema_applies_idempotently(tmp_path):
|
| 27 |
+
db = tmp_path / "memory.db"
|
| 28 |
+
client = MemoryClient.local(db)
|
| 29 |
+
# v2 is the current schema as of 2026-05-15 (added skill_proposals + learning_runs)
|
| 30 |
+
assert client.schema_version() >= 2, "schema_version should be 2 after first open"
|
| 31 |
+
# Re-open: no error
|
| 32 |
+
client2 = MemoryClient.local(db)
|
| 33 |
+
assert client2.schema_version() >= 2
|
| 34 |
+
return "schema applies and re-applies idempotently"
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def test_entity_roundtrip(tmp_path):
|
| 38 |
+
client = MemoryClient.local(tmp_path / "memory.db")
|
| 39 |
+
body = {"status": "active", "members": ["a", "b"], "score": 9.5}
|
| 40 |
+
written = client.set_entity("project", "atlas", body, status="active")
|
| 41 |
+
assert written["category"] == "project"
|
| 42 |
+
assert written["name"] == "atlas"
|
| 43 |
+
assert written["status"] == "active"
|
| 44 |
+
assert written["body"] == body
|
| 45 |
+
assert written["tenant_id"] == DEFAULT_TENANT
|
| 46 |
+
assert written["id"] # UUID assigned
|
| 47 |
+
|
| 48 |
+
read = client.get_entity("project", "atlas")
|
| 49 |
+
assert read["body"] == body
|
| 50 |
+
|
| 51 |
+
# Update via set_entity overwrites
|
| 52 |
+
body["score"] = 9.7
|
| 53 |
+
updated = client.set_entity("project", "atlas", body, status="active")
|
| 54 |
+
assert updated["body"]["score"] == 9.7
|
| 55 |
+
assert updated["id"] == written["id"], "update preserves entity id"
|
| 56 |
+
return "entity roundtrip works (insert, read, update)"
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def test_entity_listing_and_filtering(tmp_path):
|
| 60 |
+
client = MemoryClient.local(tmp_path / "memory.db")
|
| 61 |
+
client.set_entity("project", "alpha", {}, status="active")
|
| 62 |
+
client.set_entity("project", "beta", {}, status="paused")
|
| 63 |
+
client.set_entity("person", "alice", {}, status="active")
|
| 64 |
+
|
| 65 |
+
all_projects = client.list_entities(category="project")
|
| 66 |
+
assert len(all_projects) == 2, f"expected 2 projects, got {len(all_projects)}"
|
| 67 |
+
|
| 68 |
+
active_projects = client.list_entities(category="project", status="active")
|
| 69 |
+
assert len(active_projects) == 1
|
| 70 |
+
assert active_projects[0]["name"] == "alpha"
|
| 71 |
+
|
| 72 |
+
all_active = client.list_entities(status="active")
|
| 73 |
+
assert len(all_active) == 2
|
| 74 |
+
return f"list/filter works: {len(all_projects)} projects, {len(active_projects)} active project"
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def test_journal_append_and_read(tmp_path):
|
| 78 |
+
import time
|
| 79 |
+
client = MemoryClient.local(tmp_path / "memory.db")
|
| 80 |
+
client.write_event(
|
| 81 |
+
evaluated=["option A", "option B"],
|
| 82 |
+
acted=["chose A", "tx 0xabc"],
|
| 83 |
+
forward=["follow up tomorrow"],
|
| 84 |
+
extra={"session": "smoke", "n": 1},
|
| 85 |
+
)
|
| 86 |
+
time.sleep(0.001) # guarantee microsecond ts separation across writes
|
| 87 |
+
client.write_event(acted=["another event"])
|
| 88 |
+
events = client.read_events(limit=10)
|
| 89 |
+
assert len(events) == 2, f"expected 2 events, got {len(events)}"
|
| 90 |
+
# Newest first (ts DESC, id DESC tiebreaker)
|
| 91 |
+
assert events[0]["acted"] == ["another event"], f"events[0] acted = {events[0]['acted']}"
|
| 92 |
+
assert events[1]["evaluated"] == ["option A", "option B"], f"events[1] evaluated = {events[1]['evaluated']}"
|
| 93 |
+
return f"journal append+read works ({len(events)} events round-tripped)"
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def test_state_documents(tmp_path):
|
| 97 |
+
client = MemoryClient.local(tmp_path / "memory.db")
|
| 98 |
+
assert client.get_state("priorities") is None
|
| 99 |
+
client.set_state("priorities", {"items": [1, 2, 3], "version": "v2"})
|
| 100 |
+
got = client.get_state("priorities")
|
| 101 |
+
assert got["body"]["items"] == [1, 2, 3]
|
| 102 |
+
# Upsert
|
| 103 |
+
client.set_state("priorities", {"items": [4, 5], "version": "v3"})
|
| 104 |
+
got2 = client.get_state("priorities")
|
| 105 |
+
assert got2["body"]["version"] == "v3"
|
| 106 |
+
return "state_documents upsert + read works"
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def test_reference_documents(tmp_path):
|
| 110 |
+
client = MemoryClient.local(tmp_path / "memory.db")
|
| 111 |
+
client.set_reference(
|
| 112 |
+
"voice-rules",
|
| 113 |
+
"no em-dashes, no LLM tells, lowercase ok.",
|
| 114 |
+
metadata={"applies_to": ["x", "ping", "email"]},
|
| 115 |
+
)
|
| 116 |
+
ref = client.get_reference("voice-rules")
|
| 117 |
+
assert "em-dashes" in ref["body"]
|
| 118 |
+
assert ref["metadata"]["applies_to"] == ["x", "ping", "email"]
|
| 119 |
+
return "reference_documents (body + metadata) works"
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def test_archive_flow(tmp_path):
|
| 123 |
+
client = MemoryClient.local(tmp_path / "memory.db")
|
| 124 |
+
client.set_entity("project", "dead-deal", {"note": "abandoned"})
|
| 125 |
+
result = client.archive_entity("project", "dead-deal", reason="founder disappeared")
|
| 126 |
+
assert "archived_id" in result
|
| 127 |
+
# Entity should be gone from active set
|
| 128 |
+
try:
|
| 129 |
+
client.get_entity("project", "dead-deal")
|
| 130 |
+
return "FAIL: archived entity still in active set"
|
| 131 |
+
except NotFoundError:
|
| 132 |
+
pass
|
| 133 |
+
return "archive moves entity out of active set"
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def test_fts_search(tmp_path):
|
| 137 |
+
client = MemoryClient.local(tmp_path / "memory.db")
|
| 138 |
+
client.set_entity("project", "atlas", {"description": "Distributed inference platform"})
|
| 139 |
+
client.set_entity("project", "horizon", {"description": "On-chain prediction markets"})
|
| 140 |
+
client.set_entity("person", "alice", {"role": "infrastructure engineer"})
|
| 141 |
+
results = client.search_entities("inference")
|
| 142 |
+
assert len(results) >= 1
|
| 143 |
+
found_names = {r["name"] for r in results}
|
| 144 |
+
assert "atlas" in found_names
|
| 145 |
+
return f"FTS5 search works ({len(results)} hits for 'inference')"
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def test_json_validation(tmp_path):
|
| 149 |
+
client = MemoryClient.local(tmp_path / "memory.db")
|
| 150 |
+
class Unserializable:
|
| 151 |
+
pass
|
| 152 |
+
try:
|
| 153 |
+
client.set_entity("test", "broken", {"obj": Unserializable()})
|
| 154 |
+
return "FAIL: should have raised ValidationError"
|
| 155 |
+
except ValidationError:
|
| 156 |
+
pass
|
| 157 |
+
return "JSON validation rejects unserializable input"
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def test_tenant_isolation(tmp_path):
|
| 161 |
+
client_a = MemoryClient.local(tmp_path / "memory.db", tenant_id="tenant-a")
|
| 162 |
+
client_b = MemoryClient.local(tmp_path / "memory.db", tenant_id="tenant-b")
|
| 163 |
+
client_a.set_entity("project", "shared-name", {"owner": "a"})
|
| 164 |
+
client_b.set_entity("project", "shared-name", {"owner": "b"})
|
| 165 |
+
assert client_a.get_entity("project", "shared-name")["body"]["owner"] == "a"
|
| 166 |
+
assert client_b.get_entity("project", "shared-name")["body"]["owner"] == "b"
|
| 167 |
+
return "multi-tenant: same (category, name) isolated by tenant_id"
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def main():
|
| 171 |
+
tests = [
|
| 172 |
+
test_schema_applies_idempotently,
|
| 173 |
+
test_entity_roundtrip,
|
| 174 |
+
test_entity_listing_and_filtering,
|
| 175 |
+
test_journal_append_and_read,
|
| 176 |
+
test_state_documents,
|
| 177 |
+
test_reference_documents,
|
| 178 |
+
test_archive_flow,
|
| 179 |
+
test_fts_search,
|
| 180 |
+
test_json_validation,
|
| 181 |
+
test_tenant_isolation,
|
| 182 |
+
]
|
| 183 |
+
passed = failed = 0
|
| 184 |
+
for t in tests:
|
| 185 |
+
with tempfile.TemporaryDirectory() as td:
|
| 186 |
+
try:
|
| 187 |
+
msg = t(Path(td))
|
| 188 |
+
print(f" PASS {t.__name__:48s} {msg}")
|
| 189 |
+
passed += 1
|
| 190 |
+
except AssertionError as e:
|
| 191 |
+
print(f" FAIL {t.__name__:48s} {e}")
|
| 192 |
+
failed += 1
|
| 193 |
+
except Exception as e:
|
| 194 |
+
print(f" ERR {t.__name__:48s} {type(e).__name__}: {e}")
|
| 195 |
+
failed += 1
|
| 196 |
+
print()
|
| 197 |
+
print(f" {passed}/{len(tests)} passed, {failed} failed")
|
| 198 |
+
return 0 if failed == 0 else 1
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
if __name__ == "__main__":
|
| 202 |
+
sys.exit(main())
|
sibyl-memory-hermes/CHANGELOG.md
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Changelog
|
| 2 |
+
|
| 3 |
+
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] β 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:
|
| 11 |
+
"beneath the large SIBYL title it needs to say underneath the memory
|
| 12 |
+
you can hold in your hand tagline, 'a Sibyl Labs LLC Product.
|
| 13 |
+
Agentic Infrastructure and Memory Products' or something similar."
|
| 14 |
+
|
| 15 |
+
### Changed
|
| 16 |
+
|
| 17 |
+
- Vendored `_banner.py` adds an attribution line under the tagline:
|
| 18 |
+
`a Sibyl Labs LLC Product. Agentic Infrastructure and Memory Products`.
|
| 19 |
+
Same deepest-gold color as the tagline + ANSI dim so the install
|
| 20 |
+
ceremony reads SIBYL > tagline > attribution at a glance. Visible
|
| 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 +
|
| 31 |
+
sectioned numbered onboarding menu treatment.
|
| 32 |
+
|
| 33 |
+
### Added
|
| 34 |
+
|
| 35 |
+
- Vendored `_aesthetic.py` and `_banner.py` from `sibyl-memory-cli`
|
| 36 |
+
(small, stable files; avoids a hard runtime dep on the CLI package).
|
| 37 |
+
- `install-plugin` output: SIBYL gradient banner β section header
|
| 38 |
+
("install-plugin Β· hermes memory provider Β· drops adapter at...") β
|
| 39 |
+
KV rows for paths β "WRITING PAYLOAD" eyebrow with β glyphs on each
|
| 40 |
+
write β success line β "next steps" section header β 3 numbered
|
| 41 |
+
chips with bold step titles and contextual help underneath each β
|
| 42 |
+
divider with uninstall hint and docs link.
|
| 43 |
+
- `uninstall-plugin` output: same banner + section header treatment,
|
| 44 |
+
matching the ceremonial bookend.
|
| 45 |
+
- Status / warning / error lines use the brand palette (jade pulse for
|
| 46 |
+
success, warm ochre for warn, measured red for error) instead of
|
| 47 |
+
generic ANSI 31/33.
|
| 48 |
+
|
| 49 |
+
### Compatibility
|
| 50 |
+
|
| 51 |
+
- No API changes. Same install_plugin entry point, same flags
|
| 52 |
+
(--hermes-home, --force, --dry-run).
|
| 53 |
+
- All visual choices honor `NO_COLOR`. `SIBYL_FORCE_COLOR=1` available
|
| 54 |
+
for non-tty rendering (CI, doc captures).
|
| 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
|
| 62 |
+
perms, identifier validation, FTS5 error surfacing). No Hermes adapter
|
| 63 |
+
code changes in this release.
|
| 64 |
+
|
| 65 |
+
### Changed
|
| 66 |
+
|
| 67 |
+
- `sibyl-memory-client` pin: `>=0.3.3` β `>=0.4.0`.
|
| 68 |
+
- KAPPA's fixes flow through automatically. Hermes tools (`sibyl_remember`,
|
| 69 |
+
`sibyl_recall`, `sibyl_search`, `sibyl_list`) now reject empty / null-byte
|
| 70 |
+
/ oversized identifiers on write and surface malformed FTS5 queries as
|
| 71 |
+
`ValidationError` instead of silently returning empty.
|
| 72 |
+
|
| 73 |
+
### Notes
|
| 74 |
+
|
| 75 |
+
- 40/40 hermes tests pass unchanged. The provider + adapter contract is
|
| 76 |
+
unchanged from v0.3.1.
|
| 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
|
| 84 |
+
Hermes-side fixes. Companion releases: `sibyl-memory-client` v0.3.3 (engine
|
| 85 |
+
+ schema v3 + cross-tier search), `sibyl-memory-cli` v0.1.2,
|
| 86 |
+
`sibyl-memory-mcp` v0.1.1.
|
| 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,
|
| 94 |
+
cross-tier search hits all four tiers, malformed FTS5 queries don't
|
| 95 |
+
crash or leak, missing required args produce structured errors,
|
| 96 |
+
shutdown sets the stop flag, sync_turn during shutdown skips cleanly.
|
| 97 |
+
Closes audit H1.
|
| 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.)
|
| 111 |
+
with exponential backoff up to 3 attempts. On final failure escalates
|
| 112 |
+
from DEBUG to WARNING log. Audit P-C1.
|
| 113 |
+
- `SibylAdapter.shutdown` sets `_shutting_down` BEFORE joining the daemon
|
| 114 |
+
thread. The worker checks the flag and exits cleanly without issuing
|
| 115 |
+
a slow cap-gate refresh. Audit P-C2.
|
| 116 |
+
- `SibylAdapter.handle_tool_call` exception path now returns exception
|
| 117 |
+
class name only, not `str(e)`. Prevents echoing arg contents back to
|
| 118 |
+
the agent on backend errors. Audit SEC-10.
|
| 119 |
+
- Adapter type hints converted to PEP 604 unions throughout. Audit N5.
|
| 120 |
+
- Default search/list limits extracted as named module constants
|
| 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.
|
| 128 |
+
- `install_plugin.py` type hints converted to PEP 604 unions. Audit N5.
|
| 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
|
| 160 |
+
|
| 161 |
+
- `sibyl-memory-client>=0.3.3` (was `>=0.3.2`). Required for cross-tier
|
| 162 |
+
`MemoryClient.search()` and atomic 0600-at-create.
|
| 163 |
+
- Optional `hermes-agent>=0.13.0` unchanged.
|
| 164 |
+
|
| 165 |
+
### How to upgrade from v0.3.0
|
| 166 |
+
|
| 167 |
+
```
|
| 168 |
+
pip install --upgrade sibyl-memory-hermes
|
| 169 |
+
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 β 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
|
| 180 |
+
abstract methods, no plugin-loader awareness). Diagnosed end-to-end against
|
| 181 |
+
the installed hermes-agent 0.13.0 wheel: full ABC source extracted, the
|
| 182 |
+
bundled byterover reference implementation read for the idiomatic pattern,
|
| 183 |
+
side-by-side method mapping built, discovery contract traced through
|
| 184 |
+
`plugins/memory/__init__.py`. Adapter written from that ground-truth read
|
| 185 |
+
and validated via Hermes' own `load_memory_provider('sibyl')` loader.
|
| 186 |
+
|
| 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`:
|
| 224 |
+
- Detects HERMES_HOME from CLI flag β `$HERMES_HOME` env var β `~/.hermes`.
|
| 225 |
+
- Copies bundled adapter via `importlib.resources` (no fragile path math).
|
| 226 |
+
- Renames `adapter.py` β `__init__.py` at destination (source can't be
|
| 227 |
+
`__init__.py` because the Hermes-only imports would TypeError under
|
| 228 |
+
our standalone tests).
|
| 229 |
+
- Prints activation steps (config.yaml edit + `sibyl init` reminder).
|
| 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
|
| 237 |
+
class was effectively `object`-derived already. v0.3.0 makes that
|
| 238 |
+
explicit and moves all Hermes glue to the adapter. The `hermes_bound`
|
| 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(
|
| 246 |
+
'sibyl-memory-hermes')`. The v0.2.x drift (`__init__.py` said 0.2.1
|
| 247 |
+
while the wheel was 0.2.2) is no longer possible.
|
| 248 |
+
|
| 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
|
| 264 |
+
|
| 265 |
+
```
|
| 266 |
+
pip install --upgrade sibyl-memory-hermes
|
| 267 |
+
sibyl-memory-hermes install-plugin
|
| 268 |
+
# edit ~/.hermes/config.yaml: memory.provider: sibyl
|
| 269 |
+
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 β 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 |
+
|
| 277 |
+
### Verification
|
| 278 |
+
|
| 279 |
+
- Adapter dry-run via Hermes' own `load_memory_provider('sibyl')` returns
|
| 280 |
+
the SibylAdapter instance with `name='sibyl'`, `is_available()=True`,
|
| 281 |
+
and all 4 tool schemas resolved.
|
| 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.
|
| 309 |
+
|
| 310 |
+
### Tests
|
| 311 |
+
|
| 312 |
+
- 21/21 unchanged, all green. The narrower exception class is a strict
|
| 313 |
+
subset of the prior catch-all behavior for the soft-miss case.
|
| 314 |
+
|
| 315 |
+
### Notes
|
| 316 |
+
|
| 317 |
+
- Depends on `sibyl-memory-client>=0.3.2` to pick up the matching
|
| 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.
|
| 325 |
+
|
| 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
|
| 333 |
+
`MemoryClient.local()`, which forwards to `CapGate`. The cap gate
|
| 334 |
+
attaches them to every server-side cap-check request so the server
|
| 335 |
+
can verify and log tampering.
|
| 336 |
+
- `load_credentials` / `write_credentials` round-trip the new fields.
|
| 337 |
+
|
| 338 |
+
The verification itself is server-side only (HMAC requires a shared
|
| 339 |
+
secret the client cannot hold). The client's job is to faithfully
|
| 340 |
+
echo back what was issued. Authoritative tier always comes from the
|
| 341 |
+
database.
|
| 342 |
+
|
| 343 |
+
### Tests
|
| 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 |
+
|
| 351 |
+
### Changed
|
| 352 |
+
|
| 353 |
+
- `SibylMemoryProvider.__init__` now passes `account_id`,
|
| 354 |
+
`session_token`, and `tier` from `credentials.json` through to
|
| 355 |
+
`MemoryClient.local()`. The v0.3.0 cap gate uses these to verify
|
| 356 |
+
the user's actual tier against the server when the local DB
|
| 357 |
+
approaches 2 MB. Without them, the SDK enforces a strict local
|
| 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`),
|
| 387 |
+
entities (`remember`/`recall`/`list`/`forget`), state (`set_state`/
|
| 388 |
+
`get_state`), references (`set_reference`/`get_reference`), archive
|
| 389 |
+
(`archive`), FTS5 (`search`).
|
| 390 |
+
- `Credentials` dataclass + `load_credentials` / `write_credentials` for
|
| 391 |
+
the activation file at `~/.sibyl-memory/credentials.json`.
|
| 392 |
+
- `CredentialsNotFoundError` with explicit recovery message pointing the
|
| 393 |
+
user to `sibyl init`.
|
| 394 |
+
- Auto-detect activation: provider reads credentials.json on construction
|
| 395 |
+
by default; explicit `tenant_id=` overrides; missing credentials
|
| 396 |
+
degrade to `DEFAULT_TENANT` so tests / pre-activation use works.
|
| 397 |
+
- `health()` diagnostic dict (used by `sibyl status`).
|
| 398 |
+
- Comprehensive smoke test suite covering provider construction, tier
|
| 399 |
+
routing, search, archive, credentials, multi-tenant isolation, and
|
| 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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 Sibyl Labs LLC
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
sibyl-memory-hermes/README.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# sibyl-memory-hermes
|
| 2 |
+
|
| 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
|
| 17 |
+
sibyl-memory-hermes install-plugin
|
| 18 |
+
```
|
| 19 |
+
|
| 20 |
+
Then edit `~/.hermes/config.yaml`:
|
| 21 |
+
|
| 22 |
+
```yaml
|
| 23 |
+
memory:
|
| 24 |
+
provider: sibyl
|
| 25 |
+
```
|
| 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 |
+
|
| 36 |
+
```bash
|
| 37 |
+
pip install sibyl-memory-cli
|
| 38 |
+
sibyl init
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
## Direct SDK use (any Python orchestration)
|
| 42 |
+
|
| 43 |
+
```python
|
| 44 |
+
from sibyl_memory_hermes import SibylMemoryProvider
|
| 45 |
+
|
| 46 |
+
provider = SibylMemoryProvider() # auto-loads ~/.sibyl-memory/credentials.json
|
| 47 |
+
provider.remember("project", "atlas", {"status": "shipping v2 friday"})
|
| 48 |
+
provider.recall("project", "atlas") # β {id, tenant_id, category, name, body, ...}
|
| 49 |
+
provider.set_state("active_branch", {"name": "v0.3.1"})
|
| 50 |
+
provider.save_context(
|
| 51 |
+
inputs={"user": "what changed in v0.3.1?"},
|
| 52 |
+
outputs={"assistant": "..."},
|
| 53 |
+
)
|
| 54 |
+
provider.search("v0.3.1") # FTS5 across entities + state + reference + journal
|
| 55 |
+
```
|
| 56 |
+
|
| 57 |
+
## Why "local-first"?
|
| 58 |
+
|
| 59 |
+
Mem0, Zep, Honcho, and most other agent-memory products centralize user context on their servers. The Sibyl Memory Plugin keeps the data on the user's disk. Our cloud schema has no memory-content tables. Even with admin DB access we cannot read what users have written. That's the difference between *"we promise we don't"* and *"we structurally can't."*
|
| 60 |
+
|
| 61 |
+
| | Sibyl Memory Plugin | Typical hosted memory |
|
| 62 |
+
|---|---|---|
|
| 63 |
+
| Memory content lives | on user's disk | on vendor's servers |
|
| 64 |
+
| Query latency | local SQLite (sub-ms) | round-trip + vector search |
|
| 65 |
+
| Privacy claim | structurally enforced | policy-only |
|
| 66 |
+
| Free-tier cost to vendor | near-zero | scales with users |
|
| 67 |
+
|
| 68 |
+
## Architecture: five tiers, not one bucket
|
| 69 |
+
|
| 70 |
+
The provider routes operations onto the appropriate memory tier instead of dumping everything into a single vector store:
|
| 71 |
+
|
| 72 |
+
| Intent | Tier | Storage call |
|
| 73 |
+
|---|---|---|
|
| 74 |
+
| save the conversation turn | COLD journal | `save_context(inputs, outputs)` |
|
| 75 |
+
| remember a fact | WARM entity | `remember(category, name, body)` |
|
| 76 |
+
| current state | HOT state | `set_state(key, body)` |
|
| 77 |
+
| lookup a runbook | REFERENCE | `set_reference(key, body)` |
|
| 78 |
+
| archive stale entity | ARCHIVE | `archive(category, name)` |
|
| 79 |
+
| search by content | FTS5 cross-tier | `search(query)` β tier-tagged hits |
|
| 80 |
+
|
| 81 |
+
Different intents, different lookups, no embedding model required. FTS5 covers full-text search out of the box.
|
| 82 |
+
|
| 83 |
+
## Hermes contract
|
| 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 |
+
|
| 91 |
+
Most users get here via the `sibyl init` CLI (from [`sibyl-memory-cli`](https://pypi.org/project/sibyl-memory-cli/)), which writes `~/.sibyl-memory/credentials.json` after browser authentication. The provider auto-detects this file on construction.
|
| 92 |
+
|
| 93 |
+
For pre-activation use (tests, internal tooling):
|
| 94 |
+
|
| 95 |
+
```python
|
| 96 |
+
from sibyl_memory_hermes import SibylMemoryProvider
|
| 97 |
+
|
| 98 |
+
provider = SibylMemoryProvider(
|
| 99 |
+
db_path="/tmp/test-memory.db",
|
| 100 |
+
tenant_id="test-user",
|
| 101 |
+
autoload_credentials=False,
|
| 102 |
+
)
|
| 103 |
+
```
|
| 104 |
+
|
| 105 |
+
## Free tier
|
| 106 |
+
|
| 107 |
+
- 2 MB local soft cap (with server-authoritative tier verification at the cap boundary)
|
| 108 |
+
- Single device
|
| 109 |
+
- All five tiers (HOT/WARM/COLD/REFERENCE/ARCHIVE)
|
| 110 |
+
- FTS5 full-text search across entities + state + reference + journal
|
| 111 |
+
- Multi-tenant isolation
|
| 112 |
+
|
| 113 |
+
Paid tiers (Stake, Sync, Lifetime, Enterprise) unlock self-learning, the memory check-up, no cap, and (in build) cross-device encrypted sync. See [docs.sibyllabs.org/memory/tiers](https://docs.sibyllabs.org/memory/tiers).
|
| 114 |
+
|
| 115 |
+
## Documentation
|
| 116 |
+
|
| 117 |
+
- Full docs: [docs.sibyllabs.org/memory/](https://docs.sibyllabs.org/memory/)
|
| 118 |
+
- Hermes integration guide: [docs.sibyllabs.org/memory/integrations#hermes](https://docs.sibyllabs.org/memory/integrations#hermes)
|
| 119 |
+
- Install guide: [docs.sibyllabs.org/memory/install](https://docs.sibyllabs.org/memory/install)
|
| 120 |
+
|
| 121 |
+
## License
|
| 122 |
+
|
| 123 |
+
MIT. Package on PyPI: [pypi.org/project/sibyl-memory-hermes](https://pypi.org/project/sibyl-memory-hermes/).
|
| 124 |
+
|
| 125 |
+
## Citation
|
| 126 |
+
|
| 127 |
+
The Sibyl Memory Plugin holds #2 globally on the LongMemEval Oracle benchmark. The benchmark methodology and report are at [blog.sibylcap.com/longmemeval-v2](https://blog.sibylcap.com/longmemeval-v2).
|
sibyl-memory-hermes/pyproject.toml
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["setuptools>=61.0", "wheel"]
|
| 3 |
+
build-backend = "setuptools.build_meta"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "sibyl-memory-hermes"
|
| 7 |
+
version = "0.3.4"
|
| 8 |
+
description = "Sibyl Memory SDK + bundled Hermes plugin payload. Local-first, SQLite-backed, structured-tier memory for Hermes v0.13+ (and any other Python orchestration that wants direct SDK access)."
|
| 9 |
+
authors = [{ name = "SIBYL, Sibyl Labs LLC", email = "sibyl@sibyllabs.org" }]
|
| 10 |
+
license = { text = "MIT" }
|
| 11 |
+
readme = "README.md"
|
| 12 |
+
requires-python = ">=3.10"
|
| 13 |
+
keywords = [
|
| 14 |
+
"sibyl", "memory", "hermes", "hermes-agent", "agent", "sqlite",
|
| 15 |
+
"local-first", "agentic-memory", "agent-framework", "fts5",
|
| 16 |
+
]
|
| 17 |
+
classifiers = [
|
| 18 |
+
"Development Status :: 4 - Beta",
|
| 19 |
+
"Intended Audience :: Developers",
|
| 20 |
+
"License :: OSI Approved :: MIT License",
|
| 21 |
+
"Programming Language :: Python :: 3",
|
| 22 |
+
"Programming Language :: Python :: 3.10",
|
| 23 |
+
"Programming Language :: Python :: 3.11",
|
| 24 |
+
"Programming Language :: Python :: 3.12",
|
| 25 |
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
| 26 |
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
| 27 |
+
]
|
| 28 |
+
dependencies = [
|
| 29 |
+
"sibyl-memory-client>=0.4.0",
|
| 30 |
+
]
|
| 31 |
+
|
| 32 |
+
[project.optional-dependencies]
|
| 33 |
+
dev = [
|
| 34 |
+
"pytest>=7.0",
|
| 35 |
+
"pytest-cov>=4.0",
|
| 36 |
+
]
|
| 37 |
+
# Hermes is NOT a hard dep. Users who want the Hermes integration install
|
| 38 |
+
# hermes-agent separately + run `sibyl-memory-hermes install-plugin` to
|
| 39 |
+
# drop the bundled adapter into $HERMES_HOME/plugins/sibyl/.
|
| 40 |
+
hermes = [
|
| 41 |
+
"hermes-agent>=0.13.0",
|
| 42 |
+
]
|
| 43 |
+
|
| 44 |
+
[project.scripts]
|
| 45 |
+
sibyl-memory-hermes = "sibyl_memory_hermes.install_plugin:main"
|
| 46 |
+
|
| 47 |
+
[project.urls]
|
| 48 |
+
Homepage = "https://sibyllabs.org/memory"
|
| 49 |
+
Documentation = "https://docs.sibyllabs.org/memory/integrations"
|
| 50 |
+
Repository = "https://github.com/sibyllabs/sibyl-memory-plugin"
|
| 51 |
+
|
| 52 |
+
[tool.setuptools.packages.find]
|
| 53 |
+
where = ["src"]
|
| 54 |
+
include = ["sibyl_memory_hermes*"]
|
| 55 |
+
|
| 56 |
+
[tool.setuptools.package-data]
|
| 57 |
+
# Bundle the validated Hermes plugin adapter + metadata. The install-plugin
|
| 58 |
+
# console script reads these via importlib.resources and copies bytes to
|
| 59 |
+
# $HERMES_HOME/plugins/sibyl/ (renaming adapter.py β __init__.py at dest).
|
| 60 |
+
"sibyl_memory_hermes._hermes_plugin" = ["adapter.py", "plugin.yaml"]
|
| 61 |
+
|
| 62 |
+
[tool.pytest.ini_options]
|
| 63 |
+
testpaths = ["tests"]
|
| 64 |
+
addopts = "-ra"
|
sibyl-memory-hermes/src/sibyl_memory_hermes/__init__.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""sibyl-memory-hermes β Sibyl Memory SDK + bundled Hermes plugin payload.
|
| 2 |
+
|
| 3 |
+
Public exports:
|
| 4 |
+
SibylMemoryProvider framework-agnostic Sibyl Memory SDK class
|
| 5 |
+
DEFAULT_DB_PATH ~/.sibyl-memory/memory.db
|
| 6 |
+
DEFAULT_CRED_PATH ~/.sibyl-memory/credentials.json
|
| 7 |
+
load_credentials helper for reading the activation credential file
|
| 8 |
+
HermesMemoryError base exception (re-exports SibylMemoryError)
|
| 9 |
+
|
| 10 |
+
ARCHITECTURE (v0.3.0+)
|
| 11 |
+
======================
|
| 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
|
| 32 |
+
===================
|
| 33 |
+
|
| 34 |
+
pip install sibyl-memory-hermes
|
| 35 |
+
sibyl-memory-hermes install-plugin
|
| 36 |
+
|
| 37 |
+
# then edit ~/.hermes/config.yaml:
|
| 38 |
+
# memory:
|
| 39 |
+
# provider: sibyl
|
| 40 |
+
|
| 41 |
+
# (optional) bind your account to lift the 2 MB free-tier cap:
|
| 42 |
+
pip install sibyl-memory-cli
|
| 43 |
+
sibyl init
|
| 44 |
+
|
| 45 |
+
hermes # sibyl_remember / recall / search / list
|
| 46 |
+
# now available to the agent
|
| 47 |
+
|
| 48 |
+
DIRECT SDK USAGE (any Python orchestration)
|
| 49 |
+
===========================================
|
| 50 |
+
|
| 51 |
+
from sibyl_memory_hermes import SibylMemoryProvider
|
| 52 |
+
|
| 53 |
+
provider = SibylMemoryProvider() # auto-loads credentials.json
|
| 54 |
+
provider.remember("project", "atlas", {"status": "shipping v2 friday"})
|
| 55 |
+
provider.recall("project", "atlas")
|
| 56 |
+
provider.search("SAML", limit=10)
|
| 57 |
+
|
| 58 |
+
See https://docs.sibyllabs.org/memory/integrations for the full integration
|
| 59 |
+
matrix (Claude Code, Codex, Cursor, Continue, LangChain, LlamaIndex, custom).
|
| 60 |
+
"""
|
| 61 |
+
from importlib.metadata import PackageNotFoundError, version as _pkg_version
|
| 62 |
+
|
| 63 |
+
from sibyl_memory_client import SibylMemoryError
|
| 64 |
+
|
| 65 |
+
from .credentials import (
|
| 66 |
+
DEFAULT_CRED_PATH,
|
| 67 |
+
DEFAULT_DB_PATH,
|
| 68 |
+
Credentials,
|
| 69 |
+
CredentialsNotFoundError,
|
| 70 |
+
load_credentials,
|
| 71 |
+
)
|
| 72 |
+
from .provider import SibylMemoryProvider
|
| 73 |
+
|
| 74 |
+
# Single-sourced from installed metadata. Fallback for editable / source-tree
|
| 75 |
+
# usage where metadata isn't populated yet.
|
| 76 |
+
try:
|
| 77 |
+
__version__ = _pkg_version("sibyl-memory-hermes")
|
| 78 |
+
except PackageNotFoundError: # pragma: no cover - source-tree dev only
|
| 79 |
+
__version__ = "0.0.0+source"
|
| 80 |
+
|
| 81 |
+
# Backwards-compat alias for callers who want a Hermes-namespaced exception type
|
| 82 |
+
HermesMemoryError = SibylMemoryError
|
| 83 |
+
|
| 84 |
+
__all__ = [
|
| 85 |
+
"SibylMemoryProvider",
|
| 86 |
+
"Credentials",
|
| 87 |
+
"CredentialsNotFoundError",
|
| 88 |
+
"DEFAULT_DB_PATH",
|
| 89 |
+
"DEFAULT_CRED_PATH",
|
| 90 |
+
"load_credentials",
|
| 91 |
+
"HermesMemoryError",
|
| 92 |
+
"__version__",
|
| 93 |
+
]
|
sibyl-memory-hermes/src/sibyl_memory_hermes/_aesthetic.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared visual identity for the sibyl CLI surface.
|
| 2 |
+
|
| 3 |
+
Sister module to `_banner.py`. Where the banner is the identity-reveal
|
| 4 |
+
moment for `sibyl init`, this module supplies the granular building
|
| 5 |
+
blocks every subcommand uses to share one coherent look:
|
| 6 |
+
|
| 7 |
+
- 24-bit-truecolor β 256-color β plain-text degradation cascade
|
| 8 |
+
- Brand palette derived from the lab creme paper face (rule 46)
|
| 9 |
+
- Letter-spaced eyebrow labels, gradient titles, ASCII rule dividers
|
| 10 |
+
- Key/value rows, status chips, success/warn/error glyphs
|
| 11 |
+
- Pulsing accents for live states (activation, upgrade, watching)
|
| 12 |
+
|
| 13 |
+
Voice constraint: precise, editorial, restrained. Gradients flow over
|
| 14 |
+
2β3 stops max. No rainbow. The terminal is paper.
|
| 15 |
+
"""
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import os
|
| 19 |
+
import sys
|
| 20 |
+
from typing import Iterable
|
| 21 |
+
|
| 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 = "β"
|
| 44 |
+
GLYPH_WARN = "β "
|
| 45 |
+
GLYPH_ERR = "β"
|
| 46 |
+
GLYPH_DOT = "Β·"
|
| 47 |
+
GLYPH_ARROW = "β"
|
| 48 |
+
GLYPH_BULLET = "βΈ"
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# βββ Terminal capability detection ββββββββββββββββββββββββββββββββββββ
|
| 52 |
+
|
| 53 |
+
def supports_truecolor() -> bool:
|
| 54 |
+
"""24-bit RGB ANSI. Same heuristic as _banner.py."""
|
| 55 |
+
if os.environ.get("NO_COLOR"):
|
| 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
|
| 63 |
+
if not sys.stdout.isatty():
|
| 64 |
+
return False
|
| 65 |
+
colorterm = os.environ.get("COLORTERM", "").lower()
|
| 66 |
+
if "truecolor" in colorterm or "24bit" in colorterm:
|
| 67 |
+
return True
|
| 68 |
+
term_program = os.environ.get("TERM_PROGRAM", "").lower()
|
| 69 |
+
if term_program in {"iterm.app", "wezterm", "ghostty", "vscode", "tabby"}:
|
| 70 |
+
return True
|
| 71 |
+
term = os.environ.get("TERM", "").lower()
|
| 72 |
+
if any(k in term for k in ("256color", "kitty", "alacritty", "xterm-direct")):
|
| 73 |
+
return True
|
| 74 |
+
return False
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def supports_color() -> bool:
|
| 78 |
+
"""Any color at all (3/4-bit fallback)."""
|
| 79 |
+
if os.environ.get("NO_COLOR"):
|
| 80 |
+
return False
|
| 81 |
+
if os.environ.get("TERM", "").lower() == "dumb":
|
| 82 |
+
return False
|
| 83 |
+
if os.environ.get("SIBYL_FORCE_COLOR") == "1":
|
| 84 |
+
return True
|
| 85 |
+
return sys.stdout.isatty()
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
_TC = supports_truecolor()
|
| 89 |
+
_C = supports_color()
|
| 90 |
+
RESET = "\033[0m" if _C else ""
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def rgb(r: int, g: int, b: int) -> str:
|
| 94 |
+
"""24-bit foreground escape (no-op if color disabled)."""
|
| 95 |
+
if not _TC:
|
| 96 |
+
return ""
|
| 97 |
+
return f"\033[38;2;{r};{g};{b}m"
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def rgb_bg(r: int, g: int, b: int) -> str:
|
| 101 |
+
if not _TC:
|
| 102 |
+
return ""
|
| 103 |
+
return f"\033[48;2;{r};{g};{b}m"
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def color(text: str, c: tuple[int, int, int]) -> str:
|
| 107 |
+
if not _TC:
|
| 108 |
+
return text
|
| 109 |
+
return f"{rgb(*c)}{text}{RESET}"
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
# βββ Gradient Β· char-by-char RGB interpolation ββββββββββββββββββββββββ
|
| 113 |
+
|
| 114 |
+
def _interp(a: int, b: int, t: float) -> int:
|
| 115 |
+
return round(a + (b - a) * t)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def gradient(text: str, *stops: tuple[int, int, int]) -> str:
|
| 119 |
+
"""Color a string with a gradient across N stops, one char at a time.
|
| 120 |
+
|
| 121 |
+
Plain-text fallback: returns the input unchanged when color is off.
|
| 122 |
+
Whitespace is preserved (uncolored to keep terminals consistent).
|
| 123 |
+
"""
|
| 124 |
+
if not _TC or len(stops) < 2 or not text:
|
| 125 |
+
return text
|
| 126 |
+
out = []
|
| 127 |
+
chars = list(text)
|
| 128 |
+
# Distribute char index across stop segments
|
| 129 |
+
n = max(1, len(chars) - 1)
|
| 130 |
+
segs = len(stops) - 1
|
| 131 |
+
for i, ch in enumerate(chars):
|
| 132 |
+
if ch == " ":
|
| 133 |
+
out.append(ch)
|
| 134 |
+
continue
|
| 135 |
+
seg_f = (i / n) * segs
|
| 136 |
+
seg_i = min(int(seg_f), segs - 1)
|
| 137 |
+
t = seg_f - seg_i
|
| 138 |
+
a = stops[seg_i]
|
| 139 |
+
b = stops[seg_i + 1]
|
| 140 |
+
r = _interp(a[0], b[0], t)
|
| 141 |
+
g = _interp(a[1], b[1], t)
|
| 142 |
+
bb = _interp(a[2], b[2], t)
|
| 143 |
+
out.append(f"\033[38;2;{r};{g};{bb}m{ch}")
|
| 144 |
+
return "".join(out) + RESET
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def gradient_gold(text: str) -> str:
|
| 148 |
+
"""Pale-gold β deep-ochre flow. The brand's headline gradient."""
|
| 149 |
+
return gradient(text, ACCENT_PALE, ACCENT_GOLD, ACCENT)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def gradient_jade(text: str) -> str:
|
| 153 |
+
"""Pulse β jade. Used for success states + live indicators."""
|
| 154 |
+
return gradient(text, PULSE, JADE)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
# βββ Style primitives βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 158 |
+
|
| 159 |
+
def dim(s: str) -> str:
|
| 160 |
+
return color(s, INK_FAINT)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def muted(s: str) -> str:
|
| 164 |
+
return color(s, INK_MUTE)
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def soft(s: str) -> str:
|
| 168 |
+
return color(s, INK_SOFT)
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def ink(s: str) -> str:
|
| 172 |
+
return color(s, INK)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def ok(s: str) -> str:
|
| 176 |
+
return color(s, PULSE)
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def warn(s: str) -> str:
|
| 180 |
+
return color(s, ACCENT_WARM)
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def err(s: str) -> str:
|
| 184 |
+
return color(s, ERROR)
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def accent(s: str) -> str:
|
| 188 |
+
return color(s, ACCENT)
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def bold(s: str) -> str:
|
| 192 |
+
if not _C:
|
| 193 |
+
return s
|
| 194 |
+
return f"\033[1m{s}{RESET}"
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
# βββ Composite primitives βββββββββββββββββββββββββββββββββββββββββββββ
|
| 198 |
+
|
| 199 |
+
def eyebrow(label: str) -> str:
|
| 200 |
+
"""Uppercase letter-spaced ochre label. Editorial section marker."""
|
| 201 |
+
spaced = " ".join(label.upper())
|
| 202 |
+
return color(spaced, ACCENT)
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def divider(width: int = 60, *, glyph: str = "β") -> str:
|
| 206 |
+
"""Creme-paper rule line."""
|
| 207 |
+
return color(glyph * width, RULE)
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def section_header(name: str, *, subtitle: str | None = None, width: int = 60) -> str:
|
| 211 |
+
"""The standard subcommand opener.
|
| 212 |
+
|
| 213 |
+
β <name> ββββββββββββββββββββββββββββββββββββββββ
|
| 214 |
+
<subtitle, dim>
|
| 215 |
+
"""
|
| 216 |
+
name_part = f" {gradient_gold(name)} "
|
| 217 |
+
# Stripped-color length for visible width calc
|
| 218 |
+
visible_name_len = len(f" {name} ")
|
| 219 |
+
rule_left = "β"
|
| 220 |
+
rule_right = "β" * max(3, width - 1 - visible_name_len)
|
| 221 |
+
head = color(rule_left, RULE) + name_part + color(rule_right, RULE)
|
| 222 |
+
if subtitle:
|
| 223 |
+
return head + "\n" + dim(subtitle)
|
| 224 |
+
return head
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def chip(text: str, *, palette: str = "accent") -> str:
|
| 228 |
+
"""Compact inline label Β· [text]."""
|
| 229 |
+
palettes = {
|
| 230 |
+
"accent": ACCENT,
|
| 231 |
+
"jade": PULSE,
|
| 232 |
+
"warn": ACCENT_WARM,
|
| 233 |
+
"error": ERROR,
|
| 234 |
+
"mute": INK_MUTE,
|
| 235 |
+
}
|
| 236 |
+
c = palettes.get(palette, ACCENT)
|
| 237 |
+
return color(f"[{text}]", c)
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def kv(label: str, value: str, *, label_width: int = 16, value_color: str = "ink") -> str:
|
| 241 |
+
"""One left-aligned label / value row.
|
| 242 |
+
|
| 243 |
+
Used across status / whoami / devices for the LOCAL / SERVER blocks.
|
| 244 |
+
"""
|
| 245 |
+
palettes = {
|
| 246 |
+
"ink": INK, "soft": INK_SOFT, "mute": INK_MUTE, "faint": INK_FAINT,
|
| 247 |
+
"accent": ACCENT, "ok": PULSE, "warn": ACCENT_WARM, "err": ERROR,
|
| 248 |
+
}
|
| 249 |
+
val_color = palettes.get(value_color, INK_SOFT)
|
| 250 |
+
return f" {color(label.ljust(label_width), INK_FAINT)} {color(value, val_color)}"
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def block_title(text: str) -> str:
|
| 254 |
+
"""Sub-section title within a command output. Like 'LOCAL' or 'SERVER'."""
|
| 255 |
+
return "\n" + eyebrow(text)
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def success_line(text: str) -> str:
|
| 259 |
+
"""Single-line success marker with gradient + glyph."""
|
| 260 |
+
return f" {ok(GLYPH_OK)} {gradient_jade(text)}"
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def warn_line(text: str) -> str:
|
| 264 |
+
return f" {warn(GLYPH_WARN)} {warn(text)}"
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def err_line(text: str) -> str:
|
| 268 |
+
return f" {err(GLYPH_ERR)} {err(text)}"
|
| 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 |
+
|
| 276 |
+
|
| 277 |
+
def footer_credits(*, width: int = 60) -> str:
|
| 278 |
+
"""Bottom-of-output line. Used at end of long outputs."""
|
| 279 |
+
return color("β" * width, RULE) + "\n" + dim(" sibyl labs Β· memory you can hold in your hand")
|
sibyl-memory-hermes/src/sibyl_memory_hermes/_banner.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ASCII banner for sibyl-memory-cli.
|
| 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:
|
| 9 |
+
- NO_COLOR env var set β plain text fallback
|
| 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.
|
| 17 |
+
"""
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 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 |
+
"ββββββββββββββββββ βββ ββββββ ",
|
| 27 |
+
"βββββββββββββββββββββββ βββββββ ",
|
| 28 |
+
"βββββββββββββββββββ βββββββ βββ ",
|
| 29 |
+
"βββββββββββββββββββ βββββ βββ ",
|
| 30 |
+
"βββββββββββββββββββ βββ ββββββββ",
|
| 31 |
+
"ββββββββββββββββββ βββ ββββββββ",
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
# Vertical gradient Β· cream β gold β deep ochre. One RGB tuple per row.
|
| 35 |
+
# Tuned against the SIBYL palette: --paper #f5f1e6 (top blend),
|
| 36 |
+
# --accent #8a6a2a (mid-bottom), with extra highlight + shadow stops
|
| 37 |
+
# to give the wordmark visible dimension.
|
| 38 |
+
_GRADIENT = (
|
| 39 |
+
(253, 251, 245), # almost white, slight cream (top highlight)
|
| 40 |
+
(244, 229, 184), # pale gold (upper)
|
| 41 |
+
(224, 194, 119), # mid gold (upper-mid)
|
| 42 |
+
(184, 146, 73), # rich ochre gold (mid)
|
| 43 |
+
(138, 106, 42), # deep ochre Β· brand --accent (lower)
|
| 44 |
+
(106, 79, 31), # deepest (bottom shadow)
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
_TAGLINE = "memory you can hold in your hand"
|
| 48 |
+
_ATTRIBUTION = "a Sibyl Labs LLC Product. Agentic Infrastructure and Memory Products"
|
| 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":
|
| 56 |
+
return False
|
| 57 |
+
if not sys.stdout.isatty():
|
| 58 |
+
return False
|
| 59 |
+
colorterm = os.environ.get("COLORTERM", "").lower()
|
| 60 |
+
if "truecolor" in colorterm or "24bit" in colorterm:
|
| 61 |
+
return True
|
| 62 |
+
# Many modern terminals don't set COLORTERM but do support truecolor.
|
| 63 |
+
# Recognize the well-behaved emitters.
|
| 64 |
+
term_program = os.environ.get("TERM_PROGRAM", "").lower()
|
| 65 |
+
if term_program in {"iterm.app", "wezterm", "ghostty", "vscode", "tabby"}:
|
| 66 |
+
return True
|
| 67 |
+
term = os.environ.get("TERM", "").lower()
|
| 68 |
+
if any(k in term for k in ("256color", "kitty", "alacritty", "xterm-direct")):
|
| 69 |
+
return True
|
| 70 |
+
return False
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _color_supported() -> bool:
|
| 74 |
+
"""Plain ANSI color (3/4-bit). Stricter than truecolor."""
|
| 75 |
+
if os.environ.get("NO_COLOR"):
|
| 76 |
+
return False
|
| 77 |
+
if os.environ.get("TERM", "").lower() == "dumb":
|
| 78 |
+
return False
|
| 79 |
+
return sys.stdout.isatty()
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _rgb(r: int, g: int, b: int) -> str:
|
| 83 |
+
return f"\033[38;2;{r};{g};{b}m"
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
_RESET = "\033[0m"
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def render_banner(*, force_color: bool | None = None) -> str:
|
| 90 |
+
"""Return the banner as a string ready to print.
|
| 91 |
+
|
| 92 |
+
Args:
|
| 93 |
+
force_color: Override auto-detection. None = auto, True = force
|
| 94 |
+
truecolor, False = force plain text. Useful for testing.
|
| 95 |
+
"""
|
| 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"
|
| 118 |
+
return body + tagline + attribution
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def print_banner(*, force_color: bool | None = None) -> None:
|
| 122 |
+
"""Print the banner. Safe to call unconditionally; honors NO_COLOR + TTY checks."""
|
| 123 |
+
print(render_banner(force_color=force_color))
|
sibyl-memory-hermes/src/sibyl_memory_hermes/_hermes_plugin/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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`
|
| 5 |
+
console script copies these files to $HERMES_HOME/plugins/sibyl/ where
|
| 6 |
+
Hermes' loader discovers them.
|
| 7 |
+
|
| 8 |
+
`adapter.py` is intentionally NOT named `__init__.py` here: it imports
|
| 9 |
+
`agent.memory_provider` which only exists inside a Hermes-installed
|
| 10 |
+
environment. Naming it as a module member would cause Python to attempt
|
| 11 |
+
to load it on package import and fail in our test environments.
|
| 12 |
+
"""
|
sibyl-memory-hermes/src/sibyl_memory_hermes/_hermes_plugin/adapter.py
ADDED
|
@@ -0,0 +1,531 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sibyl memory plugin β MemoryProvider adapter for sibyl-memory-hermes.
|
| 2 |
+
|
| 3 |
+
Developed by SIBYL, Sibyl Labs LLC. MIT licensed.
|
| 4 |
+
|
| 5 |
+
Bridges the Hermes v0.13 MemoryProvider ABC to the framework-agnostic
|
| 6 |
+
SibylMemoryProvider exposed by the `sibyl-memory-hermes` SDK package.
|
| 7 |
+
|
| 8 |
+
Why an adapter exists:
|
| 9 |
+
sibyl-memory-hermes ships a rich, LangChain-flavored surface
|
| 10 |
+
(save_context/load_context/remember/recall/search/set_state/...) but
|
| 11 |
+
does NOT implement Hermes' MemoryProvider ABC. This module is the
|
| 12 |
+
thin wrapper that exposes the SDK to Hermes' plugin loader.
|
| 13 |
+
|
| 14 |
+
Install location:
|
| 15 |
+
Drop this directory at one of:
|
| 16 |
+
$HERMES_HOME/plugins/sibyl/ (user install)
|
| 17 |
+
<site-packages>/plugins/memory/sibyl/ (bundled install)
|
| 18 |
+
Then activate via config.yaml:
|
| 19 |
+
memory:
|
| 20 |
+
provider: sibyl
|
| 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.
|
| 34 |
+
- sync_turn daemon uses retry-on-busy with backoff + WARNING log on
|
| 35 |
+
final drop (was: silent log-and-drop).
|
| 36 |
+
- shutdown sets a stop flag the daemon checks before issuing slow
|
| 37 |
+
writes, so 10-second join-on-shutdown doesn't drop in-flight turns.
|
| 38 |
+
"""
|
| 39 |
+
|
| 40 |
+
from __future__ import annotations
|
| 41 |
+
|
| 42 |
+
import json
|
| 43 |
+
import logging
|
| 44 |
+
import threading
|
| 45 |
+
import time
|
| 46 |
+
from hashlib import blake2b
|
| 47 |
+
from pathlib import Path
|
| 48 |
+
from typing import Any
|
| 49 |
+
|
| 50 |
+
# ---------------------------------------------------------------------------
|
| 51 |
+
# Hermes-side imports (guarded so the module loads off-Hermes for tests)
|
| 52 |
+
# ---------------------------------------------------------------------------
|
| 53 |
+
try:
|
| 54 |
+
from agent.memory_provider import MemoryProvider # type: ignore[import-not-found]
|
| 55 |
+
from tools.registry import tool_error # type: ignore[import-not-found]
|
| 56 |
+
_HERMES_AVAILABLE = True
|
| 57 |
+
except ImportError:
|
| 58 |
+
# Off-Hermes (test runner, dry-run, generic Python). Provide a no-op
|
| 59 |
+
# base + a tool_error stub that returns the same JSON shape Hermes
|
| 60 |
+
# would. The bundled module stays importable.
|
| 61 |
+
_HERMES_AVAILABLE = False
|
| 62 |
+
|
| 63 |
+
class MemoryProvider: # type: ignore[no-redef]
|
| 64 |
+
"""Standalone fallback base when hermes-agent isn't installed."""
|
| 65 |
+
pass
|
| 66 |
+
|
| 67 |
+
def tool_error(msg: str) -> str: # type: ignore[misc]
|
| 68 |
+
return json.dumps({"error": msg})
|
| 69 |
+
|
| 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)
|
| 77 |
+
_PREFETCH_LIMIT = 5 # how many search hits to inject
|
| 78 |
+
_MAX_PREFETCH_CHARS = 6000 # trim prefetch block
|
| 79 |
+
_DEFAULT_SEARCH_LIMIT = 10 # sibyl_search default limit
|
| 80 |
+
_DEFAULT_LIST_LIMIT = 50 # sibyl_list default limit
|
| 81 |
+
_BUSY_RETRY_ATTEMPTS = 3 # sync_turn retry-on-busy attempts
|
| 82 |
+
_BUSY_RETRY_BACKOFF = 0.2 # base seconds between retries
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _hermes_home() -> Path:
|
| 86 |
+
"""Resolve $HERMES_HOME at call time (profiles can rebind it)."""
|
| 87 |
+
from hermes_constants import get_hermes_home # type: ignore[import-not-found]
|
| 88 |
+
return get_hermes_home()
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def _stable_key(content: str, prefix: str = "") -> str:
|
| 92 |
+
"""Deterministic short id for on_memory_write mirroring.
|
| 93 |
+
|
| 94 |
+
blake2b keeps the value stable across runs so add+remove on the same
|
| 95 |
+
content actually targets the same entity name.
|
| 96 |
+
"""
|
| 97 |
+
h = blake2b(content.encode("utf-8", errors="replace"), digest_size=6).hexdigest()
|
| 98 |
+
return f"{prefix}{h}" if prefix else h
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
# ---------------------------------------------------------------------------
|
| 102 |
+
# Tool schemas β OpenAI function-calling shape
|
| 103 |
+
# ---------------------------------------------------------------------------
|
| 104 |
+
|
| 105 |
+
REMEMBER_SCHEMA = {
|
| 106 |
+
"name": "sibyl_remember",
|
| 107 |
+
"description": (
|
| 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",
|
| 115 |
+
"properties": {
|
| 116 |
+
"category": {
|
| 117 |
+
"type": "string",
|
| 118 |
+
"description": "Logical grouping, e.g. 'project', 'user', 'pattern', 'decision'.",
|
| 119 |
+
},
|
| 120 |
+
"name": {
|
| 121 |
+
"type": "string",
|
| 122 |
+
"description": "Short identifier unique within the category.",
|
| 123 |
+
},
|
| 124 |
+
"body": {
|
| 125 |
+
"type": "object",
|
| 126 |
+
"description": "JSON body describing the entity. Free-form dict.",
|
| 127 |
+
},
|
| 128 |
+
"status": {
|
| 129 |
+
"type": "string",
|
| 130 |
+
"description": "Optional lifecycle status (e.g. 'active', 'draft').",
|
| 131 |
+
},
|
| 132 |
+
},
|
| 133 |
+
"required": ["category", "name", "body"],
|
| 134 |
+
},
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
RECALL_SCHEMA = {
|
| 138 |
+
"name": "sibyl_recall",
|
| 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": {
|
| 146 |
+
"type": "object",
|
| 147 |
+
"properties": {
|
| 148 |
+
"category": {"type": "string", "description": "Category the entity lives under."},
|
| 149 |
+
"name": {"type": "string", "description": "Entity name within the category."},
|
| 150 |
+
},
|
| 151 |
+
"required": ["category", "name"],
|
| 152 |
+
},
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
SEARCH_SCHEMA = {
|
| 156 |
+
"name": "sibyl_search",
|
| 157 |
+
"description": (
|
| 158 |
+
"FTS5 full-text search across ALL Sibyl tiers (entities + state + "
|
| 159 |
+
"reference + journal) for this tenant. Each hit carries a `tier` tag "
|
| 160 |
+
"so you know where the match came from. Returns ranked matches. Use "
|
| 161 |
+
"whenever you want past context but don't know the exact (category, name)."
|
| 162 |
+
),
|
| 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}).",
|
| 170 |
+
"default": _DEFAULT_SEARCH_LIMIT,
|
| 171 |
+
},
|
| 172 |
+
},
|
| 173 |
+
"required": ["query"],
|
| 174 |
+
},
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
LIST_SCHEMA = {
|
| 178 |
+
"name": "sibyl_list",
|
| 179 |
+
"description": (
|
| 180 |
+
"List entities, optionally filtered by category and/or status. "
|
| 181 |
+
"Use for browsing what's been remembered rather than recalling a "
|
| 182 |
+
"specific item."
|
| 183 |
+
),
|
| 184 |
+
"parameters": {
|
| 185 |
+
"type": "object",
|
| 186 |
+
"properties": {
|
| 187 |
+
"category": {
|
| 188 |
+
"type": "string",
|
| 189 |
+
"description": "Optional: restrict to this category.",
|
| 190 |
+
},
|
| 191 |
+
"status": {
|
| 192 |
+
"type": "string",
|
| 193 |
+
"description": "Optional: restrict to entities with this status.",
|
| 194 |
+
},
|
| 195 |
+
"limit": {
|
| 196 |
+
"type": "integer",
|
| 197 |
+
"description": f"Max entries to return (default {_DEFAULT_LIST_LIMIT}).",
|
| 198 |
+
"default": _DEFAULT_LIST_LIMIT,
|
| 199 |
+
},
|
| 200 |
+
},
|
| 201 |
+
"required": [],
|
| 202 |
+
},
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
# ---------------------------------------------------------------------------
|
| 207 |
+
# Adapter
|
| 208 |
+
# ---------------------------------------------------------------------------
|
| 209 |
+
|
| 210 |
+
class SibylAdapter(MemoryProvider):
|
| 211 |
+
"""Hermes MemoryProvider that delegates to sibyl-memory-hermes."""
|
| 212 |
+
|
| 213 |
+
def __init__(self) -> None:
|
| 214 |
+
self._sibyl = None # type: ignore[assignment] # set in initialize()
|
| 215 |
+
self._session_id: str = ""
|
| 216 |
+
self._hermes_home: Path | None = None
|
| 217 |
+
self._agent_context: str = "primary"
|
| 218 |
+
self._sync_thread: threading.Thread | None = None
|
| 219 |
+
self._sync_lock = threading.Lock()
|
| 220 |
+
self._shutting_down = False # P-C2 fix: skip slow paths during shutdown
|
| 221 |
+
|
| 222 |
+
# -- mandatory ----------------------------------------------------------
|
| 223 |
+
|
| 224 |
+
@property
|
| 225 |
+
def name(self) -> str:
|
| 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
|
| 233 |
+
except Exception:
|
| 234 |
+
return False
|
| 235 |
+
|
| 236 |
+
def initialize(self, session_id: str, **kwargs: Any) -> None:
|
| 237 |
+
from sibyl_memory_hermes import SibylMemoryProvider
|
| 238 |
+
|
| 239 |
+
self._session_id = session_id
|
| 240 |
+
hermes_home_raw = kwargs.get("hermes_home") or str(_hermes_home())
|
| 241 |
+
self._hermes_home = Path(hermes_home_raw)
|
| 242 |
+
self._agent_context = kwargs.get("agent_context", "primary") or "primary"
|
| 243 |
+
self._shutting_down = False
|
| 244 |
+
|
| 245 |
+
db_dir = self._hermes_home / "sibyl"
|
| 246 |
+
db_dir.mkdir(parents=True, exist_ok=True)
|
| 247 |
+
db_path = db_dir / "memory.db"
|
| 248 |
+
|
| 249 |
+
# autoload_credentials=True picks up ~/.sibyl-memory/credentials.json
|
| 250 |
+
# (created by `sibyl init`). require_credentials=False so we degrade
|
| 251 |
+
# to DEFAULT_TENANT pre-activation rather than crash on first run.
|
| 252 |
+
self._sibyl = SibylMemoryProvider(
|
| 253 |
+
db_path=db_path,
|
| 254 |
+
autoload_credentials=True,
|
| 255 |
+
require_credentials=False,
|
| 256 |
+
)
|
| 257 |
+
logger.info("Sibyl memory initialized: db=%s session=%s", db_path, session_id)
|
| 258 |
+
|
| 259 |
+
def get_tool_schemas(self) -> list[dict[str, Any]]:
|
| 260 |
+
return [REMEMBER_SCHEMA, RECALL_SCHEMA, SEARCH_SCHEMA, LIST_SCHEMA]
|
| 261 |
+
|
| 262 |
+
# -- recommended overrides ---------------------------------------------
|
| 263 |
+
|
| 264 |
+
def system_prompt_block(self) -> str:
|
| 265 |
+
return (
|
| 266 |
+
"# Sibyl Memory\n"
|
| 267 |
+
"Active. Local SQLite-backed structured memory with four searchable "
|
| 268 |
+
"tiers (warm entities, hot state, cold journal, reference docs).\n"
|
| 269 |
+
"- sibyl_remember(category, name, body): store a fact\n"
|
| 270 |
+
"- sibyl_recall(category, name): look up a known fact (returns {body, ...} row)\n"
|
| 271 |
+
"- sibyl_search(query): FTS5 search across ALL tiers; hits are tier-tagged\n"
|
| 272 |
+
"- sibyl_list(category?, status?): browse what's remembered"
|
| 273 |
+
)
|
| 274 |
+
|
| 275 |
+
def prefetch(self, query: str, *, session_id: str = "") -> str:
|
| 276 |
+
if not self._sibyl or not query or len(query.strip()) < _MIN_QUERY_LEN:
|
| 277 |
+
return ""
|
| 278 |
+
try:
|
| 279 |
+
hits = self._sibyl.search(query.strip()[:1000], limit=_PREFETCH_LIMIT)
|
| 280 |
+
except Exception as e:
|
| 281 |
+
logger.debug("Sibyl prefetch search failed: %s", e)
|
| 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", "")
|
| 289 |
+
key = hit.get("key") or hit.get("name") or "?"
|
| 290 |
+
body = hit.get("body")
|
| 291 |
+
body_repr = json.dumps(body, ensure_ascii=False, default=str) if body else ""
|
| 292 |
+
if len(body_repr) > 400:
|
| 293 |
+
body_repr = body_repr[:400] + "β¦"
|
| 294 |
+
label = f"{category}/{key}" if category else f"{tier}:{key}"
|
| 295 |
+
lines.append(f"- [{label}] {body_repr}")
|
| 296 |
+
block = "\n".join(lines)
|
| 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 |
+
|
| 304 |
+
def sync_turn(self, user_content: str, assistant_content: str,
|
| 305 |
+
*, session_id: str = "") -> None:
|
| 306 |
+
"""Append the turn to the cold journal in a daemon thread.
|
| 307 |
+
|
| 308 |
+
v0.3.1 (audit P-C1, P-C2):
|
| 309 |
+
- Retry-on-busy with backoff (was: silent log-and-drop on first failure)
|
| 310 |
+
- WARNING log on final drop after retries exhausted
|
| 311 |
+
- Skip the slow cap-gate path during shutdown
|
| 312 |
+
- Serializes consecutive writes by joining the previous thread first
|
| 313 |
+
(mirrors the byterover/honcho pattern)
|
| 314 |
+
"""
|
| 315 |
+
if not self._sibyl:
|
| 316 |
+
return
|
| 317 |
+
if self._agent_context != "primary":
|
| 318 |
+
# Cron/subagent contexts: don't journal (would corrupt the user's
|
| 319 |
+
# representation as the ABC docstring warns).
|
| 320 |
+
return
|
| 321 |
+
if not user_content and not assistant_content:
|
| 322 |
+
return
|
| 323 |
+
|
| 324 |
+
sid = session_id or self._session_id
|
| 325 |
+
sibyl = self._sibyl
|
| 326 |
+
|
| 327 |
+
def _write() -> None:
|
| 328 |
+
attempts = _BUSY_RETRY_ATTEMPTS
|
| 329 |
+
for attempt in range(1, attempts + 1):
|
| 330 |
+
if self._shutting_down:
|
| 331 |
+
logger.warning(
|
| 332 |
+
"Sibyl sync_turn skipping write during shutdown (session=%s)", sid)
|
| 333 |
+
return
|
| 334 |
+
try:
|
| 335 |
+
sibyl.save_context(
|
| 336 |
+
inputs={"user": user_content, "session_id": sid},
|
| 337 |
+
outputs={"assistant": assistant_content},
|
| 338 |
+
)
|
| 339 |
+
return # success
|
| 340 |
+
except Exception as e:
|
| 341 |
+
if attempt < attempts:
|
| 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",
|
| 349 |
+
attempts, type(e).__name__,
|
| 350 |
+
)
|
| 351 |
+
|
| 352 |
+
with self._sync_lock:
|
| 353 |
+
if self._sync_thread and self._sync_thread.is_alive():
|
| 354 |
+
self._sync_thread.join(timeout=_SYNC_JOIN_TIMEOUT)
|
| 355 |
+
t = threading.Thread(target=_write, daemon=True, name="sibyl-sync")
|
| 356 |
+
self._sync_thread = t
|
| 357 |
+
t.start()
|
| 358 |
+
|
| 359 |
+
def handle_tool_call(self, tool_name: str, args: dict[str, Any], **kwargs: Any) -> str:
|
| 360 |
+
if not self._sibyl:
|
| 361 |
+
return tool_error("Sibyl provider not initialized")
|
| 362 |
+
|
| 363 |
+
try:
|
| 364 |
+
if tool_name == "sibyl_remember":
|
| 365 |
+
category = args.get("category")
|
| 366 |
+
name = args.get("name")
|
| 367 |
+
body = args.get("body")
|
| 368 |
+
if not category or not name or body is None:
|
| 369 |
+
return tool_error("category, name, and body are required")
|
| 370 |
+
status = args.get("status")
|
| 371 |
+
result = self._sibyl.remember(category, name, body, status=status)
|
| 372 |
+
return json.dumps({"ok": True, "entity": result}, default=str)
|
| 373 |
+
|
| 374 |
+
if tool_name == "sibyl_recall":
|
| 375 |
+
category = args.get("category")
|
| 376 |
+
name = args.get("name")
|
| 377 |
+
if not category or not name:
|
| 378 |
+
return tool_error("category and name are required")
|
| 379 |
+
result = self._sibyl.recall(category, name)
|
| 380 |
+
return json.dumps({"entity": result}, default=str)
|
| 381 |
+
|
| 382 |
+
if tool_name == "sibyl_search":
|
| 383 |
+
query = args.get("query")
|
| 384 |
+
if not query:
|
| 385 |
+
return tool_error("query is required")
|
| 386 |
+
limit = int(args.get("limit") or _DEFAULT_SEARCH_LIMIT)
|
| 387 |
+
hits = self._sibyl.search(query, limit=limit)
|
| 388 |
+
return json.dumps({"results": hits}, default=str)
|
| 389 |
+
|
| 390 |
+
if tool_name == "sibyl_list":
|
| 391 |
+
category = args.get("category")
|
| 392 |
+
status = args.get("status")
|
| 393 |
+
limit = int(args.get("limit") or _DEFAULT_LIST_LIMIT)
|
| 394 |
+
rows = self._sibyl.list(category=category, status=status, limit=limit)
|
| 395 |
+
return json.dumps({"entities": rows}, default=str)
|
| 396 |
+
|
| 397 |
+
return tool_error(f"Unknown tool: {tool_name}")
|
| 398 |
+
|
| 399 |
+
except Exception as e:
|
| 400 |
+
logger.exception("Sibyl tool %s failed", tool_name)
|
| 401 |
+
# SEC-10 hardening: send only the exception class name back to the
|
| 402 |
+
# agent. str(e) could echo entity bodies / args that contained
|
| 403 |
+
# sensitive content. The full exception is in the local log.
|
| 404 |
+
return tool_error(f"{type(e).__name__}")
|
| 405 |
+
|
| 406 |
+
def shutdown(self) -> None:
|
| 407 |
+
# P-C2 fix: set the stop flag BEFORE joining so in-flight write loops
|
| 408 |
+
# see it on their next iteration and exit without issuing a slow
|
| 409 |
+
# cap-gate refresh.
|
| 410 |
+
self._shutting_down = True
|
| 411 |
+
if self._sync_thread and self._sync_thread.is_alive():
|
| 412 |
+
self._sync_thread.join(timeout=_SHUTDOWN_JOIN_TIMEOUT)
|
| 413 |
+
|
| 414 |
+
# -- optional hooks ----------------------------------------------------
|
| 415 |
+
|
| 416 |
+
def on_session_switch(self, new_session_id: str, *,
|
| 417 |
+
parent_session_id: str = "",
|
| 418 |
+
reset: bool = False, **kwargs: Any) -> None:
|
| 419 |
+
# Sibyl doesn't cache per-session resources; just update the id we
|
| 420 |
+
# stamp onto journal events.
|
| 421 |
+
self._session_id = new_session_id
|
| 422 |
+
|
| 423 |
+
def on_pre_compress(self, messages: list[dict[str, Any]]) -> str:
|
| 424 |
+
"""Flush soon-to-be-discarded turns to the journal."""
|
| 425 |
+
if not self._sibyl or not messages:
|
| 426 |
+
return ""
|
| 427 |
+
|
| 428 |
+
# Pair user+assistant messages in order; capture the last ~10 pairs.
|
| 429 |
+
pairs: list[tuple[str, str]] = []
|
| 430 |
+
pending_user: str | None = None
|
| 431 |
+
for msg in messages[-20:]:
|
| 432 |
+
role = msg.get("role")
|
| 433 |
+
content = msg.get("content")
|
| 434 |
+
if not isinstance(content, str) or not content.strip():
|
| 435 |
+
continue
|
| 436 |
+
if role == "user":
|
| 437 |
+
pending_user = content
|
| 438 |
+
elif role == "assistant" and pending_user is not None:
|
| 439 |
+
pairs.append((pending_user, content))
|
| 440 |
+
pending_user = None
|
| 441 |
+
|
| 442 |
+
if not pairs:
|
| 443 |
+
return ""
|
| 444 |
+
|
| 445 |
+
sibyl = self._sibyl
|
| 446 |
+
sid = self._session_id
|
| 447 |
+
|
| 448 |
+
def _flush() -> None:
|
| 449 |
+
for user_c, asst_c in pairs:
|
| 450 |
+
if self._shutting_down:
|
| 451 |
+
return
|
| 452 |
+
try:
|
| 453 |
+
sibyl.save_context(
|
| 454 |
+
inputs={"user": user_c, "session_id": sid,
|
| 455 |
+
"reason": "pre_compress"},
|
| 456 |
+
outputs={"assistant": asst_c},
|
| 457 |
+
)
|
| 458 |
+
except Exception as e:
|
| 459 |
+
logger.debug("Sibyl pre_compress flush failed: %s", e)
|
| 460 |
+
|
| 461 |
+
threading.Thread(target=_flush, daemon=True, name="sibyl-flush").start()
|
| 462 |
+
return ""
|
| 463 |
+
|
| 464 |
+
def on_delegation(self, task: str, result: str, *,
|
| 465 |
+
child_session_id: str = "", **kwargs: Any) -> None:
|
| 466 |
+
if not self._sibyl:
|
| 467 |
+
return
|
| 468 |
+
try:
|
| 469 |
+
self._sibyl.save_context(
|
| 470 |
+
inputs={"delegated_task": task, "child_sid": child_session_id,
|
| 471 |
+
"session_id": self._session_id},
|
| 472 |
+
outputs={"child_result": result},
|
| 473 |
+
)
|
| 474 |
+
except Exception as e:
|
| 475 |
+
logger.debug("Sibyl on_delegation failed: %s", e)
|
| 476 |
+
|
| 477 |
+
def on_memory_write(self, action: str, target: str, content: str,
|
| 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:
|
| 485 |
+
return
|
| 486 |
+
name = _stable_key(content)
|
| 487 |
+
try:
|
| 488 |
+
if action in ("add", "replace"):
|
| 489 |
+
self._sibyl.remember(
|
| 490 |
+
category=target,
|
| 491 |
+
name=name,
|
| 492 |
+
body={"content": content, "metadata": metadata or {}},
|
| 493 |
+
)
|
| 494 |
+
elif action == "remove":
|
| 495 |
+
self._sibyl.forget(category=target, name=name)
|
| 496 |
+
except Exception as e:
|
| 497 |
+
logger.debug("Sibyl on_memory_write (%s/%s) failed: %s", action, target, e)
|
| 498 |
+
|
| 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
|
| 523 |
+
|
| 524 |
+
|
| 525 |
+
# ---------------------------------------------------------------------------
|
| 526 |
+
# Plugin entry point
|
| 527 |
+
# ---------------------------------------------------------------------------
|
| 528 |
+
|
| 529 |
+
def register(ctx: Any) -> None:
|
| 530 |
+
"""Register Sibyl as a memory provider plugin."""
|
| 531 |
+
ctx.register_memory_provider(SibylAdapter())
|
sibyl-memory-hermes/src/sibyl_memory_hermes/_hermes_plugin/plugin.yaml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 6 |
+
license: MIT
|
sibyl-memory-hermes/src/sibyl_memory_hermes/credentials.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Credential loader for the Sibyl Memory plugin.
|
| 2 |
+
|
| 3 |
+
`sibyl init` writes `~/.sibyl-memory/credentials.json` after a successful
|
| 4 |
+
activation. The Hermes provider reads it at startup so callers don't have
|
| 5 |
+
to pass account/tenant IDs explicitly. This file is mode 0600.
|
| 6 |
+
|
| 7 |
+
Shape:
|
| 8 |
+
|
| 9 |
+
{
|
| 10 |
+
"account_id": "uuid",
|
| 11 |
+
"tenant_id": "uuid OR email-like string",
|
| 12 |
+
"email": "alice@example.com", // optional
|
| 13 |
+
"wallet": "0x...", // optional
|
| 14 |
+
"tier": "free | sync | team | lifetime | stake | enterprise",
|
| 15 |
+
"issued_at": "2026-05-21T14:32:18Z",
|
| 16 |
+
"schema_version": 1
|
| 17 |
+
}
|
| 18 |
+
"""
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import json
|
| 22 |
+
import os
|
| 23 |
+
from dataclasses import dataclass
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
|
| 26 |
+
DEFAULT_DB_PATH = "~/.sibyl-memory/memory.db"
|
| 27 |
+
DEFAULT_CRED_PATH = "~/.sibyl-memory/credentials.json"
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class CredentialsNotFoundError(FileNotFoundError):
|
| 31 |
+
"""Raised when the plugin has not been activated yet."""
|
| 32 |
+
|
| 33 |
+
def __init__(self, path: str | Path) -> None:
|
| 34 |
+
super().__init__(
|
| 35 |
+
f"No Sibyl Memory credentials found at {path}. "
|
| 36 |
+
f"Run `sibyl init` to activate the plugin, or pass tenant_id "
|
| 37 |
+
f"explicitly to SibylMemoryProvider(tenant_id=...)."
|
| 38 |
+
)
|
| 39 |
+
self.path = Path(path)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@dataclass(frozen=True)
|
| 43 |
+
class Credentials:
|
| 44 |
+
"""Parsed credential file. Immutable for safety.
|
| 45 |
+
|
| 46 |
+
schema_version 2 (server-issued 2026-05-16+) adds two fields:
|
| 47 |
+
- signature: HMAC-SHA256 of the canonical credential fields
|
| 48 |
+
(account_id, tenant_id, tier, email, wallet, issued_at,
|
| 49 |
+
schema_version) signed server-side at issue time.
|
| 50 |
+
- signed_at: ISO timestamp when the signature was generated.
|
| 51 |
+
|
| 52 |
+
The SDK does NOT verify the signature locally (would require sharing the
|
| 53 |
+
server's HMAC key, which would defeat the purpose). Instead, the SDK
|
| 54 |
+
includes the signature alongside the claim in any cap-gate request, and
|
| 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
|
| 63 |
+
tenant_id: str
|
| 64 |
+
tier: str = "free"
|
| 65 |
+
email: str | None = None
|
| 66 |
+
wallet: str | None = None
|
| 67 |
+
issued_at: str | None = None
|
| 68 |
+
schema_version: int = 1
|
| 69 |
+
session_token: str | None = None # long-lived bearer for tier-check calls
|
| 70 |
+
signature: str | None = None # HMAC-SHA256 (hex, 64 chars), schema v2+
|
| 71 |
+
signed_at: str | None = None # ISO timestamp, schema v2+
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def load_credentials(path: str | Path = DEFAULT_CRED_PATH) -> Credentials:
|
| 75 |
+
"""Load credentials from disk.
|
| 76 |
+
|
| 77 |
+
v0.3.1 hardening (audit SEC-11): refuses to follow symlinks. A
|
| 78 |
+
low-privilege attacker who once had write to ~/.sibyl-memory could
|
| 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
|
| 86 |
+
ValueError: file present but unparseable / missing required fields
|
| 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()
|
| 94 |
+
if not resolved.exists():
|
| 95 |
+
raise CredentialsNotFoundError(resolved)
|
| 96 |
+
|
| 97 |
+
with resolved.open("r", encoding="utf-8") as fh:
|
| 98 |
+
raw = json.load(fh)
|
| 99 |
+
|
| 100 |
+
# Be lenient about missing optional fields; strict only about the two
|
| 101 |
+
# IDs we genuinely need.
|
| 102 |
+
if "tenant_id" not in raw and "account_id" not in raw:
|
| 103 |
+
raise ValueError(
|
| 104 |
+
f"Credentials file at {resolved} is missing both tenant_id and account_id; "
|
| 105 |
+
f"the file may be corrupted. Re-run `sibyl init` to refresh."
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
account_id = raw.get("account_id") or raw["tenant_id"]
|
| 109 |
+
tenant_id = raw.get("tenant_id") or raw["account_id"]
|
| 110 |
+
|
| 111 |
+
return Credentials(
|
| 112 |
+
account_id=account_id,
|
| 113 |
+
tenant_id=tenant_id,
|
| 114 |
+
tier=raw.get("tier", "free"),
|
| 115 |
+
email=raw.get("email"),
|
| 116 |
+
wallet=raw.get("wallet"),
|
| 117 |
+
issued_at=raw.get("issued_at"),
|
| 118 |
+
schema_version=int(raw.get("schema_version", 1)),
|
| 119 |
+
session_token=raw.get("session_token"),
|
| 120 |
+
signature=raw.get("signature"),
|
| 121 |
+
signed_at=raw.get("signed_at"),
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def write_credentials(creds: Credentials, path: str | Path = DEFAULT_CRED_PATH) -> Path:
|
| 126 |
+
"""Write a credentials file at mode 0600.
|
| 127 |
+
|
| 128 |
+
v0.3.1 hardening (audit SEC-2): atomic create-with-mode using
|
| 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 |
+
"""
|
| 136 |
+
resolved = Path(path).expanduser().resolve()
|
| 137 |
+
resolved.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
| 138 |
+
payload = {
|
| 139 |
+
"account_id": creds.account_id,
|
| 140 |
+
"tenant_id": creds.tenant_id,
|
| 141 |
+
"tier": creds.tier,
|
| 142 |
+
"email": creds.email,
|
| 143 |
+
"wallet": creds.wallet,
|
| 144 |
+
"issued_at": creds.issued_at,
|
| 145 |
+
"schema_version": creds.schema_version,
|
| 146 |
+
"session_token": creds.session_token,
|
| 147 |
+
"signature": creds.signature,
|
| 148 |
+
"signed_at": creds.signed_at,
|
| 149 |
+
}
|
| 150 |
+
data = json.dumps(payload, indent=2).encode("utf-8")
|
| 151 |
+
tmp = resolved.with_suffix(resolved.suffix + ".tmp")
|
| 152 |
+
# Clean any leftover .tmp from a crashed prior write so O_EXCL can succeed.
|
| 153 |
+
try:
|
| 154 |
+
os.unlink(tmp)
|
| 155 |
+
except FileNotFoundError:
|
| 156 |
+
pass
|
| 157 |
+
# Atomic create-with-mode. O_NOFOLLOW rejects symlink targets.
|
| 158 |
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
| 159 |
+
if hasattr(os, "O_NOFOLLOW"):
|
| 160 |
+
flags |= os.O_NOFOLLOW
|
| 161 |
+
fd = os.open(str(tmp), flags, 0o600)
|
| 162 |
+
try:
|
| 163 |
+
os.write(fd, data)
|
| 164 |
+
os.fsync(fd)
|
| 165 |
+
finally:
|
| 166 |
+
os.close(fd)
|
| 167 |
+
os.replace(str(tmp), str(resolved))
|
| 168 |
+
return resolved
|
sibyl-memory-hermes/src/sibyl_memory_hermes/install_plugin.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 5 |
+
for `__init__.py` files under two locations:
|
| 6 |
+
|
| 7 |
+
- bundled: <site-packages>/plugins/memory/<name>/__init__.py
|
| 8 |
+
- user: $HERMES_HOME/plugins/<name>/__init__.py (note: no /memory/)
|
| 9 |
+
|
| 10 |
+
After `pip install sibyl-memory-hermes`, the user runs this script to drop
|
| 11 |
+
the bundled adapter into their HERMES_HOME. They then activate by setting
|
| 12 |
+
`memory.provider: sibyl` in their config.yaml.
|
| 13 |
+
|
| 14 |
+
Usage:
|
| 15 |
+
sibyl-memory-hermes install-plugin
|
| 16 |
+
sibyl-memory-hermes install-plugin --hermes-home /custom/path
|
| 17 |
+
sibyl-memory-hermes install-plugin --force
|
| 18 |
+
sibyl-memory-hermes install-plugin --dry-run
|
| 19 |
+
sibyl-memory-hermes uninstall-plugin
|
| 20 |
+
|
| 21 |
+
v0.3.1 hardening (audit SEC-5):
|
| 22 |
+
--force will not rmtree a target directory unless it looks like an
|
| 23 |
+
actual prior Sibyl install (existing plugin.yaml with name: sibyl).
|
| 24 |
+
Prevents accidental destruction of arbitrary user-writable trees
|
| 25 |
+
via misconfigured HERMES_HOME.
|
| 26 |
+
"""
|
| 27 |
+
from __future__ import annotations
|
| 28 |
+
|
| 29 |
+
import argparse
|
| 30 |
+
import os
|
| 31 |
+
import shutil
|
| 32 |
+
import sys
|
| 33 |
+
from importlib import resources
|
| 34 |
+
from pathlib import Path
|
| 35 |
+
|
| 36 |
+
from . import _aesthetic as a
|
| 37 |
+
from ._banner import print_banner
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _hermes_home(override: str | None = None) -> Path:
|
| 41 |
+
"""Resolve the active HERMES_HOME directory.
|
| 42 |
+
|
| 43 |
+
Precedence: CLI flag β $HERMES_HOME env var β ~/.hermes default.
|
| 44 |
+
"""
|
| 45 |
+
if override:
|
| 46 |
+
return Path(override).expanduser().resolve()
|
| 47 |
+
env = os.environ.get("HERMES_HOME")
|
| 48 |
+
if env:
|
| 49 |
+
return Path(env).expanduser().resolve()
|
| 50 |
+
return Path.home() / ".hermes"
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _plugin_dest(hermes_home: Path) -> Path:
|
| 54 |
+
"""User-install location for a Hermes memory provider plugin.
|
| 55 |
+
|
| 56 |
+
Note: asymmetric vs the bundled location. Bundled providers live at
|
| 57 |
+
<site-packages>/plugins/memory/<name>/, but user plugins live at
|
| 58 |
+
$HERMES_HOME/plugins/<name>/ without the /memory/ segment. Confirmed
|
| 59 |
+
against plugins/memory/__init__.py loader source.
|
| 60 |
+
"""
|
| 61 |
+
return hermes_home / "plugins" / "sibyl"
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _payload_files() -> list[tuple[str, str]]:
|
| 65 |
+
"""Files to copy: (source_name_in_package, dest_name_in_plugin_dir).
|
| 66 |
+
|
| 67 |
+
adapter.py is renamed to __init__.py at destination so Hermes' filesystem
|
| 68 |
+
discovery (which looks for `<plugins>/<name>/__init__.py`) picks it up.
|
| 69 |
+
v0.3.1: adapter.py imports the Hermes ABC under a try/except guard, so
|
| 70 |
+
the source module is now importable in test / dry-run contexts where
|
| 71 |
+
hermes-agent isn't installed.
|
| 72 |
+
"""
|
| 73 |
+
return [
|
| 74 |
+
("adapter.py", "__init__.py"),
|
| 75 |
+
("plugin.yaml", "plugin.yaml"),
|
| 76 |
+
]
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _read_payload(filename: str) -> bytes:
|
| 80 |
+
"""Read a bundled file from the _hermes_plugin package."""
|
| 81 |
+
return (resources.files("sibyl_memory_hermes._hermes_plugin") / filename).read_bytes()
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _looks_like_sibyl_install(dest: Path) -> bool:
|
| 85 |
+
"""SEC-5 sentinel check: dest must contain a recognizable prior Sibyl
|
| 86 |
+
install before we'll rmtree it.
|
| 87 |
+
|
| 88 |
+
Recognizes the install by `plugin.yaml` with `name: sibyl` in it (the
|
| 89 |
+
canonical marker we ship). If the directory exists but doesn't match,
|
| 90 |
+
we refuse --force rather than destroy possibly-unrelated content."""
|
| 91 |
+
yaml_path = dest / "plugin.yaml"
|
| 92 |
+
if not yaml_path.exists() or not yaml_path.is_file():
|
| 93 |
+
return False
|
| 94 |
+
try:
|
| 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:
|
| 102 |
+
return True
|
| 103 |
+
return False
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def install(hermes_home: Path, force: bool, dry_run: bool) -> int:
|
| 107 |
+
dest = _plugin_dest(hermes_home)
|
| 108 |
+
|
| 109 |
+
# ββ HEAVY: install moment. Full SIBYL banner + section header. ββ
|
| 110 |
+
print_banner()
|
| 111 |
+
print(a.section_header("install-plugin",
|
| 112 |
+
subtitle="hermes memory provider Β· drops adapter at $HERMES_HOME/plugins/sibyl/"))
|
| 113 |
+
print()
|
| 114 |
+
print(a.kv("Hermes home", str(hermes_home)))
|
| 115 |
+
print(a.kv("Plugin dest", str(dest)))
|
| 116 |
+
print()
|
| 117 |
+
|
| 118 |
+
# SEC-5: refuse to follow symlinks for the dest path.
|
| 119 |
+
if dest.exists() and dest.is_symlink():
|
| 120 |
+
print(a.err_line(f"Refused: {dest} is a symlink."))
|
| 121 |
+
print(a.dim(" Sibyl will not install through symlinks. Remove the symlink and rerun."))
|
| 122 |
+
return 3
|
| 123 |
+
|
| 124 |
+
if dest.exists() and any(dest.iterdir()):
|
| 125 |
+
if not force:
|
| 126 |
+
print(a.err_line(f"Refused: {dest} already exists and is not empty."))
|
| 127 |
+
print(a.dim(" Use --force to overwrite. Existing files will be replaced."))
|
| 128 |
+
return 2
|
| 129 |
+
# SEC-5: sentinel check
|
| 130 |
+
if not _looks_like_sibyl_install(dest):
|
| 131 |
+
print(a.err_line(f"Refused: {dest} is not empty but does not contain a prior Sibyl install."))
|
| 132 |
+
print(a.dim(f" No plugin.yaml with `name: sibyl` found. Sibyl will not rmtree directories"))
|
| 133 |
+
print(a.dim(f" it does not recognize, even with --force. Remove manually if intentional."))
|
| 134 |
+
return 4
|
| 135 |
+
if not dry_run:
|
| 136 |
+
print(a.warn_line(f"Removing existing plugin at {dest}"))
|
| 137 |
+
shutil.rmtree(dest)
|
| 138 |
+
else:
|
| 139 |
+
print(a.dim(f" [dry-run] would remove existing plugin at {dest}"))
|
| 140 |
+
|
| 141 |
+
if not dry_run:
|
| 142 |
+
dest.mkdir(parents=True, exist_ok=True)
|
| 143 |
+
|
| 144 |
+
print(a.eyebrow("writing payload"))
|
| 145 |
+
for src_name, dest_name in _payload_files():
|
| 146 |
+
bytes_in = _read_payload(src_name)
|
| 147 |
+
out = dest / dest_name
|
| 148 |
+
if dry_run:
|
| 149 |
+
print(f" {a.dim('[dry-run]')} would write {a.color(str(out), a.INK)} {a.dim(f'({len(bytes_in)} bytes)')}")
|
| 150 |
+
else:
|
| 151 |
+
out.write_bytes(bytes_in)
|
| 152 |
+
print(f" {a.ok(a.GLYPH_OK)} {a.color(str(out), a.INK)} {a.dim(f'({len(bytes_in)} bytes)')}")
|
| 153 |
+
|
| 154 |
+
if dry_run:
|
| 155 |
+
print()
|
| 156 |
+
print(a.warn_line("Dry run complete. No files modified."))
|
| 157 |
+
return 0
|
| 158 |
+
|
| 159 |
+
print()
|
| 160 |
+
print(a.success_line("Plugin installed."))
|
| 161 |
+
print()
|
| 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()
|
| 169 |
+
print(f" {a.color('memory:', a.ACCENT)}")
|
| 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)}")
|
| 185 |
+
print()
|
| 186 |
+
|
| 187 |
+
print(a.divider(60))
|
| 188 |
+
print(f" {a.dim('uninstall later:')} {a.color('sibyl-memory-hermes uninstall-plugin', a.INK)}")
|
| 189 |
+
print(f" {a.dim('docs:')} {a.color('docs.sibyllabs.org/memory/integrations', a.INK)}")
|
| 190 |
+
print()
|
| 191 |
+
return 0
|
| 192 |
+
|
| 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"))
|
| 200 |
+
print()
|
| 201 |
+
print(a.kv("Hermes home", str(hermes_home)))
|
| 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."))
|
| 209 |
+
print(a.dim(" Sibyl will not rmtree through symlinks."))
|
| 210 |
+
return 3
|
| 211 |
+
if not _looks_like_sibyl_install(dest):
|
| 212 |
+
print(a.err_line(f"Refused: {dest} is not recognized as a Sibyl install."))
|
| 213 |
+
print(a.dim(" No plugin.yaml with `name: sibyl`. Remove manually if intentional."))
|
| 214 |
+
return 4
|
| 215 |
+
if dry_run:
|
| 216 |
+
print(a.dim(f" [dry-run] would remove {dest} (recursively)"))
|
| 217 |
+
return 0
|
| 218 |
+
shutil.rmtree(dest)
|
| 219 |
+
print(a.success_line(f"Removed {dest}"))
|
| 220 |
+
print()
|
| 221 |
+
print(a.dim(f" remember to remove `memory.provider: sibyl` from"))
|
| 222 |
+
print(a.dim(f" {hermes_home / 'config.yaml'}"))
|
| 223 |
+
print(a.dim(" if it's still set, or Hermes will warn on startup."))
|
| 224 |
+
return 0
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def main(argv: list[str] | None = None) -> int:
|
| 228 |
+
parser = argparse.ArgumentParser(
|
| 229 |
+
prog="sibyl-memory-hermes",
|
| 230 |
+
description="Install the Sibyl memory provider plugin into Hermes.",
|
| 231 |
+
)
|
| 232 |
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
| 233 |
+
|
| 234 |
+
p_install = sub.add_parser("install-plugin", help="Install the Sibyl plugin into HERMES_HOME.")
|
| 235 |
+
p_install.add_argument("--hermes-home", help="Override HERMES_HOME (defaults to env var or ~/.hermes).")
|
| 236 |
+
p_install.add_argument("--force", action="store_true", help="Overwrite an existing Sibyl plugin directory (refuses non-Sibyl content).")
|
| 237 |
+
p_install.add_argument("--dry-run", action="store_true", help="Show what would happen without writing.")
|
| 238 |
+
|
| 239 |
+
p_uninstall = sub.add_parser("uninstall-plugin", help="Remove the Sibyl plugin from HERMES_HOME.")
|
| 240 |
+
p_uninstall.add_argument("--hermes-home", help="Override HERMES_HOME (defaults to env var or ~/.hermes).")
|
| 241 |
+
p_uninstall.add_argument("--dry-run", action="store_true", help="Show what would happen without writing.")
|
| 242 |
+
|
| 243 |
+
args = parser.parse_args(argv)
|
| 244 |
+
hermes_home = _hermes_home(args.hermes_home)
|
| 245 |
+
|
| 246 |
+
if args.cmd == "install-plugin":
|
| 247 |
+
return install(hermes_home, force=args.force, dry_run=args.dry_run)
|
| 248 |
+
if args.cmd == "uninstall-plugin":
|
| 249 |
+
return uninstall(hermes_home, dry_run=args.dry_run)
|
| 250 |
+
parser.print_help()
|
| 251 |
+
return 1
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
if __name__ == "__main__":
|
| 255 |
+
sys.exit(main())
|
sibyl-memory-hermes/src/sibyl_memory_hermes/provider.py
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SibylMemoryProvider β framework-agnostic Sibyl Memory SDK class.
|
| 2 |
+
|
| 3 |
+
DESIGN NOTES
|
| 4 |
+
============
|
| 5 |
+
|
| 6 |
+
Pure-Python SDK class. NOT a Hermes plugin on its own. The Hermes plugin
|
| 7 |
+
contract is satisfied by a thin adapter at `_hermes_plugin/adapter.py`
|
| 8 |
+
that delegates to this class. The split is intentional:
|
| 9 |
+
|
| 10 |
+
- This class can be used by any orchestration (LangChain, LlamaIndex,
|
| 11 |
+
custom Python, the sibyl-memory-mcp server, direct callers).
|
| 12 |
+
- The Hermes adapter handles Hermes-specific lifecycle (initialize,
|
| 13 |
+
sync_turn, get_tool_schemas, etc.) and is installed via the
|
| 14 |
+
`sibyl-memory-hermes install-plugin` console script.
|
| 15 |
+
|
| 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 |
+
|
| 23 |
+
The provider routes operations onto the correct memory tier:
|
| 24 |
+
|
| 25 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 26 |
+
β intent β tier β storage call β
|
| 27 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
|
| 28 |
+
β "save the conversation" β COLD journal β write_event(...) β
|
| 29 |
+
β "remember this fact" β WARM entity β set_entity(...) β
|
| 30 |
+
β "current state" β HOT state β set_state(...) β
|
| 31 |
+
β "lookup runbook" β REFERENCE β set_reference(...) β
|
| 32 |
+
β "archive stale entity" β ARCHIVE β archive_entity β
|
| 33 |
+
β "search by content" β FTS5 β search_entities β
|
| 34 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 35 |
+
|
| 36 |
+
The split is intentional. Vector-DB-only providers collapse all of the above
|
| 37 |
+
onto similarity search, which loses structure. Sibyl Memory preserves it.
|
| 38 |
+
"""
|
| 39 |
+
from __future__ import annotations
|
| 40 |
+
|
| 41 |
+
from pathlib import Path
|
| 42 |
+
from typing import Any
|
| 43 |
+
|
| 44 |
+
from sibyl_memory_client import DEFAULT_TENANT, MemoryClient
|
| 45 |
+
from sibyl_memory_client.exceptions import NotFoundError
|
| 46 |
+
|
| 47 |
+
from .credentials import (
|
| 48 |
+
DEFAULT_CRED_PATH,
|
| 49 |
+
DEFAULT_DB_PATH,
|
| 50 |
+
Credentials,
|
| 51 |
+
CredentialsNotFoundError,
|
| 52 |
+
load_credentials,
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class SibylMemoryProvider:
|
| 57 |
+
"""Hermes Agent memory provider backed by sibyl-memory-client.
|
| 58 |
+
|
| 59 |
+
Args:
|
| 60 |
+
db_path: path to the local SQLite database. Defaults to
|
| 61 |
+
~/.sibyl-memory/memory.db (the path `sibyl init`
|
| 62 |
+
creates).
|
| 63 |
+
tenant_id: explicit tenant override. If None, credentials.json
|
| 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.
|
| 71 |
+
"""
|
| 72 |
+
|
| 73 |
+
def __init__(
|
| 74 |
+
self,
|
| 75 |
+
db_path: str | Path = DEFAULT_DB_PATH,
|
| 76 |
+
*,
|
| 77 |
+
tenant_id: str | None = None,
|
| 78 |
+
credentials_path: str | Path = DEFAULT_CRED_PATH,
|
| 79 |
+
require_credentials: bool = False,
|
| 80 |
+
autoload_credentials: bool = True,
|
| 81 |
+
) -> None:
|
| 82 |
+
# Resolve tenant: explicit > credentials > default
|
| 83 |
+
resolved_tenant = tenant_id
|
| 84 |
+
creds: Credentials | None = None
|
| 85 |
+
|
| 86 |
+
if resolved_tenant is None and autoload_credentials:
|
| 87 |
+
try:
|
| 88 |
+
creds = load_credentials(credentials_path)
|
| 89 |
+
resolved_tenant = creds.tenant_id
|
| 90 |
+
except CredentialsNotFoundError:
|
| 91 |
+
if require_credentials:
|
| 92 |
+
raise
|
| 93 |
+
resolved_tenant = DEFAULT_TENANT
|
| 94 |
+
except (OSError, ValueError):
|
| 95 |
+
if require_credentials:
|
| 96 |
+
raise
|
| 97 |
+
resolved_tenant = DEFAULT_TENANT
|
| 98 |
+
|
| 99 |
+
if resolved_tenant is None:
|
| 100 |
+
resolved_tenant = DEFAULT_TENANT
|
| 101 |
+
|
| 102 |
+
self._credentials = creds
|
| 103 |
+
# Plumb account_id, session_token, tier, and the HMAC-signed
|
| 104 |
+
# credentials claim through to the client so the cap gate can:
|
| 105 |
+
# 1. verify free-tier writes against the authoritative server when
|
| 106 |
+
# the local DB approaches 2 MB (v0.3.0 behavior), and
|
| 107 |
+
# 2. include the credentials_signature + claim in cap-check
|
| 108 |
+
# requests so the server can detect local credentials.json
|
| 109 |
+
# tampering and log it as telemetry (v0.3.1+).
|
| 110 |
+
client_tier = creds.tier if creds else "free"
|
| 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
|
| 118 |
+
if creds and creds.signature:
|
| 119 |
+
client_signature = creds.signature
|
| 120 |
+
client_claim = {
|
| 121 |
+
"account_id": creds.account_id,
|
| 122 |
+
"tenant_id": creds.tenant_id,
|
| 123 |
+
"tier": creds.tier,
|
| 124 |
+
"email": creds.email,
|
| 125 |
+
"wallet": creds.wallet,
|
| 126 |
+
"issued_at": creds.issued_at,
|
| 127 |
+
"schema_version": creds.schema_version,
|
| 128 |
+
}
|
| 129 |
+
self._client = MemoryClient.local(
|
| 130 |
+
db_path,
|
| 131 |
+
tenant_id=resolved_tenant,
|
| 132 |
+
tier=client_tier,
|
| 133 |
+
account_id=client_account_id,
|
| 134 |
+
session_token=client_session_token,
|
| 135 |
+
credentials_claim=client_claim,
|
| 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 |
+
# ------------------------------------------------------------------
|
| 143 |
+
# Properties
|
| 144 |
+
# ------------------------------------------------------------------
|
| 145 |
+
@property
|
| 146 |
+
def client(self) -> MemoryClient:
|
| 147 |
+
"""The underlying MemoryClient. Use for advanced operations not
|
| 148 |
+
covered by the provider surface."""
|
| 149 |
+
return self._client
|
| 150 |
+
|
| 151 |
+
@property
|
| 152 |
+
def credentials(self) -> Credentials | None:
|
| 153 |
+
return self._credentials
|
| 154 |
+
|
| 155 |
+
@property
|
| 156 |
+
def tenant_id(self) -> str:
|
| 157 |
+
return self._client.get_tenant()
|
| 158 |
+
|
| 159 |
+
@property
|
| 160 |
+
def hermes_bound(self) -> bool:
|
| 161 |
+
"""Deprecated since v0.3.0. The Hermes plugin contract is now
|
| 162 |
+
satisfied by the bundled adapter (`_hermes_plugin/adapter.py`),
|
| 163 |
+
not by this class's inheritance. Always returns False.
|
| 164 |
+
|
| 165 |
+
v0.3.1: emits ``DeprecationWarning`` on read so users see the
|
| 166 |
+
signal before v0.4 removal.
|
| 167 |
+
|
| 168 |
+
Removed in v0.4.0.
|
| 169 |
+
"""
|
| 170 |
+
import warnings
|
| 171 |
+
warnings.warn(
|
| 172 |
+
"SibylMemoryProvider.hermes_bound is deprecated and always "
|
| 173 |
+
"returns False since v0.3.0. The Hermes plugin contract is "
|
| 174 |
+
"now satisfied by the bundled adapter at _hermes_plugin/"
|
| 175 |
+
"adapter.py. Property will be removed in v0.4.0.",
|
| 176 |
+
DeprecationWarning,
|
| 177 |
+
stacklevel=2,
|
| 178 |
+
)
|
| 179 |
+
return False
|
| 180 |
+
|
| 181 |
+
# ==================================================================
|
| 182 |
+
# HERMES-STYLE PROVIDER SURFACE
|
| 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).
|
| 190 |
+
# ==================================================================
|
| 191 |
+
|
| 192 |
+
def save_context(
|
| 193 |
+
self,
|
| 194 |
+
inputs: dict[str, Any],
|
| 195 |
+
outputs: dict[str, Any],
|
| 196 |
+
*,
|
| 197 |
+
ts: str | None = None,
|
| 198 |
+
) -> str:
|
| 199 |
+
"""Persist a single turn (inputs + outputs) to the journal.
|
| 200 |
+
|
| 201 |
+
Returns the journal event id."""
|
| 202 |
+
return self._client.write_event(
|
| 203 |
+
evaluated=inputs,
|
| 204 |
+
acted=outputs,
|
| 205 |
+
ts=ts,
|
| 206 |
+
)
|
| 207 |
+
|
| 208 |
+
def load_context(self, *, limit: int = 20) -> list[dict[str, Any]]:
|
| 209 |
+
"""Return the most recent N turns from the journal."""
|
| 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
|
| 217 |
+
compatibility."""
|
| 218 |
+
return None
|
| 219 |
+
|
| 220 |
+
# ------------------------------------------------------------------
|
| 221 |
+
# Fact store (WARM tier)
|
| 222 |
+
# ------------------------------------------------------------------
|
| 223 |
+
def remember(
|
| 224 |
+
self,
|
| 225 |
+
category: str,
|
| 226 |
+
name: str,
|
| 227 |
+
body: dict[str, Any] | list[Any],
|
| 228 |
+
*,
|
| 229 |
+
status: str | None = None,
|
| 230 |
+
) -> dict[str, Any]:
|
| 231 |
+
"""Upsert an entity. Single source of truth per (tenant, category, name)."""
|
| 232 |
+
return self._client.set_entity(category, name, body, status=status)
|
| 233 |
+
|
| 234 |
+
def recall(self, category: str, name: str) -> dict[str, Any] | None:
|
| 235 |
+
"""Look up a single entity by (category, name).
|
| 236 |
+
|
| 237 |
+
Returns: a row dict shaped ``{id, tenant_id, category, name, status,
|
| 238 |
+
body, created_at, updated_at}`` where ``body`` is the user-supplied
|
| 239 |
+
JSON payload, or ``None`` if no matching entity exists.
|
| 240 |
+
|
| 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 |
+
|
| 248 |
+
Raises:
|
| 249 |
+
StorageError: backend (SQLite) failure
|
| 250 |
+
TenantError: misconfigured tenant_id
|
| 251 |
+
SchemaError: DB schema mismatch
|
| 252 |
+
|
| 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:
|
| 260 |
+
return self._client.get_entity(category, name)
|
| 261 |
+
except NotFoundError:
|
| 262 |
+
return None
|
| 263 |
+
|
| 264 |
+
def list( # noqa: A003 β Hermes-compatible name
|
| 265 |
+
self,
|
| 266 |
+
category: str | None = None,
|
| 267 |
+
*,
|
| 268 |
+
status: str | None = None,
|
| 269 |
+
limit: int = 100,
|
| 270 |
+
) -> list[dict[str, Any]]:
|
| 271 |
+
return self._client.list_entities(category=category, status=status, limit=limit)
|
| 272 |
+
|
| 273 |
+
def forget(self, category: str, name: str) -> bool:
|
| 274 |
+
"""Delete an entity. Returns True if a row was deleted, False if
|
| 275 |
+
the entity didn't exist (no-op).
|
| 276 |
+
|
| 277 |
+
Raises:
|
| 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 |
+
|
| 285 |
+
def archive(
|
| 286 |
+
self,
|
| 287 |
+
category: str,
|
| 288 |
+
name: str,
|
| 289 |
+
*,
|
| 290 |
+
reason: str | None = None,
|
| 291 |
+
) -> dict[str, Any]:
|
| 292 |
+
"""Move an entity to the archive tier.
|
| 293 |
+
|
| 294 |
+
Returns: dict shaped ``{archived_id, original_id}`` referencing the
|
| 295 |
+
new archive row.
|
| 296 |
+
|
| 297 |
+
Raises:
|
| 298 |
+
NotFoundError: no such (category, name) entity exists
|
| 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)
|
| 306 |
+
|
| 307 |
+
# ------------------------------------------------------------------
|
| 308 |
+
# State documents (HOT tier)
|
| 309 |
+
# ------------------------------------------------------------------
|
| 310 |
+
def set_state(self, key: str, body: dict[str, Any] | list[Any]) -> None:
|
| 311 |
+
"""Set a state-tier document. ``body`` must be a dict or list
|
| 312 |
+
(JSON-serializable container). To store a primitive, wrap in a
|
| 313 |
+
single-value dict, e.g. ``set_state("seq", {"value": 42})``.
|
| 314 |
+
|
| 315 |
+
Raises:
|
| 316 |
+
ValidationError: body not JSON-serializable
|
| 317 |
+
CapExceededError: write would push past the free-tier cap
|
| 318 |
+
StorageError: backend failure
|
| 319 |
+
"""
|
| 320 |
+
self._client.set_state(key, body)
|
| 321 |
+
|
| 322 |
+
def get_state(self, key: str) -> dict[str, Any] | None:
|
| 323 |
+
"""Read a state-tier document.
|
| 324 |
+
|
| 325 |
+
Returns: dict shaped ``{body, updated_at}`` (the user payload is
|
| 326 |
+
under ``body``), or ``None`` if no such key exists.
|
| 327 |
+
|
| 328 |
+
Raises:
|
| 329 |
+
StorageError: backend failure
|
| 330 |
+
"""
|
| 331 |
+
return self._client.get_state(key)
|
| 332 |
+
|
| 333 |
+
# ------------------------------------------------------------------
|
| 334 |
+
# Reference docs (REFERENCE tier)
|
| 335 |
+
# ------------------------------------------------------------------
|
| 336 |
+
def set_reference(
|
| 337 |
+
self,
|
| 338 |
+
key: str,
|
| 339 |
+
body: str,
|
| 340 |
+
*,
|
| 341 |
+
metadata: dict[str, Any] | None = None,
|
| 342 |
+
) -> None:
|
| 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 |
+
|
| 350 |
+
Raises:
|
| 351 |
+
ValidationError: metadata not JSON-serializable
|
| 352 |
+
CapExceededError: write would push past the free-tier cap
|
| 353 |
+
StorageError: backend failure
|
| 354 |
+
"""
|
| 355 |
+
self._client.set_reference(key, body, metadata=metadata)
|
| 356 |
+
|
| 357 |
+
def get_reference(self, key: str) -> dict[str, Any] | None:
|
| 358 |
+
"""Read a reference-tier document.
|
| 359 |
+
|
| 360 |
+
Returns: dict shaped ``{body, metadata, updated_at}`` (body is
|
| 361 |
+
the raw string), or ``None`` if no such key exists.
|
| 362 |
+
|
| 363 |
+
Raises:
|
| 364 |
+
StorageError: backend failure
|
| 365 |
+
"""
|
| 366 |
+
return self._client.get_reference(key)
|
| 367 |
+
|
| 368 |
+
# ------------------------------------------------------------------
|
| 369 |
+
# Search
|
| 370 |
+
# ------------------------------------------------------------------
|
| 371 |
+
def search(self, query: str, *, limit: int = 20,
|
| 372 |
+
prefix: bool = False,
|
| 373 |
+
tiers: tuple[str, ...] | None = None) -> list[dict[str, Any]]:
|
| 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::
|
| 381 |
+
|
| 382 |
+
{
|
| 383 |
+
"tier": "entity" | "state" | "reference" | "journal",
|
| 384 |
+
"key": <entity name | state key | doc_key | journal id>,
|
| 385 |
+
"category": <entity category or None>,
|
| 386 |
+
"body": <JSON-decoded payload (str for reference tier)>,
|
| 387 |
+
"snippet": <FTS5 snippet with [highlight] markers>,
|
| 388 |
+
"rank": <FTS5 rank, lower is better>,
|
| 389 |
+
"ts": <ISO timestamp>
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
Hits sorted globally by FTS5 rank. ``limit`` applies to the
|
| 393 |
+
combined union (not per tier). Pass ``tiers=("entity",)`` to
|
| 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 |
+
|
| 401 |
+
For warm-entity-only search returning full entity rows, use
|
| 402 |
+
``client.search_entities()`` directly.
|
| 403 |
+
|
| 404 |
+
Raises:
|
| 405 |
+
StorageError: backend failure
|
| 406 |
+
"""
|
| 407 |
+
return self._client.search(query, limit=limit, prefix=prefix, tiers=tiers)
|
| 408 |
+
|
| 409 |
+
# ------------------------------------------------------------------
|
| 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,
|
| 417 |
+
"schema_version": self._client.schema_version(),
|
| 418 |
+
"db_path": str(db_path),
|
| 419 |
+
"db_size_bytes": db_path.stat().st_size if db_path.exists() else 0,
|
| 420 |
+
"tenant_id": self.tenant_id,
|
| 421 |
+
"hermes_bound": False, # v0.3.0: adapter owns Hermes binding
|
| 422 |
+
"tier": self._credentials.tier if self._credentials else "free",
|
| 423 |
+
"email": self._credentials.email if self._credentials else None,
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
# ------------------------------------------------------------------
|
| 427 |
+
# repr
|
| 428 |
+
# ------------------------------------------------------------------
|
| 429 |
+
def __repr__(self) -> str: # pragma: no cover - trivial
|
| 430 |
+
return (
|
| 431 |
+
f"SibylMemoryProvider(db={self._client.storage.db_path}, "
|
| 432 |
+
f"tenant={self.tenant_id!r})"
|
| 433 |
+
)
|
sibyl-memory-hermes/tests/test_adapter.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the bundled Hermes plugin adapter (`_hermes_plugin/adapter.py`).
|
| 2 |
+
|
| 3 |
+
These tests close the validation gap flagged in the v0.3.1 pre-ship audit
|
| 4 |
+
(H1): the v0.3.0 CHANGELOG claimed "validated via Hermes' own
|
| 5 |
+
load_memory_provider('sibyl') dry-run + all 4 tool schemas resolved" but
|
| 6 |
+
zero references to the adapter existed in the test suite. A future change
|
| 7 |
+
that broke the adapter would have passed CI.
|
| 8 |
+
|
| 9 |
+
The adapter is designed to import cleanly off-Hermes (v0.3.1 guarded
|
| 10 |
+
imports): the `from agent.memory_provider import MemoryProvider` and
|
| 11 |
+
`from tools.registry import tool_error` are wrapped in try/except, with
|
| 12 |
+
no-op fallbacks. That means we can `import` and exercise the adapter
|
| 13 |
+
directly in pytest without mocking the Hermes runtime.
|
| 14 |
+
"""
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import json
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
|
| 20 |
+
import pytest
|
| 21 |
+
|
| 22 |
+
from sibyl_memory_hermes import SibylMemoryProvider
|
| 23 |
+
from sibyl_memory_hermes._hermes_plugin import adapter as adapter_module
|
| 24 |
+
from sibyl_memory_hermes._hermes_plugin.adapter import (
|
| 25 |
+
LIST_SCHEMA,
|
| 26 |
+
RECALL_SCHEMA,
|
| 27 |
+
REMEMBER_SCHEMA,
|
| 28 |
+
SEARCH_SCHEMA,
|
| 29 |
+
SibylAdapter,
|
| 30 |
+
_stable_key,
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
# ----------------------------------------------------------------------
|
| 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.
|
| 42 |
+
# In CI / local dev it's typically False; in a real Hermes deployment
|
| 43 |
+
# it's True. Either way the module loaded successfully (we're here).
|
| 44 |
+
assert hasattr(adapter_module, "_HERMES_AVAILABLE")
|
| 45 |
+
assert hasattr(adapter_module, "tool_error")
|
| 46 |
+
# tool_error must return a string regardless of source (Hermes-real or fallback)
|
| 47 |
+
out = adapter_module.tool_error("test message")
|
| 48 |
+
assert isinstance(out, str)
|
| 49 |
+
assert "test message" in out
|
| 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 |
+
|
| 57 |
+
|
| 58 |
+
# ----------------------------------------------------------------------
|
| 59 |
+
# Tool schemas
|
| 60 |
+
# ----------------------------------------------------------------------
|
| 61 |
+
def test_tool_schemas_have_correct_count_and_names() -> None:
|
| 62 |
+
"""The CHANGELOG promised 4 tools; this asserts they exist and are
|
| 63 |
+
named correctly. Catches accidental renames in future refactors."""
|
| 64 |
+
adapter = SibylAdapter()
|
| 65 |
+
schemas = adapter.get_tool_schemas()
|
| 66 |
+
assert len(schemas) == 4
|
| 67 |
+
names = sorted(s["name"] for s in schemas)
|
| 68 |
+
assert names == ["sibyl_list", "sibyl_recall", "sibyl_remember", "sibyl_search"]
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@pytest.mark.parametrize("schema", [REMEMBER_SCHEMA, RECALL_SCHEMA, SEARCH_SCHEMA, LIST_SCHEMA])
|
| 72 |
+
def test_tool_schemas_are_valid_openai_function_shape(schema: dict) -> None:
|
| 73 |
+
"""Each tool schema must follow OpenAI function-calling shape: name,
|
| 74 |
+
description, parameters (with type=object + properties + required)."""
|
| 75 |
+
assert "name" in schema
|
| 76 |
+
assert isinstance(schema["name"], str)
|
| 77 |
+
assert schema["name"].startswith("sibyl_")
|
| 78 |
+
assert "description" in schema
|
| 79 |
+
assert isinstance(schema["description"], str)
|
| 80 |
+
assert len(schema["description"]) > 0
|
| 81 |
+
assert "parameters" in schema
|
| 82 |
+
p = schema["parameters"]
|
| 83 |
+
assert p["type"] == "object"
|
| 84 |
+
assert "properties" in p
|
| 85 |
+
assert isinstance(p["properties"], dict)
|
| 86 |
+
assert "required" in p
|
| 87 |
+
assert isinstance(p["required"], list)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
# ----------------------------------------------------------------------
|
| 91 |
+
# Adapter init + dispatch
|
| 92 |
+
# ----------------------------------------------------------------------
|
| 93 |
+
def _make_initialized_adapter(tmp_path: Path) -> SibylAdapter:
|
| 94 |
+
"""Build a SibylAdapter wired to a temp DB. Bypasses Hermes' real
|
| 95 |
+
initialize() entry point (which calls _hermes_home from hermes_constants)
|
| 96 |
+
by setting the provider directly."""
|
| 97 |
+
adapter = SibylAdapter()
|
| 98 |
+
adapter._sibyl = SibylMemoryProvider(
|
| 99 |
+
db_path=str(tmp_path / "adapter.db"),
|
| 100 |
+
autoload_credentials=False,
|
| 101 |
+
)
|
| 102 |
+
adapter._session_id = "test-session"
|
| 103 |
+
adapter._hermes_home = tmp_path
|
| 104 |
+
return adapter
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def test_handle_tool_call_uninitialized_returns_error() -> None:
|
| 108 |
+
"""Calling handle_tool_call before initialize must return a structured
|
| 109 |
+
error, not crash."""
|
| 110 |
+
adapter = SibylAdapter()
|
| 111 |
+
result = adapter.handle_tool_call("sibyl_remember", {"category": "x", "name": "y", "body": {}})
|
| 112 |
+
parsed = json.loads(result)
|
| 113 |
+
assert "error" in parsed
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def test_handle_tool_call_unknown_tool_returns_error(tmp_path: Path) -> None:
|
| 117 |
+
"""Unknown tool names produce a clean error response, no exception."""
|
| 118 |
+
adapter = _make_initialized_adapter(tmp_path)
|
| 119 |
+
result = adapter.handle_tool_call("sibyl_does_not_exist", {})
|
| 120 |
+
parsed = json.loads(result)
|
| 121 |
+
assert "error" in parsed
|
| 122 |
+
assert "Unknown tool" in parsed["error"]
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def test_handle_tool_call_remember_then_recall(tmp_path: Path) -> None:
|
| 126 |
+
"""End-to-end: remember an entity, recall it, verify the body roundtrips."""
|
| 127 |
+
adapter = _make_initialized_adapter(tmp_path)
|
| 128 |
+
# remember
|
| 129 |
+
r1 = json.loads(adapter.handle_tool_call("sibyl_remember", {
|
| 130 |
+
"category": "project",
|
| 131 |
+
"name": "atlas",
|
| 132 |
+
"body": {"status": "shipping", "owner": "tt"},
|
| 133 |
+
}))
|
| 134 |
+
assert r1["ok"] is True
|
| 135 |
+
assert r1["entity"]["body"]["status"] == "shipping"
|
| 136 |
+
# recall
|
| 137 |
+
r2 = json.loads(adapter.handle_tool_call("sibyl_recall", {
|
| 138 |
+
"category": "project", "name": "atlas",
|
| 139 |
+
}))
|
| 140 |
+
assert r2["entity"] is not None
|
| 141 |
+
assert r2["entity"]["body"]["status"] == "shipping"
|
| 142 |
+
assert r2["entity"]["body"]["owner"] == "tt"
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def test_handle_tool_call_recall_missing_returns_null(tmp_path: Path) -> None:
|
| 146 |
+
"""Recall on a non-existent entity returns {"entity": null}, not an error."""
|
| 147 |
+
adapter = _make_initialized_adapter(tmp_path)
|
| 148 |
+
out = json.loads(adapter.handle_tool_call("sibyl_recall", {
|
| 149 |
+
"category": "project", "name": "nonexistent",
|
| 150 |
+
}))
|
| 151 |
+
assert out["entity"] is None
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def test_handle_tool_call_list_with_filter(tmp_path: Path) -> None:
|
| 155 |
+
"""list filters by category."""
|
| 156 |
+
adapter = _make_initialized_adapter(tmp_path)
|
| 157 |
+
for n, cat in [("a", "alpha"), ("b", "alpha"), ("c", "beta")]:
|
| 158 |
+
adapter.handle_tool_call("sibyl_remember", {
|
| 159 |
+
"category": cat, "name": n, "body": {"x": n},
|
| 160 |
+
})
|
| 161 |
+
out = json.loads(adapter.handle_tool_call("sibyl_list", {"category": "alpha"}))
|
| 162 |
+
names = sorted(e["name"] for e in out["entities"])
|
| 163 |
+
assert names == ["a", "b"]
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def test_handle_tool_call_search_cross_tier(tmp_path: Path) -> None:
|
| 167 |
+
"""v0.3.1 promise: search spans all four tiers, not entities only.
|
| 168 |
+
|
| 169 |
+
This is the regression test the audit (T5) said would have caught the
|
| 170 |
+
cross-tier-coverage bug if it had existed in v0.3.0."""
|
| 171 |
+
adapter = _make_initialized_adapter(tmp_path)
|
| 172 |
+
sibyl = adapter._sibyl
|
| 173 |
+
# Write a unique marker to each tier
|
| 174 |
+
sibyl.remember("project", "atlas", {"note": "entitytier_xyzzy"})
|
| 175 |
+
sibyl.set_state("active_branch", {"name": "statetier_xyzzy"})
|
| 176 |
+
sibyl.set_reference("runbook", "referencetier_xyzzy is the value")
|
| 177 |
+
sibyl.save_context(
|
| 178 |
+
inputs={"u": "journaltier_xyzzy is the user message"},
|
| 179 |
+
outputs={"a": "ok"},
|
| 180 |
+
)
|
| 181 |
+
out = json.loads(adapter.handle_tool_call("sibyl_search", {"query": "xyzzy"}))
|
| 182 |
+
hits = out["results"]
|
| 183 |
+
tiers_found = {h["tier"] for h in hits}
|
| 184 |
+
# Each tier should surface at least one hit
|
| 185 |
+
assert "entity" in tiers_found, f"entity tier missing from search: {tiers_found}"
|
| 186 |
+
assert "state" in tiers_found, f"state tier missing from search: {tiers_found}"
|
| 187 |
+
assert "reference" in tiers_found, f"reference tier missing from search: {tiers_found}"
|
| 188 |
+
assert "journal" in tiers_found, f"journal tier missing from search: {tiers_found}"
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def test_handle_tool_call_search_sanitizes_malformed_query(tmp_path: Path) -> None:
|
| 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 |
+
|
| 202 |
+
|
| 203 |
+
def test_handle_tool_call_missing_required_args(tmp_path: Path) -> None:
|
| 204 |
+
"""Required parameters surface a clean error, not a backend crash."""
|
| 205 |
+
adapter = _make_initialized_adapter(tmp_path)
|
| 206 |
+
out = json.loads(adapter.handle_tool_call("sibyl_remember", {"category": "x"}))
|
| 207 |
+
assert "error" in out
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
# ----------------------------------------------------------------------
|
| 211 |
+
# Shutdown behavior (P-C1, P-C2 audit fixes)
|
| 212 |
+
# ----------------------------------------------------------------------
|
| 213 |
+
def test_shutdown_sets_stop_flag(tmp_path: Path) -> None:
|
| 214 |
+
"""shutdown() sets _shutting_down so daemon writes can skip slow paths."""
|
| 215 |
+
adapter = _make_initialized_adapter(tmp_path)
|
| 216 |
+
assert adapter._shutting_down is False
|
| 217 |
+
adapter.shutdown()
|
| 218 |
+
assert adapter._shutting_down is True
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def test_sync_turn_during_shutdown_skips(tmp_path: Path) -> None:
|
| 222 |
+
"""sync_turn called after shutdown should not error out (writes are
|
| 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 |
+
|
| 230 |
+
# ----------------------------------------------------------------------
|
| 231 |
+
# Helper
|
| 232 |
+
# ----------------------------------------------------------------------
|
| 233 |
+
def test_stable_key_is_deterministic() -> None:
|
| 234 |
+
"""blake2b _stable_key gives the same answer for the same input across
|
| 235 |
+
runs. This is what makes add+remove on the same content actually target
|
| 236 |
+
the same entity."""
|
| 237 |
+
k1 = _stable_key("hello world")
|
| 238 |
+
k2 = _stable_key("hello world")
|
| 239 |
+
k3 = _stable_key("hello world!")
|
| 240 |
+
assert k1 == k2
|
| 241 |
+
assert k1 != k3
|
| 242 |
+
assert len(k1) == 12 # 6 bytes = 12 hex chars
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def test_stable_key_with_prefix() -> None:
|
| 246 |
+
"""prefix= argument prefixes the digest, used for namespacing built-in
|
| 247 |
+
memory-tool mirror writes."""
|
| 248 |
+
k = _stable_key("hello", prefix="mem-")
|
| 249 |
+
assert k.startswith("mem-")
|
| 250 |
+
assert len(k) == len("mem-") + 12
|
sibyl-memory-hermes/tests/test_smoke.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Smoke tests for sibyl-memory-hermes.
|
| 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 |
+
|
| 9 |
+
import json
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
import pytest
|
| 13 |
+
|
| 14 |
+
from sibyl_memory_hermes import (
|
| 15 |
+
DEFAULT_DB_PATH,
|
| 16 |
+
Credentials,
|
| 17 |
+
CredentialsNotFoundError,
|
| 18 |
+
SibylMemoryProvider,
|
| 19 |
+
__version__,
|
| 20 |
+
load_credentials,
|
| 21 |
+
)
|
| 22 |
+
from sibyl_memory_hermes.credentials import write_credentials
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# ----------------------------------------------------------------------
|
| 26 |
+
# Module-level sanity
|
| 27 |
+
# ----------------------------------------------------------------------
|
| 28 |
+
def test_version_is_pep440() -> None:
|
| 29 |
+
"""__version__ must be PEP440 format and single-sourced from importlib.metadata (v0.3.0+)."""
|
| 30 |
+
import re
|
| 31 |
+
from importlib.metadata import version as _v
|
| 32 |
+
|
| 33 |
+
# PEP440 minimum: N.N.N with optional pre/post/dev/local suffix.
|
| 34 |
+
assert re.match(r"^\d+\.\d+\.\d+", __version__), f"non-PEP440 version: {__version__}"
|
| 35 |
+
# When the package is installed (not a raw source tree), __version__ must
|
| 36 |
+
# match what importlib.metadata returns. v0.3.0 fixed the drift bug where
|
| 37 |
+
# __init__.py and the wheel could disagree.
|
| 38 |
+
if not __version__.endswith("+source"):
|
| 39 |
+
assert __version__ == _v("sibyl-memory-hermes")
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def test_default_db_path_is_home_relative() -> None:
|
| 43 |
+
assert DEFAULT_DB_PATH.startswith("~")
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
# ----------------------------------------------------------------------
|
| 47 |
+
# Construction
|
| 48 |
+
# ----------------------------------------------------------------------
|
| 49 |
+
def test_construct_explicit_path_default_tenant(tmp_path: Path) -> None:
|
| 50 |
+
db = tmp_path / "memory.db"
|
| 51 |
+
provider = SibylMemoryProvider(db_path=str(db), autoload_credentials=False)
|
| 52 |
+
assert provider.tenant_id == "00000000-0000-0000-0000-000000000001"
|
| 53 |
+
# Schema is currently v3 (cross-tier FTS5 landed 2026-05-18). Assert >= 2
|
| 54 |
+
# so the test survives future schema bumps without spurious breakage.
|
| 55 |
+
assert provider.client.schema_version() >= 2
|
| 56 |
+
assert db.exists()
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def test_construct_explicit_tenant(tmp_path: Path) -> None:
|
| 60 |
+
db = tmp_path / "memory.db"
|
| 61 |
+
provider = SibylMemoryProvider(
|
| 62 |
+
db_path=str(db), tenant_id="alice@example.com", autoload_credentials=False
|
| 63 |
+
)
|
| 64 |
+
assert provider.tenant_id == "alice@example.com"
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def test_construct_with_missing_credentials_degrades(tmp_path: Path) -> None:
|
| 68 |
+
db = tmp_path / "memory.db"
|
| 69 |
+
cred = tmp_path / "no-creds.json"
|
| 70 |
+
provider = SibylMemoryProvider(
|
| 71 |
+
db_path=str(db),
|
| 72 |
+
credentials_path=str(cred),
|
| 73 |
+
)
|
| 74 |
+
# Default tenant when creds absent
|
| 75 |
+
assert provider.tenant_id == "00000000-0000-0000-0000-000000000001"
|
| 76 |
+
assert provider.credentials is None
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def test_construct_require_credentials_raises(tmp_path: Path) -> None:
|
| 80 |
+
db = tmp_path / "memory.db"
|
| 81 |
+
cred = tmp_path / "no-creds.json"
|
| 82 |
+
with pytest.raises(CredentialsNotFoundError):
|
| 83 |
+
SibylMemoryProvider(
|
| 84 |
+
db_path=str(db),
|
| 85 |
+
credentials_path=str(cred),
|
| 86 |
+
require_credentials=True,
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def test_construct_with_credentials_file(tmp_path: Path) -> None:
|
| 91 |
+
db = tmp_path / "memory.db"
|
| 92 |
+
cred_path = tmp_path / "credentials.json"
|
| 93 |
+
creds = Credentials(
|
| 94 |
+
account_id="acct-123",
|
| 95 |
+
tenant_id="alice@example.com",
|
| 96 |
+
tier="lifetime",
|
| 97 |
+
email="alice@example.com",
|
| 98 |
+
issued_at="2026-05-21T14:32:18Z",
|
| 99 |
+
)
|
| 100 |
+
write_credentials(creds, cred_path)
|
| 101 |
+
|
| 102 |
+
provider = SibylMemoryProvider(
|
| 103 |
+
db_path=str(db), credentials_path=str(cred_path)
|
| 104 |
+
)
|
| 105 |
+
assert provider.tenant_id == "alice@example.com"
|
| 106 |
+
assert provider.credentials is not None
|
| 107 |
+
assert provider.credentials.tier == "lifetime"
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
# ----------------------------------------------------------------------
|
| 111 |
+
# Hermes contract surface
|
| 112 |
+
# ----------------------------------------------------------------------
|
| 113 |
+
def test_save_and_load_context(tmp_path: Path) -> None:
|
| 114 |
+
provider = SibylMemoryProvider(
|
| 115 |
+
db_path=str(tmp_path / "m.db"), autoload_credentials=False
|
| 116 |
+
)
|
| 117 |
+
ev_id = provider.save_context(
|
| 118 |
+
inputs={"user": "what's the status?"},
|
| 119 |
+
outputs={"agent": "all green"},
|
| 120 |
+
)
|
| 121 |
+
assert isinstance(ev_id, str)
|
| 122 |
+
assert len(ev_id) >= 8
|
| 123 |
+
|
| 124 |
+
events = provider.load_context(limit=10)
|
| 125 |
+
assert len(events) == 1
|
| 126 |
+
assert events[0]["evaluated"] == {"user": "what's the status?"}
|
| 127 |
+
assert events[0]["acted"] == {"agent": "all green"}
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def test_clear_context_is_noop(tmp_path: Path) -> None:
|
| 131 |
+
provider = SibylMemoryProvider(
|
| 132 |
+
db_path=str(tmp_path / "m.db"), autoload_credentials=False
|
| 133 |
+
)
|
| 134 |
+
provider.save_context({"q": "hi"}, {"r": "hello"})
|
| 135 |
+
assert provider.clear_context() is None
|
| 136 |
+
# journal still has the entry
|
| 137 |
+
assert len(provider.load_context()) == 1
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
# ----------------------------------------------------------------------
|
| 141 |
+
# Fact store (entities)
|
| 142 |
+
# ----------------------------------------------------------------------
|
| 143 |
+
def test_remember_recall_forget(tmp_path: Path) -> None:
|
| 144 |
+
provider = SibylMemoryProvider(
|
| 145 |
+
db_path=str(tmp_path / "m.db"), autoload_credentials=False
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
ent = provider.remember(
|
| 149 |
+
"project", "atlas",
|
| 150 |
+
{"status": "active", "owner": "jane"},
|
| 151 |
+
status="active",
|
| 152 |
+
)
|
| 153 |
+
assert ent["category"] == "project"
|
| 154 |
+
assert ent["name"] == "atlas"
|
| 155 |
+
assert ent["body"]["status"] == "active"
|
| 156 |
+
|
| 157 |
+
fetched = provider.recall("project", "atlas")
|
| 158 |
+
assert fetched is not None
|
| 159 |
+
assert fetched["body"]["owner"] == "jane"
|
| 160 |
+
|
| 161 |
+
assert provider.recall("project", "nonexistent") is None
|
| 162 |
+
|
| 163 |
+
assert provider.forget("project", "atlas") is True
|
| 164 |
+
assert provider.recall("project", "atlas") is None
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def test_list_entities(tmp_path: Path) -> None:
|
| 168 |
+
provider = SibylMemoryProvider(
|
| 169 |
+
db_path=str(tmp_path / "m.db"), autoload_credentials=False
|
| 170 |
+
)
|
| 171 |
+
provider.remember("project", "atlas", {"status": "active"}, status="active")
|
| 172 |
+
provider.remember("project", "borealis", {"status": "stale"}, status="stale")
|
| 173 |
+
provider.remember("person", "jane", {"role": "ops"})
|
| 174 |
+
|
| 175 |
+
projects = provider.list(category="project")
|
| 176 |
+
assert {p["name"] for p in projects} == {"atlas", "borealis"}
|
| 177 |
+
|
| 178 |
+
active = provider.list(category="project", status="active")
|
| 179 |
+
assert len(active) == 1
|
| 180 |
+
assert active[0]["name"] == "atlas"
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def test_archive_round_trip(tmp_path: Path) -> None:
|
| 184 |
+
provider = SibylMemoryProvider(
|
| 185 |
+
db_path=str(tmp_path / "m.db"), autoload_credentials=False
|
| 186 |
+
)
|
| 187 |
+
provider.remember("project", "dead-prototype", {"status": "abandoned"})
|
| 188 |
+
result = provider.archive("project", "dead-prototype", reason="stale")
|
| 189 |
+
assert "archived_id" in result
|
| 190 |
+
# Active set no longer contains it
|
| 191 |
+
assert provider.recall("project", "dead-prototype") is None
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
# ----------------------------------------------------------------------
|
| 195 |
+
# State / reference / search
|
| 196 |
+
# ----------------------------------------------------------------------
|
| 197 |
+
def test_state_documents(tmp_path: Path) -> None:
|
| 198 |
+
provider = SibylMemoryProvider(
|
| 199 |
+
db_path=str(tmp_path / "m.db"), autoload_credentials=False
|
| 200 |
+
)
|
| 201 |
+
provider.set_state("current-priorities", {"top": ["ship plugin"]})
|
| 202 |
+
state = provider.get_state("current-priorities")
|
| 203 |
+
assert state is not None
|
| 204 |
+
assert state["body"]["top"] == ["ship plugin"]
|
| 205 |
+
assert provider.get_state("nonexistent") is None
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def test_reference_documents(tmp_path: Path) -> None:
|
| 209 |
+
provider = SibylMemoryProvider(
|
| 210 |
+
db_path=str(tmp_path / "m.db"), autoload_credentials=False
|
| 211 |
+
)
|
| 212 |
+
provider.set_reference(
|
| 213 |
+
"voice-rules", "lowercase is fine. no em dashes.",
|
| 214 |
+
metadata={"source": "SIBYL-VOICE.md"},
|
| 215 |
+
)
|
| 216 |
+
ref = provider.get_reference("voice-rules")
|
| 217 |
+
assert ref is not None
|
| 218 |
+
assert "em dashes" in ref["body"]
|
| 219 |
+
assert ref["metadata"]["source"] == "SIBYL-VOICE.md"
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def test_fts_search(tmp_path: Path) -> None:
|
| 223 |
+
provider = SibylMemoryProvider(
|
| 224 |
+
db_path=str(tmp_path / "m.db"), autoload_credentials=False
|
| 225 |
+
)
|
| 226 |
+
provider.remember("project", "atlas", {"summary": "memory plugin shipping"})
|
| 227 |
+
provider.remember("project", "borealis", {"summary": "audit dashboard"})
|
| 228 |
+
provider.remember("person", "jane", {"role": "operator ops"})
|
| 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
|
| 236 |
+
assert "borealis" not in keys
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
# ----------------------------------------------------------------------
|
| 240 |
+
# Multi-tenant isolation
|
| 241 |
+
# ----------------------------------------------------------------------
|
| 242 |
+
def test_multi_tenant_isolation(tmp_path: Path) -> None:
|
| 243 |
+
db = tmp_path / "m.db"
|
| 244 |
+
alice = SibylMemoryProvider(
|
| 245 |
+
db_path=str(db), tenant_id="alice", autoload_credentials=False
|
| 246 |
+
)
|
| 247 |
+
bob = SibylMemoryProvider(
|
| 248 |
+
db_path=str(db), tenant_id="bob", autoload_credentials=False
|
| 249 |
+
)
|
| 250 |
+
|
| 251 |
+
alice.remember("project", "atlas", {"owner": "alice"})
|
| 252 |
+
bob.remember("project", "atlas", {"owner": "bob"})
|
| 253 |
+
|
| 254 |
+
a = alice.recall("project", "atlas")
|
| 255 |
+
b = bob.recall("project", "atlas")
|
| 256 |
+
assert a is not None and b is not None
|
| 257 |
+
assert a["body"]["owner"] == "alice"
|
| 258 |
+
assert b["body"]["owner"] == "bob"
|
| 259 |
+
|
| 260 |
+
# Neither tenant sees the other's entities
|
| 261 |
+
assert len(alice.list(category="project")) == 1
|
| 262 |
+
assert len(bob.list(category="project")) == 1
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
# ----------------------------------------------------------------------
|
| 266 |
+
# Diagnostics
|
| 267 |
+
# ----------------------------------------------------------------------
|
| 268 |
+
def test_health(tmp_path: Path) -> None:
|
| 269 |
+
provider = SibylMemoryProvider(
|
| 270 |
+
db_path=str(tmp_path / "m.db"), autoload_credentials=False
|
| 271 |
+
)
|
| 272 |
+
h = provider.health()
|
| 273 |
+
assert h["ok"] is True
|
| 274 |
+
# Schema is currently v3 (cross-tier FTS5 landed 2026-05-18)
|
| 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 |
+
|
| 282 |
+
|
| 283 |
+
def test_provider_exposes_client(tmp_path: Path) -> None:
|
| 284 |
+
provider = SibylMemoryProvider(
|
| 285 |
+
db_path=str(tmp_path / "m.db"), autoload_credentials=False
|
| 286 |
+
)
|
| 287 |
+
# The underlying MemoryClient is accessible for advanced use
|
| 288 |
+
assert provider.client is not None
|
| 289 |
+
assert hasattr(provider.client, "set_entity")
|
| 290 |
+
assert hasattr(provider.client, "write_event")
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
# ----------------------------------------------------------------------
|
| 294 |
+
# Credentials file plumbing
|
| 295 |
+
# ----------------------------------------------------------------------
|
| 296 |
+
def test_write_then_load_credentials(tmp_path: Path) -> None:
|
| 297 |
+
cred_path = tmp_path / "credentials.json"
|
| 298 |
+
creds_in = Credentials(
|
| 299 |
+
account_id="abc-def",
|
| 300 |
+
tenant_id="user@example.com",
|
| 301 |
+
tier="sync",
|
| 302 |
+
email="user@example.com",
|
| 303 |
+
wallet="0xabc",
|
| 304 |
+
issued_at="2026-05-21T14:32:18Z",
|
| 305 |
+
schema_version=1,
|
| 306 |
+
)
|
| 307 |
+
write_credentials(creds_in, cred_path)
|
| 308 |
+
|
| 309 |
+
# File is mode 0600
|
| 310 |
+
assert oct(cred_path.stat().st_mode)[-3:] == "600"
|
| 311 |
+
|
| 312 |
+
creds_out = load_credentials(cred_path)
|
| 313 |
+
assert creds_out.tenant_id == "user@example.com"
|
| 314 |
+
assert creds_out.tier == "sync"
|
| 315 |
+
assert creds_out.wallet == "0xabc"
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
def test_load_credentials_missing(tmp_path: Path) -> None:
|
| 319 |
+
with pytest.raises(CredentialsNotFoundError):
|
| 320 |
+
load_credentials(tmp_path / "nope.json")
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
def test_load_credentials_missing_ids_raises(tmp_path: Path) -> None:
|
| 324 |
+
bad = tmp_path / "bad.json"
|
| 325 |
+
bad.write_text(json.dumps({"tier": "free"}), encoding="utf-8")
|
| 326 |
+
with pytest.raises(ValueError):
|
| 327 |
+
load_credentials(bad)
|
sibyl-memory-mcp/CHANGELOG.md
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Changelog
|
| 2 |
+
|
| 3 |
+
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] β 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
|
| 11 |
+
raised `ImportError: cannot import name 'CapExceededError' from
|
| 12 |
+
'sibyl_memory_client.exceptions'`. Reported by KAPPA (independent
|
| 13 |
+
third-party install test, peer Tulip-referred) after the v0.3.3 family ship.
|
| 14 |
+
The 93/93 audit tests passed only because they ran in-tree; there was no
|
| 15 |
+
clean-venv install smoke test in CI. Gap closed by the companion
|
| 16 |
+
`tmp-test/clean-venv-install-smoke.sh` guardrail.
|
| 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
|
| 24 |
+
`>=0.4.0` to consume that fix and rolls the version forward so anyone
|
| 25 |
+
on `pip install sibyl-memory-mcp` picks up the working release.
|
| 26 |
+
|
| 27 |
+
### Changed
|
| 28 |
+
|
| 29 |
+
- `sibyl-memory-client` pin: `>=0.3.3` β `>=0.4.0`.
|
| 30 |
+
- `sibyl-memory-hermes` pin: `>=0.3.1` β `>=0.3.2`.
|
| 31 |
+
|
| 32 |
+
### Notes
|
| 33 |
+
|
| 34 |
+
- Server code (`server.py`) is unchanged from v0.1.1. The 8-tool surface
|
| 35 |
+
(memory_remember / memory_recall / memory_search / memory_list /
|
| 36 |
+
memory_forget / memory_set_state / memory_get_state / memory_record_event)
|
| 37 |
+
remains stable.
|
| 38 |
+
- v0.1.1 has been yanked on PyPI.
|
| 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
|
| 46 |
+
invocation raised TypeError). This release lands the MCP-side fixes.
|
| 47 |
+
Companion releases: `sibyl-memory-client` v0.3.3, `sibyl-memory-hermes` v0.3.1,
|
| 48 |
+
`sibyl-memory-cli` v0.1.2.
|
| 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.
|
| 76 |
+
- **memory_search now spans all four tiers** (entities + state +
|
| 77 |
+
reference + journal). Backed by the new `MemoryClient.search()` in
|
| 78 |
+
client v0.3.3. Each hit carries a `tier` tag. The MCP server marketing
|
| 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 |
+
|
| 90 |
+
### Dependencies
|
| 91 |
+
|
| 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
|
| 103 |
+
|
| 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:
|
| 118 |
+
`CAP_EXCEEDED` (with `upgrade_url`), `TIER_GATED`, `TIER_VERIFICATION_FAILED`,
|
| 119 |
+
`NOT_FOUND`, `VALIDATION_ERROR`. Agents can reason about the right next move.
|
| 120 |
+
- **Env overrides**: `SIBYL_MEMORY_DB`, `SIBYL_CREDENTIALS` for non-default
|
| 121 |
+
install locations + multi-account scenarios.
|
| 122 |
+
|
| 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.
|
| 130 |
+
- Tool names are prefixed `memory_` so they namespace cleanly when an agent
|
| 131 |
+
has multiple MCP servers loaded.
|
| 132 |
+
|
| 133 |
+
### Depends on
|
| 134 |
+
|
| 135 |
+
- `mcp>=1.0.0` (official Anthropic Python SDK)
|
| 136 |
+
- `sibyl-memory-client>=0.3.2` (cap-gate + signed credentials)
|
| 137 |
+
- `sibyl-memory-hermes>=0.2.2` (credentials loader)
|
| 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
|
| 148 |
+
|
| 149 |
+
MIT.
|
sibyl-memory-mcp/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# sibyl-memory-mcp
|
| 2 |
+
|
| 3 |
+
MCP server for [Sibyl Memory Plugin](https://sibyllabs.org/memory). Exposes the local SQLite + FTS5 memory engine to any MCP-compatible agent: **Claude Code, Codex CLI, Cursor, Continue**, anything that speaks Model Context Protocol.
|
| 4 |
+
|
| 5 |
+
## Install
|
| 6 |
+
|
| 7 |
+
```bash
|
| 8 |
+
pip install sibyl-memory-mcp
|
| 9 |
+
```
|
| 10 |
+
|
| 11 |
+
You also need an activated Sibyl Memory account. If you haven't already:
|
| 12 |
+
|
| 13 |
+
```bash
|
| 14 |
+
sibyl init
|
| 15 |
+
```
|
| 16 |
+
|
| 17 |
+
This creates `~/.sibyl-memory/credentials.json` (server-issued, HMAC-signed) and a local SQLite database at `~/.sibyl-memory/memory.db`. The MCP server reads both automatically.
|
| 18 |
+
|
| 19 |
+
## Add to Claude Code
|
| 20 |
+
|
| 21 |
+
Edit `~/.claude/settings.json` (global) or `.mcp.json` (project-local):
|
| 22 |
+
|
| 23 |
+
```json
|
| 24 |
+
{
|
| 25 |
+
"mcpServers": {
|
| 26 |
+
"sibyl-memory": {
|
| 27 |
+
"command": "sibyl-memory-mcp"
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
}
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
Restart Claude Code. The 8 memory tools (prefixed `memory_*`) become available immediately.
|
| 34 |
+
|
| 35 |
+
## Add to Codex CLI
|
| 36 |
+
|
| 37 |
+
Edit `~/.codex/config.toml`:
|
| 38 |
+
|
| 39 |
+
```toml
|
| 40 |
+
[[mcp_servers]]
|
| 41 |
+
name = "sibyl-memory"
|
| 42 |
+
command = "sibyl-memory-mcp"
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
Restart Codex.
|
| 46 |
+
|
| 47 |
+
## Tools exposed
|
| 48 |
+
|
| 49 |
+
| Tool | What it does |
|
| 50 |
+
|------|--------------|
|
| 51 |
+
| `memory_remember` | Store an entity by (category, name) |
|
| 52 |
+
| `memory_recall` | Read an entity by exact key |
|
| 53 |
+
| `memory_search` | FTS5 search across all entities |
|
| 54 |
+
| `memory_list` | List entities in a category |
|
| 55 |
+
| `memory_forget` | Archive an entity (recoverable) |
|
| 56 |
+
| `memory_set_state` | Write a HOT-tier state doc |
|
| 57 |
+
| `memory_get_state` | Read a HOT-tier state doc |
|
| 58 |
+
| `memory_record_event` | Append a COLD-tier journal event |
|
| 59 |
+
|
| 60 |
+
Full docs at [docs.sibyllabs.org/memory/integrations](https://docs.sibyllabs.org/memory/integrations).
|
| 61 |
+
|
| 62 |
+
## Environment overrides
|
| 63 |
+
|
| 64 |
+
| Var | Default | What it overrides |
|
| 65 |
+
|-----|---------|--------------------|
|
| 66 |
+
| `SIBYL_MEMORY_DB` | `~/.sibyl-memory/memory.db` | Local SQLite path |
|
| 67 |
+
| `SIBYL_CREDENTIALS` | `~/.sibyl-memory/credentials.json` | Credentials file path |
|
| 68 |
+
|
| 69 |
+
## Tier behavior
|
| 70 |
+
|
| 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
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["setuptools>=64", "wheel"]
|
| 3 |
+
build-backend = "setuptools.build_meta"
|
| 4 |
+
|
| 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" }
|
| 12 |
+
authors = [{ name = "SIBYL, Sibyl Labs LLC", email = "sibyl@sibyllabs.org" }]
|
| 13 |
+
keywords = ["mcp", "model-context-protocol", "memory", "agent", "claude", "claude-code", "codex", "sibyl"]
|
| 14 |
+
classifiers = [
|
| 15 |
+
"Programming Language :: Python :: 3",
|
| 16 |
+
"Programming Language :: Python :: 3 :: Only",
|
| 17 |
+
"Programming Language :: Python :: 3.10",
|
| 18 |
+
"Programming Language :: Python :: 3.11",
|
| 19 |
+
"Programming Language :: Python :: 3.12",
|
| 20 |
+
"License :: OSI Approved :: MIT License",
|
| 21 |
+
"Operating System :: OS Independent",
|
| 22 |
+
"Topic :: Software Development :: Libraries",
|
| 23 |
+
]
|
| 24 |
+
dependencies = [
|
| 25 |
+
"mcp>=1.0.0",
|
| 26 |
+
"sibyl-memory-client>=0.4.0",
|
| 27 |
+
"sibyl-memory-hermes>=0.3.2",
|
| 28 |
+
]
|
| 29 |
+
|
| 30 |
+
[project.scripts]
|
| 31 |
+
sibyl-memory-mcp = "sibyl_memory_mcp.__main__:main"
|
| 32 |
+
|
| 33 |
+
[project.urls]
|
| 34 |
+
Homepage = "https://sibyllabs.org/memory"
|
| 35 |
+
Documentation = "https://docs.sibyllabs.org/memory/integrations"
|
| 36 |
+
Repository = "https://github.com/sibyllabs/sibyl-memory-plugin"
|
| 37 |
+
|
| 38 |
+
[tool.setuptools.packages.find]
|
| 39 |
+
where = ["src"]
|
sibyl-memory-mcp/src/sibyl_memory_mcp/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 5 |
+
Continue, etc.
|
| 6 |
+
|
| 7 |
+
Usage (Claude Code):
|
| 8 |
+
Add to ~/.claude/settings.json or project .mcp.json:
|
| 9 |
+
{
|
| 10 |
+
"mcpServers": {
|
| 11 |
+
"sibyl-memory": { "command": "sibyl-memory-mcp" }
|
| 12 |
+
}
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
Usage (Codex CLI):
|
| 16 |
+
Add to ~/.codex/config.toml:
|
| 17 |
+
[[mcp_servers]]
|
| 18 |
+
name = "sibyl-memory"
|
| 19 |
+
command = "sibyl-memory-mcp"
|
| 20 |
+
|
| 21 |
+
Both expect `sibyl init` to have been run first so credentials.json and
|
| 22 |
+
memory.db exist at ~/.sibyl-memory/.
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
from .server import build_server, run_stdio
|
| 26 |
+
|
| 27 |
+
__version__ = "0.1.0"
|
| 28 |
+
__all__ = ["build_server", "run_stdio", "__version__"]
|
sibyl-memory-mcp/src/sibyl_memory_mcp/__main__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Entry point: `sibyl-memory-mcp` console script + `python -m sibyl_memory_mcp`."""
|
| 2 |
+
from .server import run_stdio
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def main() -> None:
|
| 6 |
+
run_stdio()
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
if __name__ == "__main__":
|
| 10 |
+
main()
|
sibyl-memory-mcp/src/sibyl_memory_mcp/server.py
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MCP server exposing Sibyl Memory Plugin tools.
|
| 2 |
+
|
| 3 |
+
8 tools:
|
| 4 |
+
- memory_remember store an entity
|
| 5 |
+
- memory_recall read an entity by category+name
|
| 6 |
+
- memory_search FTS5 search across ALL tiers (entities + state + reference + journal)
|
| 7 |
+
- memory_list list entities, optionally filtered by category
|
| 8 |
+
- memory_forget archive an entity (preserved, removed from active set)
|
| 9 |
+
- memory_set_state write a HOT-tier state document
|
| 10 |
+
- memory_get_state read a HOT-tier state document
|
| 11 |
+
- memory_record_event append a COLD-tier journal event
|
| 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):
|
| 19 |
+
- MemoryClient cached at module scope, NOT reopened per call (audit P-H1).
|
| 20 |
+
Invalidation: file-mtime watch of credentials.json so `sibyl upgrade`
|
| 21 |
+
is picked up without a server restart.
|
| 22 |
+
- memory_record_event signature fixed against actual write_event
|
| 23 |
+
contract (audit C1). Previous signature called a non-existent positional
|
| 24 |
+
form and every invocation raised TypeError.
|
| 25 |
+
- memory_get_state unpacks the nested {body, updated_at} dict so the
|
| 26 |
+
response shape has body=user_payload, not body={user_payload, ...}
|
| 27 |
+
(audit H2 body double-meaning fix).
|
| 28 |
+
- memory_list category parameter is now Optional (audit N3).
|
| 29 |
+
- credentials.json reads honor the lstat / symlink check (audit SEC-4/11).
|
| 30 |
+
"""
|
| 31 |
+
from __future__ import annotations
|
| 32 |
+
|
| 33 |
+
import json
|
| 34 |
+
import os
|
| 35 |
+
import threading
|
| 36 |
+
from pathlib import Path
|
| 37 |
+
from typing import Any
|
| 38 |
+
|
| 39 |
+
from mcp.server.fastmcp import FastMCP
|
| 40 |
+
from sibyl_memory_client import MemoryClient
|
| 41 |
+
from sibyl_memory_client.exceptions import (
|
| 42 |
+
CapExceededError,
|
| 43 |
+
NotFoundError,
|
| 44 |
+
TierGateError,
|
| 45 |
+
TierVerificationError,
|
| 46 |
+
ValidationError,
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
# Default install location matches the rest of the plugin ecosystem.
|
| 50 |
+
DEFAULT_DB_PATH = Path(os.environ.get(
|
| 51 |
+
"SIBYL_MEMORY_DB",
|
| 52 |
+
Path.home() / ".sibyl-memory" / "memory.db",
|
| 53 |
+
))
|
| 54 |
+
DEFAULT_CRED_PATH = Path(os.environ.get(
|
| 55 |
+
"SIBYL_CREDENTIALS",
|
| 56 |
+
Path.home() / ".sibyl-memory" / "credentials.json",
|
| 57 |
+
))
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# ----------------------------------------------------------------------
|
| 61 |
+
# Credential loading (audit SEC-4, SEC-11)
|
| 62 |
+
# ----------------------------------------------------------------------
|
| 63 |
+
|
| 64 |
+
def _load_credentials() -> dict[str, Any]:
|
| 65 |
+
"""Read credentials.json if present. Missing file = pre-activation, free tier.
|
| 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():
|
| 73 |
+
return {}
|
| 74 |
+
if DEFAULT_CRED_PATH.is_symlink():
|
| 75 |
+
return {}
|
| 76 |
+
try:
|
| 77 |
+
return json.loads(DEFAULT_CRED_PATH.read_text())
|
| 78 |
+
except (OSError, json.JSONDecodeError):
|
| 79 |
+
return {}
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
# ----------------------------------------------------------------------
|
| 83 |
+
# Cached MemoryClient (audit P-H1)
|
| 84 |
+
# ----------------------------------------------------------------------
|
| 85 |
+
|
| 86 |
+
_client_lock = threading.Lock()
|
| 87 |
+
_client_cache: dict[str, Any] = {
|
| 88 |
+
"client": None, # MemoryClient instance, lazily built
|
| 89 |
+
"creds_mtime": None, # mtime of credentials.json at last open
|
| 90 |
+
"creds_path_exists": False,
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _credentials_mtime() -> float | None:
|
| 95 |
+
"""Return credentials.json mtime if present, else None.
|
| 96 |
+
|
| 97 |
+
Used to detect `sibyl upgrade` having written new credentials so the
|
| 98 |
+
cached MemoryClient can be rebuilt with the new tier."""
|
| 99 |
+
try:
|
| 100 |
+
if DEFAULT_CRED_PATH.exists() and not DEFAULT_CRED_PATH.is_symlink():
|
| 101 |
+
return DEFAULT_CRED_PATH.stat().st_mtime
|
| 102 |
+
except OSError:
|
| 103 |
+
pass
|
| 104 |
+
return None
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
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 |
+
"""
|
| 115 |
+
with _client_lock:
|
| 116 |
+
cur_mtime = _credentials_mtime()
|
| 117 |
+
cur_exists = DEFAULT_CRED_PATH.exists()
|
| 118 |
+
client = _client_cache["client"]
|
| 119 |
+
cached_mtime = _client_cache["creds_mtime"]
|
| 120 |
+
cached_exists = _client_cache["creds_path_exists"]
|
| 121 |
+
# Rebuild if no cached client, or credentials.json mtime changed,
|
| 122 |
+
# or credentials.json appeared / disappeared (post-init / post-logout).
|
| 123 |
+
if client is None or cur_mtime != cached_mtime or cur_exists != cached_exists:
|
| 124 |
+
client = _build_client()
|
| 125 |
+
_client_cache["client"] = client
|
| 126 |
+
_client_cache["creds_mtime"] = cur_mtime
|
| 127 |
+
_client_cache["creds_path_exists"] = cur_exists
|
| 128 |
+
return client
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def _build_client() -> MemoryClient:
|
| 132 |
+
"""Construct a fresh MemoryClient. Called only on cache miss."""
|
| 133 |
+
creds = _load_credentials()
|
| 134 |
+
DEFAULT_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
| 135 |
+
return MemoryClient.local(
|
| 136 |
+
str(DEFAULT_DB_PATH),
|
| 137 |
+
tenant_id=creds.get("tenant_id"),
|
| 138 |
+
account_id=creds.get("account_id"),
|
| 139 |
+
session_token=creds.get("session_token"),
|
| 140 |
+
tier=creds.get("tier", "free"),
|
| 141 |
+
credentials_claim={
|
| 142 |
+
"account_id": creds.get("account_id"),
|
| 143 |
+
"tenant_id": creds.get("tenant_id"),
|
| 144 |
+
"tier": creds.get("tier"),
|
| 145 |
+
"email": creds.get("email"),
|
| 146 |
+
"wallet": creds.get("wallet"),
|
| 147 |
+
"issued_at": creds.get("issued_at"),
|
| 148 |
+
"schema_version": creds.get("schema_version", 1),
|
| 149 |
+
} if creds.get("signature") else None,
|
| 150 |
+
credentials_signature=creds.get("signature"),
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
# ----------------------------------------------------------------------
|
| 155 |
+
# Error mapping
|
| 156 |
+
# ----------------------------------------------------------------------
|
| 157 |
+
|
| 158 |
+
def _err(e: Exception) -> dict[str, Any]:
|
| 159 |
+
"""Map SDK exception β structured error payload the agent can reason about."""
|
| 160 |
+
cls = type(e).__name__
|
| 161 |
+
payload = {"error": cls, "message": str(e)}
|
| 162 |
+
if isinstance(e, CapExceededError):
|
| 163 |
+
payload["code"] = "CAP_EXCEEDED"
|
| 164 |
+
payload["recovery"] = "Run `sibyl upgrade` to lift the 2 MB free-tier cap."
|
| 165 |
+
payload["upgrade_url"] = getattr(e, "upgrade_url", "https://sibyllabs.org/plugin/upgrade")
|
| 166 |
+
elif isinstance(e, TierGateError):
|
| 167 |
+
payload["code"] = "TIER_GATED"
|
| 168 |
+
payload["recovery"] = "This feature requires a paid tier. Run `sibyl upgrade`."
|
| 169 |
+
elif isinstance(e, TierVerificationError):
|
| 170 |
+
payload["code"] = "TIER_VERIFICATION_FAILED"
|
| 171 |
+
payload["recovery"] = "The server couldn't verify your tier. Check connectivity and try again."
|
| 172 |
+
elif isinstance(e, NotFoundError):
|
| 173 |
+
payload["code"] = "NOT_FOUND"
|
| 174 |
+
elif isinstance(e, ValidationError):
|
| 175 |
+
payload["code"] = "VALIDATION_ERROR"
|
| 176 |
+
return payload
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
# ----------------------------------------------------------------------
|
| 180 |
+
# Server build
|
| 181 |
+
# ----------------------------------------------------------------------
|
| 182 |
+
|
| 183 |
+
def build_server() -> FastMCP:
|
| 184 |
+
"""Build and return the MCP server. Tool names are prefixed with `memory_`."""
|
| 185 |
+
mcp = FastMCP("sibyl-memory")
|
| 186 |
+
|
| 187 |
+
@mcp.tool()
|
| 188 |
+
def memory_remember(category: str, name: str, body: dict[str, Any]) -> dict[str, Any]:
|
| 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:
|
| 196 |
+
category: Logical grouping (e.g. "people", "projects", "facts").
|
| 197 |
+
name: Unique-within-category identifier (e.g. "alice", "acme-deal").
|
| 198 |
+
body: Arbitrary JSON-serializable object. Becomes the entity body.
|
| 199 |
+
"""
|
| 200 |
+
try:
|
| 201 |
+
client = _open_client()
|
| 202 |
+
client.set_entity(category, name, body)
|
| 203 |
+
return {"ok": True, "category": category, "name": name}
|
| 204 |
+
except Exception as e:
|
| 205 |
+
return _err(e)
|
| 206 |
+
|
| 207 |
+
@mcp.tool()
|
| 208 |
+
def memory_recall(category: str, name: str) -> dict[str, Any]:
|
| 209 |
+
"""Read an entity by exact (category, name) lookup.
|
| 210 |
+
|
| 211 |
+
Returns: {ok: True, entity: {id, tenant_id, category, name, status,
|
| 212 |
+
body, created_at, updated_at}} where `body` is the user-supplied
|
| 213 |
+
payload. Or a NOT_FOUND error.
|
| 214 |
+
"""
|
| 215 |
+
try:
|
| 216 |
+
client = _open_client()
|
| 217 |
+
return {"ok": True, "entity": client.get_entity(category, name)}
|
| 218 |
+
except Exception as e:
|
| 219 |
+
return _err(e)
|
| 220 |
+
|
| 221 |
+
@mcp.tool()
|
| 222 |
+
def memory_search(query: str, limit: int = 10) -> dict[str, Any]:
|
| 223 |
+
"""Full-text search across ALL Sibyl tiers (entities + state +
|
| 224 |
+
reference + journal).
|
| 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 |
+
|
| 235 |
+
Args:
|
| 236 |
+
query: Search terms. User input is sanitized before MATCH.
|
| 237 |
+
limit: Maximum results to return (default 10, max 50).
|
| 238 |
+
"""
|
| 239 |
+
try:
|
| 240 |
+
client = _open_client()
|
| 241 |
+
results = client.search(query, limit=min(max(limit, 1), 50))
|
| 242 |
+
return {"ok": True, "query": query, "count": len(results), "results": results}
|
| 243 |
+
except Exception as e:
|
| 244 |
+
return _err(e)
|
| 245 |
+
|
| 246 |
+
@mcp.tool()
|
| 247 |
+
def memory_list(
|
| 248 |
+
category: str | None = None,
|
| 249 |
+
limit: int = 50,
|
| 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 |
+
|
| 257 |
+
Args:
|
| 258 |
+
category: Optional category filter. Pass None or omit to list all.
|
| 259 |
+
limit: Max entities to return (default 50, max 200).
|
| 260 |
+
"""
|
| 261 |
+
try:
|
| 262 |
+
client = _open_client()
|
| 263 |
+
results = client.list_entities(category=category, limit=min(max(limit, 1), 200))
|
| 264 |
+
return {"ok": True, "category": category, "count": len(results), "results": results}
|
| 265 |
+
except Exception as e:
|
| 266 |
+
return _err(e)
|
| 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
|
| 274 |
+
record why; useful in audit reviews.
|
| 275 |
+
"""
|
| 276 |
+
try:
|
| 277 |
+
client = _open_client()
|
| 278 |
+
client.archive_entity(category, name, reason=reason)
|
| 279 |
+
return {"ok": True, "archived": {"category": category, "name": name}}
|
| 280 |
+
except Exception as e:
|
| 281 |
+
return _err(e)
|
| 282 |
+
|
| 283 |
+
@mcp.tool()
|
| 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 |
+
"""
|
| 291 |
+
try:
|
| 292 |
+
client = _open_client()
|
| 293 |
+
client.set_state(key, body)
|
| 294 |
+
return {"ok": True, "key": key}
|
| 295 |
+
except Exception as e:
|
| 296 |
+
return _err(e)
|
| 297 |
+
|
| 298 |
+
@mcp.tool()
|
| 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
|
| 306 |
+
nesting levels.
|
| 307 |
+
"""
|
| 308 |
+
try:
|
| 309 |
+
client = _open_client()
|
| 310 |
+
doc = client.get_state(key)
|
| 311 |
+
if doc is None:
|
| 312 |
+
return {"ok": False, "code": "NOT_FOUND", "key": key}
|
| 313 |
+
# Unpack the SDK's {body, updated_at} wrapper so the MCP response
|
| 314 |
+
# uses `body` for the user payload only.
|
| 315 |
+
return {
|
| 316 |
+
"ok": True,
|
| 317 |
+
"key": key,
|
| 318 |
+
"body": doc.get("body"),
|
| 319 |
+
"updated_at": doc.get("updated_at"),
|
| 320 |
+
}
|
| 321 |
+
except Exception as e:
|
| 322 |
+
return _err(e)
|
| 323 |
+
|
| 324 |
+
@mcp.tool()
|
| 325 |
+
def memory_record_event(
|
| 326 |
+
kind: str,
|
| 327 |
+
body: dict[str, Any],
|
| 328 |
+
category: str | None = None,
|
| 329 |
+
name: str | None = None,
|
| 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 |
+
|
| 337 |
+
v0.1.1 (audit C1): wired against the actual SDK signature
|
| 338 |
+
``write_event(*, evaluated, acted, forward, extra, ts)``. Previously
|
| 339 |
+
called a positional form that doesn't exist and raised TypeError on
|
| 340 |
+
every invocation. The high-level (kind, body, category, name)
|
| 341 |
+
contract is preserved by translating into the SDK shape:
|
| 342 |
+
- kind / body β `acted = {kind, body}`
|
| 343 |
+
- category / name β `extra = {category, name}` (when supplied)
|
| 344 |
+
|
| 345 |
+
Args:
|
| 346 |
+
kind: Event class (e.g. "decision", "observation", "action").
|
| 347 |
+
body: JSON-serializable event payload.
|
| 348 |
+
category: Optional entity category this event is about.
|
| 349 |
+
name: Optional entity name this event is about.
|
| 350 |
+
"""
|
| 351 |
+
try:
|
| 352 |
+
client = _open_client()
|
| 353 |
+
acted = {"kind": kind, "body": body}
|
| 354 |
+
extra = None
|
| 355 |
+
if category is not None or name is not None:
|
| 356 |
+
extra = {}
|
| 357 |
+
if category is not None:
|
| 358 |
+
extra["category"] = category
|
| 359 |
+
if name is not None:
|
| 360 |
+
extra["name"] = name
|
| 361 |
+
event_id = client.write_event(acted=acted, extra=extra)
|
| 362 |
+
return {"ok": True, "event_id": event_id, "kind": kind}
|
| 363 |
+
except Exception as e:
|
| 364 |
+
return _err(e)
|
| 365 |
+
|
| 366 |
+
return mcp
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
def run_stdio() -> None:
|
| 370 |
+
"""Run the server on stdio transport (what Claude Code / Codex / Cursor expect)."""
|
| 371 |
+
mcp = build_server()
|
| 372 |
+
mcp.run()
|