n3memorycore-free 1.3.1__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.
@@ -0,0 +1,7 @@
1
+ """N3MemoryCore — Free Edition.
2
+
3
+ A persistent hybrid-search memory store (SQLite + sqlite-vec + FTS5)
4
+ for Claude Code, with hook integration.
5
+ """
6
+
7
+ __version__ = "1.3.1"
File without changes
@@ -0,0 +1,308 @@
1
+ import sqlite3
2
+ import struct
3
+ import re
4
+ import logging
5
+ from typing import Optional, List, Tuple
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ _FTS_PUNCT_RE = re.compile(r'[()[\]{}<>,.!?;:\-—–…\'\"\`~@#$%^&*+=|/\\]')
10
+ _FTS_MAX_TERMS = 30
11
+
12
+
13
+ def strip_fts_punctuation(text: str) -> str:
14
+ # Substitute with space, not empty: dashes between words ("Alpha-9") should
15
+ # split into ["Alpha", "9"], not concatenate into "Alpha9".
16
+ cleaned = _FTS_PUNCT_RE.sub(' ', text)
17
+ return re.sub(r'\s+', ' ', cleaned).strip()
18
+
19
+
20
+ def _quote_fts_query(text: str) -> str:
21
+ stripped = strip_fts_punctuation(text)
22
+ terms = stripped.split()[:_FTS_MAX_TERMS]
23
+ return ' '.join(f'"{t}"' for t in terms)
24
+
25
+
26
+ def serialize_vector(vec: list) -> bytes:
27
+ return struct.pack(f'{len(vec)}f', *vec)
28
+
29
+
30
+ def deserialize_vector(data: bytes) -> list:
31
+ n = len(data) // 4
32
+ return list(struct.unpack(f'{n}f', data))
33
+
34
+
35
+ def _load_vec_extension(conn: sqlite3.Connection) -> None:
36
+ try:
37
+ conn.enable_load_extension(True)
38
+ try:
39
+ import sqlite_vec
40
+ sqlite_vec.load(conn)
41
+ except Exception as e1:
42
+ msg = str(e1).lower()
43
+ if 'already' in msg or 'duplicate' in msg:
44
+ return
45
+ # Fallback: direct load
46
+ try:
47
+ conn.load_extension('vec0')
48
+ except Exception as e2:
49
+ msg2 = str(e2).lower()
50
+ if 'already' in msg2 or 'duplicate' in msg2:
51
+ return
52
+ raise e2
53
+ except Exception as e:
54
+ msg = str(e).lower()
55
+ if 'already' in msg or 'duplicate' in msg:
56
+ return
57
+ raise
58
+
59
+
60
+ def get_connection(db_path: str) -> sqlite3.Connection:
61
+ conn = sqlite3.connect(db_path, check_same_thread=False)
62
+ try:
63
+ _load_vec_extension(conn)
64
+ except Exception as e:
65
+ conn.close()
66
+ raise sqlite3.DatabaseError(
67
+ f"Failed to load sqlite-vec extension: {e}. "
68
+ "Recovery: pip install sqlite-vec"
69
+ ) from e
70
+ conn.execute("PRAGMA synchronous = FULL")
71
+ conn.execute("PRAGMA journal_mode = WAL")
72
+ conn.row_factory = sqlite3.Row
73
+ return conn
74
+
75
+
76
+ def init_db(conn: sqlite3.Connection) -> None:
77
+ conn.execute("""
78
+ CREATE TABLE IF NOT EXISTS memories (
79
+ id TEXT PRIMARY KEY,
80
+ content TEXT NOT NULL,
81
+ timestamp TEXT NOT NULL,
82
+ owner_id TEXT NOT NULL,
83
+ local_id TEXT,
84
+ agent_name TEXT,
85
+ session_id TEXT,
86
+ turn_id TEXT
87
+ )
88
+ """)
89
+ conn.execute("""
90
+ CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
91
+ content,
92
+ tokenize='porter unicode61'
93
+ )
94
+ """)
95
+ conn.execute("""
96
+ CREATE VIRTUAL TABLE IF NOT EXISTS memories_vec USING vec0(
97
+ embedding float[768]
98
+ )
99
+ """)
100
+ # Defer the turn_id index to migrate_schema so an old table without the
101
+ # column does not blow up here.
102
+ cols = {r[1] for r in conn.execute("PRAGMA table_info(memories)").fetchall()}
103
+ if 'turn_id' in cols:
104
+ conn.execute(
105
+ "CREATE INDEX IF NOT EXISTS idx_memories_turn_id ON memories(turn_id)"
106
+ )
107
+ conn.commit()
108
+
109
+
110
+ def migrate_schema(conn: sqlite3.Connection) -> None:
111
+ cursor = conn.execute("PRAGMA table_info(memories)")
112
+ existing_cols = {row[1] for row in cursor.fetchall()}
113
+
114
+ for col, typedef in [
115
+ ("local_id", "TEXT"),
116
+ ("agent_name", "TEXT"),
117
+ ("session_id", "TEXT"),
118
+ ("turn_id", "TEXT"),
119
+ ]:
120
+ if col not in existing_cols:
121
+ try:
122
+ conn.execute(f"ALTER TABLE memories ADD COLUMN {col} {typedef}")
123
+ conn.commit()
124
+ except Exception:
125
+ pass
126
+
127
+ cursor = conn.execute(
128
+ "SELECT name FROM sqlite_master WHERE type='index' AND name='idx_memories_turn_id'"
129
+ )
130
+ if not cursor.fetchone():
131
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_memories_turn_id ON memories(turn_id)")
132
+ conn.commit()
133
+
134
+ # Migrate FTS tokenizer if trigram
135
+ cursor = conn.execute(
136
+ "SELECT sql FROM sqlite_master WHERE type='table' AND name='memories_fts'"
137
+ )
138
+ row = cursor.fetchone()
139
+ if row and row[0] and 'trigram' in row[0].lower():
140
+ cursor2 = conn.execute("SELECT rowid, content FROM memories_fts")
141
+ existing_fts = cursor2.fetchall()
142
+ conn.execute("DROP TABLE IF EXISTS memories_fts")
143
+ conn.execute("""
144
+ CREATE VIRTUAL TABLE memories_fts USING fts5(
145
+ content,
146
+ tokenize='porter unicode61'
147
+ )
148
+ """)
149
+ for r in existing_fts:
150
+ stripped = strip_fts_punctuation(r[1])
151
+ conn.execute(
152
+ "INSERT INTO memories_fts(rowid, content) VALUES (?, ?)",
153
+ (r[0], stripped)
154
+ )
155
+ conn.commit()
156
+ logger.info("Migrated FTS tokenizer from trigram to porter unicode61")
157
+
158
+
159
+ def insert_memory(
160
+ conn: sqlite3.Connection,
161
+ id: str,
162
+ content: str,
163
+ timestamp: str,
164
+ owner_id: str,
165
+ embedding: Optional[list] = None,
166
+ local_id: Optional[str] = None,
167
+ agent_name: Optional[str] = None,
168
+ session_id: Optional[str] = None,
169
+ turn_id: Optional[str] = None,
170
+ ) -> int:
171
+ conn.execute(
172
+ """INSERT INTO memories
173
+ (id, content, timestamp, owner_id, local_id, agent_name, session_id, turn_id)
174
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
175
+ (id, content, timestamp, owner_id, local_id, agent_name, session_id, turn_id),
176
+ )
177
+ rowid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
178
+ stripped = strip_fts_punctuation(content)
179
+ conn.execute(
180
+ "INSERT INTO memories_fts(rowid, content) VALUES (?, ?)", (rowid, stripped)
181
+ )
182
+ if embedding is not None:
183
+ conn.execute(
184
+ "INSERT INTO memories_vec(rowid, embedding) VALUES (?, ?)",
185
+ (rowid, serialize_vector(embedding)),
186
+ )
187
+ conn.commit()
188
+ return rowid
189
+
190
+
191
+ def search_vector(
192
+ conn: sqlite3.Connection, embedding: list, k: int = 50
193
+ ) -> List[sqlite3.Row]:
194
+ vec_bytes = serialize_vector(embedding)
195
+ try:
196
+ cursor = conn.execute(
197
+ """SELECT m.id, m.content, m.timestamp, m.owner_id, m.local_id,
198
+ m.agent_name, m.session_id, m.turn_id, mv.distance, m.rowid
199
+ FROM memories_vec mv
200
+ JOIN memories m ON mv.rowid = m.rowid
201
+ WHERE mv.embedding MATCH ?
202
+ AND k = ?
203
+ ORDER BY mv.distance""",
204
+ (vec_bytes, k),
205
+ )
206
+ return cursor.fetchall()
207
+ except Exception as e:
208
+ logger.warning(f"Vector search failed: {e}")
209
+ return []
210
+
211
+
212
+ def search_fts(
213
+ conn: sqlite3.Connection, query: str, limit: int = 50
214
+ ) -> List[sqlite3.Row]:
215
+ if len(query.strip()) < 2:
216
+ return []
217
+ fts_query = _quote_fts_query(query)
218
+ if not fts_query.strip():
219
+ return []
220
+ try:
221
+ cursor = conn.execute(
222
+ """SELECT m.id, m.content, m.timestamp, m.owner_id, m.local_id,
223
+ m.agent_name, m.session_id, m.turn_id,
224
+ bm25(memories_fts) AS bm25_score, m.rowid
225
+ FROM memories_fts
226
+ JOIN memories m ON memories_fts.rowid = m.rowid
227
+ WHERE memories_fts MATCH ?
228
+ ORDER BY bm25_score
229
+ LIMIT ?""",
230
+ (fts_query, limit),
231
+ )
232
+ return cursor.fetchall()
233
+ except Exception as e:
234
+ logger.warning(f"FTS search failed: {e}")
235
+ return []
236
+
237
+
238
+ def get_all_memories(conn: sqlite3.Connection) -> List[sqlite3.Row]:
239
+ cursor = conn.execute(
240
+ """SELECT id, content, timestamp, owner_id, local_id, agent_name,
241
+ session_id, turn_id, rowid
242
+ FROM memories
243
+ ORDER BY rowid"""
244
+ )
245
+ return cursor.fetchall()
246
+
247
+
248
+ def delete_memory(conn: sqlite3.Connection, memory_id: str) -> bool:
249
+ row = conn.execute(
250
+ "SELECT rowid FROM memories WHERE id = ?", (memory_id,)
251
+ ).fetchone()
252
+ if not row:
253
+ return False
254
+ rowid = row[0]
255
+ try:
256
+ conn.execute("DELETE FROM memories_fts WHERE rowid = ?", (rowid,))
257
+ conn.execute("DELETE FROM memories_vec WHERE rowid = ?", (rowid,))
258
+ conn.execute("DELETE FROM memories WHERE id = ?", (memory_id,))
259
+ conn.commit()
260
+ return True
261
+ except Exception:
262
+ conn.rollback()
263
+ raise
264
+
265
+
266
+ def count_memories(conn: sqlite3.Connection) -> int:
267
+ return conn.execute("SELECT COUNT(*) FROM memories").fetchone()[0]
268
+
269
+
270
+ def check_exact_duplicate(conn: sqlite3.Connection, content: str) -> bool:
271
+ row = conn.execute(
272
+ "SELECT 1 FROM memories WHERE content = ? LIMIT 1", (content,)
273
+ ).fetchone()
274
+ return row is not None
275
+
276
+
277
+ def find_unindexed_memories(
278
+ conn: sqlite3.Connection, limit: int = 200, offset: int = 0
279
+ ) -> List[sqlite3.Row]:
280
+ cursor = conn.execute(
281
+ """SELECT m.id, m.content, m.timestamp, m.owner_id, m.local_id,
282
+ m.agent_name, m.session_id, m.turn_id, m.rowid,
283
+ mv.rowid AS vec_rowid,
284
+ mf.rowid AS fts_rowid
285
+ FROM memories m
286
+ LEFT JOIN memories_vec mv ON m.rowid = mv.rowid
287
+ LEFT JOIN memories_fts mf ON m.rowid = mf.rowid
288
+ WHERE mv.rowid IS NULL OR mf.rowid IS NULL
289
+ LIMIT ? OFFSET ?""",
290
+ (limit, offset),
291
+ )
292
+ return cursor.fetchall()
293
+
294
+
295
+ def get_memories_by_turn_id(
296
+ conn: sqlite3.Connection, turn_id: str
297
+ ) -> List[sqlite3.Row]:
298
+ cursor = conn.execute(
299
+ """SELECT id, content, timestamp, owner_id, local_id, agent_name,
300
+ session_id, turn_id, rowid
301
+ FROM memories
302
+ WHERE turn_id = ?
303
+ ORDER BY
304
+ CASE WHEN content LIKE '[user%' THEN 0 ELSE 1 END,
305
+ rowid""",
306
+ (turn_id,),
307
+ )
308
+ return cursor.fetchall()
@@ -0,0 +1,361 @@
1
+ import os
2
+ import re
3
+ import math
4
+ import logging
5
+ from datetime import datetime, timezone
6
+ from typing import Optional
7
+
8
+ from .database import (
9
+ get_connection,
10
+ init_db,
11
+ insert_memory,
12
+ search_vector,
13
+ search_fts,
14
+ get_all_memories,
15
+ delete_memory,
16
+ count_memories,
17
+ check_exact_duplicate,
18
+ find_unindexed_memories,
19
+ serialize_vector,
20
+ get_memories_by_turn_id,
21
+ strip_fts_punctuation,
22
+ )
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ _CODE_BLOCK_RE = re.compile(r'```[\s\S]*?```')
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Lone-surrogate sanitization (Windows / cp932 data-loss guard)
30
+ # ---------------------------------------------------------------------------
31
+ # Subprocess stdin pipes on Windows can deliver UTF-8 bytes that python's
32
+ # decoder maps to lone UTF-16 surrogate halves (U+D800..U+DFFF). Such strings
33
+ # round-trip through json.loads but blow up at sqlite3.execute() with
34
+ # UnicodeEncodeError, silently dropping the entire write — a direct violation
35
+ # of the complete-preservation contract. We strip these halves before any
36
+ # value is bound to SQLite.
37
+ _LONE_SURROGATE_RE = re.compile(r'[\ud800-\udfff]')
38
+
39
+
40
+ def sanitize_surrogates(text):
41
+ """Strip lone UTF-16 surrogate halves from a string (or pass-through).
42
+
43
+ Recursively cleans dicts / lists too so audit-log JSON payloads with
44
+ surrogates buried inside multimodal content do not break json.dumps.
45
+ """
46
+ if text is None:
47
+ return text
48
+ if isinstance(text, str):
49
+ return _LONE_SURROGATE_RE.sub('', text)
50
+ if isinstance(text, list):
51
+ return [sanitize_surrogates(x) for x in text]
52
+ if isinstance(text, dict):
53
+ return {k: sanitize_surrogates(v) for k, v in text.items()}
54
+ return text
55
+
56
+ DEFAULT_EMBED_MODEL = 'intfloat/multilingual-e5-base'
57
+
58
+ _model = None
59
+ _model_name = None
60
+
61
+
62
+ def get_model(name: Optional[str] = None):
63
+ """Lazy-load and cache the embedding model.
64
+
65
+ On the first call (or when `name` differs from the cached model),
66
+ the model is selected from (in priority order):
67
+ 1. `name` argument
68
+ 2. `$N3MC_EMBED_MODEL` environment variable
69
+ 3. `DEFAULT_EMBED_MODEL` (multilingual baseline)
70
+
71
+ Subsequent calls with `name=None` return the cached model without
72
+ re-resolving — so the model loaded by the server lifespan (with
73
+ `embed_model` from config.json) wins, and `embed_passage` /
74
+ `embed_query` reuse it transparently.
75
+
76
+ Callers that want to switch to a language-specialised model should
77
+ set `embed_model` in `config.json` and run `--repair` once after
78
+ the first save under the new model (see spec §5 vector re-index
79
+ migration).
80
+ """
81
+ global _model, _model_name
82
+ if name is None and _model is not None:
83
+ return _model
84
+ target = name or os.environ.get('N3MC_EMBED_MODEL') or DEFAULT_EMBED_MODEL
85
+ if _model is None or _model_name != target:
86
+ from sentence_transformers import SentenceTransformer
87
+ _model = SentenceTransformer(target)
88
+ _model_name = target
89
+ return _model
90
+
91
+
92
+ def embed_passage(text: str) -> list:
93
+ model = get_model()
94
+ prefixed = "passage: " + text
95
+ vec = model.encode(prefixed, normalize_embeddings=True)
96
+ return vec.tolist()
97
+
98
+
99
+ def embed_query(text: str) -> list:
100
+ model = get_model()
101
+ prefixed = "query: " + text
102
+ vec = model.encode(prefixed, normalize_embeddings=True)
103
+ return vec.tolist()
104
+
105
+
106
+ def purify_text(text: str) -> str:
107
+ """Replace closed fenced code blocks with [code omitted] AND strip lone
108
+ surrogates as a defense-in-depth layer (spec §5).
109
+
110
+ Inline backtick spans and unclosed fences are preserved verbatim.
111
+ """
112
+ if not text:
113
+ return text
114
+ cleaned = sanitize_surrogates(text)
115
+ return _CODE_BLOCK_RE.sub('[code omitted]', cleaned)
116
+
117
+
118
+ def chunk_text(text: str, max_chars: int = 400, overlap: int = 40) -> list:
119
+ if not text or not text.strip():
120
+ return []
121
+ if len(text) <= max_chars:
122
+ return [text]
123
+
124
+ # Paragraph split
125
+ paragraphs = [p.strip() for p in re.split(r'\n\s*\n', text) if p.strip()]
126
+ if len(paragraphs) > 1:
127
+ chunks = _merge_chunks(paragraphs, max_chars, overlap, sep='\n\n')
128
+ if chunks:
129
+ return chunks
130
+
131
+ # Sentence split
132
+ sentences = re.split(r'(?<=[.!?])\s+', text)
133
+ if len(sentences) > 1:
134
+ chunks = _merge_chunks(sentences, max_chars, overlap, sep=' ')
135
+ if chunks:
136
+ return chunks
137
+
138
+ # Hard window fallback
139
+ return _hard_window(text, max_chars, overlap)
140
+
141
+
142
+ def _merge_chunks(parts: list, max_chars: int, overlap: int, sep: str) -> list:
143
+ chunks = []
144
+ current = ""
145
+ for part in parts:
146
+ if len(part) > max_chars:
147
+ if current:
148
+ chunks.append(current)
149
+ current = ""
150
+ sub = _hard_window(part, max_chars, overlap)
151
+ chunks.extend(sub)
152
+ elif current and len(current) + len(sep) + len(part) > max_chars:
153
+ chunks.append(current)
154
+ # Overlap: keep tail of previous chunk
155
+ tail = current[-overlap:] if overlap else ""
156
+ current = (tail + sep + part) if tail else part
157
+ else:
158
+ current = (current + sep + part) if current else part
159
+ if current:
160
+ chunks.append(current)
161
+ return chunks
162
+
163
+
164
+ def _hard_window(text: str, max_chars: int, overlap: int) -> list:
165
+ chunks = []
166
+ start = 0
167
+ length = len(text)
168
+ while start < length:
169
+ end = min(start + max_chars, length)
170
+ chunks.append(text[start:end])
171
+ if end >= length:
172
+ break
173
+ start = end - overlap
174
+ if start <= 0:
175
+ start = end
176
+ return chunks
177
+
178
+
179
+ def add_chunk_prefixes(chunks: list, role: str) -> list:
180
+ if not chunks:
181
+ return []
182
+ n = len(chunks)
183
+ if n == 1:
184
+ return [f"[{role}] {chunks[0]}"]
185
+ return [f"[{role} {i + 1}/{n}] {chunks[i]}" for i in range(n)]
186
+
187
+
188
+ def cosine_sim_from_l2(l2_distance: float) -> float:
189
+ return max(0.0, 1.0 - (l2_distance ** 2) / 2.0)
190
+
191
+
192
+ def time_decay(timestamp_str: str, half_life_days: float) -> float:
193
+ try:
194
+ ts = datetime.fromisoformat(timestamp_str)
195
+ if ts.tzinfo is None:
196
+ ts = ts.replace(tzinfo=timezone.utc)
197
+ now = datetime.now(timezone.utc)
198
+ days_elapsed = (now - ts).total_seconds() / 86400.0
199
+ return 2.0 ** (-days_elapsed / half_life_days)
200
+ except Exception:
201
+ return 1.0
202
+
203
+
204
+ def keyword_relevance(bm25_score: float, max_abs_bm25: float, bm25_min_threshold: float) -> float:
205
+ abs_score = abs(bm25_score)
206
+ if abs_score < bm25_min_threshold:
207
+ return 0.0
208
+ return abs_score / max(1.0, max_abs_bm25)
209
+
210
+
211
+ def hybrid_search(
212
+ db_path: str,
213
+ query: str,
214
+ config: dict,
215
+ current_session_id: Optional[str] = None,
216
+ ) -> dict:
217
+ half_life = config.get('half_life_days', 90)
218
+ bm25_min = config.get('bm25_min_threshold', 0.1)
219
+ limit = config.get('search_result_limit', 20)
220
+ min_score = config.get('min_score', 0.2)
221
+ max_query = config.get('search_query_max_chars', 2000)
222
+
223
+ query = query[:max_query]
224
+
225
+ conn = get_connection(db_path)
226
+ try:
227
+ # Vector search
228
+ vec_results = {}
229
+ if query.strip():
230
+ try:
231
+ q_vec = embed_query(query)
232
+ rows = search_vector(conn, q_vec, k=limit * 3)
233
+ for row in rows:
234
+ rid = row['id']
235
+ dist = row['distance']
236
+ cs = cosine_sim_from_l2(dist)
237
+ vec_results[rid] = {
238
+ 'id': rid,
239
+ 'content': row['content'],
240
+ 'timestamp': row['timestamp'],
241
+ 'session_id': row['session_id'],
242
+ 'turn_id': row['turn_id'],
243
+ 'agent_name': row['agent_name'],
244
+ 'cos_sim': cs,
245
+ 'bm25_score': None,
246
+ }
247
+ except Exception as e:
248
+ logger.warning(f"Vector search error: {e}")
249
+
250
+ # FTS search
251
+ fts_results = {}
252
+ if query.strip():
253
+ try:
254
+ rows = search_fts(conn, query, limit=limit * 3)
255
+ max_abs = max((abs(r['bm25_score']) for r in rows), default=0.0)
256
+ for row in rows:
257
+ rid = row['id']
258
+ fts_results[rid] = {
259
+ 'id': rid,
260
+ 'content': row['content'],
261
+ 'timestamp': row['timestamp'],
262
+ 'session_id': row['session_id'],
263
+ 'turn_id': row['turn_id'],
264
+ 'agent_name': row['agent_name'],
265
+ 'bm25_score': row['bm25_score'],
266
+ 'max_abs_bm25': max_abs,
267
+ 'cos_sim': None,
268
+ }
269
+ except Exception as e:
270
+ logger.warning(f"FTS search error: {e}")
271
+
272
+ # Combine
273
+ all_ids = set(vec_results.keys()) | set(fts_results.keys())
274
+ scored = []
275
+ fts_max_abs = 0.0
276
+ if fts_results:
277
+ fts_max_abs = max(abs(v['bm25_score']) for v in fts_results.values())
278
+
279
+ for rid in all_ids:
280
+ vec_r = vec_results.get(rid)
281
+ fts_r = fts_results.get(rid)
282
+
283
+ meta = vec_r or fts_r
284
+ cs = vec_r['cos_sim'] if vec_r else 0.0
285
+ bm25 = fts_r['bm25_score'] if fts_r else 0.0
286
+ kr = keyword_relevance(bm25, fts_max_abs, bm25_min)
287
+
288
+ ts = meta['timestamp']
289
+ decay = time_decay(ts, half_life)
290
+
291
+ sess = meta.get('session_id')
292
+ b_session = 1.0 if (current_session_id and sess == current_session_id) else 0.6
293
+
294
+ score = (cs * 0.7 + kr * 0.3) * decay * b_session
295
+
296
+ scored.append({
297
+ 'id': rid,
298
+ 'content': meta['content'],
299
+ 'timestamp': ts,
300
+ 'session_id': sess,
301
+ 'turn_id': meta.get('turn_id'),
302
+ 'agent_name': meta.get('agent_name'),
303
+ 'score': score,
304
+ 'cos_sim': cs,
305
+ 'keyword_relevance': kr,
306
+ })
307
+
308
+ scored.sort(key=lambda x: x['score'], reverse=True)
309
+ filtered = [r for r in scored if r['score'] >= min_score]
310
+ results = filtered[:limit]
311
+
312
+ # Q-A pair collection
313
+ pairs = {}
314
+ seen_turn_ids = set()
315
+ for r in results:
316
+ tid = r.get('turn_id')
317
+ if tid and tid not in seen_turn_ids:
318
+ seen_turn_ids.add(tid)
319
+ siblings = get_memories_by_turn_id(conn, tid)
320
+ if siblings:
321
+ pairs[tid] = [
322
+ {
323
+ 'id': s['id'],
324
+ 'content': s['content'],
325
+ 'timestamp': s['timestamp'],
326
+ }
327
+ for s in siblings
328
+ ]
329
+
330
+ return {'results': results, 'pairs': pairs}
331
+ finally:
332
+ conn.close()
333
+
334
+
335
+ def render_memory_context(search_result: dict, query: str) -> str:
336
+ results = search_result.get('results', [])
337
+ pairs = search_result.get('pairs', {})
338
+
339
+ lines = [f"# Recalled Memory Context\n検索クエリ: {query}\n"]
340
+
341
+ if not results:
342
+ lines.append("_No relevant memories found._\n")
343
+ else:
344
+ lines.append("## Top matches (use these to answer the question)\n")
345
+ for i, r in enumerate(results, 1):
346
+ score = r['score']
347
+ content = r['content']
348
+ lines.append(f"### [{i}] score={score:.4f}")
349
+ lines.append(content)
350
+ lines.append("")
351
+
352
+ if pairs:
353
+ lines.append("\n## Previous matching Q-A exchanges (supplementary context)\n")
354
+ for tid, siblings in pairs.items():
355
+ lines.append(f"**Turn:** `{tid}`\n")
356
+ for s in siblings:
357
+ lines.append(s.get('content', '[content missing]'))
358
+ lines.append("")
359
+ lines.append("---")
360
+
361
+ return '\n'.join(lines)