superlocalmemory 4.0.9 → 4.0.10
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/CHANGELOG.md +75 -0
- package/README.md +3 -3
- package/package.json +1 -1
- 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/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +45 -2
- package/src/superlocalmemory/cli/main.py +2 -2
- package/src/superlocalmemory/code_graph/bridge/maintenance.py +8 -0
- package/src/superlocalmemory/core/fact_consolidator.py +316 -125
- package/src/superlocalmemory/core/maintenance.py +44 -6
- package/src/superlocalmemory/core/memory_health.py +266 -0
- package/src/superlocalmemory/core/operation_policy_registry.py +1 -1
- package/src/superlocalmemory/core/operation_request.py +1 -1
- package/src/superlocalmemory/core/ops_remediation.py +2 -2
- package/src/superlocalmemory/core/store_pipeline.py +78 -3
- package/src/superlocalmemory/encoding/cognitive_consolidator.py +15 -1
- package/src/superlocalmemory/mcp/server.py +1 -1
- package/src/superlocalmemory/mcp/session_binding.py +92 -0
- package/src/superlocalmemory/mcp/tools_core.py +40 -39
- package/src/superlocalmemory/mcp/tools_ops.py +2 -2
- package/src/superlocalmemory/retrieval/bm25_channel.py +4 -8
- package/src/superlocalmemory/retrieval/entity_channel.py +7 -1
- package/src/superlocalmemory/retrieval/scope_policy.py +22 -1
- package/src/superlocalmemory/retrieval/temporal_channel.py +13 -1
- package/src/superlocalmemory/retrieval/vector_store.py +63 -0
- package/src/superlocalmemory/server/api.py +6 -1
- package/src/superlocalmemory/server/asset_versions.py +171 -0
- package/src/superlocalmemory/server/routes/abstraction.py +201 -0
- package/src/superlocalmemory/server/routes/data_io.py +29 -1
- package/src/superlocalmemory/server/routes/entity.py +13 -1
- package/src/superlocalmemory/server/routes/mesh.py +1 -1
- package/src/superlocalmemory/server/routes/v3_api.py +2 -2
- package/src/superlocalmemory/server/ui.py +8 -1
- package/src/superlocalmemory/server/unified_daemon.py +111 -9
- package/src/superlocalmemory/storage/_migration_internals.py +4 -0
- package/src/superlocalmemory/storage/database.py +128 -30
- package/src/superlocalmemory/storage/migration_runner.py +11 -0
- package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +488 -0
- package/src/superlocalmemory/storage/schema.py +98 -0
- package/src/superlocalmemory/summaries/base.py +1 -1
- package/src/superlocalmemory/summaries/non_answer.py +223 -0
- package/src/superlocalmemory/ui/index.html +1 -1
- package/src/superlocalmemory/ui/js/od-memories.js +190 -1
- package/src/superlocalmemory/ui/js/od-ops-health.js +1 -1
|
@@ -9,6 +9,8 @@ down to source atoms:
|
|
|
9
9
|
|
|
10
10
|
GET /api/v3/abstraction/persona — the per-profile persona roll-up
|
|
11
11
|
GET /api/v3/abstraction/communities — community summaries (Q2)
|
|
12
|
+
GET /api/v3/abstraction/consolidated — display-only cluster summaries
|
|
13
|
+
GET /api/v3/abstraction/health — can my memories be found? (4.0.10)
|
|
12
14
|
GET /api/v3/abstraction/sources — drill-down (node -> source atoms)
|
|
13
15
|
|
|
14
16
|
Read-only, profile-scoped (Rule 01), direct sqlite3 (Rule 06). All handlers
|
|
@@ -30,6 +32,31 @@ logger = logging.getLogger(__name__)
|
|
|
30
32
|
|
|
31
33
|
router = APIRouter(prefix="/api/v3/abstraction", tags=["abstraction"])
|
|
32
34
|
|
|
35
|
+
#: How many summary rows /consolidated will read before ranking them by
|
|
36
|
+
#: quality. Bounded because this runs on a request thread: a store with tens of
|
|
37
|
+
#: thousands of summaries must not turn one card into a full-table scan.
|
|
38
|
+
_SCAN_CEILING = 400
|
|
39
|
+
|
|
40
|
+
#: Characters of normalised opening text that make two summaries "the same
|
|
41
|
+
#: summary" for display. Long enough that two genuinely different subjects
|
|
42
|
+
#: diverge within it, short enough to catch the same sentence with a different
|
|
43
|
+
#: tail — which is the shape the summarizer actually produces.
|
|
44
|
+
_OPENING_KEY_CHARS = 90
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _opening_key(content: object) -> str:
|
|
48
|
+
"""Normalised opening of a summary, for near-duplicate collapsing.
|
|
49
|
+
|
|
50
|
+
Case-folded with runs of whitespace flattened, so two summaries differing
|
|
51
|
+
only in line wrapping or capitalisation collapse together. Returns "" for
|
|
52
|
+
anything too short to judge, which is then never collapsed — better to show
|
|
53
|
+
a duplicate than to hide a distinct summary on a weak signal.
|
|
54
|
+
"""
|
|
55
|
+
text = " ".join(str(content or "").split()).casefold()
|
|
56
|
+
if len(text) < 40:
|
|
57
|
+
return ""
|
|
58
|
+
return text[:_OPENING_KEY_CHARS]
|
|
59
|
+
|
|
33
60
|
|
|
34
61
|
class _ReadDB:
|
|
35
62
|
"""Adapt a raw sqlite3 connection to the .execute(...) -> list contract
|
|
@@ -111,3 +138,177 @@ def get_sources(
|
|
|
111
138
|
return JSONResponse({"profile": pid, "sources": empty})
|
|
112
139
|
finally:
|
|
113
140
|
conn.close()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@router.get("/consolidated")
|
|
144
|
+
def get_consolidated(
|
|
145
|
+
profile: str = Query(""),
|
|
146
|
+
limit: int = Query(50, ge=1, le=200),
|
|
147
|
+
include_unusable: bool = Query(False),
|
|
148
|
+
) -> JSONResponse:
|
|
149
|
+
"""Cluster summaries, read from the DISPLAY table and nowhere else.
|
|
150
|
+
|
|
151
|
+
``consolidated_summaries`` is the only source. Reading ``atomic_facts``
|
|
152
|
+
here would put the boundary back where it was: these summaries were in the
|
|
153
|
+
retrieval corpus until 4.0.10 and the whole point of moving them is that
|
|
154
|
+
exactly one surface shows them, and it is this one.
|
|
155
|
+
|
|
156
|
+
``summaries`` holds only rows worth reading. The rest are REPORTED, not
|
|
157
|
+
returned: ``unusable`` and ``near_duplicates`` are counts over the scanned
|
|
158
|
+
window. A reader is better served by "62 of these came back empty" than by a
|
|
159
|
+
page that silently shows a handful and looks complete — and hiding the fact
|
|
160
|
+
that they came back empty would hide the problem this endpoint exists to make
|
|
161
|
+
visible. ``include_unusable=true`` returns them for inspection.
|
|
162
|
+
|
|
163
|
+
Two orderings, both measured rather than chosen:
|
|
164
|
+
|
|
165
|
+
* Ranking by ``source_count`` alone put the junk on top, because the
|
|
166
|
+
summaries merging the largest clusters are exactly the ones the model had
|
|
167
|
+
least in common to work with. On the author's store **0 of the top 24 by
|
|
168
|
+
source_count were usable**, so a card asking for 24 rendered empty against
|
|
169
|
+
a store holding a thousand summaries.
|
|
170
|
+
* Rows covering real memories rank above rows covering none. 353 of these
|
|
171
|
+
are summaries of summaries; their honest ``source_count`` is 0, and a
|
|
172
|
+
digest of the summarizer's own output is worth less to a reader than a
|
|
173
|
+
digest of their own words.
|
|
174
|
+
|
|
175
|
+
Quality is a Python predicate rather than a SQL expression, which is why the
|
|
176
|
+
window is read to at most ``_SCAN_CEILING`` rows, classified, and then
|
|
177
|
+
ordered.
|
|
178
|
+
"""
|
|
179
|
+
pid = profile or get_active_profile()
|
|
180
|
+
conn = _conn()
|
|
181
|
+
if conn is None:
|
|
182
|
+
return JSONResponse({
|
|
183
|
+
"profile": pid, "summaries": [], "unusable": 0, "scanned": 0,
|
|
184
|
+
})
|
|
185
|
+
try:
|
|
186
|
+
from superlocalmemory.summaries.base import clean_llm_summary
|
|
187
|
+
from superlocalmemory.summaries.non_answer import (
|
|
188
|
+
MIN_USEFUL_CHARS,
|
|
189
|
+
is_non_answer,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
scan = min(_SCAN_CEILING, max(int(limit) * 8, int(limit)))
|
|
193
|
+
rows = conn.execute(
|
|
194
|
+
"SELECT summary_id, entity_name, content, source_count, "
|
|
195
|
+
" generated_by, source_earliest, source_latest, created_at "
|
|
196
|
+
" FROM consolidated_summaries "
|
|
197
|
+
" WHERE profile_id = ? "
|
|
198
|
+
" ORDER BY source_count DESC, created_at DESC, summary_id ASC "
|
|
199
|
+
" LIMIT ?",
|
|
200
|
+
(pid, scan),
|
|
201
|
+
).fetchall()
|
|
202
|
+
|
|
203
|
+
classified: list[dict[str, Any]] = []
|
|
204
|
+
unusable = 0
|
|
205
|
+
for row in rows:
|
|
206
|
+
item = dict(row)
|
|
207
|
+
# CLEAN, THEN JUDGE — the same order the write path uses, and for
|
|
208
|
+
# the same reason. Rows migrated from the old corpus were never
|
|
209
|
+
# cleaned, so their scaffolding is still attached: judging first let
|
|
210
|
+
# "Here is a concise summary paragraph incorporating all 10 facts..."
|
|
211
|
+
# through as usable and put it at the top of the card, because it
|
|
212
|
+
# does contain a summary and the non-answer rules are about refusals,
|
|
213
|
+
# not preambles. Cleaning is also what the reader should see: the
|
|
214
|
+
# scaffolding is addressed to a conversation they cannot read.
|
|
215
|
+
item["content"] = clean_llm_summary(str(item.get("content") or ""))
|
|
216
|
+
rejected, why = is_non_answer(
|
|
217
|
+
item["content"], min_chars=MIN_USEFUL_CHARS,
|
|
218
|
+
)
|
|
219
|
+
item["quality"] = why if rejected else "ok"
|
|
220
|
+
if rejected:
|
|
221
|
+
unusable += 1
|
|
222
|
+
classified.append(item)
|
|
223
|
+
|
|
224
|
+
# Usable first, then rows that cover real memories, then the SQL
|
|
225
|
+
# ordering within each group. Stable sort, so two runs of one request
|
|
226
|
+
# return the same rows in the same order — a summary card that
|
|
227
|
+
# reshuffles itself on refresh reads as a bug even when every row is
|
|
228
|
+
# correct.
|
|
229
|
+
classified.sort(key=lambda item: (
|
|
230
|
+
0 if item["quality"] == "ok" else 1,
|
|
231
|
+
0 if (item.get("source_count") or 0) > 0 else 1,
|
|
232
|
+
))
|
|
233
|
+
|
|
234
|
+
# Collapse near-duplicates.
|
|
235
|
+
#
|
|
236
|
+
# The table's UNIQUE constraint is on exact content, so summaries that
|
|
237
|
+
# differ by a clause survive as separate rows. On the author's store the
|
|
238
|
+
# first 24 usable rows all opened "The Pro and SuperLocalMemory (SLM)
|
|
239
|
+
# projects have made significant progress in..." — twenty-four cards
|
|
240
|
+
# saying one thing, which reads as a broken page rather than as a view
|
|
241
|
+
# of a memory.
|
|
242
|
+
#
|
|
243
|
+
# Collapsed on a normalised opening, keeping the row that merged the
|
|
244
|
+
# most memories (the ordering above already put it first). The count is
|
|
245
|
+
# reported, not swallowed: that these summaries repeat each other is a
|
|
246
|
+
# real property of the store and worth a reader knowing.
|
|
247
|
+
deduped: list[dict[str, Any]] = []
|
|
248
|
+
seen_openings: set[str] = set()
|
|
249
|
+
collapsed = 0
|
|
250
|
+
for item in classified:
|
|
251
|
+
key = _opening_key(item.get("content"))
|
|
252
|
+
if key and key in seen_openings:
|
|
253
|
+
collapsed += 1
|
|
254
|
+
continue
|
|
255
|
+
if key:
|
|
256
|
+
seen_openings.add(key)
|
|
257
|
+
deduped.append(item)
|
|
258
|
+
|
|
259
|
+
# Only rows worth reading occupy the window.
|
|
260
|
+
#
|
|
261
|
+
# A first draft returned everything, usable first, and truncated at
|
|
262
|
+
# `limit`. Because the usable rows on this store collapse to a handful
|
|
263
|
+
# of distinct openings, the tail of a limit-10 request filled with
|
|
264
|
+
# refusals — and a card asking for 10 got 2 it could render and 8 it
|
|
265
|
+
# threw away. The counts carry what the reader needs to know about the
|
|
266
|
+
# rest; the rows themselves add nothing to a card.
|
|
267
|
+
shown = (
|
|
268
|
+
deduped[:int(limit)] if include_unusable
|
|
269
|
+
else [i for i in deduped if i["quality"] == "ok"][:int(limit)]
|
|
270
|
+
)
|
|
271
|
+
return JSONResponse({
|
|
272
|
+
"profile": pid,
|
|
273
|
+
"summaries": shown,
|
|
274
|
+
"unusable": unusable,
|
|
275
|
+
"near_duplicates": collapsed,
|
|
276
|
+
"scanned": len(rows),
|
|
277
|
+
})
|
|
278
|
+
except sqlite3.Error as exc:
|
|
279
|
+
# A store that predates the display table. Empty, not an error.
|
|
280
|
+
logger.debug("consolidated summaries read failed: %s", exc)
|
|
281
|
+
return JSONResponse({
|
|
282
|
+
"profile": pid, "summaries": [], "unusable": 0, "scanned": 0,
|
|
283
|
+
})
|
|
284
|
+
finally:
|
|
285
|
+
conn.close()
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@router.get("/health")
|
|
289
|
+
def get_memory_health() -> JSONResponse:
|
|
290
|
+
"""Whether this store's memories can actually be found.
|
|
291
|
+
|
|
292
|
+
Same measurement ``slm doctor`` prints, so the dashboard and the CLI cannot
|
|
293
|
+
tell the owner two different things. Read-only and fail-soft.
|
|
294
|
+
"""
|
|
295
|
+
try:
|
|
296
|
+
from superlocalmemory.core.memory_health import describe, measure
|
|
297
|
+
|
|
298
|
+
health = measure(DB_PATH)
|
|
299
|
+
return JSONResponse({
|
|
300
|
+
"live_facts": health.live_facts,
|
|
301
|
+
"findable_by_meaning": health.findable_by_meaning,
|
|
302
|
+
"missing_vector": health.missing_vector,
|
|
303
|
+
"withheld_summaries": health.withheld_summaries,
|
|
304
|
+
"display_summaries": health.display_summaries,
|
|
305
|
+
"hidden_by_forgetting": health.hidden_by_forgetting,
|
|
306
|
+
"inconsistently_hidden": health.inconsistently_hidden,
|
|
307
|
+
"reachability": round(health.reachability, 4),
|
|
308
|
+
"healthy": health.healthy,
|
|
309
|
+
"unavailable": list(health.unavailable),
|
|
310
|
+
"summary": describe(health),
|
|
311
|
+
})
|
|
312
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
313
|
+
logger.debug("memory health read failed: %s", exc)
|
|
314
|
+
return JSONResponse({"healthy": None, "summary": [], "unavailable": ["error"]})
|
|
@@ -12,7 +12,7 @@ import gzip
|
|
|
12
12
|
import hashlib
|
|
13
13
|
import json
|
|
14
14
|
import logging
|
|
15
|
-
from typing import Optional
|
|
15
|
+
from typing import Any, Optional
|
|
16
16
|
from datetime import datetime, timezone
|
|
17
17
|
|
|
18
18
|
from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, File
|
|
@@ -78,7 +78,22 @@ async def export_memories(
|
|
|
78
78
|
use_v3 = False
|
|
79
79
|
|
|
80
80
|
if use_v3:
|
|
81
|
+
# Withheld summaries are excluded, and this is about IMPORT, not
|
|
82
|
+
# tidiness. Import re-ingests every record through the normal
|
|
83
|
+
# pipeline, which mints a fresh memory with quarantined = 0 — so an
|
|
84
|
+
# export taken here and imported anywhere would resurrect all 1,195
|
|
85
|
+
# model-written rows as though the owner had written them, on a
|
|
86
|
+
# machine where nothing had gone wrong. The repair would then have
|
|
87
|
+
# to run again there.
|
|
88
|
+
#
|
|
89
|
+
# Nothing the owner wrote is lost: these are derived artefacts, the
|
|
90
|
+
# consolidator regenerates them from the facts that ARE exported,
|
|
91
|
+
# and their text is kept in consolidated_summaries. (That table is
|
|
92
|
+
# not in this export either — it is a view, not a memory. Worth
|
|
93
|
+
# revisiting when export covers derived state.)
|
|
81
94
|
query = "SELECT * FROM atomic_facts WHERE profile_id = ?"
|
|
95
|
+
if _has_column(cursor, "atomic_facts", "quarantined"):
|
|
96
|
+
query += " AND COALESCE(quarantined, 0) = 0"
|
|
82
97
|
params = [active_profile]
|
|
83
98
|
if category:
|
|
84
99
|
query += " AND fact_type = ?"
|
|
@@ -154,6 +169,19 @@ async def export_memories(
|
|
|
154
169
|
raise _internal_error("Export error")
|
|
155
170
|
|
|
156
171
|
|
|
172
|
+
def _has_column(cursor: Any, table: str, column: str) -> bool:
|
|
173
|
+
"""Whether ``table`` carries ``column`` in this database.
|
|
174
|
+
|
|
175
|
+
Presence-guarded because ``quarantined`` arrives with a migration and this
|
|
176
|
+
route must keep working on a store the engine has not opened.
|
|
177
|
+
"""
|
|
178
|
+
try:
|
|
179
|
+
cursor.execute(f"PRAGMA table_info({table})")
|
|
180
|
+
return any(row[1] == column for row in cursor.fetchall())
|
|
181
|
+
except Exception: # noqa: BLE001 -- an export must not fail over a probe
|
|
182
|
+
return False
|
|
183
|
+
|
|
184
|
+
|
|
157
185
|
@router.post("/api/import")
|
|
158
186
|
async def import_memories(request: Request, file: UploadFile = File(...)):
|
|
159
187
|
"""Import memories from JSON file using V3 engine."""
|
|
@@ -84,7 +84,19 @@ def list_entities(
|
|
|
84
84
|
|
|
85
85
|
conn = get_read_connection(engine._config.db_path)
|
|
86
86
|
try:
|
|
87
|
-
|
|
87
|
+
# Hide the placeholder that dated facts with no recognised entity
|
|
88
|
+
# attach their temporal events to (core/store_pipeline.py,
|
|
89
|
+
# _ensure_unresolved_entity). It is a hook for a foreign key, not a
|
|
90
|
+
# concept, and it would otherwise appear here as a nameless entity and
|
|
91
|
+
# be counted in the total the owner reads as "entities I have".
|
|
92
|
+
#
|
|
93
|
+
# Only the presentation layer needs this. The other readers of
|
|
94
|
+
# canonical_entities are unaffected on inspection: graph_pruner uses
|
|
95
|
+
# `NOT IN (SELECT entity_id ...)` to find orphaned edges, where
|
|
96
|
+
# including it is correct; community_summary builds from graph edges and
|
|
97
|
+
# the placeholder has none; scale_engine counts for backend sync, not
|
|
98
|
+
# for display.
|
|
99
|
+
where = ["ce.profile_id = ?", "ce.entity_type != 'unresolved'"]
|
|
88
100
|
params: list[object] = [profile]
|
|
89
101
|
if entity_type and entity_type.lower() != "all":
|
|
90
102
|
where.append("ce.entity_type = ? COLLATE NOCASE")
|
|
@@ -408,7 +408,7 @@ def send(req: SendRequest, request: Request):
|
|
|
408
408
|
if sig_err is not None:
|
|
409
409
|
raise HTTPException(401, detail=sig_err.get("error", "signature error"))
|
|
410
410
|
|
|
411
|
-
# Admission gate parity (closes
|
|
411
|
+
# Admission gate parity (closes the P1 bypass for inbound remote send).
|
|
412
412
|
try:
|
|
413
413
|
from superlocalmemory.core.admission import (
|
|
414
414
|
AdmissionDenied,
|
|
@@ -266,7 +266,7 @@ def apply_settings_update(config: "SLMConfig", payload: dict) -> "SLMConfig":
|
|
|
266
266
|
This is the SINGLE authoritative place where incoming dashboard save payloads
|
|
267
267
|
are merged onto the stored config. The POST /api/v3/mode/set handler calls
|
|
268
268
|
this function after its HTTP-layer concerns (SSRF guard, auth) are settled,
|
|
269
|
-
so the
|
|
269
|
+
so the test suite (test_acceptance_core.py::P3) tests real product
|
|
270
270
|
behaviour — not a reimplementation.
|
|
271
271
|
|
|
272
272
|
SEC-L-01 PRESERVED:
|
|
@@ -444,7 +444,7 @@ async def set_full_config(request: Request):
|
|
|
444
444
|
# Resolve the effective endpoint value before SSRF validation.
|
|
445
445
|
# Only the Ollama default injection happens here; the fallback to the
|
|
446
446
|
# stored URL (and redacted-echo detection) live inside
|
|
447
|
-
# apply_settings_update so that the P3
|
|
447
|
+
# apply_settings_update so that the P3 test exercises
|
|
448
448
|
# the same code path as the HTTP handler — not a reimplementation.
|
|
449
449
|
_raw_ep: str = "" if clear_base_url else (base_url_input or endpoint_input or "")
|
|
450
450
|
if (
|
|
@@ -231,7 +231,14 @@ def create_app() -> FastAPI:
|
|
|
231
231
|
"<p><a href='/api/docs'>API Documentation</a></p>"
|
|
232
232
|
"</body></html>"
|
|
233
233
|
)
|
|
234
|
-
|
|
234
|
+
from superlocalmemory.server.asset_versions import render_index
|
|
235
|
+
from superlocalmemory import __version__ as _v
|
|
236
|
+
|
|
237
|
+
# __SLM_VERSION__ was substituted only by the unified daemon, so the
|
|
238
|
+
# dashboard's upgrade detector did nothing when served from here.
|
|
239
|
+
return render_index(
|
|
240
|
+
index_path, UI_DIR, substitutions={"__SLM_VERSION__": _v},
|
|
241
|
+
)
|
|
235
242
|
|
|
236
243
|
@application.get("/favicon.ico", include_in_schema=False)
|
|
237
244
|
async def favicon():
|
|
@@ -52,7 +52,7 @@ os.environ.setdefault("SLM_MCP_EMBEDDED", "1")
|
|
|
52
52
|
from fastapi import FastAPI, HTTPException, Request
|
|
53
53
|
from fastapi.middleware.cors import CORSMiddleware
|
|
54
54
|
from fastapi.middleware.gzip import GZipMiddleware
|
|
55
|
-
from pydantic import BaseModel
|
|
55
|
+
from pydantic import BaseModel, field_validator
|
|
56
56
|
|
|
57
57
|
from superlocalmemory.core.config import CANONICAL_RECALL_LIMIT
|
|
58
58
|
from superlocalmemory.infra.daemon_identity import (
|
|
@@ -587,6 +587,62 @@ class RememberRequest(BaseModel):
|
|
|
587
587
|
metadata: dict | None = None # v3.4.26: pass-through from MCP pool_store
|
|
588
588
|
idempotency_key: str | None = None
|
|
589
589
|
session_id: str = ""
|
|
590
|
+
#: WHEN this memory is about, as distinct from when it was written.
|
|
591
|
+
#:
|
|
592
|
+
#: The internal admission record has carried this field all along and this
|
|
593
|
+
#: model never had it, so every memory arriving over HTTP — which is every
|
|
594
|
+
#: memory, from the CLI, the tool interface and the dashboard alike — was
|
|
595
|
+
#: stamped with its ingestion date. Measured on the author's store: 200 of
|
|
596
|
+
#: the 200 most recent facts have an observation_date, and 196 of them are
|
|
597
|
+
#: the day they were written. A store that cannot be told "this happened in
|
|
598
|
+
#: March" cannot answer a question about March.
|
|
599
|
+
#:
|
|
600
|
+
#: Empty means "today", the previous behaviour. Format is YYYY-MM-DD or a
|
|
601
|
+
#: full ISO 8601 timestamp.
|
|
602
|
+
session_date: str = ""
|
|
603
|
+
|
|
604
|
+
@field_validator("session_date")
|
|
605
|
+
@classmethod
|
|
606
|
+
def _session_date_is_a_date(cls, value: str) -> str:
|
|
607
|
+
"""Reject a malformed date rather than ignore it.
|
|
608
|
+
|
|
609
|
+
Dropping it silently would leave the caller believing the date was
|
|
610
|
+
recorded while the memory quietly filed itself under today — which is
|
|
611
|
+
the exact failure this field exists to fix, reintroduced one layer up.
|
|
612
|
+
Rejecting is recoverable: the caller sees the error and resends.
|
|
613
|
+
|
|
614
|
+
ISO ONLY, DELIBERATELY, even though this system ships a parser that is
|
|
615
|
+
far more permissive. ``encoding/temporal_parser.parse_session_date``
|
|
616
|
+
accepts "May 8, 2026", "1:56 pm on 8 May, 2026", "14/03/2026", and also
|
|
617
|
+
"last tuesday" and "March 2026" — and those last two are why it is not
|
|
618
|
+
used here. Asked on 2026-08-21 it resolves "last tuesday" to
|
|
619
|
+
**2026-08-25**, four days into the future, and "March 2026" to the 21st,
|
|
620
|
+
a day it invents. Accepting either at this boundary would file a memory
|
|
621
|
+
under a confidently wrong date, silently, which is precisely the class
|
|
622
|
+
of defect this field was added to remove.
|
|
623
|
+
|
|
624
|
+
The caller here is a program — the command line, the tool interface, the
|
|
625
|
+
dashboard — not a person typing. ISO is the right contract for a
|
|
626
|
+
program, and a caller holding a human-typed date can run it through the
|
|
627
|
+
parser itself and send the result.
|
|
628
|
+
"""
|
|
629
|
+
if not value:
|
|
630
|
+
return value
|
|
631
|
+
from datetime import datetime as _dt
|
|
632
|
+
|
|
633
|
+
text = value.strip()
|
|
634
|
+
try:
|
|
635
|
+
_dt.fromisoformat(text.replace("Z", "+00:00"))
|
|
636
|
+
except ValueError:
|
|
637
|
+
raise ValueError(
|
|
638
|
+
"session_date must be YYYY-MM-DD or a full ISO 8601 timestamp; "
|
|
639
|
+
f"got {value!r}. To accept looser human phrasing, parse it "
|
|
640
|
+
"first with TemporalParser.parse_session_date and send the ISO "
|
|
641
|
+
"result — but check what it returns, because it resolves "
|
|
642
|
+
"relative phrases against today and can answer with a future "
|
|
643
|
+
"date."
|
|
644
|
+
) from None
|
|
645
|
+
return text
|
|
590
646
|
# v3.6.15 multi-scope: visibility of the new memory. ``None`` scope means
|
|
591
647
|
# "use the configured default_scope" (personal). shared_with is the list of
|
|
592
648
|
# profile_ids for scope='shared'.
|
|
@@ -2399,6 +2455,10 @@ async def lifespan(application: FastAPI):
|
|
|
2399
2455
|
"state": "checking_components", "embeddings_backfilled": 0,
|
|
2400
2456
|
"expansion_backfilled": 0, "null_remaining": None,
|
|
2401
2457
|
"components": None,
|
|
2458
|
+
# Declared here so /status carries the same keys whatever
|
|
2459
|
+
# happens; a field that only appears on failure is a field
|
|
2460
|
+
# nobody's dashboard renders.
|
|
2461
|
+
"incomplete_reason": None,
|
|
2402
2462
|
"started_at": _t.time(), "finished_at": None,
|
|
2403
2463
|
}
|
|
2404
2464
|
# Step 0 (v3.8.2 "whole self-healer"): repair components that
|
|
@@ -2507,9 +2567,42 @@ async def lifespan(application: FastAPI):
|
|
|
2507
2567
|
_backfill_vector_store()
|
|
2508
2568
|
except Exception as exc:
|
|
2509
2569
|
logger.warning("Self-heal vector index failed (non-fatal): %s", exc)
|
|
2510
|
-
|
|
2570
|
+
|
|
2571
|
+
# "complete" has to mean complete.
|
|
2572
|
+
#
|
|
2573
|
+
# This line used to set "complete" unconditionally, so a
|
|
2574
|
+
# backfill that gave up after five no-progress attempts, or ran
|
|
2575
|
+
# out its 500-iteration budget, reported success with facts
|
|
2576
|
+
# still unembedded — and an unembedded fact cannot be found by
|
|
2577
|
+
# meaning at all. That is how a machine sat at 56.3% of its
|
|
2578
|
+
# memory reachable while its own status endpoint said the heal
|
|
2579
|
+
# had finished. Nobody was going to look past a green light.
|
|
2580
|
+
#
|
|
2581
|
+
# Now the state is derived from the remaining count, and the
|
|
2582
|
+
# gap is named in plain language for the dashboard, so an
|
|
2583
|
+
# incomplete heal is visible and gets retried on next start
|
|
2584
|
+
# instead of being declared done forever.
|
|
2585
|
+
_remaining = _SELF_HEAL_STATUS.get("null_remaining")
|
|
2511
2586
|
_SELF_HEAL_STATUS["finished_at"] = _t.time()
|
|
2512
|
-
|
|
2587
|
+
if _remaining is None:
|
|
2588
|
+
_SELF_HEAL_STATUS["state"] = "complete"
|
|
2589
|
+
elif int(_remaining) > 0:
|
|
2590
|
+
_SELF_HEAL_STATUS["state"] = "incomplete"
|
|
2591
|
+
_SELF_HEAL_STATUS["incomplete_reason"] = (
|
|
2592
|
+
f"{int(_remaining)} memories still have no meaning "
|
|
2593
|
+
f"vector, so they cannot be found by asking a question. "
|
|
2594
|
+
f"This retries automatically next time the service "
|
|
2595
|
+
f"starts. It usually means the embedding model was "
|
|
2596
|
+
f"unavailable."
|
|
2597
|
+
)
|
|
2598
|
+
logger.warning(
|
|
2599
|
+
"Self-heal INCOMPLETE: %d facts still unembedded and "
|
|
2600
|
+
"therefore unreachable by meaning; will retry on next "
|
|
2601
|
+
"start. %s", int(_remaining), _SELF_HEAL_STATUS,
|
|
2602
|
+
)
|
|
2603
|
+
else:
|
|
2604
|
+
_SELF_HEAL_STATUS["state"] = "complete"
|
|
2605
|
+
logger.info("Self-heal complete: %s", _SELF_HEAL_STATUS)
|
|
2513
2606
|
except Exception as exc:
|
|
2514
2607
|
_SELF_HEAL_STATUS["state"] = "error"
|
|
2515
2608
|
logger.warning("Self-heal failed (non-fatal): %s", exc)
|
|
@@ -3975,8 +4068,16 @@ def _register_dashboard_routes(application: FastAPI) -> None:
|
|
|
3975
4068
|
# v3.4.23: substitute version placeholder so the dashboard can detect
|
|
3976
4069
|
# upgrades and auto-reload. Read fresh each request (daemon uptime is
|
|
3977
4070
|
# days, but we want zero caching surprises during development).
|
|
3978
|
-
|
|
3979
|
-
|
|
4071
|
+
#
|
|
4072
|
+
# 4.0.10: asset ?v= strings are now derived from file content instead of
|
|
4073
|
+
# being hand-written literals that tracked nothing. See
|
|
4074
|
+
# server/asset_versions.py — including what that does and does not fix.
|
|
4075
|
+
from superlocalmemory.server.asset_versions import render_index
|
|
4076
|
+
|
|
4077
|
+
return render_index(
|
|
4078
|
+
index_path, UI_DIR,
|
|
4079
|
+
substitutions={"__SLM_VERSION__": _SLM_VERSION},
|
|
4080
|
+
)
|
|
3980
4081
|
|
|
3981
4082
|
@application.get("/favicon.ico", include_in_schema=False)
|
|
3982
4083
|
async def favicon():
|
|
@@ -4146,7 +4247,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
4146
4247
|
"runtime_state": runtime_state,
|
|
4147
4248
|
"active_profile": profile_snapshot.profile_id,
|
|
4148
4249
|
"profile_generation": profile_snapshot.generation,
|
|
4149
|
-
#
|
|
4250
|
+
# operational failure counts (visible to all team members)
|
|
4150
4251
|
**_ops_failure_counts(engine, application),
|
|
4151
4252
|
# issue #107: does this daemon's *imported* code still match the
|
|
4152
4253
|
# installed distribution? ``version`` above reports what this
|
|
@@ -4513,6 +4614,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
4513
4614
|
shared_with=tuple(shared_with or ()),
|
|
4514
4615
|
trusted_actor_id=trusted_actor_id,
|
|
4515
4616
|
session_id=req.session_id,
|
|
4617
|
+
session_date=req.session_date,
|
|
4516
4618
|
)
|
|
4517
4619
|
actor = Actor(
|
|
4518
4620
|
principal_id=trusted_actor_id,
|
|
@@ -4859,7 +4961,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
4859
4961
|
# index backfill after an upgrade). Dashboard renders a plain
|
|
4860
4962
|
# "Optimizing memory…" line from this. Defaults to idle before start.
|
|
4861
4963
|
"self_heal": globals().get("_SELF_HEAL_STATUS", {"state": "idle"}),
|
|
4862
|
-
#
|
|
4964
|
+
# operational failure counts (dead-letter, degraded, stalled)
|
|
4863
4965
|
**_ops_failure_counts(engine, application),
|
|
4864
4966
|
}
|
|
4865
4967
|
|
|
@@ -4913,7 +5015,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
4913
5015
|
return {"status": "started"}
|
|
4914
5016
|
|
|
4915
5017
|
# ------------------------------------------------------------------
|
|
4916
|
-
#
|
|
5018
|
+
# Operational Recovery & Admin Remediation (V4 resilience slice)
|
|
4917
5019
|
# ------------------------------------------------------------------
|
|
4918
5020
|
|
|
4919
5021
|
@application.get("/operations/failed")
|
|
@@ -5460,7 +5562,7 @@ def _terminalize_orphan_operation(engine, operation_id: str) -> None:
|
|
|
5460
5562
|
|
|
5461
5563
|
|
|
5462
5564
|
def _ops_failure_counts(engine, application) -> dict:
|
|
5463
|
-
"""Return
|
|
5565
|
+
"""Return operational failure counts for /status and /health.
|
|
5464
5566
|
|
|
5465
5567
|
Always returns a dict (never raises). Counts default to 0 on any error.
|
|
5466
5568
|
Includes: dead_letter_count, degraded_operations, exhausted_obligations,
|
|
@@ -153,6 +153,9 @@ from superlocalmemory.storage.migrations import (
|
|
|
153
153
|
from superlocalmemory.storage.migrations import (
|
|
154
154
|
M042_correction_case_ledger as _M042,
|
|
155
155
|
)
|
|
156
|
+
from superlocalmemory.storage.migrations import (
|
|
157
|
+
M043_quarantine_display_summaries as _M043,
|
|
158
|
+
)
|
|
156
159
|
|
|
157
160
|
# Emit under the runner's logger name so operational log filters that key on
|
|
158
161
|
# "superlocalmemory.storage.migration_runner" keep matching after this split.
|
|
@@ -203,6 +206,7 @@ _MODULES = {
|
|
|
203
206
|
_M040.NAME: _M040,
|
|
204
207
|
_M041.NAME: _M041,
|
|
205
208
|
_M042.NAME: _M042,
|
|
209
|
+
_M043.NAME: _M043,
|
|
206
210
|
}
|
|
207
211
|
|
|
208
212
|
# Exact historical DDL fingerprints whose resulting schema is intentionally
|