superlocalmemory 4.1.0 → 4.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/.claude-plugin/marketplace.json +11 -1
  2. package/CHANGELOG.md +74 -0
  3. package/README.md +37 -72
  4. package/package.json +4 -3
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/CLAUDE.md +3 -3
  7. package/plugin/agents/slm-governance-advisor.md +1 -1
  8. package/plugin/agents/slm-loop-runner.md +1 -1
  9. package/plugin/agents/slm-memory-advisor.md +1 -1
  10. package/plugin/agents/slm-optimize-advisor.md +1 -1
  11. package/plugin/requirements.txt +1 -1
  12. package/plugin/skills/slm-cache/SKILL.md +1 -1
  13. package/plugin/skills/slm-compress/SKILL.md +1 -1
  14. package/plugin/skills/slm-governance/SKILL.md +1 -1
  15. package/plugin/skills/slm-graph/SKILL.md +1 -1
  16. package/plugin/skills/slm-loop/SKILL.md +1 -1
  17. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  18. package/plugin/skills/slm-profile/SKILL.md +1 -1
  19. package/plugin/skills/slm-recall/SKILL.md +1 -1
  20. package/plugin/skills/slm-remember/SKILL.md +1 -1
  21. package/plugin/skills/slm-scope/SKILL.md +1 -1
  22. package/plugin/skills/slm-session/SKILL.md +1 -1
  23. package/plugin/skills/slm-status/SKILL.md +1 -1
  24. package/plugin-src/rules/AGENTS.md +1 -1
  25. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-governance/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-loop/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
  31. package/plugin-src/skills/slm-profile/SKILL.md +1 -1
  32. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  33. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  34. package/plugin-src/skills/slm-scope/SKILL.md +1 -1
  35. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  36. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  37. package/pyproject.toml +1 -1
  38. package/src/superlocalmemory/__init__.py +1 -1
  39. package/src/superlocalmemory/cli/commands.py +94 -0
  40. package/src/superlocalmemory/server/recall_health.py +87 -10
  41. package/src/superlocalmemory/server/unified_daemon.py +55 -2
  42. package/src/superlocalmemory/storage/_migration_internals.py +23 -2
  43. package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +60 -36
  44. package/src/superlocalmemory/storage/schema.py +23 -0
@@ -10,8 +10,18 @@
10
10
  "name": "Qualixar"
11
11
  },
12
12
  "description": "Local-first agent memory + reversible context compression and KV cache, as an MCP server. 34-tool code profile with graph intelligence.",
13
+ "homepage": "https://github.com/qualixar/superlocalmemory",
14
+ "keywords": [
15
+ "memory",
16
+ "mcp",
17
+ "agents",
18
+ "local-first",
19
+ "context-compression"
20
+ ],
21
+ "license": "AGPL-3.0-or-later",
13
22
  "name": "superlocalmemory",
14
- "source": "./plugin"
23
+ "source": "./plugin",
24
+ "version": "4.1.2"
15
25
  }
16
26
  ]
17
27
  }
package/CHANGELOG.md CHANGED
@@ -5,6 +5,80 @@ All notable changes to SuperLocalMemory will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [4.1.2] — The monitor that was watching nothing
9
+
10
+ ### Fixed
11
+ - **A killed embedding worker was never brought back, and nothing said so.** The
12
+ worker is stopped on an idle timeout roughly every hour, by design, and a
13
+ background monitor exists to revive it. That monitor decided whether anything
14
+ was wrong by looking at a probe search: results with no meaning-score meant a
15
+ broken embedder. But it explicitly did not count *no results at all* — and no
16
+ results is what a dead embedder produces, because the meaning channel returns
17
+ nothing and the probe phrase appears in nobody's memories. So the one symptom
18
+ that should have started a repair was read as proof that none was needed, and
19
+ because a clean verdict is silent, it left no trace. Observed on a machine that
20
+ sat for over an hour, across two restarts, with searching by meaning switched
21
+ off, no explanation in the log, and a manual restart the only cure. The monitor
22
+ now asks the embedder directly, which is a question a search cannot answer.
23
+ - **There was no way to tell a quiet monitor from a dead one.** A check that
24
+ finds nothing wrong writes nothing, which is right — a monitor that narrates
25
+ every success is one whose warnings get skimmed past. But it left "is it even
26
+ running?" unanswerable except by waiting for something to break. The time of
27
+ the last check is now reported, so it can be looked at instead of inferred.
28
+ - **A failing repair would not say what it was failing.** When a completed
29
+ upgrade step stops holding, the report named the step and not the condition —
30
+ and that step checks five separate things. Anyone who hit it had to come back
31
+ and ask which. It now says which.
32
+ - **One drifted row could make the whole store unreachable.** Any failed upgrade
33
+ step made every request return "service unavailable". That is right when a
34
+ table is missing. It is wrong for the checks that guard *data* rather than
35
+ structure — ordinary use can undo those, a single background pass being
36
+ enough — and it left people restarting a daemon to fix something a restart
37
+ could not fix. Structural failures still refuse; a data check that drifted now
38
+ reports itself and keeps serving while it is repaired. Anything that does not
39
+ say which kind it is still refuses, so nothing became more permissive by
40
+ accident.
41
+
42
+ ### Changed
43
+ - **The plugin now installs and lists everywhere it should.** It is the main way
44
+ to get SLM, and it was only half-delivered:
45
+ - **Codex could not see it at all.** Codex reads `.codex-plugin/plugin.json` to
46
+ register a plugin and that file did not exist, so twelve skills, the hooks,
47
+ the launcher and the server config were all installed and none of it appeared
48
+ under Plugins. There was nothing to enable.
49
+ - **Codex was also missing two thirds of the product.** It shipped skills and
50
+ neither the four sub-agents nor the slash command. Both come from the same
51
+ single source as the Claude Code copies now, so they cannot drift.
52
+ - **Antigravity had no plugin at all.** It has one now, with the same skills,
53
+ agents, commands and hooks as every other surface.
54
+ - **VS Code was missing the slash command.**
55
+ - **Every release looked like no release.** The marketplace entry carried no
56
+ version, so a client had nothing to compare and an installed plugin never
57
+ appeared out of date. This reverses a rule of our own making; the version is
58
+ now stated, and one script owns every place that states it.
59
+ - **`slm doctor` now says whether your skills are as new as your install.** The
60
+ skills, agents and commands are delivered by your editor, not by `pip`, so
61
+ upgrading the package leaves them untouched — and nothing had ever mentioned
62
+ that. This release changed 76 files across them. Doctor now reports the plugin
63
+ version beside the package version and names the command that updates it.
64
+
65
+ ## [4.1.1] — A store older than its own indexes
66
+
67
+ ### Fixed
68
+ - **A store from an early version could not be opened at all, and there was no
69
+ way out of it.** Starting up creates the indexes, one of which is on a column
70
+ that arrives with a migration scheduled to run *after* the engine is up. On a
71
+ store old enough to predate that column, the index could not be created, so
72
+ startup failed — and the migration that would have added the column could not
73
+ run, because it runs after a startup that never finished. `slm db migrate` did
74
+ not help either: it reports nothing failed and skips that class of migration by
75
+ design. Any store in that state was stuck on the version it was already on.
76
+ The column is now added before the index that needs it, so those stores open
77
+ and finish upgrading on their own. Measured on a real 637 MB store: it went
78
+ from refusing to start to a complete upgrade in 19 seconds, with all 7,707
79
+ memories, 2,590 records and 848,945 connections unchanged and the integrity
80
+ check clean.
81
+
8
82
  ## [4.1.0] — Every door asks the same question
9
83
 
10
84
  ### Fixed
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  </picture>
6
6
  </p>
7
7
 
8
- <h1 align="center">SuperLocalMemory V4.1.0</h1>
8
+ <h1 align="center">SuperLocalMemory V4.1.2</h1>
9
9
 
10
10
  <h2 align="center">Rent the LLM. Own the memory.</h2>
11
11
 
@@ -27,12 +27,12 @@ guarantee here is stated as a falsifiable invariant, tested under an adversarial
27
27
  negative control, and shipped with the harness that regenerates the evidence:
28
28
  <code>python benchmark/run_all.py --trials 200 --output-dir results/</code>. What each experiment
29
29
  does <em>not</em> exercise is stated too.</p>
30
- <p align="center"><code>v4.1.0</code> — one control plane: <strong>SLM-Mesh</strong> peer coordination · multi-scope memory (personal / shared / global) · profiles · Cache · Compress · 7-layer retrieval · code graph · Entity Explorer · skill evolution · Modes A/B/C · GDPR retention &amp; audit chain · bounded loops — across CLI, MCP, dashboard, the <strong>Claude plugin</strong>, the <strong>Codex add-on</strong>, and documented IDE integrations.<br/>
30
+ <p align="center"><code>v4.1.2</code> — one control plane: <strong>SLM-Mesh</strong> peer coordination · multi-scope memory (personal / shared / global) · profiles · Cache · Compress · 7-layer retrieval · code graph · Entity Explorer · skill evolution · Modes A/B/C · GDPR retention &amp; audit chain · bounded loops — across CLI, MCP, dashboard, the <strong>Claude plugin</strong>, the <strong>Codex add-on</strong>, and documented IDE integrations.<br/>
31
31
  Proxy: <code>slm wrap claude</code> &nbsp;·&nbsp; MCP: add <code>slm_compress</code> to your config &nbsp;·&nbsp; Skill: zero-config</p>
32
32
  <p align="center"><strong>Four public arXiv preprints</strong> · V4: <a href="https://arxiv.org/abs/2608.08253">arXiv:2608.08253</a> · companion archive: <a href="https://zenodo.org/records/21853302">Zenodo 21853302</a> (<a href="https://doi.org/10.5281/zenodo.21853302">DOI 10.5281/zenodo.21853302</a>) · prior preprints: <a href="https://arxiv.org/abs/2603.02240">2603.02240</a> · <a href="https://arxiv.org/abs/2603.14588">2603.14588</a> · <a href="https://arxiv.org/abs/2604.04514">2604.04514</a>.</p>
33
33
 
34
34
  <p align="center">
35
- <a href="CHANGELOG.md"><img src="https://img.shields.io/badge/v4.1.0-Current_Release-2ea44f?style=for-the-badge&logo=checkmarx&logoColor=white" alt="v4.1.0 — Current Release"/></a>
35
+ <a href="CHANGELOG.md"><img src="https://img.shields.io/badge/v4.1.2-Current_Release-2ea44f?style=for-the-badge&logo=checkmarx&logoColor=white" alt="v4.1.2 — Current Release"/></a>
36
36
  <a href="https://arxiv.org/abs/2608.08253"><img src="https://img.shields.io/badge/arXiv-2608.08253-b31b1b?style=for-the-badge&logo=arxiv&logoColor=white" alt="SuperLocalMemory 4.0 paper on arXiv:2608.08253"/></a>
37
37
  <a href="https://zenodo.org/records/21853302"><img src="https://img.shields.io/badge/Zenodo-10.5281%2Fzenodo.21853302-1682D4?style=for-the-badge&logo=zenodo&logoColor=white" alt="V4 paper on Zenodo: 10.5281/zenodo.21853302"/></a>
38
38
  <a href="https://arxiv.org/abs/2603.14588"><img src="https://img.shields.io/badge/arXiv-2603.14588-b31b1b?style=for-the-badge&logo=arxiv&logoColor=white" alt="arXiv Paper"/></a>
@@ -62,11 +62,7 @@ SuperLocalMemory V4 combines conventional dense and lexical retrieval with graph
62
62
 
63
63
  **Memory with a sense of time.** SLM does not only store *what* an agent learned — it records *when*. Every fact carries ingestion timing and provenance; recall runs a dedicated temporal candidate channel alongside semantic, lexical, and associative retrieval; scenes and entity timelines reconstruct sequence; and the lifecycle lets neglected memory decay and self-archive instead of growing without bound. Time is a first-class ranking and lifecycle signal rather than a timestamp column an agent never reads — which is what lets a long-lived agent reason about how its context changed, not only what it currently holds.
64
64
 
65
- **What V4.0.7 ships.** Three things that existed but could not be used. `slm summary` gives you a readable layer over your own memories — `day` for what you recorded today, `project` for a directory, `session` for one session — each stating how much of the underlying data it could actually see, with `--json` listing the exact memories it came from. Memories that mention a function, method or file are now linked to that code, with a short description of what they point at and a marker once the code has changed; expanding a memory in the dashboard shows it. Both need no language model, so they work in the fully local mode. The code↔memory bridge behind the second one had never run at all: the setup flag was written and never read, the build discarded it, its settings had no loader, and the method it was written against was an unimplemented placeholder. Linking runs during background maintenance, never when a memory is saved. See [reviewed corrections](docs/reviewed-corrections.md) for the correction lifecycle and [MCP tools](docs/mcp-tools.md) for host-facing commands.
66
-
67
- **Fixed in V4.0.7.** Version numbers disagreed across the project — the pip requirement pins, npm lockfile, editor plugin manifest, citation metadata and lockfile all still named the previous release, so installing from `requirements.txt` fetched the wrong version; one script now sets all fifteen. Stale-memory checks reported "nothing is stale" when code linking was simply switched off, and pointed at a setting that did not exist. `slm gdpr` was missing from `slm help`. Consolidation, handed something that was neither a database handle nor a path, created a file named after the object instead of refusing it.
68
-
69
- **Carried forward from V4.0.5 and V4.0.6.** A correction is a review-gated lifecycle, not an in-place edit: SLM creates an immutable successor, keeps it out of current recall until an authenticated reviewer applies it, and preserves the predecessor for time-aware history. Every candidate path — cached context, pins, bridge and scene expansion — uses hard current-truth admission and abstains if that truth cannot be read. `slm brain`, MCP, HTTP and the Living Brain share one observation-only BrainTruth snapshot; feedback, external Bounded Loops evidence and receipt claims are shown honestly but never silently alter recall, ranking or model routing. The Living Brain leads with how many questions your memory has answered rather than a raw event count, and says so plainly where nothing has been measured yet. The knowledge graph opens reliably, with a default of 50 nodes and its details panel reachable on narrow screens. The optional adaptive ranker stays off unless an operator sets `SLM_RANKING` (`v1`, `v2`, or `v2-ensemble`) — that gate prevents feedback and observation data from changing ranking without an explicit decision, and does not disable the normal retrieval channels.
65
+ **What changed in this release.** See the [CHANGELOG](CHANGELOG.md) every release is written up there, in plain language, newest first.
70
66
 
71
67
  - **[SLM-Mesh](#slm-mesh-cross-session--cross-machine-coordination)** — authenticated cross-session and cross-machine peer coordination (messages, locks, shared state, inbox/outbox, optional discovery). Coordination only — not automatic replicated memory.
72
68
  - **Multi-scope memory & profiles** — workspaces (profiles) plus `personal` / `shared` / `global` scopes; cross-profile recall is default-deny.
@@ -300,7 +296,7 @@ retrieved at runtime rather than copied into those files.
300
296
  **Score Contract v2:** `relevance_score` is query-relative relevance;
301
297
  `ranking_score` is internal ranking utility; `memory_confidence` belongs to the
302
298
  stored assertion; and `trust_score` is an evidence-policy signal. Legacy
303
- `score` and `confidence` remain aliases for one compatibility release. V3.8.0 is
299
+ `score` and `confidence` remain aliases for one compatibility release. It is
304
300
  explicitly uncalibrated: `calibration_status` is `uncalibrated` and
305
301
  `answer_confidence` is `null`. See
306
302
  [the retrieval score contract](docs/retrieval-score-contract.md).
@@ -314,9 +310,9 @@ can run without a cloud LLM:
314
310
 
315
311
  Auto-capture hooks are installed explicitly with `slm hooks install` (Claude
316
312
  Code) or `slm hooks install --agent codex` (Codex). Hook latency and capture
317
- quality must be evaluated for the target client and workload; V3.8.0 publishes no universal p99 claim.
313
+ quality must be evaluated for the target client and workload; SLM publishes no universal p99 claim.
318
314
 
319
- **Multi-scope memory (v3.6.15, opt-in):** keep memories `personal` (default), `shared` with named profiles, or `global` across the machine. Off by default — recall only ever returns your own facts until you turn sharing on, per call or in config. See **[docs/shared-memory.md](docs/shared-memory.md)**.
315
+ **Multi-scope memory (opt-in):** keep memories `personal` (default), `shared` with named profiles, or `global` across the machine. Off by default — recall only ever returns your own facts until you turn sharing on, per call or in config. See **[docs/shared-memory.md](docs/shared-memory.md)**.
320
316
 
321
317
  <a id="multilingual-embedding-support"></a>
322
318
 
@@ -422,7 +418,7 @@ and the Claude Code plugin update path.
422
418
 
423
419
  SLM supports two MCP transports:
424
420
 
425
- **HTTP (recommended, v3.6.7+):**
421
+ **HTTP (recommended):**
426
422
  ```json
427
423
  { "mcpServers": { "superlocalmemory": { "type": "http", "url": "http://127.0.0.1:8765/mcp/" } } }
428
424
  ```
@@ -461,77 +457,46 @@ Per-IDE configs available for Claude Code, Cursor, Windsurf, VS Code Copilot, Co
461
457
 
462
458
  ---
463
459
 
464
- ## Claude Code Plugin
460
+ ## Editor plugins
465
461
 
466
- Install directly in Claude Code no system-level npm/pip needed. This is how you
467
- get the **skills, agents, hooks, commands, and rules** (the MCP server is
468
- bootstrapped automatically). It is a two-step flow — add the marketplace once,
469
- then install:
462
+ The plugin is how most people should install SLM. It brings the MCP server, the
463
+ skills, the sub-agents, the slash commands and the hooks in one step, and keeps
464
+ them at the same version as the package.
470
465
 
471
- ```bash
472
- # 1. Add the Qualixar marketplace (one-time — the repo IS the marketplace)
473
- /plugin marketplace add qualixar/superlocalmemory
474
-
475
- # 2. Install the plugin
476
- /plugin install superlocalmemory@qualixar
477
- ```
466
+ **Four surfaces, one source.** Everything below is generated from `plugin-src/`,
467
+ so no surface can quietly fall behind another:
478
468
 
479
- - Self-bootstraps a Python venv, installs all deps in an isolated `SLM_DATA_DIR`
480
- - Registers the 34-tool `code` MCP surface — the 18-tool `core` memory surface plus code-graph, portable-evidence, bounded-loop and usefulness-report tools
481
- - Ships the SLM skills / agents / hooks / commands / rules
482
- - Additive does not replace an existing SLM install
483
- - `slm connect claude-code` detects an existing plugin install and links them
469
+ | Editor | Install | Skills | Agents | Commands | Hooks |
470
+ |---|---|---:|---:|---:|---:|
471
+ | **Claude Code** | `claude plugin marketplace add qualixar/superlocalmemory` then `claude plugin install superlocalmemory@qualixar` | 12 | 4 | 1 | yes |
472
+ | **Codex** | copy `codex-plugin/` into your Codex plugins directory | 12 | 4 | 1 | yes |
473
+ | **VS Code / Copilot** | copy `copilot-plugin/.github/` into your repository | 12 | 4 | as prompts | yes |
474
+ | **Antigravity** | copy `antigravity-plugin/` into your plugins directory | 12 | 4 | 1 | yes |
484
475
 
485
- > **Plugin vs Python/npm:** `python -m pip install superlocalmemory` inside an
486
- > activated virtual environment, or `npm i -g superlocalmemory`,
487
- > give you the `slm` CLI + the MCP server (the *tools*). The **skills/agents/hooks/
488
- > commands** come only through the plugin above. Use the plugin for Claude Code; use
489
- > pip/npm for the CLI or other IDEs.
476
+ ### What you get
490
477
 
491
- To update later: `/plugin marketplace update qualixar` then `/plugin install superlocalmemory@qualixar`.
478
+ - **Skills** `slm-remember`, `slm-recall`, `slm-session`, `slm-graph`,
479
+ `slm-mesh`, `slm-scope`, `slm-profile`, `slm-governance`, `slm-cache`,
480
+ `slm-compress`, `slm-status`, `slm-loop`.
481
+ - **Sub-agents** — a memory advisor, a governance advisor, a context-optimization
482
+ advisor, and a loop runner, each scoped to the tools it actually needs.
483
+ - **Commands** — `/slm-loop`, to run a task as a gate-verified bounded loop.
484
+ - **Hooks** — session start and end, so context loads and commits without being
485
+ asked.
492
486
 
493
- ## Codex add-on
494
-
495
- For Codex, install the SLM-owned skills, two focused subagents, and four
496
- lifecycle hooks explicitly:
497
-
498
- ```bash
499
- slm codex install
500
- ```
487
+ ### Keeping it current
501
488
 
502
- This adds only SLM-owned files under `~/.agents/skills`, `~/.codex/agents`, and
503
- `~/.codex/hooks.json`; it does not replace another agent's hooks or rewrite
504
- `~/.codex/config.toml`. Codex requires review and trust for new command hooks:
505
- open `/hooks` after installation. MCP wiring remains a separate explicit step:
489
+ `pipx upgrade superlocalmemory` upgrades the **package**. It does not
490
+ upgrade the plugin those are separate channels, and the plugin is delivered by
491
+ your editor. `slm doctor` reports both versions side by side and names the
492
+ command that updates the one that is behind.
506
493
 
507
494
  ```bash
508
- slm connect codex
495
+ claude plugin marketplace update qualixar
496
+ claude plugin update superlocalmemory@qualixar
509
497
  ```
510
498
 
511
- `slm connect codex` semantically merges the `superlocalmemory` MCP server into
512
- `~/.codex/config.toml`, preserving unrelated configuration keys and writing
513
- atomically. TOML serializers can normalize whitespace and comments, so it is
514
- not a byte-preserving operation; use it only when you want the MCP server
515
- configured. Check the result with `slm codex status`; undo SLM-owned add-ons
516
- with `slm codex remove`.
517
-
518
- ## GitHub Copilot integration
519
-
520
- The shipped installer configures the SuperLocalMemory MCP server and additive
521
- agent instructions for VS Code with GitHub Copilot:
522
-
523
- ```bash
524
- slm connect vscode-copilot --here
525
- ```
526
-
527
- Run it from the project root. It semantically merges the SLM server into
528
- `.vscode/mcp.json` and adds SLM-owned guidance inside
529
- `.github/copilot-instructions.md`, preserving unrelated servers and existing
530
- instructions. The generated `copilot-plugin/` source bundle is maintained for
531
- parity checks, but v3.8.1 does not claim that `slm connect` installs its prompt,
532
- agent, or hook files.
533
-
534
- ---
499
+ For the other three, replace the directory from the tag you are on.
535
500
 
536
501
  ## Privacy controls and operating modes
537
502
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "superlocalmemory",
3
- "version": "4.1.0",
3
+ "version": "4.1.2",
4
4
  "description": "Local-first agent memory with MCP and an agent-native CLI. Documented clients include Claude Code, Cursor, and Windsurf.",
5
5
  "keywords": [
6
6
  "ai-memory",
@@ -52,10 +52,11 @@
52
52
  "check:copilot-plugin": "node scripts/build-copilot-plugin.mjs --check",
53
53
  "build:codex-plugin": "node scripts/build-codex-plugin.mjs",
54
54
  "check:codex-plugin": "node scripts/build-codex-plugin.mjs --check",
55
- "prepack": "node scripts/build-plugin.mjs && node scripts/build-copilot-plugin.mjs && node scripts/build-codex-plugin.mjs && node scripts/prepack.js",
55
+ "prepack": "node scripts/build-plugin.mjs && node scripts/build-copilot-plugin.mjs && node scripts/build-codex-plugin.mjs && node scripts/build-antigravity-plugin.mjs && node scripts/prepack.js",
56
56
  "postinstall": "node scripts/postinstall.js",
57
57
  "preuninstall": "node scripts/preuninstall.js",
58
- "test": "node scripts/run-ui-tests.mjs"
58
+ "test": "node scripts/run-ui-tests.mjs",
59
+ "build:antigravity-plugin": "node scripts/build-antigravity-plugin.mjs"
59
60
  },
60
61
  "engines": {
61
62
  "node": ">=18.0.0",
@@ -15,5 +15,5 @@
15
15
  "mcpServers": "./.mcp.json",
16
16
  "name": "superlocalmemory",
17
17
  "repository": "https://github.com/qualixar/superlocalmemory",
18
- "version": "4.1.0"
18
+ "version": "4.1.2"
19
19
  }
package/plugin/CLAUDE.md CHANGED
@@ -1,4 +1,4 @@
1
- <!-- BEGIN SuperLocalMemory v4.1.0 -->
1
+ <!-- BEGIN SuperLocalMemory v4.1.2 -->
2
2
 
3
3
  ## SuperLocalMemory (SLM) — Agent Rules
4
4
 
@@ -39,6 +39,6 @@ slm-recall · slm-remember · slm-session · slm-status · slm-cache · slm-comp
39
39
  ### Subagents
40
40
  slm-memory-advisor (memory decisions, session hygiene, scope/profile guidance) · slm-optimize-advisor (context compression + KV cache) · slm-governance-advisor (scope/roles/compliance/GDPR)
41
41
 
42
- <!-- END SuperLocalMemory v4.1.0 -->
42
+ <!-- END SuperLocalMemory v4.1.2 -->
43
43
 
44
- SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later
44
+ SuperLocalMemory v4.1.2 · Qualixar · AGPL-3.0-or-later
@@ -77,4 +77,4 @@ slm-scope · slm-governance · slm-profile · slm-remember · slm-recall
77
77
  # What NOT to do
78
78
  Never session_init twice; never forget without dry-run preview; never store secrets; never bypass role checks; never claim an erasure succeeded without verifying via recall.
79
79
 
80
- SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later
80
+ SuperLocalMemory v4.1.2 · Qualixar · AGPL-3.0-or-later
@@ -68,4 +68,4 @@ assessment. The gate is the authority.
68
68
 
69
69
  ---
70
70
 
71
- SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later
71
+ SuperLocalMemory v4.1.2 · Qualixar · AGPL-3.0-or-later
@@ -46,4 +46,4 @@ slm-recall · slm-remember · slm-session · slm-scope · slm-profile · slm-gov
46
46
  # What NOT to do
47
47
  Never session_init twice; never forget dry_run=False without reporting preview; never dump a whole file into remember; never invent a memory; never claim "saved" without success:true / clean CLI exit; never bypass scope or governance restrictions.
48
48
 
49
- SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later
49
+ SuperLocalMemory v4.1.2 · Qualixar · AGPL-3.0-or-later
@@ -41,4 +41,4 @@ slm-compress · slm-cache · slm-status · slm-profile
41
41
  # What NOT to do
42
42
  Never compress code-for-edit/JSON-to-parse/<500 chars; never store secrets/ccr_ids; never let optimize failure block/alter the task; never claim a specific savings %; never carry ccr_ids across profile switches.
43
43
 
44
- SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later
44
+ SuperLocalMemory v4.1.2 · Qualixar · AGPL-3.0-or-later
@@ -1 +1 @@
1
- superlocalmemory==4.1.0
1
+ superlocalmemory==4.1.2
@@ -145,4 +145,4 @@ These subcommands control daemon-level cache settings. They do not read or write
145
145
 
146
146
  ---
147
147
 
148
- SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later
148
+ SuperLocalMemory v4.1.2 · Qualixar · AGPL-3.0-or-later
@@ -147,4 +147,4 @@ Content over 1 MB (1 000 000 bytes UTF-8) is processed but `reversible` is force
147
147
 
148
148
  ---
149
149
 
150
- SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later
150
+ SuperLocalMemory v4.1.2 · Qualixar · AGPL-3.0-or-later
@@ -245,4 +245,4 @@ Before running any destructive operation (`forget`, `compact_memories`):
245
245
 
246
246
  ---
247
247
 
248
- *SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later*
248
+ *SuperLocalMemory v4.1.2 · Qualixar · AGPL-3.0-or-later*
@@ -312,4 +312,4 @@ profile. See `slm-profile` for the full profile switching workflow.
312
312
 
313
313
  ---
314
314
 
315
- SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later
315
+ SuperLocalMemory v4.1.2 · Qualixar · AGPL-3.0-or-later
@@ -96,4 +96,4 @@ paused, name the approval needed; when errored, quote the short detail.
96
96
 
97
97
  ---
98
98
 
99
- SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later
99
+ SuperLocalMemory v4.1.2 · Qualixar · AGPL-3.0-or-later
@@ -279,4 +279,4 @@ mesh availability.
279
279
 
280
280
  ---
281
281
 
282
- *SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later*
282
+ *SuperLocalMemory v4.1.2 · Qualixar · AGPL-3.0-or-later*
@@ -146,4 +146,4 @@ Name them differently in your MCP config (e.g. `superlocalmemory-personal` and
146
146
 
147
147
  ---
148
148
 
149
- *SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later*
149
+ *SuperLocalMemory v4.1.2 · Qualixar · AGPL-3.0-or-later*
@@ -323,4 +323,4 @@ before recalling, then switch back. See `slm-profile` for workspace switching.
323
323
 
324
324
  ---
325
325
 
326
- *SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later*
326
+ *SuperLocalMemory v4.1.2 · Qualixar · AGPL-3.0-or-later*
@@ -270,4 +270,4 @@ different workspace, use `switch_profile` first. See `slm-profile`.
270
270
 
271
271
  ---
272
272
 
273
- *SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later*
273
+ *SuperLocalMemory v4.1.2 · Qualixar · AGPL-3.0-or-later*
@@ -173,4 +173,4 @@ to review the impact. See `slm-remember` for the full deletion discipline.
173
173
 
174
174
  ---
175
175
 
176
- *SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later*
176
+ *SuperLocalMemory v4.1.2 · Qualixar · AGPL-3.0-or-later*
@@ -253,4 +253,4 @@ explicitly and call `recall` with `include_global`/`include_shared` after
253
253
 
254
254
  ---
255
255
 
256
- *SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later*
256
+ *SuperLocalMemory v4.1.2 · Qualixar · AGPL-3.0-or-later*
@@ -163,4 +163,4 @@ multi-profile setup. To switch the active profile, see `slm-profile`.
163
163
 
164
164
  ---
165
165
 
166
- SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later
166
+ SuperLocalMemory v4.1.2 · Qualixar · AGPL-3.0-or-later
@@ -137,4 +137,4 @@ When the SLM MCP server is unavailable, use these CLI equivalents:
137
137
  - **slm-optimize-advisor** — context compression and KV cache
138
138
  - **slm-governance-advisor** — scope/role compliance, retention policies, GDPR
139
139
 
140
- SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later
140
+ SuperLocalMemory v4.1.2 · Qualixar · AGPL-3.0-or-later
@@ -145,4 +145,4 @@ These subcommands control daemon-level cache settings. They do not read or write
145
145
 
146
146
  ---
147
147
 
148
- SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later
148
+ SuperLocalMemory v4.1.2 · Qualixar · AGPL-3.0-or-later
@@ -147,4 +147,4 @@ Content over 1 MB (1 000 000 bytes UTF-8) is processed but `reversible` is force
147
147
 
148
148
  ---
149
149
 
150
- SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later
150
+ SuperLocalMemory v4.1.2 · Qualixar · AGPL-3.0-or-later
@@ -245,4 +245,4 @@ Before running any destructive operation (`forget`, `compact_memories`):
245
245
 
246
246
  ---
247
247
 
248
- *SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later*
248
+ *SuperLocalMemory v4.1.2 · Qualixar · AGPL-3.0-or-later*
@@ -312,4 +312,4 @@ profile. See `slm-profile` for the full profile switching workflow.
312
312
 
313
313
  ---
314
314
 
315
- SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later
315
+ SuperLocalMemory v4.1.2 · Qualixar · AGPL-3.0-or-later
@@ -96,4 +96,4 @@ paused, name the approval needed; when errored, quote the short detail.
96
96
 
97
97
  ---
98
98
 
99
- SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later
99
+ SuperLocalMemory v4.1.2 · Qualixar · AGPL-3.0-or-later
@@ -279,4 +279,4 @@ mesh availability.
279
279
 
280
280
  ---
281
281
 
282
- *SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later*
282
+ *SuperLocalMemory v4.1.2 · Qualixar · AGPL-3.0-or-later*
@@ -146,4 +146,4 @@ Name them differently in your MCP config (e.g. `superlocalmemory-personal` and
146
146
 
147
147
  ---
148
148
 
149
- *SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later*
149
+ *SuperLocalMemory v4.1.2 · Qualixar · AGPL-3.0-or-later*
@@ -323,4 +323,4 @@ before recalling, then switch back. See `slm-profile` for workspace switching.
323
323
 
324
324
  ---
325
325
 
326
- *SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later*
326
+ *SuperLocalMemory v4.1.2 · Qualixar · AGPL-3.0-or-later*
@@ -270,4 +270,4 @@ different workspace, use `switch_profile` first. See `slm-profile`.
270
270
 
271
271
  ---
272
272
 
273
- *SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later*
273
+ *SuperLocalMemory v4.1.2 · Qualixar · AGPL-3.0-or-later*
@@ -173,4 +173,4 @@ to review the impact. See `slm-remember` for the full deletion discipline.
173
173
 
174
174
  ---
175
175
 
176
- *SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later*
176
+ *SuperLocalMemory v4.1.2 · Qualixar · AGPL-3.0-or-later*
@@ -253,4 +253,4 @@ explicitly and call `recall` with `include_global`/`include_shared` after
253
253
 
254
254
  ---
255
255
 
256
- *SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later*
256
+ *SuperLocalMemory v4.1.2 · Qualixar · AGPL-3.0-or-later*
@@ -163,4 +163,4 @@ multi-profile setup. To switch the active profile, see `slm-profile`.
163
163
 
164
164
  ---
165
165
 
166
- SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later
166
+ SuperLocalMemory v4.1.2 · Qualixar · AGPL-3.0-or-later
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "superlocalmemory"
3
- version = "4.1.0"
3
+ version = "4.1.2"
4
4
  description = "Local-first agent memory with auditable hybrid retrieval"
5
5
  readme = "README.md"
6
6
  license = "AGPL-3.0-or-later"
@@ -32,7 +32,7 @@ if "OMP_NUM_THREADS" not in os.environ:
32
32
  os.environ["OMP_NUM_THREADS"] = "2"
33
33
  # ---------------------------------------------------------------------------
34
34
 
35
- __version__ = "4.1.0"
35
+ __version__ = "4.1.2"
36
36
 
37
37
  _REQUIRED_VERSIONS = {
38
38
  "sentence_transformers": "5.3.0",
@@ -2577,6 +2577,56 @@ def _migration_error_logs() -> list:
2577
2577
  return []
2578
2578
 
2579
2579
 
2580
+ def _slm_version() -> str:
2581
+ """The installed package version, or "unknown"."""
2582
+ try:
2583
+ from importlib.metadata import version
2584
+ return version("superlocalmemory")
2585
+ except Exception: # noqa: BLE001
2586
+ return "unknown"
2587
+
2588
+
2589
+ def _installed_plugin_versions() -> dict:
2590
+ """Version of each editor plugin found on this machine, by install name.
2591
+
2592
+ The skills, agents and commands live in the editor's plugin channel rather
2593
+ than in the Python package, so upgrading with pip leaves them exactly where
2594
+ they were. This looks for them where each editor puts them, and returns an
2595
+ empty mapping when none is installed -- which is itself the answer worth
2596
+ reporting, because it means pip is the only thing being upgraded.
2597
+
2598
+ Best effort by design: an editor this does not know about should produce
2599
+ "not detected", never an error.
2600
+ """
2601
+ import json
2602
+ from pathlib import Path
2603
+
2604
+ found: dict[str, str] = {}
2605
+ roots = (
2606
+ # Claude Code: marketplace installs and directly-added plugins.
2607
+ Path.home() / ".claude" / "plugins",
2608
+ # Codex and VS Code copies, when placed by hand.
2609
+ Path.home() / ".codex" / "plugins",
2610
+ Path.home() / ".vscode" / "extensions",
2611
+ )
2612
+ for root in roots:
2613
+ if not root.is_dir():
2614
+ continue
2615
+ for manifest in list(root.glob("*/.claude-plugin/plugin.json")) + \
2616
+ list(root.glob("*/plugin.json")) + \
2617
+ list(root.glob("*/*/.claude-plugin/plugin.json")):
2618
+ try:
2619
+ data = json.loads(manifest.read_text(encoding="utf-8"))
2620
+ except Exception: # noqa: BLE001 — a sibling plugin's bad json is not ours
2621
+ continue
2622
+ if str(data.get("name", "")) != "superlocalmemory":
2623
+ continue
2624
+ found[str(manifest.parent.parent.name)] = str(
2625
+ data.get("version", "unknown")
2626
+ )
2627
+ return found
2628
+
2629
+
2580
2630
  def _detect_all_installs() -> list:
2581
2631
  """Thin shim so cmd_doctor can be tested without importing install_detector."""
2582
2632
  try:
@@ -3093,6 +3143,50 @@ def cmd_doctor(args: Namespace) -> None:
3093
3143
  except Exception as _inst_exc: # noqa: BLE001 — never break doctor
3094
3144
  _check("install_versions", "WARN", f"could not probe installs: {_inst_exc}")
3095
3145
 
3146
+ # 14. The skills, agents and commands are NOT in the Python package.
3147
+ # They ship through the editor's own plugin channel -- `plugin/` in the
3148
+ # repository -- so `pip install --upgrade` cannot move them, and until now
3149
+ # nothing said so. 4.1 changed 76 files across those trees; a user who
3150
+ # upgraded the package and read a clean `slm doctor` had every reason to
3151
+ # believe they had all of it, and no way to find out otherwise.
3152
+ try:
3153
+ _pkg_version = _slm_version()
3154
+ _pl = _installed_plugin_versions()
3155
+ if not _pl:
3156
+ _check(
3157
+ "plugin_skills",
3158
+ "WARN",
3159
+ f"package is {_pkg_version}; no editor plugin detected, so the "
3160
+ f"skills, agents and commands are not installed or updated by "
3161
+ f"pip",
3162
+ fix="Claude Code: claude plugin marketplace add "
3163
+ "qualixar/superlocalmemory && claude plugin install "
3164
+ "superlocalmemory@qualixar "
3165
+ "Codex / VS Code: copy codex-plugin/ or copilot-plugin/ "
3166
+ "from the tag you are on",
3167
+ )
3168
+ else:
3169
+ _stale = {n: v for n, v in _pl.items() if v != _pkg_version}
3170
+ if _stale:
3171
+ _check(
3172
+ "plugin_skills",
3173
+ "WARN",
3174
+ "package is %s; plugin content still at %s" % (
3175
+ _pkg_version,
3176
+ ", ".join(f"{n}={v}" for n, v in sorted(_stale.items())),
3177
+ ),
3178
+ fix="claude plugin marketplace update qualixar && "
3179
+ "claude plugin update superlocalmemory@qualixar",
3180
+ )
3181
+ else:
3182
+ _check(
3183
+ "plugin_skills",
3184
+ "PASS",
3185
+ f"plugin content matches the package ({_pkg_version})",
3186
+ )
3187
+ except Exception as _pl_exc: # noqa: BLE001 — never break doctor
3188
+ _check("plugin_skills", "WARN", f"could not probe plugins: {_pl_exc}")
3189
+
3096
3190
  # 14. Migration error logs — surface any unresolved failure from a previous
3097
3191
  # upgrade attempt. The daemon writes these and they persist until the
3098
3192
  # user takes action; doctor is the right place to surface them.
@@ -39,6 +39,7 @@ from __future__ import annotations
39
39
 
40
40
  import logging
41
41
  import threading
42
+ import time
42
43
  from contextlib import nullcontext
43
44
  from dataclasses import dataclass
44
45
 
@@ -67,6 +68,17 @@ class RecallHealth:
67
68
  checks: int = 0
68
69
  last_semantic_score: float = 0.0
69
70
  last_error: str = ""
71
+ #: When the last tick finished, as a unix timestamp. A tick that finds
72
+ #: nothing wrong logs nothing, which is correct -- a monitor that narrates
73
+ #: every success is a monitor whose real warnings get skimmed past. But it
74
+ #: left no way to tell a monitor that is ticking quietly from a thread that
75
+ #: died or never started, and that ambiguity cost someone an hour of looking
76
+ #: for log lines that were never going to appear. So the fact of the tick is
77
+ #: recorded here and surfaced on /health, where it can be checked instead of
78
+ #: inferred.
79
+ last_tick_at: float = 0.0
80
+ #: Whether the embedder could produce a vector at the last tick.
81
+ embedder_alive: bool = True
70
82
 
71
83
 
72
84
  def _max_semantic(results) -> float:
@@ -91,6 +103,38 @@ def _get_embedder(engine):
91
103
  return emb
92
104
 
93
105
 
106
+ def _embedder_is_dead(engine) -> bool:
107
+ """Can the embedder produce a vector right now?
108
+
109
+ Asked directly, because it cannot be inferred from a recall. The monitor
110
+ used to decide the embedder was fine whenever the probe recall came back
111
+ with no results at all -- and a dead embedder is one of the reasons a recall
112
+ comes back with no results, so the one symptom that should have triggered a
113
+ heal was read as proof that none was needed.
114
+
115
+ That is not hypothetical. An idle-timeout kill leaves no worker; the next
116
+ probe finds nothing by meaning, finds nothing by keyword either because the
117
+ probe phrase appears in nobody's memories, and returns zero results. The
118
+ monitor then recorded "healthy", logged nothing, and never respawned the
119
+ worker -- so ``readiness.embedding`` stayed false and the daemon sat in
120
+ ``warming`` until someone restarted it by hand, with not one line in the log
121
+ to say why.
122
+
123
+ Fails safe in the opposite direction from before: an embedder this cannot
124
+ reach is reported dead, so the worst case is one unnecessary re-warm rather
125
+ than a silent outage.
126
+ """
127
+ emb = _get_embedder(engine)
128
+ if emb is None:
129
+ return False # BM25-only by configuration; nothing to heal.
130
+ warm = getattr(emb, "is_warm", None)
131
+ if warm is not None and not warm:
132
+ return True
133
+ if getattr(emb, "_available", True) is False:
134
+ return True
135
+ return False
136
+
137
+
94
138
  def _heal_embedder(engine, *, log) -> bool:
95
139
  """Tier 3: reset the cached availability flag and re-exercise the embedder.
96
140
 
@@ -169,10 +213,27 @@ def run_health_tick(engine, state: RecallHealth, *, probe: str = DEFAULT_PROBE,
169
213
  results = list(getattr(resp, "results", []) or [])
170
214
  sem = _max_semantic(results)
171
215
  state.last_semantic_score = sem
172
-
173
- # Tier 2: readiness. Rows present but semantic never fired == warm-but-broken.
174
- # Zero results is NOT this signature (could be an empty/filtered corpus).
175
- broken = bool(results) and sem <= 0.0
216
+ state.last_tick_at = time.time()
217
+
218
+ # Tier 2: readiness. Two independent signatures, and the second one is why
219
+ # this monitor exists.
220
+ #
221
+ # * rows present but semantic never fired -> warm-but-broken
222
+ # * the embedder cannot produce a vector -> dead, whatever the recall said
223
+ #
224
+ # The second used to be missing, and its absence was load-bearing: zero
225
+ # results was treated as "not this signature", so the case where the embedder
226
+ # is dead AND the probe matches nothing by keyword -- which is the normal
227
+ # shape of an idle-timeout kill -- came out as healthy, silently.
228
+ dead = _embedder_is_dead(engine)
229
+ state.embedder_alive = not dead
230
+ broken = dead or (bool(results) and sem <= 0.0)
231
+ if dead:
232
+ log.critical(
233
+ "recall-health: embedder cannot produce a vector (%d probe results) "
234
+ "— attempting self-heal",
235
+ len(results),
236
+ )
176
237
  if not broken:
177
238
  if not state.healthy:
178
239
  log.warning(
@@ -184,12 +245,16 @@ def run_health_tick(engine, state: RecallHealth, *, probe: str = DEFAULT_PROBE,
184
245
  state.last_error = ""
185
246
  return state
186
247
 
187
- # Tier 3: self-heal.
188
- log.critical(
189
- "recall-health: semantic channel DEAD (%d results, max semantic=0.0) "
190
- "— embedder returning None; attempting self-heal",
191
- len(results),
192
- )
248
+ # Tier 3: self-heal. The dead-embedder case already said so above; saying
249
+ # "semantic channel DEAD (max semantic=0.0)" as well would be a second,
250
+ # differently-worded CRITICAL about the same tick, and one of the two would
251
+ # be describing a symptom the reader does not have.
252
+ if not dead:
253
+ log.critical(
254
+ "recall-health: semantic channel DEAD (%d results, max semantic=0.0) "
255
+ "— embedder returning None; attempting self-heal",
256
+ len(results),
257
+ )
193
258
  if _heal_embedder(engine, log=log):
194
259
  state.total_heals += 1
195
260
  state.healthy = True
@@ -256,6 +321,7 @@ def start_recall_health_monitor(engine, *, interval_s: int = DEFAULT_INTERVAL_S,
256
321
  def get_recall_health() -> dict:
257
322
  """Snapshot for /health surfacing (visibility — never silent degradation)."""
258
323
  s = _GLOBAL_STATE
324
+ now = time.time()
259
325
  return {
260
326
  "recall_healthy": s.healthy,
261
327
  "consecutive_failures": s.consecutive_failures,
@@ -263,4 +329,15 @@ def get_recall_health() -> dict:
263
329
  "checks": s.checks,
264
330
  "last_semantic_score": round(s.last_semantic_score, 4),
265
331
  "last_error": s.last_error,
332
+ # Proof of life. A tick that finds nothing wrong logs nothing, so there
333
+ # was no way to tell this monitor apart from a thread that never started
334
+ # -- someone spent an hour reading logs for lines that were never going
335
+ # to be written. These two answer that without needing the log at all:
336
+ # if seconds_since_last_tick keeps climbing past the interval, the thread
337
+ # is gone.
338
+ "last_tick_at": round(s.last_tick_at, 3) if s.last_tick_at else None,
339
+ "seconds_since_last_tick": (
340
+ round(now - s.last_tick_at, 1) if s.last_tick_at else None
341
+ ),
342
+ "embedder_alive": s.embedder_alive,
266
343
  }
@@ -511,6 +511,49 @@ _MIGRATION_EXEMPT_PATH_PREFIXES: tuple[str, ...] = (
511
511
  )
512
512
 
513
513
 
514
+ def _serving_blocked_by(migration_result: dict) -> list[str]:
515
+ """Failed migrations that should stop this daemon serving. Fail-closed.
516
+
517
+ A failed migration used to 503 every route without asking what had failed.
518
+ For a missing table that is right. For a data invariant that ordinary use can
519
+ re-violate it is not: one drifted row made the whole store unreachable until
520
+ somebody restarted it by hand, and the restart fixed nothing that a
521
+ background repair would not have fixed on its own.
522
+
523
+ A migration may answer for itself by exposing ``blocks_serving(conn)``.
524
+ Anything that does not is treated as blocking, so this cannot quietly open a
525
+ door for a migration nobody has thought about.
526
+ """
527
+ failed = list(migration_result.get("failed") or [])
528
+ if not failed:
529
+ return []
530
+ try:
531
+ import sqlite3
532
+
533
+ from superlocalmemory.infra.data_root import state_path
534
+ from superlocalmemory.storage._migration_internals import _MODULES
535
+ except Exception: # noqa: BLE001 — never let this decide by crashing
536
+ return failed
537
+
538
+ blocking: list[str] = []
539
+ for name in failed:
540
+ decide = getattr(_MODULES.get(name), "blocks_serving", None)
541
+ if not callable(decide):
542
+ blocking.append(name)
543
+ continue
544
+ try:
545
+ db = state_path("memory.db")
546
+ conn = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
547
+ try:
548
+ if decide(conn):
549
+ blocking.append(name)
550
+ finally:
551
+ conn.close()
552
+ except Exception: # noqa: BLE001 — unknown means blocking
553
+ blocking.append(name)
554
+ return blocking
555
+
556
+
514
557
  def _is_migration_exempt_path(path: str) -> bool:
515
558
  """Return True for health, status, and repair paths that must stay reachable
516
559
  even when the daemon reports a schema migration failure.
@@ -4009,7 +4052,7 @@ def _register_dashboard_routes(application: FastAPI) -> None:
4009
4052
  @application.middleware("http")
4010
4053
  async def _migration_readiness_gate(request, call_next):
4011
4054
  migration_result = getattr(application.state, "migration_result", None)
4012
- if migration_result and migration_result.get("failed"):
4055
+ if migration_result and _serving_blocked_by(migration_result):
4013
4056
  if not _is_migration_exempt_path(request.url.path):
4014
4057
  from fastapi.responses import JSONResponse
4015
4058
  return JSONResponse(
@@ -4278,7 +4321,14 @@ def _register_daemon_routes(application: FastAPI) -> None:
4278
4321
  (migration_result or {}).get("failed", []) or []
4279
4322
  )
4280
4323
  migration_details = (migration_result or {}).get("details", {}) or {}
4281
- migrations_ready = bool(migration_result) and not migration_failures
4324
+ # Ready means "can serve", so it keys off the failures that actually
4325
+ # stop this daemon serving -- not off every failure. A data invariant
4326
+ # that ordinary use re-violated leaves every route working; reporting
4327
+ # not-ready for it told operators to restart, which fixed nothing a
4328
+ # background repair would not have fixed. Everything still shows up in
4329
+ # migration_failures and migration_failure_reasons below, named.
4330
+ migration_blocking = _serving_blocked_by(migration_result or {})
4331
+ migrations_ready = bool(migration_result) and not migration_blocking
4282
4332
  if migration_details.get("_crash"):
4283
4333
  migrations_ready = False
4284
4334
  writer_runtime = getattr(
@@ -4296,6 +4346,9 @@ def _register_daemon_routes(application: FastAPI) -> None:
4296
4346
  "embedding": embedding_ready,
4297
4347
  "recall_health": recall_health.get("recall_healthy") is True,
4298
4348
  "migration_failures": migration_failures,
4349
+ # Which of those are the reason this daemon will not serve, as
4350
+ # opposed to the ones it is reporting while serving normally.
4351
+ "migration_blocking": migration_blocking,
4299
4352
  # WHY each one failed, not just which. The runner already produces
4300
4353
  # a precise sentence per migration -- "safe repair did not restore
4301
4354
  # M043_...", "schema verification failed ... : <sqlite error>" --
@@ -332,6 +332,22 @@ def _delete_log(conn: sqlite3.Connection, name: str) -> None:
332
332
  conn.execute("DELETE FROM migration_log WHERE name = ?", (name,))
333
333
 
334
334
 
335
+ def _why_unmet(mod, conn) -> str:
336
+ """The migration's own account of which check does not hold.
337
+
338
+ A migration may expose ``unmet(conn)`` returning a sentence. Most do not,
339
+ and for those the caller keeps its generic wording. Never raises: this runs
340
+ while reporting a failure and must not become a second one.
341
+ """
342
+ fn = getattr(mod, "unmet", None)
343
+ if not callable(fn):
344
+ return ""
345
+ try:
346
+ return str(fn(conn) or "")
347
+ except Exception: # noqa: BLE001 - a detail string is not worth a crash
348
+ return ""
349
+
350
+
335
351
  def _apply_single(
336
352
  conn: sqlite3.Connection,
337
353
  migration: Migration,
@@ -414,9 +430,12 @@ def _apply_single(
414
430
  try:
415
431
  repair_fn(conn)
416
432
  if not bool(verify_fn(conn)):
433
+ _why = _why_unmet(mod, conn)
417
434
  return (
418
435
  "failed",
419
- f"safe repair did not restore {migration.name}",
436
+ f"safe repair did not restore "
437
+ f"{migration.name}"
438
+ + (f": {_why}" if _why else ""),
420
439
  )
421
440
  _upsert_log(conn, migration.name, ddl_hash, "complete")
422
441
  return (
@@ -482,9 +501,11 @@ def _apply_single(
482
501
  )
483
502
  try:
484
503
  if not bool(verify_fn(conn)):
504
+ _why = _why_unmet(mod, conn)
485
505
  return (
486
506
  "failed",
487
- f"safe repair did not restore {migration.name}",
507
+ f"safe repair did not restore {migration.name}"
508
+ + (f": {_why}" if _why else ""),
488
509
  )
489
510
  except sqlite3.Error as exc:
490
511
  return (
@@ -409,45 +409,31 @@ def _sync_lifecycle_mirror(conn: sqlite3.Connection) -> None:
409
409
  """)
410
410
 
411
411
 
412
- def verify(conn: sqlite3.Connection) -> bool:
413
- """Whether the repair's end-state holds.
414
-
415
- Called on every start for an already-complete migration. Returning False
416
- routes to ``repair()``, which makes this a standing guard: if pollution ever
417
- reappears, the next daemon start withholds it without anyone asking.
412
+ def unmet(conn: sqlite3.Connection) -> str:
413
+ """Which check does not hold, named. Empty string when all of them do.
414
+
415
+ ``verify()`` returns a bare boolean, so when a completed migration stops
416
+ verifying the runner can only say "safe repair did not restore M043". This
417
+ checks five separate things, and that sentence names none of them -- a user
418
+ hitting it had to come back and ask which, and so did we. This is the same
419
+ gap that ``migration_failure_reasons`` closed one level up, left open one
420
+ level down.
418
421
  """
419
422
  if not _table_exists(conn, "atomic_facts"):
420
- return True
423
+ return ""
421
424
  if not _has_column(conn, "atomic_facts", "quarantined"):
422
- return False
425
+ return "atomic_facts has no 'quarantined' column"
423
426
  if not _table_exists(conn, "consolidated_summaries"):
424
- return False
425
-
427
+ return "the consolidated_summaries display table is missing"
426
428
  if _table_exists(conn, "fact_consolidations"):
427
- unwithheld = _count(
429
+ n = _count(
428
430
  conn,
429
431
  "SELECT COUNT(*) FROM atomic_facts WHERE COALESCE(quarantined, 0) = 0 "
430
432
  " AND fact_id IN (" + _CONSOLIDATOR_ROWS + ")",
431
433
  )
432
- if unwithheld:
433
- return False
434
-
435
- # Every withheld row must still be visible somewhere, or the repair has
436
- # deleted the owner's view of it rather than moved it.
437
- #
438
- # BY IDENTITY *OR* CONTENT, and the "or" is what makes this an invariant
439
- # rather than a trap. Matching on content alone could never become true
440
- # once a row's content changed after being preserved: the display copy
441
- # keeps the old text, verify stays false, repair() runs apply() again,
442
- # apply() cannot change the past, and the migration is reported failed
443
- # on every start for the rest of the store's life. Matching on identity
444
- # alone fails the other way, because two withheld rows with identical
445
- # text collapse into one display row under the unique triple, leaving the
446
- # second with no row of its own id.
447
- #
448
- # Either match satisfies the guarantee that actually matters: nothing
449
- # the owner could see has stopped being visible.
450
- unpreserved = _count(conn, """
434
+ if n:
435
+ return f"{n} model-written summaries are not withheld from recall"
436
+ n = _count(conn, """
451
437
  SELECT COUNT(*) FROM atomic_facts af
452
438
  WHERE af.fact_id IN (""" + _CONSOLIDATOR_ROWS + """)
453
439
  AND NOT EXISTS (
@@ -457,13 +443,51 @@ def verify(conn: sqlite3.Connection) -> bool:
457
443
  OR cs.content = af.content)
458
444
  )
459
445
  """)
460
- if unpreserved:
461
- return False
462
-
446
+ if n:
447
+ return f"{n} withheld summaries have no display copy"
463
448
  if _table_exists(conn, "fact_retention"):
464
- if _count(conn, "SELECT COUNT(*) FROM (" + _wrongly_hidden(conn) + ")"):
465
- return False
466
- return True
449
+ n = _count(conn, "SELECT COUNT(*) FROM (" + _wrongly_hidden(conn) + ")")
450
+ if n:
451
+ return f"{n} real memories are hidden from recall and should not be"
452
+ return ""
453
+
454
+
455
+ def blocks_serving(conn: sqlite3.Connection) -> bool:
456
+ """Should a daemon refuse to serve while this check does not hold?
457
+
458
+ Only when the SCHEMA is missing. The two schema conditions here -- the
459
+ column and the display table -- mean queries would hit something that is not
460
+ there, so refusing is right. The other three are about DATA: a summary that
461
+ should be withheld is not withheld, or a real memory is hidden. Those make
462
+ some answers worse; they do not stop the store working.
463
+
464
+ The distinction matters because this ``verify()`` is a standing guard over
465
+ data that ordinary use can re-violate -- a consolidation pass hiding one more
466
+ memory is enough. Treating that like a missing table meant one drifted row
467
+ could return 503 on every route indefinitely, with a manual restart the only
468
+ way out. That is an outage caused by a quality check, which is worse than the
469
+ thing the check is for.
470
+
471
+ Reported as #125, where a user's daemon sat unusable on exactly this.
472
+ """
473
+ if not _table_exists(conn, "atomic_facts"):
474
+ return False
475
+ if not _has_column(conn, "atomic_facts", "quarantined"):
476
+ return True
477
+ return not _table_exists(conn, "consolidated_summaries")
478
+
479
+
480
+ def verify(conn: sqlite3.Connection) -> bool:
481
+ """Whether the repair's end-state holds.
482
+
483
+ Called on every start for an already-complete migration. Returning False
484
+ routes to ``repair()``, which makes this a standing guard: if pollution ever
485
+ reappears, the next daemon start withholds it without anyone asking.
486
+
487
+ Thin wrapper over ``unmet()`` so the two can never disagree about what
488
+ "verified" means.
489
+ """
490
+ return not unmet(conn)
467
491
 
468
492
 
469
493
  def repair(conn: sqlite3.Connection) -> None:
@@ -972,6 +972,18 @@ _DDL_ORDERED: Final[tuple[str, ...]] = (
972
972
  #: which SQLite offers no IF NOT EXISTS form of, so presence is checked first.
973
973
  _ADDITIVE_COLUMNS: Final[tuple[tuple[str, str, str], ...]] = (
974
974
  ("atomic_facts", "quarantined", "INTEGER NOT NULL DEFAULT 0"),
975
+ # ``pinned`` arrives with M015, which is a DEFERRED migration -- it runs
976
+ # after the engine is up. But the DDL below indexes it
977
+ # (``idx_facts_pinned``), and that DDL runs during engine start. On a store
978
+ # old enough to predate M015 the index therefore raised "no such column:
979
+ # pinned" before anything could add it, and every start failed the same way:
980
+ # the deferred pass could not run because the engine could not start, and
981
+ # the engine could not start because the deferred pass had not run.
982
+ # ``slm db migrate`` did not break the loop either -- it reports Failed=0
983
+ # and skips deferred migrations by definition.
984
+ # The column has to exist before its own index either way, so it belongs
985
+ # here, where a store gets it at start regardless of migration state.
986
+ ("atomic_facts", "pinned", "INTEGER NOT NULL DEFAULT 0"),
975
987
  )
976
988
 
977
989
 
@@ -1011,6 +1023,17 @@ def create_all_tables(conn: sqlite3.Connection) -> None:
1011
1023
  """
1012
1024
  _set_pragmas(conn)
1013
1025
 
1026
+ # Before the DDL, not only after it. The DDL below indexes columns that an
1027
+ # upgraded store may not have yet, and an index on a column that does not
1028
+ # exist is a hard error, not a skipped statement -- so the whole of
1029
+ # create_all_tables would raise and the engine would never start.
1030
+ #
1031
+ # On a fresh database this pass does nothing: the tables do not exist yet,
1032
+ # which the helper treats as "nothing to alter". On an upgraded one the
1033
+ # tables are already there and this is exactly where the columns are owed.
1034
+ # It runs again at the end for tables created during this call.
1035
+ _add_missing_columns(conn)
1036
+
1014
1037
  for ddl in _DDL_ORDERED:
1015
1038
  conn.executescript(ddl)
1016
1039