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.
Files changed (64) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/README.md +3 -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/pyproject.toml +1 -1
  25. package/src/superlocalmemory/__init__.py +1 -1
  26. package/src/superlocalmemory/cli/commands.py +45 -2
  27. package/src/superlocalmemory/cli/main.py +2 -2
  28. package/src/superlocalmemory/code_graph/bridge/maintenance.py +8 -0
  29. package/src/superlocalmemory/core/fact_consolidator.py +316 -125
  30. package/src/superlocalmemory/core/maintenance.py +44 -6
  31. package/src/superlocalmemory/core/memory_health.py +266 -0
  32. package/src/superlocalmemory/core/operation_policy_registry.py +1 -1
  33. package/src/superlocalmemory/core/operation_request.py +1 -1
  34. package/src/superlocalmemory/core/ops_remediation.py +2 -2
  35. package/src/superlocalmemory/core/store_pipeline.py +78 -3
  36. package/src/superlocalmemory/encoding/cognitive_consolidator.py +15 -1
  37. package/src/superlocalmemory/mcp/server.py +1 -1
  38. package/src/superlocalmemory/mcp/session_binding.py +92 -0
  39. package/src/superlocalmemory/mcp/tools_core.py +40 -39
  40. package/src/superlocalmemory/mcp/tools_ops.py +2 -2
  41. package/src/superlocalmemory/retrieval/bm25_channel.py +4 -8
  42. package/src/superlocalmemory/retrieval/entity_channel.py +7 -1
  43. package/src/superlocalmemory/retrieval/scope_policy.py +22 -1
  44. package/src/superlocalmemory/retrieval/temporal_channel.py +13 -1
  45. package/src/superlocalmemory/retrieval/vector_store.py +63 -0
  46. package/src/superlocalmemory/server/api.py +6 -1
  47. package/src/superlocalmemory/server/asset_versions.py +171 -0
  48. package/src/superlocalmemory/server/routes/abstraction.py +201 -0
  49. package/src/superlocalmemory/server/routes/data_io.py +29 -1
  50. package/src/superlocalmemory/server/routes/entity.py +13 -1
  51. package/src/superlocalmemory/server/routes/mesh.py +1 -1
  52. package/src/superlocalmemory/server/routes/v3_api.py +2 -2
  53. package/src/superlocalmemory/server/ui.py +8 -1
  54. package/src/superlocalmemory/server/unified_daemon.py +111 -9
  55. package/src/superlocalmemory/storage/_migration_internals.py +4 -0
  56. package/src/superlocalmemory/storage/database.py +128 -30
  57. package/src/superlocalmemory/storage/migration_runner.py +11 -0
  58. package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +488 -0
  59. package/src/superlocalmemory/storage/schema.py +98 -0
  60. package/src/superlocalmemory/summaries/base.py +1 -1
  61. package/src/superlocalmemory/summaries/non_answer.py +223 -0
  62. package/src/superlocalmemory/ui/index.html +1 -1
  63. package/src/superlocalmemory/ui/js/od-memories.js +190 -1
  64. package/src/superlocalmemory/ui/js/od-ops-health.js +1 -1
@@ -2,30 +2,63 @@
2
2
  # Licensed under AGPL-3.0-or-later - see LICENSE file
3
3
  # Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
4
4
 
5
- """SuperLocalMemory V3.4.11 "Scale-Ready" Fact Consolidation Engine.
6
-
7
- Merges clusters of related facts about the same entity into single
8
- comprehensive summary facts. Original facts move to 'archived' tier
9
- but are NEVER deleted searchable via deep recall.
10
-
11
- Uses Mode B (Ollama LLM) for summarization, with Mode A (extractive)
12
- fallback if LLM is unavailable.
13
-
14
- CRITICAL RULES:
15
- 1. NEVER delete original facts
16
- 2. Original facts → lifecycle='archived' (not deleted)
17
- 3. Consolidated fact links back to originals via fact_consolidations table
18
- 4. Only consolidates facts that are already 'warm' or 'cold' tier
19
- 5. Never touches 'active' or 'pinned' facts
20
- 6. All writes per cluster wrapped in SAVEPOINT for atomicity
21
- 7. Entity ID LIKE patterns use JSON-boundary quoting to prevent
22
- substring false positives
5
+ """Fact consolidationwrites a DISPLAY summary, never a memory.
6
+
7
+ Groups warm/cold facts that share an entity and writes one summary per cluster
8
+ into ``consolidated_summaries``, a display-only table. Nothing in the retrieval
9
+ corpus is created, modified or hidden by this module.
10
+
11
+ WHAT CHANGED AND WHY IT HAD TO
12
+ ------------------------------
13
+ From v3.6.4 to 4.0.9 this module ended each cluster with a raw
14
+ ``INSERT INTO atomic_facts`` carrying ``memory_id=''``, ``importance=0.8`` and
15
+ ``entities_json`` holding *every* entity in the cluster. Three consequences, all
16
+ measured on the author's 5,089-fact store:
17
+
18
+ * 1,195 model-written rows entered the retrieval corpus, and because each
19
+ carried its whole cluster's entity list they had more entity links than any
20
+ real memory, so the entity channel ranked them first. Asked "what am I
21
+ working on", the store answered "Unfortunately, there is no information
22
+ available about 'Gateway', 'State', 'Bounded', or 'Claude' in the provided
23
+ text." at ranks 1, 2 and 3.
24
+ * The rows had no ``temporal_events`` at all (0 of 1,195), so they won the
25
+ temporal channel through its ``created_at`` recency fallback as well.
26
+ * Their entity clusters made them eligible for consolidation in turn: 353 of
27
+ them are summaries of summaries. A store summarising its own summaries
28
+ drifts away from what the user actually said, one pass at a time.
29
+
30
+ Bypassing ``DatabaseManager`` also bypassed the constraint that would have
31
+ refused the row outright — ``atomic_facts`` declares
32
+ ``FOREIGN KEY (memory_id) REFERENCES memories(memory_id)`` and ``memories`` has
33
+ no ``''`` row, but ``storage/memory_write.py`` sets only ``busy_timeout``, not
34
+ ``PRAGMA foreign_keys=ON``.
35
+
36
+ So the boundary this module now keeps is the one v3.6 intended: a summary is a
37
+ *view* of memory, shown on the dashboard and to Mode B/C readers, and it is not
38
+ a thing recall can return. ``community_summaries`` is the precedent.
39
+
40
+ CONTRACT
41
+ --------
42
+ 1. NEVER writes to ``atomic_facts`` — not the summary, not the sources.
43
+ 2. NEVER archives the source facts. Archiving them made sense only while a
44
+ retrievable summary stood in for them; a display-only summary does not, so
45
+ archiving would replace ten reachable memories with nothing.
46
+ 3. Model output is cleaned (``clean_llm_summary``) and then rejected if it is
47
+ a non-answer (``is_non_answer``) — checked before any write, with the
48
+ reason logged.
49
+ 4. Only clusters warm/cold facts; never touches 'active' or 'pinned'.
50
+ 5. All writes per cluster wrapped in SAVEPOINT for atomicity.
51
+ 6. Entity ID LIKE patterns use JSON-boundary quoting to prevent substring
52
+ false positives.
53
+
54
+ Modes: A extractive, B Ollama, C cloud LLM with fallbacks down to extractive.
23
55
 
24
56
  Part of Qualixar | Author: Varun Pratap Bhardwaj
25
57
  """
26
58
 
27
59
  from __future__ import annotations
28
60
 
61
+ import hashlib
29
62
  import json
30
63
  import logging
31
64
  import sqlite3
@@ -37,6 +70,9 @@ from typing import TYPE_CHECKING, Union
37
70
  if TYPE_CHECKING:
38
71
  from superlocalmemory.storage.database import DatabaseManager
39
72
 
73
+ from superlocalmemory.summaries.base import clean_llm_summary
74
+ from superlocalmemory.summaries.non_answer import MIN_USEFUL_CHARS, is_non_answer
75
+
40
76
  logger = logging.getLogger("superlocalmemory.fact_consolidator")
41
77
 
42
78
  _MAX_CLUSTER_SIZE = 10 # Max facts to merge into one
@@ -69,10 +105,13 @@ def consolidate_facts(
69
105
 
70
106
  Mode behavior:
71
107
  - Mode A: Extractive only (no LLM). Always available.
72
- - Mode B: Ollama LLM summarization. Falls back to extractive if Ollama down.
73
- - Mode C: Cloud LLM (user's configured provider). Falls back to extractive.
108
+ - Mode B: Ollama LLM summarization. Falls back to extractive if Ollama is
109
+ down OR if the model answers with a non-answer.
110
+ - Mode C: Cloud LLM (user's configured provider), then Ollama, then
111
+ extractive, with the same non-answer rejection at each step.
74
112
 
75
- Returns stats: consolidated, clusters_found, facts_archived, errors.
113
+ Returns stats: consolidated, clusters_found, facts_summarized,
114
+ rejected, errors.
76
115
  """
77
116
  from superlocalmemory.storage.database import DatabaseManager
78
117
  from superlocalmemory.storage.memory_write import memory_read, memory_write
@@ -80,7 +119,19 @@ def consolidate_facts(
80
119
  stats: dict = {
81
120
  "clusters_found": 0,
82
121
  "consolidated": 0,
83
- "facts_archived": 0,
122
+ # Renamed from facts_archived. Nothing is archived any more, and a key
123
+ # that keeps reporting a count for an action no longer taken is how a
124
+ # behaviour change hides from whoever reads the numbers.
125
+ "facts_summarized": 0,
126
+ # Clusters whose summary was a non-answer and was refused. Counted
127
+ # separately from `errors`: refusing junk is the guard working, not a
128
+ # failure, but a run where every cluster is refused means the model is
129
+ # misbehaving and that has to be visible.
130
+ "rejected": 0,
131
+ # Clusters whose summary already existed over the same sources, so no
132
+ # model call was made. On a settled store this becomes the whole count.
133
+ "unchanged": 0,
134
+ "facts_archived": 0, # retained at 0 for callers that still read it
84
135
  "errors": 0,
85
136
  "error_detail": "",
86
137
  "mode": "a",
@@ -119,15 +170,37 @@ def consolidate_facts(
119
170
  if len(facts) < _MIN_CLUSTER_SIZE:
120
171
  continue
121
172
 
173
+ # Step 2b: has this exact cluster already been summarised?
174
+ #
175
+ # Necessary because 4.0.10 stopped archiving the sources. The
176
+ # old code made a cluster ineligible by archiving it; now the
177
+ # same warm facts are eligible on every maintenance pass, so
178
+ # without this the summarizer is re-run for an unchanged
179
+ # cluster every 30 minutes forever. Measured over five
180
+ # consecutive passes on one cluster before this check:
181
+ # consolidated_summaries converged at 1, but
182
+ # fact_consolidations grew 1, 2, 3, 4, 5 -- and in Mode B/C
183
+ # each of those passes paid for a model call to regenerate
184
+ # text it already had.
185
+ #
186
+ # Checked before generation, not after, because the cost
187
+ # being avoided IS the generation.
188
+ if _already_summarised(db_path, profile_id, entity_id, fact_ids):
189
+ stats["unchanged"] += 1
190
+ continue
191
+
122
192
  # Step 3: generate summary OUTSIDE any write lock.
123
193
  # Ollama (Mode B) or Cloud LLM (Mode C) may take 30 s here.
124
- summary = _generate_summary(entity_name, facts, config)
194
+ summary, generated_by = _generate_summary(
195
+ entity_name, facts, config,
196
+ )
125
197
  if not summary:
198
+ stats["rejected"] += 1
126
199
  continue
127
200
 
128
201
  if dry_run:
129
202
  stats["consolidated"] += 1
130
- stats["facts_archived"] += len(fact_ids)
203
+ stats["facts_summarized"] += len(fact_ids)
131
204
  continue
132
205
 
133
206
  # Step 4: short per-cluster write — hold lock only for SQL.
@@ -136,11 +209,13 @@ def consolidate_facts(
136
209
  result = _consolidate_cluster(
137
210
  conn, profile_id, entity_id, entity_name,
138
211
  fact_ids, dry_run=False, config=None,
139
- _presummary=summary,
212
+ _presummary=summary, _generated_by=generated_by,
140
213
  )
141
214
  if result:
142
215
  stats["consolidated"] += 1
143
- stats["facts_archived"] += len(fact_ids)
216
+ stats["facts_summarized"] += len(fact_ids)
217
+ else:
218
+ stats["rejected"] += 1
144
219
  except Exception as exc:
145
220
  logger.warning(
146
221
  "Consolidation failed for %s: %s",
@@ -150,8 +225,10 @@ def consolidate_facts(
150
225
 
151
226
  if stats["consolidated"] > 0:
152
227
  logger.info(
153
- "Fact consolidation: %d clusters merged, %d facts archived",
154
- stats["consolidated"], stats["facts_archived"],
228
+ "Fact consolidation: %d display summaries written over "
229
+ "%d facts, %d clusters refused",
230
+ stats["consolidated"], stats["facts_summarized"],
231
+ stats["rejected"],
155
232
  )
156
233
  except Exception as exc:
157
234
  logger.error("Fact consolidation failed: %s", exc, exc_info=True)
@@ -228,7 +305,9 @@ def _run_consolidation(
228
305
  )
229
306
  if result:
230
307
  stats["consolidated"] += 1
231
- stats["facts_archived"] += len(fact_ids)
308
+ stats["facts_summarized"] += len(fact_ids)
309
+ else:
310
+ stats["rejected"] += 1
232
311
  except Exception as exc:
233
312
  logger.warning(
234
313
  "Consolidation failed for %s: %s",
@@ -238,11 +317,51 @@ def _run_consolidation(
238
317
 
239
318
  if stats["consolidated"] > 0:
240
319
  logger.info(
241
- "Fact consolidation: %d clusters merged, %d facts archived",
242
- stats["consolidated"], stats["facts_archived"],
320
+ "Fact consolidation: %d display summaries written over %d facts",
321
+ stats["consolidated"], stats["facts_summarized"],
243
322
  )
244
323
 
245
324
 
325
+ def _already_summarised(
326
+ db_path: "str | Path",
327
+ profile_id: str,
328
+ entity_id: str,
329
+ fact_ids: list[str],
330
+ ) -> bool:
331
+ """Whether this exact cluster already has a display summary.
332
+
333
+ Compares SORTED source ids, because cluster order comes from an
334
+ ``ORDER BY confidence DESC, created_at DESC`` that two equal-confidence
335
+ facts can swap between passes -- comparing the raw lists would report
336
+ "changed" on a cluster that did not.
337
+
338
+ Any error means "not summarised", which costs one redundant generation and
339
+ never skips work that was needed. Read-only.
340
+ """
341
+ from superlocalmemory.storage.memory_write import memory_read
342
+
343
+ wanted = sorted(fact_ids)
344
+ try:
345
+ with memory_read(db_path) as conn:
346
+ conn.row_factory = sqlite3.Row
347
+ rows = conn.execute(
348
+ "SELECT source_fact_ids FROM consolidated_summaries "
349
+ " WHERE profile_id = ? AND entity_id = ?",
350
+ (profile_id, entity_id),
351
+ ).fetchall()
352
+ except Exception as exc: # noqa: BLE001 -- never block consolidation
353
+ logger.debug("cluster-unchanged check skipped: %s", exc)
354
+ return False
355
+
356
+ for row in rows:
357
+ try:
358
+ if sorted(json.loads(row["source_fact_ids"] or "[]")) == wanted:
359
+ return True
360
+ except (json.JSONDecodeError, TypeError):
361
+ continue
362
+ return False
363
+
364
+
246
365
  def _find_consolidation_clusters(
247
366
  conn: sqlite3.Connection,
248
367
  profile_id: str,
@@ -308,8 +427,13 @@ def _consolidate_cluster(
308
427
  config: object | None = None,
309
428
  *,
310
429
  _presummary: str | None = None,
430
+ _generated_by: str = "extractive",
311
431
  ) -> dict | None:
312
- """Merge a cluster of facts into one consolidated fact.
432
+ """Write one display summary for a cluster of facts.
433
+
434
+ Touches ``consolidated_summaries`` and ``fact_consolidations`` and nothing
435
+ else. In particular it does not write, update or archive any row in
436
+ ``atomic_facts`` — see the module docstring.
313
437
 
314
438
  All writes are wrapped in a SAVEPOINT for atomicity — if any step fails,
315
439
  the entire cluster consolidation is rolled back.
@@ -319,6 +443,10 @@ def _consolidate_cluster(
319
443
  This is the short-lock path used by the DatabaseManager branch of
320
444
  consolidate_facts(). The str | Path backward-compat path still calls
321
445
  _generate_summary() inline (legacy behaviour, no regression).
446
+
447
+ _generated_by records which mode produced the text ('extractive',
448
+ 'ollama', 'cloud'), so a reader can tell a local extractive digest from
449
+ model prose without inspecting the words.
322
450
  """
323
451
  c = conn.cursor()
324
452
 
@@ -341,10 +469,30 @@ def _consolidate_cluster(
341
469
  summary = _presummary
342
470
  else:
343
471
  # Legacy path (str | Path caller) — may call Ollama with write lock held.
344
- summary = _generate_summary(entity_name, facts, config)
472
+ summary, _generated_by = _generate_summary(entity_name, facts, config)
345
473
  if not summary:
346
474
  return None
347
475
 
476
+ # Last line of defence, on BOTH paths. _generate_summary already cleans and
477
+ # vets its own output, but this function is reachable with a caller-supplied
478
+ # _presummary, and "the caller checked" is exactly the assumption that let
479
+ # 1,195 non-answers into the store.
480
+ #
481
+ # Cleaning runs here too, not just judging. A first draft of this guard
482
+ # only judged, and a test supplying "Sure! Here is a concise summary:
483
+ # <real content>" as a _presummary stored the scaffolding verbatim -- the
484
+ # text passed the non-answer check because it does contain a real summary,
485
+ # and nothing had stripped the two sentences in front of it.
486
+ # clean_llm_summary is idempotent, so running it on both paths is free.
487
+ summary = clean_llm_summary(summary)
488
+ _rejected, _why = is_non_answer(summary, min_chars=MIN_USEFUL_CHARS)
489
+ if _rejected:
490
+ logger.info(
491
+ "Consolidation summary for '%s' rejected before write (%s)",
492
+ entity_name, _why,
493
+ )
494
+ return None
495
+
348
496
  if dry_run:
349
497
  return {"entity": entity_name, "facts": len(facts), "summary_len": len(summary)}
350
498
 
@@ -355,7 +503,6 @@ def _consolidate_cluster(
355
503
  try:
356
504
  new_fact_id = uuid.uuid4().hex[:16]
357
505
  now = datetime.now(timezone.utc).isoformat()
358
- avg_confidence = sum(f["confidence"] or 0.5 for f in facts) / len(facts)
359
506
 
360
507
  # v3.6.15 multi-scope: a summary must never be MORE visible than its
361
508
  # sources, or it would leak a private fact into a shared/global summary.
@@ -372,84 +519,77 @@ def _consolidate_cluster(
372
519
  else:
373
520
  _sum_scope, _sum_shared = "personal", None
374
521
 
375
- # Collect entities from ALL source facts (already in the SELECT)
376
- all_entities = set()
377
- raw_entities = set()
378
- for f in facts:
379
- cej = f["canonical_entities_json"]
380
- if cej:
381
- try:
382
- all_entities.update(json.loads(cej))
383
- except (json.JSONDecodeError, TypeError):
384
- pass
385
-
386
- # P0-3 (dedup-complete-01): apply the SAME content-idempotency invariant
387
- # as storage.database.store_fact but on THIS cursor so it stays inside
388
- # the cluster SAVEPOINT. Previously this raw INSERT bypassed dedup, so a
389
- # consolidated summary identical to an existing live fact created a
390
- # duplicate row and never reinforced evidence. Now: reinforce-or-insert.
391
- # (Excludes 'archived' = soft-deleted, mirroring store_fact.)
392
- _existing = c.execute(
393
- "SELECT fact_id FROM atomic_facts "
394
- "WHERE profile_id = ? AND content = ? "
395
- "AND lifecycle IN ('active', 'warm', 'cold') "
396
- "ORDER BY created_at LIMIT 1",
397
- (profile_id, summary),
398
- ).fetchone()
399
- if _existing:
400
- new_fact_id = _existing["fact_id"]
401
- c.execute(
402
- "UPDATE atomic_facts "
403
- "SET evidence_count = evidence_count + ?, access_count = access_count + 1 "
404
- "WHERE fact_id = ?",
405
- (len(facts), new_fact_id),
406
- )
407
- else:
408
- c.execute("""
409
- INSERT INTO atomic_facts
410
- (fact_id, memory_id, profile_id, content, fact_type,
411
- entities_json, canonical_entities_json,
412
- confidence, importance, evidence_count, access_count,
413
- created_at, lifecycle, scope, shared_with)
414
- VALUES (?, '', ?, ?, 'semantic', ?, ?, ?, 0.8, ?, 0, ?, 'active', ?, ?)
415
- """, (
416
- new_fact_id, profile_id, summary,
417
- json.dumps(list(all_entities)),
418
- json.dumps(list(all_entities)),
419
- round(avg_confidence, 3), len(facts), now,
420
- _sum_scope, _sum_shared,
421
- ))
422
-
423
- # Record the consolidation
424
- consolidation_id = uuid.uuid4().hex[:16]
522
+ # The cluster's pooled entity list is deliberately NOT carried onto the
523
+ # summary. Pooling ten facts' entities gave the old row more entity
524
+ # links than any single real memory, which is precisely how these rows
525
+ # came to out-rank the user's own words in the entity channel. The
526
+ # summary is identified by the one entity that seeded the cluster.
527
+
528
+ # The summary goes in the DISPLAY table. Not atomic_facts — see the
529
+ # module docstring for the 1,195 rows that taught us why.
530
+ #
531
+ # UNIQUE (profile_id, entity_id, content) makes a repeated pass
532
+ # idempotent: re-summarising an unchanged cluster refreshes the
533
+ # coverage window instead of accumulating a near-duplicate every time
534
+ # maintenance runs. The old code's reinforce-or-insert dance against
535
+ # atomic_facts existed for the same reason and is no longer needed.
536
+ _dates = [f["created_at"] for f in facts if f["created_at"]]
425
537
  c.execute("""
426
- INSERT INTO fact_consolidations
538
+ INSERT INTO consolidated_summaries
539
+ (summary_id, profile_id, entity_id, entity_name, content,
540
+ source_fact_ids, source_count, char_count, generated_by,
541
+ scope, shared_with, source_earliest, source_latest, created_at)
542
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
543
+ ON CONFLICT (profile_id, entity_id, content) DO UPDATE SET
544
+ source_fact_ids = excluded.source_fact_ids,
545
+ source_count = excluded.source_count,
546
+ source_earliest = excluded.source_earliest,
547
+ source_latest = excluded.source_latest,
548
+ created_at = excluded.created_at
549
+ """, (
550
+ new_fact_id, profile_id, entity_id, entity_name, summary,
551
+ json.dumps(fact_ids), len(facts), len(summary),
552
+ _generated_by, _sum_scope, _sum_shared,
553
+ min(_dates) if _dates else None,
554
+ max(_dates) if _dates else None,
555
+ now,
556
+ ))
557
+
558
+ # Provenance ledger, kept for the repair pass and for auditability.
559
+ # `strategy` distinguishes a display summary from the retrieval-corpus
560
+ # rows the old path wrote, so 'entity_cluster' remains an exact
561
+ # selector for what has to be quarantined.
562
+ # Deterministic id + INSERT OR IGNORE, so re-running over the same
563
+ # cluster does not append a row. fact_consolidations carries no unique
564
+ # constraint, so a random uuid grew the ledger by one row per
565
+ # maintenance pass per cluster with nothing to show for it -- roughly a
566
+ # thousand rows a day on a store with twenty clusters.
567
+ consolidation_id = hashlib.sha256(
568
+ "\0".join((
569
+ profile_id, new_fact_id, "display_summary",
570
+ *sorted(fact_ids),
571
+ )).encode("utf-8")
572
+ ).hexdigest()[:16]
573
+ c.execute("""
574
+ INSERT OR IGNORE INTO fact_consolidations
427
575
  (consolidation_id, profile_id, consolidated_fact_id,
428
576
  source_fact_ids, strategy, created_at)
429
- VALUES (?, ?, ?, ?, 'entity_cluster', ?)
577
+ VALUES (?, ?, ?, ?, 'display_summary', ?)
430
578
  """, (consolidation_id, profile_id, new_fact_id,
431
579
  json.dumps(fact_ids), now))
432
580
 
433
- # Archive the original facts (NEVER delete) through the canonical
434
- # lifecycle writer so missing retention rows are created too.
435
- from superlocalmemory.core.lifecycle_state import set_fact_lifecycle_zone
436
- set_fact_lifecycle_zone(
437
- conn, fact_ids, "archive", profile_id=profile_id,
438
- )
439
-
440
- # P1-4 (graph-integrity-01): archived facts must stop influencing
441
- # graph-based ranking. The association_edges FK is ON DELETE CASCADE
442
- # only (no ON UPDATE), so archiving via UPDATE leaves orphaned edges
443
- # that spreading_activation still reads. Remove edges touching the
444
- # archived facts, and set their retention zone so ForgettingFilter
445
- # excludes them. Inside the SAVEPOINT for atomicity.
446
- c.execute(
447
- f"DELETE FROM association_edges "
448
- f"WHERE profile_id = ? "
449
- f"AND (source_fact_id IN ({placeholders}) "
450
- f" OR target_fact_id IN ({placeholders}))",
451
- (profile_id, *fact_ids, *fact_ids),
452
- )
581
+ # NO archiving, and NO association_edge deletion.
582
+ #
583
+ # Both were correct while a retrievable summary replaced the facts it
584
+ # merged. A display-only summary replaces nothing, so archiving the
585
+ # sources would take ten reachable memories out of recall and put
586
+ # nothing in their place. Measured cost of the old behaviour on the
587
+ # author's store: 528 genuine memories sitting in retention zone
588
+ # 'archive', every one of them put there by this function and by
589
+ # nothing else, all unreachable in normal recall.
590
+ #
591
+ # This module is now purely additive to the store. A test asserts it:
592
+ # tests/test_core/test_consolidation_writes_no_memories.py
453
593
  c.execute(f"RELEASE SAVEPOINT {savepoint_name}")
454
594
 
455
595
  except Exception:
@@ -457,8 +597,8 @@ def _consolidate_cluster(
457
597
  raise
458
598
 
459
599
  logger.info(
460
- "Consolidated %d facts about '%s' → %s (%d chars)",
461
- len(facts), entity_name, new_fact_id[:8], len(summary),
600
+ "Display summary for '%s' from %d facts → %s (%d chars, %s)",
601
+ entity_name, len(facts), new_fact_id[:8], len(summary), _generated_by,
462
602
  )
463
603
 
464
604
  return {"entity": entity_name, "facts": len(facts), "new_fact_id": new_fact_id}
@@ -468,8 +608,29 @@ def _generate_summary(
468
608
  entity_name: str,
469
609
  facts: list,
470
610
  config: object | None = None,
471
- ) -> str | None:
472
- """Generate a consolidated summary based on the user's configured mode.
611
+ ) -> tuple[str | None, str]:
612
+ """Generate a display summary for the user's configured mode.
613
+
614
+ Returns ``(summary_or_None, generated_by)`` where ``generated_by`` is
615
+ 'extractive', 'ollama' or 'cloud'. It used to return the text alone, which
616
+ left no way to tell a local digest from model prose after the fact — and
617
+ since only the model paths can produce a non-answer, that distinction is
618
+ what makes a bad batch traceable to its source.
619
+
620
+ Model output goes through two stages before it is offered to the caller:
621
+
622
+ 1. ``clean_llm_summary`` strips chat scaffolding *around* the answer.
623
+ This stripper has existed in ``summaries/base.py`` since 3.6 and this
624
+ module referenced it zero times, which is why 20 of the author's
625
+ stored summaries begin "Here is a concise summary paragraph".
626
+ 2. ``is_non_answer`` rejects text that is scaffolding *all the way
627
+ through* — a refusal, a request for more input, a report that the
628
+ input was empty. Order matters: step 1 rescues "Here is a summary:
629
+ <real content>", and running step 2 first would discard it.
630
+
631
+ A rejected model summary falls back to extractive, which is derived
632
+ mechanically from the facts and so cannot refuse. Falling back beats
633
+ writing nothing: the user still gets a digest.
473
634
 
474
635
  All modes cap output at _MAX_CONSOLIDATED_CHARS.
475
636
  """
@@ -479,28 +640,58 @@ def _generate_summary(
479
640
  if m:
480
641
  mode = getattr(m, 'value', str(m)).lower()
481
642
 
482
- result = None
643
+ def _vet(
644
+ text: str | None, source: str, *, model_written: bool = True,
645
+ ) -> str | None:
646
+ """Clean, then reject a non-answer. Returns None if unusable."""
647
+ if not text:
648
+ return None
649
+ cleaned = clean_llm_summary(text)
650
+ rejected, why = is_non_answer(
651
+ cleaned, min_chars=MIN_USEFUL_CHARS, model_written=model_written,
652
+ )
653
+ if rejected:
654
+ logger.info(
655
+ "%s summary for '%s' discarded (%s); falling back",
656
+ source, entity_name, why,
657
+ )
658
+ return None
659
+ return cleaned
660
+
661
+ result: str | None = None
662
+ generated_by = "extractive"
483
663
 
484
- if mode == "a":
485
- result = _summarize_extractive(entity_name, facts)
486
- elif mode == "b":
487
- result = _summarize_with_ollama(entity_name, facts, config)
488
- if not result:
489
- result = _summarize_extractive(entity_name, facts)
664
+ if mode == "b":
665
+ result = _vet(_summarize_with_ollama(entity_name, facts, config), "Ollama")
666
+ if result:
667
+ generated_by = "ollama"
490
668
  elif mode == "c":
491
- result = _summarize_with_cloud_llm(entity_name, facts, config)
492
- if not result:
493
- result = _summarize_with_ollama(entity_name, facts, config)
494
- if not result:
495
- result = _summarize_extractive(entity_name, facts)
496
- else:
497
- result = _summarize_extractive(entity_name, facts)
669
+ result = _vet(_summarize_with_cloud_llm(entity_name, facts, config), "Cloud LLM")
670
+ if result:
671
+ generated_by = "cloud"
672
+ else:
673
+ result = _vet(_summarize_with_ollama(entity_name, facts, config), "Ollama")
674
+ if result:
675
+ generated_by = "ollama"
676
+
677
+ if not result:
678
+ # Extractive is assembled from the facts' own sentences, so it has no
679
+ # opinion to refuse with — and applying the refusal rules to it would
680
+ # reject a summary because the owner's own memory happened to contain a
681
+ # phrase like "the facts provided". Still checked for the two things
682
+ # that do apply: length, and tool-call markup, which a cluster of
683
+ # markup-carrying facts would otherwise reassemble verbatim.
684
+ result = _vet(
685
+ _summarize_extractive(entity_name, facts), "Extractive",
686
+ model_written=False,
687
+ )
688
+ generated_by = "extractive"
498
689
 
499
690
  # Uniform cap across all modes
500
691
  if result and len(result) > _MAX_CONSOLIDATED_CHARS:
501
692
  result = result[:_MAX_CONSOLIDATED_CHARS - 3] + "..."
502
693
 
503
- return result
694
+ return result, generated_by
504
695
 
505
696
 
506
697
  def _summarize_with_ollama(