loop-memory 0.4.13__tar.gz → 0.4.15__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.13 → loop_memory-0.4.15}/PKG-INFO +4 -1
- {loop_memory-0.4.13 → loop_memory-0.4.15}/README.md +3 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/cli/commands/diag.py +11 -88
- loop_memory-0.4.15/loop_memory/cli/commands/doctor.py +144 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/cli/commands/read.py +27 -1
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/cli/main.py +1 -1
- loop_memory-0.4.15/loop_memory/decorators.py +243 -0
- loop_memory-0.4.15/loop_memory/doctor.py +678 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/serve/routes/system.py +37 -14
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/storage/retrieval.py +95 -6
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/storage/sqlite_store.py +400 -21
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory.egg-info/PKG-INFO +4 -1
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory.egg-info/SOURCES.txt +8 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/pyproject.toml +1 -1
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_agents_init.py +2 -2
- loop_memory-0.4.15/tests/test_content_hash_dedup_2026_10_18.py +436 -0
- loop_memory-0.4.15/tests/test_decorators_2026_10_18.py +279 -0
- loop_memory-0.4.15/tests/test_doctor_2026_10_12.py +319 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_evolution_quality.py +12 -2
- loop_memory-0.4.15/tests/test_recall_validity_http_2026_10_18.py +169 -0
- loop_memory-0.4.15/tests/test_retrieval_channels_2026_10_18.py +216 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_store.py +11 -2
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_supersession_chain.py +4 -2
- {loop_memory-0.4.13 → loop_memory-0.4.15}/LICENSE +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/__init__.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/backends/__init__.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/backends/embedding.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/backends/sentence_embedder.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/backends/vector_store.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/cli/__init__.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/cli/_common.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/cli/commands/__init__.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/cli/commands/cognitive.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/cli/commands/graph.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/cli/commands/hooks.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/cli/commands/rules.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/cli/commands/serve.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/cli/commands/write.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/engine/__init__.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/engine/loop.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/engine/reflect.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/examples/__init__.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/examples/demo.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/export/__init__.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/export/memory_md.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/graph/__init__.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/graph/build.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/graph/extract.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/ingest/__init__.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/ingest/loader.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/ingest/pipeline.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/jobs/__init__.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/jobs/cognitive.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/jobs/compact.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/jobs/consolidate.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/jobs/contradiction.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/jobs/evolution.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/jobs/graph.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/jobs/llm_compact_pass.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/jobs/llm_consolidate.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/jobs/scheduler.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/llm/__init__.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/llm/base.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/llm/openai_adapter.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/llm/providers.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/mcp/__init__.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/memory/__init__.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/memory/types.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/privacy/__init__.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/privacy/private.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/privacy/redact.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/py.typed +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/sdk.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/sdk_extensions.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/security/__init__.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/security/secrets.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/serve/__init__.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/serve/app.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/serve/handlers.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/serve/routes/_shared.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/serve/routes/admin.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/serve/routes/cognitive.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/serve/routes/export.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/serve/routes/graph.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/serve/routes/insights.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/serve/routes/memories.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/serve/routes/sessions.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/serve/routes/wiki.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/serve/static/__init__.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/serve/static/index.html +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/serve/watcher.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/storage/__init__.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/storage/snapshot.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/wiki/__init__.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/wiki/backfill.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/wiki/classifier.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/wiki/prompts.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory/wiki/scope.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory.egg-info/dependency_links.txt +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory.egg-info/entry_points.txt +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory.egg-info/requires.txt +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/loop_memory.egg-info/top_level.txt +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/setup.cfg +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_admin_ingest_route.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_agent_memory_api.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_agent_memory_sdk.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_audit_triggers_2026_09_27.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_auth_token_rotate.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_busy_timeout_2026_09_24.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_cache_cap_2026_09_23.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_cli_audit_supersede.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_cli_export_okf.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_cli_init.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_cli_memory_stats.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_cli_recall_as_of.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_cli_recall_paths.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_cli_recall_verbose.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_cli_rules.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_cli_snapshot.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_cli_v7.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_cli_version.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_contradictions.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_evolution.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_expanduser.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_export_ask.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_export_okf.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_graph.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_healthz_readyz_2026_09_24.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_import_okf_2026_09_27.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_ingest.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_llm_consolidator.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_llm_fingerprint_not_persisted.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_llm_providers.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_llm_test_endpoint.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_loop.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_mcp.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_memories_pagination.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_memory_stats.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_openclaw_loader.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_package_version.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_path_safety_2026_09_23.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_recall_as_of.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_recall_budget_2026_10_05.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_recall_paths.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_recall_provenance.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_reflection.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_repo_framing.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_routes_audit_2026_09_13.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_routes_audit_2026_09_20.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_score_api.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_scoring_v2.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_secrets.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_serve_app.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_serve_handlers.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_session_order.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_short_query.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_snapshot.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_subgraph_hops_2026_09_23.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_summarization.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_universal_memory.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_vector_store.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_watcher.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_wiki_classifier.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/tests/test_wiki_export_escape.py +0 -0
- {loop_memory-0.4.13 → loop_memory-0.4.15}/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.15
|
|
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,9 @@ Dynamic: license-file
|
|
|
66
66
|
|
|
67
67
|
---
|
|
68
68
|
|
|
69
|
+
> **What's new in 0.4.15** — **content-hash dedup + bi-temporal validity window + evidence chain + `@agent_memory` decorator** (audit 2026-10-18, weekly ecosystem sync). Three new columns on `memories`: `content_hash` (SHA-256 of normalised text, indexed), `valid_at` (epoch the memory becomes active), `invalid_at` (epoch it expires — NULL means still open-ended). The schema migration bumps `SCHEMA_VERSION` 11 → 12 with an idempotent backfill so legacy DBs upgrade in-place. New `upsert_memory(valid_at=, invalid_at=, dedup=)` parameters let callers schedule future-dated or expiring memories and opt out of content-hash dedup for episodic event streams. `recall()` and `recall_hybrid()` now accept an optional `as_of_ts` (float epoch or ISO-8601) — when set, both methods drop memories whose `valid_at > as_of` or `invalid_at <= as_of`, complementing the existing `recall_as_of()` supersession-chain walk. Every recall hit now carries an `evidence: [{field, value, weight}]` array — BM25 / semantic / entity / rrf_total / high_importance channels are exposed individually so a UI can render "this hit because ... [bm25 1.0] [entity 0.7]" instead of a black-box score (Cognee 1.0 / Mem0 v2 evidence-citation pattern, zero-dep). New `loop_memory.decorators` exposes `@agent_memory(store, agent_id=, limit=)` — a one-line wrapper that recalls the top-k relevant memories before the body runs and writes a fresh `kind="interaction"` memory after. Strings returned from the wrapped function are stored verbatim; dict/list returns are JSON-serialised; the recall list is injected as a `memory_context` keyword when the function declares it, otherwise attached to dict results under `memories`. Sister `remember(store, text, ...)` helper for seeding. The BM25 `_like_fallback` now falls through to per-token OR-LIKE when the whole-string match returns nothing — natural-language questions like "What is the deploy process?" now hit stored facts even when FTS5 trigram can't match quoted exact phrases. Source: Cognee 1.0 ECL pipeline + Mem0 v2 hybrid fusion + Zep/Graphiti bi-temporal validity fields + Letta block-memory (pattern only, no code copied). 59 new regression cases. [Full CHANGELOG →](./CHANGELOG.md#0415---2026-10-18)
|
|
70
|
+
> **What's new in 0.4.14** — **`loop-memory doctor` deep store-health check** (audit 2026-10-12). Ten diagnostic checks — `db_exists`, `db_integrity`, `schema_version`, `audit_triggers`, `audit_log_writable`, `audit_log_size`, `hooks_installed`, `version_consistent`, `wiki_no_orphans`, `recall_paths_works` — report `ok`/`info`/`warn`/`error` for the live `MemoryStore`. `--fix` auto-repairs the `audit_triggers` check. `--json` emits a stable shape for CI. `--list` prints the check function names without touching the store. `--verbose` shows `info`-level checks. Exit code `1` on any `error` status, `0` otherwise. Source: `cortexkit/magic-context doctor` auto-diagnostic pattern (MIT, 2026-10-12) + `dsh-memento` write-approval-gate pattern (Apache-2.0, 2026-10-12). Stdlib-only — we re-implement from scratch against our own `MemoryStore`. Also fixed a Python 3.14 bug: chained `store._conn().__enter__()` immediately triggers the contextmanager's `finally` and closes the connection mid-query — both call sites in `doctor.py` now use explicit `with` form. 27 new regression cases. [Full CHANGELOG →](./CHANGELOG.md#0414---2026-10-12)
|
|
71
|
+
|
|
69
72
|
> **What's new in 0.4.13** — **recall token / char budget cap** + **unified `level=0|1|2` tiered-loader knob** (audit 2026-10-05). New `max_chars` parameter on `MemoryStore.recall()` / `recall_paths()` / `recall_hybrid()` / `recall_as_of()` caps the *total* payload across all returned hits — when the budget is exceeded the lowest-ranked hit shrinks first so the top-scoring hits always keep their full body. `recall()` / `recall_paths()` / `recall_as_of()` now also accept `level=0|1|2` so a caller can ask for the L0 abstract ladder rung inline (titles + tags + abstract only, never the full `text` / `body`) without routing through `recall_paths()` separately. Both knobs are wired through the HTTP routes (`GET /api/recall?max_chars=&level=`, `GET /api/recall/outline?max_chars=`), the CLI (`loop-memory recall --max-chars N --level 0|1|2`, `recall-paths --max-chars N`), the SDK (`LoopMemoryClient.recall(max_chars=, level=)`), and the MCP `tool_recall` server. Source: `vectorize-io/hindsight v0.10.2` "concise extraction by default" (Apache-2.0, 2026-09-29) + `aiming-lab/SimpleMem` ICML'26 semantic-lossless-compression (Apache-2.0, 2026-10-04) + `volcengine/OpenViking` tiered-loader (AGPLv3, 2026-10-02 — *pattern only, no code copied*). 26 new regression cases. [Full CHANGELOG →](./CHANGELOG.md#0413---2026-10-05)
|
|
70
73
|
|
|
71
74
|
> **What's new in 0.4.12** — **OKF bundle import** + **DB-level audit enforcement** (audit 2026-09-27). The new `loop-memory import-okf <in_dir>` (and `POST /api/import/okf`) is the symmetric counterpart to `export-okf` (0.4.9) — bring an OKF v0.2 bundle back into Loop Memory as wiki pages, with idempotent upsert on `(scope, slug)`, optional `--dry-run` and `--skip-conflicts`. Source: `thecolourfoundation/rune` HN launch (MIT, 2026-09-25) + `okf-memory/okf-agent-memory` Issues #12-#15 + `Deja-Vu` (MIT, 2026-09-24) + Google OKF v0.2 spec (Apache-2.0, 2026-09-01). The new `MemoryStore._install_audit_triggers()` installs 12 SQLite triggers on `memories` / `wiki_pages` / `relations` / `consolidation_runs` so the `audit_log` table cannot be skipped — even a raw `sqlite3` writer that bypasses the Python wrapper still appends an audit row with `actor='db_trigger'`. New `MemoryStore.list_audit_log()` reads the log; `GET /api/stats` reports `audit_log` + `audit_triggers` counts. Source: `cogstate-eng/memory-eternal v0.4.3` `enforceAudit()` pattern (MIT, 2026-09-23). 31 new regression cases. [Full CHANGELOG →](./CHANGELOG.md#0412---2026-09-27)
|
|
@@ -22,6 +22,9 @@
|
|
|
22
22
|
|
|
23
23
|
---
|
|
24
24
|
|
|
25
|
+
> **What's new in 0.4.15** — **content-hash dedup + bi-temporal validity window + evidence chain + `@agent_memory` decorator** (audit 2026-10-18, weekly ecosystem sync). Three new columns on `memories`: `content_hash` (SHA-256 of normalised text, indexed), `valid_at` (epoch the memory becomes active), `invalid_at` (epoch it expires — NULL means still open-ended). The schema migration bumps `SCHEMA_VERSION` 11 → 12 with an idempotent backfill so legacy DBs upgrade in-place. New `upsert_memory(valid_at=, invalid_at=, dedup=)` parameters let callers schedule future-dated or expiring memories and opt out of content-hash dedup for episodic event streams. `recall()` and `recall_hybrid()` now accept an optional `as_of_ts` (float epoch or ISO-8601) — when set, both methods drop memories whose `valid_at > as_of` or `invalid_at <= as_of`, complementing the existing `recall_as_of()` supersession-chain walk. Every recall hit now carries an `evidence: [{field, value, weight}]` array — BM25 / semantic / entity / rrf_total / high_importance channels are exposed individually so a UI can render "this hit because ... [bm25 1.0] [entity 0.7]" instead of a black-box score (Cognee 1.0 / Mem0 v2 evidence-citation pattern, zero-dep). New `loop_memory.decorators` exposes `@agent_memory(store, agent_id=, limit=)` — a one-line wrapper that recalls the top-k relevant memories before the body runs and writes a fresh `kind="interaction"` memory after. Strings returned from the wrapped function are stored verbatim; dict/list returns are JSON-serialised; the recall list is injected as a `memory_context` keyword when the function declares it, otherwise attached to dict results under `memories`. Sister `remember(store, text, ...)` helper for seeding. The BM25 `_like_fallback` now falls through to per-token OR-LIKE when the whole-string match returns nothing — natural-language questions like "What is the deploy process?" now hit stored facts even when FTS5 trigram can't match quoted exact phrases. Source: Cognee 1.0 ECL pipeline + Mem0 v2 hybrid fusion + Zep/Graphiti bi-temporal validity fields + Letta block-memory (pattern only, no code copied). 59 new regression cases. [Full CHANGELOG →](./CHANGELOG.md#0415---2026-10-18)
|
|
26
|
+
> **What's new in 0.4.14** — **`loop-memory doctor` deep store-health check** (audit 2026-10-12). Ten diagnostic checks — `db_exists`, `db_integrity`, `schema_version`, `audit_triggers`, `audit_log_writable`, `audit_log_size`, `hooks_installed`, `version_consistent`, `wiki_no_orphans`, `recall_paths_works` — report `ok`/`info`/`warn`/`error` for the live `MemoryStore`. `--fix` auto-repairs the `audit_triggers` check. `--json` emits a stable shape for CI. `--list` prints the check function names without touching the store. `--verbose` shows `info`-level checks. Exit code `1` on any `error` status, `0` otherwise. Source: `cortexkit/magic-context doctor` auto-diagnostic pattern (MIT, 2026-10-12) + `dsh-memento` write-approval-gate pattern (Apache-2.0, 2026-10-12). Stdlib-only — we re-implement from scratch against our own `MemoryStore`. Also fixed a Python 3.14 bug: chained `store._conn().__enter__()` immediately triggers the contextmanager's `finally` and closes the connection mid-query — both call sites in `doctor.py` now use explicit `with` form. 27 new regression cases. [Full CHANGELOG →](./CHANGELOG.md#0414---2026-10-12)
|
|
27
|
+
|
|
25
28
|
> **What's new in 0.4.13** — **recall token / char budget cap** + **unified `level=0|1|2` tiered-loader knob** (audit 2026-10-05). New `max_chars` parameter on `MemoryStore.recall()` / `recall_paths()` / `recall_hybrid()` / `recall_as_of()` caps the *total* payload across all returned hits — when the budget is exceeded the lowest-ranked hit shrinks first so the top-scoring hits always keep their full body. `recall()` / `recall_paths()` / `recall_as_of()` now also accept `level=0|1|2` so a caller can ask for the L0 abstract ladder rung inline (titles + tags + abstract only, never the full `text` / `body`) without routing through `recall_paths()` separately. Both knobs are wired through the HTTP routes (`GET /api/recall?max_chars=&level=`, `GET /api/recall/outline?max_chars=`), the CLI (`loop-memory recall --max-chars N --level 0|1|2`, `recall-paths --max-chars N`), the SDK (`LoopMemoryClient.recall(max_chars=, level=)`), and the MCP `tool_recall` server. Source: `vectorize-io/hindsight v0.10.2` "concise extraction by default" (Apache-2.0, 2026-09-29) + `aiming-lab/SimpleMem` ICML'26 semantic-lossless-compression (Apache-2.0, 2026-10-04) + `volcengine/OpenViking` tiered-loader (AGPLv3, 2026-10-02 — *pattern only, no code copied*). 26 new regression cases. [Full CHANGELOG →](./CHANGELOG.md#0413---2026-10-05)
|
|
26
29
|
|
|
27
30
|
> **What's new in 0.4.12** — **OKF bundle import** + **DB-level audit enforcement** (audit 2026-09-27). The new `loop-memory import-okf <in_dir>` (and `POST /api/import/okf`) is the symmetric counterpart to `export-okf` (0.4.9) — bring an OKF v0.2 bundle back into Loop Memory as wiki pages, with idempotent upsert on `(scope, slug)`, optional `--dry-run` and `--skip-conflicts`. Source: `thecolourfoundation/rune` HN launch (MIT, 2026-09-25) + `okf-memory/okf-agent-memory` Issues #12-#15 + `Deja-Vu` (MIT, 2026-09-24) + Google OKF v0.2 spec (Apache-2.0, 2026-09-01). The new `MemoryStore._install_audit_triggers()` installs 12 SQLite triggers on `memories` / `wiki_pages` / `relations` / `consolidation_runs` so the `audit_log` table cannot be skipped — even a raw `sqlite3` writer that bypasses the Python wrapper still appends an audit row with `actor='db_trigger'`. New `MemoryStore.list_audit_log()` reads the log; `GET /api/stats` reports `audit_log` + `audit_triggers` counts. Source: `cogstate-eng/memory-eternal v0.4.3` `enforceAudit()` pattern (MIT, 2026-09-23). 31 new regression cases. [Full CHANGELOG →](./CHANGELOG.md#0412---2026-09-27)
|
|
@@ -133,95 +133,18 @@ def _detect_watcher() -> bool:
|
|
|
133
133
|
|
|
134
134
|
|
|
135
135
|
def run_doctor(_args) -> int:
|
|
136
|
-
"""
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
136
|
+
"""`loop-memory doctor` — deep store-health check.
|
|
137
|
+
|
|
138
|
+
Audit 2026-10-12. Delegates to :mod:`loop_memory.doctor` which runs
|
|
139
|
+
a small library of health checks against the live
|
|
140
|
+
`MemoryStore` and renders a human-readable report. Exits
|
|
141
|
+
non-zero if any check is in 'error' state. Source:
|
|
142
|
+
cortexkit/magic-context doctor (MIT, 2026-10-12) +
|
|
143
|
+
PerryLink/dsh-memento dsh-plugin-doctor (Apache-2.0,
|
|
144
|
+
2026-10-12) — pattern only.
|
|
140
145
|
"""
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
cli_path = shutil.which("loop-memory")
|
|
144
|
-
print(_dot(cli_path is not None,
|
|
145
|
-
f"CLI on PATH: {'yes (' + cli_path + ')' if cli_path else 'no'}"))
|
|
146
|
-
# 2) Database
|
|
147
|
-
db_path = Path(DEFAULT_DB)
|
|
148
|
-
print(_dot(db_path.exists(),
|
|
149
|
-
f"database: {db_path}" + ("" if db_path.exists() else " (will be created on first run)")))
|
|
150
|
-
# 3) Server
|
|
151
|
-
server_running = False
|
|
152
|
-
try:
|
|
153
|
-
import urllib.request
|
|
154
|
-
with urllib.request.urlopen("http://127.0.0.1:7767/api/stats", timeout=1) as r:
|
|
155
|
-
server_running = r.status == 200
|
|
156
|
-
except Exception:
|
|
157
|
-
server_running = False
|
|
158
|
-
print(_dot(server_running,
|
|
159
|
-
"web UI server: http://127.0.0.1:7767" + (" (running)" if server_running else " (not running — start with `loop-memory serve &`)")))
|
|
160
|
-
|
|
161
|
-
# 4) Clients
|
|
162
|
-
print()
|
|
163
|
-
print("clients:")
|
|
164
|
-
clients = _detect_clients()
|
|
165
|
-
for name in ("codex", "claude", "hermes", "openclaw"):
|
|
166
|
-
info = clients[name]
|
|
167
|
-
if name == "openclaw":
|
|
168
|
-
print(_dot(info["installed"],
|
|
169
|
-
f" openclaw: {'installed' if info['installed'] else 'not installed'}"))
|
|
170
|
-
if info["installed"]:
|
|
171
|
-
paths = ", ".join(Path(p).name for p in info["watch_paths"]) or "no transcript paths found"
|
|
172
|
-
print(f" {paths}")
|
|
173
|
-
print(_dot(_detect_watcher(),
|
|
174
|
-
" watcher running" if _detect_watcher() else " watcher not running — start with `loop-memory openclaw-setup`"))
|
|
175
|
-
else:
|
|
176
|
-
cfg = info["config"]
|
|
177
|
-
installed = info["installed"]
|
|
178
|
-
cfg_str = f"@ {cfg}" if installed else ""
|
|
179
|
-
print(_dot(installed,
|
|
180
|
-
f" {name}: {'installed' if installed else 'not installed'} {cfg_str}"))
|
|
181
|
-
if installed:
|
|
182
|
-
print(_dot(info["mcp_configured"],
|
|
183
|
-
f" MCP server wired: {'yes' if info['mcp_configured'] else 'no — run `loop-memory install-hooks`'}"))
|
|
184
|
-
|
|
185
|
-
# 5) LLM provider
|
|
186
|
-
print()
|
|
187
|
-
print("LLM provider:")
|
|
188
|
-
try:
|
|
189
|
-
from ...storage.sqlite_store import MemoryStore
|
|
190
|
-
from ...llm.providers import default_config
|
|
191
|
-
from ...security import backend_display_name, has_secret
|
|
192
|
-
store = MemoryStore(DEFAULT_DB)
|
|
193
|
-
cfg = store.get_setting("llm_consolidator", default_config())
|
|
194
|
-
provider = cfg.get("provider") or "echo"
|
|
195
|
-
print(f" provider: {provider}")
|
|
196
|
-
print(f" model: {cfg.get('model', '—')}")
|
|
197
|
-
print(f" secret backend: {backend_display_name()}")
|
|
198
|
-
if provider != "echo":
|
|
199
|
-
from ...security import account_for
|
|
200
|
-
account = cfg.get("api_key_account") or account_for(provider)
|
|
201
|
-
has = has_secret(account)
|
|
202
|
-
print(_dot(has,
|
|
203
|
-
f" API key: {'configured' if has else 'MISSING — open the web UI → ⚙ Model → paste your key'}"))
|
|
204
|
-
except Exception as e:
|
|
205
|
-
print(_dot(False, f" could not read LLM config: {e}"))
|
|
206
|
-
|
|
207
|
-
# 6) Data summary
|
|
208
|
-
print()
|
|
209
|
-
print("data:")
|
|
210
|
-
try:
|
|
211
|
-
from ...storage.sqlite_store import MemoryStore
|
|
212
|
-
store = MemoryStore(DEFAULT_DB)
|
|
213
|
-
n_mem = store.count_memories()
|
|
214
|
-
n_sess = store.count_sessions()
|
|
215
|
-
n_wiki = store.count_wiki_pages()
|
|
216
|
-
n_ent = store.count_entities()
|
|
217
|
-
print(f" {n_mem} memories · {n_sess} sessions · {n_wiki} wiki pages · {n_ent} entities")
|
|
218
|
-
except Exception as e:
|
|
219
|
-
print(f" could not read counters: {e}")
|
|
220
|
-
|
|
221
|
-
print()
|
|
222
|
-
print(_DIM + "Tip: run `loop-memory status` for a one-screen summary, "
|
|
223
|
-
"or `loop-memory install-hooks` to auto-configure your CLIs." + _RESET)
|
|
224
|
-
return 0
|
|
146
|
+
from . import doctor as _doctor_cli
|
|
147
|
+
return _doctor_cli.run_doctor(list(_args))
|
|
225
148
|
|
|
226
149
|
|
|
227
150
|
def run_status(_args) -> int:
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""``loop-memory doctor`` subcommand.
|
|
2
|
+
|
|
3
|
+
Audit 2026-10-12. Wraps :mod:`loop_memory.doctor` and renders the
|
|
4
|
+
result for a human or as JSON. See ``loop_memory/doctor.py`` for
|
|
5
|
+
the check library and the source attribution.
|
|
6
|
+
|
|
7
|
+
Usage examples::
|
|
8
|
+
|
|
9
|
+
loop-memory doctor # human-readable report
|
|
10
|
+
loop-memory doctor --json # machine-readable report
|
|
11
|
+
loop-memory doctor --verbose # also show INFO-level checks
|
|
12
|
+
loop-memory doctor --fix # auto-repair auto_fixable checks
|
|
13
|
+
loop-memory doctor --db /path/to/store.db # run against a specific DB
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from .._common import die
|
|
23
|
+
from ... import doctor as _doctor
|
|
24
|
+
from ...storage.sqlite_store import MemoryStore
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
28
|
+
p = argparse.ArgumentParser(
|
|
29
|
+
prog="loop-memory doctor",
|
|
30
|
+
description=(
|
|
31
|
+
"Run a small library of health checks against the loop-memory "
|
|
32
|
+
"store. Reports ok / info / warn / error per check and exits "
|
|
33
|
+
"non-zero if any check is in 'error' state. Source: cortexkit/"
|
|
34
|
+
"magic-context doctor (MIT, 2026-10-12) + PerryLink/dsh-memento "
|
|
35
|
+
"dsh-plugin-doctor (Apache-2.0, 2026-10-12) — pattern only."
|
|
36
|
+
),
|
|
37
|
+
)
|
|
38
|
+
p.add_argument(
|
|
39
|
+
"--db",
|
|
40
|
+
default=None,
|
|
41
|
+
help="path to the SQLite file (default: $LOOP_MEMORY_DB or ~/.loop_memory/loop_memory.db)",
|
|
42
|
+
)
|
|
43
|
+
p.add_argument(
|
|
44
|
+
"--json",
|
|
45
|
+
action="store_true",
|
|
46
|
+
help="emit the result as JSON for scripting",
|
|
47
|
+
)
|
|
48
|
+
p.add_argument(
|
|
49
|
+
"--verbose",
|
|
50
|
+
action="store_true",
|
|
51
|
+
help="also show INFO-level checks in human output",
|
|
52
|
+
)
|
|
53
|
+
p.add_argument(
|
|
54
|
+
"--fix",
|
|
55
|
+
action="store_true",
|
|
56
|
+
help="auto-repair any auto_fixable check that fails (idempotent; safe to re-run)",
|
|
57
|
+
)
|
|
58
|
+
p.add_argument(
|
|
59
|
+
"--list",
|
|
60
|
+
action="store_true",
|
|
61
|
+
help="list every check the doctor would run and exit (no DB access)",
|
|
62
|
+
)
|
|
63
|
+
return p
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _resolve_db_path(arg: str | None) -> Path:
|
|
67
|
+
"""Pick the store path: ``--db`` wins, else ``$LOOP_MEMORY_DB``,
|
|
68
|
+
else the default ``~/.loop_memory/loop_memory.db``."""
|
|
69
|
+
if arg:
|
|
70
|
+
return Path(arg).expanduser()
|
|
71
|
+
import os
|
|
72
|
+
env = os.environ.get("LOOP_MEMORY_DB")
|
|
73
|
+
if env:
|
|
74
|
+
return Path(env).expanduser()
|
|
75
|
+
from .._common import default_db_path
|
|
76
|
+
return Path(default_db_path())
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def run_doctor(args: list[str]) -> int:
|
|
80
|
+
parser = _build_parser()
|
|
81
|
+
parsed = parser.parse_args(args)
|
|
82
|
+
|
|
83
|
+
if parsed.list:
|
|
84
|
+
for fn in _doctor.CHECKS:
|
|
85
|
+
print(fn.__name__)
|
|
86
|
+
return 0
|
|
87
|
+
|
|
88
|
+
db_path = _resolve_db_path(parsed.db)
|
|
89
|
+
if not db_path.exists():
|
|
90
|
+
msg = (
|
|
91
|
+
f"loop-memory doctor: store file not found at {db_path}. "
|
|
92
|
+
"Run `loop-memory init` first, or pass --db to point at an existing store."
|
|
93
|
+
)
|
|
94
|
+
if parsed.json:
|
|
95
|
+
print(_doctor.render_json([
|
|
96
|
+
_doctor.DoctorCheck(
|
|
97
|
+
name="db_exists",
|
|
98
|
+
status=_doctor.STATUS_ERROR,
|
|
99
|
+
message=msg,
|
|
100
|
+
fix_hint="run `loop-memory init` to create the store",
|
|
101
|
+
),
|
|
102
|
+
]))
|
|
103
|
+
return 1
|
|
104
|
+
return die(msg)
|
|
105
|
+
|
|
106
|
+
# ``MemoryStore(<missing_path>)`` creates a fresh DB on open.
|
|
107
|
+
# We want doctor to FAIL when the path is missing rather than
|
|
108
|
+
# silently materialise an empty store, so the missing-path
|
|
109
|
+
# check above is the contract.
|
|
110
|
+
try:
|
|
111
|
+
store = MemoryStore(str(db_path))
|
|
112
|
+
except Exception as e:
|
|
113
|
+
msg = f"loop-memory doctor: could not open store at {db_path}: {e}"
|
|
114
|
+
if parsed.json:
|
|
115
|
+
print(_doctor.render_json([
|
|
116
|
+
_doctor.DoctorCheck(
|
|
117
|
+
name="db_exists",
|
|
118
|
+
status=_doctor.STATUS_ERROR,
|
|
119
|
+
message=msg,
|
|
120
|
+
),
|
|
121
|
+
]))
|
|
122
|
+
return 1
|
|
123
|
+
return die(msg)
|
|
124
|
+
|
|
125
|
+
checks = _doctor.run_checks(store)
|
|
126
|
+
|
|
127
|
+
if parsed.fix:
|
|
128
|
+
# Apply auto-fixes in a single pass. We re-run the checks
|
|
129
|
+
# so the user sees the post-fix state in the same command.
|
|
130
|
+
for c in checks:
|
|
131
|
+
if c.status in (_doctor.STATUS_ERROR, _doctor.STATUS_WARN) and c.auto_fixable:
|
|
132
|
+
post = _doctor.apply_fix(store, c.name)
|
|
133
|
+
# Replace the failing check with the post-fix result
|
|
134
|
+
# so the human / JSON output reflects reality.
|
|
135
|
+
idx = checks.index(c)
|
|
136
|
+
checks[idx] = post
|
|
137
|
+
checks = _doctor.run_checks(store)
|
|
138
|
+
|
|
139
|
+
if parsed.json:
|
|
140
|
+
sys.stdout.write(_doctor.render_json(checks))
|
|
141
|
+
else:
|
|
142
|
+
sys.stdout.write(_doctor.render_human(checks, verbose=parsed.verbose))
|
|
143
|
+
|
|
144
|
+
return _doctor.exit_code(checks)
|
|
@@ -73,6 +73,9 @@ def run_recall(args) -> int:
|
|
|
73
73
|
outline = False
|
|
74
74
|
limit = 10
|
|
75
75
|
as_of: float | None = None
|
|
76
|
+
# Audit 2026-10-18: bi-temporal *validity* window (distinct
|
|
77
|
+
# from ``as_of`` which routes through the supersession chain).
|
|
78
|
+
validity_at: float | None = None
|
|
76
79
|
max_chars: int | None = None
|
|
77
80
|
level = 1
|
|
78
81
|
qargs: list[str] = []
|
|
@@ -106,6 +109,24 @@ def run_recall(args) -> int:
|
|
|
106
109
|
f"--as-of must be float epoch or ISO-8601; got {raw!r}"
|
|
107
110
|
)
|
|
108
111
|
i += 2
|
|
112
|
+
elif a == "--validity-at" and i + 1 < len(args):
|
|
113
|
+
# Audit 2026-10-18 (loop-memory 0.4.15) -- cheaper
|
|
114
|
+
# validity-window filter (vs --as-of which walks the
|
|
115
|
+
# supersession chain). Accepts the same float-or-ISO.
|
|
116
|
+
raw = args[i + 1].strip()
|
|
117
|
+
try:
|
|
118
|
+
validity_at = float(raw)
|
|
119
|
+
except ValueError:
|
|
120
|
+
try:
|
|
121
|
+
from datetime import datetime
|
|
122
|
+
validity_at = datetime.fromisoformat(
|
|
123
|
+
raw.replace("Z", "+00:00")
|
|
124
|
+
).timestamp()
|
|
125
|
+
except Exception:
|
|
126
|
+
return die(
|
|
127
|
+
f"--validity-at must be float epoch or ISO-8601; got {raw!r}"
|
|
128
|
+
)
|
|
129
|
+
i += 2
|
|
109
130
|
elif a == "--max-chars" and i + 1 < len(args):
|
|
110
131
|
# Audit 2026-10-05 (vectorize-io/hindsight v0.10.2 +
|
|
111
132
|
# aiming-lab/SimpleMem ICML'26) — cap the total payload
|
|
@@ -154,7 +175,12 @@ def run_recall(args) -> int:
|
|
|
154
175
|
r = store.recall_paths(query, limit=limit, max_chars=max_chars)
|
|
155
176
|
else:
|
|
156
177
|
r = store.recall(query, limit=limit,
|
|
157
|
-
max_chars=max_chars, level=level
|
|
178
|
+
max_chars=max_chars, level=level,
|
|
179
|
+
# Audit 2026-10-18: pass validity_at through
|
|
180
|
+
# to recall() so the new bi-temporal window
|
|
181
|
+
# filter applies. ``None`` keeps the legacy
|
|
182
|
+
# "everything" semantics.
|
|
183
|
+
as_of_ts=validity_at)
|
|
158
184
|
has = False
|
|
159
185
|
if r["wiki"]:
|
|
160
186
|
has = True
|
|
@@ -149,7 +149,7 @@ COMMAND_HELP: dict[str, str] = {
|
|
|
149
149
|
"consolidate": "loop-memory consolidate # rescore + GC + dedupe.",
|
|
150
150
|
"consolidate-now": "loop-memory consolidate-now # ask the running server to trigger a pass right now.",
|
|
151
151
|
"digest": "loop-memory digest [--out PATH] # compact knowledge digest for AGENTS.md.",
|
|
152
|
-
"doctor": "loop-memory doctor #
|
|
152
|
+
"doctor": "loop-memory doctor [--json] [--verbose] [--fix] [--db PATH] # deep store-health check (db integrity + audit triggers + hooks + version + wiki orphans). Auto-repair with --fix (audit 2026-10-12; pattern adopted from cortexkit/magic-context + dsh-memento).",
|
|
153
153
|
"export": "loop-memory export [--out FILE | <out_dir>] # markdown dump or v7 bundle export.",
|
|
154
154
|
"export-bundle": "loop-memory export-bundle <out_dir> # explicit v7 bundle alias.",
|
|
155
155
|
"flush": "loop-memory flush # drop volatile caches; keep persisted data.",
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Lightweight decorators for one-line agent memory integration.
|
|
2
|
+
|
|
3
|
+
Audit 2026-10-18 (loop-memory 0.4.15): the decorator pattern is the
|
|
4
|
+
adoption-shortcut inspired by LangChain ``@tool`` / Mem0 v2's
|
|
5
|
+
``@remember`` -- a single line of boilerplate turns any plain Python
|
|
6
|
+
function into an "agent-callable" that reads from long-term memory
|
|
7
|
+
before it runs and writes a fresh memory after it returns.
|
|
8
|
+
|
|
9
|
+
Two decorators ship here:
|
|
10
|
+
|
|
11
|
+
* :func:`agent_memory` -- wraps a callable so every invocation:
|
|
12
|
+
- **recalls** the top-k relevant memories for the function's first
|
|
13
|
+
string argument (the "query") *before* running the body, and
|
|
14
|
+
- either exposes the recall as a ``memory_context`` keyword
|
|
15
|
+
argument (when the wrapped function's signature declares it),
|
|
16
|
+
OR surfaces it on the return value as ``result["memories"]``;
|
|
17
|
+
- **stores** the (query, return-value) pair as a single
|
|
18
|
+
``kind="interaction"`` memory row *after* the body finishes, so
|
|
19
|
+
the next invocation can recall it.
|
|
20
|
+
|
|
21
|
+
* :func:`remember` -- a tiny sugar that just calls
|
|
22
|
+
``store.upsert_memory(kind="fact", text=text, ...)`` once at
|
|
23
|
+
definition time. Useful for facts the agent wants to hard-code into
|
|
24
|
+
memory at import time ("I am a code assistant", "My user prefers
|
|
25
|
+
dark mode", etc.).
|
|
26
|
+
|
|
27
|
+
Both decorators are intentionally zero-dep and free of any LLM/agent
|
|
28
|
+
framework dependency -- they sit on top of the public
|
|
29
|
+
``MemoryStore`` API only.
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import functools
|
|
34
|
+
import inspect
|
|
35
|
+
import json
|
|
36
|
+
from collections.abc import Callable
|
|
37
|
+
from typing import Any, ParamSpec, TypeVar
|
|
38
|
+
|
|
39
|
+
from .storage.sqlite_store import MemoryStore
|
|
40
|
+
|
|
41
|
+
P = ParamSpec("P")
|
|
42
|
+
R = TypeVar("R")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _stringify(value: Any, *, max_len: int = 2000) -> str:
|
|
46
|
+
"""Coerce a Python return value to a memory-friendly string.
|
|
47
|
+
|
|
48
|
+
* ``str`` -> as-is (truncated to ``max_len``).
|
|
49
|
+
* ``dict`` / ``list`` -> JSON-serialised then truncated.
|
|
50
|
+
* ``bytes``-> hex preview.
|
|
51
|
+
* everything else -> ``repr(value)``.
|
|
52
|
+
|
|
53
|
+
The truncation is the only place this helper swallows information;
|
|
54
|
+
downstream recall still works because the original ``text`` is on
|
|
55
|
+
the row, and the full structured payload is also stored verbatim
|
|
56
|
+
in ``tags`` when the caller passes a dict/list.
|
|
57
|
+
"""
|
|
58
|
+
if isinstance(value, str):
|
|
59
|
+
return value if len(value) <= max_len else value[: max_len - 1] + "\u2026"
|
|
60
|
+
if isinstance(value, (dict, list)):
|
|
61
|
+
s = json.dumps(value, ensure_ascii=False, default=str)
|
|
62
|
+
return s if len(s) <= max_len else s[: max_len - 1] + "\u2026"
|
|
63
|
+
if isinstance(value, bytes):
|
|
64
|
+
return value[:64].hex() + (f" (len={len(value)})" if len(value) > 64 else "")
|
|
65
|
+
return repr(value)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _first_string_arg(
|
|
69
|
+
args: tuple, kwargs: dict, signature: inspect.Signature
|
|
70
|
+
) -> str | None:
|
|
71
|
+
"""Return the first positional str argument from the call.
|
|
72
|
+
|
|
73
|
+
Falls back to ``None`` when the call carries no string-y input
|
|
74
|
+
(e.g. ``@agent_memory`` wrapping an internal ``tick()`` that
|
|
75
|
+
takes only objects).
|
|
76
|
+
"""
|
|
77
|
+
if args:
|
|
78
|
+
first = args[0]
|
|
79
|
+
if isinstance(first, str) and first.strip():
|
|
80
|
+
return first
|
|
81
|
+
return None
|
|
82
|
+
for name, p in signature.parameters.items():
|
|
83
|
+
if name in kwargs:
|
|
84
|
+
v = kwargs[name]
|
|
85
|
+
if isinstance(v, str) and v.strip():
|
|
86
|
+
return v
|
|
87
|
+
return None
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def agent_memory(
|
|
92
|
+
store: MemoryStore,
|
|
93
|
+
*,
|
|
94
|
+
agent_id: str | None = None,
|
|
95
|
+
user_id: str | None = None,
|
|
96
|
+
kind: str = "interaction",
|
|
97
|
+
limit: int = 5,
|
|
98
|
+
recall_kwargs_name: str = "memory_context",
|
|
99
|
+
) -> Callable[[Callable[P, R]], Callable[P, R]]:
|
|
100
|
+
"""Wrap ``fn`` so every call reads + writes persistent memory.
|
|
101
|
+
|
|
102
|
+
Parameters
|
|
103
|
+
----------
|
|
104
|
+
store:
|
|
105
|
+
An open :class:`~loop_memory.storage.sqlite_store.MemoryStore`.
|
|
106
|
+
Required -- the decorator has nowhere to put memory otherwise.
|
|
107
|
+
agent_id, user_id:
|
|
108
|
+
Optional identity columns. When ``None`` the wrapped call
|
|
109
|
+
pushes a memory that is global to the store (the same scope
|
|
110
|
+
that pre-0.4.15 callers used).
|
|
111
|
+
kind:
|
|
112
|
+
Memory row kind. Default ``"interaction"`` matches the
|
|
113
|
+
dashboard's existing tag.
|
|
114
|
+
limit:
|
|
115
|
+
Number of top-k memories to recall *before* the body runs.
|
|
116
|
+
Capped at 50 to keep the prompt tiny.
|
|
117
|
+
recall_kwargs_name:
|
|
118
|
+
Name of the optional kwarg the wrapped function can declare to
|
|
119
|
+
receive the recall as a string. Defaults to ``"memory_context"``.
|
|
120
|
+
If the function does NOT declare that kwarg the recall is
|
|
121
|
+
silently dropped (the body still runs) but the row is still
|
|
122
|
+
stored after the call returns.
|
|
123
|
+
|
|
124
|
+
Example
|
|
125
|
+
-------
|
|
126
|
+
>>> store = MemoryStore("loop.db")
|
|
127
|
+
>>> @agent_memory(store, agent_id="codex")
|
|
128
|
+
... def ask(question: str, memory_context: str = "") -> str:
|
|
129
|
+
... # memory_context arrives pre-formatted from the recall
|
|
130
|
+
... return f"{question} -> {memory_context[:40]}"
|
|
131
|
+
>>> ask("What is the deploy process?")
|
|
132
|
+
"""
|
|
133
|
+
if not isinstance(store, MemoryStore):
|
|
134
|
+
raise TypeError(
|
|
135
|
+
f"@agent_memory requires a MemoryStore, got {type(store).__name__}"
|
|
136
|
+
)
|
|
137
|
+
if limit <= 0 or limit > 50:
|
|
138
|
+
raise ValueError(f"limit must be in [1, 50], got {limit}")
|
|
139
|
+
|
|
140
|
+
def _decorator(fn: Callable[P, R]) -> Callable[P, R]:
|
|
141
|
+
sig = inspect.signature(fn)
|
|
142
|
+
_ = sig # pre-bound for _first_string_arg
|
|
143
|
+
recall_kw = recall_kwargs_name
|
|
144
|
+
|
|
145
|
+
@functools.wraps(fn)
|
|
146
|
+
def _wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
147
|
+
query = _first_string_arg(args, kwargs, sig)
|
|
148
|
+
# --- pre-call: recall ---------------------------------
|
|
149
|
+
recall_hits: list[dict] = []
|
|
150
|
+
context_text = ""
|
|
151
|
+
if query:
|
|
152
|
+
try:
|
|
153
|
+
res = store.recall_hybrid(
|
|
154
|
+
query, limit=limit, as_of_ts=None,
|
|
155
|
+
)
|
|
156
|
+
recall_hits = list(res.get("memories", []))
|
|
157
|
+
except Exception:
|
|
158
|
+
recall_hits = []
|
|
159
|
+
if recall_hits:
|
|
160
|
+
lines = []
|
|
161
|
+
for h in recall_hits:
|
|
162
|
+
prev = (h.get("text") or h.get("preview") or "").strip()
|
|
163
|
+
if prev:
|
|
164
|
+
lines.append(f"- {prev[:240]}")
|
|
165
|
+
context_text = "\n".join(lines)
|
|
166
|
+
|
|
167
|
+
# Inject memory_context only when the function actually
|
|
168
|
+
# declares the kwarg -- otherwise the decorator stays
|
|
169
|
+
# invisible to legacy callables that don't know about it.
|
|
170
|
+
injected = False
|
|
171
|
+
if recall_kw in sig.parameters and context_text:
|
|
172
|
+
kwargs = {**kwargs, recall_kw: context_text}
|
|
173
|
+
injected = True
|
|
174
|
+
|
|
175
|
+
# --- body ----------------------------------------------
|
|
176
|
+
result = fn(*args, **kwargs)
|
|
177
|
+
|
|
178
|
+
# --- post-call: store ----------------------------------
|
|
179
|
+
if query:
|
|
180
|
+
try:
|
|
181
|
+
text = _stringify(result)
|
|
182
|
+
tags = ["agent_memory"]
|
|
183
|
+
if isinstance(result, (dict, list)):
|
|
184
|
+
tags.append("structured")
|
|
185
|
+
store.upsert_memory(
|
|
186
|
+
kind=kind,
|
|
187
|
+
text=text,
|
|
188
|
+
importance=0.6,
|
|
189
|
+
agent_id=agent_id,
|
|
190
|
+
user_id=user_id,
|
|
191
|
+
tags=tags,
|
|
192
|
+
source=f"agent_memory:{fn.__name__}",
|
|
193
|
+
)
|
|
194
|
+
except Exception:
|
|
195
|
+
# Never let a store failure break the wrapped call.
|
|
196
|
+
pass
|
|
197
|
+
|
|
198
|
+
# Attach the recall to a dict result so callers can
|
|
199
|
+
# surface "I knew this because ..." in the UI. Plain
|
|
200
|
+
# string/int/float returns stay untouched.
|
|
201
|
+
if isinstance(result, dict) and recall_hits and not injected:
|
|
202
|
+
result.setdefault("memories", recall_hits)
|
|
203
|
+
return result
|
|
204
|
+
|
|
205
|
+
# Friendly metadata on the wrapper so ``help()`` is useful.
|
|
206
|
+
_wrapper.__wrapped_agent_memory__ = True # type: ignore[attr-defined]
|
|
207
|
+
return _wrapper
|
|
208
|
+
|
|
209
|
+
return _decorator
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def remember(
|
|
213
|
+
store: MemoryStore,
|
|
214
|
+
text: str,
|
|
215
|
+
*,
|
|
216
|
+
kind: str = "fact",
|
|
217
|
+
importance: float = 0.7,
|
|
218
|
+
tags: list[str] | None = None,
|
|
219
|
+
agent_id: str | None = None,
|
|
220
|
+
user_id: str | None = None,
|
|
221
|
+
) -> str:
|
|
222
|
+
"""Insert ``text`` into the store as a single memory and return its id.
|
|
223
|
+
|
|
224
|
+
Sugar for callers that want to seed memory at import time without
|
|
225
|
+
having to instantiate ``StoredMemory`` / ``upsert_memory``:
|
|
226
|
+
|
|
227
|
+
>>> store = MemoryStore("loop.db")
|
|
228
|
+
>>> remember(store, "User prefers dark mode",
|
|
229
|
+
... tags=["ui", "preference"], agent_id="codex")
|
|
230
|
+
"""
|
|
231
|
+
if not isinstance(store, MemoryStore):
|
|
232
|
+
raise TypeError(
|
|
233
|
+
f"remember() requires a MemoryStore, got {type(store).__name__}"
|
|
234
|
+
)
|
|
235
|
+
item = store.upsert_memory(
|
|
236
|
+
kind=kind, text=text, importance=importance,
|
|
237
|
+
tags=tags or ["remember"], agent_id=agent_id, user_id=user_id,
|
|
238
|
+
source="remember",
|
|
239
|
+
)
|
|
240
|
+
return item.id
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
__all__ = ["agent_memory", "remember"]
|