8
8
"""
9
9
10
10
import argparse
11
+ import atexit
11
12
import cmd
12
13
import concurrent .futures
13
14
import dataclasses
15
+ import datetime
14
16
import json
15
17
import os
16
18
import re
17
19
import shlex
18
20
import subprocess
19
21
import sys
20
22
import tempfile
23
+ import traceback
21
24
from ast import literal_eval
22
25
from textwrap import shorten
23
26
from typing import (
27
+ Any ,
24
28
Callable ,
25
29
Dict ,
26
30
List ,
@@ -958,7 +962,12 @@ def do_unsticky(self, arg):
958
962
mp = "message" if len (t ) == 1 else "messages"
959
963
print (f"{ len (t )} { mp } unstickied" )
960
964
961
- def do_save (self , arg ):
965
+ def do_save (
966
+ self ,
967
+ arg : str ,
968
+ _extra_metadata : Optional [Dict [str , Any ]] = None ,
969
+ _print_on_success : bool = True ,
970
+ ):
962
971
"""
963
972
Save all named threads to the specified json file. With no argument,
964
973
save to the most recently loaded/saved JSON file in this session.
@@ -984,7 +993,11 @@ def do_save(self, arg):
984
993
else :
985
994
path = args [0 ]
986
995
res = {}
987
- res ["_meta" ] = {"version" : __version__ }
996
+ if _extra_metadata is None :
997
+ res ["_meta" ] = {}
998
+ else :
999
+ res ["_meta" ] = _extra_metadata .copy ()
1000
+ res ["_meta" ]["version" ] = __version__
988
1001
res ["threads" ] = {k : v .to_dict () for k , v in self ._threads .items ()}
989
1002
try :
990
1003
with open (path , "w" , encoding = "utf-8" ) as cam :
@@ -994,7 +1007,8 @@ def do_save(self, arg):
994
1007
return
995
1008
for thread in self ._threads .values ():
996
1009
thread .dirty = False
997
- print (f"{ os .path .abspath (path )} saved" )
1010
+ if _print_on_success :
1011
+ print (f"{ os .path .abspath (path )} saved" )
998
1012
self .last_path = path
999
1013
1000
1014
def do_load (self , arg , _print_on_success = True ):
@@ -1282,6 +1296,48 @@ def do_quit(self, arg):
1282
1296
return can_exit # Truthy return values cause the cmdloop to stop
1283
1297
1284
1298
1299
+ def _write_crash_dump (shell : Gptcmd , exc : Exception ) -> Optional [str ]:
1300
+ """
1301
+ Serialize the current shell into a JSON file and return its absolute
1302
+ path.
1303
+ """
1304
+ detached_added = False
1305
+ try :
1306
+ ts = (
1307
+ datetime .datetime .now ()
1308
+ .isoformat (timespec = "seconds" )
1309
+ .replace (":" , "-" )
1310
+ )
1311
+ filename = f"gptcmd-{ ts } .json"
1312
+ tb_text = "" .join (
1313
+ traceback .format_exception (type (exc ), exc , exc .__traceback__ )
1314
+ )
1315
+ if shell ._detached :
1316
+ original_dirty = shell ._detached .dirty
1317
+ detached_base = "__detached__"
1318
+ detached_key = detached_base
1319
+ i = 1
1320
+ while detached_key in shell ._threads :
1321
+ i += 1
1322
+ detached_key = f"{ detached_base } { i } "
1323
+ shell ._detached .dirty = False
1324
+ shell ._threads [detached_key ] = shell ._detached
1325
+ detached_added = True
1326
+ shell .do_save (
1327
+ filename ,
1328
+ _extra_metadata = {"crash_traceback" : tb_text },
1329
+ _print_on_success = False ,
1330
+ )
1331
+ return os .path .abspath (filename )
1332
+ except Exception as e :
1333
+ print (f"Failed to write crash dump: { e } " , file = sys .stderr )
1334
+ return None
1335
+ finally :
1336
+ if detached_added :
1337
+ shell ._detached .dirty = original_dirty
1338
+ shell ._threads .pop (detached_key , None )
1339
+
1340
+
1285
1341
def main () -> bool :
1286
1342
"""
1287
1343
Setuptools requires a callable entry point to build an installable script
@@ -1336,7 +1392,25 @@ def main() -> bool:
1336
1392
shell .do_account (args .account , _print_on_success = False )
1337
1393
if args .model :
1338
1394
shell .do_model (args .model , _print_on_success = False )
1339
- shell .cmdloop ()
1395
+ try :
1396
+ shell .cmdloop ()
1397
+ except Exception as e :
1398
+ # Does any thread contain messages?
1399
+ should_save = (shell ._detached and shell ._detached .dirty ) or any (
1400
+ t and t .dirty for t in shell ._threads .values ()
1401
+ )
1402
+ if should_save :
1403
+ dump_path = _write_crash_dump (shell , e )
1404
+ if dump_path :
1405
+ # Hack: Print the "crash dump" notice after the traceback
1406
+ atexit .register (
1407
+ lambda p = dump_path : print (
1408
+ f"Crash dump written to { p } " ,
1409
+ file = sys .stderr ,
1410
+ flush = True ,
1411
+ )
1412
+ )
1413
+ raise
1340
1414
return True
1341
1415
1342
1416
0 commit comments