superlocalmemory 3.8.11 → 3.8.12

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 (48) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +7 -3
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/CLAUDE.md +3 -3
  6. package/plugin/agents/slm-governance-advisor.md +1 -1
  7. package/plugin/agents/slm-loop-runner.md +1 -1
  8. package/plugin/agents/slm-memory-advisor.md +1 -1
  9. package/plugin/agents/slm-optimize-advisor.md +1 -1
  10. package/plugin/requirements.txt +1 -1
  11. package/plugin/skills/slm-cache/SKILL.md +1 -1
  12. package/plugin/skills/slm-compress/SKILL.md +1 -1
  13. package/plugin/skills/slm-governance/SKILL.md +1 -1
  14. package/plugin/skills/slm-graph/SKILL.md +1 -1
  15. package/plugin/skills/slm-loop/SKILL.md +1 -1
  16. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  17. package/plugin/skills/slm-profile/SKILL.md +1 -1
  18. package/plugin/skills/slm-recall/SKILL.md +1 -1
  19. package/plugin/skills/slm-remember/SKILL.md +1 -1
  20. package/plugin/skills/slm-scope/SKILL.md +1 -1
  21. package/plugin/skills/slm-session/SKILL.md +1 -1
  22. package/plugin/skills/slm-status/SKILL.md +1 -1
  23. package/plugin-src/rules/AGENTS.md +1 -1
  24. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  25. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  31. package/pyproject.toml +1 -1
  32. package/src/superlocalmemory/__init__.py +1 -1
  33. package/src/superlocalmemory/cli/commands.py +15 -3
  34. package/src/superlocalmemory/cli/daemon.py +219 -10
  35. package/src/superlocalmemory/cli/setup_wizard.py +45 -1
  36. package/src/superlocalmemory/core/component_registry.py +25 -0
  37. package/src/superlocalmemory/core/config.py +35 -1
  38. package/src/superlocalmemory/core/engine_wiring.py +81 -5
  39. package/src/superlocalmemory/core/recall_pipeline.py +25 -4
  40. package/src/superlocalmemory/core/reranker_worker.py +23 -4
  41. package/src/superlocalmemory/infra/daemon_identity.py +16 -0
  42. package/src/superlocalmemory/infra/process_identity.py +180 -0
  43. package/src/superlocalmemory/learning/feedback.py +288 -29
  44. package/src/superlocalmemory/learning/legacy_migration.py +45 -4
  45. package/src/superlocalmemory/mcp/_daemon_proxy.py +23 -1
  46. package/src/superlocalmemory/mcp/tools_active.py +109 -58
  47. package/src/superlocalmemory/mcp/tools_core.py +6 -5
  48. package/src/superlocalmemory/retrieval/remote_reranker.py +636 -0
package/CHANGELOG.md CHANGED
@@ -5,6 +5,61 @@ All notable changes to SuperLocalMemory V3 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
+ ## [3.8.12] - 2026-08-03 — Canonical learning signals, clock-independent daemon identity, remote reranker
9
+
10
+ ### Fixed
11
+ - Explicit feedback now reaches the store every consumer actually reads.
12
+ Three tables carry a "feedback" name: `feedback_records` (memory.db, read by
13
+ nothing), `learning_feedback` (the pre-v3.4.22 legacy table that
14
+ `legacy_migration` copies forward), and `learning_signals` +
15
+ `learning_features` (canonical — read by the dashboard Living Brain panel,
16
+ the ranker-phase card, and the LightGBM retrainer). The 3.8.11 fix wrote to
17
+ the legacy table, so it moved a counter nothing consumes. `record_explicit`
18
+ now writes the legacy row and the canonical signal/feature pair in one
19
+ transaction, flagged `is_synthetic=1` so the LightGBM trainer's
20
+ `WHERE is_synthetic=0` filter excludes them from training. (#106)
21
+ - The recall phase gate and the dashboard no longer read different tables.
22
+ The gate counted `learning_feedback` while every user-visible surface counted
23
+ `learning_signals`, so the phase a user was shown and the phase that actually
24
+ ranked their results could disagree without limit. Both now resolve from the
25
+ canonical store, with thresholds imported from `learning.ranker` rather than
26
+ duplicated as literals — duplicated literals are how the two surfaces drifted
27
+ apart in the first place. (#106)
28
+ - `report_feedback` no longer reports success for a write that did not happen.
29
+ It fell back to the `feedback_records` count when the canonical read failed,
30
+ and that table increments on every call regardless, so total write failure was
31
+ indistinguishable from success. The cross-store fallback is removed; a failed
32
+ durable write returns `success: false` with `durable: false`. (#106)
33
+ - The CLI no longer disowns a healthy daemon after a few minutes on WSL2.
34
+ psutil derives `create_time` as boot time plus start ticks and re-reads
35
+ `/proc/stat` `btime` on every call; WSL2 resyncs its VM clock mid-session, so
36
+ `btime` moves and every process's computed creation time moves with it,
37
+ retroactively. The recorded value stopped matching the same live PID while
38
+ `/health` still answered in milliseconds. Ownership now compares a
39
+ clock-independent process token — `boot_id` plus raw start ticks on
40
+ Linux/WSL2, psutil's monotonic creation time elsewhere — as exact equality,
41
+ with no tolerance constant left to silently expire. PID-reuse protection is
42
+ strengthened: a creation-time mismatch no longer condemns a process outright,
43
+ it falls through to cryptographic identity proof over loopback. (#104)
44
+ - `DAEMON_UNAVAILABLE` now names one of eight specific reasons with an
45
+ actionable hint instead of "owned daemon is unavailable; retry later". (#104)
46
+ - `slm setup` no longer wipes the `retrieval` config block on every re-run.
47
+
48
+ ### Added
49
+ - Remote and custom reranker endpoints, mirroring the remote embedding support
50
+ added in v3.4.24. Setting `cross_encoder_backend` to `openai` (or `remote`)
51
+ with a `cross_encoder_endpoint` routes reranking to an OpenAI-compatible
52
+ `/v1/rerank` service instead of the local subprocess worker. The built-in
53
+ cross-encoder is English-only, silently degrading recall for non-English
54
+ users; this lets them bring a multilingual model. Failure degrades to fusion
55
+ order with `applied=False` and an error log — never a silent fall back to the
56
+ local English model, which would recreate the very problem this solves. The
57
+ new outbound HTTP surface enforces an http/https allow-list, rejects 3xx and
58
+ does not follow redirects, refuses credentials embedded in the URL, and
59
+ bounds response reads at 8 MB. (#105)
60
+ - `cross_encoder_endpoint` is now a real config field. It was previously
61
+ accepted and silently ignored — the remaining half of #103.
62
+
8
63
  ## [3.8.11] - 2026-08-02 — Learning-signal integrity and honest reranker diagnostics
9
64
 
10
65
  ### Fixed
package/README.md CHANGED
@@ -5,15 +5,15 @@
5
5
  </picture>
6
6
  </p>
7
7
 
8
- <h1 align="center">SuperLocalMemory V3.8.11</h1>
8
+ <h1 align="center">SuperLocalMemory V3.8.12</h1>
9
9
  <p align="center"><strong>Enterprise-grade, local-first memory for AI agents and teams.</strong><br/>
10
10
  <em>A persistent, auditable long-term brain for your agents that runs on your own infrastructure — with multi-workspace isolation, role-based access, and GDPR + EU AI Act governance controls built in.</em></p>
11
- <p align="center"><code>v3.8.11</code> — one control plane: auditable retrieval · multi-scope memory (personal / shared / global) · Cache · Compress · trusted-peer Mesh · bounded loops — across CLI, MCP, dashboard, the <strong>Claude plugin</strong>, the <strong>Codex add-on</strong>, and documented IDE integrations.<br/>
11
+ <p align="center"><code>v3.8.12</code> — one control plane: auditable retrieval · multi-scope memory (personal / shared / global) · Cache · Compress · trusted-peer Mesh · bounded loops — across CLI, MCP, dashboard, the <strong>Claude plugin</strong>, the <strong>Codex add-on</strong>, and documented IDE integrations.<br/>
12
12
  Proxy: <code>slm wrap claude</code> &nbsp;·&nbsp; MCP: add <code>slm_compress</code> to your config &nbsp;·&nbsp; Skill: zero-config</p>
13
13
  <p align="center"><strong>3 public research preprints</strong> (arXiv + Zenodo archives) · <a href="https://arxiv.org/abs/2603.02240">arXiv:2603.02240</a> · <a href="https://arxiv.org/abs/2603.14588">arXiv:2603.14588</a> · <a href="https://arxiv.org/abs/2604.04514">arXiv:2604.04514</a></p>
14
14
 
15
15
  <p align="center">
16
- <a href="CHANGELOG.md"><img src="https://img.shields.io/badge/v3.8.11-Current_Release-2ea44f?style=for-the-badge&logo=checkmarx&logoColor=white" alt="v3.8.11 — Current Release"/></a>
16
+ <a href="CHANGELOG.md"><img src="https://img.shields.io/badge/v3.8.12-Current_Release-2ea44f?style=for-the-badge&logo=checkmarx&logoColor=white" alt="v3.8.12 — Current Release"/></a>
17
17
  <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>
18
18
  <a href="#three-surfaces-proxy--mcp-tools--skill"><img src="https://img.shields.io/badge/Proxy_|_MCP_|_Skill-22c55e?style=for-the-badge" alt="Three Surfaces: Proxy, MCP Tools, Skill"/></a>
19
19
  <a href="https://pypi.org/project/superlocalmemory/"><img src="https://img.shields.io/pypi/v/superlocalmemory?style=for-the-badge&logo=pypi&logoColor=white" alt="PyPI"/></a>
@@ -294,6 +294,10 @@ quality must be evaluated for the target client and workload; V3.8.0 publishes n
294
294
 
295
295
  **Multilingual models:** configure an OpenAI-compatible embedding endpoint such as Ollama, vLLM, LiteLLM, `bge-m3`, `multilingual-e5`, or `Qwen3-Embedding`. Language coverage and retrieval quality depend on the selected model and should be evaluated for the deployment corpus.
296
296
 
297
+ <a id="remote-embedding-and-rerank-endpoints"></a>
298
+
299
+ **Remote embedding + rerank endpoints.** The bundled reranker `cross-encoder/ms-marco-MiniLM-L-12-v2` is **English-only**, so a Chinese, Japanese, or Arabic corpus is scored by a model that cannot read it. Set `retrieval.cross_encoder_backend: "openai"` plus `retrieval.cross_encoder_endpoint` to route reranking to any Cohere-shaped `POST /v1/rerank` service — llama-server, text-embeddings-inference, Infinity — running a multilingual model such as `BAAI/bge-reranker-v2-m3` (v3.8.12, [#105](https://github.com/qualixar/superlocalmemory/issues/105); the same escape hatch embeddings got in v3.4.24, [#16](https://github.com/qualixar/superlocalmemory/issues/16)). No subprocess and no local model download. An unreachable, slow, or malformed endpoint logs an error and returns fusion-ranked results — SLM never silently substitutes the local English model. Keys, auth, and failure semantics: **[docs/configuration.md](docs/configuration.md#remote-embedding-and-rerank-endpoints)**.
300
+
297
301
  ### Cache + Compress
298
302
 
299
303
  <a id="three-surfaces-proxy--mcp-tools--skill"></a>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "superlocalmemory",
3
- "version": "3.8.11",
3
+ "version": "3.8.12",
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",
@@ -15,5 +15,5 @@
15
15
  "mcpServers": "./.mcp.json",
16
16
  "name": "superlocalmemory",
17
17
  "repository": "https://github.com/qualixar/superlocalmemory",
18
- "version": "3.8.11"
18
+ "version": "3.8.12"
19
19
  }
package/plugin/CLAUDE.md CHANGED
@@ -1,4 +1,4 @@
1
- <!-- BEGIN SuperLocalMemory v3.8.11 -->
1
+ <!-- BEGIN SuperLocalMemory v3.8.12 -->
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 v3.8.11 -->
42
+ <!-- END SuperLocalMemory v3.8.12 -->
43
43
 
44
- SuperLocalMemory v3.8.11 · Qualixar · AGPL-3.0-or-later
44
+ SuperLocalMemory v3.8.12 · 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 v3.8.11 · Qualixar · AGPL-3.0-or-later
80
+ SuperLocalMemory v3.8.12 · Qualixar · AGPL-3.0-or-later
@@ -68,4 +68,4 @@ assessment. The gate is the authority.
68
68
 
69
69
  ---
70
70
 
71
- SuperLocalMemory v3.8.11 · Qualixar · AGPL-3.0-or-later
71
+ SuperLocalMemory v3.8.12 · 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 v3.8.11 · Qualixar · AGPL-3.0-or-later
49
+ SuperLocalMemory v3.8.12 · 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 v3.8.11 · Qualixar · AGPL-3.0-or-later
44
+ SuperLocalMemory v3.8.12 · Qualixar · AGPL-3.0-or-later
@@ -1 +1 @@
1
- superlocalmemory==3.8.11
1
+ superlocalmemory==3.8.12
@@ -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 v3.8.11 · Qualixar · AGPL-3.0-or-later
148
+ SuperLocalMemory v3.8.12 · 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 v3.8.11 · Qualixar · AGPL-3.0-or-later
150
+ SuperLocalMemory v3.8.12 · 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 v3.8.11 · Qualixar · AGPL-3.0-or-later*
248
+ *SuperLocalMemory v3.8.12 · Qualixar · AGPL-3.0-or-later*
@@ -311,4 +311,4 @@ profile. See `slm-profile` for the full profile switching workflow.
311
311
 
312
312
  ---
313
313
 
314
- SuperLocalMemory v3.8.11 · Qualixar · AGPL-3.0-or-later
314
+ SuperLocalMemory v3.8.12 · 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 v3.8.11 · Qualixar · AGPL-3.0-or-later
99
+ SuperLocalMemory v3.8.12 · Qualixar · AGPL-3.0-or-later
@@ -279,4 +279,4 @@ mesh availability.
279
279
 
280
280
  ---
281
281
 
282
- *SuperLocalMemory v3.8.11 · Qualixar · AGPL-3.0-or-later*
282
+ *SuperLocalMemory v3.8.12 · Qualixar · AGPL-3.0-or-later*
@@ -145,4 +145,4 @@ Name them differently in your MCP config (e.g. `superlocalmemory-personal` and
145
145
 
146
146
  ---
147
147
 
148
- *SuperLocalMemory v3.8.11 · Qualixar · AGPL-3.0-or-later*
148
+ *SuperLocalMemory v3.8.12 · Qualixar · AGPL-3.0-or-later*
@@ -236,4 +236,4 @@ before recalling, then switch back. See `slm-profile` for workspace switching.
236
236
 
237
237
  ---
238
238
 
239
- *SuperLocalMemory v3.8.11 · Qualixar · AGPL-3.0-or-later*
239
+ *SuperLocalMemory v3.8.12 · Qualixar · AGPL-3.0-or-later*
@@ -238,4 +238,4 @@ different workspace, use `switch_profile` first. See `slm-profile`.
238
238
 
239
239
  ---
240
240
 
241
- *SuperLocalMemory v3.8.11 · Qualixar · AGPL-3.0-or-later*
241
+ *SuperLocalMemory v3.8.12 · 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 v3.8.11 · Qualixar · AGPL-3.0-or-later*
176
+ *SuperLocalMemory v3.8.12 · Qualixar · AGPL-3.0-or-later*
@@ -227,4 +227,4 @@ explicitly and call `recall` with `include_global`/`include_shared` after
227
227
 
228
228
  ---
229
229
 
230
- *SuperLocalMemory v3.8.11 · Qualixar · AGPL-3.0-or-later*
230
+ *SuperLocalMemory v3.8.12 · 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 v3.8.11 · Qualixar · AGPL-3.0-or-later
166
+ SuperLocalMemory v3.8.12 · Qualixar · AGPL-3.0-or-later
@@ -128,4 +128,4 @@ When the SLM MCP server is unavailable, use these CLI equivalents:
128
128
  - **slm-optimize-advisor** — context compression and KV cache
129
129
  - **slm-governance-advisor** — scope/role compliance, retention policies, GDPR
130
130
 
131
- SuperLocalMemory v3.8.11 · Qualixar · AGPL-3.0-or-later
131
+ SuperLocalMemory v3.8.12 · 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 v3.8.11 · Qualixar · AGPL-3.0-or-later
148
+ SuperLocalMemory v3.8.12 · 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 v3.8.11 · Qualixar · AGPL-3.0-or-later
150
+ SuperLocalMemory v3.8.12 · Qualixar · AGPL-3.0-or-later
@@ -311,4 +311,4 @@ profile. See `slm-profile` for the full profile switching workflow.
311
311
 
312
312
  ---
313
313
 
314
- SuperLocalMemory v3.8.11 · Qualixar · AGPL-3.0-or-later
314
+ SuperLocalMemory v3.8.12 · Qualixar · AGPL-3.0-or-later
@@ -236,4 +236,4 @@ before recalling, then switch back. See `slm-profile` for workspace switching.
236
236
 
237
237
  ---
238
238
 
239
- *SuperLocalMemory v3.8.11 · Qualixar · AGPL-3.0-or-later*
239
+ *SuperLocalMemory v3.8.12 · Qualixar · AGPL-3.0-or-later*
@@ -238,4 +238,4 @@ different workspace, use `switch_profile` first. See `slm-profile`.
238
238
 
239
239
  ---
240
240
 
241
- *SuperLocalMemory v3.8.11 · Qualixar · AGPL-3.0-or-later*
241
+ *SuperLocalMemory v3.8.12 · Qualixar · AGPL-3.0-or-later*
@@ -227,4 +227,4 @@ explicitly and call `recall` with `include_global`/`include_shared` after
227
227
 
228
228
  ---
229
229
 
230
- *SuperLocalMemory v3.8.11 · Qualixar · AGPL-3.0-or-later*
230
+ *SuperLocalMemory v3.8.12 · 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 v3.8.11 · Qualixar · AGPL-3.0-or-later
166
+ SuperLocalMemory v3.8.12 · Qualixar · AGPL-3.0-or-later
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "superlocalmemory"
3
- version = "3.8.11"
3
+ version = "3.8.12"
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__ = "3.8.11"
35
+ __version__ = "3.8.12"
36
36
 
37
37
  _REQUIRED_VERSIONS = {
38
38
  "sentence_transformers": "5.3.0",
@@ -22,10 +22,21 @@ logger = logging.getLogger(__name__)
22
22
 
23
23
 
24
24
  def _daemon_unavailable(command: str, use_json: bool) -> None:
25
- """Exit a mutation client without opening a process-local writer."""
25
+ """Exit a mutation client without opening a process-local writer.
26
+
27
+ The bare "owned daemon is unavailable" of earlier releases described a
28
+ stopped daemon, a recycled PID, an unreachable port and an identity
29
+ mismatch identically, which gave issue #104's reporter nothing to act on.
30
+ The diagnosis names the evidence and the next command to run.
31
+ """
32
+ from superlocalmemory.cli.daemon import describe_daemon_unavailability
33
+
34
+ diagnosis = describe_daemon_unavailability()
26
35
  error = {
27
36
  "code": "DAEMON_UNAVAILABLE",
28
- "message": "Owned daemon is unavailable; retry later.",
37
+ "reason": diagnosis["reason"],
38
+ "message": f"Owned daemon is unavailable: {diagnosis['message']}",
39
+ "hint": diagnosis["hint"],
29
40
  "retryable": True,
30
41
  }
31
42
  if use_json:
@@ -34,7 +45,8 @@ def _daemon_unavailable(command: str, use_json: bool) -> None:
34
45
  json_print(command, error=error)
35
46
  else:
36
47
  print(
37
- "DAEMON_UNAVAILABLE: owned daemon is unavailable; retry later.",
48
+ f"DAEMON_UNAVAILABLE ({diagnosis['reason']}): "
49
+ f"{diagnosis['message']} {diagnosis['hint']}",
38
50
  file=sys.stderr,
39
51
  )
40
52
  raise SystemExit(1)
@@ -41,6 +41,10 @@ from superlocalmemory.infra.data_root import (
41
41
  assert_no_durable_root_conflict,
42
42
  state_path,
43
43
  )
44
+ from superlocalmemory.infra.process_identity import (
45
+ compare_start_tokens,
46
+ process_start_token_for,
47
+ )
44
48
 
45
49
  logger = logging.getLogger(__name__)
46
50
 
@@ -72,25 +76,91 @@ def _is_pid_alive(pid: int) -> bool:
72
76
  return False
73
77
 
74
78
 
75
- def _descriptor_process_is_alive(descriptor) -> bool:
76
- """Reject stale descriptors when a PID has been reused by another process."""
77
- if not _is_pid_alive(descriptor.pid):
79
+ _CREATE_TIME_TOLERANCE_SECONDS = 1.0
80
+
81
+
82
+ def _health_proves_descriptor_ownership(descriptor) -> bool:
83
+ """Return whether the live health endpoint proves this exact daemon.
84
+
85
+ This is a *stronger* ownership proof than any process-table comparison. To
86
+ pass, a process listening on the descriptor's port must echo the random
87
+ 128-bit ``instance_id`` and the SHA-256 fingerprint of the 256-bit
88
+ capability token -- both of which exist only inside the mode-0600
89
+ ``daemon.json`` -- alongside its own PID, namespace, owner and port. A
90
+ process that merely inherited a recycled PID cannot produce any of that.
91
+ """
92
+ health = _fetch_health(descriptor.port)
93
+ if health is None:
78
94
  return False
95
+ return descriptor_matches_health(descriptor, health)
96
+
97
+
98
+ def _resolve_descriptor_liveness(descriptor) -> tuple[bool, str]:
99
+ """Return ``(is_alive, evidence)`` for the descriptor's recorded process.
100
+
101
+ Ownership is decided by the strongest available evidence, never by the
102
+ wall clock alone:
103
+
104
+ 1. The PID must exist and must not be a zombie.
105
+ 2. A clock-independent start token settles it exactly, with no tolerance.
106
+ This is the path that fixes issue #104: under WSL2 the boot time behind
107
+ ``psutil.create_time`` drifts against the wall clock during a session,
108
+ so a recorded creation time stops matching the *same* live process
109
+ (~35s after ~4 minutes). A start token cannot drift, so no tolerance
110
+ constant is needed and none can silently expire.
111
+ 3. Otherwise fall back to comparing creation times, for descriptors written
112
+ by an older release and for platforms with no token (Windows, where the
113
+ kernel creation time is already immune to clock adjustment).
114
+ 4. A creation-time mismatch is *not* proof of PID reuse -- it is exactly
115
+ what a stepped clock looks like -- so before condemning a running
116
+ daemon, ask the daemon to prove its identity over loopback. Only if that
117
+ cryptographic proof also fails is the process declared foreign.
118
+ """
119
+ if not _is_pid_alive(descriptor.pid):
120
+ return False, "process_exited"
79
121
  try:
80
122
  import psutil
81
-
123
+ except ImportError:
124
+ # Without psutil, PID existence is the only signal there is.
125
+ return True, "pid_exists_without_psutil"
126
+ try:
82
127
  process = psutil.Process(descriptor.pid)
83
128
  # A terminated daemon can remain in the process table briefly as a
84
129
  # zombie while its parent reaps it. PID existence is therefore not
85
130
  # liveness and must not block a namespace-owned restart.
86
131
  if not process.is_running() or process.status() == psutil.STATUS_ZOMBIE:
87
- return False
88
- actual = float(process.create_time())
89
- except ImportError:
90
- return True
132
+ return False, "process_zombie"
133
+ actual_create_time = float(process.create_time())
91
134
  except Exception:
92
- return False
93
- return abs(actual - float(descriptor.process_create_time)) <= 1.0
135
+ return False, "process_unreadable"
136
+
137
+ recorded_token = getattr(descriptor, "process_start_token", None)
138
+ if recorded_token:
139
+ verdict = compare_start_tokens(
140
+ recorded_token, process_start_token_for(descriptor.pid),
141
+ )
142
+ if verdict is True:
143
+ return True, "start_token_match"
144
+ if verdict is False:
145
+ return False, "start_token_mismatch"
146
+
147
+ drift = abs(actual_create_time - float(descriptor.process_create_time))
148
+ if drift <= _CREATE_TIME_TOLERANCE_SECONDS:
149
+ return True, "create_time_match"
150
+
151
+ if _health_proves_descriptor_ownership(descriptor):
152
+ logger.debug(
153
+ "descriptor creation time drifted by %.3fs for pid %s; owned "
154
+ "daemon confirmed by health identity instead",
155
+ drift, descriptor.pid,
156
+ )
157
+ return True, "health_identity_match"
158
+ return False, "identity_mismatch"
159
+
160
+
161
+ def _descriptor_process_is_alive(descriptor) -> bool:
162
+ """Reject stale descriptors when a PID has been reused by another process."""
163
+ return _resolve_descriptor_liveness(descriptor)[0]
94
164
 
95
165
 
96
166
  def _is_port_available(port: int) -> bool:
@@ -420,6 +490,7 @@ def _start_daemon_subprocess() -> bool:
420
490
  bootstrap_descriptor,
421
491
  pid=proc.pid,
422
492
  process_create_time=process_create_time_for(proc.pid),
493
+ process_start_token=process_start_token_for(proc.pid),
423
494
  )
424
495
  current = read_descriptor()
425
496
  if not (
@@ -545,6 +616,144 @@ def _wait_for_daemon(timeout: int = 60) -> bool:
545
616
  return False
546
617
 
547
618
 
619
+ _GENERIC_UNAVAILABLE = {
620
+ "reason": "unknown",
621
+ "message": "Owned daemon is unavailable; retry later.",
622
+ "hint": "Run `slm doctor`, then `slm restart` if it stays down.",
623
+ }
624
+
625
+ _LIVENESS_DIAGNOSIS = {
626
+ "process_exited": (
627
+ "daemon_process_exited",
628
+ "the recorded daemon process (pid {pid}) is no longer running",
629
+ "Start it again with `slm start`.",
630
+ ),
631
+ "process_zombie": (
632
+ "daemon_process_exited",
633
+ "the recorded daemon process (pid {pid}) has exited and is awaiting "
634
+ "reaping by its parent",
635
+ "Start it again with `slm start`.",
636
+ ),
637
+ "process_unreadable": (
638
+ "daemon_process_unreadable",
639
+ "the recorded daemon process (pid {pid}) could not be inspected; it "
640
+ "may belong to another user",
641
+ "Run `slm restart` to publish a fresh descriptor.",
642
+ ),
643
+ "start_token_mismatch": (
644
+ "pid_reused_by_another_process",
645
+ "pid {pid} is alive but is a different process than the daemon that "
646
+ "wrote {path}; the daemon exited and its pid was recycled",
647
+ "Run `slm restart` to publish a fresh descriptor.",
648
+ ),
649
+ "identity_mismatch": (
650
+ "daemon_identity_mismatch",
651
+ "pid {pid} did not match the process identity recorded in {path} and "
652
+ "the process on port {port} did not prove it owns that identity; the "
653
+ "recorded creation time can also diverge on its own if this machine's "
654
+ "clock is stepped (common under WSL2)",
655
+ "Run `slm restart` to publish a fresh descriptor.",
656
+ ),
657
+ }
658
+
659
+
660
+ def describe_daemon_unavailability() -> dict[str, str]:
661
+ """Explain *why* the owned daemon cannot be used, in actionable terms.
662
+
663
+ "Owned daemon is unavailable" is true of a stopped daemon, a recycled PID,
664
+ an unreachable port and an identity mismatch alike, which left issue #104's
665
+ reporter with nothing to act on. This names the specific evidence instead.
666
+ Diagnosis is best-effort and never raises: a broken diagnosis must not
667
+ replace the caller's real error.
668
+ """
669
+ try:
670
+ return _describe_daemon_unavailability()
671
+ except Exception: # noqa: BLE001 - diagnosis is advisory only
672
+ return dict(_GENERIC_UNAVAILABLE)
673
+
674
+
675
+ def _describe_daemon_unavailability() -> dict[str, str]:
676
+ path = descriptor_path()
677
+ descriptor = read_descriptor()
678
+ if descriptor is None:
679
+ if path.exists():
680
+ return {
681
+ "reason": "descriptor_unusable",
682
+ "message": (
683
+ f"{path} is unreadable, malformed, or belongs to another "
684
+ f"data root or user."
685
+ ),
686
+ "hint": "Run `slm restart` to publish a fresh descriptor.",
687
+ }
688
+ if _verified_legacy_health() is not None:
689
+ return {
690
+ "reason": "legacy_daemon_request_failed",
691
+ "message": (
692
+ "a pre-descriptor daemon answered health but rejected or "
693
+ "dropped the request."
694
+ ),
695
+ "hint": "Run `slm restart` to upgrade it to an owned daemon.",
696
+ }
697
+ return {
698
+ "reason": "no_daemon",
699
+ "message": f"no daemon is registered for this data root ({path} is absent).",
700
+ "hint": "Run `slm start`.",
701
+ }
702
+
703
+ alive, evidence = _resolve_descriptor_liveness(descriptor)
704
+ if not alive:
705
+ reason, template, hint = _LIVENESS_DIAGNOSIS.get(
706
+ evidence,
707
+ (
708
+ "daemon_identity_mismatch",
709
+ "pid {pid} did not match the identity recorded in {path}",
710
+ "Run `slm restart` to publish a fresh descriptor.",
711
+ ),
712
+ )
713
+ return {
714
+ "reason": reason,
715
+ "message": template.format(
716
+ pid=descriptor.pid, port=descriptor.port, path=path,
717
+ ) + ".",
718
+ "hint": hint,
719
+ }
720
+
721
+ health = _fetch_health(descriptor.port)
722
+ if health is None:
723
+ return {
724
+ "reason": "daemon_unreachable",
725
+ "message": (
726
+ f"the owned daemon (pid {descriptor.pid}) is running but did "
727
+ f"not answer http://127.0.0.1:{descriptor.port}/health within "
728
+ f"2s."
729
+ ),
730
+ "hint": (
731
+ "Check `slm logs` for a stalled request, or `slm restart` if "
732
+ "it stays unresponsive."
733
+ ),
734
+ }
735
+ if not descriptor_matches_health(descriptor, health):
736
+ return {
737
+ "reason": "port_owned_by_another_daemon",
738
+ "message": (
739
+ f"port {descriptor.port} answered health but with a different "
740
+ f"daemon identity than {path} records."
741
+ ),
742
+ "hint": (
743
+ "Another SuperLocalMemory instance holds that port. Stop it, "
744
+ "or set SLM_DAEMON_PORT to a free port."
745
+ ),
746
+ }
747
+ return {
748
+ "reason": "request_rejected",
749
+ "message": (
750
+ f"the owned daemon (pid {descriptor.pid}) is healthy but rejected "
751
+ f"or dropped this request."
752
+ ),
753
+ "hint": "Check `slm logs` for the failing request.",
754
+ }
755
+
756
+
548
757
  def stop_daemon() -> bool:
549
758
  """Stop only the daemon proven to belong to this data namespace.
550
759