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.
- {ctxttools-0.4.0 → ctxttools-0.4.2}/PKG-INFO +45 -7
- {ctxttools-0.4.0 → ctxttools-0.4.2}/README.md +43 -6
- {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/agents.py +133 -1
- {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/ccat.py +104 -57
- {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/ccopy.py +122 -48
- {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/cdir.py +23 -56
- {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/cgrep.py +155 -22
- {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/cli.py +11 -0
- ctxttools-0.4.2/ctools/ctools_mcp.py +848 -0
- {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/dispatch.py +2 -1
- ctxttools-0.4.2/ctools/pathfilter.py +91 -0
- {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/remote_search.py +2 -4
- ctxttools-0.4.2/ctools/resume.py +285 -0
- {ctxttools-0.4.0 → ctxttools-0.4.2}/ctxttools.egg-info/PKG-INFO +45 -7
- {ctxttools-0.4.0 → ctxttools-0.4.2}/ctxttools.egg-info/SOURCES.txt +2 -0
- {ctxttools-0.4.0 → ctxttools-0.4.2}/ctxttools.egg-info/requires.txt +1 -0
- {ctxttools-0.4.0 → ctxttools-0.4.2}/pyproject.toml +2 -2
- ctxttools-0.4.0/ctools/ctools_mcp.py +0 -468
- {ctxttools-0.4.0 → ctxttools-0.4.2}/LICENSE.MIT +0 -0
- {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/__init__.py +0 -0
- {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/__main__.py +0 -0
- {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/backup.py +0 -0
- {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/cconnect.py +0 -0
- {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/cdu.py +0 -0
- {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/cextract.py +0 -0
- {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/config.py +0 -0
- {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/crecover.py +0 -0
- {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/crm.py +0 -0
- {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/ctools-web.py +0 -0
- {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/filterlib.py +0 -0
- {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/lib.py +0 -0
- {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/log.py +0 -0
- {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/paths.py +0 -0
- {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/strategy.py +0 -0
- {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/testing.py +0 -0
- {ctxttools-0.4.0 → ctxttools-0.4.2}/ctools/webui.py +0 -0
- {ctxttools-0.4.0 → ctxttools-0.4.2}/ctxttools.egg-info/dependency_links.txt +0 -0
- {ctxttools-0.4.0 → ctxttools-0.4.2}/ctxttools.egg-info/entry_points.txt +0 -0
- {ctxttools-0.4.0 → ctxttools-0.4.2}/ctxttools.egg-info/top_level.txt +0 -0
- {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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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()
|