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
|
@@ -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
|
-
"""
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
5
|
+
"""Fact consolidation — writes 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
|
|
73
|
-
|
|
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,
|
|
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
|
-
|
|
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(
|
|
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["
|
|
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["
|
|
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
|
|
154
|
-
|
|
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["
|
|
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
|
|
242
|
-
stats["consolidated"], stats["
|
|
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
|
-
"""
|
|
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
|
-
#
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
#
|
|
387
|
-
#
|
|
388
|
-
#
|
|
389
|
-
|
|
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
|
|
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 (?, ?, ?, ?, '
|
|
577
|
+
VALUES (?, ?, ?, ?, 'display_summary', ?)
|
|
430
578
|
""", (consolidation_id, profile_id, new_fact_id,
|
|
431
579
|
json.dumps(fact_ids), now))
|
|
432
580
|
|
|
433
|
-
#
|
|
434
|
-
#
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
#
|
|
441
|
-
#
|
|
442
|
-
#
|
|
443
|
-
#
|
|
444
|
-
#
|
|
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
|
-
"
|
|
461
|
-
len(facts),
|
|
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
|
|
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
|
-
|
|
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 == "
|
|
485
|
-
result =
|
|
486
|
-
|
|
487
|
-
|
|
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
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
result =
|
|
496
|
-
|
|
497
|
-
|
|
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(
|