sibyllabs commited on
Commit
2cf7040
Β·
0 Parent(s):

Initial release: Sibyl Memory Plugin family v0.1.0

Browse files

Five-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.

Files changed (50) hide show
  1. .gitignore +44 -0
  2. LICENSE +21 -0
  3. README.md +90 -0
  4. sibyl-memory-cli/.gitignore +1 -0
  5. sibyl-memory-cli/CHANGELOG.md +330 -0
  6. sibyl-memory-cli/README.md +103 -0
  7. sibyl-memory-cli/pyproject.toml +45 -0
  8. sibyl-memory-cli/src/sibyl_memory_cli/__init__.py +24 -0
  9. sibyl-memory-cli/src/sibyl_memory_cli/_aesthetic.py +279 -0
  10. sibyl-memory-cli/src/sibyl_memory_cli/_banner.py +123 -0
  11. sibyl-memory-cli/src/sibyl_memory_cli/cli.py +880 -0
  12. sibyl-memory-cli/src/sibyl_memory_cli/setup.py +493 -0
  13. sibyl-memory-cli/tests/test_setup.py +381 -0
  14. sibyl-memory-client/CHANGELOG.md +389 -0
  15. sibyl-memory-client/README.md +69 -0
  16. sibyl-memory-client/pyproject.toml +46 -0
  17. sibyl-memory-client/src/sibyl_memory_client/__init__.py +109 -0
  18. sibyl-memory-client/src/sibyl_memory_client/_capcheck.py +465 -0
  19. sibyl-memory-client/src/sibyl_memory_client/client.py +896 -0
  20. sibyl-memory-client/src/sibyl_memory_client/exceptions.py +141 -0
  21. sibyl-memory-client/src/sibyl_memory_client/learning.py +925 -0
  22. sibyl-memory-client/src/sibyl_memory_client/lint.py +453 -0
  23. sibyl-memory-client/src/sibyl_memory_client/schema.sql +350 -0
  24. sibyl-memory-client/src/sibyl_memory_client/storage.py +277 -0
  25. sibyl-memory-client/tests/test_capcheck.py +339 -0
  26. sibyl-memory-client/tests/test_kappa_fixes.py +283 -0
  27. sibyl-memory-client/tests/test_learning.py +269 -0
  28. sibyl-memory-client/tests/test_lint.py +191 -0
  29. sibyl-memory-client/tests/test_smoke.py +202 -0
  30. sibyl-memory-hermes/CHANGELOG.md +411 -0
  31. sibyl-memory-hermes/LICENSE +21 -0
  32. sibyl-memory-hermes/README.md +127 -0
  33. sibyl-memory-hermes/pyproject.toml +64 -0
  34. sibyl-memory-hermes/src/sibyl_memory_hermes/__init__.py +93 -0
  35. sibyl-memory-hermes/src/sibyl_memory_hermes/_aesthetic.py +279 -0
  36. sibyl-memory-hermes/src/sibyl_memory_hermes/_banner.py +123 -0
  37. sibyl-memory-hermes/src/sibyl_memory_hermes/_hermes_plugin/__init__.py +12 -0
  38. sibyl-memory-hermes/src/sibyl_memory_hermes/_hermes_plugin/adapter.py +531 -0
  39. sibyl-memory-hermes/src/sibyl_memory_hermes/_hermes_plugin/plugin.yaml +6 -0
  40. sibyl-memory-hermes/src/sibyl_memory_hermes/credentials.py +168 -0
  41. sibyl-memory-hermes/src/sibyl_memory_hermes/install_plugin.py +255 -0
  42. sibyl-memory-hermes/src/sibyl_memory_hermes/provider.py +433 -0
  43. sibyl-memory-hermes/tests/test_adapter.py +250 -0
  44. sibyl-memory-hermes/tests/test_smoke.py +327 -0
  45. sibyl-memory-mcp/CHANGELOG.md +149 -0
  46. sibyl-memory-mcp/README.md +78 -0
  47. sibyl-memory-mcp/pyproject.toml +39 -0
  48. sibyl-memory-mcp/src/sibyl_memory_mcp/__init__.py +28 -0
  49. sibyl-memory-mcp/src/sibyl_memory_mcp/__main__.py +10 -0
  50. 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) | [![PyPI](https://img.shields.io/pypi/v/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) | [![PyPI](https://img.shields.io/pypi/v/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) | [![PyPI](https://img.shields.io/pypi/v/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) | [![PyPI](https://img.shields.io/pypi/v/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()