ctxttools 0.1.0__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.
- ctools/__init__.py +4 -0
- ctools/ccopy.py +612 -0
- ctools/cdir.py +618 -0
- ctools/cdu.py +282 -0
- ctools/cgrep.py +354 -0
- ctools/ctools_mcp.py +302 -0
- ctools/lib.py +420 -0
- ctools/strategy.py +138 -0
- ctxttools-0.1.0.dist-info/METADATA +12 -0
- ctxttools-0.1.0.dist-info/RECORD +13 -0
- ctxttools-0.1.0.dist-info/WHEEL +5 -0
- ctxttools-0.1.0.dist-info/entry_points.txt +6 -0
- ctxttools-0.1.0.dist-info/top_level.txt +1 -0
ctools/__init__.py
ADDED
ctools/ccopy.py
ADDED
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
ccopy - copy concepts between sessions and concept files
|
|
4
|
+
|
|
5
|
+
Concepts are constraints, goals, preferences, observations, and references
|
|
6
|
+
that can be extracted from agent sessions and stored in JSON files.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
ccopy @opencode/ses_abc concepts/ # extract to directory (one file per concept)
|
|
10
|
+
ccopy @opencode/ses_abc concepts.json # extract to single file
|
|
11
|
+
ccopy concepts/ @opencode/ses_abc # inject all concepts from directory
|
|
12
|
+
ccopy constraints.json @opencode/ses_abc # inject from file
|
|
13
|
+
ccopy @opencode/ses_abc @claude/ses_xyz # copy concepts between sessions
|
|
14
|
+
ccopy --strategy my-strategy.json @opencode/ses_abc concepts/ # use custom extraction strategy
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import sys
|
|
19
|
+
import re
|
|
20
|
+
import typer
|
|
21
|
+
import hashlib
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import List, Optional, Tuple
|
|
24
|
+
from rich.console import Console
|
|
25
|
+
|
|
26
|
+
from ctools.lib import AGENTS, Message, get_formatter
|
|
27
|
+
from ctools.strategy import Strategy, DEFAULT_STRATEGY
|
|
28
|
+
|
|
29
|
+
app = typer.Typer()
|
|
30
|
+
console = Console()
|
|
31
|
+
|
|
32
|
+
CONCEPT_TYPES = ("constraint", "goal", "preference", "observation", "reference")
|
|
33
|
+
|
|
34
|
+
CONCEPT_PATTERN = re.compile(
|
|
35
|
+
r"Use the following (constraint|goal|preference|observation|reference):\s*(.*)",
|
|
36
|
+
re.IGNORECASE,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def parse_args(args: List[str]) -> Tuple[List[str], List[str]]:
|
|
41
|
+
"""Split arguments into session refs (@) and concept file paths."""
|
|
42
|
+
sessions = []
|
|
43
|
+
files = []
|
|
44
|
+
for arg in args:
|
|
45
|
+
if arg.startswith("@"):
|
|
46
|
+
sessions.append(arg[1:])
|
|
47
|
+
else:
|
|
48
|
+
files.append(arg)
|
|
49
|
+
return sessions, files
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def extract_concepts_from_messages(messages: List[Message]) -> list:
|
|
53
|
+
"""Scan messages for concept patterns and return concept objects."""
|
|
54
|
+
concepts = []
|
|
55
|
+
seen = set()
|
|
56
|
+
for msg in messages:
|
|
57
|
+
for line in msg.content.split("\n"):
|
|
58
|
+
line = line.strip()
|
|
59
|
+
m = CONCEPT_PATTERN.match(line)
|
|
60
|
+
if m:
|
|
61
|
+
ctype = m.group(1).lower()
|
|
62
|
+
text = m.group(2).strip()
|
|
63
|
+
key = (ctype, text)
|
|
64
|
+
if key not in seen:
|
|
65
|
+
seen.add(key)
|
|
66
|
+
concepts.append({
|
|
67
|
+
"type": ctype,
|
|
68
|
+
"description": text[:50],
|
|
69
|
+
"short": text[:250],
|
|
70
|
+
"medium": text[:1000],
|
|
71
|
+
"long": text[:2500],
|
|
72
|
+
})
|
|
73
|
+
return concepts
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def concepts_to_messages(concepts: list) -> List[Message]:
|
|
77
|
+
"""Convert concept objects to a system message with concept lines."""
|
|
78
|
+
lines = []
|
|
79
|
+
for c in concepts:
|
|
80
|
+
ctype = c.get("type", "preference")
|
|
81
|
+
text = c.get("short") or c.get("medium") or c.get("long") or c.get("description", "")
|
|
82
|
+
lines.append(f"Use the following {ctype}: {text}")
|
|
83
|
+
if not lines:
|
|
84
|
+
return []
|
|
85
|
+
return [Message(role="system", content="\n".join(lines))]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def resolve_session(ref: str) -> Optional[Tuple[str, str]]:
|
|
89
|
+
"""Resolve a session reference like 'opencode/ses_abc' to (agent, session_id)."""
|
|
90
|
+
parts = ref.strip("/").split("/", 1)
|
|
91
|
+
agent = parts[0]
|
|
92
|
+
sid = parts[1] if len(parts) > 1 else None
|
|
93
|
+
|
|
94
|
+
if agent not in AGENTS:
|
|
95
|
+
console.print(f"[red]Unknown agent: {agent}[/red]")
|
|
96
|
+
console.print(f"[dim]Available: {', '.join(AGENTS.keys())}[/dim]")
|
|
97
|
+
raise typer.Exit(1)
|
|
98
|
+
|
|
99
|
+
if not sid:
|
|
100
|
+
console.print(f"[red]No session ID in {ref}[/red]")
|
|
101
|
+
raise typer.Exit(1)
|
|
102
|
+
|
|
103
|
+
return agent, sid
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def read_concepts_from_file(path: str) -> list:
|
|
107
|
+
"""Read concept JSON array from a file."""
|
|
108
|
+
p = Path(path)
|
|
109
|
+
if not p.exists():
|
|
110
|
+
console.print(f"[red]File not found: {path}[/red]")
|
|
111
|
+
raise typer.Exit(1)
|
|
112
|
+
with open(p) as f:
|
|
113
|
+
data = json.load(f)
|
|
114
|
+
if not isinstance(data, list):
|
|
115
|
+
console.print(f"[red]Expected JSON array in {path}[/red]")
|
|
116
|
+
raise typer.Exit(1)
|
|
117
|
+
return data
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def write_concepts_to_file(concepts: list, path: str):
|
|
121
|
+
"""Write concept JSON array to a file."""
|
|
122
|
+
with open(path, "w") as f:
|
|
123
|
+
json.dump(concepts, f, indent=2)
|
|
124
|
+
f.write("\n")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def concept_id(concept: dict) -> str:
|
|
128
|
+
"""Generate a stable ID for a concept based on its content."""
|
|
129
|
+
key = f"{concept.get('type', '')}:{concept.get('description', '')}:{concept.get('short', '')}"
|
|
130
|
+
return hashlib.sha256(key.encode()).hexdigest()[:16]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def write_concept_individual(concept: dict, dir_path: str):
|
|
134
|
+
"""Write a single concept to its own JSON file in a directory."""
|
|
135
|
+
p = Path(dir_path)
|
|
136
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
137
|
+
|
|
138
|
+
cid = concept_id(concept)
|
|
139
|
+
filename = f"{concept.get('type', 'unknown')}_{cid}.json"
|
|
140
|
+
filepath = p / filename
|
|
141
|
+
|
|
142
|
+
with open(filepath, "w") as f:
|
|
143
|
+
json.dump(concept, f, indent=2)
|
|
144
|
+
f.write("\n")
|
|
145
|
+
|
|
146
|
+
return filepath
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def read_concepts_from_dir(dir_path: str) -> list:
|
|
150
|
+
"""Read all concept JSON files from a directory."""
|
|
151
|
+
p = Path(dir_path)
|
|
152
|
+
if not p.exists():
|
|
153
|
+
console.print(f"[red]Directory not found: {dir_path}[/red]")
|
|
154
|
+
raise typer.Exit(1)
|
|
155
|
+
|
|
156
|
+
concepts = []
|
|
157
|
+
for f in sorted(p.glob("*.json")):
|
|
158
|
+
try:
|
|
159
|
+
with open(f) as fh:
|
|
160
|
+
data = json.load(fh)
|
|
161
|
+
if isinstance(data, dict):
|
|
162
|
+
concepts.append(data)
|
|
163
|
+
elif isinstance(data, list):
|
|
164
|
+
concepts.extend(data)
|
|
165
|
+
except json.JSONDecodeError:
|
|
166
|
+
console.print(f"[yellow]Skipping invalid JSON: {f}[/yellow]")
|
|
167
|
+
|
|
168
|
+
return concepts
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def write_concepts_individual(concepts: list, dir_path: str):
|
|
172
|
+
"""Write each concept as an individual JSON file in a directory."""
|
|
173
|
+
for concept in concepts:
|
|
174
|
+
write_concept_individual(concept, dir_path)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def load_strategy(strategy_path: Optional[str] = None) -> Strategy:
|
|
178
|
+
"""Load a strategy, or return the default."""
|
|
179
|
+
if strategy_path:
|
|
180
|
+
return Strategy.load(strategy_path)
|
|
181
|
+
return DEFAULT_STRATEGY
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def get_session_messages(agent: str, session_id: str) -> List[Message]:
|
|
185
|
+
"""Get messages from an agent session (all roles)."""
|
|
186
|
+
agent_info = AGENTS.get(agent)
|
|
187
|
+
if not agent_info or not agent_info.base_path.exists():
|
|
188
|
+
console.print(f"[yellow]Agent {agent} not found at {agent_info.base_path}[/yellow]")
|
|
189
|
+
raise typer.Exit(1)
|
|
190
|
+
|
|
191
|
+
if agent_info.storage_format == "sqlite":
|
|
192
|
+
return _read_sqlite_messages(agent_info, session_id)
|
|
193
|
+
elif agent_info.storage_format == "jsonl":
|
|
194
|
+
return _read_jsonl_messages(agent_info, session_id)
|
|
195
|
+
elif agent_info.storage_format == "json":
|
|
196
|
+
return _read_json_messages(agent_info, session_id)
|
|
197
|
+
|
|
198
|
+
console.print(f"[red]Unsupported format: {agent_info.storage_format}[/red]")
|
|
199
|
+
raise typer.Exit(1)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _read_sqlite_messages(agent_info, session_id: str) -> List[Message]:
|
|
203
|
+
"""Read all messages from a SQLite-backed session."""
|
|
204
|
+
import sqlite3
|
|
205
|
+
|
|
206
|
+
db_path = agent_info.base_path / "opencode.db"
|
|
207
|
+
if not db_path.exists():
|
|
208
|
+
console.print(f"[red]Database not found: {db_path}[/red]")
|
|
209
|
+
raise typer.Exit(1)
|
|
210
|
+
|
|
211
|
+
conn = sqlite3.connect(str(db_path))
|
|
212
|
+
cursor = conn.cursor()
|
|
213
|
+
|
|
214
|
+
cursor.execute('''
|
|
215
|
+
SELECT m.id, m.data
|
|
216
|
+
FROM message m
|
|
217
|
+
WHERE m.session_id = ?
|
|
218
|
+
ORDER BY m.time_created
|
|
219
|
+
''', (session_id,))
|
|
220
|
+
|
|
221
|
+
messages = []
|
|
222
|
+
for msg_id, msg_data in cursor.fetchall():
|
|
223
|
+
data = json.loads(msg_data)
|
|
224
|
+
role = data.get('role', '')
|
|
225
|
+
|
|
226
|
+
cursor.execute('''
|
|
227
|
+
SELECT data FROM part
|
|
228
|
+
WHERE message_id = ?
|
|
229
|
+
ORDER BY time_created
|
|
230
|
+
''', (msg_id,))
|
|
231
|
+
|
|
232
|
+
content_parts = []
|
|
233
|
+
for (part_data,) in cursor.fetchall():
|
|
234
|
+
part = json.loads(part_data)
|
|
235
|
+
if part.get('type') == 'text':
|
|
236
|
+
content_parts.append(part.get('text', ''))
|
|
237
|
+
|
|
238
|
+
content = '\n'.join(content_parts) if content_parts else ''
|
|
239
|
+
if content:
|
|
240
|
+
messages.append(Message(role=role, content=content))
|
|
241
|
+
|
|
242
|
+
conn.close()
|
|
243
|
+
if not messages:
|
|
244
|
+
console.print(f"[yellow]Session not found: {session_id}[/yellow]")
|
|
245
|
+
raise typer.Exit(1)
|
|
246
|
+
return messages
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _read_jsonl_messages(agent_info, session_id: str) -> List[Message]:
|
|
250
|
+
"""Read all messages from a JSONL-backed session."""
|
|
251
|
+
for session_file in agent_info.base_path.glob(agent_info.session_pattern):
|
|
252
|
+
if session_file.stem == session_id:
|
|
253
|
+
messages = []
|
|
254
|
+
with open(session_file, 'r') as f:
|
|
255
|
+
for line in f:
|
|
256
|
+
try:
|
|
257
|
+
data = json.loads(line)
|
|
258
|
+
msg_type = data.get('type', '')
|
|
259
|
+
content = data.get('message', {}).get('content', '')
|
|
260
|
+
if content:
|
|
261
|
+
role = 'user' if msg_type == 'human' else msg_type
|
|
262
|
+
messages.append(Message(role=role, content=content))
|
|
263
|
+
except (json.JSONDecodeError, KeyError):
|
|
264
|
+
continue
|
|
265
|
+
if messages:
|
|
266
|
+
return messages
|
|
267
|
+
|
|
268
|
+
console.print(f"[yellow]Session not found: {session_id}[/yellow]")
|
|
269
|
+
raise typer.Exit(1)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _read_json_messages(agent_info, session_id: str) -> List[Message]:
|
|
273
|
+
"""Read all messages from a JSON-backed session."""
|
|
274
|
+
for session_file in agent_info.base_path.glob(agent_info.session_pattern):
|
|
275
|
+
if session_file.stem == session_id:
|
|
276
|
+
try:
|
|
277
|
+
with open(session_file) as f:
|
|
278
|
+
data = json.load(f)
|
|
279
|
+
messages_raw = data if isinstance(data, list) else data.get('messages', [])
|
|
280
|
+
messages = [
|
|
281
|
+
Message(role=m.get('role', ''), content=m.get('content', ''))
|
|
282
|
+
for m in messages_raw if m.get('content')
|
|
283
|
+
]
|
|
284
|
+
if messages:
|
|
285
|
+
return messages
|
|
286
|
+
except (json.JSONDecodeError, KeyError):
|
|
287
|
+
pass
|
|
288
|
+
|
|
289
|
+
console.print(f"[yellow]Session not found: {session_id}[/yellow]")
|
|
290
|
+
raise typer.Exit(1)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def inject_concepts_to_session(agent: str, session_id: str, concepts: list):
|
|
294
|
+
"""Inject concepts into an agent session as a system message."""
|
|
295
|
+
agent_info = AGENTS.get(agent)
|
|
296
|
+
if not agent_info or not agent_info.base_path.exists():
|
|
297
|
+
console.print(f"[yellow]Agent {agent} not found[/yellow]")
|
|
298
|
+
raise typer.Exit(1)
|
|
299
|
+
|
|
300
|
+
if agent_info.storage_format == "sqlite":
|
|
301
|
+
_inject_sqlite(agent_info, session_id, concepts)
|
|
302
|
+
elif agent_info.storage_format == "jsonl":
|
|
303
|
+
_inject_jsonl(agent_info, session_id, concepts)
|
|
304
|
+
elif agent_info.storage_format == "json":
|
|
305
|
+
_inject_json(agent_info, session_id, concepts)
|
|
306
|
+
else:
|
|
307
|
+
console.print(f"[red]Unsupported format: {agent_info.storage_format}[/red]")
|
|
308
|
+
raise typer.Exit(1)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _inject_sqlite(agent_info, session_id: str, concepts: list):
|
|
312
|
+
"""Inject concepts into a SQLite-backed session."""
|
|
313
|
+
import sqlite3
|
|
314
|
+
|
|
315
|
+
db_path = agent_info.base_path / "opencode.db"
|
|
316
|
+
if not db_path.exists():
|
|
317
|
+
console.print(f"[red]Database not found: {db_path}[/red]")
|
|
318
|
+
raise typer.Exit(1)
|
|
319
|
+
|
|
320
|
+
content_lines = []
|
|
321
|
+
for c in concepts:
|
|
322
|
+
ctype = c.get("type", "preference")
|
|
323
|
+
text = c.get("short") or c.get("medium") or c.get("description", "")
|
|
324
|
+
content_lines.append(f"Use the following {ctype}: {text}")
|
|
325
|
+
|
|
326
|
+
system_content = "\n".join(content_lines)
|
|
327
|
+
|
|
328
|
+
conn = sqlite3.connect(str(db_path))
|
|
329
|
+
cursor = conn.cursor()
|
|
330
|
+
|
|
331
|
+
# Find existing gabn'go system message and replace, or insert one
|
|
332
|
+
cursor.execute(
|
|
333
|
+
"SELECT id FROM message WHERE session_id = ? AND data LIKE ?",
|
|
334
|
+
(session_id, '%"role": "system"%'),
|
|
335
|
+
)
|
|
336
|
+
row = cursor.fetchone()
|
|
337
|
+
|
|
338
|
+
now_ms = int(__import__("time").time() * 1000)
|
|
339
|
+
|
|
340
|
+
if row:
|
|
341
|
+
# Update existing system message
|
|
342
|
+
msg_id = row[0]
|
|
343
|
+
data = json.dumps({"role": "system", "content": system_content})
|
|
344
|
+
cursor.execute(
|
|
345
|
+
"UPDATE message SET data = ?, time_updated = ? WHERE id = ?",
|
|
346
|
+
(data, now_ms, msg_id),
|
|
347
|
+
)
|
|
348
|
+
# Update parts
|
|
349
|
+
cursor.execute(
|
|
350
|
+
"UPDATE part SET data = ? WHERE message_id = ?",
|
|
351
|
+
(json.dumps({"type": "text", "text": system_content}), msg_id),
|
|
352
|
+
)
|
|
353
|
+
else:
|
|
354
|
+
# Insert new system message
|
|
355
|
+
msg_id = f"ccopy_{now_ms}"
|
|
356
|
+
data = json.dumps({"role": "system", "content": system_content})
|
|
357
|
+
cursor.execute(
|
|
358
|
+
"INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)",
|
|
359
|
+
(msg_id, session_id, now_ms, now_ms, data),
|
|
360
|
+
)
|
|
361
|
+
cursor.execute(
|
|
362
|
+
"INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)",
|
|
363
|
+
(f"part_{msg_id}", msg_id, session_id, now_ms, now_ms,
|
|
364
|
+
json.dumps({"type": "text", "text": system_content})),
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
conn.commit()
|
|
368
|
+
conn.close()
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _inject_jsonl(agent_info, session_id: str, concepts: list):
|
|
372
|
+
"""Inject concepts into a JSONL-backed session."""
|
|
373
|
+
content_lines = []
|
|
374
|
+
for c in concepts:
|
|
375
|
+
ctype = c.get("type", "preference")
|
|
376
|
+
text = c.get("short") or c.get("medium") or c.get("description", "")
|
|
377
|
+
content_lines.append(f"Use the following {ctype}: {text}")
|
|
378
|
+
|
|
379
|
+
system_content = "\n".join(content_lines)
|
|
380
|
+
|
|
381
|
+
# Find the session file
|
|
382
|
+
for session_file in agent_info.base_path.glob(agent_info.session_pattern):
|
|
383
|
+
if session_file.stem == session_id:
|
|
384
|
+
# Check if there's already a system message
|
|
385
|
+
lines = session_file.read_text().splitlines()
|
|
386
|
+
replaced = False
|
|
387
|
+
new_lines = []
|
|
388
|
+
for line in lines:
|
|
389
|
+
try:
|
|
390
|
+
data = json.loads(line)
|
|
391
|
+
if data.get("type") == "system":
|
|
392
|
+
data["message"]["content"] = system_content
|
|
393
|
+
new_lines.append(json.dumps(data))
|
|
394
|
+
replaced = True
|
|
395
|
+
else:
|
|
396
|
+
new_lines.append(line)
|
|
397
|
+
except (json.JSONDecodeError, KeyError):
|
|
398
|
+
new_lines.append(line)
|
|
399
|
+
|
|
400
|
+
if not replaced:
|
|
401
|
+
system_msg = json.dumps({
|
|
402
|
+
"type": "system",
|
|
403
|
+
"message": {"role": "system", "content": system_content},
|
|
404
|
+
})
|
|
405
|
+
new_lines.insert(0, system_msg)
|
|
406
|
+
|
|
407
|
+
session_file.write_text("\n".join(new_lines) + "\n")
|
|
408
|
+
return
|
|
409
|
+
|
|
410
|
+
console.print(f"[red]Session file not found for {session_id}[/red]")
|
|
411
|
+
raise typer.Exit(1)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _inject_json(agent_info, session_id: str, concepts: list):
|
|
415
|
+
"""Inject concepts into a JSON-backed session."""
|
|
416
|
+
content_lines = []
|
|
417
|
+
for c in concepts:
|
|
418
|
+
ctype = c.get("type", "preference")
|
|
419
|
+
text = c.get("short") or c.get("medium") or c.get("description", "")
|
|
420
|
+
content_lines.append(f"Use the following {ctype}: {text}")
|
|
421
|
+
|
|
422
|
+
system_content = "\n".join(content_lines)
|
|
423
|
+
|
|
424
|
+
for session_file in agent_info.base_path.glob(agent_info.session_pattern):
|
|
425
|
+
if session_file.stem == session_id:
|
|
426
|
+
with open(session_file) as f:
|
|
427
|
+
data = json.load(f)
|
|
428
|
+
|
|
429
|
+
messages = data if isinstance(data, list) else data.get("messages", [])
|
|
430
|
+
|
|
431
|
+
# Find existing system message and replace, or insert one
|
|
432
|
+
replaced = False
|
|
433
|
+
for msg in messages:
|
|
434
|
+
if msg.get("role") == "system":
|
|
435
|
+
msg["content"] = system_content
|
|
436
|
+
replaced = True
|
|
437
|
+
break
|
|
438
|
+
|
|
439
|
+
if not replaced:
|
|
440
|
+
messages.insert(0, {"role": "system", "content": system_content})
|
|
441
|
+
|
|
442
|
+
if isinstance(data, list):
|
|
443
|
+
out = messages
|
|
444
|
+
else:
|
|
445
|
+
data["messages"] = messages
|
|
446
|
+
out = data
|
|
447
|
+
|
|
448
|
+
with open(session_file, "w") as f:
|
|
449
|
+
json.dump(out, f, indent=2)
|
|
450
|
+
f.write("\n")
|
|
451
|
+
return
|
|
452
|
+
|
|
453
|
+
console.print(f"[red]Session file not found for {session_id}[/red]")
|
|
454
|
+
raise typer.Exit(1)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
@app.command()
|
|
458
|
+
def main(
|
|
459
|
+
args: List[str] = typer.Argument(..., help="Sources and destinations (@ for sessions)"),
|
|
460
|
+
fmt: str = typer.Option("default", "--format", "-f", help="Output format: json, xml, md"),
|
|
461
|
+
strategy: Optional[str] = typer.Option(None, "--strategy", "-s", help="Strategy JSON file for LLM-based extraction"),
|
|
462
|
+
):
|
|
463
|
+
"""
|
|
464
|
+
Copy concepts between sessions and concept files.
|
|
465
|
+
|
|
466
|
+
@ prefix denotes a session (agent/session_id).
|
|
467
|
+
Plain paths are concept JSON files.
|
|
468
|
+
Directories (ending with /) get one file per concept.
|
|
469
|
+
|
|
470
|
+
Examples:
|
|
471
|
+
ccopy @opencode/ses_abc concepts.json
|
|
472
|
+
ccopy @opencode/ses_abc concepts/ # one file per concept
|
|
473
|
+
ccopy constraints.json preferences.json @opencode/ses_abc
|
|
474
|
+
ccopy @opencode/ses_abc @claude/ses_xyz
|
|
475
|
+
ccopy --strategy my-strategy.json @opencode/ses_abc concepts.json
|
|
476
|
+
"""
|
|
477
|
+
sessions, files = parse_args(args)
|
|
478
|
+
|
|
479
|
+
if not sessions:
|
|
480
|
+
console.print("[red]No session references (use @ prefix)[/red]")
|
|
481
|
+
raise typer.Exit(1)
|
|
482
|
+
|
|
483
|
+
# Determine operation mode
|
|
484
|
+
has_concept_files = len(files) > 0
|
|
485
|
+
has_sessions = len(sessions) > 0
|
|
486
|
+
|
|
487
|
+
# Extract mode: session -> concept file
|
|
488
|
+
if has_sessions and has_concept_files:
|
|
489
|
+
# Check if last arg is a session (destination)
|
|
490
|
+
# If so, all concept files before it are sources
|
|
491
|
+
# Otherwise, extract from sessions to concept files
|
|
492
|
+
|
|
493
|
+
# If the last arg is a session and others are files -> inject
|
|
494
|
+
# If the last arg is a file and others are sessions -> extract
|
|
495
|
+
# If mix -> error
|
|
496
|
+
|
|
497
|
+
last_is_session = args[-1].startswith("@")
|
|
498
|
+
first_is_session = args[0].startswith("@")
|
|
499
|
+
|
|
500
|
+
if last_is_session:
|
|
501
|
+
# Inject: concept files -> session
|
|
502
|
+
dest_session = sessions[-1]
|
|
503
|
+
agent, sid = resolve_session(dest_session)
|
|
504
|
+
|
|
505
|
+
all_concepts = []
|
|
506
|
+
for f in files:
|
|
507
|
+
if f.endswith("/") or (Path(f).exists() and Path(f).is_dir()):
|
|
508
|
+
all_concepts.extend(read_concepts_from_dir(f))
|
|
509
|
+
else:
|
|
510
|
+
all_concepts.extend(read_concepts_from_file(f))
|
|
511
|
+
|
|
512
|
+
if not all_concepts:
|
|
513
|
+
console.print("[yellow]No concepts found in files[/yellow]")
|
|
514
|
+
return
|
|
515
|
+
|
|
516
|
+
inject_concepts_to_session(agent, sid, all_concepts)
|
|
517
|
+
console.print(f"[green]Injected {len(all_concepts)} concepts into {agent}/{sid}[/green]")
|
|
518
|
+
|
|
519
|
+
elif first_is_session and len(sessions) == 1 and len(files) > 0:
|
|
520
|
+
# Extract: session -> concept files
|
|
521
|
+
agent, sid = resolve_session(sessions[0])
|
|
522
|
+
messages = get_session_messages(agent, sid)
|
|
523
|
+
|
|
524
|
+
# Use strategy for extraction if provided
|
|
525
|
+
if strategy:
|
|
526
|
+
strat = load_strategy(strategy)
|
|
527
|
+
concepts = strat.extract([{"role": m.role, "content": m.content} for m in messages])
|
|
528
|
+
else:
|
|
529
|
+
concepts = extract_concepts_from_messages(messages)
|
|
530
|
+
|
|
531
|
+
if not concepts:
|
|
532
|
+
console.print("[yellow]No concepts found in session[/yellow]")
|
|
533
|
+
return
|
|
534
|
+
|
|
535
|
+
# Determine output: directory or file
|
|
536
|
+
target = files[0]
|
|
537
|
+
is_dir = target.endswith("/") or (Path(target).exists() and Path(target).is_dir())
|
|
538
|
+
|
|
539
|
+
if is_dir:
|
|
540
|
+
# Write each concept as a separate file
|
|
541
|
+
out_dir = target.rstrip("/")
|
|
542
|
+
write_concepts_individual(concepts, out_dir)
|
|
543
|
+
console.print(f"[green]Extracted {len(concepts)} concepts to {out_dir}/ ({len(concepts)} files)[/green]")
|
|
544
|
+
else:
|
|
545
|
+
# Write all concepts to a single file
|
|
546
|
+
write_concepts_to_file(concepts, target)
|
|
547
|
+
console.print(f"[green]Extracted {len(concepts)} concepts to {target}[/green]")
|
|
548
|
+
|
|
549
|
+
elif first_is_session and not last_is_session:
|
|
550
|
+
# Session-to-concept extraction
|
|
551
|
+
agent, sid = resolve_session(sessions[0])
|
|
552
|
+
messages = get_session_messages(agent, sid)
|
|
553
|
+
|
|
554
|
+
# Use strategy for extraction if provided
|
|
555
|
+
if strategy:
|
|
556
|
+
strat = load_strategy(strategy)
|
|
557
|
+
concepts = strat.extract([{"role": m.role, "content": m.content} for m in messages])
|
|
558
|
+
else:
|
|
559
|
+
concepts = extract_concepts_from_messages(messages)
|
|
560
|
+
|
|
561
|
+
if not concepts:
|
|
562
|
+
console.print("[yellow]No concepts found in session[/yellow]")
|
|
563
|
+
return
|
|
564
|
+
|
|
565
|
+
# Determine output: directory or file
|
|
566
|
+
target = files[0]
|
|
567
|
+
is_dir = target.endswith("/") or (Path(target).exists() and Path(target).is_dir())
|
|
568
|
+
|
|
569
|
+
if is_dir:
|
|
570
|
+
# Write each concept as a separate file
|
|
571
|
+
out_dir = target.rstrip("/")
|
|
572
|
+
write_concepts_individual(concepts, out_dir)
|
|
573
|
+
console.print(f"[green]Extracted {len(concepts)} concepts to {out_dir}/ ({len(concepts)} files)[/green]")
|
|
574
|
+
else:
|
|
575
|
+
write_concepts_to_file(concepts, target)
|
|
576
|
+
console.print(f"[green]Extracted {len(concepts)} concepts to {target}[/green]")
|
|
577
|
+
|
|
578
|
+
else:
|
|
579
|
+
console.print("[red]Ambiguous: mix of sessions and files[/red]")
|
|
580
|
+
raise typer.Exit(1)
|
|
581
|
+
|
|
582
|
+
# Session-to-session mode
|
|
583
|
+
elif has_sessions and len(sessions) >= 2 and not has_concept_files:
|
|
584
|
+
# First session is source, rest are destinations
|
|
585
|
+
src_session = sessions[0]
|
|
586
|
+
src_agent, src_sid = resolve_session(src_session)
|
|
587
|
+
messages = get_session_messages(src_agent, src_sid)
|
|
588
|
+
|
|
589
|
+
# Use strategy for extraction if provided
|
|
590
|
+
if strategy:
|
|
591
|
+
strat = load_strategy(strategy)
|
|
592
|
+
concepts = strat.extract([{"role": m.role, "content": m.content} for m in messages])
|
|
593
|
+
else:
|
|
594
|
+
concepts = extract_concepts_from_messages(messages)
|
|
595
|
+
|
|
596
|
+
if not concepts:
|
|
597
|
+
console.print(f"[yellow]No concepts found in {src_session}[/yellow]")
|
|
598
|
+
return
|
|
599
|
+
|
|
600
|
+
for dest_ref in sessions[1:]:
|
|
601
|
+
dest_agent, dest_sid = resolve_session(dest_ref)
|
|
602
|
+
inject_concepts_to_session(dest_agent, dest_sid, concepts)
|
|
603
|
+
console.print(f"[green]Copied {len(concepts)} concepts to {dest_agent}/{dest_sid}[/green]")
|
|
604
|
+
|
|
605
|
+
else:
|
|
606
|
+
console.print("[red]Invalid arguments[/red]")
|
|
607
|
+
console.print("[dim]Usage: ccopy @agent/sess file.json | ccopy file.json @agent/sess | ccopy @src @dst[/dim]")
|
|
608
|
+
raise typer.Exit(1)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
if __name__ == "__main__":
|
|
612
|
+
app()
|