@zilliz/memsearch-opencode 0.2.1 → 0.3.1

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
28
53
 
29
- def split_memsearch_cmd(memsearch_cmd):
54
+
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,41 @@ 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 get_plugin_summarize_provider(memsearch_cmd: str | None = None) -> str:
94
+ """Read the memsearch OpenCode summarize provider route."""
95
+ if not memsearch_cmd:
96
+ return ""
97
+ try:
98
+ result = subprocess.run(
99
+ [*split_memsearch_cmd(memsearch_cmd), "config", "get", "plugins.opencode.summarize.provider"],
100
+ capture_output=True,
101
+ text=True,
102
+ timeout=5,
103
+ )
104
+ return result.stdout.strip()
105
+ except Exception:
106
+ return ""
107
+
108
+
109
+ def ensure_isolated_config() -> str:
66
110
  """Create isolated config dir without plugins/ to prevent recursion."""
67
- isolated = "/tmp/opencode-memsearch-summarize/opencode"
111
+ isolated = os.path.expanduser("~/.codex/tmp/opencode-memsearch-summarize/opencode")
68
112
  os.makedirs(isolated, exist_ok=True)
69
113
  # Copy opencode.json (provider config) but NOT plugins/
70
114
  src = os.path.expanduser("~/.config/opencode/opencode.json")
@@ -74,17 +118,19 @@ def ensure_isolated_config():
74
118
  os.remove(dst)
75
119
  if os.path.exists(src) and not os.path.exists(dst):
76
120
  shutil.copy2(src, dst)
77
- return os.path.dirname(isolated) # /tmp/opencode-memsearch-summarize
121
+ return os.path.dirname(isolated)
78
122
 
79
123
 
80
- def _load_summarize_prompt(agent_name, memsearch_cmd=None):
124
+ def _load_summarize_prompt(agent_name: str, memsearch_cmd: str | None = None) -> str:
81
125
  """Load summarization prompt: user custom > plugin built-in > inline fallback."""
82
126
  # Try user-custom prompt via config
83
127
  if memsearch_cmd:
84
128
  try:
85
129
  result = subprocess.run(
86
130
  [*split_memsearch_cmd(memsearch_cmd), "config", "get", "prompts.summarize"],
87
- capture_output=True, text=True, timeout=5,
131
+ capture_output=True,
132
+ text=True,
133
+ timeout=5,
88
134
  )
89
135
  custom_path = result.stdout.strip()
90
136
  if custom_path and os.path.isfile(custom_path):
@@ -106,13 +152,34 @@ def _load_summarize_prompt(agent_name, memsearch_cmd=None):
106
152
  )
107
153
 
108
154
 
109
- def summarize_with_llm(turn_text, small_model, memsearch_cmd=None):
110
- """Summarize using opencode run in isolated env (no plugins -> no recursion)."""
155
+ def summarize_with_llm(turn_text: str, small_model: str, memsearch_cmd: str | None = None) -> str | None:
156
+ """Summarize using configured provider routing."""
111
157
  system_prompt = _load_summarize_prompt("OpenCode", memsearch_cmd)
112
- full_prompt = (
113
- f"{system_prompt}\n\n"
114
- f"Transcript:\n{turn_text}"
115
- )
158
+ full_prompt = f"{system_prompt}\n\nTranscript:\n{turn_text}"
159
+
160
+ summarize_provider = get_plugin_summarize_provider(memsearch_cmd)
161
+ if summarize_provider and summarize_provider != "native" and memsearch_cmd:
162
+ try:
163
+ result = subprocess.run(
164
+ [*split_memsearch_cmd(memsearch_cmd), "summarize", "--plugin", "opencode", "--agent-name", "OpenCode"],
165
+ input=turn_text,
166
+ capture_output=True,
167
+ text=True,
168
+ timeout=30,
169
+ env={**os.environ, "MEMSEARCH_NO_WATCH": "1"},
170
+ )
171
+ output = result.stdout.strip()
172
+ lines = output.split("\n")
173
+ bullets = [line for line in lines if line.strip().startswith("- ")]
174
+ if bullets:
175
+ return "\n".join(bullets)
176
+ if output:
177
+ return output
178
+ except Exception:
179
+ pass
180
+ return None
181
+
182
+ # Native path: summarize using opencode run in isolated env (no plugins -> no recursion).
116
183
  isolated_dir = ensure_isolated_config()
117
184
 
118
185
  summarize_model = get_plugin_summarize_model(memsearch_cmd) or small_model
@@ -130,10 +197,11 @@ def summarize_with_llm(turn_text, small_model, memsearch_cmd=None):
130
197
  "XDG_DATA_HOME": os.path.join(isolated_dir, "data"),
131
198
  "MEMSEARCH_NO_WATCH": "1",
132
199
  },
133
- capture_output=True, text=True, timeout=30,
200
+ capture_output=True,
201
+ text=True,
202
+ timeout=30,
134
203
  )
135
204
  output = result.stdout.strip()
136
- # Extract bullet points (skip any opencode run header lines)
137
205
  lines = output.split("\n")
138
206
  bullets = [line for line in lines if line.strip().startswith("- ")]
139
207
  if bullets:
@@ -141,32 +209,15 @@ def summarize_with_llm(turn_text, small_model, memsearch_cmd=None):
141
209
  except Exception:
142
210
  pass
143
211
 
144
- return None # fallback to raw text
145
-
212
+ return None
146
213
 
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
158
214
 
159
-
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
215
+ def get_session_ids(conn: sqlite3.Connection, project_dir: str) -> list[str]:
216
+ """Find OpenCode sessions that belong to the given project directory."""
167
217
  sessions = conn.execute(
168
218
  """
169
- SELECT s.id FROM session s
219
+ SELECT s.id
220
+ FROM session s
170
221
  WHERE s.directory = ?
171
222
  ORDER BY s.time_updated DESC
172
223
  LIMIT 5
@@ -175,10 +226,10 @@ def get_new_completed_turns(conn, project_dir, last_msg_time):
175
226
  ).fetchall()
176
227
 
177
228
  if not sessions:
178
- # Fallback: match by basename
179
229
  sessions = conn.execute(
180
230
  """
181
- SELECT s.id FROM session s
231
+ SELECT s.id
232
+ FROM session s
182
233
  WHERE s.directory LIKE ?
183
234
  ORDER BY s.time_updated DESC
184
235
  LIMIT 5
@@ -186,75 +237,52 @@ def get_new_completed_turns(conn, project_dir, last_msg_time):
186
237
  (f"%{os.path.basename(project_dir)}%",),
187
238
  ).fetchall()
188
239
 
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()
240
+ return [row[0] for row in sessions]
201
241
 
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
242
 
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=""):
243
+ def _load_legacy_last_msg_time(project_dir: str) -> int:
244
+ """Read the pre-sidecar capture checkpoint for upgrade compatibility."""
245
+ path = Path(project_dir) / ".memsearch" / ".last_msg_time"
246
+ try:
247
+ value = int(path.read_text(encoding="utf-8").strip())
248
+ except (OSError, ValueError):
249
+ return 0
250
+ return max(value, 0)
251
+
252
+
253
+ def _make_capture_anchor_key(session_id: str, turn_id: str) -> tuple[str, str]:
254
+ return (session_id, turn_id)
255
+
256
+
257
+ def load_capture_anchor_cache(memory_dir: str) -> set[tuple[str, str]]:
258
+ if not os.path.isdir(memory_dir):
259
+ return set()
260
+
261
+ anchor_cache: set[tuple[str, str]] = set()
262
+ for name in os.listdir(memory_dir):
263
+ if not name.endswith(".md"):
264
+ continue
265
+
266
+ path = os.path.join(memory_dir, name)
267
+ try:
268
+ text = Path(path).read_text(encoding="utf-8")
269
+ except OSError:
270
+ continue
271
+
272
+ for session_id, turn_id in _ANCHOR_RE.findall(text):
273
+ anchor_cache.add(_make_capture_anchor_key(session_id, turn_id))
274
+
275
+ return anchor_cache
276
+
277
+
278
+ def write_capture(
279
+ memory_dir: str,
280
+ turn_text: str,
281
+ session_id: str,
282
+ turn_id: str,
283
+ db_path: str = "",
284
+ anchor_cache: set[tuple[str, str]] | None = None,
285
+ ) -> str:
258
286
  """Write a captured turn to the daily memory file."""
259
287
  os.makedirs(memory_dir, exist_ok=True)
260
288
 
@@ -263,19 +291,202 @@ def write_capture(memory_dir, turn_text, session_id, db_path=""):
263
291
  memory_file = os.path.join(memory_dir, f"{today}.md")
264
292
 
265
293
  if not os.path.exists(memory_file):
266
- with open(memory_file, "w") as f:
294
+ with open(memory_file, "w", encoding="utf-8") as f:
267
295
  f.write(f"# {today}\n\n## Session {now}\n\n")
268
296
 
269
- anchor = f"<!-- session:{session_id} db:{db_path} -->\n" if session_id else ""
297
+ anchor = f"<!-- session:{session_id} turn:{turn_id} db:{db_path} -->\n" if session_id else ""
270
298
  entry = f"### {now}\n{anchor}{turn_text}\n\n"
271
299
 
272
- with open(memory_file, "a") as f:
300
+ with open(memory_file, "a", encoding="utf-8") as f:
273
301
  f.write(entry)
274
302
 
303
+ if session_id and anchor_cache is not None:
304
+ anchor_cache.add(_make_capture_anchor_key(session_id, turn_id))
305
+
275
306
  return memory_file
276
307
 
277
308
 
278
- def main():
309
+ def capture_exists(memory_dir: str, session_id: str, turn_id: str) -> bool:
310
+ """Check whether a turn anchor was already written to memory markdown."""
311
+ if not os.path.isdir(memory_dir):
312
+ return False
313
+
314
+ anchor = f"<!-- session:{session_id} turn:{turn_id} "
315
+ for name in os.listdir(memory_dir):
316
+ if not name.endswith(".md"):
317
+ continue
318
+
319
+ path = os.path.join(memory_dir, name)
320
+ try:
321
+ with open(path, encoding="utf-8") as handle:
322
+ if anchor in handle.read():
323
+ return True
324
+ except OSError:
325
+ continue
326
+
327
+ return False
328
+
329
+
330
+ def load_saved_turn_index(conn: sqlite3.Connection, session_id: str, turn_id: str) -> int | None:
331
+ """Return an existing saved turn index when replaying a partially persisted turn."""
332
+ row = conn.execute(
333
+ """
334
+ SELECT turn_index
335
+ FROM turns
336
+ WHERE session_id = ? AND turn_id = ?
337
+ """,
338
+ (session_id, turn_id),
339
+ ).fetchone()
340
+ if row is None:
341
+ return None
342
+ return int(row["turn_index"])
343
+
344
+
345
+ def get_next_turn_index(conn: sqlite3.Connection, session_id: str) -> int:
346
+ """Return the next monotonically increasing turn index for a session."""
347
+ row = conn.execute(
348
+ """
349
+ SELECT COALESCE(MAX(turn_index), 0) AS max_turn_index
350
+ FROM turns
351
+ WHERE session_id = ?
352
+ """,
353
+ (session_id,),
354
+ ).fetchone()
355
+ return int(row["max_turn_index"]) + 1
356
+
357
+
358
+ def _now_ms() -> int:
359
+ return int(time.time() * 1000)
360
+
361
+
362
+ def _compute_turn_fingerprint(turn) -> str:
363
+ payload = {
364
+ "turn_id": turn.turn_id,
365
+ "last_message_id": turn.last_message_id,
366
+ "message_count": turn.message_count,
367
+ "assistant_message_count": turn.assistant_message_count,
368
+ "complete": turn.complete,
369
+ "messages": [
370
+ {
371
+ "id": message.id,
372
+ "role": message.role,
373
+ "parent_id": message.parent_id,
374
+ "time_created": message.time_created,
375
+ "finish": message.finish,
376
+ "text": message.text,
377
+ }
378
+ for message in turn.messages
379
+ ],
380
+ }
381
+ encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True)
382
+ return hashlib.sha1(encoded.encode("utf-8")).hexdigest()
383
+
384
+
385
+ def _tail_turn_is_stable_for_capture(
386
+ turn,
387
+ tail_turn_cache: dict[str, TailTurnObservation],
388
+ ) -> bool:
389
+ if not turn.complete:
390
+ tail_turn_cache.pop(turn.session_id, None)
391
+ return False
392
+
393
+ fingerprint = _compute_turn_fingerprint(turn)
394
+ now_ms = _now_ms()
395
+ observed = tail_turn_cache.get(turn.session_id)
396
+ if observed is None or observed.turn_id != turn.turn_id or observed.fingerprint != fingerprint:
397
+ tail_turn_cache[turn.session_id] = TailTurnObservation(
398
+ turn_id=turn.turn_id,
399
+ fingerprint=fingerprint,
400
+ stable_since_ms=now_ms,
401
+ )
402
+ return False
403
+
404
+ return now_ms - observed.stable_since_ms >= _TAIL_TURN_QUIET_PERIOD_MS
405
+
406
+
407
+ def _turn_ready_for_capture(
408
+ turn,
409
+ is_tail_turn: bool,
410
+ tail_turn_cache: dict[str, TailTurnObservation],
411
+ ) -> bool:
412
+ # A non-tail turn has already been closed by a later user message.
413
+ if not is_tail_turn:
414
+ tail_turn_cache.pop(turn.session_id, None)
415
+ return True
416
+
417
+ # The final turn must stay textually stable for a quiet window before capture.
418
+ return _tail_turn_is_stable_for_capture(turn, tail_turn_cache)
419
+
420
+
421
+ def capture_session_turns(
422
+ conn: sqlite3.Connection,
423
+ turn_db: sqlite3.Connection,
424
+ memory_dir: str,
425
+ session_id: str,
426
+ small_model: str,
427
+ memsearch_cmd: str,
428
+ db_path: str,
429
+ tail_turn_cache: dict[str, TailTurnObservation] | None = None,
430
+ ) -> bool:
431
+ """Capture all newly completed turns for a single session."""
432
+ state = load_turn_state(turn_db, session_id)
433
+ captured_any = False
434
+ next_turn_index = get_next_turn_index(turn_db, session_id)
435
+ if tail_turn_cache is None:
436
+ tail_turn_cache = {}
437
+
438
+ after_time = state.last_completed_time if state.last_completed_time > 0 else None
439
+ if after_time is None:
440
+ legacy_after_time = _load_legacy_last_msg_time(str(Path(memory_dir).resolve().parent.parent))
441
+ if legacy_after_time > 0:
442
+ after_time = legacy_after_time
443
+ after_message_id = state.last_completed_message_id or None
444
+ turns = build_turns(
445
+ conn,
446
+ session_id,
447
+ after_time=after_time,
448
+ after_message_id=after_message_id,
449
+ )
450
+
451
+ for index, turn in enumerate(turns):
452
+ is_tail_turn = index == len(turns) - 1
453
+ if not _turn_ready_for_capture(turn, is_tail_turn, tail_turn_cache):
454
+ break
455
+
456
+ saved_turn_index = load_saved_turn_index(turn_db, session_id, turn.turn_id)
457
+ if saved_turn_index is not None:
458
+ turn.turn_index = saved_turn_index
459
+ else:
460
+ turn.turn_index = next_turn_index
461
+ next_turn_index += 1
462
+
463
+ turn_text = turn.render()
464
+ if len(turn_text.strip()) <= 10:
465
+ continue
466
+
467
+ # Check anchor first so that sidecar rebuilds repair state without
468
+ # calling the summarizer again for already-captured turns.
469
+ if not capture_exists(memory_dir, session_id, turn.turn_id):
470
+ summary = summarize_with_llm(turn_text, small_model, memsearch_cmd)
471
+ write_capture(
472
+ memory_dir,
473
+ summary if summary else turn_text,
474
+ session_id,
475
+ turn.turn_id,
476
+ db_path,
477
+ )
478
+ save_turn(turn_db, turn)
479
+ state.last_completed_time = turn.end_time
480
+ state.last_completed_message_id = turn.last_message_id
481
+ state.last_completed_turn_id = turn.turn_id
482
+ save_turn_state(turn_db, state)
483
+ tail_turn_cache.pop(session_id, None)
484
+ captured_any = True
485
+
486
+ return captured_any
487
+
488
+
489
+ def main() -> None:
279
490
  parser = argparse.ArgumentParser(description="Capture daemon for OpenCode sessions")
280
491
  parser.add_argument("project_dir", help="Project directory")
281
492
  parser.add_argument("collection_name", help="Milvus collection name")
@@ -291,13 +502,11 @@ def main():
291
502
  memory_dir = os.path.join(args.project_dir, ".memsearch", "memory")
292
503
  pid_file = os.path.join(args.project_dir, ".memsearch", ".capture.pid")
293
504
 
294
- # Write PID file
295
505
  os.makedirs(os.path.dirname(pid_file), exist_ok=True)
296
- with open(pid_file, "w") as f:
506
+ with open(pid_file, "w", encoding="utf-8") as f:
297
507
  f.write(str(os.getpid()))
298
508
 
299
- # Clean up on exit
300
- def cleanup(signum=None, frame=None):
509
+ def cleanup(signum=None, frame=None) -> None:
301
510
  with contextlib.suppress(OSError):
302
511
  os.remove(pid_file)
303
512
  sys.exit(0)
@@ -306,41 +515,40 @@ def main():
306
515
  signal.signal(signal.SIGINT, cleanup)
307
516
 
308
517
  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())
518
+ turn_db = open_turn_db(args.project_dir)
519
+ tail_turn_cache: dict[str, TailTurnObservation] = {}
315
520
 
316
521
  while True:
522
+ any_new = False
523
+ conn = None
317
524
  try:
318
525
  conn = sqlite3.connect(db_path, timeout=5)
526
+ conn.row_factory = sqlite3.Row
527
+ for session_id in get_session_ids(conn, args.project_dir):
528
+ any_new = (
529
+ capture_session_turns(
530
+ conn,
531
+ turn_db,
532
+ memory_dir,
533
+ session_id,
534
+ small_model,
535
+ args.memsearch_cmd,
536
+ db_path,
537
+ tail_turn_cache,
538
+ )
539
+ or any_new
540
+ )
319
541
 
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
542
+ if any_new:
336
543
  os.system(
337
544
  f"{args.memsearch_cmd} index '{memory_dir}' "
338
545
  f"--collection {args.collection_name} &"
339
546
  )
340
-
341
- conn.close()
342
547
  except Exception:
343
548
  pass
549
+ finally:
550
+ if conn is not None:
551
+ conn.close()
344
552
 
345
553
  time.sleep(args.poll_interval)
346
554