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.
Files changed (105) hide show
  1. {memor_cli-0.6.0/memor_cli.egg-info → memor_cli-0.6.1}/PKG-INFO +1 -1
  2. memor_cli-0.6.1/memor/__init__.py +1 -0
  3. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/cli.py +31 -0
  4. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/daemon.py +33 -0
  5. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/dashboard/server.py +12 -3
  6. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/store/sqlite_store.py +67 -1
  7. {memor_cli-0.6.0 → memor_cli-0.6.1/memor_cli.egg-info}/PKG-INFO +1 -1
  8. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor_cli.egg-info/SOURCES.txt +2 -1
  9. {memor_cli-0.6.0 → memor_cli-0.6.1}/pyproject.toml +1 -1
  10. memor_cli-0.6.1/tests/test_vec_compaction.py +202 -0
  11. memor_cli-0.6.0/memor/__init__.py +0 -1
  12. {memor_cli-0.6.0 → memor_cli-0.6.1}/LICENSE +0 -0
  13. {memor_cli-0.6.0 → memor_cli-0.6.1}/README.md +0 -0
  14. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/dashboard/__init__.py +0 -0
  15. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/dashboard/static/index.html +0 -0
  16. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/distill/__init__.py +0 -0
  17. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/distill/distiller.py +0 -0
  18. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/distill/extractive.py +0 -0
  19. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/embed/__init__.py +0 -0
  20. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/embed/api.py +0 -0
  21. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/embed/fake.py +0 -0
  22. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/embed/local.py +0 -0
  23. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/eval/__init__.py +0 -0
  24. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/eval/baselines/__init__.py +0 -0
  25. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/eval/baselines/base.py +0 -0
  26. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/eval/baselines/claude_mem.py +0 -0
  27. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/eval/baselines/graphiti.py +0 -0
  28. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/eval/counterfactual.py +0 -0
  29. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/eval/dataset.py +0 -0
  30. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/eval/embed_benchmark.py +0 -0
  31. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/eval/judge.py +0 -0
  32. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/eval/metrics.py +0 -0
  33. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/eval/runner.py +0 -0
  34. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/feedback.py +0 -0
  35. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/global_memories.py +0 -0
  36. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/hook_cli.py +0 -0
  37. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/hook_server.py +0 -0
  38. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/ingest/__init__.py +0 -0
  39. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/ingest/claude_code.py +0 -0
  40. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/ingest/documents.py +0 -0
  41. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/interfaces.py +0 -0
  42. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/llm/__init__.py +0 -0
  43. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/llm/anthropic.py +0 -0
  44. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/llm/base.py +0 -0
  45. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/llm/openai_compat.py +0 -0
  46. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/project.py +0 -0
  47. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/query_complexity.py +0 -0
  48. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/recall.py +0 -0
  49. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/redact.py +0 -0
  50. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/retrieve/__init__.py +0 -0
  51. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/retrieve/retriever.py +0 -0
  52. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/service.py +0 -0
  53. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/session_context.py +0 -0
  54. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/store/__init__.py +0 -0
  55. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/tokencount.py +0 -0
  56. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/turn_metrics.py +0 -0
  57. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor/types.py +0 -0
  58. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor_cli.egg-info/dependency_links.txt +0 -0
  59. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor_cli.egg-info/entry_points.txt +0 -0
  60. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor_cli.egg-info/requires.txt +0 -0
  61. {memor_cli-0.6.0 → memor_cli-0.6.1}/memor_cli.egg-info/top_level.txt +0 -0
  62. {memor_cli-0.6.0 → memor_cli-0.6.1}/setup.cfg +0 -0
  63. {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_cli_smoke.py +0 -0
  64. {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_counterfactual.py +0 -0
  65. {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_daemon.py +0 -0
  66. {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_dashboard.py +0 -0
  67. {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_dashboard_quality.py +0 -0
  68. {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_dataset_builder.py +0 -0
  69. {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_dimension_safety.py +0 -0
  70. {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_distiller.py +0 -0
  71. {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_embed.py +0 -0
  72. {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_embed_benchmark.py +0 -0
  73. {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_eval_ablation.py +0 -0
  74. {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_eval_runner.py +0 -0
  75. {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_external_baselines.py +0 -0
  76. {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_extractive.py +0 -0
  77. {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_feedback.py +0 -0
  78. {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_global_memories.py +0 -0
  79. {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_hook.py +0 -0
  80. {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_hook_server.py +0 -0
  81. {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_hybrid_retrieval.py +0 -0
  82. {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_ingest_claude_code.py +0 -0
  83. {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_ingest_documents.py +0 -0
  84. {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_install_hook.py +0 -0
  85. {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_interfaces.py +0 -0
  86. {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_judge.py +0 -0
  87. {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_metrics.py +0 -0
  88. {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_negative_feedback.py +0 -0
  89. {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_noise_filter.py +0 -0
  90. {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_project_resolver.py +0 -0
  91. {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_quality_gate.py +0 -0
  92. {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_query_complexity.py +0 -0
  93. {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_recall_core.py +0 -0
  94. {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_redact.py +0 -0
  95. {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_retriever.py +0 -0
  96. {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_roi_trend.py +0 -0
  97. {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_semantic_feedback.py +0 -0
  98. {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_service.py +0 -0
  99. {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_session_context.py +0 -0
  100. {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_skill_recall.py +0 -0
  101. {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_store.py +0 -0
  102. {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_supersession.py +0 -0
  103. {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_tokencount.py +0 -0
  104. {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_turn_metrics.py +0 -0
  105. {memor_cli-0.6.0 → memor_cli-0.6.1}/tests/test_types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memor-cli
3
- Version: 0.6.0
3
+ Version: 0.6.1
4
4
  Summary: Measured memory for coding agents. Fire and forget — no API keys needed.
5
5
  Author-email: Nimit Bhandari <nimitbhandari17@gmail.com>
6
6
  License-Expression: MIT
@@ -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
- return SqliteStore(_db_path, dim=_get_dim(_db_path))
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 artifact_rows]
79
+ return [dict(r) for r in active_rows]
71
80
 
72
81
  result = list(recall_stats)
73
- for r in artifact_rows:
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 OR REPLACE INTO vec_artifacts(rowid, embedding) VALUES(?,?)",
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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memor-cli
3
- Version: 0.6.0
3
+ Version: 0.6.1
4
4
  Summary: Measured memory for coding agents. Fire and forget — no API keys needed.
5
5
  Author-email: Nimit Bhandari <nimitbhandari17@gmail.com>
6
6
  License-Expression: MIT
@@ -98,4 +98,5 @@ tests/test_store.py
98
98
  tests/test_supersession.py
99
99
  tests/test_tokencount.py
100
100
  tests/test_turn_metrics.py
101
- tests/test_types.py
101
+ tests/test_types.py
102
+ tests/test_vec_compaction.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "memor-cli"
7
- version = "0.6.0"
7
+ version = "0.6.1"
8
8
  description = "Measured memory for coding agents. Fire and forget — no API keys needed."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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