@aa2246740/dsh-resume 0.2.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.
@@ -0,0 +1,1105 @@
1
+ #!/usr/bin/env python3
2
+ """Extend Grok's foreign-session reader with inert Grok and Pi adapters."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ import re
10
+ import sys
11
+ from pathlib import Path
12
+ from typing import Any, Iterable
13
+ from urllib.parse import quote
14
+
15
+ import session_reader as core
16
+
17
+ for _stream in (sys.stdout, sys.stderr):
18
+ try:
19
+ _stream.reconfigure(encoding="utf-8", errors="replace")
20
+ except Exception:
21
+ pass
22
+
23
+
24
+ EXTENDED_TOOLS = ("grok", "pi")
25
+ TOOLS = (*core.TOOLS, *EXTENDED_TOOLS)
26
+
27
+
28
+ def _canonical_cwd(value: str) -> str:
29
+ return str(Path(value).expanduser().resolve(strict=False))
30
+
31
+
32
+ def _absolute_cwd(value: str) -> str:
33
+ return os.path.abspath(os.path.expanduser(value))
34
+
35
+
36
+ def _same_cwd(left: Any, right: str) -> bool:
37
+ if not isinstance(left, str):
38
+ return False
39
+ return (
40
+ _absolute_cwd(left) == _absolute_cwd(right)
41
+ or _canonical_cwd(left) == _canonical_cwd(right)
42
+ )
43
+
44
+
45
+ def _read_json(path: Path) -> dict[str, Any]:
46
+ try:
47
+ value = json.loads(path.read_text(encoding="utf-8", errors="replace"))
48
+ except (OSError, json.JSONDecodeError) as exc:
49
+ raise core.ReaderError(f"failed to read session metadata {path}: {exc}") from exc
50
+ if not isinstance(value, dict):
51
+ raise core.ReaderError(f"session metadata is not a JSON object: {path}")
52
+ return value
53
+
54
+
55
+ def _read_json_optional(path: Path) -> dict[str, Any] | None:
56
+ try:
57
+ value = json.loads(path.read_text(encoding="utf-8", errors="replace"))
58
+ except (OSError, json.JSONDecodeError):
59
+ return None
60
+ return value if isinstance(value, dict) else None
61
+
62
+
63
+ def _as_iso(value: Any) -> str | None:
64
+ return core._iso_from_millis(core._timestamp_to_millis(value))
65
+
66
+
67
+ def _max_mtime(paths: Iterable[Path]) -> int:
68
+ return max((core._mtime_millis(path) for path in paths), default=0)
69
+
70
+
71
+ def _bounded_text(value: Any, limit: int) -> str:
72
+ text = core._safe_text(value)
73
+ if len(text) <= limit:
74
+ return text
75
+ return text[:limit] + "..."
76
+
77
+
78
+ def _grok_message_text(
79
+ value: Any,
80
+ warnings: list[dict[str, str]],
81
+ ) -> str:
82
+ parts: list[str] = []
83
+ for block in core._blocks(value):
84
+ block_type = block.get("type")
85
+ if block_type == "text" and isinstance(block.get("text"), str):
86
+ parts.append(core._safe_text(block["text"]))
87
+ continue
88
+ label = core._one_line(block_type or "unknown", 40).replace("_", " ")
89
+ parts.append(f"[{label} content unavailable]")
90
+ core._add_warning(
91
+ warnings,
92
+ "message_content_unavailable",
93
+ "Grok non-text message content was marked unavailable.",
94
+ )
95
+ return "\n".join(parts)
96
+
97
+
98
+ def _append_text_turn(
99
+ turns: list[dict[str, Any]],
100
+ role: str,
101
+ value: Any,
102
+ warnings: list[dict[str, str]],
103
+ ) -> None:
104
+ text = _grok_message_text(value, warnings)
105
+ if not text:
106
+ return
107
+ if (
108
+ turns
109
+ and turns[-1].get("role") == role
110
+ and not turns[-1].get("tool_calls")
111
+ and not turns[-1].get("tool_results")
112
+ ):
113
+ existing = core._safe_text(turns[-1].get("text"))
114
+ separator = (
115
+ "\n"
116
+ if existing.endswith(" content unavailable]")
117
+ or text.startswith("[") and " content unavailable]" in text.splitlines()[0]
118
+ else ""
119
+ )
120
+ turns[-1]["text"] = existing + separator + text
121
+ return
122
+ turns.append(core._turn(role, text=text))
123
+
124
+
125
+ class ProviderAdapter:
126
+ tool: str
127
+
128
+ def discover(self, cwd: str, within_min: int) -> list[dict[str, Any]]:
129
+ raise NotImplementedError
130
+
131
+ def candidate_from_path(self, raw_path: str, cwd: str) -> dict[str, Any] | None:
132
+ raise NotImplementedError
133
+
134
+ def find_id(self, session_id: str, cwd: str) -> dict[str, Any] | None:
135
+ raise NotImplementedError
136
+
137
+ def read(self, candidate: dict[str, Any], max_tool_chars: int) -> dict[str, Any]:
138
+ raise NotImplementedError
139
+
140
+
141
+ class GrokAdapter(ProviderAdapter):
142
+ tool = "grok"
143
+
144
+ @staticmethod
145
+ def _home() -> Path:
146
+ configured = os.environ.get("GROK_HOME")
147
+ return Path(configured).expanduser() if configured else Path.home() / ".grok"
148
+
149
+ @classmethod
150
+ def _root(cls) -> Path:
151
+ return cls._home() / "sessions"
152
+
153
+ @staticmethod
154
+ def _workspace_names(cwd: str) -> list[str]:
155
+ raw = str(Path(cwd).expanduser())
156
+ absolute = _absolute_cwd(cwd)
157
+ canonical = _canonical_cwd(cwd)
158
+ return list(
159
+ dict.fromkeys(quote(value, safe="") for value in (raw, absolute, canonical))
160
+ )
161
+
162
+ @classmethod
163
+ def _session_dirs_for_cwd(cls, cwd: str) -> Iterable[Path]:
164
+ root = cls._root()
165
+ seen: set[Path] = set()
166
+ for name in cls._workspace_names(cwd):
167
+ workspace = root / name
168
+ if not workspace.is_dir() or workspace.is_symlink():
169
+ continue
170
+ for session_dir in sorted(workspace.iterdir(), key=lambda path: path.name):
171
+ if session_dir.is_dir() and not session_dir.is_symlink() and session_dir not in seen:
172
+ seen.add(session_dir)
173
+ yield session_dir
174
+
175
+ # Older or alternate Grok builds may encode a non-canonical spelling.
176
+ # Metadata remains the authority and keeps fallback discovery cwd-scoped.
177
+ if seen or not root.is_dir() or root.is_symlink():
178
+ return
179
+ for workspace in sorted(root.iterdir(), key=lambda path: path.name):
180
+ if not workspace.is_dir() or workspace.is_symlink():
181
+ continue
182
+ for session_dir in sorted(workspace.iterdir(), key=lambda path: path.name):
183
+ if not session_dir.is_dir() or session_dir.is_symlink():
184
+ continue
185
+ candidate = cls._candidate_from_dir(session_dir, cwd, require_cwd=True)
186
+ if candidate is not None:
187
+ yield session_dir
188
+
189
+ @classmethod
190
+ def _all_session_dirs(cls) -> Iterable[Path]:
191
+ root = cls._root()
192
+ if not root.is_dir() or root.is_symlink():
193
+ return
194
+ for workspace in sorted(root.iterdir(), key=lambda path: path.name):
195
+ if not workspace.is_dir() or workspace.is_symlink():
196
+ continue
197
+ for session_dir in sorted(workspace.iterdir(), key=lambda path: path.name):
198
+ if session_dir.is_dir() and not session_dir.is_symlink():
199
+ yield session_dir
200
+
201
+ @classmethod
202
+ def _candidate_from_dir(
203
+ cls,
204
+ session_dir: Path,
205
+ cwd: str,
206
+ *,
207
+ require_cwd: bool,
208
+ ) -> dict[str, Any] | None:
209
+ summary_path = session_dir / "summary.json"
210
+ updates_path = session_dir / "updates.jsonl"
211
+ if (
212
+ not summary_path.is_file()
213
+ or summary_path.is_symlink()
214
+ or not updates_path.is_file()
215
+ or updates_path.is_symlink()
216
+ ):
217
+ return None
218
+ summary = _read_json_optional(summary_path)
219
+ if summary is None:
220
+ return None
221
+ info = summary.get("info") if isinstance(summary.get("info"), dict) else {}
222
+ actual_cwd = info.get("cwd") if isinstance(info.get("cwd"), str) else None
223
+ if require_cwd and not _same_cwd(actual_cwd, cwd):
224
+ return None
225
+ session_id = info.get("id") if isinstance(info.get("id"), str) else session_dir.name
226
+ updated_ms = (
227
+ core._timestamp_to_millis(summary.get("last_active_at"))
228
+ or core._timestamp_to_millis(summary.get("updated_at"))
229
+ or _max_mtime((updates_path, summary_path, session_dir))
230
+ )
231
+ created_ms = core._timestamp_to_millis(summary.get("created_at"))
232
+ title = summary.get("generated_title") or summary.get("session_summary")
233
+ return {
234
+ "tool": "grok",
235
+ "source": "grok",
236
+ "session_id": core._safe_text(session_id),
237
+ "path": str(session_dir),
238
+ "title": core._safe_text(title) if title else None,
239
+ "cwd": core._safe_text(actual_cwd) if actual_cwd else None,
240
+ "branch": (
241
+ core._safe_text(summary.get("head_branch"))
242
+ if isinstance(summary.get("head_branch"), str)
243
+ else None
244
+ ),
245
+ "created_at": core._iso_from_millis(created_ms),
246
+ "updated_at_ms": updated_ms,
247
+ "updated_at": core._iso_from_millis(updated_ms),
248
+ "source_repo_root_path": (
249
+ core._safe_text(summary.get("git_root_dir"))
250
+ if isinstance(summary.get("git_root_dir"), str)
251
+ else None
252
+ ),
253
+ "model": (
254
+ core._safe_text(summary.get("current_model_id"))
255
+ if isinstance(summary.get("current_model_id"), str)
256
+ else None
257
+ ),
258
+ "head_commit": (
259
+ core._safe_text(summary.get("head_commit"))
260
+ if isinstance(summary.get("head_commit"), str)
261
+ else None
262
+ ),
263
+ "chat_format_version": summary.get("chat_format_version"),
264
+ }
265
+
266
+ def discover(self, cwd: str, within_min: int) -> list[dict[str, Any]]:
267
+ sessions: list[dict[str, Any]] = []
268
+ for session_dir in self._session_dirs_for_cwd(cwd):
269
+ candidate = self._candidate_from_dir(session_dir, cwd, require_cwd=True)
270
+ if candidate is None:
271
+ continue
272
+ if core._within(int(candidate.get("updated_at_ms") or 0), within_min):
273
+ sessions.append(candidate)
274
+ return core._sort_and_dedupe(sessions)
275
+
276
+ def candidate_from_path(self, raw_path: str, cwd: str) -> dict[str, Any] | None:
277
+ path = Path(raw_path).expanduser()
278
+ if not path.exists() or path.is_symlink():
279
+ return None
280
+ session_dir = path.parent if path.is_file() and path.name in {"summary.json", "updates.jsonl"} else path
281
+ if not session_dir.is_dir() or session_dir.is_symlink():
282
+ return None
283
+ return self._candidate_from_dir(session_dir, cwd, require_cwd=False)
284
+
285
+ def find_id(self, session_id: str, cwd: str) -> dict[str, Any] | None:
286
+ for session_dir in self._session_dirs_for_cwd(cwd):
287
+ if session_dir.name.casefold() == session_id.casefold():
288
+ return self._candidate_from_dir(session_dir, cwd, require_cwd=False)
289
+ for session_dir in self._all_session_dirs():
290
+ if session_dir.name.casefold() != session_id.casefold():
291
+ continue
292
+ return self._candidate_from_dir(session_dir, cwd, require_cwd=False)
293
+ return None
294
+
295
+ @staticmethod
296
+ def _tool_details(update: dict[str, Any]) -> tuple[str, Any]:
297
+ metadata = update.get("_meta") if isinstance(update.get("_meta"), dict) else {}
298
+ tool_metadata = (
299
+ metadata.get("x.ai/tool")
300
+ if isinstance(metadata.get("x.ai/tool"), dict)
301
+ else {}
302
+ )
303
+ name = (
304
+ tool_metadata.get("name")
305
+ or tool_metadata.get("label")
306
+ or update.get("title")
307
+ or update.get("kind")
308
+ or "grok_tool"
309
+ )
310
+ raw_input = update.get("rawInput")
311
+ if raw_input is None:
312
+ raw_input = tool_metadata.get("input", {})
313
+ return core._safe_text(name), raw_input
314
+
315
+ @staticmethod
316
+ def _tool_output(
317
+ update: dict[str, Any],
318
+ max_tool_chars: int,
319
+ warnings: list[dict[str, str]],
320
+ ) -> str:
321
+ parts: list[str] = []
322
+ content = update.get("content")
323
+ blocks = content if isinstance(content, list) else [content]
324
+ for block in blocks:
325
+ if isinstance(block, str):
326
+ parts.append(block)
327
+ continue
328
+ if not isinstance(block, dict):
329
+ continue
330
+ block_type = block.get("type")
331
+ if block_type == "content":
332
+ nested = block.get("content")
333
+ if isinstance(nested, dict) and nested.get("type") == "text":
334
+ text = nested.get("text")
335
+ if isinstance(text, str):
336
+ parts.append(text)
337
+ elif isinstance(nested, str):
338
+ parts.append(nested)
339
+ elif block_type == "text" and isinstance(block.get("text"), str):
340
+ parts.append(block["text"])
341
+ elif block_type == "diff":
342
+ path = core._one_line(block.get("path") or "unknown path", 160)
343
+ parts.append(f"[diff body unavailable for {path}]")
344
+ core._add_warning(
345
+ warnings,
346
+ "diff_content_unavailable",
347
+ "Grok diff bodies were excluded; only their paths were retained.",
348
+ )
349
+ output: Any = "\n".join(parts)
350
+ if not output:
351
+ output = update.get("rawOutput")
352
+ return core._json_preview(output, max_tool_chars)
353
+
354
+ def read(self, candidate: dict[str, Any], max_tool_chars: int) -> dict[str, Any]:
355
+ session_dir = Path(str(candidate["path"]))
356
+ summary = _read_json(session_dir / "summary.json")
357
+ records, malformed = core._read_plain_jsonl(session_dir / "updates.jsonl")
358
+ warnings: list[dict[str, str]] = []
359
+ turns: list[dict[str, Any]] = []
360
+ plan: list[dict[str, Any]] = []
361
+ emitted_calls: set[str] = set()
362
+ emitted_results: set[str] = set()
363
+ hidden_thoughts = 0
364
+ hook_records = 0
365
+ unknown_records = 0
366
+
367
+ for index, record in enumerate(records):
368
+ params = record.get("params") if isinstance(record.get("params"), dict) else {}
369
+ update = params.get("update") if isinstance(params.get("update"), dict) else None
370
+ # Accept the inner update directly in fixtures and exported streams.
371
+ if update is None and isinstance(record.get("sessionUpdate"), str):
372
+ update = record
373
+ if update is None:
374
+ unknown_records += 1
375
+ continue
376
+ update_type = update.get("sessionUpdate") or update.get("type")
377
+ if update_type == "user_message_chunk":
378
+ _append_text_turn(turns, "user", update.get("content"), warnings)
379
+ elif update_type == "agent_message_chunk":
380
+ _append_text_turn(turns, "assistant", update.get("content"), warnings)
381
+ elif update_type == "agent_thought_chunk":
382
+ hidden_thoughts += 1
383
+ elif update_type == "hook_execution":
384
+ hook_records += 1
385
+ elif update_type in {"tool_call", "tool_call_update"}:
386
+ call_id_value = update.get("toolCallId")
387
+ call_id = (
388
+ core._safe_text(call_id_value)
389
+ if call_id_value is not None
390
+ else f"grok-call-{index}"
391
+ )
392
+ status = update.get("status")
393
+ if status in {"completed", "failed"}:
394
+ if call_id in emitted_results:
395
+ continue
396
+ emitted_results.add(call_id)
397
+ turns.append(
398
+ core._turn(
399
+ "tool",
400
+ tool_results=[
401
+ {
402
+ "tool_use_id": call_id,
403
+ "content": self._tool_output(update, max_tool_chars, warnings),
404
+ "is_error": status == "failed",
405
+ "unavailable": False,
406
+ "inert": True,
407
+ }
408
+ ],
409
+ )
410
+ )
411
+ elif status in {None, "pending"} and call_id not in emitted_calls:
412
+ name, raw_input = self._tool_details(update)
413
+ emitted_calls.add(call_id)
414
+ turns.append(
415
+ core._turn(
416
+ "assistant",
417
+ tool_calls=[
418
+ {
419
+ "id": call_id,
420
+ "name": name,
421
+ "input": core._json_preview(raw_input, max_tool_chars),
422
+ "inert": True,
423
+ }
424
+ ],
425
+ )
426
+ )
427
+ elif update_type == "plan":
428
+ entries = update.get("entries")
429
+ if isinstance(entries, list):
430
+ plan = [
431
+ {
432
+ "content": core._safe_text(entry.get("content")),
433
+ "status": core._safe_text(entry.get("status") or "pending"),
434
+ "inert": True,
435
+ }
436
+ for entry in entries
437
+ if isinstance(entry, dict) and entry.get("content")
438
+ ]
439
+ elif update_type == "turn_completed":
440
+ continue
441
+ else:
442
+ unknown_records += 1
443
+
444
+ if malformed:
445
+ core._add_warning(
446
+ warnings,
447
+ "malformed_records_skipped",
448
+ f"Skipped {malformed} malformed Grok update record(s).",
449
+ )
450
+ if hidden_thoughts:
451
+ core._add_warning(
452
+ warnings,
453
+ "hidden_reasoning_skipped",
454
+ f"Excluded {hidden_thoughts} Grok thought update(s).",
455
+ )
456
+ if hook_records:
457
+ core._add_warning(
458
+ warnings,
459
+ "hook_records_skipped",
460
+ f"Excluded {hook_records} Grok hook execution record(s).",
461
+ )
462
+ if hidden_thoughts or hook_records:
463
+ core._add_warning(
464
+ warnings,
465
+ "unsafe_records_skipped",
466
+ "Grok hidden-reasoning or hook records were excluded from inert history.",
467
+ )
468
+ if unknown_records:
469
+ core._add_warning(
470
+ warnings,
471
+ "unknown_records_skipped",
472
+ f"Skipped {unknown_records} unsupported Grok update record(s).",
473
+ )
474
+
475
+ created = _as_iso(summary.get("created_at")) or candidate.get("created_at")
476
+ updated = (
477
+ _as_iso(summary.get("last_active_at"))
478
+ or _as_iso(summary.get("updated_at"))
479
+ or candidate.get("updated_at")
480
+ )
481
+ result = {
482
+ "tool": "grok",
483
+ "source": "grok",
484
+ "session_id": candidate.get("session_id"),
485
+ "path": str(session_dir),
486
+ "title": candidate.get("title"),
487
+ "cwd": candidate.get("cwd"),
488
+ "branch": candidate.get("branch"),
489
+ "created_at": created,
490
+ "updated_at": updated,
491
+ "source_repo_root_path": candidate.get("source_repo_root_path"),
492
+ "model": candidate.get("model"),
493
+ "head_commit": candidate.get("head_commit"),
494
+ "chat_format_version": candidate.get("chat_format_version"),
495
+ "turns": turns,
496
+ "plan": plan,
497
+ "warnings": warnings,
498
+ }
499
+ return core._finalize_result(result)
500
+
501
+
502
+ class PiAdapter(ProviderAdapter):
503
+ tool = "pi"
504
+
505
+ @staticmethod
506
+ def _agent_dir() -> Path:
507
+ configured = os.environ.get("PI_CODING_AGENT_DIR")
508
+ return Path(configured).expanduser() if configured else Path.home() / ".pi" / "agent"
509
+
510
+ @staticmethod
511
+ def _configured_path(value: str, cwd: str) -> Path:
512
+ path = Path(value).expanduser()
513
+ return path if path.is_absolute() else Path(cwd).expanduser() / path
514
+
515
+ @classmethod
516
+ def _settings_session_dir(cls, cwd: str) -> Path | None:
517
+ configured: str | None = None
518
+ for path in (cls._agent_dir() / "settings.json", Path(cwd).expanduser() / ".pi" / "settings.json"):
519
+ settings = _read_json_optional(path)
520
+ if settings is not None and isinstance(settings.get("sessionDir"), str):
521
+ value = settings["sessionDir"].strip()
522
+ if value:
523
+ configured = value
524
+ return cls._configured_path(configured, cwd) if configured else None
525
+
526
+ @classmethod
527
+ def _storage(cls, cwd: str) -> tuple[Path, bool]:
528
+ environment = os.environ.get("PI_CODING_AGENT_SESSION_DIR")
529
+ if environment:
530
+ return cls._configured_path(environment, cwd), True
531
+ settings = cls._settings_session_dir(cwd)
532
+ if settings is not None:
533
+ return settings, True
534
+ # Pi's getDefaultSessionDirPath uses Node path.resolve, which makes the
535
+ # path absolute but does not dereference symlinks.
536
+ resolved = _absolute_cwd(cwd)
537
+ safe_path = re.sub(r"[/\\:]", "-", re.sub(r"^[/\\]", "", resolved))
538
+ return cls._agent_dir() / "sessions" / f"--{safe_path}--", False
539
+
540
+ @staticmethod
541
+ def _files_in_root(root: Path, include_nested: bool) -> Iterable[Path]:
542
+ if not root.is_dir() or root.is_symlink():
543
+ return
544
+ seen: set[Path] = set()
545
+ for path in sorted(root.glob("*.jsonl"), key=lambda item: item.name):
546
+ if path.is_file() and not path.is_symlink():
547
+ seen.add(path)
548
+ yield path
549
+ if include_nested:
550
+ for path in sorted(root.glob("*/*.jsonl"), key=str):
551
+ if path.is_file() and not path.is_symlink() and path not in seen:
552
+ yield path
553
+
554
+ @staticmethod
555
+ def _header(records: list[dict[str, Any]]) -> dict[str, Any] | None:
556
+ return next((record for record in records if record.get("type") == "session"), None)
557
+
558
+ @staticmethod
559
+ def _visible_text(content: Any) -> str:
560
+ if isinstance(content, str):
561
+ return core._safe_text(content)
562
+ if not isinstance(content, list):
563
+ return ""
564
+ return "\n".join(
565
+ core._safe_text(block.get("text"))
566
+ for block in content
567
+ if isinstance(block, dict)
568
+ and block.get("type") == "text"
569
+ and isinstance(block.get("text"), str)
570
+ )
571
+
572
+ @classmethod
573
+ def _candidate_from_file(
574
+ cls,
575
+ path: Path,
576
+ cwd: str,
577
+ *,
578
+ require_cwd: bool,
579
+ ) -> dict[str, Any] | None:
580
+ if not path.is_file() or path.is_symlink() or path.suffix != ".jsonl":
581
+ return None
582
+ try:
583
+ records, _ = core._read_plain_jsonl(path)
584
+ except core.ReaderError:
585
+ return None
586
+ header = cls._header(records)
587
+ if header is None:
588
+ return None
589
+ actual_cwd = header.get("cwd") if isinstance(header.get("cwd"), str) else None
590
+ if require_cwd and not _same_cwd(actual_cwd, cwd):
591
+ return None
592
+ session_id = header.get("id") if isinstance(header.get("id"), str) else path.stem
593
+ title: str | None = None
594
+ model: str | None = None
595
+ version_value = header.get("version")
596
+ version = version_value if isinstance(version_value, int) else 1
597
+ entries = [record for record in records if record is not header and record.get("type") != "session"]
598
+ metadata_records = cls._active_branch(entries, version, [])
599
+ for record in metadata_records:
600
+ if record.get("type") == "session_info" and isinstance(record.get("name"), str):
601
+ title = record["name"]
602
+ if record.get("type") == "model_change" and isinstance(record.get("modelId"), str):
603
+ model = record["modelId"]
604
+ message = record.get("message") if isinstance(record.get("message"), dict) else None
605
+ if message is not None:
606
+ if message.get("role") == "user" and title is None:
607
+ text = cls._visible_text(message.get("content"))
608
+ if text:
609
+ title = core._one_line(text, 120)
610
+ if message.get("role") == "assistant" and isinstance(message.get("model"), str):
611
+ model = message["model"]
612
+ timestamps = [
613
+ core._timestamp_to_millis(record.get("timestamp"))
614
+ for record in records
615
+ ]
616
+ valid_timestamps = [value for value in timestamps if value is not None]
617
+ updated_ms = valid_timestamps[-1] if valid_timestamps else core._mtime_millis(path)
618
+ return {
619
+ "tool": "pi",
620
+ "source": "pi-coding-agent",
621
+ "session_id": core._safe_text(session_id),
622
+ "path": str(path),
623
+ "title": title,
624
+ "cwd": core._safe_text(actual_cwd) if actual_cwd else None,
625
+ "branch": None,
626
+ "created_at": _as_iso(header.get("timestamp")),
627
+ "updated_at_ms": updated_ms,
628
+ "updated_at": core._iso_from_millis(updated_ms),
629
+ "source_repo_root_path": None,
630
+ "model": model,
631
+ "session_version": header.get("version"),
632
+ "parent_session": (
633
+ header.get("parentSession")
634
+ if isinstance(header.get("parentSession"), str)
635
+ else None
636
+ ),
637
+ }
638
+
639
+ def discover(self, cwd: str, within_min: int) -> list[dict[str, Any]]:
640
+ root, custom = self._storage(cwd)
641
+ sessions: list[dict[str, Any]] = []
642
+ for path in self._files_in_root(root, include_nested=custom):
643
+ candidate = self._candidate_from_file(path, cwd, require_cwd=True)
644
+ if candidate is None:
645
+ continue
646
+ if core._within(int(candidate.get("updated_at_ms") or 0), within_min):
647
+ sessions.append(candidate)
648
+ return core._sort_and_dedupe(sessions)
649
+
650
+ def candidate_from_path(self, raw_path: str, cwd: str) -> dict[str, Any] | None:
651
+ path = Path(raw_path).expanduser()
652
+ if not path.exists() or path.is_symlink():
653
+ return None
654
+ return self._candidate_from_file(path, cwd, require_cwd=False)
655
+
656
+ def find_id(self, session_id: str, cwd: str) -> dict[str, Any] | None:
657
+ root, custom = self._storage(cwd)
658
+ for path in self._files_in_root(root, include_nested=custom):
659
+ candidate = self._candidate_from_file(path, cwd, require_cwd=False)
660
+ if candidate and str(candidate.get("session_id", "")).casefold() == session_id.casefold():
661
+ return candidate
662
+ if custom:
663
+ return None
664
+ all_root = self._agent_dir() / "sessions"
665
+ for path in self._files_in_root(all_root, include_nested=True):
666
+ candidate = self._candidate_from_file(path, cwd, require_cwd=False)
667
+ if candidate and str(candidate.get("session_id", "")).casefold() == session_id.casefold():
668
+ return candidate
669
+ return None
670
+
671
+ @staticmethod
672
+ def _active_branch(
673
+ entries: list[dict[str, Any]],
674
+ version: int,
675
+ warnings: list[dict[str, str]],
676
+ ) -> list[dict[str, Any]]:
677
+ if version < 2:
678
+ core._add_warning(
679
+ warnings,
680
+ "legacy_session_version",
681
+ f"Pi session version {version} has no reliable branch tree; records were read linearly.",
682
+ )
683
+ return entries
684
+ if not entries:
685
+ return []
686
+ leaf = entries[-1]
687
+ leaf_id = leaf.get("id")
688
+ if not isinstance(leaf_id, str):
689
+ core._add_warning(
690
+ warnings,
691
+ "broken_session_tree",
692
+ "The active Pi leaf had no id; records were read linearly.",
693
+ )
694
+ return entries
695
+ by_id = {
696
+ entry["id"]: entry
697
+ for entry in entries
698
+ if isinstance(entry.get("id"), str)
699
+ }
700
+ branch: list[dict[str, Any]] = []
701
+ seen: set[str] = set()
702
+ current_id: str | None = leaf_id
703
+ while current_id is not None:
704
+ if current_id in seen:
705
+ core._add_warning(
706
+ warnings,
707
+ "broken_session_tree",
708
+ "The Pi session tree contains a cycle; the recovered branch was truncated.",
709
+ )
710
+ break
711
+ seen.add(current_id)
712
+ entry = by_id.get(current_id)
713
+ if entry is None:
714
+ core._add_warning(
715
+ warnings,
716
+ "broken_session_tree",
717
+ f"The Pi session tree is missing parent entry {current_id!r}.",
718
+ )
719
+ break
720
+ branch.append(entry)
721
+ parent = entry.get("parentId")
722
+ current_id = parent if isinstance(parent, str) and parent else None
723
+ branch.reverse()
724
+ inactive = len(entries) - len(branch)
725
+ if inactive > 0:
726
+ core._add_warning(
727
+ warnings,
728
+ "inactive_branch_entries_skipped",
729
+ f"Excluded {inactive} Pi record(s) outside the active branch.",
730
+ )
731
+ return branch
732
+
733
+ @staticmethod
734
+ def _message_blocks(
735
+ content: Any,
736
+ warnings: list[dict[str, str]],
737
+ ) -> tuple[str, list[dict[str, Any]], int, int]:
738
+ if isinstance(content, str):
739
+ return core._safe_text(content), [], 0, 0
740
+ if not isinstance(content, list):
741
+ return "", [], 0, 1 if content is not None else 0
742
+ text_parts: list[str] = []
743
+ calls: list[dict[str, Any]] = []
744
+ hidden = 0
745
+ unknown = 0
746
+ for block in content:
747
+ if not isinstance(block, dict):
748
+ unknown += 1
749
+ continue
750
+ block_type = block.get("type")
751
+ if block_type == "text" and isinstance(block.get("text"), str):
752
+ text_parts.append(core._safe_text(block["text"]))
753
+ elif block_type == "toolCall":
754
+ calls.append(block)
755
+ elif block_type == "thinking":
756
+ hidden += 1
757
+ elif block_type in {"image", "image_url"}:
758
+ core._add_warning(
759
+ warnings,
760
+ "image_content_unavailable",
761
+ "Pi image content is not imported into the text handoff.",
762
+ )
763
+ else:
764
+ unknown += 1
765
+ return "\n".join(text_parts), calls, hidden, unknown
766
+
767
+ @staticmethod
768
+ def _tool_result_text(
769
+ content: Any,
770
+ max_tool_chars: int,
771
+ warnings: list[dict[str, str]],
772
+ ) -> str:
773
+ if isinstance(content, str):
774
+ return _bounded_text(content, max_tool_chars)
775
+ parts: list[str] = []
776
+ if isinstance(content, list):
777
+ for block in content:
778
+ if not isinstance(block, dict):
779
+ continue
780
+ if block.get("type") == "text" and isinstance(block.get("text"), str):
781
+ parts.append(block["text"])
782
+ elif block.get("type") in {"image", "image_url"}:
783
+ parts.append("[image content unavailable]")
784
+ core._add_warning(
785
+ warnings,
786
+ "image_content_unavailable",
787
+ "Pi image content is not imported into the text handoff.",
788
+ )
789
+ return _bounded_text("\n".join(parts), max_tool_chars)
790
+
791
+ def read(self, candidate: dict[str, Any], max_tool_chars: int) -> dict[str, Any]:
792
+ path = Path(str(candidate["path"]))
793
+ records, malformed = core._read_plain_jsonl(path)
794
+ header = self._header(records)
795
+ if header is None:
796
+ raise core.ReaderError(f"Pi session header is missing: {path}")
797
+ version_value = header.get("version")
798
+ version = version_value if isinstance(version_value, int) else 1
799
+ warnings: list[dict[str, str]] = []
800
+ if version > 3:
801
+ core._add_warning(
802
+ warnings,
803
+ "future_session_version",
804
+ f"Pi session version {version} is newer than the tested version 3; parsed best-effort.",
805
+ )
806
+ entries = [record for record in records if record is not header and record.get("type") != "session"]
807
+ active_entries = self._active_branch(entries, version, warnings)
808
+ turns: list[dict[str, Any]] = []
809
+ summaries: list[dict[str, Any]] = []
810
+ hidden_blocks = 0
811
+ extension_records = 0
812
+ unknown_records = 0
813
+ model = candidate.get("model")
814
+ title = candidate.get("title")
815
+
816
+ for entry in active_entries:
817
+ entry_type = entry.get("type")
818
+ if entry_type == "message":
819
+ message = entry.get("message")
820
+ if not isinstance(message, dict):
821
+ unknown_records += 1
822
+ continue
823
+ role = message.get("role")
824
+ if role in {"user", "assistant"}:
825
+ text, raw_calls, hidden, unknown = self._message_blocks(
826
+ message.get("content"), warnings
827
+ )
828
+ hidden_blocks += hidden
829
+ unknown_records += unknown
830
+ calls = [
831
+ {
832
+ "id": core._safe_text(call.get("id") or "pi-tool-call"),
833
+ "name": core._safe_text(call.get("name") or "pi_tool"),
834
+ "input": core._json_preview(call.get("arguments", {}), max_tool_chars),
835
+ "inert": True,
836
+ }
837
+ for call in raw_calls
838
+ ]
839
+ if text or calls:
840
+ turns.append(core._turn(role, text=text, tool_calls=calls))
841
+ if role == "assistant" and isinstance(message.get("model"), str):
842
+ model = message["model"]
843
+ elif role == "toolResult":
844
+ turns.append(
845
+ core._turn(
846
+ "tool",
847
+ tool_results=[
848
+ {
849
+ "tool_use_id": message.get("toolCallId"),
850
+ "content": self._tool_result_text(
851
+ message.get("content"), max_tool_chars, warnings
852
+ ),
853
+ "is_error": message.get("isError") is True,
854
+ "unavailable": False,
855
+ "inert": True,
856
+ }
857
+ ],
858
+ )
859
+ )
860
+ elif role in {"hookMessage", "system", "developer"}:
861
+ extension_records += 1
862
+ else:
863
+ unknown_records += 1
864
+ elif entry_type in {"compaction", "branch_summary"}:
865
+ summary = entry.get("summary")
866
+ if isinstance(summary, str) and summary:
867
+ summaries.append(
868
+ {
869
+ "kind": entry_type,
870
+ "content": core._safe_text(summary),
871
+ "inert": True,
872
+ }
873
+ )
874
+ elif entry_type in {"custom", "custom_message"}:
875
+ extension_records += 1
876
+ elif entry_type == "session_info":
877
+ if isinstance(entry.get("name"), str):
878
+ title = entry["name"]
879
+ elif entry_type == "model_change":
880
+ if isinstance(entry.get("modelId"), str):
881
+ model = entry["modelId"]
882
+ elif entry_type in {"thinking_level_change", "label"}:
883
+ continue
884
+ else:
885
+ unknown_records += 1
886
+
887
+ if malformed:
888
+ core._add_warning(
889
+ warnings,
890
+ "malformed_records_skipped",
891
+ f"Skipped {malformed} malformed Pi session record(s).",
892
+ )
893
+ if hidden_blocks:
894
+ core._add_warning(
895
+ warnings,
896
+ "hidden_reasoning_skipped",
897
+ f"Excluded {hidden_blocks} Pi thinking block(s).",
898
+ )
899
+ if extension_records:
900
+ core._add_warning(
901
+ warnings,
902
+ "extension_records_skipped",
903
+ f"Excluded {extension_records} Pi hook, system, or extension-injected record(s).",
904
+ )
905
+ if hidden_blocks or extension_records:
906
+ core._add_warning(
907
+ warnings,
908
+ "unsafe_records_skipped",
909
+ "Pi hidden-reasoning or extension-injected records were excluded from inert history.",
910
+ )
911
+ if unknown_records:
912
+ core._add_warning(
913
+ warnings,
914
+ "unknown_records_skipped",
915
+ f"Skipped {unknown_records} unsupported Pi record or content block(s).",
916
+ )
917
+
918
+ timestamps = [
919
+ core._timestamp_to_millis(entry.get("timestamp"))
920
+ for entry in active_entries
921
+ ]
922
+ valid_timestamps = [value for value in timestamps if value is not None]
923
+ result = {
924
+ "tool": "pi",
925
+ "source": "pi-coding-agent",
926
+ "session_id": core._safe_text(header.get("id") or candidate.get("session_id")),
927
+ "path": str(path),
928
+ "title": core._safe_text(title) if title else None,
929
+ "cwd": (
930
+ core._safe_text(header.get("cwd"))
931
+ if isinstance(header.get("cwd"), str)
932
+ else candidate.get("cwd")
933
+ ),
934
+ "branch": None,
935
+ "created_at": _as_iso(header.get("timestamp")) or candidate.get("created_at"),
936
+ "updated_at": (
937
+ core._iso_from_millis(valid_timestamps[-1])
938
+ if valid_timestamps
939
+ else candidate.get("updated_at")
940
+ ),
941
+ "source_repo_root_path": None,
942
+ "model": core._safe_text(model) if model else None,
943
+ "session_version": version,
944
+ "parent_session": (
945
+ core._safe_text(header.get("parentSession"))
946
+ if isinstance(header.get("parentSession"), str)
947
+ else None
948
+ ),
949
+ "active_leaf_id": (
950
+ active_entries[-1].get("id") if active_entries else None
951
+ ),
952
+ "turns": turns,
953
+ "summaries": summaries,
954
+ "warnings": warnings,
955
+ }
956
+ return core._finalize_result(result)
957
+
958
+
959
+ ADAPTERS: dict[str, ProviderAdapter] = {
960
+ "grok": GrokAdapter(),
961
+ "pi": PiAdapter(),
962
+ }
963
+
964
+
965
+ def discover_sessions(tool: str, cwd: str, within_min: int = 0) -> list[dict[str, Any]]:
966
+ adapter = ADAPTERS.get(tool)
967
+ if adapter is None:
968
+ raise core.ReaderError(f"unsupported extended tool: {tool}")
969
+ return adapter.discover(cwd, within_min)
970
+
971
+
972
+ def resolve_session(
973
+ tool: str,
974
+ reference: str | None,
975
+ cwd: str,
976
+ within_min: int = 0,
977
+ ) -> dict[str, Any]:
978
+ adapter = ADAPTERS.get(tool)
979
+ if adapter is None:
980
+ raise core.ReaderError(f"unsupported extended tool: {tool}")
981
+ ref = (reference or "").strip()
982
+ if not ref or ref.casefold() == "latest":
983
+ ref = "latest"
984
+ path_candidate = adapter.candidate_from_path(ref, cwd)
985
+ if path_candidate is not None:
986
+ return path_candidate
987
+ sessions = adapter.discover(cwd, within_min)
988
+ if ref == "latest":
989
+ if not sessions:
990
+ raise core.ReaderError(f"no {tool} session found for cwd {cwd}")
991
+ return sessions[0]
992
+ exact = [
993
+ item
994
+ for item in sessions
995
+ if str(item.get("session_id", "")).casefold() == ref.casefold()
996
+ ]
997
+ if len(exact) == 1:
998
+ return exact[0]
999
+ found = adapter.find_id(ref, cwd)
1000
+ if found is not None:
1001
+ return found
1002
+ query = " ".join(ref.casefold().split())
1003
+ matches = [
1004
+ item
1005
+ for item in sessions
1006
+ if query in " ".join(str(item.get("title") or "").casefold().split())
1007
+ ]
1008
+ if len(matches) == 1:
1009
+ return matches[0]
1010
+ if len(matches) > 1:
1011
+ raise core.AmbiguousReference(ref, matches)
1012
+ raise core.ReaderError(f"no {tool} session matched {ref!r} for cwd {cwd}")
1013
+
1014
+
1015
+ def _build_parser() -> argparse.ArgumentParser:
1016
+ parser = argparse.ArgumentParser(
1017
+ description="Read foreign coding-agent sessions as inert history."
1018
+ )
1019
+ parser.add_argument("tool", choices=TOOLS)
1020
+ parser.add_argument("action", choices=("list", "show"))
1021
+ parser.add_argument("ref", nargs="?")
1022
+ parser.add_argument("--cwd", default=os.getcwd())
1023
+ parser.add_argument("--within-min", type=int, default=0)
1024
+ parser.add_argument("--json", action="store_true")
1025
+ parser.add_argument("--max-tool-chars", type=int, default=300)
1026
+ return parser
1027
+
1028
+
1029
+ def _core_argv(args: argparse.Namespace) -> list[str]:
1030
+ """Grok's parser only accepts the optional ref before flags."""
1031
+ forwarded = [args.tool, args.action]
1032
+ if args.ref is not None:
1033
+ forwarded.append(args.ref)
1034
+ forwarded.extend(
1035
+ [
1036
+ "--cwd",
1037
+ args.cwd,
1038
+ "--within-min",
1039
+ str(args.within_min),
1040
+ "--max-tool-chars",
1041
+ str(args.max_tool_chars),
1042
+ ]
1043
+ )
1044
+ if args.json:
1045
+ forwarded.append("--json")
1046
+ return forwarded
1047
+
1048
+
1049
+ def main(argv: list[str] | None = None) -> int:
1050
+ arguments = list(sys.argv[1:] if argv is None else argv)
1051
+ parser = _build_parser()
1052
+ # The DSH tool passes `--json -- <ref>`. Grok's parse_args rejects that order.
1053
+ args = parser.parse_intermixed_args(arguments)
1054
+ if args.tool in core.TOOLS:
1055
+ return core.main(_core_argv(args))
1056
+ if args.within_min < 0:
1057
+ parser.error("--within-min must be non-negative")
1058
+ if args.max_tool_chars < 1:
1059
+ parser.error("--max-tool-chars must be positive")
1060
+ try:
1061
+ adapter = ADAPTERS[args.tool]
1062
+ if args.action == "list":
1063
+ if args.ref is not None:
1064
+ raise core.ReaderError("list does not accept a session reference")
1065
+ sessions = adapter.discover(args.cwd, args.within_min)
1066
+ if args.json:
1067
+ print(
1068
+ json.dumps(
1069
+ {
1070
+ "tool": args.tool,
1071
+ "cwd": args.cwd,
1072
+ "sessions": sessions,
1073
+ "warnings": [],
1074
+ },
1075
+ indent=2,
1076
+ ensure_ascii=True,
1077
+ )
1078
+ )
1079
+ else:
1080
+ print(core._render_list_human(args.tool, args.cwd, sessions), end="")
1081
+ return 0
1082
+ candidate = resolve_session(args.tool, args.ref, args.cwd, args.within_min)
1083
+ result = adapter.read(candidate, args.max_tool_chars)
1084
+ if args.json:
1085
+ print(json.dumps(result, indent=2, ensure_ascii=True))
1086
+ else:
1087
+ print(core.render_human(result), end="")
1088
+ return 0
1089
+ except core.AmbiguousReference as exc:
1090
+ print(f"error: {exc}", file=sys.stderr)
1091
+ print("Matches (choose a native id or path):", file=sys.stderr)
1092
+ for match in exc.matches:
1093
+ print(
1094
+ f" {match['session_id']} [{match.get('source')}] "
1095
+ f"{match.get('title') or '(untitled)'}",
1096
+ file=sys.stderr,
1097
+ )
1098
+ return 2
1099
+ except core.ReaderError as exc:
1100
+ print(f"error: {exc}", file=sys.stderr)
1101
+ return 2
1102
+
1103
+
1104
+ if __name__ == "__main__":
1105
+ raise SystemExit(main())