memor-cli 0.2.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 (100) hide show
  1. {memor_cli-0.2.0/memor_cli.egg-info → memor_cli-0.4.0}/PKG-INFO +43 -16
  2. {memor_cli-0.2.0 → memor_cli-0.4.0}/README.md +42 -15
  3. memor_cli-0.4.0/memor/__init__.py +1 -0
  4. {memor_cli-0.2.0 → memor_cli-0.4.0}/memor/daemon.py +24 -2
  5. {memor_cli-0.2.0 → memor_cli-0.4.0}/memor/dashboard/server.py +5 -0
  6. {memor_cli-0.2.0 → memor_cli-0.4.0}/memor/dashboard/static/index.html +64 -5
  7. {memor_cli-0.2.0 → memor_cli-0.4.0}/memor/distill/distiller.py +5 -2
  8. {memor_cli-0.2.0 → memor_cli-0.4.0}/memor/distill/extractive.py +15 -5
  9. {memor_cli-0.2.0 → memor_cli-0.4.0}/memor/eval/runner.py +3 -2
  10. memor_cli-0.4.0/memor/feedback.py +129 -0
  11. {memor_cli-0.2.0 → memor_cli-0.4.0}/memor/hook_server.py +51 -2
  12. memor_cli-0.4.0/memor/query_complexity.py +92 -0
  13. {memor_cli-0.2.0 → memor_cli-0.4.0}/memor/recall.py +35 -3
  14. memor_cli-0.4.0/memor/retrieve/retriever.py +122 -0
  15. memor_cli-0.4.0/memor/session_context.py +42 -0
  16. {memor_cli-0.2.0 → memor_cli-0.4.0}/memor/store/sqlite_store.py +156 -5
  17. memor_cli-0.4.0/memor/turn_metrics.py +90 -0
  18. {memor_cli-0.2.0 → memor_cli-0.4.0/memor_cli.egg-info}/PKG-INFO +43 -16
  19. {memor_cli-0.2.0 → memor_cli-0.4.0}/memor_cli.egg-info/SOURCES.txt +9 -0
  20. {memor_cli-0.2.0 → memor_cli-0.4.0}/pyproject.toml +1 -1
  21. {memor_cli-0.2.0 → memor_cli-0.4.0}/tests/test_dashboard.py +3 -3
  22. {memor_cli-0.2.0 → memor_cli-0.4.0}/tests/test_eval_ablation.py +5 -1
  23. {memor_cli-0.2.0 → memor_cli-0.4.0}/tests/test_feedback.py +14 -0
  24. memor_cli-0.4.0/tests/test_hook_server.py +96 -0
  25. memor_cli-0.4.0/tests/test_hybrid_retrieval.py +213 -0
  26. memor_cli-0.4.0/tests/test_quality_gate.py +141 -0
  27. memor_cli-0.4.0/tests/test_query_complexity.py +82 -0
  28. {memor_cli-0.2.0 → memor_cli-0.4.0}/tests/test_recall_core.py +63 -3
  29. memor_cli-0.4.0/tests/test_semantic_feedback.py +103 -0
  30. memor_cli-0.4.0/tests/test_session_context.py +61 -0
  31. memor_cli-0.4.0/tests/test_turn_metrics.py +122 -0
  32. memor_cli-0.2.0/memor/__init__.py +0 -1
  33. memor_cli-0.2.0/memor/feedback.py +0 -96
  34. memor_cli-0.2.0/memor/retrieve/retriever.py +0 -78
  35. memor_cli-0.2.0/tests/test_hook_server.py +0 -42
  36. {memor_cli-0.2.0 → memor_cli-0.4.0}/LICENSE +0 -0
  37. {memor_cli-0.2.0 → memor_cli-0.4.0}/memor/cli.py +0 -0
  38. {memor_cli-0.2.0 → memor_cli-0.4.0}/memor/dashboard/__init__.py +0 -0
  39. {memor_cli-0.2.0 → memor_cli-0.4.0}/memor/distill/__init__.py +0 -0
  40. {memor_cli-0.2.0 → memor_cli-0.4.0}/memor/embed/__init__.py +0 -0
  41. {memor_cli-0.2.0 → memor_cli-0.4.0}/memor/embed/api.py +0 -0
  42. {memor_cli-0.2.0 → memor_cli-0.4.0}/memor/embed/fake.py +0 -0
  43. {memor_cli-0.2.0 → memor_cli-0.4.0}/memor/embed/local.py +0 -0
  44. {memor_cli-0.2.0 → memor_cli-0.4.0}/memor/eval/__init__.py +0 -0
  45. {memor_cli-0.2.0 → memor_cli-0.4.0}/memor/eval/baselines/__init__.py +0 -0
  46. {memor_cli-0.2.0 → memor_cli-0.4.0}/memor/eval/baselines/base.py +0 -0
  47. {memor_cli-0.2.0 → memor_cli-0.4.0}/memor/eval/baselines/claude_mem.py +0 -0
  48. {memor_cli-0.2.0 → memor_cli-0.4.0}/memor/eval/baselines/graphiti.py +0 -0
  49. {memor_cli-0.2.0 → memor_cli-0.4.0}/memor/eval/dataset.py +0 -0
  50. {memor_cli-0.2.0 → memor_cli-0.4.0}/memor/eval/embed_benchmark.py +0 -0
  51. {memor_cli-0.2.0 → memor_cli-0.4.0}/memor/eval/judge.py +0 -0
  52. {memor_cli-0.2.0 → memor_cli-0.4.0}/memor/eval/metrics.py +0 -0
  53. {memor_cli-0.2.0 → memor_cli-0.4.0}/memor/hook_cli.py +0 -0
  54. {memor_cli-0.2.0 → memor_cli-0.4.0}/memor/ingest/__init__.py +0 -0
  55. {memor_cli-0.2.0 → memor_cli-0.4.0}/memor/ingest/claude_code.py +0 -0
  56. {memor_cli-0.2.0 → memor_cli-0.4.0}/memor/ingest/documents.py +0 -0
  57. {memor_cli-0.2.0 → memor_cli-0.4.0}/memor/interfaces.py +0 -0
  58. {memor_cli-0.2.0 → memor_cli-0.4.0}/memor/llm/__init__.py +0 -0
  59. {memor_cli-0.2.0 → memor_cli-0.4.0}/memor/llm/anthropic.py +0 -0
  60. {memor_cli-0.2.0 → memor_cli-0.4.0}/memor/llm/base.py +0 -0
  61. {memor_cli-0.2.0 → memor_cli-0.4.0}/memor/llm/openai_compat.py +0 -0
  62. {memor_cli-0.2.0 → memor_cli-0.4.0}/memor/project.py +0 -0
  63. {memor_cli-0.2.0 → memor_cli-0.4.0}/memor/redact.py +0 -0
  64. {memor_cli-0.2.0 → memor_cli-0.4.0}/memor/retrieve/__init__.py +0 -0
  65. {memor_cli-0.2.0 → memor_cli-0.4.0}/memor/service.py +0 -0
  66. {memor_cli-0.2.0 → memor_cli-0.4.0}/memor/store/__init__.py +0 -0
  67. {memor_cli-0.2.0 → memor_cli-0.4.0}/memor/tokencount.py +0 -0
  68. {memor_cli-0.2.0 → memor_cli-0.4.0}/memor/types.py +0 -0
  69. {memor_cli-0.2.0 → memor_cli-0.4.0}/memor_cli.egg-info/dependency_links.txt +0 -0
  70. {memor_cli-0.2.0 → memor_cli-0.4.0}/memor_cli.egg-info/entry_points.txt +0 -0
  71. {memor_cli-0.2.0 → memor_cli-0.4.0}/memor_cli.egg-info/requires.txt +0 -0
  72. {memor_cli-0.2.0 → memor_cli-0.4.0}/memor_cli.egg-info/top_level.txt +0 -0
  73. {memor_cli-0.2.0 → memor_cli-0.4.0}/setup.cfg +0 -0
  74. {memor_cli-0.2.0 → memor_cli-0.4.0}/tests/test_cli_smoke.py +0 -0
  75. {memor_cli-0.2.0 → memor_cli-0.4.0}/tests/test_daemon.py +0 -0
  76. {memor_cli-0.2.0 → memor_cli-0.4.0}/tests/test_dataset_builder.py +0 -0
  77. {memor_cli-0.2.0 → memor_cli-0.4.0}/tests/test_dimension_safety.py +0 -0
  78. {memor_cli-0.2.0 → memor_cli-0.4.0}/tests/test_distiller.py +0 -0
  79. {memor_cli-0.2.0 → memor_cli-0.4.0}/tests/test_embed.py +0 -0
  80. {memor_cli-0.2.0 → memor_cli-0.4.0}/tests/test_embed_benchmark.py +0 -0
  81. {memor_cli-0.2.0 → memor_cli-0.4.0}/tests/test_eval_runner.py +0 -0
  82. {memor_cli-0.2.0 → memor_cli-0.4.0}/tests/test_external_baselines.py +0 -0
  83. {memor_cli-0.2.0 → memor_cli-0.4.0}/tests/test_extractive.py +0 -0
  84. {memor_cli-0.2.0 → memor_cli-0.4.0}/tests/test_hook.py +0 -0
  85. {memor_cli-0.2.0 → memor_cli-0.4.0}/tests/test_ingest_claude_code.py +0 -0
  86. {memor_cli-0.2.0 → memor_cli-0.4.0}/tests/test_ingest_documents.py +0 -0
  87. {memor_cli-0.2.0 → memor_cli-0.4.0}/tests/test_install_hook.py +0 -0
  88. {memor_cli-0.2.0 → memor_cli-0.4.0}/tests/test_interfaces.py +0 -0
  89. {memor_cli-0.2.0 → memor_cli-0.4.0}/tests/test_judge.py +0 -0
  90. {memor_cli-0.2.0 → memor_cli-0.4.0}/tests/test_metrics.py +0 -0
  91. {memor_cli-0.2.0 → memor_cli-0.4.0}/tests/test_noise_filter.py +0 -0
  92. {memor_cli-0.2.0 → memor_cli-0.4.0}/tests/test_project_resolver.py +0 -0
  93. {memor_cli-0.2.0 → memor_cli-0.4.0}/tests/test_redact.py +0 -0
  94. {memor_cli-0.2.0 → memor_cli-0.4.0}/tests/test_retriever.py +0 -0
  95. {memor_cli-0.2.0 → memor_cli-0.4.0}/tests/test_service.py +0 -0
  96. {memor_cli-0.2.0 → memor_cli-0.4.0}/tests/test_skill_recall.py +0 -0
  97. {memor_cli-0.2.0 → memor_cli-0.4.0}/tests/test_store.py +0 -0
  98. {memor_cli-0.2.0 → memor_cli-0.4.0}/tests/test_supersession.py +0 -0
  99. {memor_cli-0.2.0 → memor_cli-0.4.0}/tests/test_tokencount.py +0 -0
  100. {memor_cli-0.2.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.2.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-153%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
 
@@ -65,7 +65,10 @@ pipx install memor-cli
65
65
  # Install the Claude Code hook + download embedding model (~60MB)
66
66
  memor install-hook
67
67
 
68
- # Start the background daemon
68
+ # Start as a background service (macOS/Linux)
69
+ memor service install
70
+
71
+ # Or run in the foreground
69
72
  memor daemon
70
73
  ```
71
74
 
@@ -92,7 +95,13 @@ memor dashboard
92
95
  Embed query locally (model2vec, ~2ms)
93
96
  |
94
97
  v
95
- Hybrid scoring: similarity + recency + kind weight + quality
98
+ Hybrid retrieval: dense vectors + lexical BM25, fused (RRF)
99
+ |
100
+ v
101
+ Relevance gate drops off-topic matches (inject nothing if nothing fits)
102
+ |
103
+ v
104
+ Rank: similarity + recency + kind weight + quality
96
105
  |
97
106
  v
98
107
  Inject relevant context into prompt
@@ -104,20 +113,31 @@ memor dashboard
104
113
 
105
114
  **Two background processes:**
106
115
 
107
- 1. **Daemon** — polls `~/.claude/projects/` for transcripts, embeds chunks, runs distillation, analyzes feedback, compacts duplicates. All local.
116
+ 1. **Daemon** — polls `~/.claude/projects/` for transcripts, embeds chunks, runs distillation, analyzes feedback, compacts duplicates, tracks session-level token usage. All local.
108
117
  2. **Hook** — fires on every prompt, recalls relevant memories, injects them as context. Sub-15ms.
109
118
 
110
119
  **No API keys required.** Embeddings run locally via [model2vec](https://github.com/MinishLab/model2vec) (potion-base-8M, 256-dim). Vectors stored in [sqlite-vec](https://github.com/asg017/sqlite-vec). Everything runs on your machine.
111
120
 
112
121
  ---
113
122
 
114
- ## Hybrid Scoring
123
+ ## Hybrid Retrieval
124
+
125
+ Memor retrieves over two channels and fuses them, so it catches both semantic matches and exact terms:
126
+
127
+ - **Dense** — local vector similarity (model2vec) for semantic recall.
128
+ - **Lexical** — SQLite FTS5 / BM25 over the raw text, to recover exact identifiers, error strings, and API names that static embeddings blur together.
129
+
130
+ The two rankings are combined with **Reciprocal Rank Fusion (RRF)**. A **relevance gate** drops anti-correlated (off-topic) candidates *before* ranking, so an unrelated prompt injects nothing rather than the least-bad guess. The lexical channel only activates when the dense channel finds the query on-topic, preventing generic words from pulling in noise.
131
+
132
+ > Tunable via `MEMOR_MIN_SIMILARITY` (relevance floor, default 0.0) and `MEMOR_MAX_TOKENS` (injection budget, default 1500).
133
+
134
+ ## Scoring
115
135
 
116
- Memor doesn't just match keywords. Each memory is scored by four signals:
136
+ Surviving candidates are ranked by four signals:
117
137
 
118
138
  | Signal | Weight | How it works |
119
139
  |---|---|---|
120
- | **Semantic similarity** | 50% | Vector cosine distance between query and memory |
140
+ | **Semantic similarity** | 50% | Dense + lexical relevance, fused via RRF |
121
141
  | **Recency** | 25% | Exponential decay with 14-day half-life — recent decisions rank higher |
122
142
  | **Kind weight** | 15% | Distilled memories (1.3x) rank above raw session chunks (1.0x) |
123
143
  | **Quality** | 10% | Bayesian score from implicit feedback — memories the agent actually uses rank higher |
@@ -147,10 +167,11 @@ Memories are automatically classified as `decision`, `bugfix`, `lesson`, `snippe
147
167
  memor dashboard
148
168
  ```
149
169
 
150
- Shows:
151
- - **Memory bank** — session chunks, distilled memories, projects tracked
152
- - **Context efficiency** — overhead %, recall precision, quality scores per session
153
- - **Per-project breakdown** — which projects have the most context
170
+ Dark fintech-inspired UI showing:
171
+ - **Hero metrics** — total memories, recall count, avg latency, coverage — with sparkline bars
172
+ - **Daily recall activity** — stacked bar chart of hits vs misses over time
173
+ - **Session efficiency** — real token savings measured from API usage data (avg tokens/turn with vs without recall)
174
+ - **Per-project breakdown** — artifact counts, token totals, last activity
154
175
  - **Recent recalls** — every hook event with scores, latency, and status
155
176
 
156
177
  ---
@@ -162,6 +183,11 @@ memor help Print the full manual
162
183
  memor install-hook Install Claude Code hook + download model
163
184
  memor daemon Auto-ingest + distill (background watcher)
164
185
  memor dashboard Web dashboard on localhost:8420
186
+ memor version Print installed version
187
+ memor service install Run daemon as background service (launchd/systemd)
188
+ memor service stop Stop the background service
189
+ memor service uninstall Remove the background service
190
+ memor service status Check if the service is running
165
191
  memor query <text> Search memories from the CLI
166
192
  memor reingest Wipe DB and re-ingest everything
167
193
  memor reingest --project <name> Re-ingest only one project
@@ -193,16 +219,17 @@ memor/
193
219
  +-- feedback.py Implicit feedback analyzer (usage detection)
194
220
  |
195
221
  +-- retrieve/
196
- | +-- retriever.py Hybrid scoring: similarity + recency + kind + quality
222
+ | +-- retriever.py Hybrid retrieval (dense + BM25, RRF) + relevance gate + scoring
197
223
  |
198
224
  +-- store/
199
- | +-- sqlite_store.py SQLite + sqlite-vec (WAL mode, dimension safety)
225
+ | +-- sqlite_store.py SQLite + sqlite-vec + FTS5 (WAL mode, dimension safety)
200
226
  |
201
227
  +-- embed/
202
228
  | +-- local.py model2vec (potion-base-8M, 256-dim, ~60MB)
203
229
  | +-- api.py OpenAI-compatible embedding API (optional)
204
230
  | +-- fake.py Deterministic SHA-256 embedder (tests)
205
231
  |
232
+ +-- service.py Background service management (launchd/systemd)
206
233
  +-- dashboard/
207
234
  | +-- server.py FastAPI dashboard backend
208
235
  | +-- static/index.html Self-contained dashboard (no CDN deps)
@@ -227,7 +254,7 @@ skill/recall.py Standalone recall script
227
254
  **Nothing leaves your machine.** In the default configuration:
228
255
 
229
256
  - **No telemetry, no analytics, no phone-home.** Zero outbound network calls.
230
- - **Embeddings run locally** via model2vec ONNX (one-time model download from HuggingFace — no user data sent).
257
+ - **Embeddings run locally** via model2vec static token embeddings — no inference runtime, no GPU (one-time model download from HuggingFace — no user data sent).
231
258
  - **Hook transport is a Unix socket** (`~/.memor/hook.sock`), not a network port.
232
259
  - **Dashboard binds localhost only.**
233
260
 
@@ -263,7 +290,7 @@ cd memor-ai
263
290
  python3 -m venv .venv && source .venv/bin/activate
264
291
  pip install -e ".[dev]"
265
292
 
266
- pytest # 153 tests
293
+ pytest # 215 tests
267
294
  ```
268
295
 
269
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-153%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
 
@@ -28,7 +28,10 @@ pipx install memor-cli
28
28
  # Install the Claude Code hook + download embedding model (~60MB)
29
29
  memor install-hook
30
30
 
31
- # Start the background daemon
31
+ # Start as a background service (macOS/Linux)
32
+ memor service install
33
+
34
+ # Or run in the foreground
32
35
  memor daemon
33
36
  ```
34
37
 
@@ -55,7 +58,13 @@ memor dashboard
55
58
  Embed query locally (model2vec, ~2ms)
56
59
  |
57
60
  v
58
- Hybrid scoring: similarity + recency + kind weight + quality
61
+ Hybrid retrieval: dense vectors + lexical BM25, fused (RRF)
62
+ |
63
+ v
64
+ Relevance gate drops off-topic matches (inject nothing if nothing fits)
65
+ |
66
+ v
67
+ Rank: similarity + recency + kind weight + quality
59
68
  |
60
69
  v
61
70
  Inject relevant context into prompt
@@ -67,20 +76,31 @@ memor dashboard
67
76
 
68
77
  **Two background processes:**
69
78
 
70
- 1. **Daemon** — polls `~/.claude/projects/` for transcripts, embeds chunks, runs distillation, analyzes feedback, compacts duplicates. All local.
79
+ 1. **Daemon** — polls `~/.claude/projects/` for transcripts, embeds chunks, runs distillation, analyzes feedback, compacts duplicates, tracks session-level token usage. All local.
71
80
  2. **Hook** — fires on every prompt, recalls relevant memories, injects them as context. Sub-15ms.
72
81
 
73
82
  **No API keys required.** Embeddings run locally via [model2vec](https://github.com/MinishLab/model2vec) (potion-base-8M, 256-dim). Vectors stored in [sqlite-vec](https://github.com/asg017/sqlite-vec). Everything runs on your machine.
74
83
 
75
84
  ---
76
85
 
77
- ## Hybrid Scoring
86
+ ## Hybrid Retrieval
87
+
88
+ Memor retrieves over two channels and fuses them, so it catches both semantic matches and exact terms:
89
+
90
+ - **Dense** — local vector similarity (model2vec) for semantic recall.
91
+ - **Lexical** — SQLite FTS5 / BM25 over the raw text, to recover exact identifiers, error strings, and API names that static embeddings blur together.
92
+
93
+ The two rankings are combined with **Reciprocal Rank Fusion (RRF)**. A **relevance gate** drops anti-correlated (off-topic) candidates *before* ranking, so an unrelated prompt injects nothing rather than the least-bad guess. The lexical channel only activates when the dense channel finds the query on-topic, preventing generic words from pulling in noise.
94
+
95
+ > Tunable via `MEMOR_MIN_SIMILARITY` (relevance floor, default 0.0) and `MEMOR_MAX_TOKENS` (injection budget, default 1500).
96
+
97
+ ## Scoring
78
98
 
79
- Memor doesn't just match keywords. Each memory is scored by four signals:
99
+ Surviving candidates are ranked by four signals:
80
100
 
81
101
  | Signal | Weight | How it works |
82
102
  |---|---|---|
83
- | **Semantic similarity** | 50% | Vector cosine distance between query and memory |
103
+ | **Semantic similarity** | 50% | Dense + lexical relevance, fused via RRF |
84
104
  | **Recency** | 25% | Exponential decay with 14-day half-life — recent decisions rank higher |
85
105
  | **Kind weight** | 15% | Distilled memories (1.3x) rank above raw session chunks (1.0x) |
86
106
  | **Quality** | 10% | Bayesian score from implicit feedback — memories the agent actually uses rank higher |
@@ -110,10 +130,11 @@ Memories are automatically classified as `decision`, `bugfix`, `lesson`, `snippe
110
130
  memor dashboard
111
131
  ```
112
132
 
113
- Shows:
114
- - **Memory bank** — session chunks, distilled memories, projects tracked
115
- - **Context efficiency** — overhead %, recall precision, quality scores per session
116
- - **Per-project breakdown** — which projects have the most context
133
+ Dark fintech-inspired UI showing:
134
+ - **Hero metrics** — total memories, recall count, avg latency, coverage — with sparkline bars
135
+ - **Daily recall activity** — stacked bar chart of hits vs misses over time
136
+ - **Session efficiency** — real token savings measured from API usage data (avg tokens/turn with vs without recall)
137
+ - **Per-project breakdown** — artifact counts, token totals, last activity
117
138
  - **Recent recalls** — every hook event with scores, latency, and status
118
139
 
119
140
  ---
@@ -125,6 +146,11 @@ memor help Print the full manual
125
146
  memor install-hook Install Claude Code hook + download model
126
147
  memor daemon Auto-ingest + distill (background watcher)
127
148
  memor dashboard Web dashboard on localhost:8420
149
+ memor version Print installed version
150
+ memor service install Run daemon as background service (launchd/systemd)
151
+ memor service stop Stop the background service
152
+ memor service uninstall Remove the background service
153
+ memor service status Check if the service is running
128
154
  memor query <text> Search memories from the CLI
129
155
  memor reingest Wipe DB and re-ingest everything
130
156
  memor reingest --project <name> Re-ingest only one project
@@ -156,16 +182,17 @@ memor/
156
182
  +-- feedback.py Implicit feedback analyzer (usage detection)
157
183
  |
158
184
  +-- retrieve/
159
- | +-- retriever.py Hybrid scoring: similarity + recency + kind + quality
185
+ | +-- retriever.py Hybrid retrieval (dense + BM25, RRF) + relevance gate + scoring
160
186
  |
161
187
  +-- store/
162
- | +-- sqlite_store.py SQLite + sqlite-vec (WAL mode, dimension safety)
188
+ | +-- sqlite_store.py SQLite + sqlite-vec + FTS5 (WAL mode, dimension safety)
163
189
  |
164
190
  +-- embed/
165
191
  | +-- local.py model2vec (potion-base-8M, 256-dim, ~60MB)
166
192
  | +-- api.py OpenAI-compatible embedding API (optional)
167
193
  | +-- fake.py Deterministic SHA-256 embedder (tests)
168
194
  |
195
+ +-- service.py Background service management (launchd/systemd)
169
196
  +-- dashboard/
170
197
  | +-- server.py FastAPI dashboard backend
171
198
  | +-- static/index.html Self-contained dashboard (no CDN deps)
@@ -190,7 +217,7 @@ skill/recall.py Standalone recall script
190
217
  **Nothing leaves your machine.** In the default configuration:
191
218
 
192
219
  - **No telemetry, no analytics, no phone-home.** Zero outbound network calls.
193
- - **Embeddings run locally** via model2vec ONNX (one-time model download from HuggingFace — no user data sent).
220
+ - **Embeddings run locally** via model2vec static token embeddings — no inference runtime, no GPU (one-time model download from HuggingFace — no user data sent).
194
221
  - **Hook transport is a Unix socket** (`~/.memor/hook.sock`), not a network port.
195
222
  - **Dashboard binds localhost only.**
196
223
 
@@ -226,7 +253,7 @@ cd memor-ai
226
253
  python3 -m venv .venv && source .venv/bin/activate
227
254
  pip install -e ".[dev]"
228
255
 
229
- pytest # 153 tests
256
+ pytest # 215 tests
230
257
  ```
231
258
 
232
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()
@@ -410,9 +410,9 @@
410
410
  <div class="side-stat-sub" id="e-avg-input-sub">across all sessions</div>
411
411
  </div>
412
412
  <div class="side-stat">
413
- <div class="side-stat-label">Recall Precision</div>
414
- <div class="side-stat-value" id="e-precision">&ndash;</div>
415
- <div class="side-stat-sub">% of prompts with relevant context</div>
413
+ <div class="side-stat-label">Recall Coverage</div>
414
+ <div class="side-stat-value" id="e-coverage">&ndash;</div>
415
+ <div class="side-stat-sub">% of prompts where context was injected</div>
416
416
  </div>
417
417
  <div class="side-stat">
418
418
  <div class="side-stat-label">Avg Latency</div>
@@ -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
 
@@ -581,7 +607,7 @@
581
607
 
582
608
  /* ── Efficiency ────────────────────────────────────────── */
583
609
  function renderEfficiency(data) {
584
- document.getElementById('e-precision').textContent = fmtPct(data.precision * 100);
610
+ document.getElementById('e-coverage').textContent = fmtPct(data.coverage * 100);
585
611
  }
586
612
 
587
613
  function renderSessionEfficiency(data) {
@@ -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:
@@ -72,7 +72,8 @@ def run_ablation(*, query, project, relevant_ids, store, embedder, k=8):
72
72
  "ndcg@k": ndcg_at_k(ids, relevant_ids, k)}
73
73
  return out
74
74
 
75
- def run_contradiction_eval(*, query, project, stale_id, current_id, store, embedder, k=8):
76
- r = Retriever(store, embedder, k=k, edge_expand=True)
75
+ def run_contradiction_eval(*, query, project, stale_id, current_id, store, embedder,
76
+ k=8, min_similarity=0.0):
77
+ r = Retriever(store, embedder, k=k, edge_expand=True, min_similarity=min_similarity)
77
78
  ids = [h.artifact.id for h in r.query(query, Scope(project=project)).hits]
78
79
  return (current_id in ids) and (stale_id not in ids)
@@ -0,0 +1,129 @@
1
+ """Feedback analyzer — detects whether recalled memories were used by the agent.
2
+
3
+ After a session ends, cross-references recall_log with the transcript to see
4
+ if the agent's responses referenced recalled content. Updates memory_quality
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
+ """
11
+ from __future__ import annotations
12
+ import json
13
+ import math
14
+ from pathlib import Path
15
+ from memor.store.sqlite_store import SqliteStore
16
+
17
+ _NGRAM_SIZE = 3
18
+ _MIN_WORDS = 4
19
+ _MATCH_RATIO = 0.10
20
+ _SEMANTIC_SIM_THRESHOLD = 0.45
21
+
22
+
23
+ def _extract_assistant_texts(transcript_path: Path) -> list[str]:
24
+ texts = []
25
+ for line in transcript_path.read_text().splitlines():
26
+ line = line.strip()
27
+ if not line:
28
+ continue
29
+ try:
30
+ rec = json.loads(line)
31
+ except json.JSONDecodeError:
32
+ continue
33
+ if rec.get("type") != "assistant":
34
+ continue
35
+ msg = rec.get("message", {})
36
+ content = msg.get("content", "")
37
+ if isinstance(content, str):
38
+ texts.append(content.lower())
39
+ elif isinstance(content, list):
40
+ for block in content:
41
+ if isinstance(block, dict) and block.get("type") == "text":
42
+ texts.append(block.get("text", "").lower())
43
+ return texts
44
+
45
+
46
+ def _text_was_used(memory_text: str, assistant_texts: list[str]) -> bool:
47
+ words = memory_text.lower().split()
48
+ if len(words) < _MIN_WORDS:
49
+ return False
50
+ ngrams = []
51
+ for i in range(len(words) - _NGRAM_SIZE + 1):
52
+ ngrams.append(" ".join(words[i:i + _NGRAM_SIZE]))
53
+ if not ngrams:
54
+ return False
55
+ matches = 0
56
+ for phrase in ngrams:
57
+ for text in assistant_texts:
58
+ if phrase in text:
59
+ matches += 1
60
+ break
61
+ return matches >= max(1, math.ceil(len(ngrams) * _MATCH_RATIO))
62
+
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
+
80
+ def analyze_session_feedback(
81
+ store: SqliteStore, session_id: str, transcript_path: Path,
82
+ *, embedder=None,
83
+ ) -> int:
84
+ recalled_ids = set()
85
+ rows = store.db.execute("""
86
+ SELECT q.artifact_id FROM memory_quality q
87
+ JOIN artifacts a ON a.id = q.artifact_id
88
+ WHERE a.active = 1
89
+ AND a.project = (
90
+ SELECT project FROM recall_log
91
+ WHERE session_id = ? AND hits_count > 0
92
+ LIMIT 1
93
+ )
94
+ AND q.last_recalled >= (
95
+ SELECT MIN(timestamp) FROM recall_log
96
+ WHERE session_id = ? AND hits_count > 0
97
+ )
98
+ AND q.last_recalled <= (
99
+ SELECT MAX(timestamp) FROM recall_log
100
+ WHERE session_id = ? AND hits_count > 0
101
+ ) + 5
102
+ """, (session_id, session_id, session_id)).fetchall()
103
+ for row in rows:
104
+ recalled_ids.add(row["artifact_id"])
105
+
106
+ if not recalled_ids:
107
+ return 0
108
+
109
+ assistant_texts = _extract_assistant_texts(transcript_path)
110
+ if not assistant_texts:
111
+ return 0
112
+
113
+ used_ids = []
114
+ combined_response = " ".join(assistant_texts) if embedder else ""
115
+ for aid in recalled_ids:
116
+ art = store.db.execute(
117
+ "SELECT text FROM artifacts WHERE id=?", (aid,)
118
+ ).fetchone()
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):
124
+ used_ids.append(aid)
125
+
126
+ if used_ids:
127
+ store.record_usage(used_ids)
128
+
129
+ return len(used_ids)