ctxttools 0.4.0__tar.gz → 0.4.2__tar.gz

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.
Files changed (40) hide show
  1. {ctxttools-0.4.0 → ctxttools-0.4.2}/PKG-INFO +45 -7
  2. {ctxttools-0.4.0 → ctxttools-0.4.2}/README.md +43 -6
  3. {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/agents.py +133 -1
  4. {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/ccat.py +104 -57
  5. {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/ccopy.py +122 -48
  6. {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/cdir.py +23 -56
  7. {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/cgrep.py +155 -22
  8. {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/cli.py +11 -0
  9. ctxttools-0.4.2/ctools/ctools_mcp.py +848 -0
  10. {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/dispatch.py +2 -1
  11. ctxttools-0.4.2/ctools/pathfilter.py +91 -0
  12. {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/remote_search.py +2 -4
  13. ctxttools-0.4.2/ctools/resume.py +285 -0
  14. {ctxttools-0.4.0 → ctxttools-0.4.2}/ctxttools.egg-info/PKG-INFO +45 -7
  15. {ctxttools-0.4.0 → ctxttools-0.4.2}/ctxttools.egg-info/SOURCES.txt +2 -0
  16. {ctxttools-0.4.0 → ctxttools-0.4.2}/ctxttools.egg-info/requires.txt +1 -0
  17. {ctxttools-0.4.0 → ctxttools-0.4.2}/pyproject.toml +2 -2
  18. ctxttools-0.4.0/ctools/ctools_mcp.py +0 -468
  19. {ctxttools-0.4.0 → ctxttools-0.4.2}/LICENSE.MIT +0 -0
  20. {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/__init__.py +0 -0
  21. {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/__main__.py +0 -0
  22. {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/backup.py +0 -0
  23. {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/cconnect.py +0 -0
  24. {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/cdu.py +0 -0
  25. {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/cextract.py +0 -0
  26. {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/config.py +0 -0
  27. {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/crecover.py +0 -0
  28. {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/crm.py +0 -0
  29. {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/ctools-web.py +0 -0
  30. {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/filterlib.py +0 -0
  31. {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/lib.py +0 -0
  32. {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/log.py +0 -0
  33. {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/paths.py +0 -0
  34. {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/strategy.py +0 -0
  35. {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/testing.py +0 -0
  36. {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/webui.py +0 -0
  37. {ctxttools-0.4.0 → ctxttools-0.4.2}/ctxttools.egg-info/dependency_links.txt +0 -0
  38. {ctxttools-0.4.0 → ctxttools-0.4.2}/ctxttools.egg-info/entry_points.txt +0 -0
  39. {ctxttools-0.4.0 → ctxttools-0.4.2}/ctxttools.egg-info/top_level.txt +0 -0
  40. {ctxttools-0.4.0 → ctxttools-0.4.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ctxttools
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: CLI tools for LLM conversations
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -12,6 +12,7 @@ Requires-Dist: platformdirs>=3.0.0
12
12
  Requires-Dist: tomli>=2.0.0; python_version < "3.11"
13
13
  Provides-Extra: mcp
14
14
  Requires-Dist: mcp<2.0,>=1.2.0; extra == "mcp"
15
+ Requires-Dist: pyyaml>=6.0; extra == "mcp"
15
16
  Provides-Extra: dev
16
17
  Requires-Dist: pytest>=7.0.0; extra == "dev"
17
18
  Dynamic: license-file
@@ -167,9 +168,10 @@ transform:
167
168
  rm cut sections out of a session (crm)
168
169
  recover restore from a backup (crecover)
169
170
 
170
- long-running:
171
+ long-running / hand-off:
171
172
  web run the web dashboard (ctools-web)
172
173
  mcp run the MCP server (ctools-mcp)
174
+ resume hand a session back to its tool (resume)
173
175
 
174
176
  Run 'ctools <command> --help' for a command's options.
175
177
  ```
@@ -177,7 +179,7 @@ Run 'ctools <command> --help' for a command's options.
177
179
  Each verb has its own page in [`docs/`](docs/): [dir](docs/cdir.md),
178
180
  [grep](docs/cgrep.md), [cat](docs/ccat.md), [du](docs/cdu.md),
179
181
  [copy](docs/ccopy.md), [extract](docs/cextract.md),
180
- [connect](docs/cconnect.md), [rm](docs/crm.md), and
182
+ [connect](docs/cconnect.md), [rm](docs/crm.md), [resume](docs/resume.md), and
181
183
  [recover](docs/crecover.md). Sessions are addressed as `agent/session_id`
182
184
  throughout.
183
185
 
@@ -200,6 +202,7 @@ ctools grep "bug report" # every installed agent
200
202
  ctools grep -i "deploy" claude-code # one agent
201
203
  ctools grep -C 2 "flaky test" opencode # with context
202
204
  ctools grep -l "parser" pi # just the session ids
205
+ ctools grep "parser" . # only sessions recorded in this directory
203
206
  ctools grep --user "refactor" # only what you typed
204
207
  ctools grep --hosts "mcp config" # also the hosts in ctools.toml
205
208
  ```
@@ -208,7 +211,7 @@ ctools grep --hosts "mcp config" # also the hosts in ctools.toml
208
211
 
209
212
  ```bash
210
213
  ctools cat opencode/ses_abc # JSON list of {role, content}
211
- ctools cat --text opencode/ses_abc # a readable transcript
214
+ ctools cat --text opencode/ses_abc # a markdown transcript (**role**:/content)
212
215
  ctools cat ssh://me@box/pi/<id> # a session on another machine
213
216
  ```
214
217
 
@@ -229,6 +232,33 @@ The remote side doesn't need ctools installed; plain `ssh` and `tar` will do.
229
232
  See [docs/ccopy.md](docs/ccopy.md) for how that works and where the new
230
233
  session lands.
231
234
 
235
+ ### Continue a session in its own tool
236
+
237
+ `resume` hands a session back to the tool that owns it: it computes the
238
+ agent's resume command, `cd`s into the directory the session was recorded
239
+ in, and `exec`s the command (replacing ctools). It's the one verb that
240
+ "leaves ctools".
241
+
242
+ ```bash
243
+ ctools resume claude-code/55b9c8b7-788b-4f9e-a8b7-cfa0461b357b # a cdir row, pasted
244
+ ctools resume claude-code a293cac2 # cd + exec `claude --resume a293cac2`
245
+ ctools resume pi <id> # `pi --session <id>`
246
+ ctools resume . # the most recent session in this directory
247
+ ctools dir --resume-command pi/<id> # just print the command, don't exec it
248
+ ```
249
+
250
+ The session is named the way `cdir` prints it — `agent/session`, the
251
+ two-word `agent session`, or a bare id resolved across the installed
252
+ agents. A directory (`.`, `..`, `/path`, `~/proj`) instead names the most
253
+ recently recorded session in it; `resume <agent> .` scopes that to one
254
+ agent. `resume` says which session a directory resolved to before it
255
+ leaves.
256
+
257
+ The resume command comes from the agent backend and is overridable per
258
+ machine in `~/.config/ctools/resume-commands.json`. The `cd` into the
259
+ recorded directory matters: `claude --resume` only finds a session from the
260
+ directory it was recorded in. See [docs/resume.md](docs/resume.md).
261
+
232
262
  ### Token usage
233
263
 
234
264
  ```bash
@@ -325,14 +355,18 @@ ctools mcp # run the server on stdio (same as ctools-mcp)
325
355
  ### Install and register with Pi
326
356
 
327
357
  ```bash
328
- ctools-mcp install
358
+ ctools-mcp install # global: ~/.pi/agent/mcp.json
359
+ ctools-mcp install --project # project: .pi/mcp.json in the current directory
329
360
  ```
330
361
 
331
362
  If no working `ctxttools[mcp]` install is found, this installs one with
332
- `uv tool`. It then registers the server in `~/.pi/agent/mcp.json`. Verify with:
363
+ `uv tool`. It then registers the server in `~/.pi/agent/mcp.json` (or
364
+ `.pi/mcp.json` with `--project`), always with a `python -m ctools.ctools_mcp`
365
+ entry that does not depend on the MCP host's `PATH`. Verify with:
333
366
 
334
367
  ```bash
335
368
  pi mcp list # "ctools-mcp … connected"
369
+ ctools-mcp check # also: verifies the command exists and starts, prints the fix
336
370
  ```
337
371
 
338
372
  `ctools-mcp uninstall` removes the entry again (`--all` also removes it from
@@ -352,13 +386,17 @@ Add that to your host's MCP config. The server is started with
352
386
 
353
387
  ### Tools exposed
354
388
 
389
+ Session tools take a single `session` reference: `agent/session_id` (the
390
+ `@` prefix also works), or a bare `session_id` plus the `agent` argument.
391
+
355
392
  | Tool | What it does |
356
393
  |---|---|
357
394
  | `list_agents` | Agents available and whether they're installed. |
358
395
  | `list_sessions` | Recent sessions for one agent. |
359
396
  | `search_sessions` | Find sessions by pattern or phrase. |
397
+ | `summarize_session` | Quick orientation: title, date, size, first user and last assistant message. |
360
398
  | `export_session` | Get the messages of a session. |
361
- | `extract_concepts` | Extract concepts from a session. |
399
+ | `extract_concepts` | Extract 'Use the following …' concept blocks from a session. |
362
400
  | `get_session_concepts` | Get a session's concepts, optionally of one type. |
363
401
  | `copy_concepts` | Copy extracted concepts into a destination session. |
364
402
 
@@ -149,9 +149,10 @@ transform:
149
149
  rm cut sections out of a session (crm)
150
150
  recover restore from a backup (crecover)
151
151
 
152
- long-running:
152
+ long-running / hand-off:
153
153
  web run the web dashboard (ctools-web)
154
154
  mcp run the MCP server (ctools-mcp)
155
+ resume hand a session back to its tool (resume)
155
156
 
156
157
  Run 'ctools <command> --help' for a command's options.
157
158
  ```
@@ -159,7 +160,7 @@ Run 'ctools <command> --help' for a command's options.
159
160
  Each verb has its own page in [`docs/`](docs/): [dir](docs/cdir.md),
160
161
  [grep](docs/cgrep.md), [cat](docs/ccat.md), [du](docs/cdu.md),
161
162
  [copy](docs/ccopy.md), [extract](docs/cextract.md),
162
- [connect](docs/cconnect.md), [rm](docs/crm.md), and
163
+ [connect](docs/cconnect.md), [rm](docs/crm.md), [resume](docs/resume.md), and
163
164
  [recover](docs/crecover.md). Sessions are addressed as `agent/session_id`
164
165
  throughout.
165
166
 
@@ -182,6 +183,7 @@ ctools grep "bug report" # every installed agent
182
183
  ctools grep -i "deploy" claude-code # one agent
183
184
  ctools grep -C 2 "flaky test" opencode # with context
184
185
  ctools grep -l "parser" pi # just the session ids
186
+ ctools grep "parser" . # only sessions recorded in this directory
185
187
  ctools grep --user "refactor" # only what you typed
186
188
  ctools grep --hosts "mcp config" # also the hosts in ctools.toml
187
189
  ```
@@ -190,7 +192,7 @@ ctools grep --hosts "mcp config" # also the hosts in ctools.toml
190
192
 
191
193
  ```bash
192
194
  ctools cat opencode/ses_abc # JSON list of {role, content}
193
- ctools cat --text opencode/ses_abc # a readable transcript
195
+ ctools cat --text opencode/ses_abc # a markdown transcript (**role**:/content)
194
196
  ctools cat ssh://me@box/pi/<id> # a session on another machine
195
197
  ```
196
198
 
@@ -211,6 +213,33 @@ The remote side doesn't need ctools installed; plain `ssh` and `tar` will do.
211
213
  See [docs/ccopy.md](docs/ccopy.md) for how that works and where the new
212
214
  session lands.
213
215
 
216
+ ### Continue a session in its own tool
217
+
218
+ `resume` hands a session back to the tool that owns it: it computes the
219
+ agent's resume command, `cd`s into the directory the session was recorded
220
+ in, and `exec`s the command (replacing ctools). It's the one verb that
221
+ "leaves ctools".
222
+
223
+ ```bash
224
+ ctools resume claude-code/55b9c8b7-788b-4f9e-a8b7-cfa0461b357b # a cdir row, pasted
225
+ ctools resume claude-code a293cac2 # cd + exec `claude --resume a293cac2`
226
+ ctools resume pi <id> # `pi --session <id>`
227
+ ctools resume . # the most recent session in this directory
228
+ ctools dir --resume-command pi/<id> # just print the command, don't exec it
229
+ ```
230
+
231
+ The session is named the way `cdir` prints it — `agent/session`, the
232
+ two-word `agent session`, or a bare id resolved across the installed
233
+ agents. A directory (`.`, `..`, `/path`, `~/proj`) instead names the most
234
+ recently recorded session in it; `resume <agent> .` scopes that to one
235
+ agent. `resume` says which session a directory resolved to before it
236
+ leaves.
237
+
238
+ The resume command comes from the agent backend and is overridable per
239
+ machine in `~/.config/ctools/resume-commands.json`. The `cd` into the
240
+ recorded directory matters: `claude --resume` only finds a session from the
241
+ directory it was recorded in. See [docs/resume.md](docs/resume.md).
242
+
214
243
  ### Token usage
215
244
 
216
245
  ```bash
@@ -307,14 +336,18 @@ ctools mcp # run the server on stdio (same as ctools-mcp)
307
336
  ### Install and register with Pi
308
337
 
309
338
  ```bash
310
- ctools-mcp install
339
+ ctools-mcp install # global: ~/.pi/agent/mcp.json
340
+ ctools-mcp install --project # project: .pi/mcp.json in the current directory
311
341
  ```
312
342
 
313
343
  If no working `ctxttools[mcp]` install is found, this installs one with
314
- `uv tool`. It then registers the server in `~/.pi/agent/mcp.json`. Verify with:
344
+ `uv tool`. It then registers the server in `~/.pi/agent/mcp.json` (or
345
+ `.pi/mcp.json` with `--project`), always with a `python -m ctools.ctools_mcp`
346
+ entry that does not depend on the MCP host's `PATH`. Verify with:
315
347
 
316
348
  ```bash
317
349
  pi mcp list # "ctools-mcp … connected"
350
+ ctools-mcp check # also: verifies the command exists and starts, prints the fix
318
351
  ```
319
352
 
320
353
  `ctools-mcp uninstall` removes the entry again (`--all` also removes it from
@@ -334,13 +367,17 @@ Add that to your host's MCP config. The server is started with
334
367
 
335
368
  ### Tools exposed
336
369
 
370
+ Session tools take a single `session` reference: `agent/session_id` (the
371
+ `@` prefix also works), or a bare `session_id` plus the `agent` argument.
372
+
337
373
  | Tool | What it does |
338
374
  |---|---|
339
375
  | `list_agents` | Agents available and whether they're installed. |
340
376
  | `list_sessions` | Recent sessions for one agent. |
341
377
  | `search_sessions` | Find sessions by pattern or phrase. |
378
+ | `summarize_session` | Quick orientation: title, date, size, first user and last assistant message. |
342
379
  | `export_session` | Get the messages of a session. |
343
- | `extract_concepts` | Extract concepts from a session. |
380
+ | `extract_concepts` | Extract 'Use the following …' concept blocks from a session. |
344
381
  | `get_session_concepts` | Get a session's concepts, optionally of one type. |
345
382
  | `copy_concepts` | Copy extracted concepts into a destination session. |
346
383
 
@@ -450,6 +450,7 @@ class Agent:
450
450
  # { "context": [{role, content}, ...], # portable spine (llcat/OpenAI)
451
451
  # "source": "agent/session_id",
452
452
  # "model": ..., "created": ..., "modified": ..., # session facts
453
+ # "tool_calls": [{name, summary, msg}, ...], # optional
453
454
  # "raw": [agent's verbatim records] } # lossless, optional
454
455
  # Every agent that can be seeded gets to_common (export) and from_common
455
456
  # (import); agents that can't do either refuse with UnsupportedOperation.
@@ -476,6 +477,9 @@ class Agent:
476
477
  raw = self.raw_records(session_id)
477
478
  if raw:
478
479
  doc['raw'] = raw
480
+ tool_calls = self.tool_calls(session_id)
481
+ if tool_calls:
482
+ doc['tool_calls'] = tool_calls
479
483
  return doc
480
484
 
481
485
  def _session_cwd(self, session_id: str) -> Optional[str]:
@@ -506,12 +510,15 @@ class Agent:
506
510
  """
507
511
  return self._session_cwd(session_id)
508
512
 
509
- def from_common(self, doc: Dict[str, object], cwd: Optional[str] = None) -> str:
513
+ def from_common(self, doc: Dict[str, object], cwd: Optional[str] = None,
514
+ notice: bool = True) -> str:
510
515
  """Import a common-format envelope into a NEW session; return its id.
511
516
 
512
517
  ``cwd`` (or ``doc['cwd']``) seeds the session for that working
513
518
  directory when the agent records one, so the copy lands in the
514
519
  destination's matching project dir and resumes from there.
520
+ ``notice`` appends the import notice that tells a resumed model its
521
+ own tool history was left behind (see :meth:`import_notice`).
515
522
  """
516
523
  context = doc.get('context') or []
517
524
  if not isinstance(context, list):
@@ -523,8 +530,74 @@ class Agent:
523
530
  # If the envelope carries verbatim records *for this agent* (a same-
524
531
  # agent round trip), replay them; otherwise project the portable spine.
525
532
  messages = self._from_common_messages(doc, context)
533
+ if notice:
534
+ note = self.import_notice(doc)
535
+ if note is not None:
536
+ messages = messages + [note]
526
537
  return self.create_session(messages, cwd=cwd)
527
538
 
539
+ # Marker opening the import notice, so a copy of a copy is not annotated
540
+ # twice and the notice is recognisable to a reader and to tests.
541
+ IMPORT_NOTICE_TAG = 'ctools-import-notice'
542
+
543
+ def import_notice(self, doc: Dict[str, object]) -> Optional[Message]:
544
+ """A closing message telling a resumed model what its transcript hides.
545
+
546
+ The common format carries the conversation's *text*. Tool calls and
547
+ their results do not survive the projection, so a copied session reads
548
+ as an assistant that edits files by describing the edit ("let me
549
+ validate" / "valid, now reload and test") with nothing to show it ever
550
+ did. Resumed, the model matches that style and keeps narrating work it
551
+ never performs — the "it thought it made the edits but they all failed"
552
+ failure, which is really the tools never being called at all.
553
+
554
+ The notice is appended rather than prepended: on a long imported
555
+ transcript the first message is the first thing context truncation
556
+ drops, and the last message is the one a resuming model is reading from.
557
+ Returns None when there is nothing the transcript could mislead about,
558
+ or nothing to mislead (an empty copy stays empty).
559
+ """
560
+ context = doc.get('context')
561
+ if not isinstance(context, list) or not context:
562
+ return None
563
+ source = str(doc.get('source') or '')
564
+ src_agent = source.split('/', 1)[0]
565
+ tools = doc.get('tool_calls')
566
+ n_tools = len(tools) if isinstance(tools, list) else 0
567
+ # Evidence decides. Either the envelope recorded tool activity that the
568
+ # spine cannot carry, or it names a session in a *different known*
569
+ # agent, which is a cross-agent copy by definition. A bare '-' or a
570
+ # file path names no session, so nothing was demonstrably dropped.
571
+ cross = (src_agent in REGISTRY and src_agent != self.name)
572
+ if not (n_tools or cross):
573
+ return None
574
+ last = context[-1] if isinstance(context[-1], dict) else {}
575
+ if self.IMPORT_NOTICE_TAG in str(last.get('content') or ''):
576
+ return None # already an imported copy; don't stack notices
577
+
578
+ named = source if src_agent in REGISTRY else 'another agent'
579
+ lost = (f"{n_tools} tool call(s) it recorded, and every result they "
580
+ f"produced, were left behind" if n_tools else
581
+ "any tool calls it made, and every result they produced, were "
582
+ "left behind")
583
+ if src_agent == self.name:
584
+ provenance = (f"This session was re-seeded from its own transcript in "
585
+ f"{source} by `ctools ccopy`.")
586
+ else:
587
+ provenance = (f"This session was copied into {self.name} from "
588
+ f"{named} by `ctools ccopy`.")
589
+ text = (
590
+ f"<{self.IMPORT_NOTICE_TAG}>\n"
591
+ f"{provenance} It carries the conversation's text only: {lost}.\n\n"
592
+ f"So nothing above as edited, written, created, deleted, run, printed, or "
593
+ f"tested has happened in this session. Treat those as unverified history, "
594
+ f"not as the current state of the files: an assistant turn that says it "
595
+ f"made a change is evidence that a change was *wanted*, nothing more. "
596
+ f"Re-read the files you need with your own tools before acting, and make "
597
+ f"every change with a real tool call.\n"
598
+ f"</{self.IMPORT_NOTICE_TAG}>")
599
+ return Message(role='user', content=text)
600
+
528
601
  def _from_common_messages(self, doc: Dict[str, object],
529
602
  context: List[dict]) -> List[Message]:
530
603
  """Build the Message list a common import seeds from.
@@ -1129,6 +1202,12 @@ class ClaudeCodeAgent(JsonlAgent):
1129
1202
  # No truncation here: the display layer owns column width.
1130
1203
  name = title or (first_user or '').strip().replace('\n', ' ') \
1131
1204
  or session_id[:8]
1205
+ parent_id = None
1206
+ if 'subagents' in path.parts:
1207
+ # Subagent transcripts live under <parent-session-id>/subagents/;
1208
+ # the first entry's sessionId agrees with the directory name.
1209
+ parent_id = (entries[0].get('sessionId') if entries else None) \
1210
+ or path.parent.parent.name
1132
1211
  sessions.append(Session(
1133
1212
  id=session_id,
1134
1213
  name=name,
@@ -1138,6 +1217,7 @@ class ClaudeCodeAgent(JsonlAgent):
1138
1217
  path=cwd or str(path),
1139
1218
  model=model,
1140
1219
  message_count=message_count,
1220
+ parent_id=parent_id,
1141
1221
  ))
1142
1222
  return sessions
1143
1223
 
@@ -1800,6 +1880,58 @@ class OpencodeAgent(SqliteAgent):
1800
1880
  return [Message(role=role, content=text)
1801
1881
  for _, role, text in self._rows(session_id) if text]
1802
1882
 
1883
+ def tool_calls(self, session_id: str) -> List[dict]:
1884
+ """opencode's ``tool`` parts, in conversation order.
1885
+
1886
+ The text projection drops these, so they are exactly what a copy loses;
1887
+ exporting name and count is what lets an imported session state how much
1888
+ of its own history is missing. ``msg`` indexes the conversation the way
1889
+ :meth:`messages` numbers it, matching Claude Code's tool_calls.
1890
+ """
1891
+ conn = self.connect()
1892
+ if conn is None:
1893
+ raise SessionNotFound(self.name, session_id)
1894
+ try:
1895
+ cursor = conn.cursor()
1896
+ cursor.execute('''
1897
+ SELECT id FROM message WHERE session_id = ? ORDER BY time_created
1898
+ ''', (session_id,))
1899
+ msg_ids = [row[0] for row in cursor.fetchall()]
1900
+ out: List[dict] = []
1901
+ conv = -1
1902
+ for msg_id in msg_ids:
1903
+ cursor.execute('''
1904
+ SELECT data FROM part WHERE message_id = ? ORDER BY time_created
1905
+ ''', (msg_id,))
1906
+ has_text = False
1907
+ tools: List[dict] = []
1908
+ for (part_data,) in cursor.fetchall():
1909
+ try:
1910
+ part = json.loads(part_data)
1911
+ except (json.JSONDecodeError, TypeError):
1912
+ continue
1913
+ if not isinstance(part, dict):
1914
+ continue
1915
+ if part.get('type') == 'text' and part.get('text'):
1916
+ has_text = True
1917
+ elif part.get('type') == 'tool':
1918
+ tools.append(part)
1919
+ if has_text:
1920
+ conv += 1
1921
+ for part in tools:
1922
+ state = part.get('state')
1923
+ state = state if isinstance(state, dict) else {}
1924
+ out.append({
1925
+ 'name': str(part.get('tool') or 'tool'),
1926
+ 'summary': _summarize_args(state.get('input') or {}),
1927
+ 'msg': max(conv, 0),
1928
+ })
1929
+ return out
1930
+ except sqlite3.Error:
1931
+ return []
1932
+ finally:
1933
+ conn.close()
1934
+
1803
1935
  def raw_records(self, session_id: str) -> List[dict]:
1804
1936
  """Each message's verbatim `message.data` plus its `part` rows, in order."""
1805
1937
  conn = self.connect()