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.
- {loop_memory-0.4.2 → loop_memory-0.4.3}/PKG-INFO +28 -1
- {loop_memory-0.4.2 → loop_memory-0.4.3}/README.md +27 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/jobs/cognitive.py +153 -19
- loop_memory-0.4.3/loop_memory/llm/openai_adapter.py +64 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/llm/providers.py +74 -6
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/sdk_extensions.py +7 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/serve/routes/cognitive.py +7 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/storage/sqlite_store.py +35 -1
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory.egg-info/PKG-INFO +28 -1
- {loop_memory-0.4.2 → loop_memory-0.4.3}/pyproject.toml +1 -1
- {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_graph.py +52 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_llm_providers.py +111 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_universal_memory.py +210 -0
- loop_memory-0.4.2/loop_memory/llm/openai_adapter.py +0 -31
- {loop_memory-0.4.2 → loop_memory-0.4.3}/LICENSE +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/__init__.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/backends/__init__.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/backends/embedding.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/backends/sentence_embedder.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/backends/vector_store.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/cli/__init__.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/cli/_common.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/cli/commands/__init__.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/cli/commands/cognitive.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/cli/commands/diag.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/cli/commands/graph.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/cli/commands/hooks.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/cli/commands/read.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/cli/commands/serve.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/cli/commands/write.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/cli/main.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/engine/__init__.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/engine/loop.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/engine/reflect.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/examples/__init__.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/examples/demo.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/export/__init__.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/export/memory_md.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/graph/__init__.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/graph/build.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/graph/extract.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/ingest/__init__.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/ingest/loader.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/ingest/pipeline.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/jobs/__init__.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/jobs/compact.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/jobs/consolidate.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/jobs/contradiction.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/jobs/evolution.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/jobs/graph.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/jobs/llm_compact_pass.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/jobs/llm_consolidate.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/jobs/scheduler.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/llm/__init__.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/llm/base.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/mcp/__init__.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/memory/__init__.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/memory/types.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/privacy/__init__.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/privacy/private.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/privacy/redact.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/py.typed +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/sdk.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/security/__init__.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/security/secrets.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/serve/__init__.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/serve/app.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/serve/handlers.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/serve/routes/_shared.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/serve/routes/admin.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/serve/routes/export.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/serve/routes/graph.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/serve/routes/insights.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/serve/routes/memories.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/serve/routes/sessions.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/serve/routes/system.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/serve/routes/wiki.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/serve/static/__init__.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/serve/static/index.html +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/serve/watcher.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/storage/__init__.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/storage/retrieval.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/wiki/__init__.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/wiki/backfill.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/wiki/classifier.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/wiki/prompts.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory/wiki/scope.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory.egg-info/SOURCES.txt +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory.egg-info/dependency_links.txt +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory.egg-info/entry_points.txt +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory.egg-info/requires.txt +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/loop_memory.egg-info/top_level.txt +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/setup.cfg +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_admin_ingest_route.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_agent_memory_api.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_agent_memory_sdk.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_auth_token_rotate.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_cli_v7.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_cli_version.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_contradictions.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_evolution.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_evolution_quality.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_expanduser.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_export_ask.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_ingest.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_llm_consolidator.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_llm_fingerprint_not_persisted.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_llm_test_endpoint.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_loop.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_mcp.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_memories_pagination.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_openclaw_loader.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_reflection.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_repo_framing.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_score_api.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_scoring_v2.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_secrets.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_serve_app.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_serve_handlers.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_session_order.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_store.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_summarization.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_vector_store.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_watcher.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_wiki_classifier.py +0 -0
- {loop_memory-0.4.2 → loop_memory-0.4.3}/tests/test_wiki_export_escape.py +0 -0
- {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.
|
|
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
|
-
#
|
|
183
|
-
|
|
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(
|
|
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(
|
|
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)."""
|