@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.
- package/README.md +8 -3
- package/index.ts +25 -8
- package/package.json +1 -1
- package/scripts/capture-daemon.py +311 -143
- package/scripts/opencode_turns.py +449 -0
- package/scripts/parse-transcript.py +61 -148
- package/skills/memory-recall/SKILL.md +2 -1
|
@@ -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
|
|
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
|
|
|
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,
|
|
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)
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
161
|
-
"""
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
238
|
-
for
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
310
|
-
|
|
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
|
-
|
|
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
|
|