@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
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Shared helpers for OpenCode transcript turn handling.
|
|
3
|
+
|
|
4
|
+
The raw OpenCode SQLite database remains the source of truth for transcript
|
|
5
|
+
content. This module also manages a small sidecar SQLite database under
|
|
6
|
+
<project>/.memsearch/opencode-turns.db for derived capture checkpoints and
|
|
7
|
+
stable turn ordering during replay.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import sqlite3
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(slots=True)
|
|
21
|
+
class OpenCodeMessage:
|
|
22
|
+
"""A single meaningful OpenCode message with rendered text."""
|
|
23
|
+
|
|
24
|
+
id: str
|
|
25
|
+
role: str
|
|
26
|
+
parent_id: str | None
|
|
27
|
+
time_created: int
|
|
28
|
+
finish: str | None
|
|
29
|
+
text: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(slots=True)
|
|
33
|
+
class OpenCodeTurn:
|
|
34
|
+
"""A user turn plus its assistant follow-up messages."""
|
|
35
|
+
|
|
36
|
+
session_id: str
|
|
37
|
+
turn_id: str
|
|
38
|
+
turn_index: int
|
|
39
|
+
start_time: int
|
|
40
|
+
end_time: int
|
|
41
|
+
first_message_id: str
|
|
42
|
+
last_message_id: str
|
|
43
|
+
message_count: int
|
|
44
|
+
assistant_message_count: int
|
|
45
|
+
complete: bool
|
|
46
|
+
messages: list[OpenCodeMessage] = field(default_factory=list)
|
|
47
|
+
|
|
48
|
+
def render(self, max_chars: int = 3000) -> str:
|
|
49
|
+
"""Render the turn into readable transcript text."""
|
|
50
|
+
lines: list[str] = [
|
|
51
|
+
f"=== Turn {self.turn_index} ({self.turn_id}) ===",
|
|
52
|
+
]
|
|
53
|
+
for message in self.messages:
|
|
54
|
+
label = "[Human]" if message.role == "user" else "[Assistant]"
|
|
55
|
+
text = message.text.strip()
|
|
56
|
+
if len(text) > max_chars:
|
|
57
|
+
text = text[:max_chars] + "\n..."
|
|
58
|
+
lines.append(f"{label}: {text}")
|
|
59
|
+
lines.append("")
|
|
60
|
+
return "\n".join(lines).strip()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(slots=True)
|
|
64
|
+
class TurnState:
|
|
65
|
+
"""Persistent capture progress for a session."""
|
|
66
|
+
|
|
67
|
+
session_id: str
|
|
68
|
+
last_completed_time: int
|
|
69
|
+
last_completed_message_id: str
|
|
70
|
+
last_completed_turn_id: str
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def get_db_path() -> str:
|
|
74
|
+
"""Find the OpenCode SQLite database."""
|
|
75
|
+
default = os.path.expanduser("~/.local/share/opencode/opencode.db")
|
|
76
|
+
if os.path.exists(default):
|
|
77
|
+
return default
|
|
78
|
+
|
|
79
|
+
xdg_data = os.environ.get("XDG_DATA_HOME", "")
|
|
80
|
+
if xdg_data:
|
|
81
|
+
alt = os.path.join(xdg_data, "opencode", "opencode.db")
|
|
82
|
+
if os.path.exists(alt):
|
|
83
|
+
return alt
|
|
84
|
+
|
|
85
|
+
return default
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def get_turn_db_path(project_dir: str) -> str:
|
|
89
|
+
"""Return the derived turn metadata database path for a project."""
|
|
90
|
+
return os.path.join(project_dir, ".memsearch", "opencode-turns.db")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def open_turn_db(project_dir: str) -> sqlite3.Connection:
|
|
94
|
+
"""Open the sidecar turn database and ensure its schema exists."""
|
|
95
|
+
turn_db_path = get_turn_db_path(project_dir)
|
|
96
|
+
Path(turn_db_path).parent.mkdir(parents=True, exist_ok=True)
|
|
97
|
+
conn = sqlite3.connect(turn_db_path, timeout=5)
|
|
98
|
+
conn.row_factory = sqlite3.Row
|
|
99
|
+
ensure_turn_schema(conn)
|
|
100
|
+
return conn
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def ensure_turn_schema(conn: sqlite3.Connection) -> None:
|
|
104
|
+
"""Create the sidecar schema if it does not exist yet."""
|
|
105
|
+
conn.execute(
|
|
106
|
+
"""
|
|
107
|
+
CREATE TABLE IF NOT EXISTS turns (
|
|
108
|
+
session_id TEXT NOT NULL,
|
|
109
|
+
turn_id TEXT NOT NULL,
|
|
110
|
+
turn_index INTEGER NOT NULL,
|
|
111
|
+
start_time INTEGER NOT NULL,
|
|
112
|
+
start_message_id TEXT NOT NULL,
|
|
113
|
+
end_time INTEGER NOT NULL,
|
|
114
|
+
end_message_id TEXT NOT NULL,
|
|
115
|
+
message_count INTEGER NOT NULL,
|
|
116
|
+
assistant_message_count INTEGER NOT NULL,
|
|
117
|
+
complete INTEGER NOT NULL DEFAULT 1,
|
|
118
|
+
time_created INTEGER NOT NULL,
|
|
119
|
+
time_updated INTEGER NOT NULL,
|
|
120
|
+
PRIMARY KEY (session_id, turn_id)
|
|
121
|
+
)
|
|
122
|
+
"""
|
|
123
|
+
)
|
|
124
|
+
conn.execute(
|
|
125
|
+
"""
|
|
126
|
+
CREATE TABLE IF NOT EXISTS turn_state (
|
|
127
|
+
session_id TEXT PRIMARY KEY,
|
|
128
|
+
last_completed_time INTEGER NOT NULL DEFAULT 0,
|
|
129
|
+
last_completed_message_id TEXT NOT NULL DEFAULT '',
|
|
130
|
+
last_completed_turn_id TEXT NOT NULL DEFAULT '',
|
|
131
|
+
time_created INTEGER NOT NULL,
|
|
132
|
+
time_updated INTEGER NOT NULL
|
|
133
|
+
)
|
|
134
|
+
"""
|
|
135
|
+
)
|
|
136
|
+
conn.execute(
|
|
137
|
+
"CREATE INDEX IF NOT EXISTS turns_session_index_idx ON turns (session_id, turn_index)"
|
|
138
|
+
)
|
|
139
|
+
conn.execute(
|
|
140
|
+
"CREATE INDEX IF NOT EXISTS turns_session_time_idx ON turns (session_id, end_time, turn_id)"
|
|
141
|
+
)
|
|
142
|
+
conn.commit()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def load_turn_state(conn: sqlite3.Connection, session_id: str) -> TurnState:
|
|
146
|
+
"""Load the last completed turn checkpoint for a session."""
|
|
147
|
+
row = conn.execute(
|
|
148
|
+
"""
|
|
149
|
+
SELECT session_id, last_completed_time, last_completed_message_id, last_completed_turn_id
|
|
150
|
+
FROM turn_state
|
|
151
|
+
WHERE session_id = ?
|
|
152
|
+
""",
|
|
153
|
+
(session_id,),
|
|
154
|
+
).fetchone()
|
|
155
|
+
|
|
156
|
+
if row is None:
|
|
157
|
+
return TurnState(session_id=session_id, last_completed_time=0, last_completed_message_id="", last_completed_turn_id="")
|
|
158
|
+
|
|
159
|
+
return TurnState(
|
|
160
|
+
session_id=row["session_id"],
|
|
161
|
+
last_completed_time=int(row["last_completed_time"]),
|
|
162
|
+
last_completed_message_id=row["last_completed_message_id"] or "",
|
|
163
|
+
last_completed_turn_id=row["last_completed_turn_id"] or "",
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def save_turn_state(conn: sqlite3.Connection, state: TurnState) -> None:
|
|
168
|
+
"""Persist the checkpoint for a session."""
|
|
169
|
+
now = int(_now_ms())
|
|
170
|
+
conn.execute(
|
|
171
|
+
"""
|
|
172
|
+
INSERT INTO turn_state (
|
|
173
|
+
session_id, last_completed_time, last_completed_message_id, last_completed_turn_id,
|
|
174
|
+
time_created, time_updated
|
|
175
|
+
) VALUES (?, ?, ?, ?, ?, ?)
|
|
176
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
177
|
+
last_completed_time = excluded.last_completed_time,
|
|
178
|
+
last_completed_message_id = excluded.last_completed_message_id,
|
|
179
|
+
last_completed_turn_id = excluded.last_completed_turn_id,
|
|
180
|
+
time_updated = excluded.time_updated
|
|
181
|
+
""",
|
|
182
|
+
(
|
|
183
|
+
state.session_id,
|
|
184
|
+
state.last_completed_time,
|
|
185
|
+
state.last_completed_message_id,
|
|
186
|
+
state.last_completed_turn_id,
|
|
187
|
+
now,
|
|
188
|
+
now,
|
|
189
|
+
),
|
|
190
|
+
)
|
|
191
|
+
conn.commit()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def save_turn(conn: sqlite3.Connection, turn: OpenCodeTurn) -> None:
|
|
195
|
+
"""Persist derived metadata for a completed turn."""
|
|
196
|
+
now = int(_now_ms())
|
|
197
|
+
conn.execute(
|
|
198
|
+
"""
|
|
199
|
+
INSERT INTO turns (
|
|
200
|
+
session_id, turn_id, turn_index, start_time, start_message_id, end_time,
|
|
201
|
+
end_message_id, message_count, assistant_message_count, complete,
|
|
202
|
+
time_created, time_updated
|
|
203
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
204
|
+
ON CONFLICT(session_id, turn_id) DO UPDATE SET
|
|
205
|
+
turn_index = excluded.turn_index,
|
|
206
|
+
start_time = excluded.start_time,
|
|
207
|
+
start_message_id = excluded.start_message_id,
|
|
208
|
+
end_time = excluded.end_time,
|
|
209
|
+
end_message_id = excluded.end_message_id,
|
|
210
|
+
message_count = excluded.message_count,
|
|
211
|
+
assistant_message_count = excluded.assistant_message_count,
|
|
212
|
+
complete = excluded.complete,
|
|
213
|
+
time_updated = excluded.time_updated
|
|
214
|
+
""",
|
|
215
|
+
(
|
|
216
|
+
turn.session_id,
|
|
217
|
+
turn.turn_id,
|
|
218
|
+
turn.turn_index,
|
|
219
|
+
turn.start_time,
|
|
220
|
+
turn.first_message_id,
|
|
221
|
+
turn.end_time,
|
|
222
|
+
turn.last_message_id,
|
|
223
|
+
turn.message_count,
|
|
224
|
+
turn.assistant_message_count,
|
|
225
|
+
1 if turn.complete else 0,
|
|
226
|
+
now,
|
|
227
|
+
now,
|
|
228
|
+
),
|
|
229
|
+
)
|
|
230
|
+
conn.commit()
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def load_session_turn_rows(conn: sqlite3.Connection, session_id: str) -> list[sqlite3.Row]:
|
|
234
|
+
"""Load cached turn rows for a session."""
|
|
235
|
+
return conn.execute(
|
|
236
|
+
"""
|
|
237
|
+
SELECT session_id, turn_id, turn_index, start_time, start_message_id,
|
|
238
|
+
end_time, end_message_id, message_count, assistant_message_count, complete
|
|
239
|
+
FROM turns
|
|
240
|
+
WHERE session_id = ?
|
|
241
|
+
ORDER BY turn_index ASC
|
|
242
|
+
""",
|
|
243
|
+
(session_id,),
|
|
244
|
+
).fetchall()
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def load_messages(
|
|
248
|
+
conn: sqlite3.Connection,
|
|
249
|
+
session_id: str,
|
|
250
|
+
after_time: int | None = None,
|
|
251
|
+
after_message_id: str | None = None,
|
|
252
|
+
) -> list[sqlite3.Row]:
|
|
253
|
+
"""Load OpenCode messages for a session in chronological order."""
|
|
254
|
+
query = [
|
|
255
|
+
"SELECT id, data, time_created FROM message WHERE session_id = ?",
|
|
256
|
+
]
|
|
257
|
+
params: list[object] = [session_id]
|
|
258
|
+
|
|
259
|
+
if after_time is not None:
|
|
260
|
+
if after_message_id:
|
|
261
|
+
query.append(
|
|
262
|
+
"AND (time_created > ? OR (time_created = ? AND id > ?))"
|
|
263
|
+
)
|
|
264
|
+
params.extend([after_time, after_time, after_message_id])
|
|
265
|
+
else:
|
|
266
|
+
query.append("AND time_created > ?")
|
|
267
|
+
params.append(after_time)
|
|
268
|
+
|
|
269
|
+
query.append("ORDER BY time_created ASC, id ASC")
|
|
270
|
+
return conn.execute(" ".join(query), tuple(params)).fetchall()
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def extract_message_text(conn: sqlite3.Connection, message_id: str) -> str:
|
|
274
|
+
"""Render a message's parts into readable text."""
|
|
275
|
+
parts = conn.execute(
|
|
276
|
+
"""
|
|
277
|
+
SELECT id, data, time_created
|
|
278
|
+
FROM part
|
|
279
|
+
WHERE message_id = ?
|
|
280
|
+
ORDER BY time_created ASC, id ASC
|
|
281
|
+
""",
|
|
282
|
+
(message_id,),
|
|
283
|
+
).fetchall()
|
|
284
|
+
|
|
285
|
+
text_parts: list[str] = []
|
|
286
|
+
tool_parts: list[str] = []
|
|
287
|
+
|
|
288
|
+
for part in parts:
|
|
289
|
+
try:
|
|
290
|
+
part_data = json.loads(part["data"])
|
|
291
|
+
except Exception:
|
|
292
|
+
continue
|
|
293
|
+
|
|
294
|
+
part_type = part_data.get("type", "")
|
|
295
|
+
if part_type == "text" and part_data.get("text"):
|
|
296
|
+
if part_data.get("synthetic"):
|
|
297
|
+
continue
|
|
298
|
+
text = str(part_data["text"]).strip()
|
|
299
|
+
if text:
|
|
300
|
+
text_parts.append(text)
|
|
301
|
+
elif part_type == "tool" and part_data.get("state"):
|
|
302
|
+
state = part_data.get("state", {})
|
|
303
|
+
status = state.get("status", "unknown")
|
|
304
|
+
tool_name = part_data.get("tool", "unknown")
|
|
305
|
+
|
|
306
|
+
if status == "completed":
|
|
307
|
+
tool_input = state.get("input", {})
|
|
308
|
+
tool_output = state.get("output", "")
|
|
309
|
+
if isinstance(tool_output, str) and len(tool_output) > 300:
|
|
310
|
+
tool_output = tool_output[:300] + "..."
|
|
311
|
+
|
|
312
|
+
input_summary = ""
|
|
313
|
+
if isinstance(tool_input, dict):
|
|
314
|
+
if "command" in tool_input:
|
|
315
|
+
input_summary = f" `{tool_input['command']}`"
|
|
316
|
+
elif "path" in tool_input:
|
|
317
|
+
input_summary = f" {tool_input['path']}"
|
|
318
|
+
elif "query" in tool_input:
|
|
319
|
+
input_summary = f" '{tool_input['query']}'"
|
|
320
|
+
|
|
321
|
+
tool_parts.append(
|
|
322
|
+
f"[Tool: {tool_name}{input_summary}] {tool_output}"
|
|
323
|
+
)
|
|
324
|
+
elif status == "error":
|
|
325
|
+
error = state.get("error", "unknown error")
|
|
326
|
+
tool_parts.append(f"[Tool: {tool_name}] Error: {error}")
|
|
327
|
+
|
|
328
|
+
combined = "\n".join(text_parts).strip()
|
|
329
|
+
if tool_parts:
|
|
330
|
+
combined = "\n".join([combined, "\n".join(tool_parts)]).strip()
|
|
331
|
+
return combined
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def build_turns(
|
|
335
|
+
conn: sqlite3.Connection,
|
|
336
|
+
session_id: str,
|
|
337
|
+
after_time: int | None = None,
|
|
338
|
+
after_message_id: str | None = None,
|
|
339
|
+
) -> list[OpenCodeTurn]:
|
|
340
|
+
"""Group OpenCode messages into turns."""
|
|
341
|
+
rows = load_messages(conn, session_id, after_time=after_time, after_message_id=after_message_id)
|
|
342
|
+
|
|
343
|
+
turns: list[OpenCodeTurn] = []
|
|
344
|
+
current: OpenCodeTurn | None = None
|
|
345
|
+
current_message_ids: set[str] = set()
|
|
346
|
+
|
|
347
|
+
for row in rows:
|
|
348
|
+
try:
|
|
349
|
+
msg_data = json.loads(row["data"])
|
|
350
|
+
except Exception:
|
|
351
|
+
continue
|
|
352
|
+
|
|
353
|
+
role = msg_data.get("role", "unknown")
|
|
354
|
+
parent_id = msg_data.get("parentID")
|
|
355
|
+
time_created = int(row["time_created"])
|
|
356
|
+
finish = msg_data.get("finish")
|
|
357
|
+
message_text = extract_message_text(conn, row["id"]).strip()
|
|
358
|
+
|
|
359
|
+
if role == "user":
|
|
360
|
+
if not message_text:
|
|
361
|
+
continue
|
|
362
|
+
|
|
363
|
+
message = OpenCodeMessage(
|
|
364
|
+
id=row["id"],
|
|
365
|
+
role=role,
|
|
366
|
+
parent_id=parent_id,
|
|
367
|
+
time_created=time_created,
|
|
368
|
+
finish=finish,
|
|
369
|
+
text=message_text,
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
if current is not None:
|
|
373
|
+
current.complete = _is_complete(current)
|
|
374
|
+
turns.append(current)
|
|
375
|
+
|
|
376
|
+
current = OpenCodeTurn(
|
|
377
|
+
session_id=session_id,
|
|
378
|
+
turn_id=message.id,
|
|
379
|
+
turn_index=len(turns) + 1,
|
|
380
|
+
start_time=message.time_created,
|
|
381
|
+
end_time=message.time_created,
|
|
382
|
+
first_message_id=message.id,
|
|
383
|
+
last_message_id=message.id,
|
|
384
|
+
message_count=1,
|
|
385
|
+
assistant_message_count=0,
|
|
386
|
+
complete=False,
|
|
387
|
+
messages=[message],
|
|
388
|
+
)
|
|
389
|
+
current_message_ids = {message.id}
|
|
390
|
+
continue
|
|
391
|
+
|
|
392
|
+
if role == "assistant" and current is not None:
|
|
393
|
+
if parent_id and parent_id not in current_message_ids:
|
|
394
|
+
continue
|
|
395
|
+
|
|
396
|
+
if not message_text:
|
|
397
|
+
current_message_ids.add(row["id"])
|
|
398
|
+
continue
|
|
399
|
+
|
|
400
|
+
message = OpenCodeMessage(
|
|
401
|
+
id=row["id"],
|
|
402
|
+
role=role,
|
|
403
|
+
parent_id=parent_id,
|
|
404
|
+
time_created=time_created,
|
|
405
|
+
finish=finish,
|
|
406
|
+
text=message_text,
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
current.messages.append(message)
|
|
410
|
+
current_message_ids.add(message.id)
|
|
411
|
+
current.end_time = message.time_created
|
|
412
|
+
current.last_message_id = message.id
|
|
413
|
+
current.message_count += 1
|
|
414
|
+
current.assistant_message_count += 1
|
|
415
|
+
|
|
416
|
+
if current is not None:
|
|
417
|
+
current.complete = _is_complete(current)
|
|
418
|
+
turns.append(current)
|
|
419
|
+
|
|
420
|
+
for index, turn in enumerate(turns, start=1):
|
|
421
|
+
turn.turn_index = index
|
|
422
|
+
|
|
423
|
+
return turns
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _is_complete(turn: OpenCodeTurn) -> bool:
|
|
427
|
+
"""Heuristically decide whether a grouped turn has finished."""
|
|
428
|
+
assistant_finishes = [msg.finish for msg in turn.messages if msg.role == "assistant"]
|
|
429
|
+
if not assistant_finishes:
|
|
430
|
+
return False
|
|
431
|
+
last_finish = assistant_finishes[-1]
|
|
432
|
+
return last_finish != "tool-calls"
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def find_turn_index(turns: list[OpenCodeTurn], target_turn_id: str) -> int:
|
|
436
|
+
"""Find a turn index by exact or prefix match."""
|
|
437
|
+
if not target_turn_id:
|
|
438
|
+
return -1
|
|
439
|
+
|
|
440
|
+
for index, turn in enumerate(turns):
|
|
441
|
+
if turn.turn_id == target_turn_id:
|
|
442
|
+
return index
|
|
443
|
+
if turn.turn_id.startswith(target_turn_id) or target_turn_id.startswith(turn.turn_id[:8]):
|
|
444
|
+
return index
|
|
445
|
+
return -1
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _now_ms() -> int:
|
|
449
|
+
return int(time.time() * 1000)
|
|
@@ -2,191 +2,104 @@
|
|
|
2
2
|
"""Parse OpenCode SQLite transcript for a given session.
|
|
3
3
|
|
|
4
4
|
Usage:
|
|
5
|
-
parse-transcript.py <session_id> [--limit N] [--
|
|
5
|
+
parse-transcript.py <session_id> [--limit N] [--turn TURN_ID] [--context N] [--project-dir PATH]
|
|
6
6
|
|
|
7
7
|
Options:
|
|
8
|
-
--limit N
|
|
9
|
-
--
|
|
8
|
+
--limit N Max number of turns to return when no turn is targeted (default: 20)
|
|
9
|
+
--turn TURN_ID Return the target turn plus surrounding context turns
|
|
10
|
+
--context N Number of turns before/after the target turn (default: 3)
|
|
11
|
+
--project-dir OpenCode project directory (unused for transcript reads; kept for tool compatibility)
|
|
10
12
|
|
|
11
13
|
The script reads from the OpenCode SQLite database at:
|
|
12
14
|
~/.local/share/opencode/opencode.db
|
|
13
15
|
"""
|
|
14
16
|
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import os
|
|
15
21
|
import sqlite3
|
|
16
|
-
import json
|
|
17
22
|
import sys
|
|
18
|
-
import os
|
|
19
|
-
import argparse
|
|
20
23
|
|
|
24
|
+
from opencode_turns import build_turns, find_turn_index, get_db_path
|
|
21
25
|
|
|
22
|
-
def get_db_path():
|
|
23
|
-
"""Find the OpenCode SQLite database."""
|
|
24
|
-
# Default path
|
|
25
|
-
default = os.path.expanduser("~/.local/share/opencode/opencode.db")
|
|
26
|
-
if os.path.exists(default):
|
|
27
|
-
return default
|
|
28
26
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
if
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
27
|
+
def _emit(text: str = "", *, err: bool = False) -> None:
|
|
28
|
+
"""Write a line of output without relying on print()."""
|
|
29
|
+
stream = sys.stderr if err else sys.stdout
|
|
30
|
+
stream.write(text)
|
|
31
|
+
if not text.endswith("\n"):
|
|
32
|
+
stream.write("\n")
|
|
35
33
|
|
|
36
|
-
return default
|
|
37
34
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
35
|
+
def parse_session(
|
|
36
|
+
session_id: str,
|
|
37
|
+
limit: int = 20,
|
|
38
|
+
turn_id: str | None = None,
|
|
39
|
+
context: int = 3,
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Parse messages for a session, grouped into turns."""
|
|
41
42
|
db_path = get_db_path()
|
|
42
43
|
if not os.path.exists(db_path):
|
|
43
|
-
|
|
44
|
+
_emit(f"Error: OpenCode database not found at {db_path}", err=True)
|
|
44
45
|
sys.exit(1)
|
|
45
46
|
|
|
46
47
|
conn = sqlite3.connect(db_path, timeout=5)
|
|
47
48
|
conn.row_factory = sqlite3.Row
|
|
48
49
|
|
|
49
50
|
try:
|
|
50
|
-
|
|
51
|
-
messages = conn.execute(
|
|
52
|
-
"""
|
|
53
|
-
SELECT id, data, time_created
|
|
54
|
-
FROM message
|
|
55
|
-
WHERE session_id = ?
|
|
56
|
-
ORDER BY time_created ASC
|
|
57
|
-
""",
|
|
58
|
-
(session_id,),
|
|
59
|
-
).fetchall()
|
|
60
|
-
|
|
61
|
-
if not messages:
|
|
62
|
-
print(f"No messages found for session {session_id}")
|
|
63
|
-
return
|
|
64
|
-
|
|
65
|
-
# Build structured turns
|
|
66
|
-
turns = []
|
|
67
|
-
for msg in messages:
|
|
68
|
-
msg_data = json.loads(msg["data"])
|
|
69
|
-
role = msg_data.get("role", "unknown")
|
|
70
|
-
msg_id = msg["id"]
|
|
71
|
-
|
|
72
|
-
# Get parts for this message
|
|
73
|
-
parts = conn.execute(
|
|
74
|
-
"""
|
|
75
|
-
SELECT id, data, time_created
|
|
76
|
-
FROM part
|
|
77
|
-
WHERE message_id = ?
|
|
78
|
-
ORDER BY time_created ASC
|
|
79
|
-
""",
|
|
80
|
-
(msg_id,),
|
|
81
|
-
).fetchall()
|
|
82
|
-
|
|
83
|
-
text_parts = []
|
|
84
|
-
tool_parts = []
|
|
85
|
-
|
|
86
|
-
for part in parts:
|
|
87
|
-
part_data = json.loads(part["data"])
|
|
88
|
-
part_type = part_data.get("type", "")
|
|
89
|
-
|
|
90
|
-
if part_type == "text" and part_data.get("text"):
|
|
91
|
-
text = part_data["text"].strip()
|
|
92
|
-
# Skip synthetic parts (e.g. "The following tool was executed by the user")
|
|
93
|
-
if part_data.get("synthetic"):
|
|
94
|
-
continue
|
|
95
|
-
if text:
|
|
96
|
-
text_parts.append(text)
|
|
97
|
-
|
|
98
|
-
elif part_type == "tool" and part_data.get("state"):
|
|
99
|
-
state = part_data["state"]
|
|
100
|
-
tool_name = part_data.get("tool", "unknown")
|
|
101
|
-
status = state.get("status", "unknown")
|
|
102
|
-
|
|
103
|
-
if status == "completed":
|
|
104
|
-
tool_input = state.get("input", {})
|
|
105
|
-
tool_output = state.get("output", "")
|
|
106
|
-
# Truncate long output
|
|
107
|
-
if isinstance(tool_output, str) and len(tool_output) > 300:
|
|
108
|
-
tool_output = tool_output[:300] + "..."
|
|
109
|
-
|
|
110
|
-
input_summary = ""
|
|
111
|
-
if isinstance(tool_input, dict):
|
|
112
|
-
# Summarize common tool inputs
|
|
113
|
-
if "command" in tool_input:
|
|
114
|
-
input_summary = f" `{tool_input['command']}`"
|
|
115
|
-
elif "path" in tool_input:
|
|
116
|
-
input_summary = f" {tool_input['path']}"
|
|
117
|
-
elif "query" in tool_input:
|
|
118
|
-
input_summary = f" '{tool_input['query']}'"
|
|
119
|
-
|
|
120
|
-
tool_parts.append(
|
|
121
|
-
f"[Tool: {tool_name}{input_summary}] {tool_output}"
|
|
122
|
-
)
|
|
123
|
-
elif status == "error":
|
|
124
|
-
error = state.get("error", "unknown error")
|
|
125
|
-
tool_parts.append(f"[Tool: {tool_name}] Error: {error}")
|
|
126
|
-
|
|
127
|
-
# Combine text and tool parts
|
|
128
|
-
combined = "\n".join(text_parts)
|
|
129
|
-
if tool_parts:
|
|
130
|
-
combined += "\n" + "\n".join(tool_parts)
|
|
131
|
-
|
|
132
|
-
if combined.strip():
|
|
133
|
-
turns.append({"role": role, "text": combined.strip()})
|
|
134
|
-
|
|
51
|
+
turns = build_turns(conn, session_id)
|
|
135
52
|
if not turns:
|
|
136
|
-
|
|
53
|
+
_emit(f"No messages found for session {session_id}")
|
|
137
54
|
return
|
|
138
55
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
56
|
+
if turn_id:
|
|
57
|
+
target_idx = find_turn_index(turns, turn_id)
|
|
58
|
+
if target_idx < 0:
|
|
59
|
+
_emit(f"Turn not found: {turn_id}", err=True)
|
|
60
|
+
sys.exit(1)
|
|
61
|
+
|
|
62
|
+
start = max(0, target_idx - context)
|
|
63
|
+
end = min(len(turns), target_idx + context + 1)
|
|
64
|
+
selected = turns[start:end]
|
|
65
|
+
elif limit and limit > 0:
|
|
66
|
+
selected = turns[-limit:]
|
|
67
|
+
else:
|
|
68
|
+
selected = turns
|
|
69
|
+
|
|
70
|
+
if not selected:
|
|
71
|
+
_emit(f"No meaningful content found for session {session_id}")
|
|
72
|
+
return
|
|
146
73
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
text = turn["text"]
|
|
151
|
-
# Truncate very long turns
|
|
152
|
-
if len(text) > 3000:
|
|
153
|
-
text = text[:3000] + "\n..."
|
|
154
|
-
print(f"{role_label}: {text}\n")
|
|
74
|
+
for turn in selected:
|
|
75
|
+
_emit(turn.render())
|
|
76
|
+
_emit()
|
|
155
77
|
|
|
156
78
|
finally:
|
|
157
79
|
conn.close()
|
|
158
80
|
|
|
159
81
|
|
|
160
|
-
def
|
|
161
|
-
"""Extract the last user+assistant pair from turns."""
|
|
162
|
-
if not turns:
|
|
163
|
-
return []
|
|
164
|
-
|
|
165
|
-
# Find the last user message (threshold 5 chars to support CJK)
|
|
166
|
-
last_user_idx = -1
|
|
167
|
-
for i in range(len(turns) - 1, -1, -1):
|
|
168
|
-
if turns[i]["role"] == "user" and len(turns[i]["text"]) > 5:
|
|
169
|
-
last_user_idx = i
|
|
170
|
-
break
|
|
171
|
-
|
|
172
|
-
if last_user_idx == -1:
|
|
173
|
-
return []
|
|
174
|
-
|
|
175
|
-
return turns[last_user_idx:]
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
def main():
|
|
82
|
+
def main() -> None:
|
|
179
83
|
parser = argparse.ArgumentParser(description="Parse OpenCode session transcript")
|
|
180
84
|
parser.add_argument("session_id", help="Session ID to parse")
|
|
181
|
-
parser.add_argument("--limit", type=int, default=20, help="Max
|
|
85
|
+
parser.add_argument("--limit", type=int, default=20, help="Max turns to return")
|
|
86
|
+
parser.add_argument("--turn", default=None, help="Target turn ID (prefix match)")
|
|
87
|
+
parser.add_argument("--context", type=int, default=3, help="Turns before/after target")
|
|
182
88
|
parser.add_argument(
|
|
183
|
-
"--
|
|
184
|
-
|
|
185
|
-
help="
|
|
89
|
+
"--project-dir",
|
|
90
|
+
default=os.getcwd(),
|
|
91
|
+
help="OpenCode project directory (unused for transcript reads; kept for tool compatibility)",
|
|
186
92
|
)
|
|
187
93
|
args = parser.parse_args()
|
|
188
94
|
|
|
189
|
-
|
|
95
|
+
# The capture daemon may maintain a turn sidecar for replay-safe progress,
|
|
96
|
+
# but transcript reads always rebuild from the raw OpenCode SQLite database.
|
|
97
|
+
parse_session(
|
|
98
|
+
args.session_id,
|
|
99
|
+
limit=args.limit,
|
|
100
|
+
turn_id=args.turn,
|
|
101
|
+
context=args.context,
|
|
102
|
+
)
|
|
190
103
|
|
|
191
104
|
|
|
192
105
|
if __name__ == "__main__":
|