memor-cli 0.3.0__tar.gz → 0.4.0__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 (97) hide show
  1. {memor_cli-0.3.0/memor_cli.egg-info → memor_cli-0.4.0}/PKG-INFO +3 -3
  2. {memor_cli-0.3.0 → memor_cli-0.4.0}/README.md +2 -2
  3. memor_cli-0.4.0/memor/__init__.py +1 -0
  4. {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/daemon.py +24 -2
  5. {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/dashboard/server.py +5 -0
  6. {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/dashboard/static/index.html +60 -1
  7. {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/distill/distiller.py +5 -2
  8. {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/distill/extractive.py +15 -5
  9. {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/feedback.py +31 -3
  10. {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/hook_server.py +16 -14
  11. memor_cli-0.4.0/memor/query_complexity.py +92 -0
  12. memor_cli-0.4.0/memor/session_context.py +42 -0
  13. {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/store/sqlite_store.py +98 -1
  14. memor_cli-0.4.0/memor/turn_metrics.py +90 -0
  15. {memor_cli-0.3.0 → memor_cli-0.4.0/memor_cli.egg-info}/PKG-INFO +3 -3
  16. {memor_cli-0.3.0 → memor_cli-0.4.0}/memor_cli.egg-info/SOURCES.txt +8 -0
  17. {memor_cli-0.3.0 → memor_cli-0.4.0}/pyproject.toml +1 -1
  18. {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_hook_server.py +6 -4
  19. memor_cli-0.4.0/tests/test_quality_gate.py +141 -0
  20. memor_cli-0.4.0/tests/test_query_complexity.py +82 -0
  21. memor_cli-0.4.0/tests/test_semantic_feedback.py +103 -0
  22. memor_cli-0.4.0/tests/test_session_context.py +61 -0
  23. memor_cli-0.4.0/tests/test_turn_metrics.py +122 -0
  24. memor_cli-0.3.0/memor/__init__.py +0 -1
  25. {memor_cli-0.3.0 → memor_cli-0.4.0}/LICENSE +0 -0
  26. {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/cli.py +0 -0
  27. {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/dashboard/__init__.py +0 -0
  28. {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/distill/__init__.py +0 -0
  29. {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/embed/__init__.py +0 -0
  30. {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/embed/api.py +0 -0
  31. {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/embed/fake.py +0 -0
  32. {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/embed/local.py +0 -0
  33. {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/eval/__init__.py +0 -0
  34. {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/eval/baselines/__init__.py +0 -0
  35. {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/eval/baselines/base.py +0 -0
  36. {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/eval/baselines/claude_mem.py +0 -0
  37. {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/eval/baselines/graphiti.py +0 -0
  38. {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/eval/dataset.py +0 -0
  39. {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/eval/embed_benchmark.py +0 -0
  40. {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/eval/judge.py +0 -0
  41. {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/eval/metrics.py +0 -0
  42. {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/eval/runner.py +0 -0
  43. {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/hook_cli.py +0 -0
  44. {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/ingest/__init__.py +0 -0
  45. {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/ingest/claude_code.py +0 -0
  46. {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/ingest/documents.py +0 -0
  47. {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/interfaces.py +0 -0
  48. {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/llm/__init__.py +0 -0
  49. {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/llm/anthropic.py +0 -0
  50. {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/llm/base.py +0 -0
  51. {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/llm/openai_compat.py +0 -0
  52. {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/project.py +0 -0
  53. {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/recall.py +0 -0
  54. {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/redact.py +0 -0
  55. {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/retrieve/__init__.py +0 -0
  56. {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/retrieve/retriever.py +0 -0
  57. {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/service.py +0 -0
  58. {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/store/__init__.py +0 -0
  59. {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/tokencount.py +0 -0
  60. {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/types.py +0 -0
  61. {memor_cli-0.3.0 → memor_cli-0.4.0}/memor_cli.egg-info/dependency_links.txt +0 -0
  62. {memor_cli-0.3.0 → memor_cli-0.4.0}/memor_cli.egg-info/entry_points.txt +0 -0
  63. {memor_cli-0.3.0 → memor_cli-0.4.0}/memor_cli.egg-info/requires.txt +0 -0
  64. {memor_cli-0.3.0 → memor_cli-0.4.0}/memor_cli.egg-info/top_level.txt +0 -0
  65. {memor_cli-0.3.0 → memor_cli-0.4.0}/setup.cfg +0 -0
  66. {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_cli_smoke.py +0 -0
  67. {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_daemon.py +0 -0
  68. {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_dashboard.py +0 -0
  69. {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_dataset_builder.py +0 -0
  70. {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_dimension_safety.py +0 -0
  71. {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_distiller.py +0 -0
  72. {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_embed.py +0 -0
  73. {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_embed_benchmark.py +0 -0
  74. {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_eval_ablation.py +0 -0
  75. {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_eval_runner.py +0 -0
  76. {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_external_baselines.py +0 -0
  77. {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_extractive.py +0 -0
  78. {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_feedback.py +0 -0
  79. {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_hook.py +0 -0
  80. {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_hybrid_retrieval.py +0 -0
  81. {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_ingest_claude_code.py +0 -0
  82. {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_ingest_documents.py +0 -0
  83. {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_install_hook.py +0 -0
  84. {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_interfaces.py +0 -0
  85. {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_judge.py +0 -0
  86. {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_metrics.py +0 -0
  87. {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_noise_filter.py +0 -0
  88. {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_project_resolver.py +0 -0
  89. {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_recall_core.py +0 -0
  90. {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_redact.py +0 -0
  91. {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_retriever.py +0 -0
  92. {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_service.py +0 -0
  93. {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_skill_recall.py +0 -0
  94. {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_store.py +0 -0
  95. {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_supersession.py +0 -0
  96. {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_tokencount.py +0 -0
  97. {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memor-cli
3
- Version: 0.3.0
3
+ Version: 0.4.0
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
@@ -46,7 +46,7 @@ Dynamic: license-file
46
46
  ```
47
47
 
48
48
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
49
- [![Tests](https://img.shields.io/badge/tests-179%20passing-brightgreen.svg)]()
49
+ [![Tests](https://img.shields.io/badge/tests-215%20passing-brightgreen.svg)]()
50
50
  [![Python](https://img.shields.io/badge/python-3.11%2B-blue.svg)]()
51
51
  [![PyPI](https://img.shields.io/pypi/v/memor-cli.svg)](https://pypi.org/project/memor-cli/)
52
52
 
@@ -290,7 +290,7 @@ cd memor-ai
290
290
  python3 -m venv .venv && source .venv/bin/activate
291
291
  pip install -e ".[dev]"
292
292
 
293
- pytest # 179 tests
293
+ pytest # 215 tests
294
294
  ```
295
295
 
296
296
  ---
@@ -9,7 +9,7 @@
9
9
  ```
10
10
 
11
11
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
12
- [![Tests](https://img.shields.io/badge/tests-179%20passing-brightgreen.svg)]()
12
+ [![Tests](https://img.shields.io/badge/tests-215%20passing-brightgreen.svg)]()
13
13
  [![Python](https://img.shields.io/badge/python-3.11%2B-blue.svg)]()
14
14
  [![PyPI](https://img.shields.io/pypi/v/memor-cli.svg)](https://pypi.org/project/memor-cli/)
15
15
 
@@ -253,7 +253,7 @@ cd memor-ai
253
253
  python3 -m venv .venv && source .venv/bin/activate
254
254
  pip install -e ".[dev]"
255
255
 
256
- pytest # 179 tests
256
+ pytest # 215 tests
257
257
  ```
258
258
 
259
259
  ---
@@ -0,0 +1 @@
1
+ __version__ = "0.4.0"
@@ -146,7 +146,7 @@ def distill_new_sessions(
146
146
  return distilled
147
147
 
148
148
 
149
- COMPACT_SIM_THRESHOLD = 0.90
149
+ COMPACT_SIM_THRESHOLD = 0.85
150
150
 
151
151
 
152
152
  def compact_memories(store: SqliteStore, embedder) -> int:
@@ -239,12 +239,34 @@ def run_poll_cycle(
239
239
  for path, project in pending:
240
240
  session_id = path.stem
241
241
  try:
242
- used = analyze_session_feedback(store, session_id, path)
242
+ used = analyze_session_feedback(store, session_id, path, embedder=embedder)
243
243
  if used > 0:
244
244
  print(f" feedback: {used} memories confirmed used in {session_id[:12]}...")
245
245
  except Exception:
246
246
  pass
247
247
 
248
+ # Turn-level metrics: parse tool calls per turn, correlate with recalls
249
+ if new_ingested:
250
+ from memor.turn_metrics import parse_turn_metrics, correlate_with_recalls
251
+ for path, project in pending:
252
+ session_id = path.stem
253
+ try:
254
+ metrics = parse_turn_metrics(path, session_id)
255
+ if metrics:
256
+ metrics = correlate_with_recalls(metrics, store, session_id)
257
+ store.save_turn_metrics(session_id, project, metrics)
258
+ except Exception:
259
+ pass
260
+
261
+ # Soft quality decay: unused memories lose quality over time
262
+ if new_ingested:
263
+ try:
264
+ decayed = store.decay_quality(stale_days=14, factor=0.5, deactivate_floor=0.03)
265
+ if decayed > 0:
266
+ print(f" decayed quality for {decayed} stale memories")
267
+ except Exception:
268
+ pass
269
+
248
270
  # Compact near-duplicate memories (run occasionally, not every cycle)
249
271
  if new_ingested:
250
272
  try:
@@ -134,6 +134,11 @@ def create_app(db_path: str | None = None) -> FastAPI:
134
134
  """, (f"-{days}",)).fetchall()
135
135
  return [dict(r) for r in rows]
136
136
 
137
+ @app.get("/api/roi")
138
+ def roi(project: str | None = Query(None)):
139
+ store = _store()
140
+ return store.get_token_roi(project=project)
141
+
137
142
  @app.get("/api/health")
138
143
  def health():
139
144
  store = _store()
@@ -426,6 +426,32 @@
426
426
  </div>
427
427
  </div>
428
428
  </div>
429
+ <div class="chart-card">
430
+ <div class="chart-header">
431
+ <div class="chart-title">Token ROI</div>
432
+ </div>
433
+ <div id="roi-banner" style="display:none;background:var(--ok-dim);border:1px solid rgba(61,214,140,0.2);border-radius:var(--radius-sm);padding:12px 14px;margin-bottom:14px;">
434
+ <div style="font-size:22px;font-weight:700;color:var(--ok);letter-spacing:-0.5px;" id="roi-value">&ndash;</div>
435
+ <div style="font-size:11px;color:var(--text-muted);margin-top:2px;" id="roi-desc">fewer tool calls when Memor injects context</div>
436
+ </div>
437
+ <div class="side-stats" id="roi-side">
438
+ <div class="side-stat">
439
+ <div class="side-stat-label">Avg Tools / Turn (with recall)</div>
440
+ <div class="side-stat-value" id="roi-tools-with">&ndash;</div>
441
+ <div class="side-stat-sub">when Memor injected context</div>
442
+ </div>
443
+ <div class="side-stat">
444
+ <div class="side-stat-label">Avg Tools / Turn (without)</div>
445
+ <div class="side-stat-value" id="roi-tools-without">&ndash;</div>
446
+ <div class="side-stat-sub">when no context was injected</div>
447
+ </div>
448
+ <div class="side-stat">
449
+ <div class="side-stat-label">Turns Measured</div>
450
+ <div class="side-stat-value" id="roi-turns">&ndash;</div>
451
+ <div class="side-stat-sub" id="roi-turns-sub">with vs. without recall</div>
452
+ </div>
453
+ </div>
454
+ </div>
429
455
  </div>
430
456
  </section>
431
457
 
@@ -781,6 +807,38 @@
781
807
  msg.innerHTML = 'Status: <strong>' + esc(status) + '</strong>. Run <code>memor help</code> for guidance.';
782
808
  }
783
809
 
810
+ /* ── ROI renderer ─────────────────────────────────────── */
811
+ function renderROI(data) {
812
+ document.getElementById('roi-tools-with').textContent = data.avg_tools_with_recall;
813
+ document.getElementById('roi-tools-without').textContent = data.avg_tools_without_recall;
814
+ document.getElementById('roi-turns').textContent =
815
+ (data.turns_with_recall + data.turns_without_recall).toLocaleString();
816
+ document.getElementById('roi-turns-sub').textContent =
817
+ data.turns_with_recall + ' with recall · ' + data.turns_without_recall + ' without';
818
+
819
+ var banner = document.getElementById('roi-banner');
820
+ var roiValue = document.getElementById('roi-value');
821
+ var roiDesc = document.getElementById('roi-desc');
822
+ banner.style.display = 'none';
823
+ banner.style.background = 'var(--ok-dim)';
824
+ banner.style.borderColor = 'rgba(61,214,140,0.2)';
825
+ roiValue.style.color = 'var(--ok)';
826
+ roiValue.textContent = '–';
827
+ roiDesc.textContent = 'fewer tool calls when Memor injects context';
828
+
829
+ if (data.tool_call_reduction_pct > 0 && data.turns_with_recall >= 5 && data.turns_without_recall >= 5) {
830
+ roiValue.textContent = data.tool_call_reduction_pct + '% fewer';
831
+ banner.style.display = 'block';
832
+ } else if (data.tool_call_reduction_pct < 0 && data.turns_with_recall >= 5) {
833
+ roiValue.textContent = Math.abs(data.tool_call_reduction_pct) + '% more';
834
+ roiValue.style.color = 'var(--warn)';
835
+ roiDesc.textContent = 'tool calls with recall — investigating...';
836
+ banner.style.display = 'block';
837
+ banner.style.background = 'var(--warn-dim)';
838
+ banner.style.borderColor = 'rgba(232,147,32,0.2)';
839
+ }
840
+ }
841
+
784
842
  /* ── Data loaders ──────────────────────────────────────── */
785
843
  async function loadSummary() { try { renderSummary(await api('/api/summary')); } catch(e) { console.warn('summary',e); } }
786
844
  async function loadProjects() { try { renderProjects(await api('/api/projects')); } catch(e) { console.warn('projects',e); } }
@@ -788,6 +846,7 @@
788
846
  async function loadSessionEfficiency() { try { renderSessionEfficiency(await api('/api/session-efficiency')); } catch(e) { console.warn('session-efficiency',e); } }
789
847
  async function loadHealth() { try { renderHealth(await api('/api/health')); } catch(e) { console.warn('health',e); } }
790
848
  async function loadTrend() { try { renderTrend(await api('/api/recall-trend?days=30')); } catch(e) { console.warn('trend',e); } }
849
+ async function loadROI() { try { renderROI(await api('/api/roi')); } catch(e) { console.warn('roi',e); } }
791
850
  async function loadRecalls() {
792
851
  try {
793
852
  var url = '/api/recalls?limit=50' + (projectFilter ? '&project=' + encodeURIComponent(projectFilter) : '');
@@ -796,7 +855,7 @@
796
855
  }
797
856
 
798
857
  async function refresh() {
799
- await Promise.allSettled([loadSummary(), loadProjects(), loadEfficiency(), loadSessionEfficiency(), loadHealth(), loadTrend(), loadRecalls()]);
858
+ await Promise.allSettled([loadSummary(), loadProjects(), loadEfficiency(), loadSessionEfficiency(), loadHealth(), loadTrend(), loadRecalls(), loadROI()]);
800
859
  document.getElementById('last-updated').textContent = new Date().toLocaleTimeString();
801
860
 
802
861
  renderMiniBars('mb-chunks', null);
@@ -99,11 +99,14 @@ class ExtractiveDistiller:
99
99
  def distill_session(
100
100
  self, session_id: str, chunks: list[Artifact], project: str
101
101
  ) -> list[str]:
102
- from memor.distill.extractive import classify_chunk
102
+ from memor.distill.extractive import classify_chunk, score_chunks, MIN_MEMORY_SIGNAL
103
103
  key_chunks = extract_key_chunks(chunks, self.embedder)
104
+ scores = score_chunks(key_chunks)
104
105
  created = max((c.created_at for c in chunks), default=0.0)
105
106
  new_ids: list[str] = []
106
- for c in key_chunks:
107
+ for c, score in zip(key_chunks, scores):
108
+ if score < MIN_MEMORY_SIGNAL:
109
+ continue
107
110
  mem_type = classify_chunk(c.text)
108
111
  mid = _store_memory(self.store, self.embedder, c.text, mem_type,
109
112
  session_id, project, created, [c])
@@ -132,13 +132,13 @@ def _cluster_select(chunks: list[Artifact], embedder, max_clusters: int) -> list
132
132
  return selected
133
133
 
134
134
 
135
- def extract_key_chunks(
136
- chunks: list[Artifact], embedder, *, max_extracts: int = MAX_EXTRACTS
137
- ) -> list[Artifact]:
138
- """Select the highest-signal chunks from a session. Pure local, no LLM."""
135
+ MIN_MEMORY_SIGNAL = 0.3
136
+
137
+
138
+ def score_chunks(chunks: list[Artifact]) -> list[float]:
139
+ """Score each chunk by TF-IDF + heuristic. Returns parallel list of scores."""
139
140
  if not chunks:
140
141
  return []
141
- # Score each chunk: TF-IDF + heuristic
142
142
  tfidf = _tfidf_scores(chunks)
143
143
  max_tf = max(tfidf) or 1.0
144
144
  combined = []
@@ -148,6 +148,16 @@ def extract_key_chunks(
148
148
  combined.append(-999.0)
149
149
  continue
150
150
  combined.append((tfidf[i] / max_tf) + h)
151
+ return combined
152
+
153
+
154
+ def extract_key_chunks(
155
+ chunks: list[Artifact], embedder, *, max_extracts: int = MAX_EXTRACTS
156
+ ) -> list[Artifact]:
157
+ """Select the highest-signal chunks from a session. Pure local, no LLM."""
158
+ if not chunks:
159
+ return []
160
+ combined = score_chunks(chunks)
151
161
  # Pre-filter: drop anything scored below 0
152
162
  viable_idx = [i for i, s in enumerate(combined) if s > 0]
153
163
  if not viable_idx:
@@ -2,7 +2,12 @@
2
2
 
3
3
  After a session ends, cross-references recall_log with the transcript to see
4
4
  if the agent's responses referenced recalled content. Updates memory_quality
5
- scores accordingly."""
5
+ scores accordingly.
6
+
7
+ Two matching strategies:
8
+ 1. N-gram overlap (fast, catches verbatim reuse)
9
+ 2. Semantic similarity via embeddings (catches paraphrased reuse)
10
+ """
6
11
  from __future__ import annotations
7
12
  import json
8
13
  import math
@@ -12,6 +17,7 @@ from memor.store.sqlite_store import SqliteStore
12
17
  _NGRAM_SIZE = 3
13
18
  _MIN_WORDS = 4
14
19
  _MATCH_RATIO = 0.10
20
+ _SEMANTIC_SIM_THRESHOLD = 0.45
15
21
 
16
22
 
17
23
  def _extract_assistant_texts(transcript_path: Path) -> list[str]:
@@ -55,8 +61,25 @@ def _text_was_used(memory_text: str, assistant_texts: list[str]) -> bool:
55
61
  return matches >= max(1, math.ceil(len(ngrams) * _MATCH_RATIO))
56
62
 
57
63
 
64
+ def _cosine(a: list[float], b: list[float]) -> float:
65
+ dot = sum(x * y for x, y in zip(a, b))
66
+ na = math.sqrt(sum(x * x for x in a))
67
+ nb = math.sqrt(sum(x * x for x in b))
68
+ return dot / (na * nb) if na and nb else 0.0
69
+
70
+
71
+ def _semantic_match(memory_text: str, response_text: str, embedder) -> bool:
72
+ """Check if memory content appears in the response via embedding similarity.
73
+ Catches paraphrased reuse that n-gram matching misses."""
74
+ if len(memory_text.split()) < _MIN_WORDS:
75
+ return False
76
+ vecs = embedder.embed([memory_text, response_text])
77
+ return _cosine(vecs[0], vecs[1]) >= _SEMANTIC_SIM_THRESHOLD
78
+
79
+
58
80
  def analyze_session_feedback(
59
- store: SqliteStore, session_id: str, transcript_path: Path
81
+ store: SqliteStore, session_id: str, transcript_path: Path,
82
+ *, embedder=None,
60
83
  ) -> int:
61
84
  recalled_ids = set()
62
85
  rows = store.db.execute("""
@@ -88,11 +111,16 @@ def analyze_session_feedback(
88
111
  return 0
89
112
 
90
113
  used_ids = []
114
+ combined_response = " ".join(assistant_texts) if embedder else ""
91
115
  for aid in recalled_ids:
92
116
  art = store.db.execute(
93
117
  "SELECT text FROM artifacts WHERE id=?", (aid,)
94
118
  ).fetchone()
95
- if art and _text_was_used(art["text"], assistant_texts):
119
+ if not art:
120
+ continue
121
+ if _text_was_used(art["text"], assistant_texts):
122
+ used_ids.append(aid)
123
+ elif embedder and _semantic_match(art["text"], combined_response, embedder):
96
124
  used_ids.append(aid)
97
125
 
98
126
  if used_ids:
@@ -11,19 +11,15 @@ SOCK_PATH = Path.home() / ".memor" / "hook.sock"
11
11
  PID_PATH = Path.home() / ".memor" / "hook.pid"
12
12
  DEFAULT_DB = str(Path.home() / ".memor" / "memor.db")
13
13
  IDLE_TIMEOUT_S = 600
14
- MIN_QUERY_WORDS = 10
15
- _TRIVIAL_PATTERNS = frozenset({
16
- "yes", "no", "ok", "okay", "sure", "thanks", "thank you", "ty",
17
- "looks good", "lgtm", "continue", "go ahead", "do it", "proceed",
18
- "correct", "right", "yep", "yup", "nope", "agreed", "sounds good",
19
- "perfect", "great", "nice", "cool", "done", "got it", "k",
20
- })
21
14
 
22
15
  _embedder = None
23
16
  _last_activity = 0.0
24
17
  _session_injected: dict[str, set[str]] = {}
25
18
  _MAX_TRACKED_SESSIONS = 50
26
19
 
20
+ from memor.session_context import SessionContextWindow
21
+ _session_ctx = SessionContextWindow(max_queries=5, max_sessions=_MAX_TRACKED_SESSIONS)
22
+
27
23
  _UNSET = object() # sentinel for "auto-discover embedder"
28
24
 
29
25
 
@@ -62,9 +58,10 @@ def handle_request(req: dict, *, db_path: str = DEFAULT_DB,
62
58
  }
63
59
  }
64
60
 
65
- query_stripped = query.strip().rstrip("?!.,").strip().lower()
66
- query_word_count = len(query.split())
67
- if query_word_count < MIN_QUERY_WORDS and query_stripped in _TRIVIAL_PATTERNS:
61
+ from memor.query_complexity import route_query, Tier
62
+
63
+ tier = route_query(query)
64
+ if tier == Tier.SKIP:
68
65
  msg = "Memor: skipped — trivial prompt"
69
66
  if Path(db_path).exists():
70
67
  try:
@@ -85,18 +82,23 @@ def handle_request(req: dict, *, db_path: str = DEFAULT_DB,
85
82
  }
86
83
 
87
84
  try:
88
- max_tokens = max(0, int(os.environ.get("MEMOR_MAX_TOKENS", "1500")))
85
+ env_max = int(os.environ.get("MEMOR_MAX_TOKENS", "0"))
89
86
  except (ValueError, TypeError):
90
- max_tokens = 1500
87
+ env_max = 0
88
+ max_tokens = env_max if env_max > 0 else tier.max_tokens
91
89
  try:
92
90
  min_similarity = float(os.environ.get("MEMOR_MIN_SIMILARITY", "0.0"))
93
91
  except (ValueError, TypeError):
94
92
  min_similarity = 0.0
93
+ retrieval_query = _session_ctx.enrich(query, session_id) if session_id else query
95
94
  already_injected = _session_injected.get(session_id, set()) if session_id else set()
96
- result = recall(query, project, db_path, embedder=embedder, k=8, threshold=0.15,
97
- max_tokens=max_tokens, min_similarity=min_similarity,
95
+ result = recall(retrieval_query, project, db_path, embedder=embedder, k=tier.k,
96
+ threshold=0.15, max_tokens=max_tokens, min_similarity=min_similarity,
98
97
  exclude_ids=already_injected or None, session_id=session_id)
99
98
 
99
+ if session_id:
100
+ _session_ctx.record(session_id, query)
101
+
100
102
  if session_id and result.hit_ids:
101
103
  _session_injected.setdefault(session_id, set()).update(result.hit_ids)
102
104
  if len(_session_injected) > _MAX_TRACKED_SESSIONS:
@@ -0,0 +1,92 @@
1
+ """Query complexity scoring and budget routing.
2
+
3
+ Scores queries by word count, identifier density, path references, and
4
+ question structure. Routes to budget tiers so trivial prompts skip recall,
5
+ simple follow-ups get a light budget, and complex queries get full retrieval.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import enum
10
+ import re
11
+
12
+ _IDENTIFIER_RE = re.compile(
13
+ r"[A-Z][a-z]+[A-Z]" # camelCase/PascalCase
14
+ r"|[a-z]+_[a-z]+" # snake_case
15
+ r"|[A-Z]{2,}" # SCREAMING_CASE (min 2 chars)
16
+ r"|\b\w+\.\w+\.\w+" # dotted.path.ref
17
+ r"|\w+::\w+" # C++ scope
18
+ )
19
+
20
+ _PATH_RE = re.compile(
21
+ r"[a-zA-Z0-9_\-]+/" # path components with /
22
+ r"|\.\w{1,5}\b" # file extensions (.py, .ts, .yml)
23
+ )
24
+
25
+ _QUESTION_RE = re.compile(r"\?\s*$")
26
+
27
+ _ERROR_RE = re.compile(
28
+ r"(Error|Exception|Traceback|FAIL|panic|segfault|undefined|NaN|null|nil)\b",
29
+ re.I,
30
+ )
31
+
32
+ _TRIVIAL_PATTERNS = frozenset({
33
+ "yes", "no", "ok", "okay", "sure", "thanks", "thank you", "ty",
34
+ "looks good", "lgtm", "continue", "go ahead", "do it", "proceed",
35
+ "correct", "right", "yep", "yup", "nope", "agreed", "sounds good",
36
+ "perfect", "great", "nice", "cool", "done", "got it", "k",
37
+ "yes please", "no thanks",
38
+ })
39
+
40
+
41
+ class Tier(enum.Enum):
42
+ SKIP = (0, 0)
43
+ LIGHT = (4, 500)
44
+ FULL = (8, 1500)
45
+
46
+ def __init__(self, k: int, max_tokens: int):
47
+ self.k = k
48
+ self.max_tokens = max_tokens
49
+
50
+
51
+ def score_query(query: str) -> float:
52
+ """Score query complexity on [0, 1]. Higher = more context needed."""
53
+ text = query.strip()
54
+ if not text:
55
+ return 0.0
56
+
57
+ normalized = text.rstrip("?!.,").strip().lower()
58
+ if normalized in _TRIVIAL_PATTERNS:
59
+ return 0.0
60
+
61
+ words = text.split()
62
+ n = len(words)
63
+
64
+ word_score = min(1.0, n * n / (n * n + 36))
65
+
66
+ id_matches = len(_IDENTIFIER_RE.findall(text))
67
+ id_score = min(id_matches / 3.0, 1.0)
68
+
69
+ path_matches = len(_PATH_RE.findall(text))
70
+ path_score = min(path_matches / 2.0, 1.0)
71
+
72
+ question_score = 0.1 if _QUESTION_RE.search(text) else 0.0
73
+ error_score = 0.15 if _ERROR_RE.search(text) else 0.0
74
+
75
+ raw = (
76
+ 0.45 * word_score
77
+ + 0.20 * id_score
78
+ + 0.20 * path_score
79
+ + question_score
80
+ + error_score
81
+ )
82
+ return min(raw, 1.0)
83
+
84
+
85
+ def route_query(query: str) -> Tier:
86
+ """Route a query to a retrieval budget tier based on its complexity."""
87
+ s = score_query(query)
88
+ if s < 0.05:
89
+ return Tier.SKIP
90
+ if s < 0.45:
91
+ return Tier.LIGHT
92
+ return Tier.FULL
@@ -0,0 +1,42 @@
1
+ """Session context window — tracks recent queries per session to enrich
2
+ sparse follow-up prompts with conversational context for retrieval.
3
+
4
+ When a user types "try the other approach" after discussing OAuth2, the
5
+ retriever gets the enriched query "implement OAuth2 PKCE flow ... try the
6
+ other approach" instead of searching on 5 bare words.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from collections import OrderedDict
11
+
12
+ from memor.query_complexity import score_query
13
+
14
+ _SPARSE_THRESHOLD = 0.35
15
+
16
+
17
+ class SessionContextWindow:
18
+ def __init__(self, *, max_queries: int = 5, max_sessions: int = 50):
19
+ self._max_queries = max_queries
20
+ self._max_sessions = max_sessions
21
+ self._windows: OrderedDict[str, list[str]] = OrderedDict()
22
+
23
+ def record(self, session_id: str, query: str) -> None:
24
+ if session_id not in self._windows:
25
+ if len(self._windows) >= self._max_sessions:
26
+ self._windows.popitem(last=False)
27
+ self._windows[session_id] = []
28
+ else:
29
+ self._windows.move_to_end(session_id)
30
+ buf = self._windows[session_id]
31
+ buf.append(query)
32
+ if len(buf) > self._max_queries:
33
+ del buf[0]
34
+
35
+ def enrich(self, query: str, session_id: str) -> str:
36
+ if score_query(query) >= _SPARSE_THRESHOLD:
37
+ return query
38
+ history = self._windows.get(session_id)
39
+ if not history:
40
+ return query
41
+ context = " | ".join(history[-2:])
42
+ return f"{context} | {query}"
@@ -20,6 +20,7 @@ class SqliteStore:
20
20
  self._init_schema()
21
21
  self._check_dim(dim)
22
22
  self._migrate_fts()
23
+ self._migrate_quality_decay()
23
24
 
24
25
  def _init_schema(self):
25
26
  self.db.executescript(f"""
@@ -43,7 +44,8 @@ class SqliteStore:
43
44
  recall_count INTEGER DEFAULT 0,
44
45
  use_count INTEGER DEFAULT 0,
45
46
  last_recalled REAL,
46
- quality_score REAL DEFAULT 0.5);
47
+ quality_score REAL DEFAULT 0.5,
48
+ last_decayed_at REAL);
47
49
  CREATE TABLE IF NOT EXISTS recall_log(
48
50
  id INTEGER PRIMARY KEY AUTOINCREMENT,
49
51
  timestamp REAL, project TEXT, query_preview TEXT,
@@ -63,6 +65,15 @@ class SqliteStore:
63
65
  last_turn_at REAL,
64
66
  updated_at REAL NOT NULL DEFAULT 0);
65
67
  CREATE INDEX IF NOT EXISTS idx_session_stats_project ON session_stats(project);
68
+ CREATE TABLE IF NOT EXISTS turn_metrics(
69
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
70
+ session_id TEXT NOT NULL,
71
+ project TEXT NOT NULL,
72
+ turn_idx INTEGER NOT NULL,
73
+ user_timestamp REAL,
74
+ tool_call_count INTEGER DEFAULT 0,
75
+ had_recall INTEGER DEFAULT 0);
76
+ CREATE INDEX IF NOT EXISTS idx_turn_metrics_session ON turn_metrics(session_id);
66
77
  """)
67
78
  self.db.commit()
68
79
 
@@ -77,6 +88,16 @@ class SqliteStore:
77
88
  f"but current embedder has dim={dim}. Use the same embedder or re-ingest."
78
89
  )
79
90
 
91
+ def _migrate_quality_decay(self):
92
+ """Add last_decayed_at column to memory_quality if missing."""
93
+ cols = [r[1] for r in self.db.execute("PRAGMA table_info(memory_quality)").fetchall()]
94
+ if "last_decayed_at" not in cols:
95
+ try:
96
+ self.db.execute("ALTER TABLE memory_quality ADD COLUMN last_decayed_at REAL")
97
+ self.db.commit()
98
+ except Exception:
99
+ pass
100
+
80
101
  def _migrate_fts(self):
81
102
  """One-time backfill of the FTS index for databases created before
82
103
  lexical search existed. Runs only when artifacts exist but the index is
@@ -320,6 +341,39 @@ class SqliteStore:
320
341
  """, (cutoff, cutoff)).fetchall()
321
342
  return [r["id"] for r in rows]
322
343
 
344
+ def decay_quality(self, stale_days: int = 14, factor: float = 0.5,
345
+ deactivate_floor: float = 0.03) -> int:
346
+ """Halve quality_score for memories not recalled in stale_days.
347
+ Only decays each memory once per stale_days interval (tracked via
348
+ last_decayed_at). Also catches memories with NULL last_recalled
349
+ that are old enough. If the decayed score drops below
350
+ deactivate_floor, deactivate the memory.
351
+ Returns number of memories decayed."""
352
+ import time as _time
353
+ now = _time.time()
354
+ recall_cutoff = now - (stale_days * 86400)
355
+ decay_cutoff = now - (stale_days * 86400)
356
+ rows = self.db.execute("""
357
+ SELECT q.artifact_id, q.quality_score
358
+ FROM memory_quality q
359
+ JOIN artifacts a ON a.id = q.artifact_id
360
+ WHERE a.kind = 'memory' AND a.active = 1
361
+ AND (q.last_recalled IS NULL OR q.last_recalled < ?)
362
+ AND (q.last_decayed_at IS NULL OR q.last_decayed_at < ?)
363
+ """, (recall_cutoff, decay_cutoff)).fetchall()
364
+ decayed = 0
365
+ for r in rows:
366
+ new_score = round(r["quality_score"] * factor, 4)
367
+ if new_score < deactivate_floor:
368
+ self.db.execute("UPDATE artifacts SET active=0 WHERE id=?",
369
+ (r["artifact_id"],))
370
+ self.db.execute(
371
+ "UPDATE memory_quality SET quality_score=?, last_decayed_at=? WHERE artifact_id=?",
372
+ (new_score, now, r["artifact_id"]))
373
+ decayed += 1
374
+ self.db.commit()
375
+ return decayed
376
+
323
377
  def deactivate_stale(self, days: int = 30) -> int:
324
378
  ids = self.get_stale_memories(days)
325
379
  for aid in ids:
@@ -474,6 +528,49 @@ class SqliteStore:
474
528
  "sessions": sessions,
475
529
  }
476
530
 
531
+ def save_turn_metrics(self, session_id: str, project: str, metrics: list) -> None:
532
+ """Persist per-turn tool call metrics. Idempotent — deletes old metrics
533
+ for the session first."""
534
+ self.db.execute("DELETE FROM turn_metrics WHERE session_id=?", (session_id,))
535
+ for m in metrics:
536
+ self.db.execute(
537
+ "INSERT INTO turn_metrics(session_id, project, turn_idx, "
538
+ "user_timestamp, tool_call_count, had_recall) VALUES(?,?,?,?,?,?)",
539
+ (session_id, project, m.turn_idx, m.user_timestamp,
540
+ m.tool_call_count, 1 if m.had_recall else 0))
541
+ self.db.commit()
542
+
543
+ def get_token_roi(self, project: str | None = None) -> dict:
544
+ """Compute tool-call ROI: avg tool calls per turn with vs without recall."""
545
+ where = "WHERE 1=1"
546
+ params: list = []
547
+ if project:
548
+ where += " AND project = ?"
549
+ params.append(project)
550
+
551
+ row = self.db.execute(f"""
552
+ SELECT
553
+ COUNT(CASE WHEN had_recall = 1 THEN 1 END) AS turns_with,
554
+ COUNT(CASE WHEN had_recall = 0 THEN 1 END) AS turns_without,
555
+ AVG(CASE WHEN had_recall = 1 THEN tool_call_count END) AS avg_with,
556
+ AVG(CASE WHEN had_recall = 0 THEN tool_call_count END) AS avg_without
557
+ FROM turn_metrics {where}
558
+ """, params).fetchone()
559
+
560
+ turns_with = row["turns_with"] or 0
561
+ turns_without = row["turns_without"] or 0
562
+ avg_with = round(row["avg_with"] or 0, 2)
563
+ avg_without = round(row["avg_without"] or 0, 2)
564
+ reduction = round((1 - avg_with / avg_without) * 100, 1) if avg_without > 0 else 0
565
+
566
+ return {
567
+ "turns_with_recall": turns_with,
568
+ "turns_without_recall": turns_without,
569
+ "avg_tools_with_recall": avg_with,
570
+ "avg_tools_without_recall": avg_without,
571
+ "tool_call_reduction_pct": reduction,
572
+ }
573
+
477
574
  def get_onboarding_status(self) -> str:
478
575
  chunks = self.db.execute(
479
576
  "SELECT COUNT(*) as c FROM artifacts WHERE kind='session_chunk' AND active=1"