superlocalmemory 4.0.7 → 4.0.9
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 +219 -1
- package/README.md +6 -6
- 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/scripts/ensure-venv.sh +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 +3 -3
- package/plugin-src/rules/AGENTS.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +2 -2
- package/pyproject.toml +1 -1
- package/scripts/postinstall.js +4 -0
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/_lazy_init.py +1 -1
- package/src/superlocalmemory/cli/commands.py +119 -9
- package/src/superlocalmemory/cli/db_migrate.py +0 -2
- package/src/superlocalmemory/cli/gdpr_io.py +1 -1
- package/src/superlocalmemory/cli/main.py +5 -5
- package/src/superlocalmemory/cli/service_installer.py +2 -1
- package/src/superlocalmemory/cli/setup_wizard.py +1 -1
- package/src/superlocalmemory/cli/summary_cmd.py +23 -3
- package/src/superlocalmemory/code_graph/bridge/maintenance.py +7 -1
- package/src/superlocalmemory/core/config.py +41 -7
- package/src/superlocalmemory/core/consolidation_engine.py +14 -15
- package/src/superlocalmemory/core/context_cache.py +0 -2
- package/src/superlocalmemory/core/engine.py +371 -63
- package/src/superlocalmemory/core/evidence_bundle.py +3 -1
- package/src/superlocalmemory/core/install_detector.py +131 -0
- package/src/superlocalmemory/core/progressive_abstraction.py +1 -1
- package/src/superlocalmemory/core/recall_worker.py +4 -0
- package/src/superlocalmemory/core/security_primitives.py +3 -6
- package/src/superlocalmemory/core/store_pipeline.py +94 -26
- package/src/superlocalmemory/core/topic_signature.py +0 -2
- package/src/superlocalmemory/core/transactions/concrete_owners.py +15 -8
- package/src/superlocalmemory/dynamics/eap_scheduler.py +17 -6
- package/src/superlocalmemory/encoding/graph_builder.py +2 -2
- package/src/superlocalmemory/encoding/scene_builder.py +8 -2
- package/src/superlocalmemory/evolution/skill_evolver.py +16 -1
- package/src/superlocalmemory/hooks/adapter_base.py +0 -2
- package/src/superlocalmemory/hooks/context_payload.py +0 -2
- package/src/superlocalmemory/hooks/hook_handlers.py +38 -11
- package/src/superlocalmemory/hooks/portable_kit.py +8 -8
- package/src/superlocalmemory/hooks/post_tool_async_hook.py +0 -2
- package/src/superlocalmemory/hooks/prewarm_auth.py +0 -2
- package/src/superlocalmemory/hooks/user_prompt_hook.py +0 -2
- package/src/superlocalmemory/infra/backup.py +44 -8
- package/src/superlocalmemory/integrations/bounded_loops_mcp.py +24 -7
- package/src/superlocalmemory/learning/arm_catalog.py +0 -2
- package/src/superlocalmemory/learning/bandit.py +0 -2
- package/src/superlocalmemory/learning/bandit_cache.py +0 -2
- package/src/superlocalmemory/learning/dedup_hnsw.py +11 -11
- package/src/superlocalmemory/learning/ensemble.py +0 -2
- package/src/superlocalmemory/learning/labeler.py +0 -2
- package/src/superlocalmemory/learning/legacy_migration.py +0 -2
- package/src/superlocalmemory/learning/model_cache.py +0 -2
- package/src/superlocalmemory/learning/pattern_miner.py +12 -7
- package/src/superlocalmemory/learning/ranker.py +0 -2
- package/src/superlocalmemory/learning/reward_archive.py +6 -1
- package/src/superlocalmemory/learning/reward_proxy.py +0 -2
- package/src/superlocalmemory/learning/signal_worker.py +0 -2
- package/src/superlocalmemory/math/fisher.py +1 -1
- package/src/superlocalmemory/math/hopfield.py +4 -1
- package/src/superlocalmemory/math/langevin.py +1 -1
- package/src/superlocalmemory/math/sheaf.py +7 -3
- package/src/superlocalmemory/mcp/cli_fallback.py +1 -1
- package/src/superlocalmemory/mcp/profiles.py +11 -4
- package/src/superlocalmemory/mcp/server.py +8 -1
- package/src/superlocalmemory/mcp/tools_active.py +56 -0
- package/src/superlocalmemory/mcp/tools_core.py +1 -1
- package/src/superlocalmemory/mcp/tools_summaries.py +147 -0
- package/src/superlocalmemory/optimize/cache/manager.py +2 -2
- package/src/superlocalmemory/optimize/compress/ccr.py +1 -1
- package/src/superlocalmemory/optimize/compress/router.py +1 -1
- package/src/superlocalmemory/optimize/proxy/_helpers.py +2 -2
- package/src/superlocalmemory/optimize/proxy/server.py +1 -1
- package/src/superlocalmemory/optimize/proxy/vertex_surface.py +2 -2
- package/src/superlocalmemory/optimize/storage/db.py +2 -2
- package/src/superlocalmemory/retrieval/agentic.py +1 -1
- package/src/superlocalmemory/retrieval/ann_index.py +9 -2
- package/src/superlocalmemory/retrieval/bm25_channel.py +2 -2
- package/src/superlocalmemory/retrieval/bridge_discovery.py +2 -2
- package/src/superlocalmemory/retrieval/engine.py +272 -43
- package/src/superlocalmemory/retrieval/entity_channel.py +1 -1
- package/src/superlocalmemory/retrieval/hopfield_channel.py +8 -2
- package/src/superlocalmemory/retrieval/profile_channel.py +1 -1
- package/src/superlocalmemory/retrieval/quantization_aware_search.py +1 -1
- package/src/superlocalmemory/retrieval/remote_reranker.py +2 -2
- package/src/superlocalmemory/retrieval/reranker.py +3 -3
- package/src/superlocalmemory/retrieval/semantic_channel.py +3 -3
- package/src/superlocalmemory/retrieval/spreading_activation.py +8 -8
- package/src/superlocalmemory/retrieval/strategy.py +94 -0
- package/src/superlocalmemory/retrieval/temporal_channel.py +167 -10
- package/src/superlocalmemory/retrieval/temporal_validity_filter.py +1 -1
- package/src/superlocalmemory/retrieval/vector_store.py +88 -10
- package/src/superlocalmemory/server/consolidation_runner.py +140 -0
- package/src/superlocalmemory/server/recall_serializer.py +44 -2
- package/src/superlocalmemory/server/routes/agents.py +52 -8
- package/src/superlocalmemory/server/routes/brain.py +110 -2
- package/src/superlocalmemory/server/routes/memories.py +153 -0
- package/src/superlocalmemory/server/routes/prewarm.py +4 -4
- package/src/superlocalmemory/server/routes/v3_api.py +24 -46
- package/src/superlocalmemory/server/unified_daemon.py +566 -7
- package/src/superlocalmemory/storage/_schema_version.py +46 -3
- package/src/superlocalmemory/storage/backup.py +531 -0
- package/src/superlocalmemory/storage/database.py +11 -4
- package/src/superlocalmemory/storage/embedding_codec.py +129 -0
- package/src/superlocalmemory/storage/embedding_migrator.py +5 -3
- package/src/superlocalmemory/storage/migration_runner.py +142 -2
- package/src/superlocalmemory/storage/migrations/__init__.py +1 -1
- package/src/superlocalmemory/storage/migrations.py +15 -1
- package/src/superlocalmemory/storage/models.py +7 -0
- package/src/superlocalmemory/storage/quantized_store.py +4 -2
- package/src/superlocalmemory/summaries/base.py +159 -0
- package/src/superlocalmemory/summaries/daily_reflection.py +55 -8
- package/src/superlocalmemory/summaries/project_work_log.py +23 -7
- package/src/superlocalmemory/summaries/session_summary.py +10 -6
- package/src/superlocalmemory/ui/css/legacy-dashboard.css +1 -1
- package/src/superlocalmemory/ui/css/neural-glass.css +1 -1
- package/src/superlocalmemory/ui/index.html +9 -3
- package/src/superlocalmemory/ui/js/core.js +1 -1
- package/src/superlocalmemory/ui/js/od-boundedloops.js +324 -0
- package/src/superlocalmemory/ui/js/od-brain.js +1 -1
- package/src/superlocalmemory/ui/js/od-memories.js +337 -12
- package/src/superlocalmemory/ui/js/od-mesh.js +97 -5
- package/src/superlocalmemory/ui/js/od-operations.js +1 -150
- package/src/superlocalmemory/ui/js/od-optimize.js +36 -9
- package/src/superlocalmemory/ui/js/od-shell.js +10 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Encode and decode atomic_facts.embedding values.
|
|
2
|
+
|
|
3
|
+
All read and write paths for atomic_facts.embedding must go through
|
|
4
|
+
``encode_embedding`` and ``decode_embedding``. Centralising the logic here
|
|
5
|
+
means a future format change requires one edit, not one per reader.
|
|
6
|
+
|
|
7
|
+
Storage format
|
|
8
|
+
--------------
|
|
9
|
+
New rows: 768 × float32, little-endian, stored as SQLite BLOB (3,072 bytes).
|
|
10
|
+
Legacy rows: JSON TEXT produced by json.dumps(list[float]).
|
|
11
|
+
|
|
12
|
+
The read path accepts both formats so a partial backfill is safe by
|
|
13
|
+
construction: callers see ``list[float]`` regardless of storage format.
|
|
14
|
+
|
|
15
|
+
Error contract
|
|
16
|
+
--------------
|
|
17
|
+
A value that is neither valid JSON nor a well-formed float32 buffer raises
|
|
18
|
+
``ValueError`` with the fact_id in the message. Returning ``None`` silently
|
|
19
|
+
for a malformed value is forbidden: the caller cannot distinguish a legitimate
|
|
20
|
+
absent embedding from a data-loss event.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import logging
|
|
26
|
+
from typing import TYPE_CHECKING
|
|
27
|
+
|
|
28
|
+
import numpy as np
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"EMBEDDING_DIM",
|
|
37
|
+
"EMBEDDING_BYTES",
|
|
38
|
+
"encode_embedding",
|
|
39
|
+
"decode_embedding",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
EMBEDDING_DIM: int = 768
|
|
43
|
+
EMBEDDING_BYTES: int = EMBEDDING_DIM * 4 # float32 = 4 bytes
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def encode_embedding(vec: list[float] | None) -> bytes | None:
|
|
47
|
+
"""Serialise a float list to a binary float32 BLOB for SQLite storage.
|
|
48
|
+
|
|
49
|
+
Parameters
|
|
50
|
+
----------
|
|
51
|
+
vec:
|
|
52
|
+
A list of floats, or ``None``. Production embeddings are always
|
|
53
|
+
768-dimensional; the backfill script asserts the dimension before
|
|
54
|
+
calling this function.
|
|
55
|
+
|
|
56
|
+
Returns
|
|
57
|
+
-------
|
|
58
|
+
bytes | None
|
|
59
|
+
Little-endian float32 buffer, or ``None`` if *vec* is ``None``.
|
|
60
|
+
"""
|
|
61
|
+
if vec is None:
|
|
62
|
+
return None
|
|
63
|
+
return np.array(vec, dtype=np.float32).tobytes()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def decode_embedding(
|
|
67
|
+
raw: bytes | str | None,
|
|
68
|
+
*,
|
|
69
|
+
fact_id: str = "<unknown>",
|
|
70
|
+
) -> list[float] | None:
|
|
71
|
+
"""Deserialise an embedding from either TEXT (JSON) or BLOB (binary float32).
|
|
72
|
+
|
|
73
|
+
Parameters
|
|
74
|
+
----------
|
|
75
|
+
raw:
|
|
76
|
+
The raw value from ``atomic_facts.embedding``:
|
|
77
|
+
``None`` or empty string → absent embedding (returns ``None``).
|
|
78
|
+
``bytes`` → binary float32 BLOB path.
|
|
79
|
+
``str`` → legacy JSON TEXT path.
|
|
80
|
+
fact_id:
|
|
81
|
+
Included in any ``ValueError`` message for fast triage.
|
|
82
|
+
|
|
83
|
+
Returns
|
|
84
|
+
-------
|
|
85
|
+
list[float] | None
|
|
86
|
+
768-element list of floats, or ``None`` when the embedding is absent.
|
|
87
|
+
|
|
88
|
+
Raises
|
|
89
|
+
------
|
|
90
|
+
ValueError
|
|
91
|
+
For a non-null value that is neither valid JSON nor a well-formed
|
|
92
|
+
float32 buffer. Never returns ``None`` for a malformed value.
|
|
93
|
+
"""
|
|
94
|
+
if raw is None or raw == "":
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
if isinstance(raw, (bytes, bytearray)):
|
|
98
|
+
if len(raw) % 4 != 0 or len(raw) == 0:
|
|
99
|
+
raise ValueError(
|
|
100
|
+
f"Corrupt embedding buffer for fact {fact_id!r}: "
|
|
101
|
+
f"{len(raw)} bytes is not a multiple of 4 (float32)"
|
|
102
|
+
)
|
|
103
|
+
if len(raw) != EMBEDDING_BYTES:
|
|
104
|
+
# A torn write that happens to land on a 4-byte boundary is
|
|
105
|
+
# indistinguishable from a short vector by length alone, and it was
|
|
106
|
+
# accepted silently at debug level: 767 of 768 values still looks
|
|
107
|
+
# like a valid embedding, and every similarity computed from it is
|
|
108
|
+
# quietly wrong. Smaller vectors ARE legitimate in tests, so this is
|
|
109
|
+
# a warning rather than a refusal, but it must be visible.
|
|
110
|
+
logger.warning(
|
|
111
|
+
"embedding for fact %s is %d bytes (%d floats), not the expected "
|
|
112
|
+
"%d (%d floats) — expected only for a test vector; on a real "
|
|
113
|
+
"store this is a truncated write",
|
|
114
|
+
fact_id, len(raw), len(raw) // 4, EMBEDDING_BYTES, EMBEDDING_DIM,
|
|
115
|
+
)
|
|
116
|
+
return np.frombuffer(raw, dtype=np.float32).tolist()
|
|
117
|
+
|
|
118
|
+
if isinstance(raw, str):
|
|
119
|
+
try:
|
|
120
|
+
return json.loads(raw)
|
|
121
|
+
except (json.JSONDecodeError, ValueError) as exc:
|
|
122
|
+
raise ValueError(
|
|
123
|
+
f"Corrupt JSON embedding for fact {fact_id!r}: {exc}"
|
|
124
|
+
) from exc
|
|
125
|
+
|
|
126
|
+
raise ValueError(
|
|
127
|
+
f"Unexpected embedding type {type(raw).__name__!r} for fact {fact_id!r}; "
|
|
128
|
+
f"expected bytes or str"
|
|
129
|
+
)
|
|
@@ -29,6 +29,8 @@ from typing import TYPE_CHECKING, Any
|
|
|
29
29
|
|
|
30
30
|
import numpy as np
|
|
31
31
|
|
|
32
|
+
from superlocalmemory.storage.embedding_codec import encode_embedding
|
|
33
|
+
|
|
32
34
|
if TYPE_CHECKING:
|
|
33
35
|
from superlocalmemory.core.config import SLMConfig
|
|
34
36
|
|
|
@@ -185,7 +187,7 @@ def _activate_staged_vectors(
|
|
|
185
187
|
updated = conn.execute(
|
|
186
188
|
"UPDATE atomic_facts SET embedding = ? "
|
|
187
189
|
"WHERE fact_id = ? AND profile_id = ?",
|
|
188
|
-
(
|
|
190
|
+
(encode_embedding(vector), fact_id, profile_id),
|
|
189
191
|
)
|
|
190
192
|
if updated.rowcount != 1:
|
|
191
193
|
raise RuntimeError(
|
|
@@ -606,7 +608,7 @@ def backfill_missing_embeddings(
|
|
|
606
608
|
logger.warning("backfill: null vector for fact %s — skipping.", fid[:16])
|
|
607
609
|
continue
|
|
608
610
|
try:
|
|
609
|
-
|
|
611
|
+
embedding_blob = encode_embedding(vec)
|
|
610
612
|
# Metadata is not an independent record: it is the pointer to
|
|
611
613
|
# a sqlite-vec row. Creating it before the vector payload leaves
|
|
612
614
|
# semantic recall permanently blind while reporting success.
|
|
@@ -638,7 +640,7 @@ def backfill_missing_embeddings(
|
|
|
638
640
|
# this remains the supported JSON-only fallback path.
|
|
639
641
|
db.execute(
|
|
640
642
|
"UPDATE atomic_facts SET embedding = ? WHERE fact_id = ?",
|
|
641
|
-
(
|
|
643
|
+
(embedding_blob, fid),
|
|
642
644
|
)
|
|
643
645
|
except Exception:
|
|
644
646
|
if projection_written and vector_store is not None:
|
|
@@ -4,8 +4,6 @@
|
|
|
4
4
|
|
|
5
5
|
"""Forward-only additive migrations for SLM v3.4.22.
|
|
6
6
|
|
|
7
|
-
LLD reference: ``.backup/active-brain/lld/LLD-07-schema-migrations-and-security-primitives.md``
|
|
8
|
-
Section 4 (Migration Runner).
|
|
9
7
|
|
|
10
8
|
Contract:
|
|
11
9
|
- ``apply_all(learning_db, memory_db, *, dry_run=False) -> dict`` —
|
|
@@ -34,6 +32,7 @@ catalogue and the public orchestration functions.
|
|
|
34
32
|
from __future__ import annotations
|
|
35
33
|
|
|
36
34
|
import logging
|
|
35
|
+
import os
|
|
37
36
|
import sqlite3
|
|
38
37
|
from pathlib import Path
|
|
39
38
|
|
|
@@ -177,6 +176,10 @@ from superlocalmemory.storage._migration_internals import (
|
|
|
177
176
|
_migration_log_exists,
|
|
178
177
|
_read_log,
|
|
179
178
|
)
|
|
179
|
+
from superlocalmemory.storage.backup import (
|
|
180
|
+
_gc_old_backups,
|
|
181
|
+
_pre_migration_backup,
|
|
182
|
+
)
|
|
180
183
|
|
|
181
184
|
logger = logging.getLogger(__name__)
|
|
182
185
|
|
|
@@ -375,6 +378,61 @@ def _bootstrap_learning_schema(learning_db: Path, *, dry_run: bool) -> str | Non
|
|
|
375
378
|
return None
|
|
376
379
|
|
|
377
380
|
|
|
381
|
+
def _foreign_live_daemon(memory_db: Path) -> "int | None":
|
|
382
|
+
"""Return the pid of another live daemon holding this data dir, or None.
|
|
383
|
+
|
|
384
|
+
Migrations are not fenced against concurrent writers. The realistic hazard
|
|
385
|
+
is an OLD daemon still running after an upgrade while a NEW one starts: its
|
|
386
|
+
WAL appends continue while DDL is applied, which can make a migration fail
|
|
387
|
+
non-deterministically. The snapshot itself stays consistent — the SQLite
|
|
388
|
+
backup API copies committed pages only — and a racing migration is recorded
|
|
389
|
+
as ``failed`` and is non-fatal, so this does not corrupt data.
|
|
390
|
+
|
|
391
|
+
This detects the condition and reports it. It deliberately does NOT refuse:
|
|
392
|
+
``apply_all`` runs inside the daemon's own startup, so refusing whenever "a
|
|
393
|
+
daemon is running" would refuse on itself, and blocking on a lock here would
|
|
394
|
+
risk wedging startup — a worse outcome than a retryable failed step.
|
|
395
|
+
"""
|
|
396
|
+
try:
|
|
397
|
+
pid_file = memory_db.parent / "daemon.pid"
|
|
398
|
+
if not pid_file.is_file():
|
|
399
|
+
return None
|
|
400
|
+
pid = int(pid_file.read_text().strip() or 0)
|
|
401
|
+
if pid <= 0 or pid == os.getpid():
|
|
402
|
+
return None
|
|
403
|
+
os.kill(pid, 0) # signal 0 tests liveness without touching it
|
|
404
|
+
return pid
|
|
405
|
+
except (OSError, ValueError):
|
|
406
|
+
return None
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _nothing_left_to_apply(learning_db: Path, memory_db: Path) -> bool:
|
|
410
|
+
"""True when every migration is already recorded in its target database.
|
|
411
|
+
|
|
412
|
+
Used to decide whether a snapshot is worth taking. A snapshot is only
|
|
413
|
+
valuable when something is about to change; taking one on a start where
|
|
414
|
+
nothing changes copies the ALREADY-MIGRATED store and then prunes a
|
|
415
|
+
generation — so after two such starts the last copy of the original is gone,
|
|
416
|
+
and the safety net has quietly deleted the thing it exists to protect.
|
|
417
|
+
|
|
418
|
+
Errs toward False, which means "take the snapshot" — the safe direction.
|
|
419
|
+
"""
|
|
420
|
+
try:
|
|
421
|
+
for migration in MIGRATIONS:
|
|
422
|
+
db_path = _db_for(migration.db_target, learning_db, memory_db)
|
|
423
|
+
if not db_path.exists():
|
|
424
|
+
return False
|
|
425
|
+
conn = _connect(db_path)
|
|
426
|
+
try:
|
|
427
|
+
if not _deferred_already_applied(conn, migration.name):
|
|
428
|
+
return False
|
|
429
|
+
finally:
|
|
430
|
+
conn.close()
|
|
431
|
+
except Exception: # noqa: BLE001 — any doubt means take the snapshot
|
|
432
|
+
return False
|
|
433
|
+
return True
|
|
434
|
+
|
|
435
|
+
|
|
378
436
|
def apply_all(
|
|
379
437
|
learning_db: Path,
|
|
380
438
|
memory_db: Path,
|
|
@@ -401,6 +459,44 @@ def apply_all(
|
|
|
401
459
|
failed: list[str] = []
|
|
402
460
|
details: dict[str, str] = {}
|
|
403
461
|
|
|
462
|
+
# Take a consistent snapshot of both databases before any migration runs.
|
|
463
|
+
# The backup uses the SQLite backup API so in-flight WAL writers are
|
|
464
|
+
# never captured mid-transaction. InsufficientDiskSpaceError propagates
|
|
465
|
+
# to the caller — migration is intentionally aborted when disk is too
|
|
466
|
+
# tight to keep a recoverable copy.
|
|
467
|
+
# A snapshot is only worth taking when something is about to change. This
|
|
468
|
+
# runs on every engine construction, not just upgrades, so snapshotting
|
|
469
|
+
# unconditionally meant an ordinary start copied the already-migrated store
|
|
470
|
+
# and pruned a generation — two extra starts and the original was gone.
|
|
471
|
+
_pending = not _nothing_left_to_apply(learning_db, memory_db)
|
|
472
|
+
if not dry_run and not _pending:
|
|
473
|
+
details["_backup"] = "skipped: every migration already applied"
|
|
474
|
+
|
|
475
|
+
if not dry_run and _pending:
|
|
476
|
+
_other = _foreign_live_daemon(memory_db)
|
|
477
|
+
if _other is not None:
|
|
478
|
+
logger.warning(
|
|
479
|
+
"Another SuperLocalMemory daemon (pid %s) is still running and "
|
|
480
|
+
"writing to this data directory. Migrations are not fenced "
|
|
481
|
+
"against concurrent writers, so a step may fail and need a "
|
|
482
|
+
"retry. Your data is not at risk: the snapshot copies committed "
|
|
483
|
+
"pages only, and a failed step is recorded, never forced. Stop "
|
|
484
|
+
"the other daemon and restart if a step fails.",
|
|
485
|
+
_other,
|
|
486
|
+
)
|
|
487
|
+
details["_concurrent_daemon_pid"] = str(_other)
|
|
488
|
+
|
|
489
|
+
backup_dir = _pre_migration_backup(
|
|
490
|
+
learning_db, memory_db,
|
|
491
|
+
backups_root=memory_db.parent / "pre-migration-snapshots",
|
|
492
|
+
)
|
|
493
|
+
# _pre_migration_backup returns the snapshots root itself, so this is
|
|
494
|
+
# the directory to prune. Passing .parent pointed the collector at the
|
|
495
|
+
# data directory, where it matched nothing and pruned nothing — leaving
|
|
496
|
+
# every snapshot on disk for ever.
|
|
497
|
+
_gc_old_backups(backup_dir)
|
|
498
|
+
details["_backup"] = str(backup_dir)
|
|
499
|
+
|
|
404
500
|
schema_error = _bootstrap_learning_schema(learning_db, dry_run=dry_run)
|
|
405
501
|
if schema_error is not None:
|
|
406
502
|
failed.append("learning_schema_bootstrap")
|
|
@@ -461,6 +557,28 @@ def apply_all(
|
|
|
461
557
|
}
|
|
462
558
|
|
|
463
559
|
|
|
560
|
+
def _deferred_already_applied(conn: sqlite3.Connection, name: str) -> bool:
|
|
561
|
+
"""True when ``name`` is recorded as ``complete`` in this database's migration_log.
|
|
562
|
+
|
|
563
|
+
Used only to decide whether a snapshot is needed. On any error it returns
|
|
564
|
+
False, which errs toward taking a snapshot — the safe direction.
|
|
565
|
+
|
|
566
|
+
A row whose status is ``failed`` or ``in_progress`` is NOT considered applied:
|
|
567
|
+
the runner will retry those entries, and the store deserves a fresh snapshot
|
|
568
|
+
before any retry runs DDL against it. Counting any row (regardless of status)
|
|
569
|
+
caused ``_nothing_left_to_apply`` to return True after a failed migration,
|
|
570
|
+
so the retry ran against the already-partial store with no new safety copy.
|
|
571
|
+
"""
|
|
572
|
+
try:
|
|
573
|
+
row = conn.execute(
|
|
574
|
+
"SELECT 1 FROM migration_log WHERE name = ? AND status = 'complete' LIMIT 1",
|
|
575
|
+
(name,),
|
|
576
|
+
).fetchone()
|
|
577
|
+
return row is not None
|
|
578
|
+
except sqlite3.Error:
|
|
579
|
+
return False
|
|
580
|
+
|
|
581
|
+
|
|
464
582
|
def apply_deferred(
|
|
465
583
|
learning_db: Path,
|
|
466
584
|
memory_db: Path,
|
|
@@ -495,6 +613,25 @@ def apply_deferred(
|
|
|
495
613
|
failed: list[str] = []
|
|
496
614
|
details: dict[str, str] = {}
|
|
497
615
|
|
|
616
|
+
# apply_all snapshots before it touches anything; this pass did not, yet it
|
|
617
|
+
# applies real DDL to both managed databases — including the column the
|
|
618
|
+
# daemon needs to start. An interrupted deferred pass therefore had no
|
|
619
|
+
# recoverable copy at all. The snapshot is taken LAZILY, immediately before
|
|
620
|
+
# the first migration that will actually be applied, so a pass with nothing
|
|
621
|
+
# to do costs no disk and does not capture post-init state unnecessarily.
|
|
622
|
+
_snapshot_state: dict[str, object] = {"taken": dry_run}
|
|
623
|
+
|
|
624
|
+
def _ensure_snapshot() -> None:
|
|
625
|
+
if _snapshot_state["taken"]:
|
|
626
|
+
return
|
|
627
|
+
_snapshot_state["taken"] = True
|
|
628
|
+
backup_dir = _pre_migration_backup(
|
|
629
|
+
learning_db, memory_db,
|
|
630
|
+
backups_root=memory_db.parent / "pre-migration-snapshots",
|
|
631
|
+
)
|
|
632
|
+
_gc_old_backups(backup_dir)
|
|
633
|
+
details["_deferred_backup"] = str(backup_dir)
|
|
634
|
+
|
|
498
635
|
blocked: set[str] = set()
|
|
499
636
|
for migration in DEFERRED_MIGRATIONS:
|
|
500
637
|
unmet = [d for d in migration.dependencies if d in failed or d in blocked]
|
|
@@ -529,6 +666,9 @@ def apply_deferred(
|
|
|
529
666
|
)
|
|
530
667
|
continue
|
|
531
668
|
|
|
669
|
+
if not dry_run and not _deferred_already_applied(conn, migration.name):
|
|
670
|
+
_ensure_snapshot()
|
|
671
|
+
|
|
532
672
|
outcome, detail = _apply_single(conn, migration, dry_run=dry_run)
|
|
533
673
|
details[migration.name] = detail
|
|
534
674
|
if outcome == "applied":
|
|
@@ -40,7 +40,7 @@ from . import (
|
|
|
40
40
|
# ``superlocalmemory/storage/migrations.py`` shipped in v3.4.20 carried
|
|
41
41
|
# ``CURRENT_SCHEMA_VERSION`` / ``get_schema_version`` / ``set_schema_version``
|
|
42
42
|
# / ``is_v1_database`` / ``needs_migration`` / ``backup_database``. Creating
|
|
43
|
-
# the ``migrations/`` package in
|
|
43
|
+
# the ``migrations/`` package (introduced in v3.4) shadowed that flat module, so any
|
|
44
44
|
# caller that did ``from superlocalmemory.storage.migrations import X``
|
|
45
45
|
# broke. We re-load the legacy module under a distinct name and re-export
|
|
46
46
|
# the symbols here so pre-3.4.22 callers (and the pre-existing tests at
|
|
@@ -16,6 +16,7 @@ import json
|
|
|
16
16
|
import logging
|
|
17
17
|
import shutil
|
|
18
18
|
import sqlite3
|
|
19
|
+
import warnings
|
|
19
20
|
from datetime import UTC, datetime
|
|
20
21
|
from pathlib import Path
|
|
21
22
|
from typing import Any
|
|
@@ -75,7 +76,20 @@ def is_v1_database(db_path: Path) -> bool:
|
|
|
75
76
|
|
|
76
77
|
|
|
77
78
|
def backup_database(db_path: Path) -> Path:
|
|
78
|
-
"""Create timestamped
|
|
79
|
+
"""Create a timestamped copy of db_path alongside the source file.
|
|
80
|
+
|
|
81
|
+
.. deprecated::
|
|
82
|
+
Use the pre-migration backup in ``superlocalmemory.storage.backup``
|
|
83
|
+
instead, which produces a WAL-consistent snapshot via the SQLite
|
|
84
|
+
backup API rather than a raw filesystem copy.
|
|
85
|
+
"""
|
|
86
|
+
warnings.warn(
|
|
87
|
+
"backup_database() uses shutil.copy2 which cannot produce a "
|
|
88
|
+
"consistent snapshot of a live WAL database. "
|
|
89
|
+
"Use superlocalmemory.storage.backup._pre_migration_backup() instead.",
|
|
90
|
+
DeprecationWarning,
|
|
91
|
+
stacklevel=2,
|
|
92
|
+
)
|
|
79
93
|
timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S")
|
|
80
94
|
backup_path = db_path.with_suffix(f".backup_{timestamp}.db")
|
|
81
95
|
shutil.copy2(str(db_path), str(backup_path))
|
|
@@ -452,3 +452,10 @@ class RecallResponse:
|
|
|
452
452
|
# fall into (thematic context). None unless results cluster into one
|
|
453
453
|
# community above threshold. Additive — backward compatible.
|
|
454
454
|
community_context: dict | None = None
|
|
455
|
+
# Channels abandoned at the hang guard, so they contributed no candidates
|
|
456
|
+
# to this answer. Non-empty means the result is INCOMPLETE, not merely
|
|
457
|
+
# slow: asking the same question again on an idle machine can legitimately
|
|
458
|
+
# return something better. Empty is the normal case and the only one in
|
|
459
|
+
# which two runs of one query are expected to agree. Sorted, so the field
|
|
460
|
+
# is itself repeatable. Additive — backward compatible.
|
|
461
|
+
incomplete_channels: tuple[str, ...] = ()
|
|
@@ -214,8 +214,10 @@ class QuantizedEmbeddingStore:
|
|
|
214
214
|
|
|
215
215
|
results.append((qe.fact_id, sim))
|
|
216
216
|
|
|
217
|
-
# Sort descending
|
|
218
|
-
|
|
217
|
+
# Sort by descending similarity; use fact_id as the tie-break so that
|
|
218
|
+
# two facts with identical approximate similarity always return in the
|
|
219
|
+
# same order regardless of SQLite storage layout or insertion sequence.
|
|
220
|
+
results.sort(key=lambda x: (-x[1], x[0]))
|
|
219
221
|
return results[:top_k]
|
|
220
222
|
|
|
221
223
|
# -- Compression helpers -----------------------------------------------
|
|
@@ -98,6 +98,165 @@ GENERATED_BY_LLM_C = "llm_c"
|
|
|
98
98
|
"""Cloud LLM (Mode C). Falls back via llm_b to extractive."""
|
|
99
99
|
|
|
100
100
|
|
|
101
|
+
# ── highlight formatting ────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
#: Display width for one bullet in a summary body.
|
|
104
|
+
#:
|
|
105
|
+
#: Chosen for a bullet, not for a paragraph. The generators originally truncated
|
|
106
|
+
#: at 300 characters and nothing else, which looks fine on a synthetic corpus of
|
|
107
|
+
#: one-line facts and falls apart on a real store: agent-written facts routinely
|
|
108
|
+
#: contain blank lines and markdown headings, so a 300-character slice rendered
|
|
109
|
+
#: as six or more display lines and the bullet list stopped being a list.
|
|
110
|
+
HIGHLIGHT_CHARS = 180
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def format_highlight(content: str, limit: int = HIGHLIGHT_CHARS) -> str:
|
|
114
|
+
"""Collapse *content* to a single readable line for a summary bullet.
|
|
115
|
+
|
|
116
|
+
Three things, in order:
|
|
117
|
+
|
|
118
|
+
1. **Flatten whitespace.** Newlines, blank lines and runs of spaces all
|
|
119
|
+
become one space. This is the fix that matters: character truncation
|
|
120
|
+
alone cannot keep a multi-paragraph fact on one line, and every
|
|
121
|
+
generator here writes into a bullet list.
|
|
122
|
+
2. **Prefer a whole first sentence** when there is one and it fits. A
|
|
123
|
+
complete sentence reads better than a slice of one, and the first
|
|
124
|
+
sentence of a report is usually its summary.
|
|
125
|
+
3. **Otherwise cut at a word boundary** and mark the cut with an ellipsis,
|
|
126
|
+
so it is visible that text was dropped rather than that a fact ended
|
|
127
|
+
mid-word.
|
|
128
|
+
|
|
129
|
+
Markdown heading markers are stripped because a flattened ``**Summary**``
|
|
130
|
+
mid-sentence reads as noise.
|
|
131
|
+
"""
|
|
132
|
+
import re
|
|
133
|
+
|
|
134
|
+
text = re.sub(r"\s+", " ", (content or "")).strip()
|
|
135
|
+
# Leading/inline markdown emphasis and heading marks, once flattened, add
|
|
136
|
+
# nothing but clutter to a one-line bullet.
|
|
137
|
+
text = re.sub(r"(?:^|\s)#{1,6}\s+", " ", text)
|
|
138
|
+
text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
|
|
139
|
+
text = re.sub(r"\s+", " ", text).strip()
|
|
140
|
+
|
|
141
|
+
if not text:
|
|
142
|
+
return ""
|
|
143
|
+
if len(text) <= limit:
|
|
144
|
+
return text
|
|
145
|
+
|
|
146
|
+
# A complete first sentence, if it fits comfortably.
|
|
147
|
+
match = re.match(r"(.+?[.!?])(?:\s|$)", text)
|
|
148
|
+
if match:
|
|
149
|
+
sentence = match.group(1).strip()
|
|
150
|
+
if len(sentence) <= limit:
|
|
151
|
+
return sentence
|
|
152
|
+
|
|
153
|
+
cut = text[:limit]
|
|
154
|
+
space = cut.rfind(" ")
|
|
155
|
+
if space > limit * 0.6: # don't cut a long unbroken token to a stub
|
|
156
|
+
cut = cut[:space]
|
|
157
|
+
return cut.rstrip(" ,;:—-") + "…"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ── LLM output cleanup ──────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
#: Sentences a chat-tuned model emits *around* the answer rather than as part of
|
|
163
|
+
#: it. Anchored to the start of a paragraph so they cannot match mid-content.
|
|
164
|
+
#:
|
|
165
|
+
#: Measured, not guessed: a Mode B summary on the author's own store opened with
|
|
166
|
+
#: "I apologize for the previous confusion. It seems that I misunderstood the
|
|
167
|
+
#: context of the texts provided.\n\nTo provide a concise summary paragraph, here
|
|
168
|
+
#: is a merge of all the key information:" — 180 characters of the model talking
|
|
169
|
+
#: to itself, shown to the user as their daily reflection.
|
|
170
|
+
_LLM_PREAMBLE = (
|
|
171
|
+
r"^(?:"
|
|
172
|
+
r"i\s+apologi[sz]e\b.*"
|
|
173
|
+
r"|i'?m\s+sorry\b.*"
|
|
174
|
+
r"|it\s+seems\s+that\s+i\b.*"
|
|
175
|
+
r"|sure[,!.]?\s*(?:thing)?\b.*"
|
|
176
|
+
r"|certainly[,!.]?\b.*"
|
|
177
|
+
r"|of\s+course[,!.]?\b.*"
|
|
178
|
+
r"|here(?:'s|\s+is|\s+are)\b[^.!?]*:"
|
|
179
|
+
r"|to\s+(?:provide|summari[sz]e|answer)\b[^.!?]*:"
|
|
180
|
+
r"|based\s+on\s+the\s+(?:facts|texts|information|data)\s+provided[,:]?"
|
|
181
|
+
r"|as\s+(?:an|a)\s+(?:ai|language\s+model)\b.*"
|
|
182
|
+
r")\s*$"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
#: Closing pleasantries. Same anchoring rule.
|
|
186
|
+
_LLM_POSTAMBLE = (
|
|
187
|
+
r"^(?:"
|
|
188
|
+
r"(?:i\s+hope|hope)\s+(?:this|that)\s+helps\b.*"
|
|
189
|
+
r"|let\s+me\s+know\b.*"
|
|
190
|
+
r"|feel\s+free\s+to\b.*"
|
|
191
|
+
r"|would\s+you\s+like\s+me\s+to\b.*"
|
|
192
|
+
r")$"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def clean_llm_summary(text: str) -> str:
|
|
197
|
+
"""Strip chat-assistant scaffolding from a model-written summary.
|
|
198
|
+
|
|
199
|
+
WHY THIS EXISTS
|
|
200
|
+
---------------
|
|
201
|
+
Mode B/C summaries are shown to the user as *their* memory, with no chat
|
|
202
|
+
framing around them. A chat-tuned model does not know that: it opens with an
|
|
203
|
+
apology or "Here is a concise summary:" and closes with "Let me know if you
|
|
204
|
+
want more detail". Both are addressed to a conversation that the reader
|
|
205
|
+
cannot see, and both make the product look broken.
|
|
206
|
+
|
|
207
|
+
The system prompt now asks for bare prose, which handles most of it. This is
|
|
208
|
+
the second line of defence, because instruction-following on a 3B local
|
|
209
|
+
model is not something to bet the displayed output on.
|
|
210
|
+
|
|
211
|
+
Conservative by construction: patterns are anchored to whole paragraphs or
|
|
212
|
+
whole leading sentences, so a summary that legitimately contains the word
|
|
213
|
+
"sure" mid-paragraph is untouched. If stripping would empty the text, the
|
|
214
|
+
original is returned — a scaffolded summary beats a blank one.
|
|
215
|
+
"""
|
|
216
|
+
import re
|
|
217
|
+
|
|
218
|
+
original = (text or "").strip()
|
|
219
|
+
if not original:
|
|
220
|
+
return ""
|
|
221
|
+
|
|
222
|
+
# Fenced code blocks wrapping the whole answer: keep the contents.
|
|
223
|
+
fenced = re.match(r"^```[a-zA-Z]*\n(.*?)\n?```$", original, re.DOTALL)
|
|
224
|
+
if fenced:
|
|
225
|
+
original = fenced.group(1).strip()
|
|
226
|
+
|
|
227
|
+
paras = [p.strip() for p in re.split(r"\n\s*\n", original) if p.strip()]
|
|
228
|
+
|
|
229
|
+
while paras and re.match(_LLM_PREAMBLE, paras[0], re.IGNORECASE | re.DOTALL):
|
|
230
|
+
paras.pop(0)
|
|
231
|
+
while paras and re.match(_LLM_POSTAMBLE, paras[-1], re.IGNORECASE | re.DOTALL):
|
|
232
|
+
paras.pop()
|
|
233
|
+
|
|
234
|
+
if not paras:
|
|
235
|
+
return original
|
|
236
|
+
|
|
237
|
+
# A preamble that shares a paragraph with real content: drop just the leading
|
|
238
|
+
# sentence, and only when what follows is substantial enough to stand alone.
|
|
239
|
+
lead = re.match(r"(.+?[.:!?])\s+(\S.*)$", paras[0], re.DOTALL)
|
|
240
|
+
if lead and re.match(_LLM_PREAMBLE, lead.group(1), re.IGNORECASE | re.DOTALL):
|
|
241
|
+
if len(lead.group(2).strip()) > 40:
|
|
242
|
+
paras[0] = lead.group(2).strip()
|
|
243
|
+
|
|
244
|
+
cleaned = "\n\n".join(paras).strip()
|
|
245
|
+
return cleaned or original
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
#: System prompt for every summary generator, Mode B and Mode C alike.
|
|
249
|
+
#:
|
|
250
|
+
#: Mode C always sent one; Mode B sent none at all, which is why local-model
|
|
251
|
+
#: output arrived wrapped in chat scaffolding while cloud output did not.
|
|
252
|
+
SUMMARY_SYSTEM_PROMPT = (
|
|
253
|
+
"You summarise a person's own saved notes for them. "
|
|
254
|
+
"Reply with the summary text only — no preamble, no apologies, no sign-off, "
|
|
255
|
+
"no markdown headings, and never refer to yourself or to these instructions. "
|
|
256
|
+
"Write plain prose in the third person about the work described."
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
101
260
|
def get_mode_str(config: object | None) -> str:
|
|
102
261
|
"""Extract the operating mode string ('a', 'b', or 'c') from a config."""
|
|
103
262
|
if config is None:
|