1818MIT License
1919"""
2020
21- __version__ = "1.0 .0"
21+ __version__ = "1.1 .0"
2222
2323import json
2424import os
@@ -986,6 +986,217 @@ def run_once() -> tuple[bool, bool]:
986986 return success , had_messages
987987
988988
989+ def scaffold_adapter (name : str ):
990+ """Generate a new communication adapter from a template."""
991+ COMMS_DIR .mkdir (exist_ok = True )
992+ adapter_path = COMMS_DIR / name
993+
994+ if adapter_path .exists ():
995+ print (f"Error: adapter '{ name } ' already exists at { adapter_path } " )
996+ sys .exit (1 )
997+
998+ template = f'''#!/usr/bin/env python3
999+ """
1000+ Communication adapter: { name }
1001+
1002+ Output JSON to stdout: a list of message objects.
1003+ Each message should have: source, from, date, body
1004+ Optional fields: subject, channel, priority
1005+
1006+ Exit 0 on success (even if no new messages — output empty []).
1007+ Exit non-zero on failure (alive will retry, then circuit-break after 3 failures).
1008+ """
1009+
1010+ import json
1011+ import sys
1012+ from datetime import datetime, timezone
1013+
1014+
1015+ def check_messages():
1016+ """Check for new messages and return them as a list of dicts."""
1017+ messages = []
1018+
1019+ # --- Your logic here ---
1020+ # Example: check an API, read a file, poll a service, etc.
1021+ #
1022+ # messages.append({{
1023+ # "source": "{ name } ",
1024+ # "from": "sender@example.com",
1025+ # "date": datetime.now(timezone.utc).isoformat(),
1026+ # "subject": "Optional subject line",
1027+ # "body": "The message content",
1028+ # }})
1029+
1030+ return messages
1031+
1032+
1033+ if __name__ == "__main__":
1034+ try:
1035+ msgs = check_messages()
1036+ print(json.dumps(msgs))
1037+ except Exception as e:
1038+ print(f"Adapter { name } failed: {{e}}", file=sys.stderr)
1039+ sys.exit(1)
1040+ '''
1041+
1042+ adapter_path .write_text (template )
1043+ adapter_path .chmod (0o755 )
1044+ print (f"Created adapter: { adapter_path } " )
1045+ print (f" Edit it to add your message-checking logic." )
1046+ print (f" Test it: { adapter_path } " )
1047+ print (f" Validate: python3 alive.py --test-adapters" )
1048+
1049+
1050+ def test_adapters ():
1051+ """Run all adapters in dry-run mode and validate their output."""
1052+ COMMS_DIR .mkdir (exist_ok = True )
1053+ adapters = sorted (
1054+ f for f in COMMS_DIR .iterdir ()
1055+ if f .is_file () and os .access (f , os .X_OK )
1056+ )
1057+
1058+ if not adapters :
1059+ print ("No adapters found in comms/" )
1060+ print (" Create one: python3 alive.py --scaffold-adapter my_adapter" )
1061+ return
1062+
1063+ print (f"Testing { len (adapters )} adapter(s)...\n " )
1064+ passed = 0
1065+ failed = 0
1066+
1067+ for adapter in adapters :
1068+ name = adapter .name
1069+ print (f" { name } :" )
1070+ try :
1071+ result = subprocess .run (
1072+ [str (adapter )],
1073+ capture_output = True ,
1074+ text = True ,
1075+ timeout = 30 ,
1076+ cwd = str (BASE_DIR ),
1077+ )
1078+ if result .returncode != 0 :
1079+ print (f" FAIL — exit code { result .returncode } " )
1080+ if result .stderr .strip ():
1081+ print (f" stderr: { result .stderr .strip ()[:200 ]} " )
1082+ failed += 1
1083+ continue
1084+
1085+ stdout = result .stdout .strip ()
1086+ if not stdout :
1087+ print (f" OK — no output (no new messages)" )
1088+ passed += 1
1089+ continue
1090+
1091+ data = json .loads (stdout )
1092+ if not isinstance (data , list ):
1093+ print (f" FAIL — output is { type (data ).__name__ } , expected list" )
1094+ failed += 1
1095+ continue
1096+
1097+ # Validate message structure
1098+ warnings = []
1099+ for i , msg in enumerate (data ):
1100+ if not isinstance (msg , dict ):
1101+ warnings .append (f"message[{ i } ] is { type (msg ).__name__ } , expected dict" )
1102+ continue
1103+ for field in ("source" , "body" ):
1104+ if field not in msg :
1105+ warnings .append (f"message[{ i } ] missing '{ field } '" )
1106+
1107+ tokens = estimate_tokens (stdout )
1108+ print (f" OK — { len (data )} message(s), ~{ tokens :,} tokens" )
1109+ if warnings :
1110+ for w in warnings :
1111+ print (f" WARN — { w } " )
1112+ passed += 1
1113+
1114+ except json .JSONDecodeError as e :
1115+ print (f" FAIL — invalid JSON: { e } " )
1116+ failed += 1
1117+ except subprocess .TimeoutExpired :
1118+ print (f" FAIL — timed out (>30s)" )
1119+ failed += 1
1120+ except Exception as e :
1121+ print (f" FAIL — { e } " )
1122+ failed += 1
1123+
1124+ print (f"\n { passed } passed, { failed } failed" )
1125+
1126+
1127+ def show_metrics ():
1128+ """Show a summary of session metrics from metrics.jsonl."""
1129+ if not METRICS_FILE .exists ():
1130+ print ("No metrics file found. Run at least one cycle first." )
1131+ return
1132+
1133+ entries = []
1134+ try :
1135+ for line in METRICS_FILE .read_text ().splitlines ():
1136+ if line .strip ():
1137+ entries .append (json .loads (line ))
1138+ except Exception as e :
1139+ print (f"Error reading metrics: { e } " )
1140+ return
1141+
1142+ if not entries :
1143+ print ("No metrics recorded yet." )
1144+ return
1145+
1146+ total = len (entries )
1147+ successes = sum (1 for e in entries if e .get ("success" ))
1148+ failures = total - successes
1149+ durations = [e .get ("duration_seconds" , 0 ) for e in entries ]
1150+ tokens = [e .get ("prompt_tokens_est" , 0 ) for e in entries ]
1151+ outputs = [e .get ("output_size" , 0 ) for e in entries ]
1152+
1153+ avg_dur = sum (durations ) / total if total else 0
1154+ avg_tok = sum (tokens ) / total if total else 0
1155+ avg_out = sum (outputs ) / total if total else 0
1156+
1157+ # Time range
1158+ first_ts = entries [0 ].get ("timestamp" , "?" )
1159+ last_ts = entries [- 1 ].get ("timestamp" , "?" )
1160+
1161+ # Provider/model breakdown
1162+ providers = {}
1163+ for e in entries :
1164+ p = e .get ("provider" , "?" )
1165+ providers [p ] = providers .get (p , 0 ) + 1
1166+
1167+ models = {}
1168+ for e in entries :
1169+ m = e .get ("model" , "?" )
1170+ models [m ] = models .get (m , 0 ) + 1
1171+
1172+ print (f"alive — metrics summary ({ total } sessions)" )
1173+ print (f" Period: { first_ts [:10 ]} to { last_ts [:10 ]} " )
1174+ print (f" Success rate: { successes } /{ total } ({ 100 * successes / total :.0f} %)" )
1175+ print (f" Avg duration: { avg_dur :.1f} s" )
1176+ print (f" Avg tokens: { avg_tok :,.0f} " )
1177+ print (f" Avg output: { avg_out :,.0f} chars" )
1178+ print (f" Total time: { sum (durations )/ 3600 :.1f} h" )
1179+ print ()
1180+
1181+ if len (providers ) > 1 or len (models ) > 1 :
1182+ print (" Providers:" )
1183+ for p , count in sorted (providers .items (), key = lambda x : - x [1 ]):
1184+ print (f" { p } : { count } sessions" )
1185+ print (" Models:" )
1186+ for m , count in sorted (models .items (), key = lambda x : - x [1 ]):
1187+ print (f" { m } : { count } sessions" )
1188+ print ()
1189+
1190+ # Last 5 sessions
1191+ print (" Recent sessions:" )
1192+ for e in entries [- 5 :]:
1193+ ts = e .get ("timestamp" , "?" )[:19 ]
1194+ dur = e .get ("duration_seconds" , 0 )
1195+ ok = "OK" if e .get ("success" ) else "FAIL"
1196+ tok = e .get ("prompt_tokens_est" , 0 )
1197+ print (f" { ts } { dur :>6.1f} s { tok :>6,} tok { ok } " )
1198+
1199+
9891200def check_config ():
9901201 """Validate configuration and show what would be loaded. No LLM call."""
9911202 load_env ()
@@ -1243,8 +1454,36 @@ def main():
12431454 "--dashboard-only" , action = "store_true" ,
12441455 help = "Run only the dashboard, no wake loop" ,
12451456 )
1457+ parser .add_argument (
1458+ "--scaffold-adapter" , metavar = "NAME" ,
1459+ help = "Create a new adapter template in comms/" ,
1460+ )
1461+ parser .add_argument (
1462+ "--test-adapters" , action = "store_true" ,
1463+ help = "Dry-run all adapters and validate their output" ,
1464+ )
1465+ parser .add_argument (
1466+ "--metrics" , action = "store_true" ,
1467+ help = "Show session metrics summary" ,
1468+ )
1469+ parser .add_argument (
1470+ "--soul" , metavar = "PATH" ,
1471+ help = "Use a custom soul file instead of soul.md" ,
1472+ )
12461473 args = parser .parse_args ()
12471474
1475+ if args .scaffold_adapter :
1476+ scaffold_adapter (args .scaffold_adapter )
1477+ return
1478+
1479+ if args .test_adapters :
1480+ test_adapters ()
1481+ return
1482+
1483+ if args .metrics :
1484+ show_metrics ()
1485+ return
1486+
12481487 if args .check :
12491488 check_config ()
12501489 return
@@ -1253,6 +1492,11 @@ def main():
12531492 run_demo ()
12541493 return
12551494
1495+ # Custom soul file
1496+ if args .soul :
1497+ global SOUL_FILE
1498+ SOUL_FILE = Path (args .soul ).resolve ()
1499+
12561500 load_env ()
12571501
12581502 # Dashboard mode
0 commit comments