@zilliz/memsearch-opencode 0.2.1 → 0.3.0

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.
@@ -4,17 +4,22 @@
4
4
  Usage:
5
5
  capture-daemon.py <project_dir> <collection_name> [--memsearch-cmd CMD]
6
6
 
7
- The daemon polls the OpenCode SQLite database for new completed messages,
8
- extracts the last turn, summarizes it as bullet points, and writes to
7
+ The daemon polls the OpenCode SQLite database for completed turns, extracts
8
+ the latest completed turn, summarizes it as bullet points, and writes to
9
9
  <project_dir>/.memsearch/memory/YYYY-MM-DD.md.
10
10
 
11
- It also triggers memsearch indexing after writing.
11
+ It also persists derived turn metadata in <project_dir>/.memsearch/opencode-turns.db
12
+ and triggers memsearch indexing after writing.
12
13
  """
13
14
 
15
+ from __future__ import annotations
16
+
14
17
  import argparse
15
18
  import contextlib
19
+ import hashlib
16
20
  import json
17
21
  import os
22
+ import re
18
23
  import shlex
19
24
  import shutil
20
25
  import signal
@@ -25,13 +30,34 @@ import time
25
30
  from datetime import datetime
26
31
  from pathlib import Path
27
32
 
33
+ from opencode_turns import (
34
+ build_turns,
35
+ get_db_path,
36
+ load_turn_state,
37
+ open_turn_db,
38
+ save_turn,
39
+ save_turn_state,
40
+ )
41
+
42
+ _ANCHOR_RE = re.compile(r"<!-- session:([^ ]+) turn:([^ ]+) db:")
43
+ _TAIL_TURN_QUIET_PERIOD_MS = 300_000
44
+
45
+
46
+ class TailTurnObservation:
47
+ __slots__ = ("fingerprint", "stable_since_ms", "turn_id")
48
+
49
+ def __init__(self, turn_id: str, fingerprint: str, stable_since_ms: int) -> None:
50
+ self.turn_id = turn_id
51
+ self.fingerprint = fingerprint
52
+ self.stable_since_ms = stable_since_ms
53
+
28
54
 
29
- def split_memsearch_cmd(memsearch_cmd):
55
+ def split_memsearch_cmd(memsearch_cmd: str) -> list[str]:
30
56
  """Split the configured memsearch command preserving quoted arguments."""
31
57
  return shlex.split(memsearch_cmd)
32
58
 
33
59
 
34
- def get_small_model():
60
+ def get_small_model() -> str:
35
61
  """Read small_model from opencode.json config (fallback to model, then empty)."""
36
62
  config_paths = [
37
63
  os.path.expanduser("~/.config/opencode/opencode.json"),
@@ -40,7 +66,7 @@ def get_small_model():
40
66
  for p in config_paths:
41
67
  if os.path.exists(p):
42
68
  try:
43
- with open(p) as f:
69
+ with open(p, encoding="utf-8") as f:
44
70
  cfg = json.load(f)
45
71
  return cfg.get("small_model", cfg.get("model", ""))
46
72
  except Exception:
@@ -48,23 +74,25 @@ def get_small_model():
48
74
  return ""
49
75
 
50
76
 
51
- def get_plugin_summarize_model(memsearch_cmd=None):
77
+ def get_plugin_summarize_model(memsearch_cmd: str | None = None) -> str:
52
78
  """Read the memsearch OpenCode summarize model override."""
53
79
  if not memsearch_cmd:
54
80
  return ""
55
81
  try:
56
82
  result = subprocess.run(
57
83
  [*split_memsearch_cmd(memsearch_cmd), "config", "get", "plugins.opencode.summarize.model"],
58
- capture_output=True, text=True, timeout=5,
84
+ capture_output=True,
85
+ text=True,
86
+ timeout=5,
59
87
  )
60
88
  return result.stdout.strip()
61
89
  except Exception:
62
90
  return ""
63
91
 
64
92
 
65
- def ensure_isolated_config():
93
+ def ensure_isolated_config() -> str:
66
94
  """Create isolated config dir without plugins/ to prevent recursion."""
67
- isolated = "/tmp/opencode-memsearch-summarize/opencode"
95
+ isolated = os.path.expanduser("~/.codex/tmp/opencode-memsearch-summarize/opencode")
68
96
  os.makedirs(isolated, exist_ok=True)
69
97
  # Copy opencode.json (provider config) but NOT plugins/
70
98
  src = os.path.expanduser("~/.config/opencode/opencode.json")
@@ -74,17 +102,19 @@ def ensure_isolated_config():
74
102
  os.remove(dst)
75
103
  if os.path.exists(src) and not os.path.exists(dst):
76
104
  shutil.copy2(src, dst)
77
- return os.path.dirname(isolated) # /tmp/opencode-memsearch-summarize
105
+ return os.path.dirname(isolated)
78
106
 
79
107
 
80
- def _load_summarize_prompt(agent_name, memsearch_cmd=None):
108
+ def _load_summarize_prompt(agent_name: str, memsearch_cmd: str | None = None) -> str:
81
109
  """Load summarization prompt: user custom > plugin built-in > inline fallback."""
82
110
  # Try user-custom prompt via config
83
111
  if memsearch_cmd:
84
112
  try:
85
113
  result = subprocess.run(
86
114
  [*split_memsearch_cmd(memsearch_cmd), "config", "get", "prompts.summarize"],
87
- capture_output=True, text=True, timeout=5,
115
+ capture_output=True,
116
+ text=True,
117
+ timeout=5,
88
118
  )
89
119
  custom_path = result.stdout.strip()
90
120
  if custom_path and os.path.isfile(custom_path):
@@ -106,13 +136,10 @@ def _load_summarize_prompt(agent_name, memsearch_cmd=None):
106
136
  )
107
137
 
108
138
 
109
- def summarize_with_llm(turn_text, small_model, memsearch_cmd=None):
139
+ def summarize_with_llm(turn_text: str, small_model: str, memsearch_cmd: str | None = None) -> str | None:
110
140
  """Summarize using opencode run in isolated env (no plugins -> no recursion)."""
111
141
  system_prompt = _load_summarize_prompt("OpenCode", memsearch_cmd)
112
- full_prompt = (
113
- f"{system_prompt}\n\n"
114
- f"Transcript:\n{turn_text}"
115
- )
142
+ full_prompt = f"{system_prompt}\n\nTranscript:\n{turn_text}"
116
143
  isolated_dir = ensure_isolated_config()
117
144
 
118
145
  summarize_model = get_plugin_summarize_model(memsearch_cmd) or small_model
@@ -130,10 +157,11 @@ def summarize_with_llm(turn_text, small_model, memsearch_cmd=None):
130
157
  "XDG_DATA_HOME": os.path.join(isolated_dir, "data"),
131
158
  "MEMSEARCH_NO_WATCH": "1",
132
159
  },
133
- capture_output=True, text=True, timeout=30,
160
+ capture_output=True,
161
+ text=True,
162
+ timeout=30,
134
163
  )
135
164
  output = result.stdout.strip()
136
- # Extract bullet points (skip any opencode run header lines)
137
165
  lines = output.split("\n")
138
166
  bullets = [line for line in lines if line.strip().startswith("- ")]
139
167
  if bullets:
@@ -141,32 +169,15 @@ def summarize_with_llm(turn_text, small_model, memsearch_cmd=None):
141
169
  except Exception:
142
170
  pass
143
171
 
144
- return None # fallback to raw text
145
-
146
-
147
- def get_db_path():
148
- """Find the OpenCode SQLite database."""
149
- default = os.path.expanduser("~/.local/share/opencode/opencode.db")
150
- if os.path.exists(default):
151
- return default
152
- xdg_data = os.environ.get("XDG_DATA_HOME", "")
153
- if xdg_data:
154
- alt = os.path.join(xdg_data, "opencode", "opencode.db")
155
- if os.path.exists(alt):
156
- return alt
157
- return default
172
+ return None
158
173
 
159
174
 
160
- def get_new_completed_turns(conn, project_dir, last_msg_time):
161
- """Get new user+assistant pairs from sessions in the project directory.
162
-
163
- Returns a list of (session_id, turn_text, max_msg_time) tuples for
164
- turns whose messages are newer than last_msg_time.
165
- """
166
- # Find all sessions for this project directory
175
+ def get_session_ids(conn: sqlite3.Connection, project_dir: str) -> list[str]:
176
+ """Find OpenCode sessions that belong to the given project directory."""
167
177
  sessions = conn.execute(
168
178
  """
169
- SELECT s.id FROM session s
179
+ SELECT s.id
180
+ FROM session s
170
181
  WHERE s.directory = ?
171
182
  ORDER BY s.time_updated DESC
172
183
  LIMIT 5
@@ -175,10 +186,10 @@ def get_new_completed_turns(conn, project_dir, last_msg_time):
175
186
  ).fetchall()
176
187
 
177
188
  if not sessions:
178
- # Fallback: match by basename
179
189
  sessions = conn.execute(
180
190
  """
181
- SELECT s.id FROM session s
191
+ SELECT s.id
192
+ FROM session s
182
193
  WHERE s.directory LIKE ?
183
194
  ORDER BY s.time_updated DESC
184
195
  LIMIT 5
@@ -186,75 +197,52 @@ def get_new_completed_turns(conn, project_dir, last_msg_time):
186
197
  (f"%{os.path.basename(project_dir)}%",),
187
198
  ).fetchall()
188
199
 
189
- results = []
190
- for (session_id,) in sessions:
191
- # Get messages newer than last_msg_time, in pairs (user + assistant)
192
- messages = conn.execute(
193
- """
194
- SELECT m.id, m.data, m.time_created
195
- FROM message m
196
- WHERE m.session_id = ? AND m.time_created > ?
197
- ORDER BY m.time_created ASC
198
- """,
199
- (session_id, last_msg_time),
200
- ).fetchall()
200
+ return [row[0] for row in sessions]
201
201
 
202
- # Group into user+assistant pairs
203
- i = 0
204
- while i < len(messages):
205
- msg_data = json.loads(messages[i][1])
206
- role = msg_data.get("role", "")
207
- msg_time = messages[i][2]
208
-
209
- if role == "user":
210
- user_text = _extract_msg_text(conn, messages[i][0], messages[i][1])
211
- assistant_text = ""
212
- # Look for following assistant message
213
- if i + 1 < len(messages):
214
- next_data = json.loads(messages[i + 1][1])
215
- if next_data.get("role") == "assistant":
216
- assistant_text = _extract_msg_text(conn, messages[i + 1][0], messages[i + 1][1])
217
- msg_time = messages[i + 1][2]
218
- i += 1
219
-
220
- if user_text and len(user_text) > 5:
221
- turn = f"[Human]: {user_text[:2000]}"
222
- if assistant_text:
223
- turn += f"\n\n[Assistant]: {assistant_text[:2000]}"
224
- results.append((session_id, turn, msg_time))
225
- i += 1
226
-
227
- return results
228
-
229
-
230
- def _extract_msg_text(conn, msg_id, msg_json):
231
- """Extract readable text from a message's parts."""
232
- parts = conn.execute(
233
- "SELECT data FROM part WHERE message_id = ? ORDER BY time_created ASC",
234
- (msg_id,),
235
- ).fetchall()
236
202
 
237
- text_parts = []
238
- for (part_json,) in parts:
239
- part_data = json.loads(part_json)
240
- if part_data.get("type") == "text" and part_data.get("text"):
241
- if not part_data.get("synthetic"):
242
- text_parts.append(part_data["text"].strip())
243
- elif part_data.get("type") == "tool" and part_data.get("state", {}).get("status") == "completed":
244
- tool_name = part_data.get("tool", "unknown")
245
- tool_input = part_data.get("state", {}).get("input", {})
246
- hint = ""
247
- if isinstance(tool_input, dict):
248
- if "command" in tool_input:
249
- hint = f" `{tool_input['command'][:80]}`"
250
- elif "path" in tool_input:
251
- hint = f" {tool_input['path']}"
252
- text_parts.append(f"[Tool: {tool_name}{hint}]")
253
-
254
- return "\n".join(text_parts)
255
-
256
-
257
- def write_capture(memory_dir, turn_text, session_id, db_path=""):
203
+ def _load_legacy_last_msg_time(project_dir: str) -> int:
204
+ """Read the pre-sidecar capture checkpoint for upgrade compatibility."""
205
+ path = Path(project_dir) / ".memsearch" / ".last_msg_time"
206
+ try:
207
+ value = int(path.read_text(encoding="utf-8").strip())
208
+ except (OSError, ValueError):
209
+ return 0
210
+ return max(value, 0)
211
+
212
+
213
+ def _make_capture_anchor_key(session_id: str, turn_id: str) -> tuple[str, str]:
214
+ return (session_id, turn_id)
215
+
216
+
217
+ def load_capture_anchor_cache(memory_dir: str) -> set[tuple[str, str]]:
218
+ if not os.path.isdir(memory_dir):
219
+ return set()
220
+
221
+ anchor_cache: set[tuple[str, str]] = set()
222
+ for name in os.listdir(memory_dir):
223
+ if not name.endswith(".md"):
224
+ continue
225
+
226
+ path = os.path.join(memory_dir, name)
227
+ try:
228
+ text = Path(path).read_text(encoding="utf-8")
229
+ except OSError:
230
+ continue
231
+
232
+ for session_id, turn_id in _ANCHOR_RE.findall(text):
233
+ anchor_cache.add(_make_capture_anchor_key(session_id, turn_id))
234
+
235
+ return anchor_cache
236
+
237
+
238
+ def write_capture(
239
+ memory_dir: str,
240
+ turn_text: str,
241
+ session_id: str,
242
+ turn_id: str,
243
+ db_path: str = "",
244
+ anchor_cache: set[tuple[str, str]] | None = None,
245
+ ) -> str:
258
246
  """Write a captured turn to the daily memory file."""
259
247
  os.makedirs(memory_dir, exist_ok=True)
260
248
 
@@ -263,19 +251,202 @@ def write_capture(memory_dir, turn_text, session_id, db_path=""):
263
251
  memory_file = os.path.join(memory_dir, f"{today}.md")
264
252
 
265
253
  if not os.path.exists(memory_file):
266
- with open(memory_file, "w") as f:
254
+ with open(memory_file, "w", encoding="utf-8") as f:
267
255
  f.write(f"# {today}\n\n## Session {now}\n\n")
268
256
 
269
- anchor = f"<!-- session:{session_id} db:{db_path} -->\n" if session_id else ""
257
+ anchor = f"<!-- session:{session_id} turn:{turn_id} db:{db_path} -->\n" if session_id else ""
270
258
  entry = f"### {now}\n{anchor}{turn_text}\n\n"
271
259
 
272
- with open(memory_file, "a") as f:
260
+ with open(memory_file, "a", encoding="utf-8") as f:
273
261
  f.write(entry)
274
262
 
263
+ if session_id and anchor_cache is not None:
264
+ anchor_cache.add(_make_capture_anchor_key(session_id, turn_id))
265
+
275
266
  return memory_file
276
267
 
277
268
 
278
- def main():
269
+ def capture_exists(memory_dir: str, session_id: str, turn_id: str) -> bool:
270
+ """Check whether a turn anchor was already written to memory markdown."""
271
+ if not os.path.isdir(memory_dir):
272
+ return False
273
+
274
+ anchor = f"<!-- session:{session_id} turn:{turn_id} "
275
+ for name in os.listdir(memory_dir):
276
+ if not name.endswith(".md"):
277
+ continue
278
+
279
+ path = os.path.join(memory_dir, name)
280
+ try:
281
+ with open(path, encoding="utf-8") as handle:
282
+ if anchor in handle.read():
283
+ return True
284
+ except OSError:
285
+ continue
286
+
287
+ return False
288
+
289
+
290
+ def load_saved_turn_index(conn: sqlite3.Connection, session_id: str, turn_id: str) -> int | None:
291
+ """Return an existing saved turn index when replaying a partially persisted turn."""
292
+ row = conn.execute(
293
+ """
294
+ SELECT turn_index
295
+ FROM turns
296
+ WHERE session_id = ? AND turn_id = ?
297
+ """,
298
+ (session_id, turn_id),
299
+ ).fetchone()
300
+ if row is None:
301
+ return None
302
+ return int(row["turn_index"])
303
+
304
+
305
+ def get_next_turn_index(conn: sqlite3.Connection, session_id: str) -> int:
306
+ """Return the next monotonically increasing turn index for a session."""
307
+ row = conn.execute(
308
+ """
309
+ SELECT COALESCE(MAX(turn_index), 0) AS max_turn_index
310
+ FROM turns
311
+ WHERE session_id = ?
312
+ """,
313
+ (session_id,),
314
+ ).fetchone()
315
+ return int(row["max_turn_index"]) + 1
316
+
317
+
318
+ def _now_ms() -> int:
319
+ return int(time.time() * 1000)
320
+
321
+
322
+ def _compute_turn_fingerprint(turn) -> str:
323
+ payload = {
324
+ "turn_id": turn.turn_id,
325
+ "last_message_id": turn.last_message_id,
326
+ "message_count": turn.message_count,
327
+ "assistant_message_count": turn.assistant_message_count,
328
+ "complete": turn.complete,
329
+ "messages": [
330
+ {
331
+ "id": message.id,
332
+ "role": message.role,
333
+ "parent_id": message.parent_id,
334
+ "time_created": message.time_created,
335
+ "finish": message.finish,
336
+ "text": message.text,
337
+ }
338
+ for message in turn.messages
339
+ ],
340
+ }
341
+ encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True)
342
+ return hashlib.sha1(encoded.encode("utf-8")).hexdigest()
343
+
344
+
345
+ def _tail_turn_is_stable_for_capture(
346
+ turn,
347
+ tail_turn_cache: dict[str, TailTurnObservation],
348
+ ) -> bool:
349
+ if not turn.complete:
350
+ tail_turn_cache.pop(turn.session_id, None)
351
+ return False
352
+
353
+ fingerprint = _compute_turn_fingerprint(turn)
354
+ now_ms = _now_ms()
355
+ observed = tail_turn_cache.get(turn.session_id)
356
+ if observed is None or observed.turn_id != turn.turn_id or observed.fingerprint != fingerprint:
357
+ tail_turn_cache[turn.session_id] = TailTurnObservation(
358
+ turn_id=turn.turn_id,
359
+ fingerprint=fingerprint,
360
+ stable_since_ms=now_ms,
361
+ )
362
+ return False
363
+
364
+ return now_ms - observed.stable_since_ms >= _TAIL_TURN_QUIET_PERIOD_MS
365
+
366
+
367
+ def _turn_ready_for_capture(
368
+ turn,
369
+ is_tail_turn: bool,
370
+ tail_turn_cache: dict[str, TailTurnObservation],
371
+ ) -> bool:
372
+ # A non-tail turn has already been closed by a later user message.
373
+ if not is_tail_turn:
374
+ tail_turn_cache.pop(turn.session_id, None)
375
+ return True
376
+
377
+ # The final turn must stay textually stable for a quiet window before capture.
378
+ return _tail_turn_is_stable_for_capture(turn, tail_turn_cache)
379
+
380
+
381
+ def capture_session_turns(
382
+ conn: sqlite3.Connection,
383
+ turn_db: sqlite3.Connection,
384
+ memory_dir: str,
385
+ session_id: str,
386
+ small_model: str,
387
+ memsearch_cmd: str,
388
+ db_path: str,
389
+ tail_turn_cache: dict[str, TailTurnObservation] | None = None,
390
+ ) -> bool:
391
+ """Capture all newly completed turns for a single session."""
392
+ state = load_turn_state(turn_db, session_id)
393
+ captured_any = False
394
+ next_turn_index = get_next_turn_index(turn_db, session_id)
395
+ if tail_turn_cache is None:
396
+ tail_turn_cache = {}
397
+
398
+ after_time = state.last_completed_time if state.last_completed_time > 0 else None
399
+ if after_time is None:
400
+ legacy_after_time = _load_legacy_last_msg_time(str(Path(memory_dir).resolve().parent.parent))
401
+ if legacy_after_time > 0:
402
+ after_time = legacy_after_time
403
+ after_message_id = state.last_completed_message_id or None
404
+ turns = build_turns(
405
+ conn,
406
+ session_id,
407
+ after_time=after_time,
408
+ after_message_id=after_message_id,
409
+ )
410
+
411
+ for index, turn in enumerate(turns):
412
+ is_tail_turn = index == len(turns) - 1
413
+ if not _turn_ready_for_capture(turn, is_tail_turn, tail_turn_cache):
414
+ break
415
+
416
+ saved_turn_index = load_saved_turn_index(turn_db, session_id, turn.turn_id)
417
+ if saved_turn_index is not None:
418
+ turn.turn_index = saved_turn_index
419
+ else:
420
+ turn.turn_index = next_turn_index
421
+ next_turn_index += 1
422
+
423
+ turn_text = turn.render()
424
+ if len(turn_text.strip()) <= 10:
425
+ continue
426
+
427
+ # Check anchor first so that sidecar rebuilds repair state without
428
+ # calling the summarizer again for already-captured turns.
429
+ if not capture_exists(memory_dir, session_id, turn.turn_id):
430
+ summary = summarize_with_llm(turn_text, small_model, memsearch_cmd)
431
+ write_capture(
432
+ memory_dir,
433
+ summary if summary else turn_text,
434
+ session_id,
435
+ turn.turn_id,
436
+ db_path,
437
+ )
438
+ save_turn(turn_db, turn)
439
+ state.last_completed_time = turn.end_time
440
+ state.last_completed_message_id = turn.last_message_id
441
+ state.last_completed_turn_id = turn.turn_id
442
+ save_turn_state(turn_db, state)
443
+ tail_turn_cache.pop(session_id, None)
444
+ captured_any = True
445
+
446
+ return captured_any
447
+
448
+
449
+ def main() -> None:
279
450
  parser = argparse.ArgumentParser(description="Capture daemon for OpenCode sessions")
280
451
  parser.add_argument("project_dir", help="Project directory")
281
452
  parser.add_argument("collection_name", help="Milvus collection name")
@@ -291,13 +462,11 @@ def main():
291
462
  memory_dir = os.path.join(args.project_dir, ".memsearch", "memory")
292
463
  pid_file = os.path.join(args.project_dir, ".memsearch", ".capture.pid")
293
464
 
294
- # Write PID file
295
465
  os.makedirs(os.path.dirname(pid_file), exist_ok=True)
296
- with open(pid_file, "w") as f:
466
+ with open(pid_file, "w", encoding="utf-8") as f:
297
467
  f.write(str(os.getpid()))
298
468
 
299
- # Clean up on exit
300
- def cleanup(signum=None, frame=None):
469
+ def cleanup(signum=None, frame=None) -> None:
301
470
  with contextlib.suppress(OSError):
302
471
  os.remove(pid_file)
303
472
  sys.exit(0)
@@ -306,41 +475,40 @@ def main():
306
475
  signal.signal(signal.SIGINT, cleanup)
307
476
 
308
477
  small_model = get_small_model()
309
- # Track by message time — persisted to disk so daemon restarts don't re-capture
310
- state_file = os.path.join(args.project_dir, ".memsearch", ".last_msg_time")
311
- last_msg_time = 0
312
- if os.path.exists(state_file):
313
- with contextlib.suppress(ValueError, OSError), open(state_file) as f:
314
- last_msg_time = int(f.read().strip())
478
+ turn_db = open_turn_db(args.project_dir)
479
+ tail_turn_cache: dict[str, TailTurnObservation] = {}
315
480
 
316
481
  while True:
482
+ any_new = False
483
+ conn = None
317
484
  try:
318
485
  conn = sqlite3.connect(db_path, timeout=5)
486
+ conn.row_factory = sqlite3.Row
487
+ for session_id in get_session_ids(conn, args.project_dir):
488
+ any_new = (
489
+ capture_session_turns(
490
+ conn,
491
+ turn_db,
492
+ memory_dir,
493
+ session_id,
494
+ small_model,
495
+ args.memsearch_cmd,
496
+ db_path,
497
+ tail_turn_cache,
498
+ )
499
+ or any_new
500
+ )
319
501
 
320
- new_turns = get_new_completed_turns(conn, args.project_dir, last_msg_time)
321
- for session_id, turn_text, msg_time in new_turns:
322
- if turn_text and len(turn_text) > 10:
323
- # Summarize with LLM, fallback to raw text
324
- summary = summarize_with_llm(turn_text, small_model, args.memsearch_cmd)
325
- write_capture(memory_dir, summary if summary else turn_text, session_id, db_path)
326
- if msg_time > last_msg_time:
327
- last_msg_time = msg_time
328
- try:
329
- with open(state_file, "w") as sf:
330
- sf.write(str(last_msg_time))
331
- except OSError:
332
- pass
333
-
334
- if new_turns:
335
- # Index in background after batch capture
502
+ if any_new:
336
503
  os.system(
337
504
  f"{args.memsearch_cmd} index '{memory_dir}' "
338
505
  f"--collection {args.collection_name} &"
339
506
  )
340
-
341
- conn.close()
342
507
  except Exception:
343
508
  pass
509
+ finally:
510
+ if conn is not None:
511
+ conn.close()
344
512
 
345
513
  time.sleep(args.poll_interval)
346
514