11# chronos/memory.py
2- # Responsibility: High-level memory operations — remember, recall, get, update.
3- # Lifecycle transitions (forget/restore/purge) live in lifecycle.py;
4- # time-travel lives in time_travel.py. This is the primary interface
5- # between MCP tools and the storage layer.
2+ # Responsibility: High-level memory operations — remember, get, update —
3+ # and the MemoryStore facade that tool handlers close over.
4+ # Delegation map (each module owns its logic, this file stays thin):
5+ # recall pipeline → ranking.py
6+ # forget / restore / purge → lifecycle.py
7+ # time-travel → time_travel.py
8+ # semantic vectors (opt-in) → semantic.py
69#
7- # Architecture (v4.0 ):
10+ # Architecture (v4.x ):
811# - memories table → persistent text storage (db.py)
912# - memories_fts → BM25 full-text index, trigger-synced (db.py, search.py)
13+ # - memory_embeddings→ semantic vectors, write-through ONLY when a
14+ # SemanticSearch is injected (CHRONOS_SEMANTIC=1)
1015#
11- # The v3.x in-memory TF-IDF index and structural memory vectors are gone:
12- # the index now lives in the same SQLite file and commits atomically with
13- # content writes. MemoryStore holds no state of its own.
14- #
15- # Token budget design:
16- # recall() reports a token_estimate per result. Compression is applied
17- # ONLY when the caller passes token_budget — results are never silently
18- # truncated (v3.3 trimmed anything over 150 tokens unconditionally, and
19- # offered no way to fetch full content; both fixed in v4.0).
16+ # MemoryStore holds no state of its own beyond the optional semantic handle.
2017
2118import json
2219from datetime import datetime
2320from typing import List , Optional
2421
25- from chronos import lifecycle
26- from chronos .beliefs import CONFIDENCE_DEFAULT , STABILITY_DEFAULT , BeliefEngine
27- from chronos .compression import compress_results
22+ from chronos import lifecycle , ranking
2823from chronos .db import get_db
29- from chronos .search import estimate_tokens , search_memories
24+ from chronos .search import estimate_tokens
3025from chronos .time_travel import query_at as _time_travel_query_at
3126from chronos .uuid7 import uuid7
3227
33- # Approximate tokens consumed by the recall response wrapper itself
34- _RECALL_OVERHEAD_TOKENS = 40
35-
36- # Recency decay: score multiplier = 1 + recency_weight * boost.
37- # Default 0.3 gives a mild boost to fresh/confident memories without
38- # inverting rankings on high-scoring older ones. Set to 0.0 to disable.
39- _DEFAULT_RECENCY_WEIGHT = 0.3
40-
41-
42- def _recency_factor (created_at_iso : str ) -> float :
43- """
44- Returns 1/(1+days_old) — approaches 1 for brand-new, approaches 0 for old.
45- Falls back to 0.0 if the timestamp cannot be parsed.
46- Legacy fallback used when FSRS data is unavailable.
47- """
48- try :
49- created = datetime .fromisoformat (created_at_iso )
50- now = datetime .now ()
51- days = max (0.0 , (now - created .replace (tzinfo = None )).total_seconds () / 86400 )
52- return 1.0 / (1.0 + days )
53- except (ValueError , TypeError ):
54- return 0.0
55-
56-
57- def _fsrs_boost (stability : float , last_reviewed : str , created_at : str ,
58- confidence : float ) -> float :
59- """
60- FSRS-aware ranking boost. Combines retention probability with confidence.
61-
62- Returns a value in [0, 1]. Higher when the memory is both confident AND
63- well-retained:
64- boost = retention * confidence_factor
65- retention = (1 + days/(9*S))^(-1) [FSRS forgetting curve]
66- confidence_factor = 0.5 + 0.5*confidence [0.01→0.505, 0.99→0.995]
67-
68- Confidence alone doesn't override staleness, and vice versa.
69- """
70- review_ts = last_reviewed or created_at
71- days = BeliefEngine .days_since (review_ts )
72- stab = stability if stability and stability > 0 else STABILITY_DEFAULT
73- retention = BeliefEngine .compute_retention (stab , days )
74-
75- conf = confidence if confidence is not None else CONFIDENCE_DEFAULT
76- confidence_factor = 0.5 + 0.5 * conf
77-
78- return retention * confidence_factor
79-
8028
8129class MemoryStore :
8230 """
83- Full lifecycle of free-text memories. Stateless — all persistence and
84- indexing live in SQLite (see db.py). Constructed once at startup and
85- injected into tool handlers via closure.
31+ Full lifecycle of free-text memories. All persistence and indexing live
32+ in SQLite (see db.py). Constructed once at startup and injected into
33+ tool handlers via closure.
34+
35+ semantic: optional SemanticSearch. When provided, remember() and
36+ update() write-through to the embedding index, and recall() runs in
37+ hybrid (BM25 ∪ vector, RRF-fused) mode. None = pure lexical v4.0
38+ behavior.
8639 """
8740
41+ def __init__ (self , semantic = None ) -> None :
42+ self .semantic = semantic
43+
8844 # ------------------------------------------------------------------
8945 # remember
9046 # ------------------------------------------------------------------
@@ -98,7 +54,8 @@ def remember(
9854 ) -> dict :
9955 """
10056 Store a free-text memory. The FTS trigger indexes it in the same
101- transaction as the insert.
57+ transaction as the insert; the semantic index (when enabled) is
58+ updated write-through immediately after.
10259
10360 source: provenance label — where this memory's content came from
10461 (e.g. 'user', 'claude', 'web', 'document'). Recall results echo it
@@ -124,6 +81,9 @@ def remember(
12481 )
12582 db .commit ()
12683
84+ if self .semantic is not None :
85+ self .semantic .embed_memory (mem_id , content .strip ())
86+
12787 return {
12888 "id" : mem_id ,
12989 "project" : project ,
@@ -132,101 +92,30 @@ def remember(
13292 }
13393
13494 # ------------------------------------------------------------------
135- # recall
95+ # recall — delegated to ranking.py
13696 # ------------------------------------------------------------------
13797
13898 def recall (
13999 self ,
140100 query : str ,
141101 project : Optional [str ] = None ,
142102 k : int = 5 ,
143- recency_weight : float = _DEFAULT_RECENCY_WEIGHT ,
103+ recency_weight : float = ranking . DEFAULT_RECENCY_WEIGHT ,
144104 token_budget : Optional [int ] = None ,
145105 ) -> dict :
146106 """
147- Retrieve the k most relevant memories for a query.
148-
149- Ranking: BM25 over the FTS5 index (porter-stemmed), then re-ranked
150- with an FSRS retention*confidence boost when recency_weight > 0.
151- Scores are relative ranking values, not normalised [0,1].
152-
153- Compression runs ONLY when token_budget is set. Without a budget,
154- content is returned in full.
155-
156- Returns:
157- {
158- results: [{id, project, content, score, token_estimate,
159- source, confidence?}],
160- total_tokens: int,
161- count: int,
162- query: str,
163- compression_applied: [..] (only when token_budget was set)
164- }
107+ Retrieve the k most relevant memories. Lexical BM25 by default;
108+ hybrid BM25 ∪ semantic with RRF fusion when semantic search is
109+ enabled. See ranking.recall() for the full pipeline.
165110 """
166- if not query or not query .strip ():
167- return {"results" : [], "total_tokens" : 0 , "count" : 0 , "query" : query }
168-
169- # Over-fetch when re-ranking may reorder the top-k
170- fetch_k = k * 3 if recency_weight > 0 else k
171- with get_db () as db :
172- rows = search_memories (db , query , project = project , k = fetch_k )
173-
174- boosted = []
175- for r in rows :
176- score = r ["score" ]
177- if recency_weight > 0 :
178- if r ["stability" ] is not None :
179- boost = _fsrs_boost (
180- r ["stability" ], r ["last_reviewed" ],
181- r ["created_at" ], r ["confidence" ],
182- )
183- else :
184- boost = _recency_factor (r ["created_at" ])
185- score = score * (1.0 + recency_weight * boost )
186- boosted .append ((r , score ))
187-
188- boosted .sort (key = lambda x : x [1 ], reverse = True )
189- boosted = boosted [:k ]
190-
191- results = []
192- total_tokens = _RECALL_OVERHEAD_TOKENS
193-
194- for r , score in boosted :
195- tok_est = estimate_tokens (r ["content" ])
196- total_tokens += tok_est
197- entry = {
198- "id" : r ["id" ],
199- "project" : r ["project" ],
200- "content" : r ["content" ],
201- "score" : round (score , 5 ),
202- "token_estimate" : tok_est ,
203- "source" : r ["source" ] or "user" ,
204- }
205- if r ["confidence" ] is not None :
206- entry ["confidence" ] = round (r ["confidence" ], 4 )
207- results .append (entry )
208-
209- # v4.0: compression is opt-in. No budget → full content, always.
210- if token_budget is not None :
211- compressed = compress_results (
212- results ,
213- budget = token_budget ,
214- overhead = _RECALL_OVERHEAD_TOKENS ,
215- )
216- return {
217- "results" : compressed ["results" ],
218- "total_tokens" : compressed ["total_tokens" ],
219- "count" : len (compressed ["results" ]),
220- "query" : query ,
221- "compression_applied" : compressed ["compression_applied" ],
222- }
223-
224- return {
225- "results" : results ,
226- "total_tokens" : total_tokens ,
227- "count" : len (results ),
228- "query" : query ,
229- }
111+ return ranking .recall (
112+ query ,
113+ project = project ,
114+ k = k ,
115+ recency_weight = recency_weight ,
116+ token_budget = token_budget ,
117+ semantic = self .semantic ,
118+ )
230119
231120 # ------------------------------------------------------------------
232121 # get — full single-memory retrieval (no truncation, ever)
@@ -279,8 +168,9 @@ def get(self, memory_id: str) -> dict:
279168 def update (self , memory_id : str , content : str ) -> dict :
280169 """
281170 Replace the content of an existing memory. The old content is
282- snapshotted into memory_versions (time-travel), and the FTS trigger
283- re-indexes the new content in the same transaction.
171+ snapshotted into memory_versions (time-travel), the FTS trigger
172+ re-indexes the new content in the same transaction, and the
173+ semantic vector (when enabled) is refreshed write-through.
284174 Raises ValueError if the memory does not exist or is forgotten.
285175
286176 Returns: {id, status, token_estimate}
@@ -320,6 +210,9 @@ def update(self, memory_id: str, content: str) -> dict:
320210 )
321211 db .commit ()
322212
213+ if self .semantic is not None :
214+ self .semantic .embed_memory (memory_id , content .strip ())
215+
323216 return {
324217 "id" : memory_id ,
325218 "status" : "updated" ,
0 commit comments