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.
- package/.claude-plugin/marketplace.json +11 -1
- package/CHANGELOG.md +74 -0
- package/README.md +37 -72
- package/package.json +4 -3
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +1 -1
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-governance/SKILL.md +1 -1
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-loop/SKILL.md +1 -1
- package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
- package/plugin-src/skills/slm-profile/SKILL.md +1 -1
- package/plugin-src/skills/slm-recall/SKILL.md +1 -1
- package/plugin-src/skills/slm-remember/SKILL.md +1 -1
- package/plugin-src/skills/slm-scope/SKILL.md +1 -1
- package/plugin-src/skills/slm-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +94 -0
- package/src/superlocalmemory/server/recall_health.py +87 -10
- package/src/superlocalmemory/server/unified_daemon.py +55 -2
- package/src/superlocalmemory/storage/_migration_internals.py +23 -2
- package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +60 -36
- 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.
|
|
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.
|
|
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 & 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> · MCP: add <code>slm_compress</code> to your config · 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.
|
|
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
|
|
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.
|
|
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;
|
|
313
|
+
quality must be evaluated for the target client and workload; SLM publishes no universal p99 claim.
|
|
318
314
|
|
|
319
|
-
**Multi-scope memory (
|
|
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
|
|
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
|
-
##
|
|
460
|
+
## Editor plugins
|
|
465
461
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
-
|
|
472
|
-
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
-
|
|
483
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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
|
-
|
|
495
|
+
claude plugin marketplace update qualixar
|
|
496
|
+
claude plugin update superlocalmemory@qualixar
|
|
509
497
|
```
|
|
510
498
|
|
|
511
|
-
|
|
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.
|
|
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",
|
package/plugin/CLAUDE.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- BEGIN SuperLocalMemory v4.1.
|
|
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.
|
|
42
|
+
<!-- END SuperLocalMemory v4.1.2 -->
|
|
43
43
|
|
|
44
|
-
SuperLocalMemory v4.1.
|
|
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.
|
|
80
|
+
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.
|
|
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.
|
|
44
|
+
SuperLocalMemory v4.1.2 · Qualixar · AGPL-3.0-or-later
|
package/plugin/requirements.txt
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
superlocalmemory==4.1.
|
|
1
|
+
superlocalmemory==4.1.2
|
|
@@ -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.
|
|
140
|
+
SuperLocalMemory v4.1.2 · Qualixar · AGPL-3.0-or-later
|
package/pyproject.toml
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
174
|
-
#
|
|
175
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
413
|
-
"""
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
|
423
|
+
return ""
|
|
421
424
|
if not _has_column(conn, "atomic_facts", "quarantined"):
|
|
422
|
-
return
|
|
425
|
+
return "atomic_facts has no 'quarantined' column"
|
|
423
426
|
if not _table_exists(conn, "consolidated_summaries"):
|
|
424
|
-
return
|
|
425
|
-
|
|
427
|
+
return "the consolidated_summaries display table is missing"
|
|
426
428
|
if _table_exists(conn, "fact_consolidations"):
|
|
427
|
-
|
|
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
|
|
433
|
-
return
|
|
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
|
|
461
|
-
return
|
|
462
|
-
|
|
446
|
+
if n:
|
|
447
|
+
return f"{n} withheld summaries have no display copy"
|
|
463
448
|
if _table_exists(conn, "fact_retention"):
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
|