@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.
- package/README.md +16 -3
- package/index.ts +25 -8
- package/package.json +3 -2
- package/scripts/capture-daemon.py +344 -155
- package/scripts/opencode_turns.py +449 -0
- package/scripts/parse-transcript.py +61 -148
- package/skills/memory-recall/SKILL.md +2 -1
- package/scripts/__pycache__/capture-daemon.cpython-313.pyc +0 -0
|
@@ -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
|
|
8
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
|
63
|
-
capture_output=True,
|
|
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
|
|
96
|
-
cmd += ["-m",
|
|
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,
|
|
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 = [
|
|
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
|
|
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
|
-
|
|
139
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
275
|
-
|
|
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
|
-
|
|
287
|
-
|
|
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
|
-
|
|
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
|
|