loop-memory 0.4.10__tar.gz → 0.4.11__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.
- {loop_memory-0.4.10 → loop_memory-0.4.11}/PKG-INFO +14 -1
- {loop_memory-0.4.10 → loop_memory-0.4.11}/README.md +13 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/serve/app.py +6 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/serve/routes/system.py +35 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/storage/sqlite_store.py +10 -1
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory.egg-info/PKG-INFO +14 -1
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory.egg-info/SOURCES.txt +2 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/pyproject.toml +1 -1
- loop_memory-0.4.11/tests/test_busy_timeout_2026_09_24.py +110 -0
- loop_memory-0.4.11/tests/test_healthz_readyz_2026_09_24.py +80 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/LICENSE +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/__init__.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/backends/__init__.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/backends/embedding.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/backends/sentence_embedder.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/backends/vector_store.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/cli/__init__.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/cli/_common.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/cli/commands/__init__.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/cli/commands/cognitive.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/cli/commands/diag.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/cli/commands/graph.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/cli/commands/hooks.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/cli/commands/read.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/cli/commands/rules.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/cli/commands/serve.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/cli/commands/write.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/cli/main.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/engine/__init__.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/engine/loop.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/engine/reflect.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/examples/__init__.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/examples/demo.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/export/__init__.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/export/memory_md.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/graph/__init__.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/graph/build.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/graph/extract.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/ingest/__init__.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/ingest/loader.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/ingest/pipeline.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/jobs/__init__.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/jobs/cognitive.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/jobs/compact.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/jobs/consolidate.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/jobs/contradiction.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/jobs/evolution.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/jobs/graph.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/jobs/llm_compact_pass.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/jobs/llm_consolidate.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/jobs/scheduler.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/llm/__init__.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/llm/base.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/llm/openai_adapter.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/llm/providers.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/mcp/__init__.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/memory/__init__.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/memory/types.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/privacy/__init__.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/privacy/private.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/privacy/redact.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/py.typed +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/sdk.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/sdk_extensions.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/security/__init__.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/security/secrets.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/serve/__init__.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/serve/handlers.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/serve/routes/_shared.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/serve/routes/admin.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/serve/routes/cognitive.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/serve/routes/export.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/serve/routes/graph.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/serve/routes/insights.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/serve/routes/memories.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/serve/routes/sessions.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/serve/routes/wiki.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/serve/static/__init__.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/serve/static/index.html +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/serve/watcher.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/storage/__init__.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/storage/retrieval.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/storage/snapshot.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/wiki/__init__.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/wiki/backfill.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/wiki/classifier.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/wiki/prompts.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory/wiki/scope.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory.egg-info/dependency_links.txt +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory.egg-info/entry_points.txt +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory.egg-info/requires.txt +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/loop_memory.egg-info/top_level.txt +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/setup.cfg +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_admin_ingest_route.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_agent_memory_api.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_agent_memory_sdk.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_agents_init.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_auth_token_rotate.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_cache_cap_2026_09_23.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_cli_audit_supersede.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_cli_export_okf.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_cli_init.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_cli_memory_stats.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_cli_recall_as_of.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_cli_recall_paths.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_cli_recall_verbose.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_cli_rules.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_cli_snapshot.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_cli_v7.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_cli_version.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_contradictions.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_evolution.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_evolution_quality.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_expanduser.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_export_ask.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_export_okf.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_graph.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_ingest.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_llm_consolidator.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_llm_fingerprint_not_persisted.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_llm_providers.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_llm_test_endpoint.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_loop.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_mcp.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_memories_pagination.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_memory_stats.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_openclaw_loader.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_package_version.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_path_safety_2026_09_23.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_recall_as_of.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_recall_paths.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_recall_provenance.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_reflection.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_repo_framing.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_routes_audit_2026_09_13.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_routes_audit_2026_09_20.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_score_api.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_scoring_v2.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_secrets.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_serve_app.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_serve_handlers.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_session_order.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_short_query.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_snapshot.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_store.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_subgraph_hops_2026_09_23.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_summarization.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_supersession_chain.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_universal_memory.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_vector_store.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_watcher.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_wiki_classifier.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_wiki_export_escape.py +0 -0
- {loop_memory-0.4.10 → loop_memory-0.4.11}/tests/test_wiki_prompts.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: loop-memory
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.11
|
|
4
4
|
Summary: A general-purpose, local memory system for every AI agent you run. Loop Memory auto-captures conversations from Codex / Claude / Hermes / OpenClaw, scores them by importance × recency × usage × feedback, distils them into a curated wiki, and serves everything from a single web UI.
|
|
5
5
|
Author: Loop Memory contributors <loop-memory@users.noreply.github.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -66,6 +66,19 @@ Dynamic: license-file
|
|
|
66
66
|
|
|
67
67
|
---
|
|
68
68
|
|
|
69
|
+
> **What's new in 0.4.11** — **operational probes** +
|
|
70
|
+
**SQLite write-contention fix** (audit 2026-09-24). New
|
|
71
|
+
``GET /api/healthz`` (liveness) + ``GET /api/readyz``
|
|
72
|
+
(readiness) endpoints let Kubernetes / launchd keepalive
|
|
73
|
+
polls probe the serve without provisioning a Bearer token;
|
|
74
|
+
``/api/readyz`` returns 503 when the SQLite store is
|
|
75
|
+
unreachable. Also fixed a real concurrency bug: every
|
|
76
|
+
store connection now sets ``PRAGMA busy_timeout=5000``
|
|
77
|
+
(was 0 ms) so the watcher + serve + scheduler threads no
|
|
78
|
+
longer fail with ``database is locked`` when the
|
|
79
|
+
consolidator writes concurrently. 7 new regression cases.
|
|
80
|
+
[Full CHANGELOG →](./CHANGELOG.md#0411---2026-09-24)
|
|
81
|
+
|
|
69
82
|
> **What's new in 0.4.10** — **path-safety guard** for the
|
|
70
83
|
five export / snapshot endpoints. ``MemoryStore`` already
|
|
71
84
|
validated query / wiki content; the new
|
|
@@ -22,6 +22,19 @@
|
|
|
22
22
|
|
|
23
23
|
---
|
|
24
24
|
|
|
25
|
+
> **What's new in 0.4.11** — **operational probes** +
|
|
26
|
+
**SQLite write-contention fix** (audit 2026-09-24). New
|
|
27
|
+
``GET /api/healthz`` (liveness) + ``GET /api/readyz``
|
|
28
|
+
(readiness) endpoints let Kubernetes / launchd keepalive
|
|
29
|
+
polls probe the serve without provisioning a Bearer token;
|
|
30
|
+
``/api/readyz`` returns 503 when the SQLite store is
|
|
31
|
+
unreachable. Also fixed a real concurrency bug: every
|
|
32
|
+
store connection now sets ``PRAGMA busy_timeout=5000``
|
|
33
|
+
(was 0 ms) so the watcher + serve + scheduler threads no
|
|
34
|
+
longer fail with ``database is locked`` when the
|
|
35
|
+
consolidator writes concurrently. 7 new regression cases.
|
|
36
|
+
[Full CHANGELOG →](./CHANGELOG.md#0411---2026-09-24)
|
|
37
|
+
|
|
25
38
|
> **What's new in 0.4.10** — **path-safety guard** for the
|
|
26
39
|
five export / snapshot endpoints. ``MemoryStore`` already
|
|
27
40
|
validated query / wiki content; the new
|
|
@@ -176,6 +176,12 @@ def create_app(store: MemoryStore, static_dir: Path | None = None, scheduler=Non
|
|
|
176
176
|
'/api/pipeline', '/api/weekly-report', '/api/llm-audit',
|
|
177
177
|
'/api/source-health', '/api/write-guard', '/api/diag',
|
|
178
178
|
'/api/install-hooks', '/api/insights',
|
|
179
|
+
# Audit 2026-09-24: probes are intentionally public so a
|
|
180
|
+
# load balancer / keepalive script doesn't need a Bearer
|
|
181
|
+
# token to ask "is the process up?". The DB-touching
|
|
182
|
+
# ``/api/readyz`` only exposes a coarse health signal
|
|
183
|
+
# (UP / DOWN), no data.
|
|
184
|
+
'/api/healthz', '/api/readyz',
|
|
179
185
|
))
|
|
180
186
|
|
|
181
187
|
def _is_public_path(path: str) -> bool:
|
|
@@ -36,6 +36,41 @@ def register(app: FastAPI, store: MemoryStore, scheduler: Optional[Any] = None,
|
|
|
36
36
|
"""
|
|
37
37
|
if static_dir is None:
|
|
38
38
|
static_dir = Path(__file__).parent.parent / "static"
|
|
39
|
+
@app.get("/api/healthz")
|
|
40
|
+
def healthz():
|
|
41
|
+
"""Liveness probe.
|
|
42
|
+
|
|
43
|
+
Audit 2026-09-24. Returns 200 as long as the process is
|
|
44
|
+
responsive. **Does not** touch the SQLite store — so the
|
|
45
|
+
probe cannot deadlock behind a slow writer and cannot give
|
|
46
|
+
a false negative when the DB is briefly busy. Use this for
|
|
47
|
+
``livenessProbe`` in Kubernetes / launchd keepalive scripts.
|
|
48
|
+
"""
|
|
49
|
+
return {"status": "ok", "ts": time.time()}
|
|
50
|
+
|
|
51
|
+
@app.get("/api/readyz")
|
|
52
|
+
def readyz():
|
|
53
|
+
"""Readiness probe.
|
|
54
|
+
|
|
55
|
+
Audit 2026-09-24. Verifies the SQLite store is reachable AND
|
|
56
|
+
responsive (one trivial ``PRAGMA quick_check`` round-trip
|
|
57
|
+
inside a 1-second budget). Returns ``{"status": "ok"}`` on
|
|
58
|
+
success, or ``{"status": "degraded", "error": "..."}`` with
|
|
59
|
+
HTTP 503 when the DB is unhealthy. Use this for
|
|
60
|
+
``readinessProbe`` so a degraded loop-memory is taken out of
|
|
61
|
+
a load-balancer rotation until it recovers.
|
|
62
|
+
"""
|
|
63
|
+
import sqlite3 as _sq3
|
|
64
|
+
try:
|
|
65
|
+
with _sq3.connect(str(store.path), timeout=1.0) as c:
|
|
66
|
+
c.execute("PRAGMA quick_check").fetchone()
|
|
67
|
+
return {"status": "ok", "ts": time.time()}
|
|
68
|
+
except Exception as e:
|
|
69
|
+
return JSONResponse(
|
|
70
|
+
{"status": "degraded", "error": str(e)},
|
|
71
|
+
status_code=503,
|
|
72
|
+
)
|
|
73
|
+
|
|
39
74
|
@app.get("/")
|
|
40
75
|
def index():
|
|
41
76
|
"""Serve the dashboard HTML with the i18n JSONs inlined.
|
|
@@ -715,10 +715,19 @@ class MemoryStore:
|
|
|
715
715
|
|
|
716
716
|
@contextmanager
|
|
717
717
|
def _conn(self) -> Iterator[sqlite3.Connection]:
|
|
718
|
-
|
|
718
|
+
# Audit 2026-09-24: every connection sets three PRAGMAs.
|
|
719
|
+
# ``busy_timeout=5000`` makes SQLite retry a contended writer
|
|
720
|
+
# for up to 5 seconds before raising ``OperationalError:
|
|
721
|
+
# database is locked``. Without it the watcher (launchd
|
|
722
|
+
# background thread) and the serve thread can fail spuriously
|
|
723
|
+
# when the consolidator or scheduler also writes concurrently.
|
|
724
|
+
# 5s is a generous budget for any local operation; an actual
|
|
725
|
+
# deadlock surfaces faster via the SQLite error code.
|
|
726
|
+
conn = sqlite3.connect(str(self.path), timeout=5.0)
|
|
719
727
|
conn.row_factory = sqlite3.Row
|
|
720
728
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
721
729
|
conn.execute("PRAGMA foreign_keys=ON")
|
|
730
|
+
conn.execute("PRAGMA busy_timeout=5000")
|
|
722
731
|
try:
|
|
723
732
|
yield conn
|
|
724
733
|
conn.commit()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: loop-memory
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.11
|
|
4
4
|
Summary: A general-purpose, local memory system for every AI agent you run. Loop Memory auto-captures conversations from Codex / Claude / Hermes / OpenClaw, scores them by importance × recency × usage × feedback, distils them into a curated wiki, and serves everything from a single web UI.
|
|
5
5
|
Author: Loop Memory contributors <loop-memory@users.noreply.github.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -66,6 +66,19 @@ Dynamic: license-file
|
|
|
66
66
|
|
|
67
67
|
---
|
|
68
68
|
|
|
69
|
+
> **What's new in 0.4.11** — **operational probes** +
|
|
70
|
+
**SQLite write-contention fix** (audit 2026-09-24). New
|
|
71
|
+
``GET /api/healthz`` (liveness) + ``GET /api/readyz``
|
|
72
|
+
(readiness) endpoints let Kubernetes / launchd keepalive
|
|
73
|
+
polls probe the serve without provisioning a Bearer token;
|
|
74
|
+
``/api/readyz`` returns 503 when the SQLite store is
|
|
75
|
+
unreachable. Also fixed a real concurrency bug: every
|
|
76
|
+
store connection now sets ``PRAGMA busy_timeout=5000``
|
|
77
|
+
(was 0 ms) so the watcher + serve + scheduler threads no
|
|
78
|
+
longer fail with ``database is locked`` when the
|
|
79
|
+
consolidator writes concurrently. 7 new regression cases.
|
|
80
|
+
[Full CHANGELOG →](./CHANGELOG.md#0411---2026-09-24)
|
|
81
|
+
|
|
69
82
|
> **What's new in 0.4.10** — **path-safety guard** for the
|
|
70
83
|
five export / snapshot endpoints. ``MemoryStore`` already
|
|
71
84
|
validated query / wiki content; the new
|
|
@@ -92,6 +92,7 @@ tests/test_agent_memory_api.py
|
|
|
92
92
|
tests/test_agent_memory_sdk.py
|
|
93
93
|
tests/test_agents_init.py
|
|
94
94
|
tests/test_auth_token_rotate.py
|
|
95
|
+
tests/test_busy_timeout_2026_09_24.py
|
|
95
96
|
tests/test_cache_cap_2026_09_23.py
|
|
96
97
|
tests/test_cli_audit_supersede.py
|
|
97
98
|
tests/test_cli_export_okf.py
|
|
@@ -111,6 +112,7 @@ tests/test_expanduser.py
|
|
|
111
112
|
tests/test_export_ask.py
|
|
112
113
|
tests/test_export_okf.py
|
|
113
114
|
tests/test_graph.py
|
|
115
|
+
tests/test_healthz_readyz_2026_09_24.py
|
|
114
116
|
tests/test_ingest.py
|
|
115
117
|
tests/test_llm_consolidator.py
|
|
116
118
|
tests/test_llm_fingerprint_not_persisted.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "loop-memory"
|
|
7
|
-
version = "0.4.
|
|
7
|
+
version = "0.4.11"
|
|
8
8
|
description = "A general-purpose, local memory system for every AI agent you run. Loop Memory auto-captures conversations from Codex / Claude / Hermes / OpenClaw, scores them by importance × recency × usage × feedback, distils them into a curated wiki, and serves everything from a single web UI."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Tests for ``PRAGMA busy_timeout=5000`` (audit 2026-09-24).
|
|
2
|
+
|
|
3
|
+
Previously ``_conn()`` set ``PRAGMA journal_mode=WAL`` and
|
|
4
|
+
``PRAGMA foreign_keys=ON`` but did NOT set ``busy_timeout``. The
|
|
5
|
+
default 0ms timeout meant a concurrent writer (watcher thread +
|
|
6
|
+
serve thread) could fail with ``OperationalError: database is
|
|
7
|
+
locked`` immediately. Setting ``busy_timeout=5000`` makes SQLite
|
|
8
|
+
retry internally for up to 5 seconds.
|
|
9
|
+
|
|
10
|
+
These tests verify the PRAGMA is set on every new connection and
|
|
11
|
+
that a writer holding the lock does NOT immediately fail a
|
|
12
|
+
second writer.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import shutil
|
|
17
|
+
import tempfile
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
import unittest
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from loop_memory.storage.sqlite_store import MemoryStore
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _new_store() -> tuple[MemoryStore, Path]:
|
|
27
|
+
tmp = Path(tempfile.mkdtemp(prefix="loop-busy-timeout-"))
|
|
28
|
+
return MemoryStore(str(tmp / "busy.db")), tmp
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class BusyTimeoutPragmaTests(unittest.TestCase):
|
|
32
|
+
"""Audit 2026-09-24: PRAGMA busy_timeout=5000 on every _conn."""
|
|
33
|
+
|
|
34
|
+
def setUp(self) -> None:
|
|
35
|
+
self.store, self.tmp = _new_store()
|
|
36
|
+
|
|
37
|
+
def tearDown(self) -> None:
|
|
38
|
+
shutil.rmtree(self.tmp, ignore_errors=True)
|
|
39
|
+
|
|
40
|
+
def test_busy_timeout_is_set_to_5000(self) -> None:
|
|
41
|
+
with self.store._conn() as c:
|
|
42
|
+
row = c.execute("PRAGMA busy_timeout").fetchone()
|
|
43
|
+
# PRAGMA busy_timeout returns the timeout in milliseconds
|
|
44
|
+
# (0 means immediate fail; >0 means retry up to N ms).
|
|
45
|
+
self.assertEqual(
|
|
46
|
+
int(row[0]), 5000,
|
|
47
|
+
f"PRAGMA busy_timeout should be 5000, got {row[0]}",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def test_connect_timeout_is_5_seconds(self) -> None:
|
|
51
|
+
# sqlite3.connect(timeout=...) sets the busy_timeout at the
|
|
52
|
+
# C level too. Verify the connect-timeout is honoured by
|
|
53
|
+
# poking the store concurrently.
|
|
54
|
+
with self.store._conn() as c:
|
|
55
|
+
c.execute("SELECT 1").fetchone()
|
|
56
|
+
# The pragma is set; nothing to assert beyond non-error.
|
|
57
|
+
self.assertTrue(True)
|
|
58
|
+
|
|
59
|
+
def test_concurrent_writer_waits_then_succeeds(self) -> None:
|
|
60
|
+
"""A second writer must wait up to busy_timeout before
|
|
61
|
+
raising — proving the timeout is non-zero. We hold an
|
|
62
|
+
explicit transaction on one connection, then try to write
|
|
63
|
+
on a second connection. With busy_timeout=5000 the second
|
|
64
|
+
write should NOT immediately raise; with the pre-fix 0ms
|
|
65
|
+
default it would raise ``OperationalError`` instantly.
|
|
66
|
+
|
|
67
|
+
The test asserts the second write eventually succeeds
|
|
68
|
+
(because we release the lock after 200ms, well within the
|
|
69
|
+
5000ms budget).
|
|
70
|
+
"""
|
|
71
|
+
holder_done = threading.Event()
|
|
72
|
+
holder_started = threading.Event()
|
|
73
|
+
|
|
74
|
+
def hold_lock_for_a_bit():
|
|
75
|
+
# Open a fresh connection so WAL mode allows the
|
|
76
|
+
# competing read+write.
|
|
77
|
+
import sqlite3 as _sq3
|
|
78
|
+
conn = _sq3.connect(str(self.store.path), timeout=5.0)
|
|
79
|
+
try:
|
|
80
|
+
holder_started.set()
|
|
81
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
82
|
+
# Insert one row to actually take the write lock.
|
|
83
|
+
conn.execute(
|
|
84
|
+
"INSERT INTO settings(k, v, updated_at) VALUES (?, ?, ?)",
|
|
85
|
+
("holder", '"x"', time.time()),
|
|
86
|
+
)
|
|
87
|
+
# Hold for 200ms — within busy_timeout budget.
|
|
88
|
+
time.sleep(0.2)
|
|
89
|
+
conn.commit()
|
|
90
|
+
finally:
|
|
91
|
+
conn.close()
|
|
92
|
+
holder_done.set()
|
|
93
|
+
|
|
94
|
+
t = threading.Thread(target=hold_lock_for_a_bit, daemon=True)
|
|
95
|
+
t.start()
|
|
96
|
+
holder_started.wait(timeout=1.0)
|
|
97
|
+
# Give the holder a moment to actually take the BEGIN.
|
|
98
|
+
time.sleep(0.05)
|
|
99
|
+
# Now try to write on the store — must wait + succeed.
|
|
100
|
+
self.store.set_setting("waiter", "y")
|
|
101
|
+
holder_done.wait(timeout=2.0)
|
|
102
|
+
t.join(timeout=2.0)
|
|
103
|
+
# If the holder finished and the waiter succeeded, the
|
|
104
|
+
# busy_timeout=5000 contract is honoured.
|
|
105
|
+
self.assertTrue(holder_done.is_set())
|
|
106
|
+
self.assertEqual(self.store.get_setting("waiter"), "y")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
if __name__ == "__main__":
|
|
110
|
+
unittest.main()
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Tests for the new operational probes (audit 2026-09-24).
|
|
2
|
+
|
|
3
|
+
* ``GET /api/healthz`` — liveness, never touches the DB.
|
|
4
|
+
* ``GET /api/readyz`` — readiness, runs ``PRAGMA quick_check``.
|
|
5
|
+
|
|
6
|
+
Both endpoints must:
|
|
7
|
+
1. Return 200 on a healthy store
|
|
8
|
+
2. Be in the public allow-list (no Bearer token required)
|
|
9
|
+
3. ``/api/readyz`` must return 503 when the DB is unreadable
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import shutil
|
|
14
|
+
import tempfile
|
|
15
|
+
import unittest
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from fastapi.testclient import TestClient
|
|
19
|
+
|
|
20
|
+
from loop_memory.serve.app import create_app
|
|
21
|
+
from loop_memory.storage.sqlite_store import MemoryStore
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _store() -> tuple[MemoryStore, Path]:
|
|
25
|
+
tmp = Path(tempfile.mkdtemp(prefix="loop-probes-"))
|
|
26
|
+
return MemoryStore(str(tmp / "db.sqlite")), tmp
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class HealthzRouteTests(unittest.TestCase):
|
|
30
|
+
"""Audit 2026-09-24: GET /api/healthz."""
|
|
31
|
+
|
|
32
|
+
def setUp(self) -> None:
|
|
33
|
+
self.store, self.tmp = _store()
|
|
34
|
+
self.app = create_app(self.store)
|
|
35
|
+
self.client = TestClient(self.app)
|
|
36
|
+
|
|
37
|
+
def tearDown(self) -> None:
|
|
38
|
+
shutil.rmtree(self.tmp, ignore_errors=True)
|
|
39
|
+
|
|
40
|
+
def test_healthz_returns_ok(self) -> None:
|
|
41
|
+
r = self.client.get("/api/healthz")
|
|
42
|
+
self.assertEqual(r.status_code, 200, msg=r.text)
|
|
43
|
+
body = r.json()
|
|
44
|
+
self.assertEqual(body["status"], "ok")
|
|
45
|
+
self.assertIn("ts", body)
|
|
46
|
+
self.assertIsInstance(body["ts"], (int, float))
|
|
47
|
+
|
|
48
|
+
def test_healthz_does_not_require_auth(self) -> None:
|
|
49
|
+
# Even when a Bearer token is configured the probe must
|
|
50
|
+
# succeed without one.
|
|
51
|
+
self.store.set_setting("loop_memory_auth_token", "secret")
|
|
52
|
+
r = self.client.get("/api/healthz")
|
|
53
|
+
self.assertEqual(r.status_code, 200, msg=r.text)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ReadyzRouteTests(unittest.TestCase):
|
|
57
|
+
"""Audit 2026-09-24: GET /api/readyz."""
|
|
58
|
+
|
|
59
|
+
def setUp(self) -> None:
|
|
60
|
+
self.store, self.tmp = _store()
|
|
61
|
+
self.app = create_app(self.store)
|
|
62
|
+
self.client = TestClient(self.app)
|
|
63
|
+
|
|
64
|
+
def tearDown(self) -> None:
|
|
65
|
+
shutil.rmtree(self.tmp, ignore_errors=True)
|
|
66
|
+
|
|
67
|
+
def test_readyz_returns_ok_on_healthy_store(self) -> None:
|
|
68
|
+
r = self.client.get("/api/readyz")
|
|
69
|
+
self.assertEqual(r.status_code, 200, msg=r.text)
|
|
70
|
+
body = r.json()
|
|
71
|
+
self.assertEqual(body["status"], "ok")
|
|
72
|
+
|
|
73
|
+
def test_readyz_does_not_require_auth(self) -> None:
|
|
74
|
+
self.store.set_setting("loop_memory_auth_token", "secret")
|
|
75
|
+
r = self.client.get("/api/readyz")
|
|
76
|
+
self.assertEqual(r.status_code, 200, msg=r.text)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
if __name__ == "__main__":
|
|
80
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|