memor-cli 0.5.0__tar.gz → 0.6.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 (104) hide show
  1. {memor_cli-0.5.0/memor_cli.egg-info → memor_cli-0.6.0}/PKG-INFO +26 -6
  2. {memor_cli-0.5.0 → memor_cli-0.6.0}/README.md +25 -5
  3. memor_cli-0.6.0/memor/__init__.py +1 -0
  4. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor/daemon.py +10 -0
  5. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor/dashboard/server.py +26 -5
  6. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor/dashboard/static/index.html +93 -6
  7. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor/feedback.py +55 -9
  8. memor_cli-0.6.0/memor/global_memories.py +102 -0
  9. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor/store/sqlite_store.py +33 -6
  10. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor/types.py +4 -1
  11. {memor_cli-0.5.0 → memor_cli-0.6.0/memor_cli.egg-info}/PKG-INFO +26 -6
  12. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor_cli.egg-info/SOURCES.txt +4 -0
  13. {memor_cli-0.5.0 → memor_cli-0.6.0}/pyproject.toml +1 -1
  14. memor_cli-0.6.0/tests/test_dashboard_quality.py +93 -0
  15. memor_cli-0.6.0/tests/test_global_memories.py +178 -0
  16. memor_cli-0.6.0/tests/test_negative_feedback.py +208 -0
  17. memor_cli-0.5.0/memor/__init__.py +0 -1
  18. {memor_cli-0.5.0 → memor_cli-0.6.0}/LICENSE +0 -0
  19. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor/cli.py +0 -0
  20. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor/dashboard/__init__.py +0 -0
  21. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor/distill/__init__.py +0 -0
  22. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor/distill/distiller.py +0 -0
  23. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor/distill/extractive.py +0 -0
  24. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor/embed/__init__.py +0 -0
  25. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor/embed/api.py +0 -0
  26. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor/embed/fake.py +0 -0
  27. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor/embed/local.py +0 -0
  28. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor/eval/__init__.py +0 -0
  29. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor/eval/baselines/__init__.py +0 -0
  30. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor/eval/baselines/base.py +0 -0
  31. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor/eval/baselines/claude_mem.py +0 -0
  32. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor/eval/baselines/graphiti.py +0 -0
  33. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor/eval/counterfactual.py +0 -0
  34. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor/eval/dataset.py +0 -0
  35. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor/eval/embed_benchmark.py +0 -0
  36. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor/eval/judge.py +0 -0
  37. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor/eval/metrics.py +0 -0
  38. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor/eval/runner.py +0 -0
  39. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor/hook_cli.py +0 -0
  40. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor/hook_server.py +0 -0
  41. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor/ingest/__init__.py +0 -0
  42. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor/ingest/claude_code.py +0 -0
  43. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor/ingest/documents.py +0 -0
  44. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor/interfaces.py +0 -0
  45. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor/llm/__init__.py +0 -0
  46. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor/llm/anthropic.py +0 -0
  47. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor/llm/base.py +0 -0
  48. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor/llm/openai_compat.py +0 -0
  49. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor/project.py +0 -0
  50. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor/query_complexity.py +0 -0
  51. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor/recall.py +0 -0
  52. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor/redact.py +0 -0
  53. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor/retrieve/__init__.py +0 -0
  54. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor/retrieve/retriever.py +0 -0
  55. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor/service.py +0 -0
  56. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor/session_context.py +0 -0
  57. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor/store/__init__.py +0 -0
  58. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor/tokencount.py +0 -0
  59. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor/turn_metrics.py +0 -0
  60. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor_cli.egg-info/dependency_links.txt +0 -0
  61. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor_cli.egg-info/entry_points.txt +0 -0
  62. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor_cli.egg-info/requires.txt +0 -0
  63. {memor_cli-0.5.0 → memor_cli-0.6.0}/memor_cli.egg-info/top_level.txt +0 -0
  64. {memor_cli-0.5.0 → memor_cli-0.6.0}/setup.cfg +0 -0
  65. {memor_cli-0.5.0 → memor_cli-0.6.0}/tests/test_cli_smoke.py +0 -0
  66. {memor_cli-0.5.0 → memor_cli-0.6.0}/tests/test_counterfactual.py +0 -0
  67. {memor_cli-0.5.0 → memor_cli-0.6.0}/tests/test_daemon.py +0 -0
  68. {memor_cli-0.5.0 → memor_cli-0.6.0}/tests/test_dashboard.py +0 -0
  69. {memor_cli-0.5.0 → memor_cli-0.6.0}/tests/test_dataset_builder.py +0 -0
  70. {memor_cli-0.5.0 → memor_cli-0.6.0}/tests/test_dimension_safety.py +0 -0
  71. {memor_cli-0.5.0 → memor_cli-0.6.0}/tests/test_distiller.py +0 -0
  72. {memor_cli-0.5.0 → memor_cli-0.6.0}/tests/test_embed.py +0 -0
  73. {memor_cli-0.5.0 → memor_cli-0.6.0}/tests/test_embed_benchmark.py +0 -0
  74. {memor_cli-0.5.0 → memor_cli-0.6.0}/tests/test_eval_ablation.py +0 -0
  75. {memor_cli-0.5.0 → memor_cli-0.6.0}/tests/test_eval_runner.py +0 -0
  76. {memor_cli-0.5.0 → memor_cli-0.6.0}/tests/test_external_baselines.py +0 -0
  77. {memor_cli-0.5.0 → memor_cli-0.6.0}/tests/test_extractive.py +0 -0
  78. {memor_cli-0.5.0 → memor_cli-0.6.0}/tests/test_feedback.py +0 -0
  79. {memor_cli-0.5.0 → memor_cli-0.6.0}/tests/test_hook.py +0 -0
  80. {memor_cli-0.5.0 → memor_cli-0.6.0}/tests/test_hook_server.py +0 -0
  81. {memor_cli-0.5.0 → memor_cli-0.6.0}/tests/test_hybrid_retrieval.py +0 -0
  82. {memor_cli-0.5.0 → memor_cli-0.6.0}/tests/test_ingest_claude_code.py +0 -0
  83. {memor_cli-0.5.0 → memor_cli-0.6.0}/tests/test_ingest_documents.py +0 -0
  84. {memor_cli-0.5.0 → memor_cli-0.6.0}/tests/test_install_hook.py +0 -0
  85. {memor_cli-0.5.0 → memor_cli-0.6.0}/tests/test_interfaces.py +0 -0
  86. {memor_cli-0.5.0 → memor_cli-0.6.0}/tests/test_judge.py +0 -0
  87. {memor_cli-0.5.0 → memor_cli-0.6.0}/tests/test_metrics.py +0 -0
  88. {memor_cli-0.5.0 → memor_cli-0.6.0}/tests/test_noise_filter.py +0 -0
  89. {memor_cli-0.5.0 → memor_cli-0.6.0}/tests/test_project_resolver.py +0 -0
  90. {memor_cli-0.5.0 → memor_cli-0.6.0}/tests/test_quality_gate.py +0 -0
  91. {memor_cli-0.5.0 → memor_cli-0.6.0}/tests/test_query_complexity.py +0 -0
  92. {memor_cli-0.5.0 → memor_cli-0.6.0}/tests/test_recall_core.py +0 -0
  93. {memor_cli-0.5.0 → memor_cli-0.6.0}/tests/test_redact.py +0 -0
  94. {memor_cli-0.5.0 → memor_cli-0.6.0}/tests/test_retriever.py +0 -0
  95. {memor_cli-0.5.0 → memor_cli-0.6.0}/tests/test_roi_trend.py +0 -0
  96. {memor_cli-0.5.0 → memor_cli-0.6.0}/tests/test_semantic_feedback.py +0 -0
  97. {memor_cli-0.5.0 → memor_cli-0.6.0}/tests/test_service.py +0 -0
  98. {memor_cli-0.5.0 → memor_cli-0.6.0}/tests/test_session_context.py +0 -0
  99. {memor_cli-0.5.0 → memor_cli-0.6.0}/tests/test_skill_recall.py +0 -0
  100. {memor_cli-0.5.0 → memor_cli-0.6.0}/tests/test_store.py +0 -0
  101. {memor_cli-0.5.0 → memor_cli-0.6.0}/tests/test_supersession.py +0 -0
  102. {memor_cli-0.5.0 → memor_cli-0.6.0}/tests/test_tokencount.py +0 -0
  103. {memor_cli-0.5.0 → memor_cli-0.6.0}/tests/test_turn_metrics.py +0 -0
  104. {memor_cli-0.5.0 → memor_cli-0.6.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.5.0
3
+ Version: 0.6.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-227%20passing-brightgreen.svg)]()
49
+ [![Tests](https://img.shields.io/badge/tests-256%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
 
@@ -113,7 +113,7 @@ memor dashboard
113
113
 
114
114
  **Two background processes:**
115
115
 
116
- 1. **Daemon** — polls `~/.claude/projects/` for transcripts, embeds chunks, runs distillation, analyzes feedback, compacts duplicates, tracks session-level token usage. All local.
116
+ 1. **Daemon** — polls `~/.claude/projects/` for transcripts, embeds chunks, runs distillation, analyzes feedback (positive and negative), promotes cross-project patterns to global scope, compacts duplicates, tracks session-level token usage. All local.
117
117
  2. **Hook** — fires on every prompt, recalls relevant memories, injects them as context. Sub-15ms.
118
118
 
119
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.
@@ -146,7 +146,12 @@ This means a relevant decision from yesterday beats a vaguely-related chunk from
146
146
 
147
147
  ### Feedback Loop
148
148
 
149
- Memor tracks whether recalled memories actually get used by the agent. After each session, the daemon analyzes the transcript to detect if recalled content appeared in the agent's responses. Memories that consistently prove useful get quality boosts; memories never recalled in 30+ days get automatically deactivated. Near-duplicate memories are compacted into one.
149
+ Memor tracks whether recalled memories actually get used by the agent — and whether they actively hurt. After each session, the daemon analyzes the transcript in both directions:
150
+
151
+ - **Positive signal** — n-gram overlap or semantic similarity between recalled content and the agent's response. Memories that consistently prove useful get quality boosts.
152
+ - **Negative signal** — user rejection ("no that's wrong", "we switched to X") or assistant contradiction ("however, looking at the current code, we actually use Y"). Memories that get corrected receive a quality penalty, making them less likely to be recalled next time.
153
+
154
+ The quality formula is Bayesian: `(uses - negatives + 1) / (recalls + 2)`. One correction weighs as much as one positive use, so harmful memories drop fast. Memories never recalled in 30+ days get automatically deactivated. Near-duplicate memories are compacted into one.
150
155
 
151
156
  ---
152
157
 
@@ -161,6 +166,20 @@ Memories are automatically classified as `decision`, `bugfix`, `lesson`, `snippe
161
166
 
162
167
  ---
163
168
 
169
+ ## Global Memories
170
+
171
+ Some patterns aren't project-specific — they're yours. "Always use type hints." "Structure FastAPI apps with a `routes/` directory." "Prefer composition over inheritance."
172
+
173
+ Memor detects these automatically. When the same pattern appears in **3 or more projects** (measured by embedding similarity), the daemon promotes it to a `_global` scope:
174
+
175
+ - **Global memories are recalled everywhere** — they show up in every project's search results alongside project-specific memories.
176
+ - **Source duplicates are deactivated** — the per-project copies get superseded by the single global version, reducing clutter.
177
+ - **No manual tagging** — promotion is fully automatic, based on cross-project clustering.
178
+
179
+ This means your coding habits and preferences follow you into new projects from the first prompt, without you having to re-explain anything.
180
+
181
+ ---
182
+
164
183
  ## Dashboard
165
184
 
166
185
  ```bash
@@ -217,7 +236,8 @@ memor/
217
236
  +-- project.py Git-root project resolver (filesystem-aware)
218
237
  +-- recall.py Shared recall core (used by hook + skill)
219
238
  +-- redact.py Secret detection and redaction at ingest
220
- +-- feedback.py Implicit feedback analyzer (usage detection)
239
+ +-- feedback.py Feedback analyzer (positive usage + negative signals)
240
+ +-- global_memories.py Cross-project promotion to _global scope
221
241
  |
222
242
  +-- retrieve/
223
243
  | +-- retriever.py Hybrid retrieval (dense + BM25, RRF) + relevance gate + scoring
@@ -291,7 +311,7 @@ cd memor-ai
291
311
  python3 -m venv .venv && source .venv/bin/activate
292
312
  pip install -e ".[dev]"
293
313
 
294
- pytest # 215 tests
314
+ pytest # 251 tests
295
315
  ```
296
316
 
297
317
  ---
@@ -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-227%20passing-brightgreen.svg)]()
12
+ [![Tests](https://img.shields.io/badge/tests-256%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
 
@@ -76,7 +76,7 @@ memor dashboard
76
76
 
77
77
  **Two background processes:**
78
78
 
79
- 1. **Daemon** — polls `~/.claude/projects/` for transcripts, embeds chunks, runs distillation, analyzes feedback, compacts duplicates, tracks session-level token usage. All local.
79
+ 1. **Daemon** — polls `~/.claude/projects/` for transcripts, embeds chunks, runs distillation, analyzes feedback (positive and negative), promotes cross-project patterns to global scope, compacts duplicates, tracks session-level token usage. All local.
80
80
  2. **Hook** — fires on every prompt, recalls relevant memories, injects them as context. Sub-15ms.
81
81
 
82
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.
@@ -109,7 +109,12 @@ This means a relevant decision from yesterday beats a vaguely-related chunk from
109
109
 
110
110
  ### Feedback Loop
111
111
 
112
- Memor tracks whether recalled memories actually get used by the agent. After each session, the daemon analyzes the transcript to detect if recalled content appeared in the agent's responses. Memories that consistently prove useful get quality boosts; memories never recalled in 30+ days get automatically deactivated. Near-duplicate memories are compacted into one.
112
+ Memor tracks whether recalled memories actually get used by the agent — and whether they actively hurt. After each session, the daemon analyzes the transcript in both directions:
113
+
114
+ - **Positive signal** — n-gram overlap or semantic similarity between recalled content and the agent's response. Memories that consistently prove useful get quality boosts.
115
+ - **Negative signal** — user rejection ("no that's wrong", "we switched to X") or assistant contradiction ("however, looking at the current code, we actually use Y"). Memories that get corrected receive a quality penalty, making them less likely to be recalled next time.
116
+
117
+ The quality formula is Bayesian: `(uses - negatives + 1) / (recalls + 2)`. One correction weighs as much as one positive use, so harmful memories drop fast. Memories never recalled in 30+ days get automatically deactivated. Near-duplicate memories are compacted into one.
113
118
 
114
119
  ---
115
120
 
@@ -124,6 +129,20 @@ Memories are automatically classified as `decision`, `bugfix`, `lesson`, `snippe
124
129
 
125
130
  ---
126
131
 
132
+ ## Global Memories
133
+
134
+ Some patterns aren't project-specific — they're yours. "Always use type hints." "Structure FastAPI apps with a `routes/` directory." "Prefer composition over inheritance."
135
+
136
+ Memor detects these automatically. When the same pattern appears in **3 or more projects** (measured by embedding similarity), the daemon promotes it to a `_global` scope:
137
+
138
+ - **Global memories are recalled everywhere** — they show up in every project's search results alongside project-specific memories.
139
+ - **Source duplicates are deactivated** — the per-project copies get superseded by the single global version, reducing clutter.
140
+ - **No manual tagging** — promotion is fully automatic, based on cross-project clustering.
141
+
142
+ This means your coding habits and preferences follow you into new projects from the first prompt, without you having to re-explain anything.
143
+
144
+ ---
145
+
127
146
  ## Dashboard
128
147
 
129
148
  ```bash
@@ -180,7 +199,8 @@ memor/
180
199
  +-- project.py Git-root project resolver (filesystem-aware)
181
200
  +-- recall.py Shared recall core (used by hook + skill)
182
201
  +-- redact.py Secret detection and redaction at ingest
183
- +-- feedback.py Implicit feedback analyzer (usage detection)
202
+ +-- feedback.py Feedback analyzer (positive usage + negative signals)
203
+ +-- global_memories.py Cross-project promotion to _global scope
184
204
  |
185
205
  +-- retrieve/
186
206
  | +-- retriever.py Hybrid retrieval (dense + BM25, RRF) + relevance gate + scoring
@@ -254,7 +274,7 @@ cd memor-ai
254
274
  python3 -m venv .venv && source .venv/bin/activate
255
275
  pip install -e ".[dev]"
256
276
 
257
- pytest # 215 tests
277
+ pytest # 251 tests
258
278
  ```
259
279
 
260
280
  ---
@@ -0,0 +1 @@
1
+ __version__ = "0.6.0"
@@ -267,6 +267,16 @@ def run_poll_cycle(
267
267
  except Exception:
268
268
  pass
269
269
 
270
+ # Promote cross-project patterns to global scope
271
+ if new_ingested:
272
+ try:
273
+ from memor.global_memories import run_promotion
274
+ promoted = run_promotion(store, embedder, min_projects=3)
275
+ if promoted > 0:
276
+ print(f" promoted {promoted} memories to global scope")
277
+ except Exception:
278
+ pass
279
+
270
280
  # Compact near-duplicate memories (run occasionally, not every cycle)
271
281
  if new_ingested:
272
282
  try:
@@ -27,6 +27,7 @@ def create_app(db_path: str | None = None) -> FastAPI:
27
27
 
28
28
  @app.get("/api/summary")
29
29
  def summary():
30
+ from memor.types import GLOBAL_PROJECT
30
31
  store = _store()
31
32
  recall_stats = store.get_recall_stats()
32
33
  ingestion = {}
@@ -38,17 +39,22 @@ def create_app(db_path: str | None = None) -> FastAPI:
38
39
  project_count = store.db.execute(
39
40
  "SELECT COUNT(DISTINCT project) as c FROM artifacts"
40
41
  ).fetchone()["c"]
42
+ global_count = store.db.execute(
43
+ "SELECT COUNT(*) as c FROM artifacts WHERE active=1 AND project=? AND kind='memory'",
44
+ (GLOBAL_PROJECT,)
45
+ ).fetchone()["c"]
41
46
  recall_stats["ingestion"] = ingestion
42
47
  recall_stats["project_count"] = project_count
48
+ recall_stats["global_memories"] = global_count
43
49
  return recall_stats
44
50
 
45
51
  @app.get("/api/projects")
46
52
  def projects():
47
53
  store = _store()
48
54
  recall_stats = store.get_project_stats()
49
- if recall_stats:
50
- return recall_stats
51
- rows = store.db.execute("""
55
+ recall_projects = {r["project"] for r in recall_stats} if recall_stats else set()
56
+
57
+ artifact_rows = store.db.execute("""
52
58
  SELECT project,
53
59
  COUNT(*) as artifacts,
54
60
  SUM(CASE WHEN kind='session_chunk' THEN 1 ELSE 0 END) as chunks,
@@ -59,7 +65,15 @@ def create_app(db_path: str | None = None) -> FastAPI:
59
65
  GROUP BY project
60
66
  ORDER BY artifacts DESC
61
67
  """).fetchall()
62
- return [dict(r) for r in rows]
68
+
69
+ if not recall_stats:
70
+ return [dict(r) for r in artifact_rows]
71
+
72
+ result = list(recall_stats)
73
+ for r in artifact_rows:
74
+ if r["project"] not in recall_projects:
75
+ result.append(dict(r))
76
+ return result
63
77
 
64
78
  @app.get("/api/recalls")
65
79
  def recalls(limit: int = Query(50, ge=1, le=500),
@@ -71,7 +85,9 @@ def create_app(db_path: str | None = None) -> FastAPI:
71
85
  def quality():
72
86
  store = _store()
73
87
  rows = store.db.execute("""
74
- SELECT q.artifact_id, q.recall_count, q.use_count, q.quality_score,
88
+ SELECT q.artifact_id, q.recall_count, q.use_count,
89
+ COALESCE(q.negative_count, 0) as negative_count,
90
+ q.quality_score,
75
91
  a.project, a.kind, json_extract(a.meta, '$.mem_type') as mem_type,
76
92
  substr(a.text, 1, 100) as preview
77
93
  FROM memory_quality q
@@ -152,6 +168,11 @@ def create_app(db_path: str | None = None) -> FastAPI:
152
168
  return {"status": "no_runs"}
153
169
  return result
154
170
 
171
+ @app.get("/api/version")
172
+ def version():
173
+ from memor import __version__
174
+ return {"version": __version__}
175
+
155
176
  @app.get("/api/health")
156
177
  def health():
157
178
  store = _store()
@@ -113,7 +113,7 @@
113
113
  /* ── Metric cards ──────────────────────────────────────── */
114
114
  .metric-grid {
115
115
  display: grid;
116
- grid-template-columns: repeat(4, 1fr);
116
+ grid-template-columns: repeat(5, 1fr);
117
117
  gap: 14px;
118
118
  }
119
119
  @media (max-width: 900px) { .metric-grid { grid-template-columns: repeat(2, 1fr); } }
@@ -300,6 +300,16 @@
300
300
  .badge-extractive::before { background: var(--warn); }
301
301
  .badge-no_hits { background: var(--neutral-dim); color: var(--neutral); }
302
302
  .badge-no_hits::before { background: var(--neutral); }
303
+ .badge-global { background: rgba(120,100,255,0.12); color: #a090ff; }
304
+ .badge-global::before { background: #a090ff; }
305
+ .badge-negative { background: rgba(232,84,84,0.12); color: var(--error); }
306
+ .badge-negative::before { background: var(--error); }
307
+
308
+ .quality-bar { display: flex; height: 6px; border-radius: 3px; overflow: hidden; background: var(--surface3); width: 100%; min-width: 60px; }
309
+ .quality-fill { height: 100%; border-radius: 3px; transition: width 0.3s; }
310
+ .quality-fill-ok { background: var(--ok); }
311
+ .quality-fill-warn { background: var(--warn); }
312
+ .quality-fill-error { background: var(--error); }
303
313
 
304
314
  .empty-state {
305
315
  text-align: center; padding: 40px 20px;
@@ -319,6 +329,7 @@
319
329
  <div class="topbar-inner">
320
330
  <div class="breadcrumb">Dashboard &rsaquo; <span>Overview</span></div>
321
331
  <div class="topbar-right">
332
+ <span id="version-badge" class="mono" style="font-size:11px;color:var(--text-muted);margin-right:12px;"></span>
322
333
  <div class="topbar-time">
323
334
  <span class="refresh-dot"></span>
324
335
  <span id="last-updated">Live</span>
@@ -369,6 +380,13 @@
369
380
  </div>
370
381
  <div class="mini-bars" id="mb-projects"></div>
371
382
  </div>
383
+ <div class="metric-card">
384
+ <div class="metric-info">
385
+ <div class="metric-label">Global</div>
386
+ <div class="metric-value" id="m-global">&ndash;</div>
387
+ <div class="metric-sub">cross-project memories</div>
388
+ </div>
389
+ </div>
372
390
  <div class="metric-card">
373
391
  <div class="metric-info">
374
392
  <div class="metric-label">Total Recalls</div>
@@ -485,6 +503,30 @@
485
503
  </div>
486
504
  </section>
487
505
 
506
+ <!-- ── Memory Quality ──────────────────────────────────── -->
507
+ <section>
508
+ <div class="table-card" id="quality-section" style="display:none;">
509
+ <div class="table-header">
510
+ <div class="table-title"><span class="dot"></span> Memory Quality</div>
511
+ </div>
512
+ <table>
513
+ <thead>
514
+ <tr>
515
+ <th>Memory</th>
516
+ <th>Project</th>
517
+ <th>Type</th>
518
+ <th>Recalls</th>
519
+ <th>Used</th>
520
+ <th>Rejected</th>
521
+ <th>Score</th>
522
+ <th style="min-width:80px;"></th>
523
+ </tr>
524
+ </thead>
525
+ <tbody id="quality-body"></tbody>
526
+ </table>
527
+ </div>
528
+ </section>
529
+
488
530
  <!-- ── Recent recalls ─────────────────────────────────── -->
489
531
  <section>
490
532
  <div class="table-card">
@@ -603,6 +645,7 @@
603
645
  document.getElementById('m-chunks').textContent = fmt(chunks);
604
646
  document.getElementById('m-memories').textContent = fmt(memories);
605
647
  document.getElementById('m-projects').textContent = fmt(d.project_count || 0);
648
+ document.getElementById('m-global').textContent = fmt(d.global_memories || 0);
606
649
  document.getElementById('m-recalls').textContent = fmt(d.total_recalls);
607
650
  if (d.total_recalls > 0) {
608
651
  document.getElementById('m-recalls-sub').textContent =
@@ -695,6 +738,39 @@
695
738
  });
696
739
  }
697
740
 
741
+ /* ── Quality ───────────────────────────────────────────── */
742
+ function renderQuality(data) {
743
+ var section = document.getElementById('quality-section');
744
+ var tbody = document.getElementById('quality-body');
745
+ tbody.innerHTML = '';
746
+ if (!data || !data.length) { section.style.display = 'none'; return; }
747
+ section.style.display = '';
748
+ data.forEach(function(row) {
749
+ var neg = row.negative_count || 0;
750
+ var score = row.quality_score || 0;
751
+ var barClass = score >= 0.5 ? 'quality-fill-ok' : (score >= 0.25 ? 'quality-fill-warn' : 'quality-fill-error');
752
+ var barWidth = Math.max(2, Math.round(score * 100));
753
+ var projLabel = row.project === '_global'
754
+ ? '<span class="badge badge-global">Global</span>'
755
+ : esc(row.project);
756
+ var typeLabel = esc(row.mem_type || row.kind);
757
+ var negHtml = neg > 0
758
+ ? '<span style="color:var(--error);font-weight:600;">' + neg + '</span>'
759
+ : '<span style="color:var(--text-muted);">0</span>';
760
+ var tr = el('tr', {});
761
+ tr.innerHTML =
762
+ '<td class="truncate" style="max-width:260px" title="' + esc(row.preview) + '">' + esc(row.preview) + '</td>' +
763
+ '<td>' + projLabel + '</td>' +
764
+ '<td><span class="mono" style="font-size:11px;color:var(--text-muted);">' + typeLabel + '</span></td>' +
765
+ '<td>' + (row.recall_count || 0) + '</td>' +
766
+ '<td style="color:var(--ok);">' + (row.use_count || 0) + '</td>' +
767
+ '<td>' + negHtml + '</td>' +
768
+ '<td class="mono">' + fmtScore(score) + '</td>' +
769
+ '<td><div class="quality-bar"><div class="quality-fill ' + barClass + '" style="width:' + barWidth + '%;"></div></div></td>';
770
+ tbody.appendChild(tr);
771
+ });
772
+ }
773
+
698
774
  /* ── Projects ──────────────────────────────────────────── */
699
775
  function renderProjects(data) {
700
776
  projectsData = data;
@@ -737,9 +813,13 @@
737
813
  sorted.forEach(function(row) {
738
814
  var tr = el('tr', {});
739
815
  if (projectFilter === row.project) tr.classList.add('selected');
816
+ var isGlobal = row.project === '_global';
817
+ var projCell = isGlobal
818
+ ? '<td><span class="badge badge-global">Global</span></td>'
819
+ : '<td class="truncate mono" title="' + esc(row.project) + '">' + esc(row.project) + '</td>';
740
820
  if (isRecallMode) {
741
821
  tr.innerHTML =
742
- '<td class="truncate mono" title="' + esc(row.project) + '">' + esc(row.project) + '</td>' +
822
+ projCell +
743
823
  '<td>' + fmt(row.recalls) + '</td><td>' + fmt(row.tokens) + '</td>' +
744
824
  '<td>' + fmtScore(row.avg_score) + '</td>' +
745
825
  '<td style="color:var(--ok)">' + (row.ok_count||0) + '</td>' +
@@ -747,10 +827,10 @@
747
827
  '<td style="color:var(--warn)">' + (row.extractive_count||0) + '</td>';
748
828
  } else {
749
829
  var hasMemories = (row.memories||0) > 0;
750
- var badgeCls = hasMemories ? 'badge-ok' : 'badge-extractive';
751
- var status = hasMemories ? 'Active' : 'Ingested';
830
+ var badgeCls = isGlobal ? 'badge-global' : (hasMemories ? 'badge-ok' : 'badge-extractive');
831
+ var status = isGlobal ? 'Global' : (hasMemories ? 'Active' : 'Ingested');
752
832
  tr.innerHTML =
753
- '<td class="truncate mono" title="' + esc(row.project) + '">' + esc(row.project) + '</td>' +
833
+ projCell +
754
834
  '<td>' + fmt(row.chunks||0) + '</td><td>' + fmt(row.memories||0) + '</td>' +
755
835
  '<td>' + fmt(row.total_tokens||0) + '</td>' +
756
836
  '<td><span class="badge ' + badgeCls + '">' + status + '</span></td>' +
@@ -931,6 +1011,7 @@
931
1011
  async function loadTrend() { try { renderTrend(await api('/api/recall-trend?days=30')); } catch(e) { console.warn('trend',e); } }
932
1012
  async function loadROI() { try { renderROI(await api('/api/roi')); } catch(e) { console.warn('roi',e); } }
933
1013
  async function loadROITrend() { try { renderROITrend(await api('/api/roi-trend')); } catch(e) { console.warn('roi-trend',e); } }
1014
+ async function loadQuality() { try { renderQuality(await api('/api/quality')); } catch(e) { console.warn('quality',e); } }
934
1015
  async function loadRecalls() {
935
1016
  try {
936
1017
  var url = '/api/recalls?limit=50' + (projectFilter ? '&project=' + encodeURIComponent(projectFilter) : '');
@@ -939,7 +1020,7 @@
939
1020
  }
940
1021
 
941
1022
  async function refresh() {
942
- await Promise.allSettled([loadSummary(), loadProjects(), loadEfficiency(), loadSessionEfficiency(), loadHealth(), loadTrend(), loadRecalls(), loadROI(), loadROITrend()]);
1023
+ await Promise.allSettled([loadSummary(), loadProjects(), loadEfficiency(), loadSessionEfficiency(), loadHealth(), loadTrend(), loadRecalls(), loadROI(), loadROITrend(), loadQuality()]);
943
1024
  document.getElementById('last-updated').textContent = new Date().toLocaleTimeString();
944
1025
 
945
1026
  renderMiniBars('mb-chunks', null);
@@ -956,6 +1037,12 @@
956
1037
  });
957
1038
  });
958
1039
 
1040
+ (async function() {
1041
+ try {
1042
+ var v = await api('/api/version');
1043
+ document.getElementById('version-badge').textContent = 'v' + v.version;
1044
+ } catch(e) {}
1045
+ })();
959
1046
  refresh();
960
1047
  setInterval(refresh, 30000);
961
1048
  })();
@@ -19,9 +19,29 @@ _MIN_WORDS = 4
19
19
  _MATCH_RATIO = 0.10
20
20
  _SEMANTIC_SIM_THRESHOLD = 0.45
21
21
 
22
-
23
- def _extract_assistant_texts(transcript_path: Path) -> list[str]:
24
- texts = []
22
+ _REJECTION_PATTERNS = [
23
+ "no that's wrong", "that's not right", "that's incorrect", "that's outdated",
24
+ "no, we", "no we", "actually we", "actually, we",
25
+ "we switched", "we moved", "we changed", "we no longer",
26
+ "that's not how", "not what i meant", "wrong approach",
27
+ "we don't use", "we stopped using", "we dropped",
28
+ ]
29
+
30
+ _CONTRADICTION_PATTERNS = [
31
+ "however, looking at the current code",
32
+ "but the current implementation",
33
+ "actually use", "actually uses",
34
+ "instead of what the memory",
35
+ "contrary to the recalled",
36
+ "the memory is outdated",
37
+ "this is no longer",
38
+ ]
39
+
40
+
41
+ def _extract_texts(transcript_path: Path) -> tuple[list[str], list[str]]:
42
+ """Extract (assistant_texts, user_texts) from a transcript JSONL."""
43
+ assistant_texts = []
44
+ user_texts = []
25
45
  for line in transcript_path.read_text().splitlines():
26
46
  line = line.strip()
27
47
  if not line:
@@ -30,17 +50,40 @@ def _extract_assistant_texts(transcript_path: Path) -> list[str]:
30
50
  rec = json.loads(line)
31
51
  except json.JSONDecodeError:
32
52
  continue
33
- if rec.get("type") != "assistant":
34
- continue
35
53
  msg = rec.get("message", {})
36
54
  content = msg.get("content", "")
55
+ parts = []
37
56
  if isinstance(content, str):
38
- texts.append(content.lower())
57
+ parts.append(content.lower())
39
58
  elif isinstance(content, list):
40
59
  for block in content:
41
60
  if isinstance(block, dict) and block.get("type") == "text":
42
- texts.append(block.get("text", "").lower())
43
- return texts
61
+ parts.append(block.get("text", "").lower())
62
+ if rec.get("type") == "assistant":
63
+ assistant_texts.extend(parts)
64
+ elif rec.get("type") == "human":
65
+ user_texts.extend(parts)
66
+ return assistant_texts, user_texts
67
+
68
+
69
+ def _extract_assistant_texts(transcript_path: Path) -> list[str]:
70
+ assistant_texts, _ = _extract_texts(transcript_path)
71
+ return assistant_texts
72
+
73
+
74
+ def _detect_negative_signals(assistant_texts: list[str], user_texts: list[str]) -> bool:
75
+ """Detect if user rejected or assistant contradicted recalled content."""
76
+ combined_user = " ".join(user_texts)
77
+ for pattern in _REJECTION_PATTERNS:
78
+ if pattern in combined_user:
79
+ return True
80
+
81
+ combined_assistant = " ".join(assistant_texts)
82
+ for pattern in _CONTRADICTION_PATTERNS:
83
+ if pattern in combined_assistant:
84
+ return True
85
+
86
+ return False
44
87
 
45
88
 
46
89
  def _text_was_used(memory_text: str, assistant_texts: list[str]) -> bool:
@@ -106,7 +149,7 @@ def analyze_session_feedback(
106
149
  if not recalled_ids:
107
150
  return 0
108
151
 
109
- assistant_texts = _extract_assistant_texts(transcript_path)
152
+ assistant_texts, user_texts = _extract_texts(transcript_path)
110
153
  if not assistant_texts:
111
154
  return 0
112
155
 
@@ -126,4 +169,7 @@ def analyze_session_feedback(
126
169
  if used_ids:
127
170
  store.record_usage(used_ids)
128
171
 
172
+ if _detect_negative_signals(assistant_texts, user_texts) and recalled_ids:
173
+ store.record_negative(list(recalled_ids))
174
+
129
175
  return len(used_ids)
@@ -0,0 +1,102 @@
1
+ """Cross-project global memory promotion.
2
+
3
+ Detects patterns that appear across 3+ projects and promotes them to the
4
+ _global scope so they're recalled everywhere. Fully local — no API keys.
5
+ """
6
+ from __future__ import annotations
7
+ import hashlib
8
+ import math
9
+ from memor.store.sqlite_store import SqliteStore
10
+ from memor.types import Artifact, Scope, GLOBAL_PROJECT
11
+ from memor.tokencount import count_tokens
12
+
13
+
14
+ def _cosine(a: list[float], b: list[float]) -> float:
15
+ dot = sum(x * y for x, y in zip(a, b))
16
+ na = math.sqrt(sum(x * x for x in a))
17
+ nb = math.sqrt(sum(x * x for x in b))
18
+ return dot / (na * nb) if na and nb else 0.0
19
+
20
+
21
+ def find_promotion_candidates(
22
+ store: SqliteStore, embedder, *,
23
+ min_projects: int = 3, sim_threshold: float = 0.85,
24
+ ) -> list[dict]:
25
+ """Find memories that appear in min_projects+ different projects.
26
+
27
+ Returns list of dicts: {"text": str, "source_ids": [str], "projects": [str]}
28
+ """
29
+ rows = store.db.execute(
30
+ "SELECT * FROM artifacts WHERE kind='memory' AND active=1 AND project != ?",
31
+ (GLOBAL_PROJECT,)
32
+ ).fetchall()
33
+ if len(rows) < min_projects:
34
+ return []
35
+
36
+ memories = [store._row_to_artifact(r) for r in rows]
37
+ vecs = embedder.embed([m.text for m in memories])
38
+
39
+ clusters: list[dict] = []
40
+ assigned = set()
41
+
42
+ for i, mem in enumerate(memories):
43
+ if i in assigned:
44
+ continue
45
+ cluster = {"text": mem.text, "source_ids": [mem.id], "projects": {mem.project}}
46
+ assigned.add(i)
47
+ for j in range(i + 1, len(memories)):
48
+ if j in assigned:
49
+ continue
50
+ if memories[j].project in cluster["projects"]:
51
+ continue
52
+ sim = _cosine(vecs[i], vecs[j])
53
+ if sim >= sim_threshold:
54
+ cluster["source_ids"].append(memories[j].id)
55
+ cluster["projects"].add(memories[j].project)
56
+ assigned.add(j)
57
+ if len(cluster["projects"]) >= min_projects:
58
+ cluster["projects"] = sorted(cluster["projects"])
59
+ clusters.append(cluster)
60
+
61
+ return clusters
62
+
63
+
64
+ def promote_to_global(
65
+ store: SqliteStore, embedder,
66
+ text: str, source_ids: list[str],
67
+ ) -> str | None:
68
+ """Create a _global memory from text and deactivate source duplicates.
69
+
70
+ Returns the new global memory ID, or None if a similar global already exists.
71
+ """
72
+ vec = embedder.embed([text])[0]
73
+
74
+ existing = store.search(vec, Scope(project=GLOBAL_PROJECT, kinds=["memory"]), k=1)
75
+ if existing and existing[0][1] >= 0.85:
76
+ return None
77
+
78
+ mid = f"mem:global:{hashlib.sha1(text.encode()).hexdigest()[:8]}"
79
+ import time
80
+ art = Artifact(
81
+ id=mid, kind="memory", project=GLOBAL_PROJECT, source="promotion",
82
+ text=text, token_count=max(1, count_tokens(text)), created_at=time.time(),
83
+ meta={"mem_type": "global", "promoted_from": source_ids},
84
+ )
85
+ store.add_artifacts([art], [vec])
86
+
87
+ for sid in source_ids:
88
+ store.deactivate(sid, superseded_by=mid)
89
+
90
+ return mid
91
+
92
+
93
+ def run_promotion(store: SqliteStore, embedder, *, min_projects: int = 3) -> int:
94
+ """Find and promote cross-project patterns. Returns count promoted."""
95
+ candidates = find_promotion_candidates(
96
+ store, embedder, min_projects=min_projects)
97
+ promoted = 0
98
+ for c in candidates:
99
+ mid = promote_to_global(store, embedder, c["text"], c["source_ids"])
100
+ if mid:
101
+ promoted += 1
102
+ return promoted