pico-cli-core 0.1.1__py3-none-any.whl

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.
pico_core/tools.py ADDED
@@ -0,0 +1,527 @@
1
+ """The core tools: read, write, edit, bash, grep, fetch, websearch.
2
+
3
+ Each tool operates against a working directory. Bash runs unsandboxed and is
4
+ disabled unless an opt-in flag is set. Tool errors (missing file, non-zero exit)
5
+ surface as results rather than exceptions.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import fnmatch
12
+ import html as _html
13
+ import re
14
+ from html.parser import HTMLParser
15
+ from pathlib import Path
16
+ from typing import Protocol
17
+ from urllib.parse import urlparse
18
+
19
+ import httpx
20
+ from pydantic import BaseModel
21
+
22
+ from pico_ai.types import ToolDefinition
23
+
24
+
25
+ class ToolOutcome(BaseModel):
26
+ """The result of running a tool."""
27
+
28
+ content: str
29
+ is_error: bool = False
30
+
31
+
32
+ class Tool(Protocol):
33
+ """A capability the agent can invoke."""
34
+
35
+ name: str
36
+ description: str
37
+ input_schema: dict
38
+
39
+ async def run(self, arguments: dict) -> ToolOutcome:
40
+ """Execute the tool and return its outcome."""
41
+ ...
42
+
43
+
44
+ def _resolve(cwd: Path, raw: str) -> Path:
45
+ """Resolve a possibly-relative path against the working directory."""
46
+ p = Path(raw)
47
+ return p if p.is_absolute() else cwd / p
48
+
49
+
50
+ class ReadTool:
51
+ """Read a file's contents."""
52
+
53
+ name = "read"
54
+ description = "Read the contents of a file."
55
+ input_schema = {
56
+ "type": "object",
57
+ "properties": {"path": {"type": "string"}},
58
+ "required": ["path"],
59
+ }
60
+
61
+ def __init__(self, cwd: Path) -> None:
62
+ self._cwd = cwd
63
+
64
+ async def run(self, arguments: dict) -> ToolOutcome:
65
+ path = _resolve(self._cwd, arguments["path"])
66
+ try:
67
+ return ToolOutcome(content=path.read_text(encoding="utf-8"))
68
+ except FileNotFoundError:
69
+ return ToolOutcome(
70
+ content=f"error: file not found: {arguments['path']}", is_error=True
71
+ )
72
+ except OSError as exc:
73
+ return ToolOutcome(content=f"error: {exc}", is_error=True)
74
+
75
+
76
+ class WriteTool:
77
+ """Create or overwrite a file."""
78
+
79
+ name = "write"
80
+ description = "Create or overwrite a file with the given content."
81
+ input_schema = {
82
+ "type": "object",
83
+ "properties": {
84
+ "path": {"type": "string"},
85
+ "content": {"type": "string"},
86
+ },
87
+ "required": ["path", "content"],
88
+ }
89
+
90
+ def __init__(self, cwd: Path) -> None:
91
+ self._cwd = cwd
92
+
93
+ async def run(self, arguments: dict) -> ToolOutcome:
94
+ path = _resolve(self._cwd, arguments["path"])
95
+ try:
96
+ path.parent.mkdir(parents=True, exist_ok=True)
97
+ path.write_text(arguments.get("content", ""), encoding="utf-8")
98
+ return ToolOutcome(content=f"wrote {arguments['path']}")
99
+ except OSError as exc:
100
+ return ToolOutcome(content=f"error: {exc}", is_error=True)
101
+
102
+
103
+ class EditTool:
104
+ """Apply a surgical search/replace patch to a file."""
105
+
106
+ name = "edit"
107
+ description = "Replace a unique occurrence of old_text with new_text in a file."
108
+ input_schema = {
109
+ "type": "object",
110
+ "properties": {
111
+ "path": {"type": "string"},
112
+ "old_text": {"type": "string"},
113
+ "new_text": {"type": "string"},
114
+ },
115
+ "required": ["path", "old_text", "new_text"],
116
+ }
117
+
118
+ def __init__(self, cwd: Path) -> None:
119
+ self._cwd = cwd
120
+
121
+ async def run(self, arguments: dict) -> ToolOutcome:
122
+ path = _resolve(self._cwd, arguments["path"])
123
+ old = arguments["old_text"]
124
+ new = arguments.get("new_text", "")
125
+ try:
126
+ content = path.read_text(encoding="utf-8")
127
+ except FileNotFoundError:
128
+ return ToolOutcome(
129
+ content=f"error: file not found: {arguments['path']}", is_error=True
130
+ )
131
+ count = content.count(old)
132
+ if count == 0:
133
+ return ToolOutcome(content="error: old_text not found", is_error=True)
134
+ if count > 1:
135
+ return ToolOutcome(
136
+ content=f"error: old_text found {count} times; provide a unique match",
137
+ is_error=True,
138
+ )
139
+ try:
140
+ path.write_text(content.replace(old, new, 1), encoding="utf-8")
141
+ return ToolOutcome(content=f"edited {arguments['path']}")
142
+ except OSError as exc:
143
+ return ToolOutcome(content=f"error: {exc}", is_error=True)
144
+
145
+
146
+ class BashTool:
147
+ """Run a shell command (unsandboxed, opt-in)."""
148
+
149
+ name = "bash"
150
+ description = "Run a shell command and return its output and exit code."
151
+ input_schema = {
152
+ "type": "object",
153
+ "properties": {"command": {"type": "string"}},
154
+ "required": ["command"],
155
+ }
156
+
157
+ def __init__(self, cwd: Path, enabled: bool = False) -> None:
158
+ self._cwd = cwd
159
+ self.enabled = enabled
160
+
161
+ async def run(self, arguments: dict) -> ToolOutcome:
162
+ if not self.enabled:
163
+ return ToolOutcome(
164
+ content="error: bash is disabled; drop --no-bash to enable",
165
+ is_error=True,
166
+ )
167
+ command = arguments["command"]
168
+ try:
169
+ proc = await asyncio.create_subprocess_shell(
170
+ command,
171
+ cwd=str(self._cwd),
172
+ stdout=asyncio.subprocess.PIPE,
173
+ stderr=asyncio.subprocess.PIPE,
174
+ )
175
+ stdout, stderr = await proc.communicate()
176
+ output = stdout.decode(errors="replace") + stderr.decode(errors="replace")
177
+ content = output.rstrip() + f"\n[exit code: {proc.returncode}]"
178
+ return ToolOutcome(content=content, is_error=proc.returncode != 0)
179
+ except Exception as exc: # noqa: BLE001 - surface any failure as a result
180
+ return ToolOutcome(content=f"error: {exc}", is_error=True)
181
+
182
+
183
+ class GrepTool:
184
+ """Search file contents for a regex pattern."""
185
+
186
+ name = "grep"
187
+ description = (
188
+ "Search file contents for a regex pattern. Searches a single file or "
189
+ "recursively under a directory; returns matching lines as "
190
+ "`path:line: content`."
191
+ )
192
+ input_schema = {
193
+ "type": "object",
194
+ "properties": {
195
+ "pattern": {"type": "string"},
196
+ "path": {"type": "string"},
197
+ "include": {"type": "string"},
198
+ },
199
+ "required": ["pattern"],
200
+ }
201
+
202
+ # Directories never descended into during a recursive search.
203
+ _SKIP_DIRS = frozenset(
204
+ {
205
+ ".git",
206
+ ".venv",
207
+ "venv",
208
+ "__pycache__",
209
+ "node_modules",
210
+ ".mypy_cache",
211
+ ".pytest_cache",
212
+ ".ruff_cache",
213
+ "dist",
214
+ "build",
215
+ }
216
+ )
217
+ _MAX_MATCHES = 100
218
+ _MAX_LINE_LEN = 200
219
+
220
+ def __init__(self, cwd: Path) -> None:
221
+ self._cwd = cwd
222
+
223
+ async def run(self, arguments: dict) -> ToolOutcome:
224
+ raw_pattern = arguments.get("pattern", "")
225
+ if not raw_pattern:
226
+ return ToolOutcome(content="error: pattern is required", is_error=True)
227
+ try:
228
+ regex = re.compile(raw_pattern)
229
+ except re.error as exc:
230
+ return ToolOutcome(content=f"error: invalid regex: {exc}", is_error=True)
231
+
232
+ target = _resolve(self._cwd, arguments.get("path", ".") or ".")
233
+ include = arguments.get("include", "")
234
+
235
+ if not target.exists():
236
+ return ToolOutcome(
237
+ content=f"error: path not found: {arguments.get('path', '.')}",
238
+ is_error=True,
239
+ )
240
+
241
+ files: list[Path] = []
242
+ if target.is_file():
243
+ files = [target]
244
+ else:
245
+ for p in sorted(target.rglob("*")):
246
+ if not p.is_file():
247
+ continue
248
+ if any(part in self._SKIP_DIRS for part in p.parts):
249
+ continue
250
+ if p.suffix == ".egg-info" or ".egg-info" in p.parts:
251
+ continue
252
+ if include and not fnmatch.fnmatch(p.name, include):
253
+ continue
254
+ files.append(p)
255
+
256
+ if target.is_file() and include and not fnmatch.fnmatch(target.name, include):
257
+ return ToolOutcome(content="(no matches)")
258
+
259
+ matches: list[str] = []
260
+ for file in files:
261
+ try:
262
+ text = file.read_text(encoding="utf-8", errors="strict")
263
+ except (OSError, UnicodeDecodeError, ValueError):
264
+ continue
265
+ try:
266
+ rel = file.relative_to(self._cwd).as_posix()
267
+ except ValueError:
268
+ rel = str(file)
269
+ for lineno, line in enumerate(text.splitlines(), start=1):
270
+ if regex.search(line):
271
+ line = line.strip()
272
+ if len(line) > self._MAX_LINE_LEN:
273
+ line = line[: self._MAX_LINE_LEN] + "…"
274
+ matches.append(f"{rel}:{lineno}: {line}")
275
+ if len(matches) >= self._MAX_MATCHES:
276
+ matches.append(
277
+ f"... truncated at {self._MAX_MATCHES} matches"
278
+ )
279
+ return ToolOutcome(content="\n".join(matches))
280
+ if not matches:
281
+ return ToolOutcome(content="(no matches)")
282
+ return ToolOutcome(content="\n".join(matches))
283
+
284
+
285
+ class _TextExtractor(HTMLParser):
286
+ """Collect visible text from HTML, skipping script/style content."""
287
+
288
+ _SKIP_TAGS = frozenset({"script", "style", "noscript"})
289
+
290
+ def __init__(self) -> None:
291
+ super().__init__()
292
+ self._chunks: list[str] = []
293
+ self._skip_depth = 0
294
+
295
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
296
+ if tag in self._SKIP_TAGS:
297
+ self._skip_depth += 1
298
+ elif tag in ("p", "br", "li", "tr", "h1", "h2", "h3", "h4", "h5", "h6"):
299
+ self._chunks.append("\n")
300
+
301
+ def handle_endtag(self, tag: str) -> None:
302
+ if tag in self._SKIP_TAGS and self._skip_depth:
303
+ self._skip_depth -= 1
304
+ elif tag in ("p", "li", "tr", "h1", "h2", "h3", "h4", "h5", "h6"):
305
+ self._chunks.append("\n")
306
+
307
+ def handle_data(self, data: str) -> None:
308
+ if not self._skip_depth:
309
+ self._chunks.append(data)
310
+
311
+ def text(self) -> str:
312
+ raw = "".join(self._chunks)
313
+ raw = _html.unescape(raw)
314
+ lines = [re.sub(r"\s+", " ", ln).strip() for ln in raw.splitlines()]
315
+ return "\n".join(ln for ln in lines if ln)
316
+
317
+
318
+ def _html_to_text(body: str) -> str:
319
+ """Strip tags from an HTML document, returning readable plain text."""
320
+ extractor = _TextExtractor()
321
+ extractor.feed(body)
322
+ return extractor.text()
323
+
324
+
325
+ class FetchTool:
326
+ """Fetch a URL and return its content as text."""
327
+
328
+ name = "fetch"
329
+ description = (
330
+ "Fetch a URL over HTTP(S) and return its content as text. "
331
+ "HTML pages are converted to readable plain text."
332
+ )
333
+ input_schema = {
334
+ "type": "object",
335
+ "properties": {
336
+ "url": {"type": "string"},
337
+ "max_chars": {"type": "integer"},
338
+ },
339
+ "required": ["url"],
340
+ }
341
+
342
+ _DEFAULT_MAX_CHARS = 8000
343
+ _MAX_CHARS_LIMIT = 50_000
344
+
345
+ def __init__(self, client: httpx.AsyncClient | None = None) -> None:
346
+ self._client = client
347
+
348
+ async def run(self, arguments: dict) -> ToolOutcome:
349
+ url = (arguments.get("url") or "").strip()
350
+ if not url:
351
+ return ToolOutcome(content="error: url is required", is_error=True)
352
+ try:
353
+ scheme = urlparse(url).scheme.lower()
354
+ except ValueError as exc:
355
+ return ToolOutcome(content=f"error: invalid url: {exc}", is_error=True)
356
+ if scheme not in ("http", "https"):
357
+ return ToolOutcome(
358
+ content="error: only http(s) urls are supported", is_error=True
359
+ )
360
+ try:
361
+ max_chars = int(arguments.get("max_chars", self._DEFAULT_MAX_CHARS))
362
+ except (TypeError, ValueError):
363
+ return ToolOutcome(content="error: max_chars must be an integer",
364
+ is_error=True)
365
+ max_chars = max(1, min(max_chars, self._MAX_CHARS_LIMIT))
366
+
367
+ client = self._client or httpx.AsyncClient(
368
+ timeout=httpx.Timeout(30.0, connect=10.0), follow_redirects=True
369
+ )
370
+ try:
371
+ response = await client.get(
372
+ url, headers={"User-Agent": "pico-agent/0.1"}
373
+ )
374
+ except httpx.HTTPError as exc:
375
+ return ToolOutcome(content=f"error: request failed: {exc}", is_error=True)
376
+ finally:
377
+ if self._client is None:
378
+ await client.aclose()
379
+
380
+ if response.status_code >= 400:
381
+ return ToolOutcome(
382
+ content=f"error: HTTP {response.status_code} for {url}",
383
+ is_error=True,
384
+ )
385
+ content_type = response.headers.get("content-type", "")
386
+ body = response.text
387
+ if "html" in content_type or body.lstrip().startswith("<"):
388
+ body = _html_to_text(body)
389
+ if not body.strip():
390
+ return ToolOutcome(content="(empty response)")
391
+ if len(body) > max_chars:
392
+ body = body[:max_chars] + f"\n... truncated at {max_chars} chars"
393
+ return ToolOutcome(content=body)
394
+
395
+
396
+ class _DDGResultParser(HTMLParser):
397
+ """Parse DuckDuckGo ``/html/`` results into (title, url, snippet) triples."""
398
+
399
+ def __init__(self) -> None:
400
+ super().__init__()
401
+ self.results: list[dict[str, str]] = []
402
+ self._current: dict[str, str] | None = None
403
+ self._in_title = False
404
+ self._in_snippet = False
405
+
406
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
407
+ classes = dict(attrs).get("class", "") or ""
408
+ if tag == "a" and "result__a" in classes:
409
+ self._current = {"title": "", "url": dict(attrs).get("href", "") or "",
410
+ "snippet": ""}
411
+ self._in_title = True
412
+ elif tag == "a" and "result__snippet" in classes:
413
+ self._in_snippet = True
414
+
415
+ def handle_endtag(self, tag: str) -> None:
416
+ if tag != "a":
417
+ return
418
+ if self._in_title:
419
+ self._in_title = False
420
+ elif self._in_snippet:
421
+ self._in_snippet = False
422
+ if self._current and self._current["url"]:
423
+ self._current["title"] = self._current["title"].strip()
424
+ self._current["snippet"] = self._current["snippet"].strip()
425
+ self.results.append(self._current)
426
+ self._current = None
427
+
428
+ def handle_data(self, data: str) -> None:
429
+ if self._in_title and self._current is not None:
430
+ self._current["title"] += data
431
+ elif self._in_snippet and self._current is not None:
432
+ self._current["snippet"] += data
433
+
434
+
435
+ class WebSearchTool:
436
+ """Search the web (no API key required) and return ranked results."""
437
+
438
+ name = "websearch"
439
+ description = (
440
+ "Search the web for a query and return ranked results "
441
+ "(title, url, snippet)."
442
+ )
443
+ input_schema = {
444
+ "type": "object",
445
+ "properties": {
446
+ "query": {"type": "string"},
447
+ "count": {"type": "integer"},
448
+ },
449
+ "required": ["query"],
450
+ }
451
+
452
+ _DEFAULT_COUNT = 5
453
+ _MAX_COUNT = 10
454
+ _SEARCH_URL = "https://html.duckduckgo.com/html/"
455
+
456
+ def __init__(self, client: httpx.AsyncClient | None = None) -> None:
457
+ self._client = client
458
+
459
+ async def run(self, arguments: dict) -> ToolOutcome:
460
+ query = (arguments.get("query") or "").strip()
461
+ if not query:
462
+ return ToolOutcome(content="error: query is required", is_error=True)
463
+ try:
464
+ count = int(arguments.get("count", self._DEFAULT_COUNT))
465
+ except (TypeError, ValueError):
466
+ return ToolOutcome(content="error: count must be an integer",
467
+ is_error=True)
468
+ count = max(1, min(count, self._MAX_COUNT))
469
+
470
+ client = self._client or httpx.AsyncClient(
471
+ timeout=httpx.Timeout(30.0, connect=10.0)
472
+ )
473
+ try:
474
+ response = await client.post(
475
+ self._SEARCH_URL,
476
+ data={"q": query},
477
+ headers={"User-Agent": "pico-agent/0.1"},
478
+ )
479
+ except httpx.HTTPError as exc:
480
+ return ToolOutcome(content=f"error: search failed: {exc}", is_error=True)
481
+ finally:
482
+ if self._client is None:
483
+ await client.aclose()
484
+
485
+ if response.status_code >= 400:
486
+ return ToolOutcome(
487
+ content=f"error: search returned HTTP {response.status_code}",
488
+ is_error=True,
489
+ )
490
+ parser = _DDGResultParser()
491
+ parser.feed(response.text)
492
+ results = parser.results[:count]
493
+ if not results:
494
+ return ToolOutcome(content="(no results)")
495
+ lines: list[str] = []
496
+ for i, item in enumerate(results, start=1):
497
+ lines.append(f"{i}. {item['title']}\n {item['url']}\n {item['snippet']}")
498
+ return ToolOutcome(content="\n".join(lines))
499
+
500
+
501
+ class ToolRegistry:
502
+ """A name → tool mapping that also exposes tool definitions."""
503
+
504
+ def __init__(self) -> None:
505
+ self._tools: dict[str, Tool] = {}
506
+
507
+ def register(self, tool: Tool) -> None:
508
+ self._tools[tool.name] = tool
509
+
510
+ def unregister(self, name: str) -> Tool | None:
511
+ """Remove a tool by name, returning it (or ``None`` if absent)."""
512
+ return self._tools.pop(name, None)
513
+
514
+ def get(self, name: str) -> Tool | None:
515
+ return self._tools.get(name)
516
+
517
+ def names(self) -> list[str]:
518
+ return list(self._tools)
519
+
520
+ def definitions(self) -> list[ToolDefinition]:
521
+ return [
522
+ ToolDefinition(
523
+ name=t.name, description=t.description, input_schema=t.input_schema
524
+ )
525
+ for t in self._tools.values()
526
+ ]
527
+
pico_core/trace.py ADDED
@@ -0,0 +1,128 @@
1
+ """Trace rows for the trace view (ADR-0006).
2
+
3
+ Pure function: session nodes -> display rows. No Textual/Rich dependency,
4
+ so the TUI overlay and any future headless export share one semantic.
5
+
6
+ Missing data renders as blanks (``None``), never estimates: only assistant
7
+ nodes carry token usage, only assistant nodes carry a measured duration,
8
+ and only tool results paired with a request carry a computed duration.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from datetime import datetime
15
+
16
+ from pydantic import BaseModel
17
+
18
+ from .session import (
19
+ AssistantPayload,
20
+ CompactionSummaryPayload,
21
+ Node,
22
+ ToolRequestPayload,
23
+ ToolResultPayload,
24
+ UserPayload,
25
+ )
26
+
27
+ #: Summary width, matching the history picker's 80-char rows.
28
+ SUMMARY_LIMIT = 80
29
+
30
+ #: Same rule as the TUI's bash collapse: trailing ``[exit code: N]``.
31
+ _BASH_EXIT = re.compile(r"\[exit code: (-?\d+)\]\s*$")
32
+
33
+
34
+ class TraceRow(BaseModel):
35
+ """One display row in the trace view, derived from a single node."""
36
+
37
+ node_id: str
38
+ time: str # HH:MM:SS of the node timestamp (raw fallback when unparsable)
39
+ kind: str
40
+ summary: str # one line, truncated to SUMMARY_LIMIT
41
+ status: str # "ok" | "error", bash rows add "(exit N)"
42
+ tokens: int | None # assistant total; None elsewhere
43
+ duration_ms: float | None # measured (assistant) or computed (paired tool result)
44
+
45
+
46
+ def _short_time(timestamp: str) -> str:
47
+ """Format an ISO-8601 timestamp as HH:MM:SS; never raises."""
48
+ try:
49
+ return datetime.fromisoformat(timestamp).strftime("%H:%M:%S")
50
+ except ValueError:
51
+ return timestamp[:8] if timestamp else "--:--:--"
52
+
53
+
54
+ def _truncate(text: str, limit: int = SUMMARY_LIMIT) -> str:
55
+ """Collapse whitespace and cut to one line (mirrors the TUI renderer)."""
56
+ text = " ".join(text.split())
57
+ return text if len(text) <= limit else text[:limit] + "..."
58
+
59
+
60
+ def _summary(payload: object) -> str:
61
+ """One-line human-readable summary per payload kind."""
62
+ if isinstance(payload, UserPayload):
63
+ return payload.content
64
+ if isinstance(payload, AssistantPayload):
65
+ return payload.text
66
+ if isinstance(payload, ToolRequestPayload):
67
+ return f"{payload.tool_call.name} {payload.tool_call.arguments}"
68
+ if isinstance(payload, ToolResultPayload):
69
+ return payload.content
70
+ if isinstance(payload, CompactionSummaryPayload):
71
+ return payload.summary
72
+ return ""
73
+
74
+
75
+ def _status(payload: object) -> str:
76
+ """Derived status: ok/error from is_error, plus bash exit code."""
77
+ if isinstance(payload, ToolResultPayload):
78
+ base = "error" if payload.is_error else "ok"
79
+ if payload.name == "bash":
80
+ match = _BASH_EXIT.search(payload.content)
81
+ if match is not None:
82
+ return f"{base} (exit {match.group(1)})"
83
+ return base
84
+ return "ok"
85
+
86
+
87
+ def _tool_duration_ms(request_ts: str | None, result_ts: str) -> float | None:
88
+ """Computed tool span: result.timestamp - request.timestamp in ms."""
89
+ if request_ts is None:
90
+ return None
91
+ try:
92
+ delta = datetime.fromisoformat(result_ts) - datetime.fromisoformat(request_ts)
93
+ except ValueError:
94
+ return None
95
+ return max(delta.total_seconds() * 1000.0, 0.0)
96
+
97
+
98
+ def assemble_trace_rows(branch: list[Node]) -> list[TraceRow]:
99
+ """Map one branch (root-to-leaf nodes) to one trace row per node."""
100
+ request_times: dict[str, str] = {}
101
+ for node in branch:
102
+ if isinstance(node.payload, ToolRequestPayload):
103
+ request_times.setdefault(node.payload.tool_call.id, node.timestamp)
104
+ rows: list[TraceRow] = []
105
+ for node in branch:
106
+ payload = node.payload
107
+ tokens: int | None = None
108
+ duration_ms: float | None = None
109
+ if isinstance(payload, AssistantPayload):
110
+ if payload.usage is not None:
111
+ tokens = payload.usage.total_tokens
112
+ duration_ms = payload.duration_ms
113
+ elif isinstance(payload, ToolResultPayload):
114
+ duration_ms = _tool_duration_ms(
115
+ request_times.get(payload.tool_call_id), node.timestamp
116
+ )
117
+ rows.append(
118
+ TraceRow(
119
+ node_id=node.id,
120
+ time=_short_time(node.timestamp),
121
+ kind=payload.kind,
122
+ summary=_truncate(_summary(payload)),
123
+ status=_status(payload),
124
+ tokens=tokens,
125
+ duration_ms=duration_ms,
126
+ )
127
+ )
128
+ return rows