@zilliz/memsearch-opencode 0.2.0 → 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,27 +4,60 @@
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
 
14
- import sqlite3
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import contextlib
19
+ import hashlib
15
20
  import json
16
- import sys
17
21
  import os
18
- import time
22
+ import re
23
+ import shlex
19
24
  import shutil
20
- import subprocess
21
- import argparse
22
25
  import signal
26
+ import sqlite3
27
+ import subprocess
28
+ import sys
29
+ import time
23
30
  from datetime import datetime
24
31
  from pathlib import Path
25
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
26
53
 
27
- def get_small_model():
54
+
55
+ def split_memsearch_cmd(memsearch_cmd: str) -> list[str]:
56
+ """Split the configured memsearch command preserving quoted arguments."""
57
+ return shlex.split(memsearch_cmd)
58
+
59
+
60
+ def get_small_model() -> str:
28
61
  """Read small_model from opencode.json config (fallback to model, then empty)."""
29
62
  config_paths = [
30
63
  os.path.expanduser("~/.config/opencode/opencode.json"),
@@ -33,7 +66,7 @@ def get_small_model():
33
66
  for p in config_paths:
34
67
  if os.path.exists(p):
35
68
  try:
36
- with open(p) as f:
69
+ with open(p, encoding="utf-8") as f:
37
70
  cfg = json.load(f)
38
71
  return cfg.get("small_model", cfg.get("model", ""))
39
72
  except Exception:
@@ -41,26 +74,47 @@ def get_small_model():
41
74
  return ""
42
75
 
43
76
 
44
- def ensure_isolated_config():
77
+ def get_plugin_summarize_model(memsearch_cmd: str | None = None) -> str:
78
+ """Read the memsearch OpenCode summarize model override."""
79
+ if not memsearch_cmd:
80
+ return ""
81
+ try:
82
+ result = subprocess.run(
83
+ [*split_memsearch_cmd(memsearch_cmd), "config", "get", "plugins.opencode.summarize.model"],
84
+ capture_output=True,
85
+ text=True,
86
+ timeout=5,
87
+ )
88
+ return result.stdout.strip()
89
+ except Exception:
90
+ return ""
91
+
92
+
93
+ def ensure_isolated_config() -> str:
45
94
  """Create isolated config dir without plugins/ to prevent recursion."""
46
- isolated = "/tmp/opencode-memsearch-summarize/opencode"
95
+ isolated = os.path.expanduser("~/.codex/tmp/opencode-memsearch-summarize/opencode")
47
96
  os.makedirs(isolated, exist_ok=True)
48
97
  # Copy opencode.json (provider config) but NOT plugins/
49
98
  src = os.path.expanduser("~/.config/opencode/opencode.json")
50
99
  dst = os.path.join(isolated, "opencode.json")
100
+ if os.path.islink(dst) or (os.path.lexists(dst) and not os.path.isfile(dst)):
101
+ with contextlib.suppress(OSError):
102
+ os.remove(dst)
51
103
  if os.path.exists(src) and not os.path.exists(dst):
52
104
  shutil.copy2(src, dst)
53
- return os.path.dirname(isolated) # /tmp/opencode-memsearch-summarize
105
+ return os.path.dirname(isolated)
54
106
 
55
107
 
56
- def _load_summarize_prompt(agent_name, memsearch_cmd=None):
108
+ def _load_summarize_prompt(agent_name: str, memsearch_cmd: str | None = None) -> str:
57
109
  """Load summarization prompt: user custom > plugin built-in > inline fallback."""
58
110
  # Try user-custom prompt via config
59
111
  if memsearch_cmd:
60
112
  try:
61
113
  result = subprocess.run(
62
- memsearch_cmd.split() + ["config", "get", "prompts.summarize"],
63
- capture_output=True, text=True, timeout=5,
114
+ [*split_memsearch_cmd(memsearch_cmd), "config", "get", "prompts.summarize"],
115
+ capture_output=True,
116
+ text=True,
117
+ timeout=5,
64
118
  )
65
119
  custom_path = result.stdout.strip()
66
120
  if custom_path and os.path.isfile(custom_path):
@@ -82,18 +136,16 @@ def _load_summarize_prompt(agent_name, memsearch_cmd=None):
82
136
  )
83
137
 
84
138
 
85
- 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:
86
140
  """Summarize using opencode run in isolated env (no plugins -> no recursion)."""
87
141
  system_prompt = _load_summarize_prompt("OpenCode", memsearch_cmd)
88
- full_prompt = (
89
- f"{system_prompt}\n\n"
90
- f"Transcript:\n{turn_text}"
91
- )
142
+ full_prompt = f"{system_prompt}\n\nTranscript:\n{turn_text}"
92
143
  isolated_dir = ensure_isolated_config()
93
144
 
145
+ summarize_model = get_plugin_summarize_model(memsearch_cmd) or small_model
94
146
  cmd = ["opencode", "run"]
95
- if small_model:
96
- cmd += ["-m", small_model]
147
+ if summarize_model:
148
+ cmd += ["-m", summarize_model]
97
149
  cmd.append(full_prompt)
98
150
 
99
151
  try:
@@ -105,43 +157,27 @@ def summarize_with_llm(turn_text, small_model, memsearch_cmd=None):
105
157
  "XDG_DATA_HOME": os.path.join(isolated_dir, "data"),
106
158
  "MEMSEARCH_NO_WATCH": "1",
107
159
  },
108
- capture_output=True, text=True, timeout=30,
160
+ capture_output=True,
161
+ text=True,
162
+ timeout=30,
109
163
  )
110
164
  output = result.stdout.strip()
111
- # Extract bullet points (skip any opencode run header lines)
112
165
  lines = output.split("\n")
113
- bullets = [l for l in lines if l.strip().startswith("- ")]
166
+ bullets = [line for line in lines if line.strip().startswith("- ")]
114
167
  if bullets:
115
168
  return "\n".join(bullets)
116
169
  except Exception:
117
170
  pass
118
171
 
119
- return None # fallback to raw text
120
-
121
-
122
- def get_db_path():
123
- """Find the OpenCode SQLite database."""
124
- default = os.path.expanduser("~/.local/share/opencode/opencode.db")
125
- if os.path.exists(default):
126
- return default
127
- xdg_data = os.environ.get("XDG_DATA_HOME", "")
128
- if xdg_data:
129
- alt = os.path.join(xdg_data, "opencode", "opencode.db")
130
- if os.path.exists(alt):
131
- return alt
132
- return default
133
-
172
+ return None
134
173
 
135
- def get_new_completed_turns(conn, project_dir, last_msg_time):
136
- """Get new user+assistant pairs from sessions in the project directory.
137
174
 
138
- Returns a list of (session_id, turn_text, max_msg_time) tuples for
139
- turns whose messages are newer than last_msg_time.
140
- """
141
- # 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."""
142
177
  sessions = conn.execute(
143
178
  """
144
- SELECT s.id FROM session s
179
+ SELECT s.id
180
+ FROM session s
145
181
  WHERE s.directory = ?
146
182
  ORDER BY s.time_updated DESC
147
183
  LIMIT 5
@@ -150,10 +186,10 @@ def get_new_completed_turns(conn, project_dir, last_msg_time):
150
186
  ).fetchall()
151
187
 
152
188
  if not sessions:
153
- # Fallback: match by basename
154
189
  sessions = conn.execute(
155
190
  """
156
- SELECT s.id FROM session s
191
+ SELECT s.id
192
+ FROM session s
157
193
  WHERE s.directory LIKE ?
158
194
  ORDER BY s.time_updated DESC
159
195
  LIMIT 5
@@ -161,75 +197,52 @@ def get_new_completed_turns(conn, project_dir, last_msg_time):
161
197
  (f"%{os.path.basename(project_dir)}%",),
162
198
  ).fetchall()
163
199
 
164
- results = []
165
- for (session_id,) in sessions:
166
- # Get messages newer than last_msg_time, in pairs (user + assistant)
167
- messages = conn.execute(
168
- """
169
- SELECT m.id, m.data, m.time_created
170
- FROM message m
171
- WHERE m.session_id = ? AND m.time_created > ?
172
- ORDER BY m.time_created ASC
173
- """,
174
- (session_id, last_msg_time),
175
- ).fetchall()
200
+ return [row[0] for row in sessions]
201
+
202
+
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)
176
215
 
177
- # Group into user+assistant pairs
178
- i = 0
179
- while i < len(messages):
180
- msg_data = json.loads(messages[i][1])
181
- role = msg_data.get("role", "")
182
- msg_time = messages[i][2]
183
-
184
- if role == "user":
185
- user_text = _extract_msg_text(conn, messages[i][0], messages[i][1])
186
- assistant_text = ""
187
- # Look for following assistant message
188
- if i + 1 < len(messages):
189
- next_data = json.loads(messages[i + 1][1])
190
- if next_data.get("role") == "assistant":
191
- assistant_text = _extract_msg_text(conn, messages[i + 1][0], messages[i + 1][1])
192
- msg_time = messages[i + 1][2]
193
- i += 1
194
-
195
- if user_text and len(user_text) > 5:
196
- turn = f"[Human]: {user_text[:2000]}"
197
- if assistant_text:
198
- turn += f"\n\n[Assistant]: {assistant_text[:2000]}"
199
- results.append((session_id, turn, msg_time))
200
- i += 1
201
-
202
- return results
203
-
204
-
205
- def _extract_msg_text(conn, msg_id, msg_json):
206
- """Extract readable text from a message's parts."""
207
- parts = conn.execute(
208
- "SELECT data FROM part WHERE message_id = ? ORDER BY time_created ASC",
209
- (msg_id,),
210
- ).fetchall()
211
216
 
212
- text_parts = []
213
- for (part_json,) in parts:
214
- part_data = json.loads(part_json)
215
- if part_data.get("type") == "text" and part_data.get("text"):
216
- if not part_data.get("synthetic"):
217
- text_parts.append(part_data["text"].strip())
218
- elif part_data.get("type") == "tool" and part_data.get("state", {}).get("status") == "completed":
219
- tool_name = part_data.get("tool", "unknown")
220
- tool_input = part_data.get("state", {}).get("input", {})
221
- hint = ""
222
- if isinstance(tool_input, dict):
223
- if "command" in tool_input:
224
- hint = f" `{tool_input['command'][:80]}`"
225
- elif "path" in tool_input:
226
- hint = f" {tool_input['path']}"
227
- text_parts.append(f"[Tool: {tool_name}{hint}]")
228
-
229
- return "\n".join(text_parts)
230
-
231
-
232
- def write_capture(memory_dir, turn_text, session_id, db_path=""):
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:
233
246
  """Write a captured turn to the daily memory file."""
234
247
  os.makedirs(memory_dir, exist_ok=True)
235
248
 
@@ -238,19 +251,202 @@ def write_capture(memory_dir, turn_text, session_id, db_path=""):
238
251
  memory_file = os.path.join(memory_dir, f"{today}.md")
239
252
 
240
253
  if not os.path.exists(memory_file):
241
- with open(memory_file, "w") as f:
254
+ with open(memory_file, "w", encoding="utf-8") as f:
242
255
  f.write(f"# {today}\n\n## Session {now}\n\n")
243
256
 
244
- 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 ""
245
258
  entry = f"### {now}\n{anchor}{turn_text}\n\n"
246
259
 
247
- with open(memory_file, "a") as f:
260
+ with open(memory_file, "a", encoding="utf-8") as f:
248
261
  f.write(entry)
249
262
 
263
+ if session_id and anchor_cache is not None:
264
+ anchor_cache.add(_make_capture_anchor_key(session_id, turn_id))
265
+
250
266
  return memory_file
251
267
 
252
268
 
253
- 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:
254
450
  parser = argparse.ArgumentParser(description="Capture daemon for OpenCode sessions")
255
451
  parser.add_argument("project_dir", help="Project directory")
256
452
  parser.add_argument("collection_name", help="Milvus collection name")
@@ -260,66 +456,59 @@ def main():
260
456
 
261
457
  db_path = get_db_path()
262
458
  if not os.path.exists(db_path):
263
- print(f"OpenCode database not found at {db_path}", file=sys.stderr)
459
+ sys.stderr.write(f"OpenCode database not found at {db_path}\n")
264
460
  sys.exit(1)
265
461
 
266
462
  memory_dir = os.path.join(args.project_dir, ".memsearch", "memory")
267
463
  pid_file = os.path.join(args.project_dir, ".memsearch", ".capture.pid")
268
464
 
269
- # Write PID file
270
465
  os.makedirs(os.path.dirname(pid_file), exist_ok=True)
271
- with open(pid_file, "w") as f:
466
+ with open(pid_file, "w", encoding="utf-8") as f:
272
467
  f.write(str(os.getpid()))
273
468
 
274
- # Clean up on exit
275
- def cleanup(signum=None, frame=None):
276
- try:
469
+ def cleanup(signum=None, frame=None) -> None:
470
+ with contextlib.suppress(OSError):
277
471
  os.remove(pid_file)
278
- except OSError:
279
- pass
280
472
  sys.exit(0)
281
473
 
282
474
  signal.signal(signal.SIGTERM, cleanup)
283
475
  signal.signal(signal.SIGINT, cleanup)
284
476
 
285
477
  small_model = get_small_model()
286
- # Track by message time — persisted to disk so daemon restarts don't re-capture
287
- state_file = os.path.join(args.project_dir, ".memsearch", ".last_msg_time")
288
- last_msg_time = 0
289
- if os.path.exists(state_file):
290
- try:
291
- last_msg_time = int(open(state_file).read().strip())
292
- except (ValueError, OSError):
293
- pass
478
+ turn_db = open_turn_db(args.project_dir)
479
+ tail_turn_cache: dict[str, TailTurnObservation] = {}
294
480
 
295
481
  while True:
482
+ any_new = False
483
+ conn = None
296
484
  try:
297
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
+ )
298
501
 
299
- new_turns = get_new_completed_turns(conn, args.project_dir, last_msg_time)
300
- for session_id, turn_text, msg_time in new_turns:
301
- if turn_text and len(turn_text) > 10:
302
- # Summarize with LLM, fallback to raw text
303
- summary = summarize_with_llm(turn_text, small_model, args.memsearch_cmd)
304
- write_capture(memory_dir, summary if summary else turn_text, session_id, db_path)
305
- if msg_time > last_msg_time:
306
- last_msg_time = msg_time
307
- try:
308
- with open(state_file, "w") as sf:
309
- sf.write(str(last_msg_time))
310
- except OSError:
311
- pass
312
-
313
- if new_turns:
314
- # Index in background after batch capture
502
+ if any_new:
315
503
  os.system(
316
504
  f"{args.memsearch_cmd} index '{memory_dir}' "
317
505
  f"--collection {args.collection_name} &"
318
506
  )
319
-
320
- conn.close()
321
507
  except Exception:
322
508
  pass
509
+ finally:
510
+ if conn is not None:
511
+ conn.close()
323
512
 
324
513
  time.sleep(args.poll_interval)
325
514