@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.
- package/README.md +19 -5
- package/index.ts +25 -8
- package/package.json +1 -1
- package/scripts/capture-daemon.py +352 -144
- 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
|
|
28
53
|
|
|
29
|
-
|
|
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,
|
|
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
|
|
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)
|
|
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,
|
|
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
|
|
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
|
-
|
|
114
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
238
|
-
for
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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())
|
|
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
|
-
|
|
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
|
|