loop-memory 0.4.0__py3-none-any.whl
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/__init__.py +62 -0
- loop_memory/backends/__init__.py +13 -0
- loop_memory/backends/embedding.py +82 -0
- loop_memory/backends/sentence_embedder.py +30 -0
- loop_memory/backends/vector_store.py +139 -0
- loop_memory/cli/__init__.py +0 -0
- loop_memory/cli/_common.py +68 -0
- loop_memory/cli/commands/__init__.py +13 -0
- loop_memory/cli/commands/cognitive.py +205 -0
- loop_memory/cli/commands/diag.py +346 -0
- loop_memory/cli/commands/graph.py +21 -0
- loop_memory/cli/commands/hooks.py +212 -0
- loop_memory/cli/commands/read.py +362 -0
- loop_memory/cli/commands/serve.py +147 -0
- loop_memory/cli/commands/write.py +138 -0
- loop_memory/cli/main.py +115 -0
- loop_memory/engine/__init__.py +0 -0
- loop_memory/engine/loop.py +247 -0
- loop_memory/engine/reflect.py +89 -0
- loop_memory/examples/__init__.py +0 -0
- loop_memory/examples/demo.py +39 -0
- loop_memory/export/__init__.py +39 -0
- loop_memory/export/memory_md.py +629 -0
- loop_memory/graph/__init__.py +0 -0
- loop_memory/graph/build.py +259 -0
- loop_memory/graph/extract.py +197 -0
- loop_memory/ingest/__init__.py +0 -0
- loop_memory/ingest/loader.py +782 -0
- loop_memory/ingest/pipeline.py +458 -0
- loop_memory/jobs/__init__.py +0 -0
- loop_memory/jobs/cognitive.py +353 -0
- loop_memory/jobs/compact.py +371 -0
- loop_memory/jobs/consolidate.py +95 -0
- loop_memory/jobs/contradiction.py +281 -0
- loop_memory/jobs/evolution.py +2021 -0
- loop_memory/jobs/graph.py +395 -0
- loop_memory/jobs/llm_compact_pass.py +24 -0
- loop_memory/jobs/llm_consolidate.py +980 -0
- loop_memory/jobs/scheduler.py +495 -0
- loop_memory/llm/__init__.py +0 -0
- loop_memory/llm/base.py +80 -0
- loop_memory/llm/openai_adapter.py +31 -0
- loop_memory/llm/providers.py +517 -0
- loop_memory/mcp/__init__.py +804 -0
- loop_memory/memory/__init__.py +0 -0
- loop_memory/memory/types.py +199 -0
- loop_memory/privacy/__init__.py +22 -0
- loop_memory/privacy/private.py +46 -0
- loop_memory/privacy/redact.py +188 -0
- loop_memory/py.typed +0 -0
- loop_memory/sdk.py +875 -0
- loop_memory/sdk_extensions.py +384 -0
- loop_memory/security/__init__.py +20 -0
- loop_memory/security/secrets.py +464 -0
- loop_memory/serve/__init__.py +0 -0
- loop_memory/serve/app.py +506 -0
- loop_memory/serve/handlers.py +316 -0
- loop_memory/serve/routes/_shared.py +59 -0
- loop_memory/serve/routes/admin.py +970 -0
- loop_memory/serve/routes/cognitive.py +64 -0
- loop_memory/serve/routes/export.py +65 -0
- loop_memory/serve/routes/graph.py +101 -0
- loop_memory/serve/routes/insights.py +702 -0
- loop_memory/serve/routes/memories.py +435 -0
- loop_memory/serve/routes/sessions.py +75 -0
- loop_memory/serve/routes/system.py +493 -0
- loop_memory/serve/routes/wiki.py +812 -0
- loop_memory/serve/static/__init__.py +0 -0
- loop_memory/serve/static/index.html +15 -0
- loop_memory/serve/watcher.py +451 -0
- loop_memory/storage/__init__.py +5 -0
- loop_memory/storage/retrieval.py +365 -0
- loop_memory/storage/sqlite_store.py +3627 -0
- loop_memory/wiki/__init__.py +41 -0
- loop_memory/wiki/backfill.py +143 -0
- loop_memory/wiki/classifier.py +238 -0
- loop_memory/wiki/prompts.py +295 -0
- loop_memory/wiki/scope.py +227 -0
- loop_memory-0.4.0.dist-info/METADATA +627 -0
- loop_memory-0.4.0.dist-info/RECORD +84 -0
- loop_memory-0.4.0.dist-info/WHEEL +5 -0
- loop_memory-0.4.0.dist-info/entry_points.txt +2 -0
- loop_memory-0.4.0.dist-info/licenses/LICENSE +21 -0
- loop_memory-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Hybrid retrieval primitives.
|
|
3
|
+
|
|
4
|
+
Two building blocks used by ``MemoryStore.recall_hybrid``:
|
|
5
|
+
|
|
6
|
+
* ``bm25_search`` — runs an FTS5 MATCH against the memories or wiki
|
|
7
|
+
mirror, returning ranked candidates with their native BM25 score.
|
|
8
|
+
|
|
9
|
+
* ``fuse_rrf`` — Reciprocal Rank Fusion across multiple ranked lists.
|
|
10
|
+
Each list contributes ``1 / (k + rank_i(d))`` per document;
|
|
11
|
+
the document's fused score is the sum across lists. RRF is the
|
|
12
|
+
standard 2020+ recipe for combining heterogeneous rankers
|
|
13
|
+
(semantic, keyword, entity) without needing to align their raw
|
|
14
|
+
score distributions, which is exactly the problem we have here.
|
|
15
|
+
|
|
16
|
+
No external deps. SQLite FTS5 ships with the stdlib ``sqlite3`` module.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import Any, Iterable
|
|
21
|
+
|
|
22
|
+
# RRF constant. 60 is the value used in the original Cormack et al.
|
|
23
|
+
# 2009 paper and matches what Mem0/Graphiti use in 2026.
|
|
24
|
+
DEFAULT_RRF_K = 60
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _escape_fts(query: str) -> str:
|
|
28
|
+
"""Wrap the query for FTS5's MATCH expression.
|
|
29
|
+
|
|
30
|
+
The pipeline is query-type-aware:
|
|
31
|
+
|
|
32
|
+
* ASCII/Latin tokens (``vue``, ``codex``) → wrapped in double
|
|
33
|
+
quotes so ``vue.js`` does NOT collapse into one exact-phrase.
|
|
34
|
+
* CJK runs (``知识图谱``, ``记忆系统``) → decomposed into character
|
|
35
|
+
trigrams (``知识图``, ``识图谱``) joined by ``AND``. The trigram
|
|
36
|
+
tokenizer otherwise needs each trigram to be a separate FTS5
|
|
37
|
+
token, and quoting the whole run as an exact phrase matches
|
|
38
|
+
only documents that contain the literal byte sequence — which
|
|
39
|
+
nothing does for arbitrary text.
|
|
40
|
+
|
|
41
|
+
Returns ``""`` when there is no usable content.
|
|
42
|
+
"""
|
|
43
|
+
import re
|
|
44
|
+
|
|
45
|
+
s = (query or "").lower()
|
|
46
|
+
if not s.strip():
|
|
47
|
+
return ""
|
|
48
|
+
|
|
49
|
+
# 1. Split into "Latin word" tokens and "CJK runs" tokens. Any
|
|
50
|
+
# contiguous run of CJK ideographs is treated as one chunk.
|
|
51
|
+
cjk_re = re.compile(r"[一-鿿]+")
|
|
52
|
+
lat_re = re.compile(r"[\w]+")
|
|
53
|
+
|
|
54
|
+
parts: list[str] = []
|
|
55
|
+
|
|
56
|
+
# Walk through the string preserving order; we don't actually need
|
|
57
|
+
# FTS5 to honour order (RRF rescues it), so a flat list is fine.
|
|
58
|
+
for cjk_run in cjk_re.findall(s):
|
|
59
|
+
if len(cjk_run) < 3:
|
|
60
|
+
# too short for trigrams; quote and fall back to LIKE
|
|
61
|
+
parts.append(f'"{cjk_run}"')
|
|
62
|
+
continue
|
|
63
|
+
grams = [cjk_run[i:i + 3] for i in range(len(cjk_run) - 2)]
|
|
64
|
+
if len(grams) <= 2:
|
|
65
|
+
# 3-4 chars: emit them all (precise enough)
|
|
66
|
+
parts.extend(grams)
|
|
67
|
+
else:
|
|
68
|
+
# 5+ chars: AND only the boundary trigrams (first + last).
|
|
69
|
+
# The middle grams are usually present whenever the
|
|
70
|
+
# boundary grams are, so omitting them makes the query
|
|
71
|
+
# more recall-friendly without losing precision.
|
|
72
|
+
parts.append(grams[0])
|
|
73
|
+
parts.append(grams[-1])
|
|
74
|
+
|
|
75
|
+
# Also pull out Latin words (skip CJK runs).
|
|
76
|
+
cleaned = cjk_re.sub(" ", s)
|
|
77
|
+
for w in lat_re.findall(cleaned):
|
|
78
|
+
if len(w) >= 1:
|
|
79
|
+
parts.append(f'"{w}"')
|
|
80
|
+
|
|
81
|
+
if not parts:
|
|
82
|
+
return ""
|
|
83
|
+
# Default FTS5 operator between terms is AND; that's what we want.
|
|
84
|
+
return " ".join(parts)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def bm25_search(
|
|
88
|
+
store: Any,
|
|
89
|
+
query: str,
|
|
90
|
+
kind: str = "memories",
|
|
91
|
+
limit: int = 50,
|
|
92
|
+
source_filter: str | None = None,
|
|
93
|
+
) -> list[dict]:
|
|
94
|
+
"""Run an FTS5 query and return a list of ``{"id": ..., "_score": bm25}``.
|
|
95
|
+
|
|
96
|
+
``kind`` is "memories" or "wiki". The native BM25 score from
|
|
97
|
+
SQLite's ``bm25(memories_fts)`` is negative (lower = better), so
|
|
98
|
+
we negate it for the RRF caller which expects positive scores.
|
|
99
|
+
|
|
100
|
+
For ``wiki``, the FTS query is restricted to rows whose scope
|
|
101
|
+
allows the caller's ``source_filter`` (when provided).
|
|
102
|
+
|
|
103
|
+
Fallback: the FTS5 trigram tokenizer requires >=3 contiguous
|
|
104
|
+
characters to create any token. For inputs that are all-CJK
|
|
105
|
+
AND total length <3, FTS5 returns nothing; we then issue a
|
|
106
|
+
``LIKE '%<q>%'`` over the source table as a backstop. For our
|
|
107
|
+
scale (<10k memories, <1k wiki) LIKE is well under 5 ms.
|
|
108
|
+
"""
|
|
109
|
+
import re as _re
|
|
110
|
+
fts_q = _escape_fts(query)
|
|
111
|
+
is_short_cjk = bool(
|
|
112
|
+
_re.fullmatch(r"[一-鿿]+", (query or "").strip() or "")
|
|
113
|
+
and len((query or "").strip()) < 3
|
|
114
|
+
)
|
|
115
|
+
rows: list = []
|
|
116
|
+
if fts_q and not is_short_cjk:
|
|
117
|
+
table = "memories_fts" if kind == "memories" else "wiki_fts"
|
|
118
|
+
score_col = f"bm25({table})"
|
|
119
|
+
with store._conn() as c:
|
|
120
|
+
if kind == "memories":
|
|
121
|
+
# Join back to memories so we can return the actual
|
|
122
|
+
# UUID `id` column (the FTS5 rowid is just a sqlite
|
|
123
|
+
# rowid, not the memory's primary key).
|
|
124
|
+
sql = (
|
|
125
|
+
f"SELECT m.id AS id, {score_col} AS s "
|
|
126
|
+
f"FROM {table} t "
|
|
127
|
+
f"JOIN memories m ON m.rowid = t.rowid "
|
|
128
|
+
f"WHERE {table} MATCH ? "
|
|
129
|
+
f"ORDER BY s LIMIT ?"
|
|
130
|
+
)
|
|
131
|
+
rows = c.execute(sql, (fts_q, limit)).fetchall()
|
|
132
|
+
else:
|
|
133
|
+
# Wiki: also join back to wiki_pages so we can apply the
|
|
134
|
+
# per-source scope filter at the SQL layer. We negate
|
|
135
|
+
# the bm25 score so the RRF caller sees positive values.
|
|
136
|
+
sql = (
|
|
137
|
+
f"SELECT w.id AS id, {score_col} AS s, w.scope AS scope "
|
|
138
|
+
f"FROM {table} t "
|
|
139
|
+
f"JOIN wiki_pages w ON w.rowid = t.rowid "
|
|
140
|
+
f"WHERE {table} MATCH ? "
|
|
141
|
+
f"ORDER BY s LIMIT ?"
|
|
142
|
+
)
|
|
143
|
+
rows = c.execute(sql, (fts_q, limit * 3)).fetchall()
|
|
144
|
+
# Apply scope filter in Python so we can use the same
|
|
145
|
+
# token-matching convention as the rest of the system.
|
|
146
|
+
rows = _filter_wiki_scope(rows, source_filter)[:limit]
|
|
147
|
+
# Always run LIKE as a backstop. Even when FTS5 returns hits,
|
|
148
|
+
# LIKE may surface documents the trigram tokenizer can't see
|
|
149
|
+
# (short CJK, OCR noise, mixed code identifiers, ...). The two
|
|
150
|
+
# lists are merged by id; LIKE rows get a synthetic importance
|
|
151
|
+
# score so the RRF caller can rank them alongside BM25.
|
|
152
|
+
like_rows = _like_fallback(store, query, kind=kind, limit=limit,
|
|
153
|
+
source_filter=source_filter)
|
|
154
|
+
merged: dict[str, dict] = {}
|
|
155
|
+
for r in (rows or []):
|
|
156
|
+
merged[r["id"]] = {"id": r["id"], "_score": -float(r["s"])}
|
|
157
|
+
for r in like_rows:
|
|
158
|
+
imp = r["importance"] if "importance" in r.keys() else 0.5
|
|
159
|
+
if r["id"] not in merged:
|
|
160
|
+
merged[r["id"]] = {"id": r["id"], "_score": float(imp or 0.5)}
|
|
161
|
+
return list(merged.values())
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _like_fallback(
|
|
165
|
+
store: Any,
|
|
166
|
+
query: str,
|
|
167
|
+
kind: str,
|
|
168
|
+
limit: int,
|
|
169
|
+
source_filter: str | None = None,
|
|
170
|
+
) -> list:
|
|
171
|
+
"""Brute-force LIKE fallback used when FTS5 trigram can't index
|
|
172
|
+
short CJK queries. Searches <substr> against the source table
|
|
173
|
+
directly. Returns rows in the same shape as bm25_search.
|
|
174
|
+
"""
|
|
175
|
+
pat = f"%{(query or '').strip()}%"
|
|
176
|
+
if not query.strip():
|
|
177
|
+
return []
|
|
178
|
+
with store._conn() as c:
|
|
179
|
+
if kind == "memories":
|
|
180
|
+
sql = (
|
|
181
|
+
"SELECT m.id AS id, m.importance AS importance "
|
|
182
|
+
"FROM memories m WHERE LOWER(text) LIKE LOWER(?) "
|
|
183
|
+
"ORDER BY m.importance DESC, m.created_at DESC LIMIT ?"
|
|
184
|
+
)
|
|
185
|
+
return [dict(r) for r in c.execute(sql, (pat, limit)).fetchall()]
|
|
186
|
+
sql = (
|
|
187
|
+
"SELECT w.id AS id, w.importance AS importance, w.scope AS scope "
|
|
188
|
+
"FROM wiki_pages w "
|
|
189
|
+
"WHERE LOWER(w.title) LIKE LOWER(?) OR LOWER(w.body) LIKE LOWER(?) "
|
|
190
|
+
"ORDER BY w.importance DESC, w.updated_at DESC LIMIT ?"
|
|
191
|
+
)
|
|
192
|
+
rows = list(c.execute(sql, (pat, pat, limit * 3)).fetchall())
|
|
193
|
+
return _filter_wiki_scope(rows, source_filter)[:limit]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _filter_wiki_scope(rows: Iterable, source: str | None) -> list:
|
|
197
|
+
if not source:
|
|
198
|
+
return list(rows)
|
|
199
|
+
tok = (source or "").strip().lower()
|
|
200
|
+
out = []
|
|
201
|
+
for r in rows:
|
|
202
|
+
scope = (r["scope"] if "scope" in r.keys() else "global") or "global"
|
|
203
|
+
if scope in {"global", "all"}:
|
|
204
|
+
out.append(r)
|
|
205
|
+
else:
|
|
206
|
+
# scope is "global" or a comma-list like "codex,claude"
|
|
207
|
+
allowed = {s.strip() for s in scope.split(",") if s.strip()}
|
|
208
|
+
if tok in allowed:
|
|
209
|
+
out.append(r)
|
|
210
|
+
return out
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def fuse_rrf(
|
|
214
|
+
ranked_lists: list[list[dict]],
|
|
215
|
+
k: int = DEFAULT_RRF_K,
|
|
216
|
+
) -> list[dict]:
|
|
217
|
+
"""Reciprocal Rank Fusion.
|
|
218
|
+
|
|
219
|
+
Each input list is a list of ``{"id": ..., "_score": ...}``,
|
|
220
|
+
already sorted by descending score (best first). Documents not
|
|
221
|
+
present in a list contribute 0 from that list.
|
|
222
|
+
|
|
223
|
+
The fused score is::
|
|
224
|
+
|
|
225
|
+
fused(d) = sum over lists i of 1 / (k + rank_i(d))
|
|
226
|
+
|
|
227
|
+
where ``rank_i(d)`` is 1-based and ``None`` (= not in list) is
|
|
228
|
+
treated as 0.
|
|
229
|
+
|
|
230
|
+
Returns a list of ``{"id": ..., "_rrf": ...}`` sorted by fused
|
|
231
|
+
score descending. The ``_score`` field from each input is ignored
|
|
232
|
+
(RRF operates on ranks, not raw scores).
|
|
233
|
+
"""
|
|
234
|
+
fused: dict[str, float] = {}
|
|
235
|
+
for lst in ranked_lists:
|
|
236
|
+
for rank, item in enumerate(lst, start=1):
|
|
237
|
+
fused[item["id"]] = fused.get(item["id"], 0.0) + 1.0 / (k + rank)
|
|
238
|
+
return [{"id": i, "_rrf": s} for i, s in sorted(fused.items(), key=lambda kv: -kv[1])]
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ----------------------------------------------------------------------
|
|
242
|
+
# Temporal reasoning layer
|
|
243
|
+
# ----------------------------------------------------------------------
|
|
244
|
+
# Mem0 (April 2026) showed that explicitly modelling the *temporal intent*
|
|
245
|
+
# of the query + reranking by date relevance contributes about 27 points
|
|
246
|
+
# on LongMemEval (94.4 vs 67.8 baseline). The intuition is simple:
|
|
247
|
+
# queries that say "what is the current X" should prefer the *most recent*
|
|
248
|
+
# memory that covers X, even if an older one is semantically closer.
|
|
249
|
+
# Conversely, "the project I shipped last week" should prefer the dated
|
|
250
|
+
# memory from that week, even if a fresher one exists. Without a temporal
|
|
251
|
+
# pass, RRF will happily return a newer-but-irrelevant memory.
|
|
252
|
+
#
|
|
253
|
+
# This module exposes two primitives. The store wires them in below
|
|
254
|
+
# the RRF fusion, so the change is opt-in for callers that already
|
|
255
|
+
# use ``recall_hybrid``.
|
|
256
|
+
# ----------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
# Lightweight Chinese + English lexicons. We keep this in a plain
|
|
259
|
+
# constant (not an LLM call) so the latency cost of adding temporal
|
|
260
|
+
# reasoning is one regex pass per query.
|
|
261
|
+
_TEMPORAL_CURRENT = (
|
|
262
|
+
"current", "currently", "now", "today", "latest", "recent",
|
|
263
|
+
"现在", "当前", "目前", "今天", "此刻", "现在的", "最新的", "现在的",
|
|
264
|
+
)
|
|
265
|
+
_TEMPORAL_PAST = (
|
|
266
|
+
"previous", "previously", "before", "last", "ago", "yesterday",
|
|
267
|
+
"earlier", "originally", "initially", "at that time", "back then",
|
|
268
|
+
"之前", "上次", "上次", "上次", "曾经", "过去", "原来的", "当初",
|
|
269
|
+
"之前", "前几天", "上次", "已经", "之前",
|
|
270
|
+
)
|
|
271
|
+
_TEMPORAL_FUTURE = (
|
|
272
|
+
"tomorrow", "upcoming", "next", "will", "plan to", "going to",
|
|
273
|
+
"future", "scheduled",
|
|
274
|
+
"明天", "下次", "未来", "即将", "之后", "将要", "计划", "打算",
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def detect_temporal_intent(query: str) -> tuple[str, float]:
|
|
279
|
+
"""Return (intent, confidence) where intent is one of
|
|
280
|
+
'current', 'past', 'future', 'any'.
|
|
281
|
+
|
|
282
|
+
Confidence is the rough lexical overlap with the matching lexicon,
|
|
283
|
+
capped at 1.0. 'any' always has confidence 0.0 (no signal).
|
|
284
|
+
"""
|
|
285
|
+
import re as _re
|
|
286
|
+
|
|
287
|
+
q = (query or "").lower()
|
|
288
|
+
if not q.strip():
|
|
289
|
+
return ("any", 0.0)
|
|
290
|
+
|
|
291
|
+
def _hit(lex):
|
|
292
|
+
hits = 0
|
|
293
|
+
for w in lex:
|
|
294
|
+
# use a simple substring match; we deliberately avoid a
|
|
295
|
+
# tokenizer here because the query is short (≤ a few
|
|
296
|
+
# sentences) and Jinja-style tokenization would slow us
|
|
297
|
+
# down without measurable benefit.
|
|
298
|
+
if w in q:
|
|
299
|
+
hits += 1
|
|
300
|
+
return hits
|
|
301
|
+
|
|
302
|
+
n_cur = _hit(_TEMPORAL_CURRENT)
|
|
303
|
+
n_past = _hit(_TEMPORAL_PAST)
|
|
304
|
+
n_fut = _hit(_TEMPORAL_FUTURE)
|
|
305
|
+
counts = {"current": n_cur, "past": n_past, "future": n_fut}
|
|
306
|
+
intent = max(counts, key=counts.get) # type: ignore[arg-type]
|
|
307
|
+
total = n_cur + n_past + n_fut
|
|
308
|
+
if total == 0:
|
|
309
|
+
return ("any", 0.0)
|
|
310
|
+
# If the winning intent only wins by 1 over the runner-up, treat
|
|
311
|
+
# as 'any' (too ambiguous to do anything useful).
|
|
312
|
+
sorted_counts = sorted(counts.values(), reverse=True)
|
|
313
|
+
if sorted_counts[0] - sorted_counts[1] < 1:
|
|
314
|
+
return ("any", 0.0)
|
|
315
|
+
confidence = min(1.0, counts[intent] / 3.0) # 3 hits = full confidence
|
|
316
|
+
return (intent, confidence)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def temporal_score(
|
|
320
|
+
*,
|
|
321
|
+
created_at: float,
|
|
322
|
+
updated_at: float | None,
|
|
323
|
+
intent: str,
|
|
324
|
+
now: float,
|
|
325
|
+
confidence: float,
|
|
326
|
+
) -> float:
|
|
327
|
+
"""Return a multiplier in roughly [0.5, 1.5] for how well a memory's
|
|
328
|
+
date matches the query intent.
|
|
329
|
+
|
|
330
|
+
- intent='current': more recent = better; 30d-half-life decay.
|
|
331
|
+
- intent='past': memories close to "now - small_delta" get a small
|
|
332
|
+
boost; very recent memories are penalised so dated history wins.
|
|
333
|
+
- intent='future': memories with created/updated_at > now get a
|
|
334
|
+
strong boost (they're "upcoming plans"). We also accept memories
|
|
335
|
+
whose text mentions future intent (caller decides via flag).
|
|
336
|
+
- intent='any': returns 1.0 (no opinion).
|
|
337
|
+
|
|
338
|
+
A confidence < 1.0 softens the effect; with confidence 0.0 the
|
|
339
|
+
function returns 1.0 regardless of intent.
|
|
340
|
+
"""
|
|
341
|
+
if intent == "any" or confidence <= 0.0:
|
|
342
|
+
return 1.0
|
|
343
|
+
import math as _math
|
|
344
|
+
|
|
345
|
+
dt = float(updated_at or created_at or now)
|
|
346
|
+
age_days = max(0.0, (now - dt) / 86400.0)
|
|
347
|
+
if intent == "current":
|
|
348
|
+
# 30-day half-life: fresh ≈1.5, 30d ≈1.0, 180d ≈0.65, 1y ≈0.5
|
|
349
|
+
base = 1.25 + 0.25 * _math.exp(-age_days / 30.0)
|
|
350
|
+
elif intent == "past":
|
|
351
|
+
# Sigmoid centred on "1 week ago" — slight penalty for very
|
|
352
|
+
# recent memories (they're probably the new state, not the past
|
|
353
|
+
# state the user asked about). Cross-over at ~7 days old.
|
|
354
|
+
x = (age_days - 7.0) / 7.0
|
|
355
|
+
base = 1.25 + 0.25 * (_math.tanh(x))
|
|
356
|
+
elif intent == "future":
|
|
357
|
+
if dt >= now:
|
|
358
|
+
base = 1.4 # upcoming / planned
|
|
359
|
+
else:
|
|
360
|
+
base = 0.7 # not future-dated
|
|
361
|
+
else:
|
|
362
|
+
base = 1.0
|
|
363
|
+
# Blend toward 1.0 by (1 - confidence) so we don't blow up a
|
|
364
|
+
# borderline match into a hard rule.
|
|
365
|
+
return 1.0 + (base - 1.0) * confidence
|