memor-cli 0.6.0__tar.gz → 0.6.1__tar.gz
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.
- {memor_cli-0.6.0/memor_cli.egg-info → memor_cli-0.6.1}/PKG-INFO +1 -1
- memor_cli-0.6.1/memor/__init__.py +1 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/cli.py +31 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/daemon.py +33 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/dashboard/server.py +12 -3
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/store/sqlite_store.py +67 -1
- {memor_cli-0.6.0 → memor_cli-0.6.1/memor_cli.egg-info}/PKG-INFO +1 -1
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor_cli.egg-info/SOURCES.txt +2 -1
- {memor_cli-0.6.0 → memor_cli-0.6.1}/pyproject.toml +1 -1
- memor_cli-0.6.1/tests/test_vec_compaction.py +202 -0
- memor_cli-0.6.0/memor/__init__.py +0 -1
- {memor_cli-0.6.0 → memor_cli-0.6.1}/LICENSE +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/README.md +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/dashboard/__init__.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/dashboard/static/index.html +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/distill/__init__.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/distill/distiller.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/distill/extractive.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/embed/__init__.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/embed/api.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/embed/fake.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/embed/local.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/eval/__init__.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/eval/baselines/__init__.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/eval/baselines/base.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/eval/baselines/claude_mem.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/eval/baselines/graphiti.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/eval/counterfactual.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/eval/dataset.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/eval/embed_benchmark.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/eval/judge.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/eval/metrics.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/eval/runner.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/feedback.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/global_memories.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/hook_cli.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/hook_server.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/ingest/__init__.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/ingest/claude_code.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/ingest/documents.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/interfaces.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/llm/__init__.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/llm/anthropic.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/llm/base.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/llm/openai_compat.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/project.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/query_complexity.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/recall.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/redact.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/retrieve/__init__.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/retrieve/retriever.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/service.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/session_context.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/store/__init__.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/tokencount.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/turn_metrics.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/types.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor_cli.egg-info/dependency_links.txt +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor_cli.egg-info/entry_points.txt +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor_cli.egg-info/requires.txt +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/memor_cli.egg-info/top_level.txt +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/setup.cfg +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_cli_smoke.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_counterfactual.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_daemon.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_dashboard.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_dashboard_quality.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_dataset_builder.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_dimension_safety.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_distiller.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_embed.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_embed_benchmark.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_eval_ablation.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_eval_runner.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_external_baselines.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_extractive.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_feedback.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_global_memories.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_hook.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_hook_server.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_hybrid_retrieval.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_ingest_claude_code.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_ingest_documents.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_install_hook.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_interfaces.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_judge.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_metrics.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_negative_feedback.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_noise_filter.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_project_resolver.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_quality_gate.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_query_complexity.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_recall_core.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_redact.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_retriever.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_roi_trend.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_semantic_feedback.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_service.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_session_context.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_skill_recall.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_store.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_supersession.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_tokencount.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_turn_metrics.py +0 -0
- {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_types.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.6.1"
|
|
@@ -72,6 +72,7 @@ MAINTENANCE
|
|
|
72
72
|
memor reingest --project <name> Re-ingest only one project
|
|
73
73
|
memor distill --project <name> Run distillation manually
|
|
74
74
|
memor forget-stale Deactivate memories not recalled in 30 days
|
|
75
|
+
memor compact Rebuild vector index, reclaim space
|
|
75
76
|
memor scan Audit DB for leaked secrets
|
|
76
77
|
memor scan --purge Redact secrets in place
|
|
77
78
|
memor setup-model Download/retry the embedding model (~60MB)
|
|
@@ -391,6 +392,36 @@ def forget_stale(days: int = typer.Option(30, help="Deactivate memories not reca
|
|
|
391
392
|
typer.echo(f"Deactivated {count} stale memories.")
|
|
392
393
|
|
|
393
394
|
|
|
395
|
+
@app.command("compact")
|
|
396
|
+
def compact(db: str = typer.Option(str(Path.home() / ".memor" / "memor.db")),
|
|
397
|
+
fake: bool = False,
|
|
398
|
+
confirm: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation")):
|
|
399
|
+
"""Rebuild the vector index to reclaim wasted space."""
|
|
400
|
+
db_path = _db_path(db)
|
|
401
|
+
if not Path(db_path).exists():
|
|
402
|
+
typer.echo("No database found.")
|
|
403
|
+
raise typer.Exit(1)
|
|
404
|
+
dim = _get_dim(db_path)
|
|
405
|
+
embedder = _embedder(fake)
|
|
406
|
+
if embedder.dim != dim:
|
|
407
|
+
typer.echo(f"Warning: embedder dim={embedder.dim} != DB dim={dim}. Using DB dim.")
|
|
408
|
+
from memor.embed.fake import FakeEmbedder
|
|
409
|
+
embedder = FakeEmbedder(dim=dim)
|
|
410
|
+
store = SqliteStore(db_path, dim=dim)
|
|
411
|
+
chunk_count = store.db.execute("SELECT COUNT(*) as c FROM vec_artifacts_chunks").fetchone()["c"]
|
|
412
|
+
active_count = store.db.execute("SELECT COUNT(*) as c FROM artifacts WHERE active=1").fetchone()["c"]
|
|
413
|
+
db_size_mb = round(Path(db_path).stat().st_size / 1_048_576, 1)
|
|
414
|
+
typer.echo(f"Current: {chunk_count} chunks, {active_count} active vectors, {db_size_mb} MB")
|
|
415
|
+
if not confirm:
|
|
416
|
+
typer.confirm("Rebuild vector index?", abort=True)
|
|
417
|
+
typer.echo("Compacting...")
|
|
418
|
+
result = store.rebuild_vec_index(embedder)
|
|
419
|
+
db_size_after = round(Path(db_path).stat().st_size / 1_048_576, 1)
|
|
420
|
+
typer.echo(f"Done: {result['vectors_reindexed']} vectors reindexed in {result['duration_ms']}ms")
|
|
421
|
+
typer.echo(f"Chunks: {result['before_chunks']} -> {result['after_chunks']} (chunk_size={result['chunk_size']})")
|
|
422
|
+
typer.echo(f"DB size: {db_size_mb} MB -> {db_size_after} MB")
|
|
423
|
+
|
|
424
|
+
|
|
394
425
|
@app.command("scan")
|
|
395
426
|
def scan(db: str = typer.Option(str(Path.home() / ".memor" / "memor.db")),
|
|
396
427
|
purge: bool = typer.Option(False, "--purge", help="Redact secrets in place"),
|
|
@@ -186,6 +186,29 @@ def compact_memories(store: SqliteStore, embedder) -> int:
|
|
|
186
186
|
return deactivated
|
|
187
187
|
|
|
188
188
|
|
|
189
|
+
def should_compact(store: SqliteStore) -> bool:
|
|
190
|
+
try:
|
|
191
|
+
chunk_count = store.db.execute(
|
|
192
|
+
"SELECT COUNT(*) as c FROM vec_artifacts_chunks").fetchone()["c"]
|
|
193
|
+
active_count = store.db.execute(
|
|
194
|
+
"SELECT COUNT(*) as c FROM artifacts WHERE active=1").fetchone()["c"]
|
|
195
|
+
except Exception as e:
|
|
196
|
+
print(f" should_compact error: {e}")
|
|
197
|
+
return False
|
|
198
|
+
if chunk_count == 0 or active_count == 0:
|
|
199
|
+
return False
|
|
200
|
+
from memor.store.sqlite_store import _choose_chunk_size
|
|
201
|
+
chunk_size = _choose_chunk_size(active_count)
|
|
202
|
+
ideal_chunks = max(1, active_count // chunk_size + 1)
|
|
203
|
+
return chunk_count > ideal_chunks * 2
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def auto_compact(store: SqliteStore, embedder) -> dict | None:
|
|
207
|
+
if not should_compact(store):
|
|
208
|
+
return None
|
|
209
|
+
return store.rebuild_vec_index(embedder, vacuum=False)
|
|
210
|
+
|
|
211
|
+
|
|
189
212
|
def run_poll_cycle(
|
|
190
213
|
state: dict[str, float],
|
|
191
214
|
store: SqliteStore,
|
|
@@ -286,6 +309,16 @@ def run_poll_cycle(
|
|
|
286
309
|
except Exception:
|
|
287
310
|
pass
|
|
288
311
|
|
|
312
|
+
# Auto-compact vec index if bloated
|
|
313
|
+
if new_ingested:
|
|
314
|
+
try:
|
|
315
|
+
result = auto_compact(store, embedder)
|
|
316
|
+
if result:
|
|
317
|
+
print(f" vec compact: {result['before_chunks']} -> {result['after_chunks']} chunks "
|
|
318
|
+
f"({result['vectors_reindexed']} vectors, {result['duration_ms']}ms)")
|
|
319
|
+
except Exception:
|
|
320
|
+
pass
|
|
321
|
+
|
|
289
322
|
return state, distilled
|
|
290
323
|
|
|
291
324
|
|
|
@@ -14,9 +14,13 @@ def create_app(db_path: str | None = None) -> FastAPI:
|
|
|
14
14
|
|
|
15
15
|
app = FastAPI(title="Memor Dashboard")
|
|
16
16
|
_db_path = db_path
|
|
17
|
+
_cached_store: SqliteStore | None = None
|
|
17
18
|
|
|
18
19
|
def _store() -> SqliteStore:
|
|
19
|
-
|
|
20
|
+
nonlocal _cached_store
|
|
21
|
+
if _cached_store is None:
|
|
22
|
+
_cached_store = SqliteStore(_db_path, dim=_get_dim(_db_path))
|
|
23
|
+
return _cached_store
|
|
20
24
|
|
|
21
25
|
@app.get("/", response_class=HTMLResponse)
|
|
22
26
|
def index():
|
|
@@ -66,11 +70,16 @@ def create_app(db_path: str | None = None) -> FastAPI:
|
|
|
66
70
|
ORDER BY artifacts DESC
|
|
67
71
|
""").fetchall()
|
|
68
72
|
|
|
73
|
+
active_rows = [
|
|
74
|
+
r for r in artifact_rows
|
|
75
|
+
if (r["memories"] or 0) > 0 or r["project"] in recall_projects
|
|
76
|
+
]
|
|
77
|
+
|
|
69
78
|
if not recall_stats:
|
|
70
|
-
return [dict(r) for r in
|
|
79
|
+
return [dict(r) for r in active_rows]
|
|
71
80
|
|
|
72
81
|
result = list(recall_stats)
|
|
73
|
-
for r in
|
|
82
|
+
for r in active_rows:
|
|
74
83
|
if r["project"] not in recall_projects:
|
|
75
84
|
result.append(dict(r))
|
|
76
85
|
return result
|
|
@@ -7,6 +7,15 @@ from memor.types import Artifact, Scope, SessionUsage
|
|
|
7
7
|
def _serialize(v: list[float]) -> bytes:
|
|
8
8
|
return struct.pack("%sf" % len(v), *v)
|
|
9
9
|
|
|
10
|
+
def _choose_chunk_size(active_count: int) -> int:
|
|
11
|
+
if active_count < 1000:
|
|
12
|
+
return 64
|
|
13
|
+
if active_count < 10000:
|
|
14
|
+
return 256
|
|
15
|
+
if active_count < 100000:
|
|
16
|
+
return 512
|
|
17
|
+
return 1024
|
|
18
|
+
|
|
10
19
|
class SqliteStore:
|
|
11
20
|
def __init__(self, path: str, dim: int):
|
|
12
21
|
self.dim = dim
|
|
@@ -126,12 +135,15 @@ class SqliteStore:
|
|
|
126
135
|
def add_artifacts(self, artifacts: list[Artifact], vectors: list[list[float]]) -> None:
|
|
127
136
|
cur = self.db.cursor()
|
|
128
137
|
for a, v in zip(artifacts, vectors):
|
|
138
|
+
old = cur.execute("SELECT rowid FROM artifacts WHERE id=?", (a.id,)).fetchone()
|
|
139
|
+
if old:
|
|
140
|
+
cur.execute("DELETE FROM vec_artifacts WHERE rowid=?", (old[0],))
|
|
129
141
|
cur.execute(
|
|
130
142
|
"INSERT OR REPLACE INTO artifacts(id,kind,project,source,text,token_count,created_at,meta,active,superseded_by)"
|
|
131
143
|
" VALUES(?,?,?,?,?,?,?,?,1,NULL)",
|
|
132
144
|
(a.id, a.kind, a.project, a.source, a.text, a.token_count, a.created_at, json.dumps(a.meta)))
|
|
133
145
|
rowid = cur.execute("SELECT rowid FROM artifacts WHERE id=?", (a.id,)).fetchone()[0]
|
|
134
|
-
cur.execute("INSERT
|
|
146
|
+
cur.execute("INSERT INTO vec_artifacts(rowid, embedding) VALUES(?,?)",
|
|
135
147
|
(rowid, _serialize(v)))
|
|
136
148
|
cur.execute("DELETE FROM fts_artifacts WHERE id=?", (a.id,))
|
|
137
149
|
cur.execute("INSERT INTO fts_artifacts(id, text) VALUES(?,?)", (a.id, a.text))
|
|
@@ -182,6 +194,50 @@ class SqliteStore:
|
|
|
182
194
|
self.db.commit()
|
|
183
195
|
return n
|
|
184
196
|
|
|
197
|
+
def rebuild_vec_index(self, embedder, vacuum: bool = True) -> dict:
|
|
198
|
+
import time as _time
|
|
199
|
+
start = _time.time()
|
|
200
|
+
chunk_count_before = self.db.execute(
|
|
201
|
+
"SELECT COUNT(*) as c FROM vec_artifacts_chunks").fetchone()["c"]
|
|
202
|
+
|
|
203
|
+
rows = self.db.execute(
|
|
204
|
+
"SELECT id, text, rowid FROM artifacts WHERE active=1"
|
|
205
|
+
).fetchall()
|
|
206
|
+
texts = [r["text"] for r in rows]
|
|
207
|
+
rowids = [r["rowid"] for r in rows]
|
|
208
|
+
|
|
209
|
+
if texts:
|
|
210
|
+
vectors = embedder.embed(texts)
|
|
211
|
+
else:
|
|
212
|
+
vectors = []
|
|
213
|
+
|
|
214
|
+
self.db.execute("DROP TABLE IF EXISTS vec_artifacts")
|
|
215
|
+
chunk_size = _choose_chunk_size(len(rows))
|
|
216
|
+
self.db.execute(
|
|
217
|
+
f"CREATE VIRTUAL TABLE vec_artifacts USING vec0("
|
|
218
|
+
f"embedding float[{self.dim}], chunk_size={chunk_size})")
|
|
219
|
+
|
|
220
|
+
cur = self.db.cursor()
|
|
221
|
+
for rowid, vec in zip(rowids, vectors):
|
|
222
|
+
cur.execute("INSERT INTO vec_artifacts(rowid, embedding) VALUES(?,?)",
|
|
223
|
+
(rowid, _serialize(vec)))
|
|
224
|
+
self.db.commit()
|
|
225
|
+
|
|
226
|
+
if vacuum:
|
|
227
|
+
self.db.execute("VACUUM")
|
|
228
|
+
|
|
229
|
+
chunk_count_after = self.db.execute(
|
|
230
|
+
"SELECT COUNT(*) as c FROM vec_artifacts_chunks").fetchone()["c"]
|
|
231
|
+
elapsed = round((_time.time() - start) * 1000, 1)
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
"before_chunks": chunk_count_before,
|
|
235
|
+
"after_chunks": chunk_count_after,
|
|
236
|
+
"vectors_reindexed": len(rows),
|
|
237
|
+
"chunk_size": chunk_size,
|
|
238
|
+
"duration_ms": elapsed,
|
|
239
|
+
}
|
|
240
|
+
|
|
185
241
|
def search_lexical(self, query: str, scope: Scope, k: int) -> list[tuple[Artifact, float]]:
|
|
186
242
|
"""Lexical BM25 search over artifact text via FTS5. Returns
|
|
187
243
|
(artifact, bm25_score) ordered best-first (lower bm25 = better match).
|
|
@@ -225,7 +281,15 @@ class SqliteStore:
|
|
|
225
281
|
""", (*ids, *types, hops, *types)).fetchall()
|
|
226
282
|
return [self._row_to_artifact(r) for r in rows]
|
|
227
283
|
|
|
284
|
+
def _deactivate_artifact(self, artifact_id: str) -> None:
|
|
285
|
+
row = self.db.execute(
|
|
286
|
+
"SELECT rowid FROM artifacts WHERE id=?", (artifact_id,)).fetchone()
|
|
287
|
+
if row:
|
|
288
|
+
self.db.execute("DELETE FROM vec_artifacts WHERE rowid=?", (row[0],))
|
|
289
|
+
self.db.execute("DELETE FROM fts_artifacts WHERE id=?", (artifact_id,))
|
|
290
|
+
|
|
228
291
|
def deactivate(self, artifact_id: str, superseded_by: str) -> None:
|
|
292
|
+
self._deactivate_artifact(artifact_id)
|
|
229
293
|
self.db.execute("UPDATE artifacts SET active=0, superseded_by=? WHERE id=?",
|
|
230
294
|
(superseded_by, artifact_id))
|
|
231
295
|
self.add_edge(superseded_by, artifact_id, "supersedes")
|
|
@@ -405,6 +469,7 @@ class SqliteStore:
|
|
|
405
469
|
for r in rows:
|
|
406
470
|
new_score = round(r["quality_score"] * factor, 4)
|
|
407
471
|
if new_score < deactivate_floor:
|
|
472
|
+
self._deactivate_artifact(r["artifact_id"])
|
|
408
473
|
self.db.execute("UPDATE artifacts SET active=0 WHERE id=?",
|
|
409
474
|
(r["artifact_id"],))
|
|
410
475
|
self.db.execute(
|
|
@@ -417,6 +482,7 @@ class SqliteStore:
|
|
|
417
482
|
def deactivate_stale(self, days: int = 30) -> int:
|
|
418
483
|
ids = self.get_stale_memories(days)
|
|
419
484
|
for aid in ids:
|
|
485
|
+
self._deactivate_artifact(aid)
|
|
420
486
|
self.db.execute("UPDATE artifacts SET active=0 WHERE id=?", (aid,))
|
|
421
487
|
self.db.commit()
|
|
422
488
|
return len(ids)
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Tests for vec storage bloat fix: deactivation cleanup, rebuild, auto-compact."""
|
|
2
|
+
from memor.store.sqlite_store import SqliteStore, _serialize
|
|
3
|
+
from memor.embed.fake import FakeEmbedder
|
|
4
|
+
from memor.types import Artifact
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _make_store(tmp_path, n=5):
|
|
8
|
+
"""Create a store with n artifacts and return (store, embedder, artifacts)."""
|
|
9
|
+
db_path = str(tmp_path / "test.db")
|
|
10
|
+
e = FakeEmbedder(dim=16)
|
|
11
|
+
s = SqliteStore(db_path, dim=16)
|
|
12
|
+
arts = []
|
|
13
|
+
for i in range(n):
|
|
14
|
+
a = Artifact(
|
|
15
|
+
id=f"art-{i}", kind="memory", project="proj", source="distill",
|
|
16
|
+
text=f"memory about topic {i}", token_count=5,
|
|
17
|
+
created_at=100.0 + i, meta={"mem_type": "decision", "session_id": "s1"})
|
|
18
|
+
arts.append(a)
|
|
19
|
+
vecs = e.embed([a.text for a in arts])
|
|
20
|
+
s.add_artifacts(arts, vecs)
|
|
21
|
+
return s, e, arts
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_deactivate_cleans_vec_and_fts(tmp_path):
|
|
25
|
+
s, e, arts = _make_store(tmp_path)
|
|
26
|
+
vec_count_before = s.db.execute("SELECT COUNT(*) as c FROM vec_artifacts_rowids").fetchone()["c"]
|
|
27
|
+
fts_count_before = s.db.execute("SELECT COUNT(*) as c FROM fts_artifacts").fetchone()["c"]
|
|
28
|
+
assert vec_count_before == 5
|
|
29
|
+
assert fts_count_before == 5
|
|
30
|
+
|
|
31
|
+
s.deactivate("art-2", superseded_by="art-3")
|
|
32
|
+
|
|
33
|
+
vec_count_after = s.db.execute("SELECT COUNT(*) as c FROM vec_artifacts_rowids").fetchone()["c"]
|
|
34
|
+
fts_count_after = s.db.execute("SELECT COUNT(*) as c FROM fts_artifacts").fetchone()["c"]
|
|
35
|
+
assert vec_count_after == 4
|
|
36
|
+
assert fts_count_after == 4
|
|
37
|
+
|
|
38
|
+
row = s.db.execute("SELECT active FROM artifacts WHERE id='art-2'").fetchone()
|
|
39
|
+
assert row["active"] == 0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_deactivate_stale_cleans_vec(tmp_path):
|
|
43
|
+
s, e, arts = _make_store(tmp_path)
|
|
44
|
+
vec_before = s.db.execute("SELECT COUNT(*) as c FROM vec_artifacts_rowids").fetchone()["c"]
|
|
45
|
+
assert vec_before == 5
|
|
46
|
+
|
|
47
|
+
count = s.deactivate_stale(days=0)
|
|
48
|
+
assert count > 0
|
|
49
|
+
|
|
50
|
+
vec_after = s.db.execute("SELECT COUNT(*) as c FROM vec_artifacts_rowids").fetchone()["c"]
|
|
51
|
+
assert vec_after < vec_before
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_decay_quality_cleans_vec(tmp_path):
|
|
55
|
+
s, e, arts = _make_store(tmp_path)
|
|
56
|
+
for a in arts[:2]:
|
|
57
|
+
s.db.execute(
|
|
58
|
+
"INSERT INTO memory_quality(artifact_id, recall_count, use_count, quality_score, last_recalled) "
|
|
59
|
+
"VALUES(?, 10, 0, 0.02, ?)", (a.id, 1.0))
|
|
60
|
+
s.db.commit()
|
|
61
|
+
|
|
62
|
+
vec_before = s.db.execute("SELECT COUNT(*) as c FROM vec_artifacts_rowids").fetchone()["c"]
|
|
63
|
+
assert vec_before == 5
|
|
64
|
+
|
|
65
|
+
decayed = s.decay_quality(stale_days=0, factor=0.5, deactivate_floor=0.03)
|
|
66
|
+
assert decayed >= 2
|
|
67
|
+
|
|
68
|
+
vec_after = s.db.execute("SELECT COUNT(*) as c FROM vec_artifacts_rowids").fetchone()["c"]
|
|
69
|
+
assert vec_after < vec_before
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_add_artifacts_re_add_no_extra_chunks(tmp_path):
|
|
73
|
+
"""Re-adding the same artifact should not leak extra vec0 chunk slots."""
|
|
74
|
+
s, e, arts = _make_store(tmp_path, n=3)
|
|
75
|
+
rowids_before = s.db.execute("SELECT COUNT(*) as c FROM vec_artifacts_rowids").fetchone()["c"]
|
|
76
|
+
assert rowids_before == 3
|
|
77
|
+
|
|
78
|
+
vecs = e.embed([a.text for a in arts])
|
|
79
|
+
s.add_artifacts(arts, vecs)
|
|
80
|
+
|
|
81
|
+
rowids_after = s.db.execute("SELECT COUNT(*) as c FROM vec_artifacts_rowids").fetchone()["c"]
|
|
82
|
+
assert rowids_after == 3
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_rebuild_vec_index(tmp_path):
|
|
86
|
+
s, e, arts = _make_store(tmp_path, n=10)
|
|
87
|
+
for a in arts[:5]:
|
|
88
|
+
s.deactivate(a.id, superseded_by=arts[5].id)
|
|
89
|
+
|
|
90
|
+
active_before = s.db.execute(
|
|
91
|
+
"SELECT COUNT(*) as c FROM artifacts WHERE active=1").fetchone()["c"]
|
|
92
|
+
assert active_before == 5
|
|
93
|
+
|
|
94
|
+
result = s.rebuild_vec_index(e)
|
|
95
|
+
assert result["vectors_reindexed"] == 5
|
|
96
|
+
|
|
97
|
+
vec_count = s.db.execute("SELECT COUNT(*) as c FROM vec_artifacts_rowids").fetchone()["c"]
|
|
98
|
+
assert vec_count == 5
|
|
99
|
+
|
|
100
|
+
from memor.types import Scope
|
|
101
|
+
hits = s.search(e.embed(["topic 7"])[0], Scope(project="proj"), k=3)
|
|
102
|
+
assert len(hits) > 0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_rebuild_chunk_size_selection(tmp_path):
|
|
106
|
+
from memor.store.sqlite_store import _choose_chunk_size
|
|
107
|
+
assert _choose_chunk_size(500) == 64
|
|
108
|
+
assert _choose_chunk_size(1000) == 256
|
|
109
|
+
assert _choose_chunk_size(5000) == 256
|
|
110
|
+
assert _choose_chunk_size(10000) == 512
|
|
111
|
+
assert _choose_chunk_size(50000) == 512
|
|
112
|
+
assert _choose_chunk_size(100000) == 1024
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_compact_cli_command(tmp_path):
|
|
116
|
+
from typer.testing import CliRunner
|
|
117
|
+
from memor.cli import app
|
|
118
|
+
|
|
119
|
+
db_path = str(tmp_path / "test.db")
|
|
120
|
+
e = FakeEmbedder(dim=16)
|
|
121
|
+
s = SqliteStore(db_path, dim=16)
|
|
122
|
+
arts = [Artifact(id=f"a{i}", kind="memory", project="p", source="distill",
|
|
123
|
+
text=f"text {i}", token_count=3, created_at=100.0 + i,
|
|
124
|
+
meta={"mem_type": "decision"}) for i in range(3)]
|
|
125
|
+
s.add_artifacts(arts, e.embed([a.text for a in arts]))
|
|
126
|
+
|
|
127
|
+
runner = CliRunner()
|
|
128
|
+
result = runner.invoke(app, ["compact", "--db", db_path, "--fake", "--yes"])
|
|
129
|
+
assert result.exit_code == 0
|
|
130
|
+
assert "reindexed" in result.output.lower()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_auto_compact_skips_when_healthy(tmp_path):
|
|
134
|
+
from memor.daemon import should_compact
|
|
135
|
+
db_path = str(tmp_path / "test.db")
|
|
136
|
+
e = FakeEmbedder(dim=16)
|
|
137
|
+
s = SqliteStore(db_path, dim=16)
|
|
138
|
+
arts = [Artifact(id=f"a{i}", kind="memory", project="p", source="distill",
|
|
139
|
+
text=f"text {i}", token_count=3, created_at=100.0 + i,
|
|
140
|
+
meta={"mem_type": "decision"}) for i in range(3)]
|
|
141
|
+
s.add_artifacts(arts, e.embed([a.text for a in arts]))
|
|
142
|
+
|
|
143
|
+
assert should_compact(s) is False
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_auto_compact_returns_none_when_healthy(tmp_path):
|
|
147
|
+
from memor.daemon import auto_compact
|
|
148
|
+
db_path = str(tmp_path / "test.db")
|
|
149
|
+
e = FakeEmbedder(dim=16)
|
|
150
|
+
s = SqliteStore(db_path, dim=16)
|
|
151
|
+
arts = [Artifact(id=f"a{i}", kind="memory", project="p", source="distill",
|
|
152
|
+
text=f"text {i}", token_count=3, created_at=100.0 + i,
|
|
153
|
+
meta={"mem_type": "decision"}) for i in range(3)]
|
|
154
|
+
s.add_artifacts(arts, e.embed([a.text for a in arts]))
|
|
155
|
+
|
|
156
|
+
assert auto_compact(s, e) is None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_dashboard_reuses_store(tmp_path):
|
|
160
|
+
db_path = str(tmp_path / "test.db")
|
|
161
|
+
e = FakeEmbedder(dim=16)
|
|
162
|
+
s = SqliteStore(db_path, dim=16)
|
|
163
|
+
a = Artifact(id="a1", kind="memory", project="p", source="distill",
|
|
164
|
+
text="test", token_count=1, created_at=100.0, meta={})
|
|
165
|
+
s.add_artifacts([a], e.embed(["test"]))
|
|
166
|
+
|
|
167
|
+
from memor.dashboard.server import create_app
|
|
168
|
+
from starlette.testclient import TestClient
|
|
169
|
+
|
|
170
|
+
app = create_app(db_path)
|
|
171
|
+
client = TestClient(app)
|
|
172
|
+
|
|
173
|
+
r1 = client.get("/api/health")
|
|
174
|
+
r2 = client.get("/api/health")
|
|
175
|
+
assert r1.status_code == 200
|
|
176
|
+
assert r2.status_code == 200
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_dashboard_projects_hides_inactive(tmp_path):
|
|
180
|
+
"""Projects with only raw chunks and no recalls/memories should be hidden."""
|
|
181
|
+
db_path = str(tmp_path / "test.db")
|
|
182
|
+
e = FakeEmbedder(dim=16)
|
|
183
|
+
s = SqliteStore(db_path, dim=16)
|
|
184
|
+
|
|
185
|
+
active_art = Artifact(id="m1", kind="memory", project="active-proj", source="distill",
|
|
186
|
+
text="a distilled memory", token_count=5, created_at=100.0, meta={})
|
|
187
|
+
inactive_art = Artifact(id="c1", kind="session_chunk", project="dormant-proj", source="ingest",
|
|
188
|
+
text="raw chunk only", token_count=10, created_at=100.0, meta={})
|
|
189
|
+
s.add_artifacts([active_art, inactive_art], e.embed(["a distilled memory", "raw chunk only"]))
|
|
190
|
+
|
|
191
|
+
from memor.dashboard.server import create_app
|
|
192
|
+
from starlette.testclient import TestClient
|
|
193
|
+
|
|
194
|
+
app = create_app(db_path)
|
|
195
|
+
client = TestClient(app)
|
|
196
|
+
|
|
197
|
+
resp = client.get("/api/projects")
|
|
198
|
+
assert resp.status_code == 200
|
|
199
|
+
projects = resp.json()
|
|
200
|
+
project_names = [p["project"] for p in projects]
|
|
201
|
+
assert "active-proj" in project_names
|
|
202
|
+
assert "dormant-proj" not in project_names
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.6.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|