memor-cli 0.3.0__tar.gz → 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {memor_cli-0.3.0/memor_cli.egg-info → memor_cli-0.4.0}/PKG-INFO +3 -3
- {memor_cli-0.3.0 → memor_cli-0.4.0}/README.md +2 -2
- memor_cli-0.4.0/memor/__init__.py +1 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/daemon.py +24 -2
- {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/dashboard/server.py +5 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/dashboard/static/index.html +60 -1
- {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/distill/distiller.py +5 -2
- {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/distill/extractive.py +15 -5
- {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/feedback.py +31 -3
- {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/hook_server.py +16 -14
- memor_cli-0.4.0/memor/query_complexity.py +92 -0
- memor_cli-0.4.0/memor/session_context.py +42 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/store/sqlite_store.py +98 -1
- memor_cli-0.4.0/memor/turn_metrics.py +90 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0/memor_cli.egg-info}/PKG-INFO +3 -3
- {memor_cli-0.3.0 → memor_cli-0.4.0}/memor_cli.egg-info/SOURCES.txt +8 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/pyproject.toml +1 -1
- {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_hook_server.py +6 -4
- memor_cli-0.4.0/tests/test_quality_gate.py +141 -0
- memor_cli-0.4.0/tests/test_query_complexity.py +82 -0
- memor_cli-0.4.0/tests/test_semantic_feedback.py +103 -0
- memor_cli-0.4.0/tests/test_session_context.py +61 -0
- memor_cli-0.4.0/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.0}/LICENSE +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/cli.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/dashboard/__init__.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/distill/__init__.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/embed/__init__.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/embed/api.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/embed/fake.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/embed/local.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/eval/__init__.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/eval/baselines/__init__.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/eval/baselines/base.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/eval/baselines/claude_mem.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/eval/baselines/graphiti.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/eval/dataset.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/eval/embed_benchmark.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/eval/judge.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/eval/metrics.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/eval/runner.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/hook_cli.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/ingest/__init__.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/ingest/claude_code.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/ingest/documents.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/interfaces.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/llm/__init__.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/llm/anthropic.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/llm/base.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/llm/openai_compat.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/project.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/recall.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/redact.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/retrieve/__init__.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/retrieve/retriever.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/service.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/store/__init__.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/tokencount.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/memor/types.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/memor_cli.egg-info/dependency_links.txt +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/memor_cli.egg-info/entry_points.txt +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/memor_cli.egg-info/requires.txt +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/memor_cli.egg-info/top_level.txt +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/setup.cfg +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_cli_smoke.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_daemon.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_dashboard.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_dataset_builder.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_dimension_safety.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_distiller.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_embed.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_embed_benchmark.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_eval_ablation.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_eval_runner.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_external_baselines.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_extractive.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_feedback.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_hook.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_hybrid_retrieval.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_ingest_claude_code.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_ingest_documents.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_install_hook.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_interfaces.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_judge.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_metrics.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_noise_filter.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_project_resolver.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_recall_core.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_redact.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_retriever.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_service.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_skill_recall.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_store.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_supersession.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_tokencount.py +0 -0
- {memor_cli-0.3.0 → memor_cli-0.4.0}/tests/test_types.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memor-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
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)
|
|
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.0"
|
|
@@ -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()
|
|
@@ -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">–</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">–</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">–</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">–</div>
|
|
451
|
+
<div class="side-stat-sub" id="roi-turns-sub">with vs. without recall</div>
|
|
452
|
+
</div>
|
|
453
|
+
</div>
|
|
454
|
+
</div>
|
|
429
455
|
</div>
|
|
430
456
|
</section>
|
|
431
457
|
|
|
@@ -781,6 +807,38 @@
|
|
|
781
807
|
msg.innerHTML = 'Status: <strong>' + esc(status) + '</strong>. Run <code>memor help</code> for guidance.';
|
|
782
808
|
}
|
|
783
809
|
|
|
810
|
+
/* ── ROI renderer ─────────────────────────────────────── */
|
|
811
|
+
function renderROI(data) {
|
|
812
|
+
document.getElementById('roi-tools-with').textContent = data.avg_tools_with_recall;
|
|
813
|
+
document.getElementById('roi-tools-without').textContent = data.avg_tools_without_recall;
|
|
814
|
+
document.getElementById('roi-turns').textContent =
|
|
815
|
+
(data.turns_with_recall + data.turns_without_recall).toLocaleString();
|
|
816
|
+
document.getElementById('roi-turns-sub').textContent =
|
|
817
|
+
data.turns_with_recall + ' with recall · ' + data.turns_without_recall + ' without';
|
|
818
|
+
|
|
819
|
+
var banner = document.getElementById('roi-banner');
|
|
820
|
+
var roiValue = document.getElementById('roi-value');
|
|
821
|
+
var roiDesc = document.getElementById('roi-desc');
|
|
822
|
+
banner.style.display = 'none';
|
|
823
|
+
banner.style.background = 'var(--ok-dim)';
|
|
824
|
+
banner.style.borderColor = 'rgba(61,214,140,0.2)';
|
|
825
|
+
roiValue.style.color = 'var(--ok)';
|
|
826
|
+
roiValue.textContent = '–';
|
|
827
|
+
roiDesc.textContent = 'fewer tool calls when Memor injects context';
|
|
828
|
+
|
|
829
|
+
if (data.tool_call_reduction_pct > 0 && data.turns_with_recall >= 5 && data.turns_without_recall >= 5) {
|
|
830
|
+
roiValue.textContent = data.tool_call_reduction_pct + '% fewer';
|
|
831
|
+
banner.style.display = 'block';
|
|
832
|
+
} else if (data.tool_call_reduction_pct < 0 && data.turns_with_recall >= 5) {
|
|
833
|
+
roiValue.textContent = Math.abs(data.tool_call_reduction_pct) + '% more';
|
|
834
|
+
roiValue.style.color = 'var(--warn)';
|
|
835
|
+
roiDesc.textContent = 'tool calls with recall — investigating...';
|
|
836
|
+
banner.style.display = 'block';
|
|
837
|
+
banner.style.background = 'var(--warn-dim)';
|
|
838
|
+
banner.style.borderColor = 'rgba(232,147,32,0.2)';
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
784
842
|
/* ── Data loaders ──────────────────────────────────────── */
|
|
785
843
|
async function loadSummary() { try { renderSummary(await api('/api/summary')); } catch(e) { console.warn('summary',e); } }
|
|
786
844
|
async function loadProjects() { try { renderProjects(await api('/api/projects')); } catch(e) { console.warn('projects',e); } }
|
|
@@ -788,6 +846,7 @@
|
|
|
788
846
|
async function loadSessionEfficiency() { try { renderSessionEfficiency(await api('/api/session-efficiency')); } catch(e) { console.warn('session-efficiency',e); } }
|
|
789
847
|
async function loadHealth() { try { renderHealth(await api('/api/health')); } catch(e) { console.warn('health',e); } }
|
|
790
848
|
async function loadTrend() { try { renderTrend(await api('/api/recall-trend?days=30')); } catch(e) { console.warn('trend',e); } }
|
|
849
|
+
async function loadROI() { try { renderROI(await api('/api/roi')); } catch(e) { console.warn('roi',e); } }
|
|
791
850
|
async function loadRecalls() {
|
|
792
851
|
try {
|
|
793
852
|
var url = '/api/recalls?limit=50' + (projectFilter ? '&project=' + encodeURIComponent(projectFilter) : '');
|
|
@@ -796,7 +855,7 @@
|
|
|
796
855
|
}
|
|
797
856
|
|
|
798
857
|
async function refresh() {
|
|
799
|
-
await Promise.allSettled([loadSummary(), loadProjects(), loadEfficiency(), loadSessionEfficiency(), loadHealth(), loadTrend(), loadRecalls()]);
|
|
858
|
+
await Promise.allSettled([loadSummary(), loadProjects(), loadEfficiency(), loadSessionEfficiency(), loadHealth(), loadTrend(), loadRecalls(), loadROI()]);
|
|
800
859
|
document.getElementById('last-updated').textContent = new Date().toLocaleTimeString();
|
|
801
860
|
|
|
802
861
|
renderMiniBars('mb-chunks', null);
|
|
@@ -99,11 +99,14 @@ class ExtractiveDistiller:
|
|
|
99
99
|
def distill_session(
|
|
100
100
|
self, session_id: str, chunks: list[Artifact], project: str
|
|
101
101
|
) -> list[str]:
|
|
102
|
-
from memor.distill.extractive import classify_chunk
|
|
102
|
+
from memor.distill.extractive import classify_chunk, score_chunks, MIN_MEMORY_SIGNAL
|
|
103
103
|
key_chunks = extract_key_chunks(chunks, self.embedder)
|
|
104
|
+
scores = score_chunks(key_chunks)
|
|
104
105
|
created = max((c.created_at for c in chunks), default=0.0)
|
|
105
106
|
new_ids: list[str] = []
|
|
106
|
-
for c in key_chunks:
|
|
107
|
+
for c, score in zip(key_chunks, scores):
|
|
108
|
+
if score < MIN_MEMORY_SIGNAL:
|
|
109
|
+
continue
|
|
107
110
|
mem_type = classify_chunk(c.text)
|
|
108
111
|
mid = _store_memory(self.store, self.embedder, c.text, mem_type,
|
|
109
112
|
session_id, project, created, [c])
|
|
@@ -132,13 +132,13 @@ def _cluster_select(chunks: list[Artifact], embedder, max_clusters: int) -> list
|
|
|
132
132
|
return selected
|
|
133
133
|
|
|
134
134
|
|
|
135
|
-
|
|
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}"
|
|
@@ -20,6 +20,7 @@ class SqliteStore:
|
|
|
20
20
|
self._init_schema()
|
|
21
21
|
self._check_dim(dim)
|
|
22
22
|
self._migrate_fts()
|
|
23
|
+
self._migrate_quality_decay()
|
|
23
24
|
|
|
24
25
|
def _init_schema(self):
|
|
25
26
|
self.db.executescript(f"""
|
|
@@ -43,7 +44,8 @@ class SqliteStore:
|
|
|
43
44
|
recall_count INTEGER DEFAULT 0,
|
|
44
45
|
use_count INTEGER DEFAULT 0,
|
|
45
46
|
last_recalled REAL,
|
|
46
|
-
quality_score REAL DEFAULT 0.5
|
|
47
|
+
quality_score REAL DEFAULT 0.5,
|
|
48
|
+
last_decayed_at REAL);
|
|
47
49
|
CREATE TABLE IF NOT EXISTS recall_log(
|
|
48
50
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
49
51
|
timestamp REAL, project TEXT, query_preview TEXT,
|
|
@@ -63,6 +65,15 @@ class SqliteStore:
|
|
|
63
65
|
last_turn_at REAL,
|
|
64
66
|
updated_at REAL NOT NULL DEFAULT 0);
|
|
65
67
|
CREATE INDEX IF NOT EXISTS idx_session_stats_project ON session_stats(project);
|
|
68
|
+
CREATE TABLE IF NOT EXISTS turn_metrics(
|
|
69
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
70
|
+
session_id TEXT NOT NULL,
|
|
71
|
+
project TEXT NOT NULL,
|
|
72
|
+
turn_idx INTEGER NOT NULL,
|
|
73
|
+
user_timestamp REAL,
|
|
74
|
+
tool_call_count INTEGER DEFAULT 0,
|
|
75
|
+
had_recall INTEGER DEFAULT 0);
|
|
76
|
+
CREATE INDEX IF NOT EXISTS idx_turn_metrics_session ON turn_metrics(session_id);
|
|
66
77
|
""")
|
|
67
78
|
self.db.commit()
|
|
68
79
|
|
|
@@ -77,6 +88,16 @@ class SqliteStore:
|
|
|
77
88
|
f"but current embedder has dim={dim}. Use the same embedder or re-ingest."
|
|
78
89
|
)
|
|
79
90
|
|
|
91
|
+
def _migrate_quality_decay(self):
|
|
92
|
+
"""Add last_decayed_at column to memory_quality if missing."""
|
|
93
|
+
cols = [r[1] for r in self.db.execute("PRAGMA table_info(memory_quality)").fetchall()]
|
|
94
|
+
if "last_decayed_at" not in cols:
|
|
95
|
+
try:
|
|
96
|
+
self.db.execute("ALTER TABLE memory_quality ADD COLUMN last_decayed_at REAL")
|
|
97
|
+
self.db.commit()
|
|
98
|
+
except Exception:
|
|
99
|
+
pass
|
|
100
|
+
|
|
80
101
|
def _migrate_fts(self):
|
|
81
102
|
"""One-time backfill of the FTS index for databases created before
|
|
82
103
|
lexical search existed. Runs only when artifacts exist but the index is
|
|
@@ -320,6 +341,39 @@ class SqliteStore:
|
|
|
320
341
|
""", (cutoff, cutoff)).fetchall()
|
|
321
342
|
return [r["id"] for r in rows]
|
|
322
343
|
|
|
344
|
+
def decay_quality(self, stale_days: int = 14, factor: float = 0.5,
|
|
345
|
+
deactivate_floor: float = 0.03) -> int:
|
|
346
|
+
"""Halve quality_score for memories not recalled in stale_days.
|
|
347
|
+
Only decays each memory once per stale_days interval (tracked via
|
|
348
|
+
last_decayed_at). Also catches memories with NULL last_recalled
|
|
349
|
+
that are old enough. If the decayed score drops below
|
|
350
|
+
deactivate_floor, deactivate the memory.
|
|
351
|
+
Returns number of memories decayed."""
|
|
352
|
+
import time as _time
|
|
353
|
+
now = _time.time()
|
|
354
|
+
recall_cutoff = now - (stale_days * 86400)
|
|
355
|
+
decay_cutoff = now - (stale_days * 86400)
|
|
356
|
+
rows = self.db.execute("""
|
|
357
|
+
SELECT q.artifact_id, q.quality_score
|
|
358
|
+
FROM memory_quality q
|
|
359
|
+
JOIN artifacts a ON a.id = q.artifact_id
|
|
360
|
+
WHERE a.kind = 'memory' AND a.active = 1
|
|
361
|
+
AND (q.last_recalled IS NULL OR q.last_recalled < ?)
|
|
362
|
+
AND (q.last_decayed_at IS NULL OR q.last_decayed_at < ?)
|
|
363
|
+
""", (recall_cutoff, decay_cutoff)).fetchall()
|
|
364
|
+
decayed = 0
|
|
365
|
+
for r in rows:
|
|
366
|
+
new_score = round(r["quality_score"] * factor, 4)
|
|
367
|
+
if new_score < deactivate_floor:
|
|
368
|
+
self.db.execute("UPDATE artifacts SET active=0 WHERE id=?",
|
|
369
|
+
(r["artifact_id"],))
|
|
370
|
+
self.db.execute(
|
|
371
|
+
"UPDATE memory_quality SET quality_score=?, last_decayed_at=? WHERE artifact_id=?",
|
|
372
|
+
(new_score, now, r["artifact_id"]))
|
|
373
|
+
decayed += 1
|
|
374
|
+
self.db.commit()
|
|
375
|
+
return decayed
|
|
376
|
+
|
|
323
377
|
def deactivate_stale(self, days: int = 30) -> int:
|
|
324
378
|
ids = self.get_stale_memories(days)
|
|
325
379
|
for aid in ids:
|
|
@@ -474,6 +528,49 @@ class SqliteStore:
|
|
|
474
528
|
"sessions": sessions,
|
|
475
529
|
}
|
|
476
530
|
|
|
531
|
+
def save_turn_metrics(self, session_id: str, project: str, metrics: list) -> None:
|
|
532
|
+
"""Persist per-turn tool call metrics. Idempotent — deletes old metrics
|
|
533
|
+
for the session first."""
|
|
534
|
+
self.db.execute("DELETE FROM turn_metrics WHERE session_id=?", (session_id,))
|
|
535
|
+
for m in metrics:
|
|
536
|
+
self.db.execute(
|
|
537
|
+
"INSERT INTO turn_metrics(session_id, project, turn_idx, "
|
|
538
|
+
"user_timestamp, tool_call_count, had_recall) VALUES(?,?,?,?,?,?)",
|
|
539
|
+
(session_id, project, m.turn_idx, m.user_timestamp,
|
|
540
|
+
m.tool_call_count, 1 if m.had_recall else 0))
|
|
541
|
+
self.db.commit()
|
|
542
|
+
|
|
543
|
+
def get_token_roi(self, project: str | None = None) -> dict:
|
|
544
|
+
"""Compute tool-call ROI: avg tool calls per turn with vs without recall."""
|
|
545
|
+
where = "WHERE 1=1"
|
|
546
|
+
params: list = []
|
|
547
|
+
if project:
|
|
548
|
+
where += " AND project = ?"
|
|
549
|
+
params.append(project)
|
|
550
|
+
|
|
551
|
+
row = self.db.execute(f"""
|
|
552
|
+
SELECT
|
|
553
|
+
COUNT(CASE WHEN had_recall = 1 THEN 1 END) AS turns_with,
|
|
554
|
+
COUNT(CASE WHEN had_recall = 0 THEN 1 END) AS turns_without,
|
|
555
|
+
AVG(CASE WHEN had_recall = 1 THEN tool_call_count END) AS avg_with,
|
|
556
|
+
AVG(CASE WHEN had_recall = 0 THEN tool_call_count END) AS avg_without
|
|
557
|
+
FROM turn_metrics {where}
|
|
558
|
+
""", params).fetchone()
|
|
559
|
+
|
|
560
|
+
turns_with = row["turns_with"] or 0
|
|
561
|
+
turns_without = row["turns_without"] or 0
|
|
562
|
+
avg_with = round(row["avg_with"] or 0, 2)
|
|
563
|
+
avg_without = round(row["avg_without"] or 0, 2)
|
|
564
|
+
reduction = round((1 - avg_with / avg_without) * 100, 1) if avg_without > 0 else 0
|
|
565
|
+
|
|
566
|
+
return {
|
|
567
|
+
"turns_with_recall": turns_with,
|
|
568
|
+
"turns_without_recall": turns_without,
|
|
569
|
+
"avg_tools_with_recall": avg_with,
|
|
570
|
+
"avg_tools_without_recall": avg_without,
|
|
571
|
+
"tool_call_reduction_pct": reduction,
|
|
572
|
+
}
|
|
573
|
+
|
|
477
574
|
def get_onboarding_status(self) -> str:
|
|
478
575
|
chunks = self.db.execute(
|
|
479
576
|
"SELECT COUNT(*) as c FROM artifacts WHERE kind='session_chunk' AND active=1"
|