@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.
@@ -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] [--last-turn]
5
+ parse-transcript.py <session_id> [--limit N] [--turn TURN_ID] [--context N] [--project-dir PATH]
6
6
 
7
7
  Options:
8
- --limit N Max number of messages to return (default: 20)
9
- --last-turn Only return the last user+assistant turn (for capture)
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
- # Check XDG_DATA_HOME
30
- xdg_data = os.environ.get("XDG_DATA_HOME", "")
31
- if xdg_data:
32
- alt = os.path.join(xdg_data, "opencode", "opencode.db")
33
- if os.path.exists(alt):
34
- return alt
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
- def parse_session(session_id, limit=20, last_turn=False):
40
- """Parse messages and parts for a session, formatted as [Human]/[Assistant]."""
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
- print(f"Error: OpenCode database not found at {db_path}", file=sys.stderr)
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
- # Get messages for the session, ordered by creation time
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
- print(f"No meaningful content found for session {session_id}")
53
+ _emit(f"No messages found for session {session_id}")
137
54
  return
138
55
 
139
- # If --last-turn, extract only the last user+assistant exchange
140
- if last_turn:
141
- turns = extract_last_turn(turns)
142
-
143
- # Apply limit
144
- if limit and not last_turn:
145
- turns = turns[-limit:]
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
- # Format output
148
- for turn in turns:
149
- role_label = "[Human]" if turn["role"] == "user" else "[Assistant]"
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 extract_last_turn(turns):
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 messages to return")
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
- "--last-turn",
184
- action="store_true",
185
- help="Only return the last user+assistant turn",
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
- parse_session(args.session_id, limit=args.limit, last_turn=args.last_turn)
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__":