aline-ai 0.5.3__py3-none-any.whl → 0.5.5__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.
Files changed (80) hide show
  1. {aline_ai-0.5.3.dist-info → aline_ai-0.5.5.dist-info}/METADATA +1 -1
  2. aline_ai-0.5.5.dist-info/RECORD +93 -0
  3. realign/__init__.py +1 -1
  4. realign/adapters/antigravity.py +28 -20
  5. realign/adapters/base.py +46 -50
  6. realign/adapters/claude.py +14 -14
  7. realign/adapters/codex.py +7 -7
  8. realign/adapters/gemini.py +11 -11
  9. realign/adapters/registry.py +14 -10
  10. realign/claude_detector.py +2 -2
  11. realign/claude_hooks/__init__.py +3 -3
  12. realign/claude_hooks/permission_request_hook.py +35 -0
  13. realign/claude_hooks/permission_request_hook_installer.py +31 -32
  14. realign/claude_hooks/stop_hook.py +4 -1
  15. realign/claude_hooks/stop_hook_installer.py +30 -31
  16. realign/cli.py +24 -0
  17. realign/codex_detector.py +11 -11
  18. realign/commands/add.py +361 -35
  19. realign/commands/config.py +3 -12
  20. realign/commands/context.py +3 -1
  21. realign/commands/export_shares.py +86 -127
  22. realign/commands/import_shares.py +145 -155
  23. realign/commands/init.py +166 -30
  24. realign/commands/restore.py +18 -6
  25. realign/commands/search.py +14 -42
  26. realign/commands/upgrade.py +155 -11
  27. realign/commands/watcher.py +98 -219
  28. realign/commands/worker.py +29 -6
  29. realign/config.py +25 -20
  30. realign/context.py +1 -3
  31. realign/dashboard/app.py +4 -4
  32. realign/dashboard/screens/create_event.py +3 -1
  33. realign/dashboard/screens/event_detail.py +14 -6
  34. realign/dashboard/screens/session_detail.py +3 -1
  35. realign/dashboard/screens/share_import.py +7 -3
  36. realign/dashboard/tmux_manager.py +91 -22
  37. realign/dashboard/widgets/config_panel.py +85 -1
  38. realign/dashboard/widgets/events_table.py +3 -1
  39. realign/dashboard/widgets/header.py +1 -0
  40. realign/dashboard/widgets/search_panel.py +37 -27
  41. realign/dashboard/widgets/sessions_table.py +24 -15
  42. realign/dashboard/widgets/terminal_panel.py +207 -17
  43. realign/dashboard/widgets/watcher_panel.py +6 -2
  44. realign/dashboard/widgets/worker_panel.py +10 -1
  45. realign/db/__init__.py +1 -1
  46. realign/db/base.py +5 -15
  47. realign/db/locks.py +0 -1
  48. realign/db/migration.py +82 -76
  49. realign/db/schema.py +2 -6
  50. realign/db/sqlite_db.py +23 -41
  51. realign/events/__init__.py +0 -1
  52. realign/events/event_summarizer.py +27 -15
  53. realign/events/session_summarizer.py +29 -15
  54. realign/file_lock.py +1 -0
  55. realign/hooks.py +150 -60
  56. realign/logging_config.py +12 -15
  57. realign/mcp_server.py +30 -51
  58. realign/mcp_watcher.py +0 -1
  59. realign/models/event.py +29 -20
  60. realign/prompts/__init__.py +7 -7
  61. realign/prompts/presets.py +15 -11
  62. realign/redactor.py +99 -59
  63. realign/triggers/__init__.py +9 -9
  64. realign/triggers/antigravity_trigger.py +30 -28
  65. realign/triggers/base.py +4 -3
  66. realign/triggers/claude_trigger.py +104 -85
  67. realign/triggers/codex_trigger.py +15 -5
  68. realign/triggers/gemini_trigger.py +57 -47
  69. realign/triggers/next_turn_trigger.py +3 -1
  70. realign/triggers/registry.py +6 -2
  71. realign/triggers/turn_status.py +3 -1
  72. realign/watcher_core.py +306 -131
  73. realign/watcher_daemon.py +8 -8
  74. realign/worker_core.py +3 -1
  75. realign/worker_daemon.py +3 -1
  76. aline_ai-0.5.3.dist-info/RECORD +0 -93
  77. {aline_ai-0.5.3.dist-info → aline_ai-0.5.5.dist-info}/WHEEL +0 -0
  78. {aline_ai-0.5.3.dist-info → aline_ai-0.5.5.dist-info}/entry_points.txt +0 -0
  79. {aline_ai-0.5.3.dist-info → aline_ai-0.5.5.dist-info}/licenses/LICENSE +0 -0
  80. {aline_ai-0.5.3.dist-info → aline_ai-0.5.5.dist-info}/top_level.txt +0 -0
@@ -34,7 +34,7 @@ class ClaudeTrigger(TurnTrigger):
34
34
  def __init__(self, config: Optional[Dict] = None):
35
35
  super().__init__(config)
36
36
  # Retry detection config
37
- self.retry_time_window = config.get('retry_time_window', 120) if config else 120
37
+ self.retry_time_window = config.get("retry_time_window", 120) if config else 120
38
38
 
39
39
  def get_supported_formats(self) -> List[str]:
40
40
  return ["claude_code"]
@@ -42,7 +42,7 @@ class ClaudeTrigger(TurnTrigger):
42
42
  def detect_session_format(self, session_file: Path) -> Optional[str]:
43
43
  """Detect if this is a Claude Code session file."""
44
44
  try:
45
- with open(session_file, 'r', encoding='utf-8') as f:
45
+ with open(session_file, "r", encoding="utf-8") as f:
46
46
  for i, line in enumerate(f):
47
47
  if i >= 10:
48
48
  break
@@ -84,7 +84,7 @@ class ClaudeTrigger(TurnTrigger):
84
84
  return "request interrupted by user" in text.strip().lower()
85
85
 
86
86
  try:
87
- with open(session_file, 'r', encoding='utf-8') as f:
87
+ with open(session_file, "r", encoding="utf-8") as f:
88
88
  for line_no, line in enumerate(f, 1):
89
89
  line = line.strip()
90
90
  if not line:
@@ -119,20 +119,25 @@ class ClaudeTrigger(TurnTrigger):
119
119
 
120
120
  if isinstance(content, list):
121
121
  text_items = [
122
- item.get("text", "") for item in content
122
+ item.get("text", "")
123
+ for item in content
123
124
  if isinstance(item, dict) and item.get("type") == "text"
124
125
  ]
125
- if text_items and all(_is_interrupt_placeholder(t) for t in text_items):
126
+ if text_items and all(
127
+ _is_interrupt_placeholder(t) for t in text_items
128
+ ):
126
129
  continue
127
130
 
128
131
  if not is_tool_result:
129
- messages.append({
130
- 'line_no': line_no,
131
- 'uuid': data.get('uuid'),
132
- 'parent_uuid': data.get('parentUuid'),
133
- 'timestamp': data.get('timestamp'),
134
- 'content': content
135
- })
132
+ messages.append(
133
+ {
134
+ "line_no": line_no,
135
+ "uuid": data.get("uuid"),
136
+ "parent_uuid": data.get("parentUuid"),
137
+ "timestamp": data.get("timestamp"),
138
+ "content": content,
139
+ }
140
+ )
136
141
 
137
142
  except json.JSONDecodeError:
138
143
  continue
@@ -151,27 +156,27 @@ class ClaudeTrigger(TurnTrigger):
151
156
  visited = set()
152
157
  current = msg
153
158
 
154
- while current['parent_uuid'] and current['parent_uuid'] in uuid_to_msg:
155
- if current['uuid'] in visited:
159
+ while current["parent_uuid"] and current["parent_uuid"] in uuid_to_msg:
160
+ if current["uuid"] in visited:
156
161
  break
157
- visited.add(current['uuid'])
158
- current = uuid_to_msg[current['parent_uuid']]
162
+ visited.add(current["uuid"])
163
+ current = uuid_to_msg[current["parent_uuid"]]
159
164
 
160
165
  return current
161
166
 
162
167
  def _group_by_root_timestamp(self, messages: List[Dict]) -> Dict[str, List[Dict]]:
163
168
  """Group messages by their root's timestamp."""
164
- uuid_to_msg = {m['uuid']: m for m in messages}
169
+ uuid_to_msg = {m["uuid"]: m for m in messages}
165
170
 
166
171
  for msg in messages:
167
172
  root = self._find_root(msg, uuid_to_msg)
168
- msg['root_uuid'] = root['uuid']
169
- msg['root_timestamp'] = root['timestamp']
173
+ msg["root_uuid"] = root["uuid"]
174
+ msg["root_timestamp"] = root["timestamp"]
170
175
 
171
176
  groups = defaultdict(list)
172
177
  for msg in messages:
173
- if msg['root_timestamp']:
174
- groups[msg['root_timestamp']].append(msg)
178
+ if msg["root_timestamp"]:
179
+ groups[msg["root_timestamp"]].append(msg)
175
180
 
176
181
  return dict(groups)
177
182
 
@@ -183,22 +188,22 @@ class ClaudeTrigger(TurnTrigger):
183
188
  """Compute hash of message content for duplicate detection."""
184
189
  texts = []
185
190
  for msg in messages:
186
- content = msg.get('content', [])
191
+ content = msg.get("content", [])
187
192
  if isinstance(content, str):
188
193
  texts.append(content)
189
194
  elif isinstance(content, list):
190
195
  for item in content:
191
- if isinstance(item, dict) and item.get('type') == 'text':
192
- texts.append(item.get('text', ''))
196
+ if isinstance(item, dict) and item.get("type") == "text":
197
+ texts.append(item.get("text", ""))
193
198
 
194
- combined = '|'.join(texts)
199
+ combined = "|".join(texts)
195
200
  return hashlib.md5(combined.encode()).hexdigest()
196
201
 
197
202
  def _parse_timestamp(self, timestamp: str) -> Optional[datetime]:
198
203
  if not timestamp:
199
204
  return None
200
205
  try:
201
- return datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
206
+ return datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
202
207
  except Exception:
203
208
  return None
204
209
 
@@ -206,17 +211,17 @@ class ClaudeTrigger(TurnTrigger):
206
211
  dt1 = self._parse_timestamp(ts1)
207
212
  dt2 = self._parse_timestamp(ts2)
208
213
  if not dt1 or not dt2:
209
- return float('inf')
214
+ return float("inf")
210
215
  return abs((dt2 - dt1).total_seconds())
211
216
 
212
217
  def _is_retry(self, group1: Dict, group2: Dict) -> bool:
213
218
  """Check if group2 is a retry of group1 (same content within time window)."""
214
- time_diff = self._time_diff_seconds(group1['timestamp'], group2['timestamp'])
219
+ time_diff = self._time_diff_seconds(group1["timestamp"], group2["timestamp"])
215
220
  if time_diff > self.retry_time_window:
216
221
  return False
217
222
 
218
- hash1 = self._get_content_hash(group1['messages'])
219
- hash2 = self._get_content_hash(group2['messages'])
223
+ hash1 = self._get_content_hash(group1["messages"])
224
+ hash2 = self._get_content_hash(group2["messages"])
220
225
 
221
226
  return hash1 == hash2
222
227
 
@@ -225,7 +230,7 @@ class ClaudeTrigger(TurnTrigger):
225
230
  if not groups:
226
231
  return []
227
232
 
228
- sorted_groups = sorted(groups, key=lambda g: g['timestamp'] or '')
233
+ sorted_groups = sorted(groups, key=lambda g: g["timestamp"] or "")
229
234
  merged = []
230
235
  i = 0
231
236
 
@@ -243,19 +248,19 @@ class ClaudeTrigger(TurnTrigger):
243
248
 
244
249
  if len(retry_groups) > 1:
245
250
  merged_group = {
246
- 'timestamp': retry_groups[0]['timestamp'],
247
- 'messages': [],
248
- 'lines': [],
249
- 'retry_count': len(retry_groups),
250
- 'parent_chains': sum(g.get('parent_chains', 1) for g in retry_groups)
251
+ "timestamp": retry_groups[0]["timestamp"],
252
+ "messages": [],
253
+ "lines": [],
254
+ "retry_count": len(retry_groups),
255
+ "parent_chains": sum(g.get("parent_chains", 1) for g in retry_groups),
251
256
  }
252
257
  for g in retry_groups:
253
- merged_group['messages'].extend(g['messages'])
254
- merged_group['lines'].extend(g['lines'])
255
- merged_group['lines'] = sorted(set(merged_group['lines']))
258
+ merged_group["messages"].extend(g["messages"])
259
+ merged_group["lines"].extend(g["lines"])
260
+ merged_group["lines"] = sorted(set(merged_group["lines"]))
256
261
  merged.append(merged_group)
257
262
  else:
258
- current['retry_count'] = 0
263
+ current["retry_count"] = 0
259
264
  merged.append(current)
260
265
 
261
266
  i = j
@@ -268,7 +273,7 @@ class ClaudeTrigger(TurnTrigger):
268
273
 
269
274
  def _get_file_line_count(self, session_file: Path) -> int:
270
275
  try:
271
- with open(session_file, 'r', encoding='utf-8') as f:
276
+ with open(session_file, "r", encoding="utf-8") as f:
272
277
  return sum(1 for _ in f)
273
278
  except Exception:
274
279
  return 0
@@ -300,7 +305,7 @@ class ClaudeTrigger(TurnTrigger):
300
305
 
301
306
  groups = []
302
307
  for timestamp, msgs in groups_dict.items():
303
- root_uuids = set(msg.get('root_uuid') for msg in msgs if msg.get('root_uuid'))
308
+ root_uuids = set(msg.get("root_uuid") for msg in msgs if msg.get("root_uuid"))
304
309
  groups.append(
305
310
  {
306
311
  "timestamp": timestamp,
@@ -343,7 +348,11 @@ class ClaudeTrigger(TurnTrigger):
343
348
  has_explicit_end_marker = False
344
349
  if detect_turn_end_status and start_line > 0 and end_line >= start_line:
345
350
  status = detect_turn_end_status(session_file, start_line, end_line)
346
- if status.line is not None and status.status in ("user_interrupted", "rate_limited", "compacted"):
351
+ if status.line is not None and status.status in (
352
+ "user_interrupted",
353
+ "rate_limited",
354
+ "compacted",
355
+ ):
347
356
  has_explicit_end_marker = True
348
357
 
349
358
  if has_explicit_end_marker:
@@ -357,20 +366,20 @@ class ClaudeTrigger(TurnTrigger):
357
366
 
358
367
  analysis = self.get_detailed_analysis(session_file)
359
368
 
360
- if 0 < turn_number <= len(analysis['groups']):
361
- group = analysis['groups'][turn_number - 1]
369
+ if 0 < turn_number <= len(analysis["groups"]):
370
+ group = analysis["groups"][turn_number - 1]
362
371
 
363
372
  messages = self._extract_messages(session_file)
364
- group_lines = set(group['lines'])
373
+ group_lines = set(group["lines"])
365
374
  first_msg = None
366
375
 
367
376
  for msg in messages:
368
- if msg['line_no'] in group_lines:
377
+ if msg["line_no"] in group_lines:
369
378
  first_msg = msg
370
379
  break
371
380
 
372
381
  if first_msg:
373
- content = first_msg['content']
382
+ content = first_msg["content"]
374
383
  extracted_text = None
375
384
 
376
385
  if isinstance(content, str):
@@ -388,9 +397,9 @@ class ClaudeTrigger(TurnTrigger):
388
397
  return TurnInfo(
389
398
  turn_number=turn_number,
390
399
  user_message=cleaned_text,
391
- start_line=group['start_line'],
392
- end_line=group['end_line'],
393
- timestamp=group['root_timestamp']
400
+ start_line=group["start_line"],
401
+ end_line=group["end_line"],
402
+ timestamp=group["root_timestamp"],
394
403
  )
395
404
 
396
405
  return None
@@ -407,25 +416,27 @@ class ClaudeTrigger(TurnTrigger):
407
416
 
408
417
  groups = []
409
418
  for timestamp, msgs in groups_dict.items():
410
- root_uuids = set(msg.get('root_uuid') for msg in msgs if msg.get('root_uuid'))
411
- groups.append({
412
- 'timestamp': timestamp,
413
- 'messages': msgs,
414
- 'lines': sorted([msg['line_no'] for msg in msgs]),
415
- 'parent_chains': len(root_uuids)
416
- })
419
+ root_uuids = set(msg.get("root_uuid") for msg in msgs if msg.get("root_uuid"))
420
+ groups.append(
421
+ {
422
+ "timestamp": timestamp,
423
+ "messages": msgs,
424
+ "lines": sorted([msg["line_no"] for msg in msgs]),
425
+ "parent_chains": len(root_uuids),
426
+ }
427
+ )
417
428
 
418
429
  merged_groups = self._merge_retry_groups(groups)
419
430
  total_lines = self._get_file_line_count(session_file)
420
431
 
421
432
  detailed_groups = []
422
433
  for turn_num, group in enumerate(merged_groups, 1):
423
- lines = group['lines']
434
+ lines = group["lines"]
424
435
  start_line = lines[0]
425
436
 
426
437
  # Extend end_line to next turn's start - 1, or file end
427
438
  if turn_num < len(merged_groups):
428
- next_start = merged_groups[turn_num]['lines'][0]
439
+ next_start = merged_groups[turn_num]["lines"][0]
429
440
  end_line = max(lines[-1], next_start - 1)
430
441
  else:
431
442
  end_line = max(lines[-1], total_lines)
@@ -447,45 +458,53 @@ class ClaudeTrigger(TurnTrigger):
447
458
 
448
459
  user_message = clean_user_message(extracted_text) if extracted_text else ""
449
460
 
450
- detailed_groups.append({
451
- 'turn_number': turn_num,
452
- 'root_timestamp': group['timestamp'],
453
- 'message_count': len(group['messages']),
454
- 'parent_chains': group.get('parent_chains', 1),
455
- 'retry_count': group.get('retry_count', 0),
456
- 'lines': lines,
457
- 'start_line': start_line,
458
- 'end_line': end_line,
459
- 'line_range': f"{start_line}-{end_line}" if start_line != end_line else str(start_line),
460
- 'user_message': user_message,
461
- })
461
+ detailed_groups.append(
462
+ {
463
+ "turn_number": turn_num,
464
+ "root_timestamp": group["timestamp"],
465
+ "message_count": len(group["messages"]),
466
+ "parent_chains": group.get("parent_chains", 1),
467
+ "retry_count": group.get("retry_count", 0),
468
+ "lines": lines,
469
+ "start_line": start_line,
470
+ "end_line": end_line,
471
+ "line_range": (
472
+ f"{start_line}-{end_line}" if start_line != end_line else str(start_line)
473
+ ),
474
+ "user_message": user_message,
475
+ }
476
+ )
462
477
 
463
478
  # Enrich with turn status
464
479
  try:
465
480
  from .turn_status import detect_turn_end_status, preview_text
481
+
466
482
  for g in detailed_groups:
467
- status = detect_turn_end_status(session_file, g['start_line'], g['end_line'])
468
- g['turn_status'] = status.status
469
- g['interrupted'] = status.status in ("user_interrupted", "rate_limited")
470
- g['turn_status_line'] = status.line
471
- g['turn_status_message_preview'] = preview_text(status.message)
483
+ status = detect_turn_end_status(session_file, g["start_line"], g["end_line"])
484
+ g["turn_status"] = status.status
485
+ g["interrupted"] = status.status in ("user_interrupted", "rate_limited")
486
+ g["turn_status_line"] = status.line
487
+ g["turn_status_message_preview"] = preview_text(status.message)
472
488
  except Exception:
473
489
  pass
474
490
 
475
491
  # Enrich with assistant summary
476
492
  try:
477
493
  from .turn_summary import extract_turn_summary
494
+
478
495
  for g in detailed_groups:
479
- summary_line, summary_msg = extract_turn_summary(session_file, g['start_line'], g['end_line'])
480
- g['summary_line'] = summary_line
481
- g['summary_message'] = summary_msg
496
+ summary_line, summary_msg = extract_turn_summary(
497
+ session_file, g["start_line"], g["end_line"]
498
+ )
499
+ g["summary_line"] = summary_line
500
+ g["summary_message"] = summary_msg
482
501
  except Exception:
483
502
  pass
484
503
 
485
504
  return {
486
- 'total_turns': len(merged_groups),
487
- 'total_messages': len(messages),
488
- 'total_retries': sum(g.get('retry_count', 0) for g in merged_groups),
489
- 'groups': detailed_groups,
490
- 'format': 'claude_code',
505
+ "total_turns": len(merged_groups),
506
+ "total_messages": len(messages),
507
+ "total_retries": sum(g.get("retry_count", 0) for g in merged_groups),
508
+ "groups": detailed_groups,
509
+ "format": "claude_code",
491
510
  }
@@ -215,7 +215,10 @@ class CodexTrigger(TurnTrigger):
215
215
  # Format 2: response_item wrapper around messages
216
216
  if data.get("type") == "response_item":
217
217
  payload = data.get("payload") or {}
218
- if payload.get("type") == "message" and payload.get("role") in ("user", "assistant"):
218
+ if payload.get("type") == "message" and payload.get("role") in (
219
+ "user",
220
+ "assistant",
221
+ ):
219
222
  return "codex"
220
223
 
221
224
  # Format 3: event_msg with user_message/agent_message
@@ -267,7 +270,9 @@ class CodexTrigger(TurnTrigger):
267
270
  else:
268
271
  # Deduplicate the common pair:
269
272
  # response_item(role=user) + event_msg(user_message) with same content.
270
- if current.assistant_line is None and _normalize(user_msg) == _normalize(current.user_message):
273
+ if current.assistant_line is None and _normalize(
274
+ user_msg
275
+ ) == _normalize(current.user_message):
271
276
  current.start_line = min(current.start_line, line_no)
272
277
  if current.start_timestamp is None:
273
278
  current.start_timestamp = user_ts
@@ -295,7 +300,11 @@ class CodexTrigger(TurnTrigger):
295
300
  continue
296
301
 
297
302
  # 3) Aborted turns (user interrupted / cancelled)
298
- if current is not None and current.assistant_line is None and _is_turn_aborted(data):
303
+ if (
304
+ current is not None
305
+ and current.assistant_line is None
306
+ and _is_turn_aborted(data)
307
+ ):
299
308
  current = None
300
309
 
301
310
  if current is not None and current.assistant_line is not None:
@@ -343,7 +352,9 @@ class CodexTrigger(TurnTrigger):
343
352
  "retry_count": 0,
344
353
  "start_line": start_line,
345
354
  "end_line": end_line,
346
- "line_range": f"{start_line}-{end_line}" if start_line != end_line else str(start_line),
355
+ "line_range": (
356
+ f"{start_line}-{end_line}" if start_line != end_line else str(start_line)
357
+ ),
347
358
  "user_message": t.user_message,
348
359
  "summary_line": t.assistant_line,
349
360
  "summary_message": t.assistant_message,
@@ -360,4 +371,3 @@ class CodexTrigger(TurnTrigger):
360
371
  "total_retries": 0,
361
372
  "groups": groups,
362
373
  }
363
-
@@ -40,15 +40,15 @@ class GeminiTrigger(TurnTrigger):
40
40
  """Detect if this is a Gemini session file."""
41
41
  try:
42
42
  # Check file extension and path
43
- if session_file.suffix != '.json':
43
+ if session_file.suffix != ".json":
44
44
  return None
45
- if '.gemini/' not in str(session_file) or '/chats/' not in str(session_file):
45
+ if ".gemini/" not in str(session_file) or "/chats/" not in str(session_file):
46
46
  return None
47
47
 
48
48
  # Verify it's a valid Gemini session
49
- with open(session_file, 'r', encoding='utf-8') as f:
49
+ with open(session_file, "r", encoding="utf-8") as f:
50
50
  data = json.load(f)
51
- if isinstance(data, dict) and 'messages' in data and 'sessionId' in data:
51
+ if isinstance(data, dict) and "messages" in data and "sessionId" in data:
52
52
  return "gemini_json"
53
53
 
54
54
  return None
@@ -63,11 +63,11 @@ class GeminiTrigger(TurnTrigger):
63
63
  A turn is complete when there's a user message.
64
64
  """
65
65
  try:
66
- with open(session_file, 'r', encoding='utf-8') as f:
66
+ with open(session_file, "r", encoding="utf-8") as f:
67
67
  data = json.load(f)
68
68
 
69
- messages = data.get('messages', [])
70
- user_count = sum(1 for m in messages if m.get('type') == 'user')
69
+ messages = data.get("messages", [])
70
+ user_count = sum(1 for m in messages if m.get("type") == "user")
71
71
 
72
72
  return user_count
73
73
  except Exception as e:
@@ -86,13 +86,13 @@ class GeminiTrigger(TurnTrigger):
86
86
  Returns TurnInfo with user message and metadata.
87
87
  """
88
88
  try:
89
- with open(session_file, 'r', encoding='utf-8') as f:
89
+ with open(session_file, "r", encoding="utf-8") as f:
90
90
  data = json.load(f)
91
91
 
92
- messages = data.get('messages', [])
92
+ messages = data.get("messages", [])
93
93
 
94
94
  # Find the nth user message
95
- user_messages = [(i, m) for i, m in enumerate(messages) if m.get('type') == 'user']
95
+ user_messages = [(i, m) for i, m in enumerate(messages) if m.get("type") == "user"]
96
96
 
97
97
  if turn_number < 1 or turn_number > len(user_messages):
98
98
  return None
@@ -101,17 +101,19 @@ class GeminiTrigger(TurnTrigger):
101
101
 
102
102
  return TurnInfo(
103
103
  turn_number=turn_number,
104
- user_message=user_msg.get('content', ''),
104
+ user_message=user_msg.get("content", ""),
105
105
  start_line=1, # JSON file doesn't use line numbers
106
106
  end_line=1,
107
- timestamp=user_msg.get('timestamp'),
107
+ timestamp=user_msg.get("timestamp"),
108
108
  )
109
109
 
110
110
  except Exception as e:
111
111
  logger.error(f"Error extracting Gemini turn info: {e}")
112
112
  return None
113
113
 
114
- def extract_turn_content(self, session_file: Path, turn_number: int) -> Optional[Dict[str, Any]]:
114
+ def extract_turn_content(
115
+ self, session_file: Path, turn_number: int
116
+ ) -> Optional[Dict[str, Any]]:
115
117
  """
116
118
  Extract full content for a specific turn (for commit generation).
117
119
 
@@ -122,13 +124,13 @@ class GeminiTrigger(TurnTrigger):
122
124
  - timestamp: Turn timestamp
123
125
  """
124
126
  try:
125
- with open(session_file, 'r', encoding='utf-8') as f:
127
+ with open(session_file, "r", encoding="utf-8") as f:
126
128
  data = json.load(f)
127
129
 
128
- messages = data.get('messages', [])
130
+ messages = data.get("messages", [])
129
131
 
130
132
  # Find the nth user message
131
- user_messages = [(i, m) for i, m in enumerate(messages) if m.get('type') == 'user']
133
+ user_messages = [(i, m) for i, m in enumerate(messages) if m.get("type") == "user"]
132
134
 
133
135
  if turn_number < 1 or turn_number > len(user_messages):
134
136
  return None
@@ -143,22 +145,26 @@ class GeminiTrigger(TurnTrigger):
143
145
  gemini_responses = []
144
146
  for i in range(user_idx + 1, next_user_idx):
145
147
  msg = messages[i]
146
- if msg.get('type') == 'gemini':
147
- gemini_responses.append(msg.get('content', ''))
148
+ if msg.get("type") == "gemini":
149
+ gemini_responses.append(msg.get("content", ""))
148
150
 
149
151
  # Build turn content
150
- turn_content = json.dumps({
151
- 'turn_number': turn_number,
152
- 'user': user_msg,
153
- 'responses': [messages[i] for i in range(user_idx, next_user_idx)]
154
- }, ensure_ascii=False, indent=2)
152
+ turn_content = json.dumps(
153
+ {
154
+ "turn_number": turn_number,
155
+ "user": user_msg,
156
+ "responses": [messages[i] for i in range(user_idx, next_user_idx)],
157
+ },
158
+ ensure_ascii=False,
159
+ indent=2,
160
+ )
155
161
 
156
162
  return {
157
- 'user_message': user_msg.get('content', ''),
158
- 'assistant_response': '\n'.join(gemini_responses),
159
- 'turn_content': turn_content,
160
- 'timestamp': user_msg.get('timestamp'),
161
- 'turn_number': turn_number,
163
+ "user_message": user_msg.get("content", ""),
164
+ "assistant_response": "\n".join(gemini_responses),
165
+ "turn_content": turn_content,
166
+ "timestamp": user_msg.get("timestamp"),
167
+ "turn_number": turn_number,
162
168
  }
163
169
 
164
170
  except Exception as e:
@@ -174,11 +180,11 @@ class GeminiTrigger(TurnTrigger):
174
180
  - total_turns: Total number of turns
175
181
  """
176
182
  try:
177
- with open(session_file, 'r', encoding='utf-8') as f:
183
+ with open(session_file, "r", encoding="utf-8") as f:
178
184
  data = json.load(f)
179
185
 
180
- messages = data.get('messages', [])
181
- user_messages = [(i, m) for i, m in enumerate(messages) if m.get('type') == 'user']
186
+ messages = data.get("messages", [])
187
+ user_messages = [(i, m) for i, m in enumerate(messages) if m.get("type") == "user"]
182
188
 
183
189
  groups = []
184
190
  for turn_idx, (msg_idx, user_msg) in enumerate(user_messages, 1):
@@ -190,25 +196,29 @@ class GeminiTrigger(TurnTrigger):
190
196
  # Collect gemini responses
191
197
  gemini_responses = []
192
198
  for i in range(msg_idx + 1, next_user_idx):
193
- if messages[i].get('type') == 'gemini':
194
- gemini_responses.append(messages[i].get('content', ''))
195
-
196
- groups.append({
197
- 'turn_number': turn_idx,
198
- 'user_message': user_msg.get('content', ''),
199
- 'summary_message': '\n'.join(gemini_responses)[:500] if gemini_responses else '',
200
- 'turn_status': 'completed',
201
- 'start_line': 1, # JSON file doesn't use line numbers
202
- 'end_line': 1,
203
- 'lines': [1],
204
- })
199
+ if messages[i].get("type") == "gemini":
200
+ gemini_responses.append(messages[i].get("content", ""))
201
+
202
+ groups.append(
203
+ {
204
+ "turn_number": turn_idx,
205
+ "user_message": user_msg.get("content", ""),
206
+ "summary_message": (
207
+ "\n".join(gemini_responses)[:500] if gemini_responses else ""
208
+ ),
209
+ "turn_status": "completed",
210
+ "start_line": 1, # JSON file doesn't use line numbers
211
+ "end_line": 1,
212
+ "lines": [1],
213
+ }
214
+ )
205
215
 
206
216
  return {
207
- 'groups': groups,
208
- 'total_turns': len(groups),
209
- 'format': 'gemini_json',
217
+ "groups": groups,
218
+ "total_turns": len(groups),
219
+ "format": "gemini_json",
210
220
  }
211
221
 
212
222
  except Exception as e:
213
223
  logger.error(f"Error in get_detailed_analysis for Gemini: {e}")
214
- return {'groups': [], 'total_turns': 0, 'format': 'gemini_json'}
224
+ return {"groups": [], "total_turns": 0, "format": "gemini_json"}
@@ -50,7 +50,9 @@ class NextTurnTrigger(TurnTrigger):
50
50
  continue
51
51
 
52
52
  # Claude Code: {"type":"user"|"assistant", "message": {...}}
53
- if obj.get("type") in ("user", "assistant") and isinstance(obj.get("message"), dict):
53
+ if obj.get("type") in ("user", "assistant") and isinstance(
54
+ obj.get("message"), dict
55
+ ):
54
56
  return ClaudeTrigger(self.config)
55
57
 
56
58
  # Codex: session_meta/response_item patterns
@@ -64,7 +64,9 @@ class TriggerRegistry:
64
64
  return trigger_class(config)
65
65
  return None
66
66
 
67
- def get_trigger_for_type(self, session_type: str, config: Optional[Dict] = None) -> Optional[TurnTrigger]:
67
+ def get_trigger_for_type(
68
+ self, session_type: str, config: Optional[Dict] = None
69
+ ) -> Optional[TurnTrigger]:
68
70
  """
69
71
  根据session类型获取对应的trigger
70
72
 
@@ -99,7 +101,9 @@ class TriggerRegistry:
99
101
  """
100
102
  return list(self._type_to_trigger.keys())
101
103
 
102
- def auto_select_trigger(self, session_file: Path, config: Optional[Dict] = None) -> Optional[TurnTrigger]:
104
+ def auto_select_trigger(
105
+ self, session_file: Path, config: Optional[Dict] = None
106
+ ) -> Optional[TurnTrigger]:
103
107
  """
104
108
  Auto-select an appropriate trigger for the given session file.
105
109