memor-cli 0.3.0__tar.gz → 0.4.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {memor_cli-0.3.0/memor_cli.egg-info → memor_cli-0.4.1}/PKG-INFO +3 -3
- {memor_cli-0.3.0 → memor_cli-0.4.1}/README.md +2 -2
- memor_cli-0.4.1/memor/__init__.py +1 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/memor/daemon.py +24 -2
- {memor_cli-0.3.0 → memor_cli-0.4.1}/memor/dashboard/server.py +5 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/memor/dashboard/static/index.html +72 -17
- {memor_cli-0.3.0 → memor_cli-0.4.1}/memor/distill/distiller.py +5 -2
- {memor_cli-0.3.0 → memor_cli-0.4.1}/memor/distill/extractive.py +15 -5
- {memor_cli-0.3.0 → memor_cli-0.4.1}/memor/feedback.py +31 -3
- {memor_cli-0.3.0 → memor_cli-0.4.1}/memor/hook_server.py +16 -14
- memor_cli-0.4.1/memor/query_complexity.py +92 -0
- memor_cli-0.4.1/memor/session_context.py +42 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/memor/store/sqlite_store.py +98 -1
- memor_cli-0.4.1/memor/turn_metrics.py +90 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1/memor_cli.egg-info}/PKG-INFO +3 -3
- {memor_cli-0.3.0 → memor_cli-0.4.1}/memor_cli.egg-info/SOURCES.txt +8 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/pyproject.toml +1 -1
- {memor_cli-0.3.0 → memor_cli-0.4.1}/tests/test_hook_server.py +6 -4
- memor_cli-0.4.1/tests/test_quality_gate.py +141 -0
- memor_cli-0.4.1/tests/test_query_complexity.py +82 -0
- memor_cli-0.4.1/tests/test_semantic_feedback.py +103 -0
- memor_cli-0.4.1/tests/test_session_context.py +61 -0
- memor_cli-0.4.1/tests/test_turn_metrics.py +122 -0
- memor_cli-0.3.0/memor/__init__.py +0 -1
- {memor_cli-0.3.0 → memor_cli-0.4.1}/LICENSE +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/memor/cli.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/memor/dashboard/__init__.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/memor/distill/__init__.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/memor/embed/__init__.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/memor/embed/api.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/memor/embed/fake.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/memor/embed/local.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/memor/eval/__init__.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/memor/eval/baselines/__init__.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/memor/eval/baselines/base.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/memor/eval/baselines/claude_mem.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/memor/eval/baselines/graphiti.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/memor/eval/dataset.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/memor/eval/embed_benchmark.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/memor/eval/judge.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/memor/eval/metrics.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/memor/eval/runner.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/memor/hook_cli.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/memor/ingest/__init__.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/memor/ingest/claude_code.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/memor/ingest/documents.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/memor/interfaces.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/memor/llm/__init__.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/memor/llm/anthropic.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/memor/llm/base.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/memor/llm/openai_compat.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/memor/project.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/memor/recall.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/memor/redact.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/memor/retrieve/__init__.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/memor/retrieve/retriever.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/memor/service.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/memor/store/__init__.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/memor/tokencount.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/memor/types.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/memor_cli.egg-info/dependency_links.txt +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/memor_cli.egg-info/entry_points.txt +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/memor_cli.egg-info/requires.txt +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/memor_cli.egg-info/top_level.txt +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/setup.cfg +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/tests/test_cli_smoke.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/tests/test_daemon.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/tests/test_dashboard.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/tests/test_dataset_builder.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/tests/test_dimension_safety.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/tests/test_distiller.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/tests/test_embed.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/tests/test_embed_benchmark.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/tests/test_eval_ablation.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/tests/test_eval_runner.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/tests/test_external_baselines.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/tests/test_extractive.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/tests/test_feedback.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/tests/test_hook.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/tests/test_hybrid_retrieval.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/tests/test_ingest_claude_code.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/tests/test_ingest_documents.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/tests/test_install_hook.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/tests/test_interfaces.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/tests/test_judge.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/tests/test_metrics.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/tests/test_noise_filter.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/tests/test_project_resolver.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/tests/test_recall_core.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/tests/test_redact.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/tests/test_retriever.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/tests/test_service.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/tests/test_skill_recall.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/tests/test_store.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/tests/test_supersession.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/tests/test_tokencount.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.1}/tests/test_types.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memor-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.1
|
|
4
4
|
Summary: Measured memory for coding agents. Fire and forget — no API keys needed.
|
|
5
5
|
Author-email: Nimit Bhandari <nimitbhandari17@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -46,7 +46,7 @@ Dynamic: license-file
|
|
|
46
46
|
```
|
|
47
47
|
|
|
48
48
|
[](LICENSE)
|
|
49
|
-
[]()
|
|
50
50
|
[]()
|
|
51
51
|
[](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 #
|
|
293
|
+
pytest # 215 tests
|
|
294
294
|
```
|
|
295
295
|
|
|
296
296
|
---
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
[](LICENSE)
|
|
12
|
-
[]()
|
|
13
13
|
[]()
|
|
14
14
|
[](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 #
|
|
256
|
+
pytest # 215 tests
|
|
257
257
|
```
|
|
258
258
|
|
|
259
259
|
---
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.4.1"
|
|
@@ -146,7 +146,7 @@ def distill_new_sessions(
|
|
|
146
146
|
return distilled
|
|
147
147
|
|
|
148
148
|
|
|
149
|
-
COMPACT_SIM_THRESHOLD = 0.
|
|
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()
|
|
@@ -172,6 +172,7 @@
|
|
|
172
172
|
background: var(--surface); border: 1px solid var(--border);
|
|
173
173
|
border-radius: var(--radius); padding: 22px 24px;
|
|
174
174
|
box-shadow: var(--card-shadow);
|
|
175
|
+
display: flex; flex-direction: column;
|
|
175
176
|
}
|
|
176
177
|
.chart-header {
|
|
177
178
|
display: flex; align-items: center; justify-content: space-between;
|
|
@@ -199,7 +200,7 @@
|
|
|
199
200
|
|
|
200
201
|
.bar-chart {
|
|
201
202
|
display: flex; align-items: flex-end; gap: 4px;
|
|
202
|
-
height: 180px; padding-top: 8px;
|
|
203
|
+
flex: 1; min-height: 180px; padding-top: 8px;
|
|
203
204
|
border-bottom: 1px solid var(--border-light);
|
|
204
205
|
}
|
|
205
206
|
.bar-group {
|
|
@@ -207,15 +208,16 @@
|
|
|
207
208
|
align-items: center; gap: 0; height: 100%;
|
|
208
209
|
justify-content: flex-end;
|
|
209
210
|
}
|
|
210
|
-
.bar-stack { display: flex; flex-direction: column-reverse; gap:
|
|
211
|
-
.bar-
|
|
212
|
-
.bar-
|
|
211
|
+
.bar-stack { display: flex; flex-direction: column-reverse; gap: 2px; width: 100%; max-width: 28px; flex: 1; align-items: center; justify-content: flex-end; }
|
|
212
|
+
.bar-cell { width: 16px; height: 10px; border-radius: 2px; transition: opacity 0.3s; }
|
|
213
|
+
.bar-cell-hit { background: var(--accent); }
|
|
214
|
+
.bar-cell-miss { background: var(--surface3); }
|
|
213
215
|
.bar-label {
|
|
214
216
|
font-size: 9px; color: var(--text-muted); margin-top: 6px;
|
|
215
217
|
text-align: center; white-space: nowrap;
|
|
216
218
|
}
|
|
217
219
|
|
|
218
|
-
.bar-group:hover .bar-
|
|
220
|
+
.bar-group:hover .bar-cell-hit { background: #f0a030; }
|
|
219
221
|
.bar-group { cursor: default; position: relative; }
|
|
220
222
|
.bar-tooltip {
|
|
221
223
|
display: none; position: absolute; bottom: calc(100% + 8px);
|
|
@@ -378,6 +380,33 @@
|
|
|
378
380
|
</div>
|
|
379
381
|
</section>
|
|
380
382
|
|
|
383
|
+
<!-- ── Token ROI hero ─────────────────────────────────── -->
|
|
384
|
+
<section>
|
|
385
|
+
<div class="chart-card" id="roi-card" style="display:none;">
|
|
386
|
+
<div style="display:flex;align-items:center;gap:20px;flex-wrap:wrap;">
|
|
387
|
+
<div>
|
|
388
|
+
<div class="chart-title" style="margin-bottom:4px;">Token ROI</div>
|
|
389
|
+
<div style="font-size:32px;font-weight:700;color:var(--ok);letter-spacing:-1px;" id="roi-value">–</div>
|
|
390
|
+
<div style="font-size:12px;color:var(--text-muted);margin-top:2px;" id="roi-desc">fewer tool calls when Memor injects context</div>
|
|
391
|
+
</div>
|
|
392
|
+
<div style="display:flex;gap:24px;flex:1;justify-content:flex-end;">
|
|
393
|
+
<div style="text-align:center;">
|
|
394
|
+
<div style="font-size:18px;font-weight:600;color:var(--text);" id="roi-tools-with">–</div>
|
|
395
|
+
<div style="font-size:10px;color:var(--text-muted);">avg tools/turn<br>with recall</div>
|
|
396
|
+
</div>
|
|
397
|
+
<div style="text-align:center;">
|
|
398
|
+
<div style="font-size:18px;font-weight:600;color:var(--text);" id="roi-tools-without">–</div>
|
|
399
|
+
<div style="font-size:10px;color:var(--text-muted);">avg tools/turn<br>without recall</div>
|
|
400
|
+
</div>
|
|
401
|
+
<div style="text-align:center;">
|
|
402
|
+
<div style="font-size:18px;font-weight:600;color:var(--text);" id="roi-turns">–</div>
|
|
403
|
+
<div style="font-size:10px;color:var(--text-muted);" id="roi-turns-sub">turns measured</div>
|
|
404
|
+
</div>
|
|
405
|
+
</div>
|
|
406
|
+
</div>
|
|
407
|
+
</div>
|
|
408
|
+
</section>
|
|
409
|
+
|
|
381
410
|
<!-- ── Recall trend chart ─────────────────────────────── -->
|
|
382
411
|
<section>
|
|
383
412
|
<div class="chart-grid">
|
|
@@ -513,7 +542,7 @@
|
|
|
513
542
|
function badge(status) {
|
|
514
543
|
var map = {
|
|
515
544
|
ok: ['badge-ok', 'Success'],
|
|
516
|
-
extractive_only: ['badge-extractive','
|
|
545
|
+
extractive_only: ['badge-extractive','No Distill'],
|
|
517
546
|
no_hits: ['badge-no_hits', 'No Hits'],
|
|
518
547
|
};
|
|
519
548
|
var m = map[status] || ['badge-no_hits', status];
|
|
@@ -643,21 +672,20 @@
|
|
|
643
672
|
var recallsByDay = data.map(function(d) { return d.recalls; });
|
|
644
673
|
renderMiniBars('mb-recalls', recallsByDay);
|
|
645
674
|
|
|
675
|
+
var maxCells = 30;
|
|
646
676
|
data.forEach(function(d) {
|
|
647
|
-
var
|
|
648
|
-
var
|
|
649
|
-
var
|
|
650
|
-
var hitH = Math.max(0, pct * hitPct / 100);
|
|
651
|
-
var missH = Math.max(0, pct * missPct / 100);
|
|
677
|
+
var totalCells = Math.max(1, Math.round((d.recalls / maxRecalls) * maxCells));
|
|
678
|
+
var hitCells = Math.round((d.hits / d.recalls) * totalCells);
|
|
679
|
+
var missCells = totalCells - hitCells;
|
|
652
680
|
|
|
653
681
|
var dayStr = d.day.slice(5);
|
|
654
682
|
var group = el('div', {class: 'bar-group'});
|
|
683
|
+
var cells = '';
|
|
684
|
+
for (var i = 0; i < hitCells; i++) cells += '<div class="bar-cell bar-cell-hit"></div>';
|
|
685
|
+
for (var i = 0; i < missCells; i++) cells += '<div class="bar-cell bar-cell-miss"></div>';
|
|
655
686
|
group.innerHTML =
|
|
656
687
|
'<div class="bar-tooltip">' + esc(d.day) + '<br>' + d.recalls + ' recalls · ' + (d.hits||0) + ' hits</div>' +
|
|
657
|
-
'<div class="bar-stack">' +
|
|
658
|
-
'<div class="bar-hits" style="height:' + hitH + '%"></div>' +
|
|
659
|
-
'<div class="bar-miss" style="height:' + missH + '%"></div>' +
|
|
660
|
-
'</div>' +
|
|
688
|
+
'<div class="bar-stack">' + cells + '</div>' +
|
|
661
689
|
'<div class="bar-label">' + dayStr + '</div>';
|
|
662
690
|
container.appendChild(group);
|
|
663
691
|
});
|
|
@@ -699,7 +727,7 @@
|
|
|
699
727
|
document.getElementById('th-col3').textContent = 'Avg Score';
|
|
700
728
|
document.getElementById('th-col4').textContent = 'OK';
|
|
701
729
|
document.getElementById('th-col5').textContent = 'No Hits';
|
|
702
|
-
document.getElementById('th-col6').textContent = '
|
|
730
|
+
document.getElementById('th-col6').textContent = 'No Distill';
|
|
703
731
|
}
|
|
704
732
|
|
|
705
733
|
sorted.forEach(function(row) {
|
|
@@ -781,6 +809,32 @@
|
|
|
781
809
|
msg.innerHTML = 'Status: <strong>' + esc(status) + '</strong>. Run <code>memor help</code> for guidance.';
|
|
782
810
|
}
|
|
783
811
|
|
|
812
|
+
/* ── ROI renderer ─────────────────────────────────────── */
|
|
813
|
+
function renderROI(data) {
|
|
814
|
+
var card = document.getElementById('roi-card');
|
|
815
|
+
var roiValue = document.getElementById('roi-value');
|
|
816
|
+
var roiDesc = document.getElementById('roi-desc');
|
|
817
|
+
|
|
818
|
+
document.getElementById('roi-tools-with').textContent = data.avg_tools_with_recall;
|
|
819
|
+
document.getElementById('roi-tools-without').textContent = data.avg_tools_without_recall;
|
|
820
|
+
var totalTurns = data.turns_with_recall + data.turns_without_recall;
|
|
821
|
+
document.getElementById('roi-turns').textContent = totalTurns.toLocaleString();
|
|
822
|
+
document.getElementById('roi-turns-sub').textContent =
|
|
823
|
+
data.turns_with_recall + ' with · ' + data.turns_without_recall + ' without';
|
|
824
|
+
|
|
825
|
+
if (data.tool_call_reduction_pct > 0 && data.turns_with_recall >= 5 && data.turns_without_recall >= 5) {
|
|
826
|
+
roiValue.textContent = data.tool_call_reduction_pct + '% fewer';
|
|
827
|
+
roiValue.style.color = 'var(--ok)';
|
|
828
|
+
roiDesc.textContent = 'fewer tool calls when Memor injects context';
|
|
829
|
+
card.style.display = 'block';
|
|
830
|
+
} else if (data.tool_call_reduction_pct < 0 && data.turns_with_recall >= 5) {
|
|
831
|
+
roiValue.textContent = Math.abs(data.tool_call_reduction_pct) + '% more';
|
|
832
|
+
roiValue.style.color = 'var(--warn)';
|
|
833
|
+
roiDesc.textContent = 'tool calls with recall — investigating...';
|
|
834
|
+
card.style.display = 'block';
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
784
838
|
/* ── Data loaders ──────────────────────────────────────── */
|
|
785
839
|
async function loadSummary() { try { renderSummary(await api('/api/summary')); } catch(e) { console.warn('summary',e); } }
|
|
786
840
|
async function loadProjects() { try { renderProjects(await api('/api/projects')); } catch(e) { console.warn('projects',e); } }
|
|
@@ -788,6 +842,7 @@
|
|
|
788
842
|
async function loadSessionEfficiency() { try { renderSessionEfficiency(await api('/api/session-efficiency')); } catch(e) { console.warn('session-efficiency',e); } }
|
|
789
843
|
async function loadHealth() { try { renderHealth(await api('/api/health')); } catch(e) { console.warn('health',e); } }
|
|
790
844
|
async function loadTrend() { try { renderTrend(await api('/api/recall-trend?days=30')); } catch(e) { console.warn('trend',e); } }
|
|
845
|
+
async function loadROI() { try { renderROI(await api('/api/roi')); } catch(e) { console.warn('roi',e); } }
|
|
791
846
|
async function loadRecalls() {
|
|
792
847
|
try {
|
|
793
848
|
var url = '/api/recalls?limit=50' + (projectFilter ? '&project=' + encodeURIComponent(projectFilter) : '');
|
|
@@ -796,7 +851,7 @@
|
|
|
796
851
|
}
|
|
797
852
|
|
|
798
853
|
async function refresh() {
|
|
799
|
-
await Promise.allSettled([loadSummary(), loadProjects(), loadEfficiency(), loadSessionEfficiency(), loadHealth(), loadTrend(), loadRecalls()]);
|
|
854
|
+
await Promise.allSettled([loadSummary(), loadProjects(), loadEfficiency(), loadSessionEfficiency(), loadHealth(), loadTrend(), loadRecalls(), loadROI()]);
|
|
800
855
|
document.getElementById('last-updated').textContent = new Date().toLocaleTimeString();
|
|
801
856
|
|
|
802
857
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
85
|
+
env_max = int(os.environ.get("MEMOR_MAX_TOKENS", "0"))
|
|
89
86
|
except (ValueError, TypeError):
|
|
90
|
-
|
|
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(
|
|
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}"
|