loop-memory 0.4.2__tar.gz → 0.4.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. {loop_memory-0.4.2 → loop_memory-0.4.3}/PKG-INFO +28 -1
  2. {loop_memory-0.4.2 → loop_memory-0.4.3}/README.md +27 -0
  3. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/jobs/cognitive.py +153 -19
  4. loop_memory-0.4.3/loop_memory/llm/openai_adapter.py +64 -0
  5. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/llm/providers.py +74 -6
  6. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/sdk_extensions.py +7 -0
  7. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/serve/routes/cognitive.py +7 -0
  8. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/storage/sqlite_store.py +35 -1
  9. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory.egg-info/PKG-INFO +28 -1
  10. {loop_memory-0.4.2 → loop_memory-0.4.3}/pyproject.toml +1 -1
  11. {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_graph.py +52 -0
  12. {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_llm_providers.py +111 -0
  13. {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_universal_memory.py +210 -0
  14. loop_memory-0.4.2/loop_memory/llm/openai_adapter.py +0 -31
  15. {loop_memory-0.4.2 → loop_memory-0.4.3}/LICENSE +0 -0
  16. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/__init__.py +0 -0
  17. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/backends/__init__.py +0 -0
  18. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/backends/embedding.py +0 -0
  19. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/backends/sentence_embedder.py +0 -0
  20. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/backends/vector_store.py +0 -0
  21. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/cli/__init__.py +0 -0
  22. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/cli/_common.py +0 -0
  23. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/cli/commands/__init__.py +0 -0
  24. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/cli/commands/cognitive.py +0 -0
  25. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/cli/commands/diag.py +0 -0
  26. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/cli/commands/graph.py +0 -0
  27. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/cli/commands/hooks.py +0 -0
  28. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/cli/commands/read.py +0 -0
  29. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/cli/commands/serve.py +0 -0
  30. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/cli/commands/write.py +0 -0
  31. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/cli/main.py +0 -0
  32. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/engine/__init__.py +0 -0
  33. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/engine/loop.py +0 -0
  34. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/engine/reflect.py +0 -0
  35. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/examples/__init__.py +0 -0
  36. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/examples/demo.py +0 -0
  37. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/export/__init__.py +0 -0
  38. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/export/memory_md.py +0 -0
  39. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/graph/__init__.py +0 -0
  40. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/graph/build.py +0 -0
  41. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/graph/extract.py +0 -0
  42. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/ingest/__init__.py +0 -0
  43. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/ingest/loader.py +0 -0
  44. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/ingest/pipeline.py +0 -0
  45. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/jobs/__init__.py +0 -0
  46. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/jobs/compact.py +0 -0
  47. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/jobs/consolidate.py +0 -0
  48. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/jobs/contradiction.py +0 -0
  49. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/jobs/evolution.py +0 -0
  50. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/jobs/graph.py +0 -0
  51. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/jobs/llm_compact_pass.py +0 -0
  52. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/jobs/llm_consolidate.py +0 -0
  53. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/jobs/scheduler.py +0 -0
  54. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/llm/__init__.py +0 -0
  55. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/llm/base.py +0 -0
  56. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/mcp/__init__.py +0 -0
  57. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/memory/__init__.py +0 -0
  58. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/memory/types.py +0 -0
  59. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/privacy/__init__.py +0 -0
  60. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/privacy/private.py +0 -0
  61. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/privacy/redact.py +0 -0
  62. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/py.typed +0 -0
  63. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/sdk.py +0 -0
  64. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/security/__init__.py +0 -0
  65. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/security/secrets.py +0 -0
  66. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/serve/__init__.py +0 -0
  67. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/serve/app.py +0 -0
  68. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/serve/handlers.py +0 -0
  69. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/serve/routes/_shared.py +0 -0
  70. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/serve/routes/admin.py +0 -0
  71. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/serve/routes/export.py +0 -0
  72. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/serve/routes/graph.py +0 -0
  73. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/serve/routes/insights.py +0 -0
  74. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/serve/routes/memories.py +0 -0
  75. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/serve/routes/sessions.py +0 -0
  76. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/serve/routes/system.py +0 -0
  77. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/serve/routes/wiki.py +0 -0
  78. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/serve/static/__init__.py +0 -0
  79. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/serve/static/index.html +0 -0
  80. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/serve/watcher.py +0 -0
  81. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/storage/__init__.py +0 -0
  82. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/storage/retrieval.py +0 -0
  83. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/wiki/__init__.py +0 -0
  84. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/wiki/backfill.py +0 -0
  85. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/wiki/classifier.py +0 -0
  86. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/wiki/prompts.py +0 -0
  87. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/wiki/scope.py +0 -0
  88. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory.egg-info/SOURCES.txt +0 -0
  89. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory.egg-info/dependency_links.txt +0 -0
  90. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory.egg-info/entry_points.txt +0 -0
  91. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory.egg-info/requires.txt +0 -0
  92. {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory.egg-info/top_level.txt +0 -0
  93. {loop_memory-0.4.2 → loop_memory-0.4.3}/setup.cfg +0 -0
  94. {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_admin_ingest_route.py +0 -0
  95. {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_agent_memory_api.py +0 -0
  96. {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_agent_memory_sdk.py +0 -0
  97. {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_auth_token_rotate.py +0 -0
  98. {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_cli_v7.py +0 -0
  99. {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_cli_version.py +0 -0
  100. {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_contradictions.py +0 -0
  101. {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_evolution.py +0 -0
  102. {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_evolution_quality.py +0 -0
  103. {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_expanduser.py +0 -0
  104. {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_export_ask.py +0 -0
  105. {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_ingest.py +0 -0
  106. {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_llm_consolidator.py +0 -0
  107. {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_llm_fingerprint_not_persisted.py +0 -0
  108. {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_llm_test_endpoint.py +0 -0
  109. {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_loop.py +0 -0
  110. {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_mcp.py +0 -0
  111. {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_memories_pagination.py +0 -0
  112. {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_openclaw_loader.py +0 -0
  113. {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_reflection.py +0 -0
  114. {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_repo_framing.py +0 -0
  115. {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_score_api.py +0 -0
  116. {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_scoring_v2.py +0 -0
  117. {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_secrets.py +0 -0
  118. {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_serve_app.py +0 -0
  119. {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_serve_handlers.py +0 -0
  120. {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_session_order.py +0 -0
  121. {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_store.py +0 -0
  122. {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_summarization.py +0 -0
  123. {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_vector_store.py +0 -0
  124. {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_watcher.py +0 -0
  125. {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_wiki_classifier.py +0 -0
  126. {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_wiki_export_escape.py +0 -0
  127. {loop_memory-0.4.2 → loop_memory-0.4.3}/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.2
3
+ Version: 0.4.3
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
@@ -402,6 +402,15 @@ pass** over the store:
402
402
 
403
403
  Dry-run by default; pass `--apply` to commit.
404
404
 
405
+ **Observability (since 0.4.3).** The report carries per-stage
406
+ timings (``scan``, ``stale``, ``merge``, ``contradict``, ``apply``,
407
+ ``audit``) and an explicit ``aborted`` flag with an ``abort_reason``
408
+ that names the stage the budget fired in. Pass
409
+ ``--deadline-seconds <N>`` (HTTP: ``POST /api/v1/cognitive/sleep``
410
+ with ``{"deadline_seconds": N}``; SDK: ``client.cognitive_sleep(deadline_seconds=N)``)
411
+ to bound the sweep — useful for nightly cron, where a stuck
412
+ sweep should leave a loud trace instead of a silent spinner.
413
+
405
414
  ### Knowledge graph
406
415
 
407
416
  `loop-memory graph-rebuild` extracts entities from every distilled
@@ -632,6 +641,24 @@ classifier is local and makes no model or network request on a wiki write.
632
641
 
633
642
  ---
634
643
 
644
+ ### LLM env-var overrides
645
+
646
+ The OpenAI-compat / Anthropic / Ollama providers (and the optional
647
+ ``openai`` adapter) honour two env-var knobs so you can pin
648
+ distillation deterministically without touching the behaviour
649
+ config:
650
+
651
+ - ``LLM_TEMPERATURE`` — float, defaults to ``0.3`` (or the explicit
652
+ ``kwargs.temperature``). Invalid values are ignored with a warning.
653
+ - ``LLM_SEED`` — int, sent as ``seed`` for OpenAI / Anthropic /
654
+ Ollama where supported. Omitting it preserves the existing
655
+ "no seed" behaviour so older call sites do not need to migrate.
656
+
657
+ Both env vars are read at every ``complete()`` call, so a single
658
+ ``export LLM_SEED=42`` plus a nightly cron makes wiki distillation
659
+ reproducible. Pinned by 11 cases in
660
+ ``tests/test_llm_providers.py::LLMEnvVarTests``.
661
+
635
662
  ## FAQ & troubleshooting
636
663
 
637
664
  **Q: `pip install loop-memory` succeeds but `loop-memory serve` says `ModuleNotFoundError: No module named 'fastapi'`.**
@@ -358,6 +358,15 @@ pass** over the store:
358
358
 
359
359
  Dry-run by default; pass `--apply` to commit.
360
360
 
361
+ **Observability (since 0.4.3).** The report carries per-stage
362
+ timings (``scan``, ``stale``, ``merge``, ``contradict``, ``apply``,
363
+ ``audit``) and an explicit ``aborted`` flag with an ``abort_reason``
364
+ that names the stage the budget fired in. Pass
365
+ ``--deadline-seconds <N>`` (HTTP: ``POST /api/v1/cognitive/sleep``
366
+ with ``{"deadline_seconds": N}``; SDK: ``client.cognitive_sleep(deadline_seconds=N)``)
367
+ to bound the sweep — useful for nightly cron, where a stuck
368
+ sweep should leave a loud trace instead of a silent spinner.
369
+
361
370
  ### Knowledge graph
362
371
 
363
372
  `loop-memory graph-rebuild` extracts entities from every distilled
@@ -588,6 +597,24 @@ classifier is local and makes no model or network request on a wiki write.
588
597
 
589
598
  ---
590
599
 
600
+ ### LLM env-var overrides
601
+
602
+ The OpenAI-compat / Anthropic / Ollama providers (and the optional
603
+ ``openai`` adapter) honour two env-var knobs so you can pin
604
+ distillation deterministically without touching the behaviour
605
+ config:
606
+
607
+ - ``LLM_TEMPERATURE`` — float, defaults to ``0.3`` (or the explicit
608
+ ``kwargs.temperature``). Invalid values are ignored with a warning.
609
+ - ``LLM_SEED`` — int, sent as ``seed`` for OpenAI / Anthropic /
610
+ Ollama where supported. Omitting it preserves the existing
611
+ "no seed" behaviour so older call sites do not need to migrate.
612
+
613
+ Both env vars are read at every ``complete()`` call, so a single
614
+ ``export LLM_SEED=42`` plus a nightly cron makes wiki distillation
615
+ reproducible. Pinned by 11 cases in
616
+ ``tests/test_llm_providers.py::LLMEnvVarTests``.
617
+
591
618
  ## FAQ & troubleshooting
592
619
 
593
620
  **Q: `pip install loop-memory` succeeds but `loop-memory serve` says `ModuleNotFoundError: No module named 'fastapi'`.**
@@ -34,7 +34,7 @@ import math
34
34
  import time
35
35
  from collections.abc import Iterable
36
36
  from dataclasses import dataclass, field
37
- from typing import Any
37
+ from typing import Any, Callable
38
38
 
39
39
  from ..storage.sqlite_store import MemoryStore
40
40
 
@@ -105,6 +105,14 @@ class CognitiveReport:
105
105
  elapsed_ms: float = 0.0
106
106
  counts: dict[str, int] = field(default_factory=dict)
107
107
  applied: bool = False
108
+ # Audit 2026-08-16: per-stage timings so a stalled sweep can be
109
+ # attributed to a specific stage instead of returning a single
110
+ # opaque number. Mirrors the "fails loudly instead of quietly"
111
+ # convention from ``EverMind-AI/EverOS`` v1.2.3 where any stall
112
+ # names the table / phase it happened in.
113
+ stages: dict[str, float] = field(default_factory=dict)
114
+ aborted: bool = False
115
+ abort_reason: str = ""
108
116
 
109
117
  def to_dict(self) -> dict[str, Any]:
110
118
  return {
@@ -113,6 +121,9 @@ class CognitiveReport:
113
121
  "counts": self.counts,
114
122
  "applied": self.applied,
115
123
  "total": len(self.actions),
124
+ "stages": dict(self.stages),
125
+ "aborted": self.aborted,
126
+ "abort_reason": self.abort_reason,
116
127
  }
117
128
 
118
129
 
@@ -132,6 +143,8 @@ def cognitive_sleep(
132
143
  merge_threshold: float = DEFAULT_MERGE_THRESHOLD,
133
144
  limit: int = 1000,
134
145
  record_audit: bool = True,
146
+ progress: Callable[[str], None] | None = None,
147
+ deadline_seconds: float | None = None,
135
148
  ) -> CognitiveReport:
136
149
  """Run a single cognitive sweep.
137
150
 
@@ -148,19 +161,80 @@ def cognitive_sleep(
148
161
  The sweep is bounded to ``limit`` memories per pass to keep it
149
162
  cheap; for very large stores the user can call it multiple
150
163
  times or wire it into a cron.
164
+
165
+ Audit 2026-08-16 -- observability additions
166
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
167
+ * ``progress``: optional ``Callable[[str], None]`` invoked once per
168
+ stage so the UI can render a live progress bar without polling.
169
+ Stage names are ``scan``, ``stale``, ``low_value``, ``merge``,
170
+ ``contradict``, ``apply``, ``audit``.
171
+ * ``deadline_seconds``: when set, the sweep checks the deadline
172
+ between the O(n^2) near-duplicate pairs and between
173
+ individual stages; if it elapses, the sweep stops with
174
+ ``report.aborted=True`` and ``report.abort_reason`` naming
175
+ the stage it stalled in. Following the
176
+ ``EverMind-AI/EverOS`` v1.2.3 pattern: stalls must name the
177
+ stage so the user can act on them, not return a silent
178
+ "still working…" spinner. ``report.stages`` carries the
179
+ per-stage elapsed_ms for the same reason.
151
180
  """
152
181
  t0 = time.time()
153
182
  actions: list[CognitiveAction] = []
154
183
  counts: dict[str, int] = {
155
184
  "stale": 0, "low_value": 0, "merge": 0, "contradict": 0, "forget": 0,
156
185
  }
186
+ stages: dict[str, float] = {}
187
+ aborted = False
188
+ abort_reason = ""
157
189
  now = time.time()
190
+ # Explicit ``is not None`` check guards against the
191
+ # ``0.0 == falsy`` pitfall (the same bug class Mem0 v2.0.18
192
+ # fixed for Oracle ``index_accuracy=0``): a caller passing
193
+ # ``deadline_seconds=0.0`` to mean "fail-fast / never run"
194
+ # would otherwise be silently downgraded to ``deadline_at=None``
195
+ # and the sweep would happily run past the deadline.
196
+ deadline_at: float | None = (
197
+ now + float(deadline_seconds)
198
+ if deadline_seconds is not None else None
199
+ )
200
+
201
+ def _deadline_left() -> float:
202
+ if deadline_at is None:
203
+ return float("inf")
204
+ return max(0.0, deadline_at - time.time())
205
+
206
+ def _tick(stage: str, stage_t0: float) -> None:
207
+ nonlocal aborted, abort_reason
208
+ stages[stage] = round((time.time() - stage_t0) * 1000, 1)
209
+ if progress is not None:
210
+ try:
211
+ progress(stage)
212
+ except Exception: # pragma: no cover - progress is best-effort
213
+ log.warning("cognitive_sleep progress(%r) raised; ignoring", stage)
214
+ if deadline_at is not None and time.time() >= deadline_at:
215
+ aborted = True
216
+ abort_reason = f"deadline exceeded in stage {stage!r} ({stages[stage]} ms)"
217
+
158
218
  stale_cutoff = now - stale_days * 86400.0
159
219
 
160
220
  # ----- 1. Stale memories --------------------------------------
161
221
  # Pull every memory below the score + importance gates. We do a
162
222
  # single SQL scan to keep the pass fast.
223
+ scan_t0 = time.time()
163
224
  rows = store.list_memories(limit=limit)
225
+ _tick("scan", scan_t0)
226
+ if aborted:
227
+ return _finalize_report(actions, counts, stages, aborted, abort_reason,
228
+ t0, apply, record_audit, store)
229
+
230
+ # Stages 1+2: stale gate + low-value gate share one pass over
231
+ # ``rows`` because both need (score, importance, created_at).
232
+ sl_t0 = time.time()
233
+ # Audit 2026-08-16: bulk-fetch signals in one query instead of
234
+ # one SELECT per memory -- the nightly sweep previously did
235
+ # ``limit`` round-trips just to read ``recall_count`` for the
236
+ # low-value gate.
237
+ signals_by_id = store.get_signals([r.id for r in rows]) if rows else {}
164
238
  for r in rows:
165
239
  score = float(r.score or 0)
166
240
  importance = float(r.importance or 0)
@@ -178,10 +252,9 @@ def cognitive_sleep(
178
252
  continue
179
253
  # Low value: never recalled, score + importance * 0.5 below
180
254
  # ``low_value`` (this is the cheap "noise from a long
181
- # transcript" filter).
182
- # ``list_memories`` doesn't include recall_count in the
183
- # dataclass; re-fetch via the signals table.
184
- signals = _signals_for(store, r.id)
255
+ # transcript" filter). Signals are pre-fetched in bulk above
256
+ # so this is now a dict lookup, not a per-row SQL hit.
257
+ signals = signals_by_id.get(r.id, {"recall_count": 0, "positive": 0, "negative": 0})
185
258
  if signals["recall_count"] == 0 and score + 0.5 * importance < low_value:
186
259
  counts["low_value"] += 1
187
260
  actions.append(CognitiveAction(
@@ -190,16 +263,28 @@ def cognitive_sleep(
190
263
  reason=f"never recalled & score+0.5*importance<{low_value}",
191
264
  score=score, payload={"importance": importance},
192
265
  ))
266
+ _tick("stale", sl_t0)
267
+ if aborted:
268
+ return _finalize_report(actions, counts, stages, aborted, abort_reason,
269
+ t0, apply, record_audit, store)
193
270
 
194
271
  # ----- 2. Near-duplicate merges ------------------------------
195
272
  # Cheap O(n^2) on the first ``limit`` memories; good enough for
196
273
  # nightly sweeps on a store of a few thousand rows. We use the
197
274
  # ``text`` Jaccard over a small token set so the comparison
198
275
  # doesn't need embeddings.
276
+ merge_t0 = time.time()
199
277
  text_index = [(r.id, _token_set(r.text or "")) for r in rows]
200
278
  seen_pairs: set[tuple[str, str]] = set()
201
279
  for i in range(len(text_index)):
202
280
  for j in range(i + 1, len(text_index)):
281
+ # Check the deadline every 256 pairs so the cost is
282
+ # amortised away on large sweeps but a runaway near-
283
+ # duplicate loop can't escape the budget.
284
+ if deadline_at is not None and (j & 0xFF) == 0 and _deadline_left() <= 0:
285
+ aborted = True
286
+ abort_reason = f"deadline exceeded during near-duplicate scan (i={i}, j={j})"
287
+ break
203
288
  mid_i, ti = text_index[i]
204
289
  mid_j, tj = text_index[j]
205
290
  if not ti or not tj:
@@ -225,12 +310,19 @@ def cognitive_sleep(
225
310
  reason=f"Jaccard={j_sim:.3f} ≥ {merge_threshold}",
226
311
  score=j_sim, payload={"other_id": mid_j, "jaccard": j_sim},
227
312
  ))
313
+ if aborted:
314
+ break
315
+ _tick("merge", merge_t0)
316
+ if aborted:
317
+ return _finalize_report(actions, counts, stages, aborted, abort_reason,
318
+ t0, apply, record_audit, store)
228
319
 
229
320
  # ----- 3. Contradictions -------------------------------------
230
321
  # Reuse the existing wiki-page contradiction detector. It's
231
322
  # cheap (key_facts Jaccard, no LLM) and already returns the
232
323
  # matches we need.
233
324
  from .contradiction import list_contradictions
325
+ contr_t0 = time.time()
234
326
  try:
235
327
  contradictions = list_contradictions(store)
236
328
  except Exception as e:
@@ -252,8 +344,13 @@ def cognitive_sleep(
252
344
  "partner_title": partner.get("title"),
253
345
  },
254
346
  ))
347
+ _tick("contradict", contr_t0)
348
+ if aborted:
349
+ return _finalize_report(actions, counts, stages, aborted, abort_reason,
350
+ t0, apply, record_audit, store)
255
351
 
256
352
  # ----- 4. Apply (optional) -----------------------------------
353
+ apply_t0 = time.time()
257
354
  if apply:
258
355
  applied_actions: list[CognitiveAction] = []
259
356
  for a in actions:
@@ -277,8 +374,10 @@ def cognitive_sleep(
277
374
  # both". But we still mark the action as suggested
278
375
  # so the audit trail is complete.
279
376
  continue
377
+ _tick("apply", apply_t0)
280
378
 
281
379
  # ----- 5. Persist to cognitive_audit -------------------------
380
+ audit_t0 = time.time()
282
381
  if record_audit:
283
382
  for a in actions:
284
383
  store.record_audit(
@@ -292,12 +391,61 @@ def cognitive_sleep(
292
391
  payload=a.payload,
293
392
  )
294
393
 
394
+ _tick("audit", audit_t0)
395
+ elapsed_ms = (time.time() - t0) * 1000
396
+ return CognitiveReport(
397
+ actions=actions,
398
+ elapsed_ms=round(elapsed_ms, 1),
399
+ counts=counts,
400
+ applied=bool(apply),
401
+ stages=stages,
402
+ aborted=aborted,
403
+ abort_reason=abort_reason,
404
+ )
405
+
406
+
407
+ def _finalize_report(
408
+ actions: list[CognitiveAction],
409
+ counts: dict[str, int],
410
+ stages: dict[str, float],
411
+ aborted: bool,
412
+ abort_reason: str,
413
+ t0: float,
414
+ apply: bool,
415
+ record_audit: bool,
416
+ store: MemoryStore,
417
+ ) -> CognitiveReport:
418
+ """Build an early-return report when the deadline fires.
419
+
420
+ The early-return path still runs the audit stage for any actions
421
+ the sweep already collected, so a partial sweep leaves the same
422
+ trace a full sweep would.
423
+ """
424
+ if record_audit and actions:
425
+ for a in actions:
426
+ try:
427
+ store.record_audit(
428
+ kind=a.kind,
429
+ action=a.action,
430
+ target_kind=a.target_kind,
431
+ target_id=a.target_id,
432
+ target_text=a.target_text,
433
+ reason=a.reason,
434
+ score=a.score,
435
+ payload=a.payload,
436
+ )
437
+ except Exception: # pragma: no cover - audit is best-effort
438
+ log.warning("record_audit on early-return failed; skipping")
439
+ stages["audit"] = round((time.time() - t0) * 1000, 1) - sum(stages.values())
295
440
  elapsed_ms = (time.time() - t0) * 1000
296
441
  return CognitiveReport(
297
442
  actions=actions,
298
443
  elapsed_ms=round(elapsed_ms, 1),
299
444
  counts=counts,
300
445
  applied=bool(apply),
446
+ stages=stages,
447
+ aborted=aborted,
448
+ abort_reason=abort_reason,
301
449
  )
302
450
 
303
451
 
@@ -306,20 +454,6 @@ def cognitive_sleep(
306
454
  # ---------------------------------------------------------------------------
307
455
 
308
456
 
309
- def _signals_for(store: MemoryStore, memory_id: str) -> dict[str, Any]:
310
- """Return the signal row for a memory, or zeros if missing."""
311
- with store._conn() as c: # type: ignore[attr-defined]
312
- row = c.execute(
313
- "SELECT recall_count, positive, negative, last_recalled_at "
314
- "FROM memory_signals WHERE memory_id=?",
315
- (memory_id,),
316
- ).fetchone()
317
- if not row:
318
- return {"recall_count": 0, "positive": 0, "negative": 0,
319
- "last_recalled_at": None}
320
- return dict(row)
321
-
322
-
323
457
  def _token_set(text: str) -> set[str]:
324
458
  """Cheap token set: lowercase + split on whitespace + punctuation.
325
459
 
@@ -0,0 +1,64 @@
1
+ """Optional OpenAI adapter.
2
+
3
+ Only imported when the user has the ``openai`` package installed
4
+ (``pip install loop-memory[openai]``). Keeps the core library
5
+ zero-dependency.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+
12
+ from ..llm.base import ChatHistory, LLMClient
13
+
14
+
15
+ def _env_float(name: str, default: float | None) -> float | None:
16
+ raw = os.environ.get(name)
17
+ if raw is None or raw.strip() == "":
18
+ return default
19
+ try:
20
+ return float(raw)
21
+ except ValueError:
22
+ return default
23
+
24
+
25
+ def _env_optional_int(name: str) -> int | None:
26
+ raw = os.environ.get(name)
27
+ if raw is None or raw.strip() == "":
28
+ return None
29
+ try:
30
+ return int(raw)
31
+ except ValueError:
32
+ return None
33
+
34
+
35
+ class OpenAIClient(LLMClient):
36
+ def __init__(self, model: str = "gpt-4o-mini", api_key: str | None = None) -> None:
37
+ try:
38
+ from openai import OpenAI # type: ignore
39
+ except ImportError as e:
40
+ raise RuntimeError("openai is not installed; pip install loop-memory[openai]") from e
41
+ self.model = model
42
+ self._client = OpenAI(api_key=api_key) # type: ignore[arg-type]
43
+
44
+ def complete(self, history: ChatHistory, **kwargs) -> str:
45
+ msgs = [{"role": "system", "content": history.system}] if history.system else []
46
+ msgs += [{"role": m.role, "content": m.content} for m in history.messages]
47
+ temperature = _env_float(
48
+ "LLM_TEMPERATURE",
49
+ float(kwargs.get("temperature", 0.4)),
50
+ )
51
+ create_kwargs = dict(
52
+ model=self.model,
53
+ messages=msgs,
54
+ temperature=float(temperature if temperature is not None else 0.4),
55
+ max_tokens=int(kwargs.get("max_tokens", 600)),
56
+ )
57
+ seed = _env_optional_int("LLM_SEED")
58
+ if seed is None:
59
+ seed_val = kwargs.get("seed")
60
+ seed = int(seed_val) if seed_val is not None else None
61
+ if seed is not None:
62
+ create_kwargs["seed"] = int(seed)
63
+ resp = self._client.chat.completions.create(**create_kwargs)
64
+ return resp.choices[0].message.content or ""
@@ -163,6 +163,41 @@ def _http_post_json(url: str, body: dict, headers: dict, timeout: float) -> dict
163
163
  raise LLMHttpError(e.code, url, err)
164
164
 
165
165
 
166
+ def _env_float(name: str, default: float | None) -> float | None:
167
+ """Read a float from env, returning *default* when unset or invalid.
168
+
169
+ Used to plumb ``LLM_TEMPERATURE`` so a user can pin the consolidator
170
+ temperature globally without touching the behaviour config. Mirrors
171
+ the env-var conventions in ``topoteretes/cognee`` v1.5.0 (PR #4504)
172
+ while keeping the kwargs / behaviour config as the canonical knobs.
173
+ """
174
+ raw = os.environ.get(name)
175
+ if raw is None or raw.strip() == "":
176
+ return default
177
+ try:
178
+ return float(raw)
179
+ except ValueError:
180
+ log.warning("env %s=%r is not a float; ignoring", name, raw)
181
+ return default
182
+
183
+
184
+ def _env_optional_int(name: str) -> int | None:
185
+ """Read an int from env, returning None when unset or invalid.
186
+
187
+ ``LLM_SEED`` is opt-in for providers that support it (OpenAI /
188
+ Anthropic / Ollama all accept ``seed``). Leaving it unset keeps the
189
+ pre-existing "no seed" behaviour so callers do not need to migrate.
190
+ """
191
+ raw = os.environ.get(name)
192
+ if raw is None or raw.strip() == "":
193
+ return None
194
+ try:
195
+ return int(raw)
196
+ except ValueError:
197
+ log.warning("env %s=%r is not an int; ignoring", name, raw)
198
+ return None
199
+
200
+
166
201
  class OpenAICompatProvider(LLMClient):
167
202
  """OpenAI-compatible chat completions client.
168
203
 
@@ -199,12 +234,22 @@ class OpenAICompatProvider(LLMClient):
199
234
  msgs.append({"role": "system", "content": history.system})
200
235
  for m in history.messages:
201
236
  msgs.append({"role": m.role, "content": m.content})
237
+ temperature = _env_float(
238
+ "LLM_TEMPERATURE",
239
+ float(kwargs.get("temperature", 0.3)),
240
+ )
202
241
  body = {
203
242
  "model": self.model,
204
243
  "messages": msgs,
205
- "temperature": float(kwargs.get("temperature", 0.3)),
244
+ "temperature": float(temperature if temperature is not None else 0.3),
206
245
  "max_tokens": int(kwargs.get("max_tokens", 800)),
207
246
  }
247
+ seed = _env_optional_int("LLM_SEED")
248
+ if seed is None:
249
+ seed_val = kwargs.get("seed")
250
+ seed = int(seed_val) if seed_val is not None else None
251
+ if seed is not None:
252
+ body["seed"] = int(seed)
208
253
  url = self.base_url + "/chat/completions"
209
254
  headers = {"Content-Type": "application/json"}
210
255
  if self.api_key:
@@ -236,13 +281,25 @@ class AnthropicProvider(LLMClient):
236
281
  sys_prompt += "\n" + m.content
237
282
  continue
238
283
  msgs.append({"role": m.role, "content": m.content})
284
+ temperature = _env_float(
285
+ "LLM_TEMPERATURE",
286
+ float(kwargs.get("temperature", 0.3)),
287
+ )
239
288
  body = {
240
289
  "model": self.model,
241
290
  "system": sys_prompt or "You are a helpful assistant.",
242
291
  "messages": msgs,
243
292
  "max_tokens": int(kwargs.get("max_tokens", 800)),
244
- "temperature": float(kwargs.get("temperature", 0.3)),
293
+ "temperature": float(temperature if temperature is not None else 0.3),
245
294
  }
295
+ seed = _env_optional_int("LLM_SEED")
296
+ if seed is None:
297
+ seed_val = kwargs.get("seed")
298
+ seed = int(seed_val) if seed_val is not None else None
299
+ if seed is not None:
300
+ # Anthropic only honours an integer seed; non-int env values
301
+ # are dropped at parse time above.
302
+ body["seed"] = int(seed)
246
303
  url = self.base_url + "/v1/messages"
247
304
  headers = {
248
305
  "Content-Type": "application/json",
@@ -274,14 +331,25 @@ class OllamaProvider(LLMClient):
274
331
  msgs.append({"role": "system", "content": history.system})
275
332
  for m in history.messages:
276
333
  msgs.append({"role": m.role, "content": m.content})
334
+ temperature = _env_float(
335
+ "LLM_TEMPERATURE",
336
+ float(kwargs.get("temperature", 0.3)),
337
+ )
338
+ options = {
339
+ "temperature": float(temperature if temperature is not None else 0.3),
340
+ "num_predict": int(kwargs.get("max_tokens", 800)),
341
+ }
342
+ seed = _env_optional_int("LLM_SEED")
343
+ if seed is None:
344
+ seed_val = kwargs.get("seed")
345
+ seed = int(seed_val) if seed_val is not None else None
346
+ if seed is not None:
347
+ options["seed"] = int(seed)
277
348
  body = {
278
349
  "model": self.model,
279
350
  "messages": msgs,
280
351
  "stream": False,
281
- "options": {
282
- "temperature": float(kwargs.get("temperature", 0.3)),
283
- "num_predict": int(kwargs.get("max_tokens", 800)),
284
- },
352
+ "options": options,
285
353
  }
286
354
  url = self.base_url + "/api/chat"
287
355
  data = _http_post_json(url, body, {"Content-Type": "application/json"}, self.timeout)
@@ -124,6 +124,10 @@ class CognitiveReportView:
124
124
  applied: bool = False
125
125
  elapsed_ms: float = 0.0
126
126
  total: int = 0
127
+ # Audit 2026-08-16: per-stage timings + deadline observability.
128
+ stages: dict[str, float] = field(default_factory=dict)
129
+ aborted: bool = False
130
+ abort_reason: str = ""
127
131
 
128
132
  @classmethod
129
133
  def from_dict(cls, d: dict[str, Any]) -> CognitiveReportView:
@@ -133,6 +137,9 @@ class CognitiveReportView:
133
137
  applied=bool(d.get("applied", False)),
134
138
  elapsed_ms=float(d.get("elapsed_ms", 0) or 0),
135
139
  total=int(d.get("total", 0)),
140
+ stages=dict(d.get("stages", {})),
141
+ aborted=bool(d.get("aborted", False)),
142
+ abort_reason=str(d.get("abort_reason", "") or ""),
136
143
  )
137
144
 
138
145
 
@@ -28,6 +28,12 @@ def register(app: FastAPI, store: MemoryStore, scheduler: Optional[Any] = None)
28
28
  @app.post("/api/v1/cognitive/sleep")
29
29
  def v1_cognitive_sleep(body: dict):
30
30
  from ...jobs.cognitive import cognitive_sleep
31
+ # ``deadline_seconds`` is optional. ``None`` / missing means
32
+ # no deadline; an explicit ``0`` is honoured (the
33
+ # truthiness-vs-``is not None`` pitfall that bit
34
+ # ``mem0ai/mem0`` Oracle ``index_accuracy=0`` in v2.0.18
35
+ # is caught in ``cognitive_sleep`` itself).
36
+ deadline = body.get("deadline_seconds")
31
37
  rpt = cognitive_sleep(
32
38
  store,
33
39
  apply=bool(body.get("apply", False)),
@@ -38,6 +44,7 @@ def register(app: FastAPI, store: MemoryStore, scheduler: Optional[Any] = None)
38
44
  merge_threshold=float(body.get("merge_threshold", 0.92)),
39
45
  limit=int(body.get("limit", 1000)),
40
46
  record_audit=bool(body.get("record_audit", True)),
47
+ deadline_seconds=float(deadline) if deadline is not None else None,
41
48
  )
42
49
  return rpt.to_dict()
43
50
 
@@ -32,7 +32,7 @@ import uuid
32
32
  # dependency on .retrieval during package import; the helpers used by
33
33
  # _hydrate_* are imported here for the same reason.
34
34
  from .retrieval import temporal_score # noqa: E402
35
- from collections.abc import Iterator
35
+ from collections.abc import Iterable, Iterator
36
36
  from contextlib import contextmanager
37
37
  from dataclasses import dataclass
38
38
  from pathlib import Path
@@ -826,6 +826,40 @@ class MemoryStore:
826
826
  "last_recalled_at": row["last_recalled_at"],
827
827
  "last_feedback_at": row["last_feedback_at"],
828
828
  }
829
+ def get_signals(self, memory_ids):
830
+ """Bulk fetch signals for many memory ids in a single query.
831
+
832
+ Returns a dict keyed by memory_id. Memories with no signal row
833
+ resolve to the same zero-shape dict as `get_signal`.
834
+ Used by the cognitive-sweep loop, where iterating per-id caused
835
+ an N+1 SQL hit on nightly runs over large stores.
836
+ """
837
+ ids = [str(x) for x in memory_ids if x]
838
+ empty = {
839
+ "recall_count": 0, "positive": 0, "negative": 0,
840
+ "last_recalled_at": None, "last_feedback_at": None,
841
+ }
842
+ out = {mid: dict(empty) for mid in ids}
843
+ if not ids:
844
+ return out
845
+ placeholders = ",".join("?" for _ in ids)
846
+ with self._conn() as c:
847
+ rows = c.execute(
848
+ f"SELECT memory_id, recall_count, positive, negative,"
849
+ f" last_recalled_at, last_feedback_at"
850
+ f" FROM memory_signals WHERE memory_id IN ({placeholders})",
851
+ tuple(ids),
852
+ ).fetchall()
853
+ for row in rows:
854
+ mid = row["memory_id"]
855
+ out[mid] = {
856
+ "recall_count": row["recall_count"] or 0,
857
+ "positive": row["positive"] or 0,
858
+ "negative": row["negative"] or 0,
859
+ "last_recalled_at": row["last_recalled_at"],
860
+ "last_feedback_at": row["last_feedback_at"],
861
+ }
862
+ return out
829
863
 
830
864
  def top_signals(self, kind: str = "recall_count", limit: int = 20) -> list[Dict[str, Any]]:
831
865
  """Top-N memories by a signal column (recall_count / positive / negative)."""