sibyllabs commited on
Commit
b2101ae
Β·
1 Parent(s): 943b798

voice: strip em dashes repo-wide per rule 9

Browse files

423 em dashes across 38 files: 4 CHANGELOGs, 4 READMEs, source
files, banners, aesthetic palette comments, test docstrings.
Context-aware replacement:

"β€”" placeholders -> "-"
] β€” YYYY-MM-DD -> ] - YYYY-MM-DD (Keep-A-Changelog convention)
β€” Foo (next caps) -> . Foo (sentence break)
β€” foo (next lc) -> : foo (introduces detail)

LLM-tells scan clean (zero hits across all prose surfaces).
Client suite 75/75 + hermes suite 40/40 green post-scrub.
All Python files compile.

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