aline-ai 0.5.4__py3-none-any.whl → 0.5.6__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.
- {aline_ai-0.5.4.dist-info → aline_ai-0.5.6.dist-info}/METADATA +1 -1
- aline_ai-0.5.6.dist-info/RECORD +95 -0
- realign/__init__.py +1 -1
- realign/adapters/antigravity.py +28 -20
- realign/adapters/base.py +46 -50
- realign/adapters/claude.py +14 -14
- realign/adapters/codex.py +7 -7
- realign/adapters/gemini.py +11 -11
- realign/adapters/registry.py +14 -10
- realign/claude_detector.py +2 -2
- realign/claude_hooks/__init__.py +3 -3
- realign/claude_hooks/permission_request_hook_installer.py +31 -32
- realign/claude_hooks/stop_hook.py +4 -1
- realign/claude_hooks/stop_hook_installer.py +30 -31
- realign/cli.py +23 -4
- realign/codex_detector.py +11 -11
- realign/commands/add.py +88 -65
- realign/commands/config.py +3 -12
- realign/commands/context.py +3 -1
- realign/commands/export_shares.py +86 -127
- realign/commands/import_shares.py +145 -155
- realign/commands/init.py +166 -30
- realign/commands/restore.py +18 -6
- realign/commands/search.py +14 -42
- realign/commands/upgrade.py +155 -11
- realign/commands/watcher.py +98 -219
- realign/commands/worker.py +29 -6
- realign/config.py +25 -20
- realign/context.py +1 -3
- realign/dashboard/app.py +34 -24
- realign/dashboard/screens/__init__.py +10 -1
- realign/dashboard/screens/create_agent.py +244 -0
- realign/dashboard/screens/create_event.py +3 -1
- realign/dashboard/screens/event_detail.py +14 -6
- realign/dashboard/screens/help_screen.py +114 -0
- realign/dashboard/screens/session_detail.py +3 -1
- realign/dashboard/screens/share_import.py +7 -3
- realign/dashboard/tmux_manager.py +54 -9
- realign/dashboard/widgets/config_panel.py +85 -1
- realign/dashboard/widgets/events_table.py +314 -70
- realign/dashboard/widgets/header.py +2 -1
- realign/dashboard/widgets/search_panel.py +37 -27
- realign/dashboard/widgets/sessions_table.py +404 -85
- realign/dashboard/widgets/terminal_panel.py +155 -175
- realign/dashboard/widgets/watcher_panel.py +6 -2
- realign/dashboard/widgets/worker_panel.py +10 -1
- realign/db/__init__.py +1 -1
- realign/db/base.py +5 -15
- realign/db/locks.py +0 -1
- realign/db/migration.py +82 -76
- realign/db/schema.py +2 -6
- realign/db/sqlite_db.py +23 -41
- realign/events/__init__.py +0 -1
- realign/events/event_summarizer.py +27 -15
- realign/events/session_summarizer.py +29 -15
- realign/file_lock.py +1 -0
- realign/hooks.py +150 -60
- realign/logging_config.py +12 -15
- realign/mcp_server.py +30 -51
- realign/mcp_watcher.py +0 -1
- realign/models/event.py +29 -20
- realign/prompts/__init__.py +7 -7
- realign/prompts/presets.py +15 -11
- realign/redactor.py +99 -59
- realign/triggers/__init__.py +9 -9
- realign/triggers/antigravity_trigger.py +30 -28
- realign/triggers/base.py +4 -3
- realign/triggers/claude_trigger.py +104 -85
- realign/triggers/codex_trigger.py +15 -5
- realign/triggers/gemini_trigger.py +57 -47
- realign/triggers/next_turn_trigger.py +3 -1
- realign/triggers/registry.py +6 -2
- realign/triggers/turn_status.py +3 -1
- realign/watcher_core.py +306 -131
- realign/watcher_daemon.py +8 -8
- realign/worker_core.py +3 -1
- realign/worker_daemon.py +3 -1
- aline_ai-0.5.4.dist-info/RECORD +0 -93
- {aline_ai-0.5.4.dist-info → aline_ai-0.5.6.dist-info}/WHEEL +0 -0
- {aline_ai-0.5.4.dist-info → aline_ai-0.5.6.dist-info}/entry_points.txt +0 -0
- {aline_ai-0.5.4.dist-info → aline_ai-0.5.6.dist-info}/licenses/LICENSE +0 -0
- {aline_ai-0.5.4.dist-info → aline_ai-0.5.6.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(
|
|
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,
|
|
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,
|
|
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", "")
|
|
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(
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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[
|
|
155
|
-
if current[
|
|
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[
|
|
158
|
-
current = uuid_to_msg[current[
|
|
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[
|
|
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[
|
|
169
|
-
msg[
|
|
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[
|
|
174
|
-
groups[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(
|
|
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(
|
|
192
|
-
texts.append(item.get(
|
|
196
|
+
if isinstance(item, dict) and item.get("type") == "text":
|
|
197
|
+
texts.append(item.get("text", ""))
|
|
193
198
|
|
|
194
|
-
combined =
|
|
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(
|
|
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(
|
|
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[
|
|
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[
|
|
219
|
-
hash2 = self._get_content_hash(group2[
|
|
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[
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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[
|
|
254
|
-
merged_group[
|
|
255
|
-
merged_group[
|
|
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[
|
|
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,
|
|
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(
|
|
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 (
|
|
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[
|
|
361
|
-
group = analysis[
|
|
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[
|
|
373
|
+
group_lines = set(group["lines"])
|
|
365
374
|
first_msg = None
|
|
366
375
|
|
|
367
376
|
for msg in messages:
|
|
368
|
-
if msg[
|
|
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[
|
|
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[
|
|
392
|
-
end_line=group[
|
|
393
|
-
timestamp=group[
|
|
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(
|
|
411
|
-
groups.append(
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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[
|
|
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][
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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[
|
|
468
|
-
g[
|
|
469
|
-
g[
|
|
470
|
-
g[
|
|
471
|
-
g[
|
|
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(
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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 (
|
|
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(
|
|
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
|
|
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":
|
|
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 !=
|
|
43
|
+
if session_file.suffix != ".json":
|
|
44
44
|
return None
|
|
45
|
-
if
|
|
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,
|
|
49
|
+
with open(session_file, "r", encoding="utf-8") as f:
|
|
50
50
|
data = json.load(f)
|
|
51
|
-
if isinstance(data, dict) and
|
|
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,
|
|
66
|
+
with open(session_file, "r", encoding="utf-8") as f:
|
|
67
67
|
data = json.load(f)
|
|
68
68
|
|
|
69
|
-
messages = data.get(
|
|
70
|
-
user_count = sum(1 for m in messages if m.get(
|
|
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,
|
|
89
|
+
with open(session_file, "r", encoding="utf-8") as f:
|
|
90
90
|
data = json.load(f)
|
|
91
91
|
|
|
92
|
-
messages = data.get(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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,
|
|
127
|
+
with open(session_file, "r", encoding="utf-8") as f:
|
|
126
128
|
data = json.load(f)
|
|
127
129
|
|
|
128
|
-
messages = data.get(
|
|
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(
|
|
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(
|
|
147
|
-
gemini_responses.append(msg.get(
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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,
|
|
183
|
+
with open(session_file, "r", encoding="utf-8") as f:
|
|
178
184
|
data = json.load(f)
|
|
179
185
|
|
|
180
|
-
messages = data.get(
|
|
181
|
-
user_messages = [(i, m) for i, m in enumerate(messages) if m.get(
|
|
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(
|
|
194
|
-
gemini_responses.append(messages[i].get(
|
|
195
|
-
|
|
196
|
-
groups.append(
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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 {
|
|
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(
|
|
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
|
realign/triggers/registry.py
CHANGED
|
@@ -64,7 +64,9 @@ class TriggerRegistry:
|
|
|
64
64
|
return trigger_class(config)
|
|
65
65
|
return None
|
|
66
66
|
|
|
67
|
-
def get_trigger_for_type(
|
|
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(
|
|
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
|
|