ctxttools 0.4.1__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.1/ctxttools.egg-info → ctxttools-0.4.2}/PKG-INFO +13 -3
- ctxttools-0.4.1/PKG-INFO → ctxttools-0.4.2/README.md +11 -20
- {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/agents.py +122 -1
- {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/ccat.py +25 -18
- {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/ccopy.py +24 -4
- {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/cdir.py +10 -8
- {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/ctools_mcp.py +304 -106
- ctxttools-0.4.2/ctools/resume.py +285 -0
- ctxttools-0.4.1/README.md → ctxttools-0.4.2/ctxttools.egg-info/PKG-INFO +30 -2
- {ctxttools-0.4.1 → ctxttools-0.4.2}/ctxttools.egg-info/requires.txt +1 -0
- {ctxttools-0.4.1 → ctxttools-0.4.2}/pyproject.toml +2 -2
- ctxttools-0.4.1/ctools/resume.py +0 -112
- {ctxttools-0.4.1 → ctxttools-0.4.2}/LICENSE.MIT +0 -0
- {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/__init__.py +0 -0
- {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/__main__.py +0 -0
- {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/backup.py +0 -0
- {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/cconnect.py +0 -0
- {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/cdu.py +0 -0
- {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/cextract.py +0 -0
- {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/cgrep.py +0 -0
- {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/cli.py +0 -0
- {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/config.py +0 -0
- {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/crecover.py +0 -0
- {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/crm.py +0 -0
- {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/ctools-web.py +0 -0
- {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/dispatch.py +0 -0
- {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/filterlib.py +0 -0
- {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/lib.py +0 -0
- {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/log.py +0 -0
- {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/pathfilter.py +0 -0
- {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/paths.py +0 -0
- {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/remote_search.py +0 -0
- {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/strategy.py +0 -0
- {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/testing.py +0 -0
- {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/webui.py +0 -0
- {ctxttools-0.4.1 → ctxttools-0.4.2}/ctxttools.egg-info/SOURCES.txt +0 -0
- {ctxttools-0.4.1 → ctxttools-0.4.2}/ctxttools.egg-info/dependency_links.txt +0 -0
- {ctxttools-0.4.1 → ctxttools-0.4.2}/ctxttools.egg-info/entry_points.txt +0 -0
- {ctxttools-0.4.1 → ctxttools-0.4.2}/ctxttools.egg-info/top_level.txt +0 -0
- {ctxttools-0.4.1 → 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
|
|
@@ -210,7 +211,7 @@ ctools grep --hosts "mcp config" # also the hosts in ctools.toml
|
|
|
210
211
|
|
|
211
212
|
```bash
|
|
212
213
|
ctools cat opencode/ses_abc # JSON list of {role, content}
|
|
213
|
-
ctools cat --text opencode/ses_abc # a
|
|
214
|
+
ctools cat --text opencode/ses_abc # a markdown transcript (**role**:/content)
|
|
214
215
|
ctools cat ssh://me@box/pi/<id> # a session on another machine
|
|
215
216
|
```
|
|
216
217
|
|
|
@@ -239,11 +240,20 @@ in, and `exec`s the command (replacing ctools). It's the one verb that
|
|
|
239
240
|
"leaves ctools".
|
|
240
241
|
|
|
241
242
|
```bash
|
|
243
|
+
ctools resume claude-code/55b9c8b7-788b-4f9e-a8b7-cfa0461b357b # a cdir row, pasted
|
|
242
244
|
ctools resume claude-code a293cac2 # cd + exec `claude --resume a293cac2`
|
|
243
245
|
ctools resume pi <id> # `pi --session <id>`
|
|
244
|
-
ctools
|
|
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
|
|
245
248
|
```
|
|
246
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
|
+
|
|
247
257
|
The resume command comes from the agent backend and is overridable per
|
|
248
258
|
machine in `~/.config/ctools/resume-commands.json`. The `cd` into the
|
|
249
259
|
recorded directory matters: `claude --resume` only finds a session from the
|
|
@@ -1,21 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: ctxttools
|
|
3
|
-
Version: 0.4.1
|
|
4
|
-
Summary: CLI tools for LLM conversations
|
|
5
|
-
Requires-Python: >=3.10
|
|
6
|
-
Description-Content-Type: text/markdown
|
|
7
|
-
License-File: LICENSE.MIT
|
|
8
|
-
Requires-Dist: rich>=13.0.0
|
|
9
|
-
Requires-Dist: tiktoken>=0.5.0
|
|
10
|
-
Requires-Dist: structlog>=24.0
|
|
11
|
-
Requires-Dist: platformdirs>=3.0.0
|
|
12
|
-
Requires-Dist: tomli>=2.0.0; python_version < "3.11"
|
|
13
|
-
Provides-Extra: mcp
|
|
14
|
-
Requires-Dist: mcp<2.0,>=1.2.0; extra == "mcp"
|
|
15
|
-
Provides-Extra: dev
|
|
16
|
-
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
17
|
-
Dynamic: license-file
|
|
18
|
-
|
|
19
1
|
# ctools
|
|
20
2
|
|
|
21
3
|
<p align="center">
|
|
@@ -210,7 +192,7 @@ ctools grep --hosts "mcp config" # also the hosts in ctools.toml
|
|
|
210
192
|
|
|
211
193
|
```bash
|
|
212
194
|
ctools cat opencode/ses_abc # JSON list of {role, content}
|
|
213
|
-
ctools cat --text opencode/ses_abc # a
|
|
195
|
+
ctools cat --text opencode/ses_abc # a markdown transcript (**role**:/content)
|
|
214
196
|
ctools cat ssh://me@box/pi/<id> # a session on another machine
|
|
215
197
|
```
|
|
216
198
|
|
|
@@ -239,11 +221,20 @@ in, and `exec`s the command (replacing ctools). It's the one verb that
|
|
|
239
221
|
"leaves ctools".
|
|
240
222
|
|
|
241
223
|
```bash
|
|
224
|
+
ctools resume claude-code/55b9c8b7-788b-4f9e-a8b7-cfa0461b357b # a cdir row, pasted
|
|
242
225
|
ctools resume claude-code a293cac2 # cd + exec `claude --resume a293cac2`
|
|
243
226
|
ctools resume pi <id> # `pi --session <id>`
|
|
244
|
-
ctools
|
|
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
|
|
245
229
|
```
|
|
246
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
|
+
|
|
247
238
|
The resume command comes from the agent backend and is overridable per
|
|
248
239
|
machine in `~/.config/ctools/resume-commands.json`. The `cd` into the
|
|
249
240
|
recorded directory matters: `claude --resume` only finds a session from the
|
|
@@ -510,12 +510,15 @@ class Agent:
|
|
|
510
510
|
"""
|
|
511
511
|
return self._session_cwd(session_id)
|
|
512
512
|
|
|
513
|
-
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:
|
|
514
515
|
"""Import a common-format envelope into a NEW session; return its id.
|
|
515
516
|
|
|
516
517
|
``cwd`` (or ``doc['cwd']``) seeds the session for that working
|
|
517
518
|
directory when the agent records one, so the copy lands in the
|
|
518
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`).
|
|
519
522
|
"""
|
|
520
523
|
context = doc.get('context') or []
|
|
521
524
|
if not isinstance(context, list):
|
|
@@ -527,8 +530,74 @@ class Agent:
|
|
|
527
530
|
# If the envelope carries verbatim records *for this agent* (a same-
|
|
528
531
|
# agent round trip), replay them; otherwise project the portable spine.
|
|
529
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]
|
|
530
537
|
return self.create_session(messages, cwd=cwd)
|
|
531
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
|
+
|
|
532
601
|
def _from_common_messages(self, doc: Dict[str, object],
|
|
533
602
|
context: List[dict]) -> List[Message]:
|
|
534
603
|
"""Build the Message list a common import seeds from.
|
|
@@ -1811,6 +1880,58 @@ class OpencodeAgent(SqliteAgent):
|
|
|
1811
1880
|
return [Message(role=role, content=text)
|
|
1812
1881
|
for _, role, text in self._rows(session_id) if text]
|
|
1813
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
|
+
|
|
1814
1935
|
def raw_records(self, session_id: str) -> List[dict]:
|
|
1815
1936
|
"""Each message's verbatim `message.data` plus its `part` rows, in order."""
|
|
1816
1937
|
conn = self.connect()
|
|
@@ -10,13 +10,13 @@ By default the output is a JSON list of {role, content} objects (one array per
|
|
|
10
10
|
session) — the llcat/OpenAI spine — so it is importable and pipeable: feed it to
|
|
11
11
|
jq, to another tool, or back into a session. Tool calls are interleaved as
|
|
12
12
|
{role: "tool"} entries after the turn that made them. Use --text for a
|
|
13
|
-
|
|
13
|
+
model-readable markdown transcript (each message as '**role**:' then content), or --raw for the lossless envelope (the context plus
|
|
14
14
|
the source agent's verbatim records, its tool calls, and session metadata).
|
|
15
15
|
|
|
16
16
|
Usage:
|
|
17
17
|
ccat opencode/ses_abc123 # print one conversation as JSON
|
|
18
18
|
ccat opencode/ses_a opencode/ses_b # several, each a JSON array
|
|
19
|
-
ccat --text opencode/ses_abc #
|
|
19
|
+
ccat --text opencode/ses_abc # markdown transcript (**role**: / content)
|
|
20
20
|
ccat --raw opencode/ses_abc > session.json # lossless envelope
|
|
21
21
|
ccat ssh://chris@remote/opencode/ses_a # a conversation on another host
|
|
22
22
|
ccat opencode/ses_abc | jq . # it is JSON, so pipe it anywhere
|
|
@@ -60,24 +60,30 @@ _DEFAULT_ROLE = ('msg', 'bold')
|
|
|
60
60
|
|
|
61
61
|
|
|
62
62
|
def _render_transcript(messages: List[dict], color: bool) -> None:
|
|
63
|
-
"""Print one conversation as a readable transcript
|
|
64
|
-
|
|
65
|
-
Each message becomes
|
|
66
|
-
|
|
63
|
+
"""Print one conversation as a model-readable markdown transcript.
|
|
64
|
+
|
|
65
|
+
Each message becomes ``**<role>**:\n<content>`` — the role in bold
|
|
66
|
+
markdown (so it is parseable for a model, not just a human) followed by a
|
|
67
|
+
blank line. When ``color`` is set the role line is styled via a rich
|
|
68
|
+
console (``force_terminal`` so ``--color always`` works even when piped).
|
|
69
|
+
Content is printed as-is: markup and syntax highlighting are off so a
|
|
70
|
+
transcript that itself contains ``**`` or ``[red]`` is not mangled.
|
|
67
71
|
"""
|
|
68
|
-
|
|
72
|
+
if not color:
|
|
73
|
+
for m in messages:
|
|
74
|
+
role = m.get('role', '')
|
|
75
|
+
label, _style = _ROLE.get(role, _DEFAULT_ROLE)
|
|
76
|
+
print(f'**{label}**:')
|
|
77
|
+
print(m.get('content', ''))
|
|
78
|
+
print()
|
|
79
|
+
return
|
|
80
|
+
console = Console(force_terminal=True, highlight=False, markup=False)
|
|
69
81
|
for m in messages:
|
|
70
82
|
role = m.get('role', '')
|
|
71
83
|
label, style = _ROLE.get(role, _DEFAULT_ROLE)
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
line.append(f'{label:<{width}}', style=style)
|
|
76
|
-
line.append(' ')
|
|
77
|
-
line.append(m.get('content', ''))
|
|
78
|
-
print(line)
|
|
79
|
-
else:
|
|
80
|
-
print(f' {label:<{width}} {m.get("content", "")}')
|
|
84
|
+
console.print(Text(f'**{label}**:', style=style))
|
|
85
|
+
console.print(m.get('content', ''))
|
|
86
|
+
console.print()
|
|
81
87
|
|
|
82
88
|
|
|
83
89
|
def _merge_tool_calls(messages: List[dict],
|
|
@@ -228,7 +234,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
228
234
|
"(one array per session) — the llcat/OpenAI spine — so it "
|
|
229
235
|
"can be imported or piped to another tool. Tool calls are "
|
|
230
236
|
"interleaved as {role: 'tool'} entries after the turn that "
|
|
231
|
-
"made them. Pass --text for a
|
|
237
|
+
"made them. Pass --text for a markdown transcript instead, "
|
|
232
238
|
"or --raw for the lossless envelope (the context plus the "
|
|
233
239
|
"source agent's verbatim records, its tool calls, and "
|
|
234
240
|
"session metadata). Several sessions print in order, "
|
|
@@ -239,7 +245,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
239
245
|
help="One or more sessions to print (agent/session_id, "
|
|
240
246
|
"or ssh://[user@]host[:port]/agent/session_id)")
|
|
241
247
|
parser.add_argument("-t", "--text", action="store_true", dest="text_out",
|
|
242
|
-
help="Print a
|
|
248
|
+
help=("Print a markdown transcript (each message as "
|
|
249
|
+
"'**role**:' then the content) instead of JSON"))
|
|
243
250
|
parser.add_argument("-r", "--raw", action="store_true", dest="raw_out",
|
|
244
251
|
help=("Print the lossless envelope: context plus the "
|
|
245
252
|
"agent's verbatim records and session metadata "
|
|
@@ -86,9 +86,13 @@ def export_common(agent: Agent, session_id: str) -> dict:
|
|
|
86
86
|
return agent.to_common(session_id)
|
|
87
87
|
|
|
88
88
|
|
|
89
|
-
def import_common(agent: Agent, doc: dict) -> str:
|
|
90
|
-
"""Import a common-format envelope into a NEW session in `agent`; return id.
|
|
91
|
-
|
|
89
|
+
def import_common(agent: Agent, doc: dict, notice: bool = True) -> str:
|
|
90
|
+
"""Import a common-format envelope into a NEW session in `agent`; return id.
|
|
91
|
+
|
|
92
|
+
``notice`` keeps the import notice that tells a resumed model its tool
|
|
93
|
+
history was not copied (see Agent.import_notice).
|
|
94
|
+
"""
|
|
95
|
+
return agent.from_common(doc, notice=notice)
|
|
92
96
|
|
|
93
97
|
|
|
94
98
|
def common_copy(source: Agent, source_id: str, destination: Agent) -> Tuple[str, int]:
|
|
@@ -536,6 +540,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
536
540
|
help="Seed the new session for the current directory "
|
|
537
541
|
"instead of the source session's working "
|
|
538
542
|
"directory")
|
|
543
|
+
parser.add_argument("--no-import-notice", action="store_true",
|
|
544
|
+
dest="no_import_notice",
|
|
545
|
+
help=("Do not append the notice that tells a copied "
|
|
546
|
+
"session its tool calls were not carried over"))
|
|
539
547
|
parser.add_argument("-v", "--verbose", action="store_true",
|
|
540
548
|
help="Verbose output")
|
|
541
549
|
version_option(parser, "ccopy")
|
|
@@ -708,7 +716,8 @@ def main(argv=None) -> int:
|
|
|
708
716
|
if dst_remote is None:
|
|
709
717
|
guard = guard_write(dst_agent, note="ccopy")
|
|
710
718
|
try:
|
|
711
|
-
new_id = import_common(dst_agent, common_doc
|
|
719
|
+
new_id = import_common(dst_agent, common_doc,
|
|
720
|
+
notice=not args.no_import_notice)
|
|
712
721
|
except BaseException:
|
|
713
722
|
guard.rollback()
|
|
714
723
|
raise
|
|
@@ -733,11 +742,22 @@ def main(argv=None) -> int:
|
|
|
733
742
|
f"write: {backup_path}[/dim]")
|
|
734
743
|
console.print(f"[dim]Restore it with: crecover restore {dst_ref} "
|
|
735
744
|
f"{backup_path}[/dim]")
|
|
745
|
+
# Ask the destination whether it actually annotated the copy, so the note
|
|
746
|
+
# below can never drift from what was written.
|
|
747
|
+
noted = (dst_remote is None and not args.no_import_notice
|
|
748
|
+
and dst_agent.import_notice(common_doc) is not None)
|
|
736
749
|
resume = get_resume_command(dst_ref, new_id)
|
|
737
750
|
if resume:
|
|
738
751
|
suffix = f" on {dst_remote.target}" if dst_remote else ""
|
|
739
752
|
console.print(f"[green]Resume it{suffix} with:[/green]")
|
|
740
753
|
console.print(f" {resume}")
|
|
754
|
+
if noted:
|
|
755
|
+
# Advisory, on stderr: stdout keeps the id/resume lines parseable.
|
|
756
|
+
err_console.print("[dim]Copied text only — the source's tool calls and "
|
|
757
|
+
"their results are not in the new session, so it opens "
|
|
758
|
+
"with a notice telling the model to re-read files "
|
|
759
|
+
"rather than trust that narration (--no-import-notice "
|
|
760
|
+
"to suppress).[/dim]")
|
|
741
761
|
return 0
|
|
742
762
|
|
|
743
763
|
|
|
@@ -40,7 +40,7 @@ from ctools.lib import format_datetime, format_size, get_formatter, install_clea
|
|
|
40
40
|
from ctools.pathfilter import (canonical_dir as _canonical_dir,
|
|
41
41
|
looks_like_path as _looks_like_path,
|
|
42
42
|
session_matches_dir as _session_path_matches)
|
|
43
|
-
from ctools.resume import _resolve as _resume_resolve
|
|
43
|
+
from ctools.resume import _resolve as _resume_resolve, parse_ref_args
|
|
44
44
|
|
|
45
45
|
__all__ = ['app']
|
|
46
46
|
|
|
@@ -908,11 +908,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
908
908
|
parser.add_argument("-o", "--output",
|
|
909
909
|
help=("Select output fields (comma-separated). "
|
|
910
910
|
"Use 'help' to list available fields."))
|
|
911
|
-
parser.add_argument("--resume-command", nargs=
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
"
|
|
915
|
-
"
|
|
911
|
+
parser.add_argument("--resume-command", nargs="+", metavar="AGENT/SESSION",
|
|
912
|
+
help=("Print the resume command for a session "
|
|
913
|
+
"(AGENT/SESSION, as listed; or AGENT SESSION) "
|
|
914
|
+
"with the cd into its recorded cwd, and exit "
|
|
915
|
+
"without exec-ing it"))
|
|
916
916
|
version_option(parser, "cdir")
|
|
917
917
|
return parser
|
|
918
918
|
|
|
@@ -925,8 +925,10 @@ def main(argv=None) -> int:
|
|
|
925
925
|
return v
|
|
926
926
|
|
|
927
927
|
if args.resume_command:
|
|
928
|
-
|
|
929
|
-
|
|
928
|
+
# Same accepted shapes as `ctools resume`, so a listed row pastes here too.
|
|
929
|
+
agent_name, session_id, err = parse_ref_args(args.resume_command)
|
|
930
|
+
if not err:
|
|
931
|
+
cmd, cwd, err = _resume_resolve(agent_name, session_id)
|
|
930
932
|
if err:
|
|
931
933
|
console.print(f"[red]{err}[/red]")
|
|
932
934
|
return 1
|