@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.
- package/LICENSE +205 -0
- package/NOTICE +14 -0
- package/README.en.md +59 -0
- package/README.md +59 -0
- package/cordis.patch.yml +5 -0
- package/cordis.yml +5 -0
- package/lib/dsh-resume.js +410 -0
- package/lib/types/dsh-resume.d.ts +8 -0
- package/lib/types/reader.d.ts +33 -0
- package/lib/types/skills.d.ts +44 -0
- package/package.json +86 -0
- package/resources/dsh_session_reader.py +1105 -0
- package/resources/session_reader.py +2199 -0
|
@@ -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())
|