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 ADDED
@@ -0,0 +1,4 @@
1
+ """ctools - CLI tools for LLM context windows.
2
+
3
+ Provides cdir (ls for context windows) and cgrep (grep for context windows).
4
+ """
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()