OppaAI commited on
Commit
2b1be2b
Β·
1 Parent(s): a7bf600

refactor: replace external memory backend with custom sqlite-vec storage engine featuring RRF search and Ebbinghaus decay

Browse files
Files changed (1) hide show
  1. core/memorize.py +623 -367
core/memorize.py CHANGED
@@ -1,435 +1,691 @@
1
  """
2
- core/think.py
3
-
4
- Aiko's cognitive loop.
5
- - Retrieves relevant memories before each turn (scoped by user_id)
6
- - Tool routing: LLM-driven tool calling (preferred), with regex-based
7
- intent detection as fallback for weather, timezone, currency, joke,
8
- anime, and web search
9
- - Streams LLM response via token_callback
10
- - Stores the turn into long-term memory after each response (background thread)
11
- - Supports single-shot reasoning mode via set_reasoning(True) / /think command
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  """
 
 
13
 
14
- import os
15
  import json
16
- from datetime import datetime
17
- import httpx
18
- from pathlib import Path
19
- import queue
20
  import re
21
- import threading
 
 
 
 
 
 
22
 
23
- from core.log import get_logger
 
 
 
 
 
24
 
25
  log = get_logger(__name__)
26
 
27
  # ── boot labels ───────────────────────────────────────────────────────────────
28
 
29
  BOOT_LABELS = {
30
- 'think_start': 'Loading LLM client + persona...',
31
- 'think_warmup': 'Warming up language model...',
 
 
32
  }
33
 
34
- # ── config ────────────────────────────────────────────────────────────────────
35
 
36
- LLAMA_BASE_URL = os.getenv("LLAMA_BASE_URL", "https://your-modal-endpoint.modal.run")
37
- LLAMA_API_KEY = os.getenv("LLAMA_API_KEY", "")
38
- CONTEXT_WINDOW_TURNS = int(os.getenv("CONTEXT_WINDOW_TURNS", 20))
 
 
39
 
40
- _BASE_PREDICT = 400
41
- _REASONING_SCALE = 3
42
 
43
- _PERSONA_PATH = Path(__file__).resolve().parent.parent / "persona" / "soul.md"
 
 
44
 
45
- _DEFAULT_USER_ID = os.getenv("USER_ID", "Guest")
 
 
 
 
 
 
 
 
 
 
 
 
46
 
 
 
47
 
48
- def _render_persona(template: str, user_id: str) -> str:
49
- today = datetime.now().strftime("%B %d, %Y")
50
- return template.replace("USER_ID_HERE", user_id).replace("TODAY_HERE", today)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
 
52
 
53
- # ── think ─────────────────────────────────────────────────────────────────────
54
 
55
- class AikoThink:
56
  """
57
- Aiko's conversational core.
58
- LLM warmup starts immediately on init in a background thread.
59
- wakeup.py calls join_warmup() to block until the model is hot.
60
- speak is accepted but ignored (kept for BootResult API compatibility).
61
-
62
- user_id is tracked per-instance and updated via set_system_prompt()
63
- when the HF OAuth login resolves the real username. All memory
64
- operations (search + store) are scoped to the current user_id so
65
- memories never bleed between users.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  """
67
 
68
- def __init__(self, memorize: AikoMemorize, speak=None) -> None:
69
- headers = {"Content-Type": "application/json"}
70
- if LLAMA_API_KEY:
71
- headers["Authorization"] = f"Bearer {LLAMA_API_KEY}"
72
-
73
- self._client = httpx.Client(
74
- base_url=LLAMA_BASE_URL,
75
- headers=headers,
76
- timeout=120.0,
 
 
 
 
 
77
  )
78
- self._memorize = memorize
79
- self._user_id = _DEFAULT_USER_ID # updated on HF login via set_system_prompt()
80
-
81
- if not _PERSONA_PATH.exists():
82
- raise FileNotFoundError(f"soul.md not found at {_PERSONA_PATH}")
83
- self._persona_raw = _PERSONA_PATH.read_text(encoding="utf-8").strip()
84
-
85
- # Live rendered system prompt β€” set_system_prompt() replaces this
86
- self._system_prompts: dict[str, str] = {}
87
-
88
- self._histories: dict[str, list[dict]] = {}
89
- self._reasoning = False
90
- self._token_callback = None
91
- self._mem_queue = queue.Queue()
92
- self._mem_worker = threading.Thread(target=self._mem_write_loop, daemon=True)
93
- self._mem_worker.start()
94
-
95
- # Internal warmup β€” just checks the LLM endpoint is reachable.
96
- # The real KV-cache warmup with soul.md is handled by wakeup._warmup_llm()
97
- # which runs concurrently. This one only fires a tiny probe so
98
- # join_warmup() can confirm the network path is alive.
99
- self._warmup_thread = threading.Thread(target=self._probe_llm, daemon=True)
100
- self._warmup_thread.start()
101
-
102
- def _probe_llm(self) -> None:
103
- """Lightweight connectivity probe β€” does NOT duplicate the full warmup."""
104
- try:
105
- self._client.post(
106
- "/",
107
- json={
108
- "max_tokens": 8,
109
- "messages": [{"role": "user", "content": "hi"}],
110
- "temperature": 0.1,
111
- },
112
- timeout=60,
113
- )
114
- log.info("LLM probe complete")
115
- except Exception as e:
116
- log.warning("LLM probe failed (non-fatal): %s", e)
117
 
118
- # ── public api ────────────────────────────────────────────────────────────
 
 
119
 
120
- def join_warmup(self) -> None:
121
- if self._warmup_thread.is_alive():
122
- self._warmup_thread.join()
 
 
123
 
124
- def set_system_prompt(self, rendered_soul: str, user_id: str | None = None) -> None:
125
- """
126
- Replace the active system prompt with a fully rendered soul.md string.
127
- Called by app.py _check_login() after HF OAuth resolves the username.
128
- Also updates user_id so memory ops are scoped to the logged-in user.
129
- Clears conversation history so the new persona starts fresh.
130
- """
131
- if user_id:
132
- self._user_id = user_id
133
- log.info("System prompt updated for user: %s", user_id)
134
-
135
- effective_user_id = user_id or self._user_id or _DEFAULT_USER_ID
136
- self._system_prompts[effective_user_id] = rendered_soul
137
- self._histories.setdefault(effective_user_id, []).clear()
138
-
139
- def chat(self, user_input: str, user_id: str | None = None, token_callback=None) -> str:
140
- self._token_callback = token_callback
141
-
142
- # Resolve effective user_id: explicit arg > instance state > env default
143
- effective_user_id = user_id or self._user_id or _DEFAULT_USER_ID
144
-
145
- # 1. retrieve relevant long-term memories (scoped to this user)
146
- if self._memorize:
147
- memories = self._memorize.search(user_input, user_id=effective_user_id, limit=int(os.getenv("MEMORY_RECALL_LIMIT", 5)))
148
- memory_block = self._memorize.format_for_context(memories)
149
- else:
150
- memories = []
151
- memory_block = None
152
-
153
- # 2. build system prompt β€” rendered persona + injected memories
154
- # Use the live _system_prompts if set (post-login), otherwise render fresh
155
- if effective_user_id in self._system_prompts:
156
- system = self._system_prompts[effective_user_id]
157
- else:
158
- system = _render_persona(self._persona_raw, effective_user_id)
159
-
160
- if memory_block:
161
- system = f"{system}\n\n{memory_block}"
162
-
163
- # 3. tool routing β€” try LLM-driven tool calling first, fall back to regex
164
- tool_result = None
165
- tool_tag = None
166
-
167
- user_history = self._histories.setdefault(effective_user_id, [])
168
-
169
- history_for_check = self._sanitize_history(
170
- user_history[-(CONTEXT_WINDOW_TURNS * 2):] + [{"role": "user", "content": user_input}]
171
  )
 
172
 
173
- tool_tag, tool_result = self._try_tool_call(history_for_check, system)
174
-
175
- if tool_result is None:
176
- # fallback: regex-based intent detection (unchanged behavior)
177
- from core.tools import (
178
- is_search_intent, is_weather_intent, is_timezone_intent,
179
- is_currency_intent, is_joke_intent, is_anime_intent,
180
- extract_search_query, extract_location, extract_currency_parts,
181
- extract_anime_query,
182
- web_search_and_fetch, get_weather, get_timezone,
183
- get_currency, get_joke, get_anime,
184
  )
 
 
 
185
 
186
- if is_joke_intent(user_input):
187
- tool_result = get_joke()
188
- tool_tag = "joke"
189
-
190
- elif is_weather_intent(user_input):
191
- location = extract_location(user_input)
192
- if token_callback:
193
- token_callback(f"__TOOL__:Checking weather for {location}...")
194
- tool_result = get_weather(location)
195
- tool_tag = "weather_data"
196
-
197
- elif is_timezone_intent(user_input):
198
- location = extract_location(user_input)
199
- if token_callback:
200
- token_callback(f"__TOOL__:Looking up time in {location}...")
201
- tool_result = get_timezone(location)
202
- tool_tag = "time_data"
203
-
204
- elif is_currency_intent(user_input):
205
- amount, from_cur, to_cur = extract_currency_parts(user_input)
206
- if token_callback:
207
- token_callback(f"__TOOL__:Converting {from_cur} to {to_cur}...")
208
- tool_result = get_currency(amount, from_cur, to_cur)
209
- tool_tag = "currency_data"
210
-
211
- elif is_anime_intent(user_input):
212
- query = extract_anime_query(user_input)
213
- if token_callback:
214
- token_callback(f"__TOOL__:Searching anime for {query}...")
215
- tool_result = get_anime(query)
216
- tool_tag = "anime_data"
217
-
218
- elif is_search_intent(user_input):
219
- query = extract_search_query(user_input)
220
- if token_callback:
221
- token_callback(f"__SEARCHING__:{query}")
222
- try:
223
- tool_result = web_search_and_fetch(query)
224
- tool_tag = "search_results"
225
- except Exception as exc:
226
- log.warning("Web search failed: %s", exc)
227
-
228
- if tool_result and tool_tag:
229
- system = (
230
- f"{system}\n\n"
231
- f"<{tool_tag}>\n{tool_result}\n</{tool_tag}>\n\n"
232
- f"Use the above {tool_tag.replace('_', ' ')} to inform your response naturally. "
233
- f"Don't recite raw data β€” weave it into your answer as Aiko would."
234
- )
235
 
236
- # 4. wrap user turn with reasoning instruction if active
237
- if self._reasoning:
238
- prompt = (
239
- f"{user_input}\n\n"
240
- "Think through this carefully before answering. "
241
- "Show your reasoning inside <think> tags, then give your final answer."
242
- )
243
- else:
244
- prompt = user_input
245
 
246
- # 5. append user turn
247
- user_history.append({"role": "user", "content": prompt})
 
 
 
 
248
 
249
- # 6. trim history to context window
250
- trimmed = self._sanitize_history(user_history[-(CONTEXT_WINDOW_TURNS * 2):])
251
 
252
- # 7. LLM call
253
- response_text, _ = self._stream_response(trimmed, system=system)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
 
255
- # 8. remove orphaned user turn on empty response
256
- if not response_text:
257
- if user_history and user_history[-1]["role"] == "user":
258
- user_history.pop()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
 
260
- # 9. append assistant turn to history
261
- user_history.append({"role": "assistant", "content": response_text})
262
 
263
- # 10. persist to memory (background), scoped to effective user
264
- self._store_async(user_input, response_text, effective_user_id)
 
 
 
 
265
 
266
- # 11. auto-reset reasoning mode
267
- self._reasoning = False
 
268
 
269
- return response_text
 
270
 
271
- def reset_context(self, user_id: str | None = None) -> None:
272
- """Clear the in-memory conversation history for a fresh session."""
273
- effective_user_id = user_id or self._user_id or _DEFAULT_USER_ID
274
- if effective_user_id in self._histories:
275
- self._histories[effective_user_id].clear()
276
 
277
- def last_turn(self, user_id: str | None = None) -> tuple[str, str] | None:
278
- """Return the latest complete user/assistant exchange, or None."""
279
- effective_user_id = user_id or self._user_id or _DEFAULT_USER_ID
280
- user_history = self._histories.get(effective_user_id, [])
281
- assistant_text: str | None = None
282
- for message in reversed(user_history):
283
- role = message.get("role")
284
- content = (message.get("content") or "").strip()
285
- if not content:
286
- continue
287
- if assistant_text is None:
288
- if role == "assistant":
289
- assistant_text = content
290
- continue
291
- if role == "user":
292
- return content, assistant_text
293
- return None
294
 
295
- def set_reasoning(self, enabled: bool) -> None:
296
- """Enable or disable reasoning mode for the next turn only."""
297
- self._reasoning = enabled
 
 
 
 
298
 
299
- def wait_for_memory(self) -> None:
300
- """Block until all enqueued memory writes have been persisted."""
301
- self._mem_queue.join()
302
 
303
- # ── internal ──────────────────────────────────────────────────────────────
304
 
305
- def _try_tool_call(self, messages: list[dict], system: str) -> tuple[str | None, str | None]:
306
  """
307
- Ask the LLM whether a tool should be called for this turn.
308
-
309
- Sends the conversation + tool schemas with tool_choice="auto" and a
310
- short max_tokens budget (this is a routing decision, not the final
311
- answer). If the model returns tool_calls, dispatch the first one to
312
- its Python implementation and return (tag, result) for context
313
- injection. Returns (None, None) if no tool call was made, the tool
314
- name/args were invalid, or the request failed for any reason β€” in
315
- which case the caller falls back to regex-based intent detection.
316
  """
317
- from core.tools import TOOL_SCHEMAS, TOOL_DISPATCH
 
 
 
 
 
 
 
 
 
 
 
318
 
 
 
 
 
 
319
  try:
320
- response = self._client.post(
321
- "/",
322
- json={
323
- "messages": [{"role": "system", "content": system}] + messages,
324
- "tools": TOOL_SCHEMAS,
325
- "tool_choice": "auto",
326
- "stream": False,
327
- "temperature": 0.2,
328
- "max_tokens": 150,
329
- },
330
- )
331
- data = response.json()
332
- msg = data.get("choices", [{}])[0].get("message", {})
333
- calls = msg.get("tool_calls")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
334
 
335
- if not calls:
336
- return None, None
337
 
338
- call = calls[0]
339
- name = call.get("function", {}).get("name")
340
- args_raw = call.get("function", {}).get("arguments", "{}")
341
 
342
- if name not in TOOL_DISPATCH:
343
- log.warning("LLM requested unknown tool: %s", name)
344
- return None, None
 
 
 
 
 
 
 
 
 
 
 
 
345
 
346
- try:
347
- args = json.loads(args_raw) if isinstance(args_raw, str) else (args_raw or {})
348
- except (ValueError, TypeError) as exc:
349
- log.warning("Failed to parse tool args for %s: %s (%r)", name, exc, args_raw)
350
- return None, None
351
 
352
- tag, fn = TOOL_DISPATCH[name]
353
- if self._token_callback:
354
- self._token_callback(f"__TOOL__:Calling {name}...")
355
 
356
- try:
357
- result = fn(**args)
358
- except Exception as exc:
359
- log.warning("Tool execution failed for %s: %s", name, exc)
360
- return None, None
361
 
362
- return tag, result
 
 
363
 
364
- except Exception as exc:
365
- log.warning("Tool-call attempt failed: %s", exc)
366
- return None, None
 
 
 
 
367
 
368
- def _stream_response(self, messages: list[dict], system: str = "") -> tuple[str, None]:
369
- num_predict = _BASE_PREDICT * _REASONING_SCALE if self._reasoning else _BASE_PREDICT
370
 
371
- try:
372
- response = self._client.post(
373
- "/",
374
- json={
375
- "messages": ([{"role": "system", "content": system}] + messages) if system else messages,
376
- "stream": False,
377
- "temperature": float(os.getenv("LLAMA_TEMPERATURE", 0.75)),
378
- "max_tokens": num_predict,
379
- "top_p": float(os.getenv("LLAMA_TOP_P", 0.90)),
380
- "top_k": int(os.getenv("LLAMA_TOP_K", 40)),
381
- "repeat_penalty": float(os.getenv("LLAMA_REPEAT_PENALTY", 1.18)),
382
- "stop": ["<|im_end|>", "</s>", "[INST]"],
383
- },
384
- )
385
 
386
- data = response.json()
387
- full_text = data.get("choices", [{}])[0].get("message", {}).get("content", "") or ""
388
- clean_text = re.sub(r"\[?SEARCH:\s*.+?\]?", "", full_text, flags=re.IGNORECASE).strip()
 
 
 
 
 
 
389
 
390
- if self._token_callback and clean_text:
391
- self._token_callback(clean_text)
392
 
393
- except Exception as exc:
394
- msg = f"Stream failed: {exc}"
395
- log.error(msg)
396
- if self._token_callback:
397
- self._token_callback(f"[think] {msg}")
398
- return "", None
399
 
400
- return full_text, None
 
 
401
 
402
- def _sanitize_history(self, messages: list[dict]) -> list[dict]:
403
- """Enforce strict user/assistant alternation."""
404
- if not messages:
405
- return []
406
- sanitized = [messages[0]]
407
- for msg in messages[1:]:
408
- if msg["role"] == sanitized[-1]["role"]:
409
- sanitized[-1] = msg
410
- else:
411
- sanitized.append(msg)
412
- while sanitized and sanitized[0]["role"] != "user":
413
- sanitized.pop(0)
414
- return sanitized
415
-
416
- def _store_async(self, user_input: str, response_text: str, user_id: str) -> None:
417
- self._mem_queue.put((user_input, response_text, user_id))
418
-
419
- def _mem_write_loop(self) -> None:
420
- """Serial background worker that drains the memory write queue."""
421
- while True:
422
- user_input, response_text, user_id = self._mem_queue.get()
423
- try:
424
- if self._memorize:
425
- self._memorize.add(
426
- [
427
- {"role": "user", "content": user_input[:500]},
428
- {"role": "assistant", "content": response_text[:800]},
429
- ],
430
- user_id=user_id,
431
- )
432
- except Exception as exc:
433
- log.error("Async memory write failed: %s", exc)
434
- finally:
435
- self._mem_queue.task_done()
 
1
  """
2
+ core/memorize.py
3
+ Aiko's persistent memory β€” custom backend via sqlite-vec + fastembed + Modal LLM.
4
+ Abstracts all memory calls so think.py stays clean.
5
+
6
+ Memory lifecycle:
7
+ - Every search() call increments access_count and updates last_accessed_at
8
+ in the memories table, enabling Ebbinghaus-style exponential decay scoring.
9
+ - cleanup() deletes memories below decay threshold, with grace period
10
+ protection for newly created entries.
11
+ - Decay logic lives in core/forget.py (pure math, no I/O).
12
+ - Pinned memories (created via pin()) are permanently immune to decay
13
+ cleanup. The pinned flag lives in the memories table.
14
+
15
+ Storage layout (single .db file):
16
+ memories β€” canonical record: id, user_id, memory, metadata
17
+ memories_fts β€” FTS5 virtual table for lexical search (BM25)
18
+ memories_vec β€” vec0 virtual table for KNN cosine search
19
+
20
+ Recall strategy β€” Reciprocal Rank Fusion (RRF):
21
+ score = 1/(k + rank_knn) + 1/(k + rank_fts)
22
+ k=60 (standard RRF constant β€” dampens outlier ranks)
23
+
24
+ KNN catches semantic similarity ("I love cats" ↔ "I adore cats")
25
+ FTS5 catches exact token matches ("Max", "birthday", proper nouns)
26
+ RRF fuses both without weighting either arbitrarily.
27
+
28
+ Custom backend (replaces Qdrant + mem0):
29
+ - _MemoryBackend handles LLM-based fact extraction, fastembed embeddings,
30
+ and direct sqlite-vec upsert/search/delete/scroll.
31
+ - Extraction prompt is tuned for small models: asks for a JSON array of
32
+ atomic facts, strips <think> blocks for CoT models, skips trivial turns.
33
+ - All schema fields (memory, user_id, created_at, access_count,
34
+ last_accessed_at, pinned) are owned by this module β€” no hidden schema.
35
+
36
+ Extraction LLM:
37
+ - Uses LLAMA_BASE_URL (Modal OpenAI-compat endpoint) + LLAMA_API_KEY.
38
+
39
+ Dependencies:
40
+ pip install sqlite-vec fastembed
41
  """
42
+ from dotenv import load_dotenv
43
+ load_dotenv()
44
 
 
45
  import json
46
+ import os
 
 
 
47
  import re
48
+ import sqlite3
49
+ import struct
50
+ import time
51
+ import uuid
52
+ from datetime import datetime, timezone
53
+ from pathlib import Path
54
+ from typing import Optional
55
 
56
+ import httpx
57
+ import sqlite_vec
58
+ from fastembed import TextEmbedding
59
+
60
+ from core.forget import compute_weighted_score, should_cleanup, CLEANUP_THRESHOLD
61
+ from core.log import get_logger
62
 
63
  log = get_logger(__name__)
64
 
65
  # ── boot labels ───────────────────────────────────────────────────────────────
66
 
67
  BOOT_LABELS = {
68
+ 'mem_sqlite_vec': 'Opening sqlite-vec memory store...',
69
+ 'mem_embed': 'Loading fastembed model...',
70
+ 'mem_cleanup': 'Running memory cleanup...',
71
+ 'mem_ready': 'Memory backend ready',
72
  }
73
 
74
+ # ── constants ─────────────────────────────────────────────────────────────────
75
 
76
+ EMBED_MODEL = "BAAI/bge-base-en-v1.5"
77
+ EMBED_DIMS = 768
78
+ RRF_K = 60 # standard RRF constant β€” dampens outlier ranks
79
+ KNN_LIMIT = 20 # candidates fetched before RRF re-rank
80
+ FTS_LIMIT = 20 # candidates fetched before RRF re-rank
81
 
82
+ USER_ID = os.getenv("USER_ID", "Guest")
 
83
 
84
+ # Minimum conversation size (chars) worth sending to LLM for extraction.
85
+ # Skips trivial turns (greetings, one-word replies) to save inference time.
86
+ _EXTRACT_MIN_CHARS = int(os.getenv("MEMORY_EXTRACT_MIN_CHARS", 80))
87
 
88
+ # Extraction prompt β€” tuned for small models.
89
+ # {user_id} and {conversation} are formatted at call time so facts are always
90
+ # scoped to the correct user, not hardcoded to a specific name.
91
+ _EXTRACT_PROMPT = """\
92
+ Extract memorable facts about the USER from this conversation.
93
+ The USER is {user_id}. The ASSISTANT is Aiko.
94
+ Write every fact from Aiko's perspective, using second-person for the user.
95
+ Example format: "Oppa's birthday is June 3, 2026" "Oppa created you (Aiko) recently"
96
+ Return ONLY a JSON array of short strings. Each string is one atomic fact.
97
+ Facts should be about the user's preferences, identity, life, or goals.
98
+ If nothing is worth remembering, return: []
99
+ Do NOT include facts about Aiko's own behavior or feelings.
100
+ Do NOT explain. No markdown.
101
 
102
+ Conversation:
103
+ {conversation}"""
104
 
105
+
106
+ def _sanitize_fts_query(query: str) -> str:
107
+ """
108
+ Strip characters that break FTS5 query parsing.
109
+ FTS5 treats , " ( ) * ^ : - ' as syntax tokens β€” remove them all.
110
+ """
111
+ cleaned = re.sub(r'[^\w\s]', ' ', query)
112
+ cleaned = ' '.join(cleaned.split())
113
+ return cleaned or "*"
114
+
115
+
116
+ # ── schema ────────────────────────────────────────────────────────────────────
117
+
118
+ _DDL = """
119
+ PRAGMA journal_mode = WAL;
120
+ PRAGMA foreign_keys = ON;
121
+
122
+ CREATE TABLE IF NOT EXISTS memories (
123
+ id TEXT PRIMARY KEY,
124
+ user_id TEXT NOT NULL,
125
+ memory TEXT NOT NULL,
126
+ created_at TEXT NOT NULL,
127
+ access_count INTEGER NOT NULL DEFAULT 0,
128
+ last_accessed_at TEXT NOT NULL DEFAULT 'never',
129
+ pinned INTEGER NOT NULL DEFAULT 0
130
+ );
131
+
132
+ CREATE INDEX IF NOT EXISTS idx_memories_user ON memories(user_id);
133
+
134
+ CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
135
+ memory,
136
+ id UNINDEXED,
137
+ content='memories',
138
+ content_rowid='rowid'
139
+ );
140
+
141
+ CREATE VIRTUAL TABLE IF NOT EXISTS memories_vec USING vec0(
142
+ id TEXT PRIMARY KEY,
143
+ embedding FLOAT[{dims}]
144
+ );
145
+
146
+ CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
147
+ INSERT INTO memories_fts(rowid, memory, id)
148
+ VALUES (new.rowid, new.memory, new.id);
149
+ END;
150
+
151
+ CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
152
+ INSERT INTO memories_fts(memories_fts, rowid, memory, id)
153
+ VALUES ('delete', old.rowid, old.memory, old.id);
154
+ END;
155
+
156
+ CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE OF memory ON memories BEGIN
157
+ INSERT INTO memories_fts(memories_fts, rowid, memory, id)
158
+ VALUES ('delete', old.rowid, old.memory, old.id);
159
+ INSERT INTO memories_fts(rowid, memory, id)
160
+ VALUES (new.rowid, new.memory, new.id);
161
+ END;
162
+ """.format(dims=EMBED_DIMS)
163
+
164
+
165
+ # ── sqlite helpers ────────────────────────────────────────────────────────────
166
+
167
+ def _sqlite_get_payload(conn: sqlite3.Connection, mem_id: str) -> dict:
168
+ conn.row_factory = sqlite3.Row
169
+ row = conn.execute(
170
+ "SELECT * FROM memories WHERE id = ?", (mem_id,)
171
+ ).fetchone()
172
+ return dict(row) if row else {}
173
+
174
+
175
+ def _sqlite_set_payload(conn: sqlite3.Connection, mem_id: str, payload: dict) -> None:
176
+ if not payload:
177
+ return
178
+ cols = ", ".join(f"{k} = ?" for k in payload)
179
+ vals = list(payload.values()) + [mem_id]
180
+ conn.execute(f"UPDATE memories SET {cols} WHERE id = ?", vals)
181
+ conn.commit()
182
+
183
+
184
+ def _sqlite_batch_get_payloads(conn: sqlite3.Connection, mem_ids: list[str]) -> dict:
185
+ if not mem_ids:
186
+ return {}
187
+ conn.row_factory = sqlite3.Row
188
+ placeholders = ",".join("?" * len(mem_ids))
189
+ rows = conn.execute(
190
+ f"SELECT id, access_count, last_accessed_at FROM memories WHERE id IN ({placeholders})",
191
+ mem_ids,
192
+ ).fetchall()
193
+ return {
194
+ r["id"]: (r["access_count"] or 0, r["last_accessed_at"] or "never")
195
+ for r in rows
196
+ }
197
+
198
+
199
+ def _sqlite_is_pinned(conn: sqlite3.Connection, mem_id: str) -> bool:
200
+ row = conn.execute(
201
+ "SELECT pinned FROM memories WHERE id = ?", (mem_id,)
202
+ ).fetchone()
203
+ return bool(row and row[0])
204
+
205
+
206
+ def _sqlite_knn_search(
207
+ conn: sqlite3.Connection,
208
+ vector: list[float],
209
+ user_id: str,
210
+ limit: int,
211
+ ) -> list[sqlite3.Row]:
212
+ vec_blob = sqlite_vec.serialize_float32(vector)
213
+ rows = conn.execute(
214
+ """
215
+ SELECT v.id, vec_distance_cosine(v.embedding, ?) AS dist
216
+ FROM memories_vec v
217
+ JOIN memories m ON m.id = v.id
218
+ WHERE m.user_id = ?
219
+ ORDER BY dist ASC
220
+ LIMIT ?
221
+ """,
222
+ (vec_blob, user_id, limit),
223
+ ).fetchall()
224
+ return rows
225
 
226
 
227
+ # ── extraction LLM call ──────────────────���────────────────────────────────────
228
 
229
+ def _call_extraction_llm(prompt: str, base_url: str, api_key: str) -> str:
230
  """
231
+ Send the extraction prompt to the Modal OpenAI-compat endpoint.
232
+ Raises on failure β€” caller catches and returns [].
233
+ """
234
+ headers = {"Content-Type": "application/json"}
235
+ if api_key:
236
+ headers["Authorization"] = f"Bearer {api_key}"
237
+
238
+ resp = httpx.post(
239
+ base_url.rstrip('/'),
240
+ headers=headers,
241
+ json={
242
+ "messages": [{"role": "user", "content": prompt}],
243
+ "stream": False,
244
+ "temperature": 0.1,
245
+ "max_tokens": 512,
246
+ },
247
+ timeout=45,
248
+ )
249
+ resp.raise_for_status()
250
+ return resp.json()["choices"][0]["message"]["content"].strip()
251
+
252
+
253
+ # ── memory backend ────────────────────────────────────────────────────────────
254
+
255
+ class _MemoryBackend:
256
+ """
257
+ sqlite-vec + FTS5 + RRF backend.
258
+ Public API: add(), search(), get_all(), delete(), delete_all()
259
  """
260
 
261
+ def __init__(
262
+ self,
263
+ db_path: str,
264
+ llama_base_url: str,
265
+ llama_api_key: str,
266
+ fastembed_cache: Optional[str] = None,
267
+ ) -> None:
268
+ Path(db_path).parent.mkdir(parents=True, exist_ok=True)
269
+ self._db_path = db_path
270
+ self._llama_base_url = llama_base_url
271
+ self._llama_api_key = llama_api_key
272
+ self._embedder = TextEmbedding(
273
+ model_name=EMBED_MODEL,
274
+ cache_dir=fastembed_cache,
275
  )
276
+ self._conn = self._connect()
277
+ self._apply_schema()
278
+
279
+ def _connect(self) -> sqlite3.Connection:
280
+ conn = sqlite3.connect(self._db_path, check_same_thread=False)
281
+ conn.row_factory = sqlite3.Row
282
+ sqlite_vec.load(conn)
283
+ return conn
284
+
285
+ def _apply_schema(self) -> None:
286
+ self._conn.executescript(_DDL)
287
+ self._conn.commit()
288
+
289
+ def _embed(self, text: str) -> list[float]:
290
+ return list(self._embedder.embed([text]))[0].tolist()
291
+
292
+ # ── extraction ────────────────────────────────────────────────────────────
293
+
294
+ def _should_extract(self, messages: list[dict]) -> bool:
295
+ total = sum(
296
+ len(m.get("content") or "")
297
+ for m in messages
298
+ if m.get("role") in ("user", "assistant")
299
+ and (m.get("content") or "").strip()
300
+ )
301
+ return total >= _EXTRACT_MIN_CHARS
 
 
 
 
 
 
 
 
 
 
 
 
 
302
 
303
+ def _extract_facts(self, messages: list[dict], user_id: str) -> list[str]:
304
+ if not self._should_extract(messages):
305
+ return []
306
 
307
+ clean_messages = [
308
+ m for m in messages
309
+ if m.get("role") in ("user", "assistant")
310
+ and (m.get("content") or "").strip()
311
+ ]
312
 
313
+ while clean_messages and clean_messages[0].get("role") != "user":
314
+ clean_messages.pop(0)
315
+ while len(clean_messages) > 1 and clean_messages[-1].get("role") == "assistant":
316
+ if any(m.get("role") == "user" for m in clean_messages[:-1]):
317
+ break
318
+ clean_messages.pop()
319
+
320
+ if not clean_messages:
321
+ return []
322
+
323
+ total = sum(len(m.get("content") or "") for m in clean_messages)
324
+ if total < _EXTRACT_MIN_CHARS:
325
+ return []
326
+
327
+ convo = "\n".join(
328
+ f"{m['role'].upper()}: {m['content'].strip()}"
329
+ for m in clean_messages
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330
  )
331
+ prompt = _EXTRACT_PROMPT.format(user_id=user_id, conversation=convo)
332
 
333
+ try:
334
+ raw = _call_extraction_llm(
335
+ prompt=prompt,
336
+ base_url=self._llama_base_url,
337
+ api_key=self._llama_api_key,
 
 
 
 
 
 
338
  )
339
+ except Exception as e:
340
+ log.warning("Extraction LLM call failed: %s", e)
341
+ return []
342
 
343
+ raw = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip()
344
+ raw = re.sub(r"^```(?:json)?|```$", "", raw, flags=re.MULTILINE).strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
345
 
346
+ try:
347
+ facts = json.loads(raw)
348
+ if isinstance(facts, list):
349
+ return [f.strip() for f in facts if isinstance(f, str) and f.strip()]
350
+ except json.JSONDecodeError:
351
+ log.warning("Failed to parse extraction JSON: %r", raw[:200])
352
+
353
+ return []
 
354
 
355
+ # ── write ─────────────────────────────────────────────────────────────────
356
+
357
+ def add(self, messages: list[dict], user_id: str) -> list[str]:
358
+ facts = self._extract_facts(messages, user_id=user_id)
359
+ if not facts:
360
+ return []
361
 
362
+ now = datetime.now(timezone.utc).isoformat()
363
+ ids = []
364
 
365
+ for fact in facts:
366
+ mem_id = str(uuid.uuid4())
367
+ try:
368
+ vector = self._embed(fact)
369
+ self._conn.execute(
370
+ """
371
+ INSERT INTO memories
372
+ (id, user_id, memory, created_at, access_count, last_accessed_at, pinned)
373
+ VALUES (?, ?, ?, ?, 0, 'never', 0)
374
+ """,
375
+ (mem_id, user_id, fact, now),
376
+ )
377
+ self._conn.execute(
378
+ "INSERT INTO memories_vec(id, embedding) VALUES (?, ?)",
379
+ (mem_id, sqlite_vec.serialize_float32(vector)),
380
+ )
381
+ self._conn.commit()
382
+ ids.append(mem_id)
383
+ except Exception as e:
384
+ log.warning("Failed to upsert fact %r: %s", mem_id, e)
385
+ self._conn.rollback()
386
+
387
+ return ids
388
+
389
+ # ── read ──────────────────────────────────────────────────────────────────
390
+
391
+ def search(self, query: str, user_id: str, limit: int = 5) -> list[dict]:
392
+ vector = self._embed(query)
393
+ knn_rows = _sqlite_knn_search(self._conn, vector, user_id, KNN_LIMIT)
394
+ rank_knn = {row["id"]: i + 1 for i, row in enumerate(knn_rows)}
395
+
396
+ fts_rows = self._conn.execute(
397
+ """
398
+ SELECT f.id
399
+ FROM memories_fts f
400
+ JOIN memories m ON m.id = f.id
401
+ WHERE memories_fts MATCH ?
402
+ AND m.user_id = ?
403
+ ORDER BY rank
404
+ LIMIT ?
405
+ """,
406
+ (_sanitize_fts_query(query), user_id, FTS_LIMIT),
407
+ ).fetchall()
408
+ rank_fts = {row["id"]: i + 1 for i, row in enumerate(fts_rows)}
409
+
410
+ all_ids = set(rank_knn) | set(rank_fts)
411
+ if not all_ids:
412
+ return []
413
 
414
+ def rrf(mem_id: str) -> float:
415
+ score = 0.0
416
+ if mem_id in rank_knn:
417
+ score += 1.0 / (RRF_K + rank_knn[mem_id])
418
+ if mem_id in rank_fts:
419
+ score += 1.0 / (RRF_K + rank_fts[mem_id])
420
+ return score
421
+
422
+ ranked = sorted(all_ids, key=rrf, reverse=True)[:limit]
423
+ placeholders = ",".join("?" * len(ranked))
424
+ rows = self._conn.execute(
425
+ f"SELECT * FROM memories WHERE id IN ({placeholders})", ranked
426
+ ).fetchall()
427
+
428
+ order = {mid: i for i, mid in enumerate(ranked)}
429
+ rows_sorted = sorted(rows, key=lambda r: order.get(r["id"], 999))
430
+ return [dict(r) for r in rows_sorted]
431
+
432
+ def get_all(self, user_id: str) -> list[dict]:
433
+ rows = self._conn.execute(
434
+ "SELECT * FROM memories WHERE user_id = ?", (user_id,)
435
+ ).fetchall()
436
+ return [dict(r) for r in rows]
437
+
438
+ def delete(self, memory_id: str) -> None:
439
+ self._conn.execute("DELETE FROM memories WHERE id = ?", (memory_id,))
440
+ self._conn.execute("DELETE FROM memories_vec WHERE id = ?", (memory_id,))
441
+ self._conn.commit()
442
+
443
+ def delete_all(self, user_id: str) -> None:
444
+ ids = [
445
+ r["id"] for r in self._conn.execute(
446
+ "SELECT id FROM memories WHERE user_id = ?", (user_id,)
447
+ ).fetchall()
448
+ ]
449
+ if not ids:
450
+ return
451
+ placeholders = ",".join("?" * len(ids))
452
+ self._conn.execute(f"DELETE FROM memories WHERE id IN ({placeholders})", ids)
453
+ self._conn.execute(f"DELETE FROM memories_vec WHERE id IN ({placeholders})", ids)
454
+ self._conn.commit()
455
+
456
+
457
+ # ── memorize ──────────────────────────────────────────────────────────────────
458
+
459
+ class AikoMemorize:
460
+ """
461
+ Persistent memory with Ebbinghaus decay lifecycle.
462
 
463
+ Uses _MemoryBackend (LLM extraction + fastembed + sqlite-vec).
 
464
 
465
+ Env vars:
466
+ LLAMA_BASE_URL β€” Modal OpenAI-compat endpoint
467
+ LLAMA_API_KEY β€” Modal API key (optional)
468
+ SQLITE_MEMORY_PATH β€” path to .db file (default: ~/.aiko/memory.db)
469
+ Point this at a persistent volume on HF Space.
470
+ FASTEMBED_CACHE_PATH β€” optional cache dir for fastembed model weights
471
 
472
+ Boot sequence (called by wakeup.py):
473
+ memorize = AikoMemorize()
474
+ memorize.cleanup()
475
 
476
+ Pinned memories are immune to cleanup() regardless of decay score.
477
+ """
478
 
479
+ def __init__(self, silent: bool = False) -> None:
480
+ db_path = os.getenv(
481
+ "SQLITE_MEMORY_PATH",
482
+ str(Path.home() / ".aiko" / "memory.db"),
483
+ )
484
 
485
+ if not silent:
486
+ log.info("Opening sqlite-vec memory store...")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
487
 
488
+ self._mem = _MemoryBackend(
489
+ db_path=db_path,
490
+ llama_base_url=os.getenv("LLAMA_BASE_URL", ""),
491
+ llama_api_key=os.getenv("LLAMA_API_KEY", ""),
492
+ fastembed_cache=os.getenv("FASTEMBED_CACHE_PATH"),
493
+ )
494
+ self._conn = self._mem._conn
495
 
496
+ if not silent:
497
+ log.info("Ready.")
 
498
 
499
+ # ── write ─────────────────────────────────────────────────────────────────
500
 
501
+ def add(self, messages: list[dict], user_id: str = USER_ID) -> bool:
502
  """
503
+ Extract facts from a conversation turn and persist to memory.
504
+ Returns True on success, False on failure.
 
 
 
 
 
 
 
505
  """
506
+ try:
507
+ t = time.perf_counter()
508
+ ids = self._mem.add(messages, user_id=user_id)
509
+ elapsed = time.perf_counter() - t
510
+ if ids:
511
+ log.info("Saved %d memories in %.2fs", len(ids), elapsed)
512
+ else:
513
+ log.debug("No facts extracted (%.2fs) β€” nothing saved.", elapsed)
514
+ return True
515
+ except Exception as e:
516
+ log.error("Save failed: %s", e)
517
+ return False
518
 
519
+ def pin(self, messages: list[dict], user_id: str = USER_ID) -> bool:
520
+ """
521
+ Store messages and mark all resulting memories as pinned.
522
+ Pinned memories are immune to cleanup() regardless of decay score.
523
+ """
524
  try:
525
+ before = {str(m["id"]) for m in self.get_all(user_id=user_id)}
526
+ ok = self.add(messages, user_id=user_id)
527
+ if not ok:
528
+ return False
529
+ after = {str(m["id"]) for m in self.get_all(user_id=user_id)}
530
+ pin_ids = list(after - before)
531
+
532
+ if not pin_ids:
533
+ query = "\n".join(
534
+ (m.get("content") or "").strip()
535
+ for m in messages
536
+ if (m.get("content") or "").strip()
537
+ )
538
+ pin_ids = [
539
+ str(m.get("id"))
540
+ for m in self.search(query, user_id=user_id, limit=3)
541
+ if m.get("id")
542
+ ]
543
+
544
+ if not pin_ids:
545
+ log.warning("pin(): add succeeded but no memory IDs found to pin.")
546
+ return False
547
+
548
+ for mem_id in pin_ids:
549
+ _sqlite_set_payload(self._conn, mem_id, {"pinned": 1})
550
+
551
+ log.info("Pinned %d memories: %s", len(pin_ids), pin_ids)
552
+ return True
553
+ except Exception as e:
554
+ log.error("Pin failed: %s", e)
555
+ return False
556
+
557
+ # ── read ──────────────────────────────────────────────────────────────────
558
+
559
+ def search(self, query: str, user_id: str = USER_ID, limit: int = 5) -> list[dict]:
560
+ """
561
+ Retrieve top-k memories relevant to query via KNN + FTS5 RRF fusion.
562
+ Side-effect: increments access_count and updates last_accessed_at.
563
+ """
564
+ results = self._mem.search(query, user_id=user_id, limit=limit)
565
+
566
+ if results:
567
+ now = datetime.now(timezone.utc).isoformat()
568
+ for r in results:
569
+ mem_id = str(r.get("id", ""))
570
+ if not mem_id:
571
+ continue
572
+ try:
573
+ payload = _sqlite_get_payload(self._conn, mem_id)
574
+ current_count = payload.get("access_count", 0) or 0
575
+ _sqlite_set_payload(self._conn, mem_id, {
576
+ "access_count": min(current_count + 1, 255),
577
+ "last_accessed_at": now,
578
+ })
579
+ except Exception as e:
580
+ log.warning("Access tracking failed for %s: %s", mem_id, e)
581
+
582
+ return results
583
+
584
+ def format_for_context(self, memories: list[dict]) -> Optional[str]:
585
+ """
586
+ Format retrieved memories into a string for injection into system prompt.
587
+ Returns None if nothing to inject.
588
+ """
589
+ if not memories:
590
+ return None
591
+
592
+ now = datetime.now(timezone.utc)
593
+ lines = [
594
+ "<memory_context>",
595
+ "The following are background facts about this person, with how long ago they were recorded.",
596
+ "Use them silently to inform your response. Never repeat, quote, or reference this block directly.",
597
+ "",
598
+ ]
599
+ for m in memories:
600
+ text = m.get("memory") or m.get("text") or str(m)
601
+ created_at = m.get("created_at")
602
+ if created_at:
603
+ try:
604
+ ts = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
605
+ delta = now - ts
606
+ days = delta.days
607
+ if days == 0:
608
+ age = "today"
609
+ elif days == 1:
610
+ age = "yesterday"
611
+ else:
612
+ age = f"{days} days ago"
613
+ lines.append(f" - [{age}] {text}")
614
+ except Exception:
615
+ lines.append(f" - {text}")
616
+ else:
617
+ lines.append(f" - {text}")
618
 
619
+ lines.append("</memory_context>")
620
+ return "\n".join(lines)
621
 
622
+ # ── lifecycle ─────────────────────────────────────────────────────────────
 
 
623
 
624
+ def cleanup(
625
+ self,
626
+ user_id: str = USER_ID,
627
+ threshold: float = CLEANUP_THRESHOLD,
628
+ dry_run: bool = False,
629
+ ) -> dict:
630
+ """
631
+ Prune decayed memories below threshold score.
632
+ Grace period (14 days) protects newly created memories.
633
+ Pinned memories are unconditionally kept.
634
+ Returns dict: {deleted, kept, failed}
635
+ """
636
+ all_mems = self.get_all(user_id=user_id)
637
+ if not all_mems:
638
+ return {"deleted": 0, "kept": 0, "failed": 0}
639
 
640
+ mem_ids = [str(m.get("id", "")) for m in all_mems if m.get("id")]
641
+ payload_map = _sqlite_batch_get_payloads(self._conn, mem_ids)
 
 
 
642
 
643
+ candidates = []
644
+ kept = 0
 
645
 
646
+ for m in all_mems:
647
+ mem_id = str(m.get("id", ""))
648
+ ac, la = payload_map.get(mem_id, (0, "never"))
649
+ created_at = m.get("created_at", "")
 
650
 
651
+ if _sqlite_is_pinned(self._conn, mem_id):
652
+ kept += 1
653
+ continue
654
 
655
+ if should_cleanup(ac, la, created_at):
656
+ candidates.append({
657
+ "id": mem_id,
658
+ "weighted_score": round(compute_weighted_score(ac, la), 4),
659
+ })
660
+ else:
661
+ kept += 1
662
 
663
+ candidates.sort(key=lambda x: x["weighted_score"])
 
664
 
665
+ if dry_run:
666
+ log.info("Dry run: %d candidates for deletion, %d kept.", len(candidates), kept)
667
+ return {"deleted": 0, "kept": kept, "failed": 0, "candidates": candidates}
 
 
 
 
 
 
 
 
 
 
 
668
 
669
+ deleted = 0
670
+ failed = 0
671
+ for c in candidates:
672
+ try:
673
+ self._mem.delete(memory_id=c["id"])
674
+ deleted += 1
675
+ except Exception as e:
676
+ log.warning("Cleanup delete failed for %s: %s", c["id"], e)
677
+ failed += 1
678
 
679
+ log.info("Cleanup: deleted=%d, kept=%d, failed=%d", deleted, kept, failed)
680
+ return {"deleted": deleted, "kept": kept, "failed": failed}
681
 
682
+ # ── debug ─────────────────────────────────────────────────────────────────
 
 
 
 
 
683
 
684
+ def get_all(self, user_id: str = USER_ID) -> list[dict]:
685
+ """Return all stored memories for a user."""
686
+ return self._mem.get_all(user_id=user_id)
687
 
688
+ def clear(self, user_id: str = USER_ID) -> None:
689
+ """Wipe all memories for a user. Use carefully."""
690
+ self._mem.delete_all(user_id=user_id)
691
+ log.info("Cleared all memories for user '%s'.", user_id)