aline-ai 0.1.10__py3-none-any.whl → 0.2.1__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.1.10.dist-info → aline_ai-0.2.1.dist-info}/METADATA +1 -1
- aline_ai-0.2.1.dist-info/RECORD +25 -0
- realign/__init__.py +1 -1
- realign/commands/auto_commit.py +1 -1
- realign/commands/commit.py +100 -0
- realign/commands/config.py +13 -12
- realign/commands/init.py +48 -16
- realign/commands/search.py +57 -31
- realign/commands/session_utils.py +28 -0
- realign/commands/show.py +25 -38
- realign/file_lock.py +120 -0
- realign/hooks.py +362 -49
- realign/mcp_server.py +4 -54
- realign/mcp_watcher.py +356 -253
- aline_ai-0.1.10.dist-info/RECORD +0 -23
- {aline_ai-0.1.10.dist-info → aline_ai-0.2.1.dist-info}/WHEEL +0 -0
- {aline_ai-0.1.10.dist-info → aline_ai-0.2.1.dist-info}/entry_points.txt +0 -0
- {aline_ai-0.1.10.dist-info → aline_ai-0.2.1.dist-info}/licenses/LICENSE +0 -0
- {aline_ai-0.1.10.dist-info → aline_ai-0.2.1.dist-info}/top_level.txt +0 -0
realign/mcp_watcher.py
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
"""Session file watcher for MCP auto-commit per user request completion.
|
|
1
|
+
"""Session file watcher for MCP auto-commit per user request completion.
|
|
2
|
+
|
|
3
|
+
Supports both Claude Code and Codex session formats with unified interface.
|
|
4
|
+
"""
|
|
2
5
|
|
|
3
6
|
import asyncio
|
|
4
7
|
import json
|
|
@@ -6,49 +9,25 @@ import subprocess
|
|
|
6
9
|
import sys
|
|
7
10
|
import time
|
|
8
11
|
from pathlib import Path
|
|
9
|
-
from typing import Optional, Dict
|
|
12
|
+
from typing import Optional, Dict, Literal
|
|
10
13
|
from datetime import datetime
|
|
11
14
|
|
|
12
15
|
from .config import ReAlignConfig
|
|
13
16
|
from .hooks import find_all_active_sessions
|
|
14
17
|
|
|
15
18
|
|
|
16
|
-
#
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def _log(msg: str):
|
|
20
|
-
"""Log to both stderr and a debug file."""
|
|
21
|
-
global _log_file
|
|
22
|
-
timestamp = datetime.now().strftime("%H:%M:%S")
|
|
23
|
-
full_msg = f"[{timestamp}] {msg}"
|
|
24
|
-
print(f"[MCP Watcher] {msg}", file=sys.stderr)
|
|
25
|
-
|
|
26
|
-
# Also write to file for debugging
|
|
27
|
-
if _log_file is None:
|
|
28
|
-
log_path = Path.home() / ".aline_watcher.log"
|
|
29
|
-
try:
|
|
30
|
-
_log_file = open(log_path, "a", buffering=1) # Line buffered
|
|
31
|
-
_log_file.write(f"\n{'='*60}\n")
|
|
32
|
-
_log_file.write(f"[{timestamp}] MCP Watcher Started\n")
|
|
33
|
-
_log_file.write(f"{'='*60}\n")
|
|
34
|
-
except Exception:
|
|
35
|
-
_log_file = False # Mark as failed so we don't retry
|
|
36
|
-
|
|
37
|
-
if _log_file and _log_file is not False:
|
|
38
|
-
try:
|
|
39
|
-
_log_file.write(full_msg + "\n")
|
|
40
|
-
_log_file.flush()
|
|
41
|
-
except Exception:
|
|
42
|
-
pass
|
|
19
|
+
# Session type detection
|
|
20
|
+
SessionType = Literal["claude", "codex", "unknown"]
|
|
43
21
|
|
|
44
22
|
|
|
45
23
|
class DialogueWatcher:
|
|
46
24
|
"""Watch session files and auto-commit immediately after each user request completes."""
|
|
47
25
|
|
|
48
|
-
def __init__(self
|
|
49
|
-
|
|
26
|
+
def __init__(self):
|
|
27
|
+
"""Initialize watcher without fixed repo_path - will extract dynamically from sessions."""
|
|
50
28
|
self.config = ReAlignConfig.load()
|
|
51
|
-
self.
|
|
29
|
+
self.project_path = self._detect_project_path()
|
|
30
|
+
self.last_commit_times: Dict[str, float] = {} # Track last commit time per project
|
|
52
31
|
self.last_session_sizes: Dict[str, int] = {} # Track file sizes
|
|
53
32
|
self.last_stop_reason_counts: Dict[str, int] = {} # Track stop_reason counts per session
|
|
54
33
|
self.min_commit_interval = 5.0 # Minimum 5 seconds between commits (cooldown)
|
|
@@ -59,38 +38,30 @@ class DialogueWatcher:
|
|
|
59
38
|
async def start(self):
|
|
60
39
|
"""Start watching session files."""
|
|
61
40
|
if not self.config.mcp_auto_commit:
|
|
62
|
-
|
|
41
|
+
print("[MCP Watcher] Auto-commit disabled in config", file=sys.stderr)
|
|
63
42
|
return
|
|
64
43
|
|
|
65
44
|
self.running = True
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
45
|
+
print("[MCP Watcher] Started watching for dialogue completion", file=sys.stderr)
|
|
46
|
+
print(f"[MCP Watcher] Mode: Per-request (triggers at end of each AI response)", file=sys.stderr)
|
|
47
|
+
print(f"[MCP Watcher] Supports: Claude Code & Codex (auto-detected)", file=sys.stderr)
|
|
48
|
+
print(f"[MCP Watcher] Debounce: {self.debounce_delay}s, Cooldown: {self.min_commit_interval}s", file=sys.stderr)
|
|
49
|
+
if self.project_path:
|
|
50
|
+
print(f"[MCP Watcher] Monitoring project: {self.project_path}", file=sys.stderr)
|
|
51
|
+
else:
|
|
52
|
+
print("[MCP Watcher] Project path unknown, falling back to multi-project scan", file=sys.stderr)
|
|
71
53
|
|
|
72
54
|
# Initialize baseline sizes and stop_reason counts
|
|
73
55
|
self.last_session_sizes = self._get_session_sizes()
|
|
74
56
|
self.last_stop_reason_counts = self._get_stop_reason_counts()
|
|
75
57
|
|
|
76
|
-
# Log initial session files being monitored
|
|
77
|
-
if self.last_session_sizes:
|
|
78
|
-
_log(f"Monitoring {len(self.last_session_sizes)} session file(s):")
|
|
79
|
-
for session_path in list(self.last_session_sizes.keys())[:5]: # Show first 5
|
|
80
|
-
_log(f" - {session_path}")
|
|
81
|
-
if len(self.last_session_sizes) > 5:
|
|
82
|
-
_log(f" ... and {len(self.last_session_sizes) - 5} more")
|
|
83
|
-
else:
|
|
84
|
-
claude_projects = Path.home() / ".claude" / "projects"
|
|
85
|
-
_log(f"WARNING: No session files found in {claude_projects}")
|
|
86
|
-
|
|
87
58
|
# Poll for file changes more frequently
|
|
88
59
|
while self.running:
|
|
89
60
|
try:
|
|
90
61
|
await self.check_for_changes()
|
|
91
62
|
await asyncio.sleep(0.5) # Check every 0.5 seconds for responsiveness
|
|
92
63
|
except Exception as e:
|
|
93
|
-
|
|
64
|
+
print(f"[MCP Watcher] Error: {e}", file=sys.stderr)
|
|
94
65
|
await asyncio.sleep(1.0)
|
|
95
66
|
|
|
96
67
|
async def stop(self):
|
|
@@ -98,42 +69,178 @@ class DialogueWatcher:
|
|
|
98
69
|
self.running = False
|
|
99
70
|
if self.pending_commit_task:
|
|
100
71
|
self.pending_commit_task.cancel()
|
|
101
|
-
|
|
72
|
+
print("[MCP Watcher] Stopped", file=sys.stderr)
|
|
102
73
|
|
|
103
74
|
def _get_session_sizes(self) -> Dict[str, int]:
|
|
104
|
-
"""Get current sizes of all active session files
|
|
75
|
+
"""Get current sizes of all active session files."""
|
|
105
76
|
sizes = {}
|
|
106
77
|
try:
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
78
|
+
session_files = find_all_active_sessions(
|
|
79
|
+
self.config,
|
|
80
|
+
project_path=self.project_path if self.project_path and self.project_path.exists() else None,
|
|
81
|
+
)
|
|
82
|
+
for session_file in session_files:
|
|
83
|
+
if session_file.exists():
|
|
84
|
+
sizes[str(session_file)] = session_file.stat().st_size
|
|
113
85
|
except Exception as e:
|
|
114
|
-
|
|
86
|
+
print(f"[MCP Watcher] Error getting session sizes: {e}", file=sys.stderr)
|
|
115
87
|
return sizes
|
|
116
88
|
|
|
117
89
|
def _get_stop_reason_counts(self) -> Dict[str, int]:
|
|
118
|
-
"""Get current count of
|
|
90
|
+
"""Get current count of turn completion markers in all active session files."""
|
|
119
91
|
counts = {}
|
|
120
92
|
try:
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
93
|
+
session_files = find_all_active_sessions(
|
|
94
|
+
self.config,
|
|
95
|
+
project_path=self.project_path if self.project_path and self.project_path.exists() else None,
|
|
96
|
+
)
|
|
97
|
+
for session_file in session_files:
|
|
98
|
+
if session_file.exists():
|
|
99
|
+
counts[str(session_file)] = self._count_complete_turns(session_file)
|
|
127
100
|
except Exception as e:
|
|
128
|
-
|
|
101
|
+
print(f"[MCP Watcher] Error getting turn counts: {e}", file=sys.stderr)
|
|
129
102
|
return counts
|
|
130
103
|
|
|
131
|
-
def
|
|
104
|
+
def _extract_project_path(self, session_file: Path) -> Optional[Path]:
|
|
105
|
+
"""
|
|
106
|
+
Extract project path (cwd) from session file.
|
|
107
|
+
|
|
108
|
+
Supports:
|
|
109
|
+
1. Codex: Read cwd from session_meta payload
|
|
110
|
+
2. Claude Code: Read cwd from message objects OR infer from project directory name
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
session_file: Path to session file
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Path to project directory, or None if cannot determine
|
|
117
|
+
"""
|
|
118
|
+
try:
|
|
119
|
+
# Method 1: Try to read cwd from session content
|
|
120
|
+
with open(session_file, 'r', encoding='utf-8') as f:
|
|
121
|
+
# Check first 10 lines for cwd field
|
|
122
|
+
for i, line in enumerate(f):
|
|
123
|
+
if i >= 10:
|
|
124
|
+
break
|
|
125
|
+
|
|
126
|
+
line = line.strip()
|
|
127
|
+
if not line:
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
data = json.loads(line)
|
|
132
|
+
|
|
133
|
+
# Codex format: session_meta.payload.cwd
|
|
134
|
+
if data.get('type') == 'session_meta':
|
|
135
|
+
cwd = data.get('payload', {}).get('cwd')
|
|
136
|
+
if cwd:
|
|
137
|
+
project_path = Path(cwd)
|
|
138
|
+
if project_path.exists():
|
|
139
|
+
return project_path
|
|
140
|
+
|
|
141
|
+
# Claude Code format: message object has cwd field
|
|
142
|
+
if 'cwd' in data:
|
|
143
|
+
cwd = data['cwd']
|
|
144
|
+
if cwd:
|
|
145
|
+
project_path = Path(cwd)
|
|
146
|
+
if project_path.exists():
|
|
147
|
+
return project_path
|
|
148
|
+
|
|
149
|
+
except json.JSONDecodeError:
|
|
150
|
+
continue
|
|
151
|
+
|
|
152
|
+
# Method 2: For Claude Code, infer from project directory name
|
|
153
|
+
# ~/.claude/projects/-Users-huminhao-Projects-ReAlign/xxx.jsonl
|
|
154
|
+
if '.claude/projects/' in str(session_file):
|
|
155
|
+
project_dir_name = session_file.parent.name
|
|
156
|
+
if project_dir_name.startswith('-'):
|
|
157
|
+
# Decode: -Users-huminhao-Projects-ReAlign -> /Users/huminhao/Projects/ReAlign
|
|
158
|
+
project_path_str = '/' + project_dir_name[1:].replace('-', '/')
|
|
159
|
+
project_path = Path(project_path_str)
|
|
160
|
+
if project_path.exists() and (project_path / '.git').exists():
|
|
161
|
+
return project_path
|
|
162
|
+
|
|
163
|
+
print(f"[MCP Watcher] Could not extract project path from {session_file.name}", file=sys.stderr)
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
except Exception as e:
|
|
167
|
+
print(f"[MCP Watcher] Error extracting project path from {session_file}: {e}", file=sys.stderr)
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
def _detect_session_type(self, session_file: Path) -> SessionType:
|
|
171
|
+
"""
|
|
172
|
+
Detect the type of session file by examining its structure.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
"claude" for Claude Code sessions
|
|
176
|
+
"codex" for Codex/GPT sessions
|
|
177
|
+
"unknown" if cannot determine
|
|
178
|
+
"""
|
|
179
|
+
try:
|
|
180
|
+
with open(session_file, 'r', encoding='utf-8') as f:
|
|
181
|
+
# Check first 20 lines for format indicators
|
|
182
|
+
for i, line in enumerate(f):
|
|
183
|
+
if i >= 20:
|
|
184
|
+
break
|
|
185
|
+
|
|
186
|
+
line = line.strip()
|
|
187
|
+
if not line:
|
|
188
|
+
continue
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
data = json.loads(line)
|
|
192
|
+
|
|
193
|
+
# Claude Code format: {type: "assistant", message: {...}}
|
|
194
|
+
if data.get("type") in ("assistant", "user") and "message" in data:
|
|
195
|
+
return "claude"
|
|
196
|
+
|
|
197
|
+
# Codex format: {type: "session_meta", payload: {originator: "codex_*"}}
|
|
198
|
+
if data.get("type") == "session_meta":
|
|
199
|
+
payload = data.get("payload", {})
|
|
200
|
+
if "codex" in payload.get("originator", "").lower():
|
|
201
|
+
return "codex"
|
|
202
|
+
|
|
203
|
+
# Codex format: {type: "response_item", payload: {...}}
|
|
204
|
+
if data.get("type") == "response_item":
|
|
205
|
+
payload = data.get("payload", {})
|
|
206
|
+
# Claude has "message" wrapper, Codex doesn't
|
|
207
|
+
if "message" not in data and "role" in payload:
|
|
208
|
+
return "codex"
|
|
209
|
+
|
|
210
|
+
except json.JSONDecodeError:
|
|
211
|
+
continue
|
|
212
|
+
|
|
213
|
+
return "unknown"
|
|
214
|
+
|
|
215
|
+
except Exception as e:
|
|
216
|
+
print(f"[MCP Watcher] Error detecting session type for {session_file.name}: {e}", file=sys.stderr)
|
|
217
|
+
return "unknown"
|
|
218
|
+
|
|
219
|
+
def _count_complete_turns(self, session_file: Path) -> int:
|
|
220
|
+
"""
|
|
221
|
+
Unified interface to count complete dialogue turns for any session type.
|
|
222
|
+
|
|
223
|
+
Automatically detects session format and uses appropriate counting method.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
Number of complete dialogue turns (user request + assistant response)
|
|
227
|
+
"""
|
|
228
|
+
session_type = self._detect_session_type(session_file)
|
|
229
|
+
|
|
230
|
+
if session_type == "claude":
|
|
231
|
+
return self._count_claude_turns(session_file)
|
|
232
|
+
elif session_type == "codex":
|
|
233
|
+
return self._count_codex_turns(session_file)
|
|
234
|
+
else:
|
|
235
|
+
print(f"[MCP Watcher] Unknown session type for {session_file.name}, skipping", file=sys.stderr)
|
|
236
|
+
return 0
|
|
237
|
+
|
|
238
|
+
def _count_claude_turns(self, session_file: Path) -> int:
|
|
132
239
|
"""
|
|
133
|
-
Count
|
|
240
|
+
Count complete dialogue turns for Claude Code sessions.
|
|
134
241
|
|
|
135
|
-
|
|
136
|
-
|
|
242
|
+
Uses stop_reason='end_turn' as the marker, with message ID deduplication
|
|
243
|
+
to handle Claude Code's incremental writes (thinking first, then full content).
|
|
137
244
|
"""
|
|
138
245
|
unique_message_ids = set()
|
|
139
246
|
try:
|
|
@@ -157,26 +264,50 @@ class DialogueWatcher:
|
|
|
157
264
|
except json.JSONDecodeError:
|
|
158
265
|
continue
|
|
159
266
|
except Exception as e:
|
|
160
|
-
|
|
267
|
+
print(f"[MCP Watcher] Error counting Claude turns in {session_file}: {e}", file=sys.stderr)
|
|
161
268
|
return len(unique_message_ids)
|
|
162
269
|
|
|
270
|
+
def _count_codex_turns(self, session_file: Path) -> int:
|
|
271
|
+
"""
|
|
272
|
+
Count complete dialogue turns for Codex sessions.
|
|
273
|
+
|
|
274
|
+
Uses 'token_count' event_msg as the marker for dialogue turn completion.
|
|
275
|
+
Each token_count event appears after an assistant response finishes.
|
|
276
|
+
|
|
277
|
+
Note: Codex doesn't have the duplicate write issue that Claude Code has,
|
|
278
|
+
so no deduplication is needed.
|
|
279
|
+
"""
|
|
280
|
+
count = 0
|
|
281
|
+
try:
|
|
282
|
+
with open(session_file, 'r', encoding='utf-8') as f:
|
|
283
|
+
for line in f:
|
|
284
|
+
line = line.strip()
|
|
285
|
+
if not line:
|
|
286
|
+
continue
|
|
287
|
+
try:
|
|
288
|
+
data = json.loads(line)
|
|
289
|
+
# Look for token_count events which mark turn completion
|
|
290
|
+
if data.get("type") == "event_msg":
|
|
291
|
+
payload = data.get("payload", {})
|
|
292
|
+
if payload.get("type") == "token_count":
|
|
293
|
+
count += 1
|
|
294
|
+
except json.JSONDecodeError:
|
|
295
|
+
continue
|
|
296
|
+
except Exception as e:
|
|
297
|
+
print(f"[MCP Watcher] Error counting Codex turns in {session_file}: {e}", file=sys.stderr)
|
|
298
|
+
return count
|
|
299
|
+
|
|
163
300
|
async def check_for_changes(self):
|
|
164
301
|
"""Check if any session file has been modified."""
|
|
165
302
|
try:
|
|
166
303
|
current_sizes = self._get_session_sizes()
|
|
167
304
|
|
|
168
|
-
# Debug: log if no sessions found (only once)
|
|
169
|
-
if not current_sizes and not hasattr(self, '_no_session_warned'):
|
|
170
|
-
print(f"[MCP Watcher] Warning: No active sessions found for repo: {self.repo_path}", file=sys.stderr)
|
|
171
|
-
self._no_session_warned = True
|
|
172
|
-
|
|
173
305
|
# Detect changed files
|
|
174
306
|
changed_files = []
|
|
175
307
|
for path, size in current_sizes.items():
|
|
176
308
|
old_size = self.last_session_sizes.get(path, 0)
|
|
177
309
|
if size > old_size:
|
|
178
310
|
changed_files.append(Path(path))
|
|
179
|
-
_log(f"Session file changed: {Path(path).name} ({old_size} -> {size} bytes)")
|
|
180
311
|
|
|
181
312
|
if changed_files:
|
|
182
313
|
# File changed - cancel any pending commit and schedule a new one
|
|
@@ -192,7 +323,7 @@ class DialogueWatcher:
|
|
|
192
323
|
self.last_session_sizes = current_sizes
|
|
193
324
|
|
|
194
325
|
except Exception as e:
|
|
195
|
-
|
|
326
|
+
print(f"[MCP Watcher] Error checking for changes: {e}", file=sys.stderr)
|
|
196
327
|
|
|
197
328
|
async def _debounced_commit(self, changed_files: list):
|
|
198
329
|
"""Wait for debounce period, then check if dialogue is complete and commit."""
|
|
@@ -201,51 +332,65 @@ class DialogueWatcher:
|
|
|
201
332
|
await asyncio.sleep(self.debounce_delay)
|
|
202
333
|
|
|
203
334
|
# Check if any of the changed files contains a complete dialogue turn
|
|
335
|
+
# And collect the session file that triggered the commit
|
|
336
|
+
session_to_commit = None
|
|
204
337
|
for session_file in changed_files:
|
|
205
338
|
if await self._check_if_turn_complete(session_file):
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
339
|
+
session_to_commit = session_file
|
|
340
|
+
print(f"[MCP Watcher] Complete turn detected in {session_file.name}", file=sys.stderr)
|
|
341
|
+
break
|
|
342
|
+
|
|
343
|
+
if session_to_commit:
|
|
344
|
+
# Extract project path from the session file
|
|
345
|
+
project_path = self._extract_project_path(session_to_commit)
|
|
346
|
+
if not project_path:
|
|
347
|
+
print(f"[MCP Watcher] Could not determine project path for {session_to_commit.name}, skipping commit", file=sys.stderr)
|
|
348
|
+
return
|
|
349
|
+
|
|
350
|
+
# Check cooldown period for this specific project
|
|
351
|
+
current_time = time.time()
|
|
352
|
+
project_key = str(project_path)
|
|
353
|
+
last_commit_time = self.last_commit_times.get(project_key)
|
|
354
|
+
if last_commit_time:
|
|
355
|
+
time_since_last = current_time - last_commit_time
|
|
356
|
+
if time_since_last < self.min_commit_interval:
|
|
357
|
+
print(f"[MCP Watcher] Skipping commit for {project_path.name} (cooldown: {time_since_last:.1f}s < {self.min_commit_interval}s)", file=sys.stderr)
|
|
358
|
+
return
|
|
359
|
+
|
|
360
|
+
# Perform commit for this project
|
|
361
|
+
await self._do_commit(project_path, session_to_commit)
|
|
221
362
|
|
|
222
363
|
except asyncio.CancelledError:
|
|
223
364
|
# Task was cancelled because a newer change was detected
|
|
224
365
|
pass
|
|
225
366
|
except Exception as e:
|
|
226
|
-
|
|
367
|
+
print(f"[MCP Watcher] Error in debounced commit: {e}", file=sys.stderr)
|
|
227
368
|
|
|
228
369
|
async def _check_if_turn_complete(self, session_file: Path) -> bool:
|
|
229
370
|
"""
|
|
230
|
-
Check if the session file has at least 1 new
|
|
371
|
+
Check if the session file has at least 1 new complete dialogue turn since last check.
|
|
231
372
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
373
|
+
Supports both Claude Code and Codex formats:
|
|
374
|
+
- Claude Code: Uses stop_reason='end_turn' (deduplicated by message ID)
|
|
375
|
+
- Codex: Uses token_count events (no deduplication needed)
|
|
235
376
|
|
|
236
|
-
|
|
237
|
-
|
|
377
|
+
Each complete dialogue round consists of:
|
|
378
|
+
1. User message/request
|
|
379
|
+
2. Assistant response
|
|
380
|
+
3. Turn completion marker (format-specific)
|
|
238
381
|
"""
|
|
239
382
|
try:
|
|
240
383
|
session_path = str(session_file)
|
|
241
|
-
|
|
384
|
+
session_type = self._detect_session_type(session_file)
|
|
385
|
+
|
|
386
|
+
current_count = self._count_complete_turns(session_file)
|
|
242
387
|
last_count = self.last_stop_reason_counts.get(session_path, 0)
|
|
243
388
|
|
|
244
|
-
|
|
389
|
+
new_turns = current_count - last_count
|
|
245
390
|
|
|
246
|
-
# Commit after each complete assistant response (1 new
|
|
247
|
-
if
|
|
248
|
-
|
|
391
|
+
# Commit after each complete assistant response (1 new turn)
|
|
392
|
+
if new_turns >= 1:
|
|
393
|
+
print(f"[MCP Watcher] Detected {new_turns} new turn(s) in {session_file.name} ({session_type})", file=sys.stderr)
|
|
249
394
|
# Update baseline immediately to avoid double-counting
|
|
250
395
|
self.last_stop_reason_counts[session_path] = current_count
|
|
251
396
|
return True
|
|
@@ -253,216 +398,174 @@ class DialogueWatcher:
|
|
|
253
398
|
return False
|
|
254
399
|
|
|
255
400
|
except Exception as e:
|
|
256
|
-
|
|
401
|
+
print(f"[MCP Watcher] Error checking turn completion: {e}", file=sys.stderr)
|
|
257
402
|
return False
|
|
258
403
|
|
|
259
|
-
def
|
|
404
|
+
async def _do_commit(self, project_path: Path, session_file: Path):
|
|
260
405
|
"""
|
|
261
|
-
|
|
406
|
+
Perform the actual commit for a specific project.
|
|
262
407
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
408
|
+
Args:
|
|
409
|
+
project_path: Path to the project directory
|
|
410
|
+
session_file: Session file that triggered the commit
|
|
266
411
|
"""
|
|
267
|
-
try:
|
|
268
|
-
project_dir_name = session_file.parent.name
|
|
269
|
-
_log(f"Extracting project path from: {project_dir_name}")
|
|
270
|
-
|
|
271
|
-
if project_dir_name.startswith('-'):
|
|
272
|
-
# Split into segments
|
|
273
|
-
segments = project_dir_name[1:].split('-')
|
|
274
|
-
_log(f"Path segments: {segments}")
|
|
275
|
-
|
|
276
|
-
# Try to reconstruct the path by checking which combinations exist
|
|
277
|
-
candidate_path = self._reconstruct_path_from_segments(segments)
|
|
278
|
-
|
|
279
|
-
if candidate_path and candidate_path.exists():
|
|
280
|
-
_log(f"Project path exists: {candidate_path}")
|
|
281
|
-
return candidate_path
|
|
282
|
-
else:
|
|
283
|
-
# Fallback: try simple replacement (for backward compatibility)
|
|
284
|
-
simple_path = Path('/' + project_dir_name[1:].replace('-', '/'))
|
|
285
|
-
if simple_path.exists():
|
|
286
|
-
_log(f"Project path exists (simple method): {simple_path}")
|
|
287
|
-
return simple_path
|
|
288
|
-
_log(f"WARNING: Could not find valid project path for: {project_dir_name}")
|
|
289
|
-
else:
|
|
290
|
-
_log(f"WARNING: Directory name doesn't start with '-': {project_dir_name}")
|
|
291
|
-
except Exception as e:
|
|
292
|
-
_log(f"Error extracting project path: {e}")
|
|
293
|
-
return None
|
|
294
|
-
|
|
295
|
-
def _reconstruct_path_from_segments(self, segments: list) -> Optional[Path]:
|
|
296
|
-
"""
|
|
297
|
-
Reconstruct a file path from encoded segments.
|
|
298
|
-
|
|
299
|
-
Claude Code encodes paths by replacing both '/' and '_' with '-'.
|
|
300
|
-
This method tries to find the correct path by testing which separators
|
|
301
|
-
produce valid paths.
|
|
302
|
-
|
|
303
|
-
Strategy:
|
|
304
|
-
1. Start from root '/'
|
|
305
|
-
2. Build path incrementally, checking if each partial path exists
|
|
306
|
-
3. When a segment doesn't match, try combining with next segment using '_' or '-'
|
|
307
|
-
"""
|
|
308
|
-
if not segments:
|
|
309
|
-
return None
|
|
310
|
-
|
|
311
|
-
current_path = Path('/')
|
|
312
|
-
i = 0
|
|
313
|
-
|
|
314
|
-
while i < len(segments):
|
|
315
|
-
segment = segments[i]
|
|
316
|
-
|
|
317
|
-
# Try direct match first (segment is a directory/file name)
|
|
318
|
-
test_path = current_path / segment
|
|
319
|
-
if test_path.exists():
|
|
320
|
-
current_path = test_path
|
|
321
|
-
i += 1
|
|
322
|
-
continue
|
|
323
|
-
|
|
324
|
-
# If direct match fails, try combining with next segments using '_' or '-'
|
|
325
|
-
found_match = False
|
|
326
|
-
for lookahead in range(1, min(10, len(segments) - i)): # Try up to 10 segments ahead
|
|
327
|
-
# Try combining segments with underscores
|
|
328
|
-
combined_underscore = '_'.join(segments[i:i+lookahead+1])
|
|
329
|
-
test_path_underscore = current_path / combined_underscore
|
|
330
|
-
|
|
331
|
-
# Try combining segments with hyphens
|
|
332
|
-
combined_hyphen = '-'.join(segments[i:i+lookahead+1])
|
|
333
|
-
test_path_hyphen = current_path / combined_hyphen
|
|
334
|
-
|
|
335
|
-
# Try mixed combinations for longer paths
|
|
336
|
-
if test_path_underscore.exists():
|
|
337
|
-
current_path = test_path_underscore
|
|
338
|
-
i += lookahead + 1
|
|
339
|
-
found_match = True
|
|
340
|
-
_log(f"Found match with underscores: {combined_underscore}")
|
|
341
|
-
break
|
|
342
|
-
elif test_path_hyphen.exists():
|
|
343
|
-
current_path = test_path_hyphen
|
|
344
|
-
i += lookahead + 1
|
|
345
|
-
found_match = True
|
|
346
|
-
_log(f"Found match with hyphens: {combined_hyphen}")
|
|
347
|
-
break
|
|
348
|
-
|
|
349
|
-
if not found_match:
|
|
350
|
-
# If no match found, this might be the final segment (file or non-existent dir)
|
|
351
|
-
# Just append remaining segments with '/' and check
|
|
352
|
-
remaining = '/'.join(segments[i:])
|
|
353
|
-
final_path = current_path / remaining
|
|
354
|
-
if final_path.exists():
|
|
355
|
-
return final_path
|
|
356
|
-
|
|
357
|
-
# Try treating remaining as underscore-joined
|
|
358
|
-
remaining_underscore = '_'.join(segments[i:])
|
|
359
|
-
final_path_underscore = current_path / remaining_underscore
|
|
360
|
-
if final_path_underscore.exists():
|
|
361
|
-
return final_path_underscore
|
|
362
|
-
|
|
363
|
-
# Nothing worked, return what we have
|
|
364
|
-
_log(f"Could not match segment '{segment}' at path {current_path}")
|
|
365
|
-
return None
|
|
366
|
-
|
|
367
|
-
return current_path if current_path != Path('/') else None
|
|
368
|
-
|
|
369
|
-
async def _do_commit(self, project_path: Path):
|
|
370
|
-
"""Perform the actual commit for a specific project."""
|
|
371
412
|
try:
|
|
372
413
|
# Generate commit message
|
|
373
414
|
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
374
415
|
message = f"chore: Auto-commit MCP session ({timestamp})"
|
|
375
416
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
# Use realign commit command with the specific project path
|
|
417
|
+
# Use realign commit command
|
|
379
418
|
result = await asyncio.get_event_loop().run_in_executor(
|
|
380
419
|
None,
|
|
381
420
|
self._run_realign_commit,
|
|
382
|
-
|
|
383
|
-
|
|
421
|
+
project_path,
|
|
422
|
+
message
|
|
384
423
|
)
|
|
385
424
|
|
|
386
425
|
if result:
|
|
387
|
-
|
|
388
|
-
|
|
426
|
+
print(f"[MCP Watcher] ✓ Committed to {project_path.name}: {message}", file=sys.stderr)
|
|
427
|
+
# Update last commit time for this project
|
|
428
|
+
self.last_commit_times[str(project_path)] = time.time()
|
|
389
429
|
# Baseline counts already updated in _check_if_turn_complete()
|
|
390
430
|
|
|
391
431
|
except Exception as e:
|
|
392
|
-
|
|
432
|
+
print(f"[MCP Watcher] Error during commit for {project_path}: {e}", file=sys.stderr)
|
|
393
433
|
|
|
394
|
-
def _run_realign_commit(self,
|
|
434
|
+
def _run_realign_commit(self, project_path: Path, message: str) -> bool:
|
|
395
435
|
"""
|
|
396
|
-
Run
|
|
436
|
+
Run realign commit command with file locking to prevent race conditions.
|
|
437
|
+
|
|
438
|
+
Args:
|
|
439
|
+
project_path: Path to the project directory
|
|
440
|
+
message: Commit message
|
|
397
441
|
|
|
398
442
|
The command will:
|
|
399
|
-
-
|
|
443
|
+
- Acquire a file lock to prevent concurrent commits from multiple watchers
|
|
444
|
+
- Auto-initialize git and ReAlign if needed
|
|
400
445
|
- Check for session changes (modified within last 5 minutes)
|
|
401
446
|
- Create empty commit if only sessions changed
|
|
402
447
|
- Return True if commit was created, False otherwise
|
|
403
448
|
"""
|
|
449
|
+
from .file_lock import commit_lock
|
|
450
|
+
|
|
404
451
|
try:
|
|
405
|
-
from
|
|
406
|
-
|
|
452
|
+
# Acquire commit lock to prevent concurrent commits from multiple MCP servers
|
|
453
|
+
with commit_lock(project_path, timeout=5.0) as locked:
|
|
454
|
+
if not locked:
|
|
455
|
+
print(f"[MCP Watcher] Another watcher is committing to {project_path.name}, skipping", file=sys.stderr)
|
|
456
|
+
return False
|
|
457
|
+
|
|
458
|
+
return self._do_commit_locked(project_path, message)
|
|
459
|
+
|
|
460
|
+
except TimeoutError:
|
|
461
|
+
print("[MCP Watcher] Could not acquire commit lock (timeout)", file=sys.stderr)
|
|
462
|
+
return False
|
|
463
|
+
except Exception as e:
|
|
464
|
+
print(f"[MCP Watcher] Commit error: {e}", file=sys.stderr)
|
|
465
|
+
return False
|
|
407
466
|
|
|
408
|
-
|
|
467
|
+
def _do_commit_locked(self, project_path: Path, message: str) -> bool:
|
|
468
|
+
"""
|
|
469
|
+
Perform the actual commit operation (must be called with lock held).
|
|
470
|
+
|
|
471
|
+
Args:
|
|
472
|
+
project_path: Path to the project directory
|
|
473
|
+
message: Commit message
|
|
474
|
+
|
|
475
|
+
Returns:
|
|
476
|
+
True if commit was created, False otherwise
|
|
477
|
+
"""
|
|
478
|
+
try:
|
|
479
|
+
# Check if ReAlign is initialized
|
|
409
480
|
realign_dir = project_path / ".realign"
|
|
410
481
|
|
|
411
482
|
if not realign_dir.exists():
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
# Auto-initialize
|
|
415
|
-
init_result =
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
483
|
+
print(f"[MCP Watcher] ReAlign not initialized in {project_path.name}, initializing...", file=sys.stderr)
|
|
484
|
+
|
|
485
|
+
# Auto-initialize ReAlign (which also inits git if needed)
|
|
486
|
+
init_result = subprocess.run(
|
|
487
|
+
["python3", "-m", "realign.cli", "init", "--yes"],
|
|
488
|
+
cwd=project_path,
|
|
489
|
+
capture_output=True,
|
|
490
|
+
text=True,
|
|
491
|
+
timeout=30,
|
|
419
492
|
)
|
|
420
493
|
|
|
421
|
-
if
|
|
422
|
-
|
|
494
|
+
if init_result.returncode != 0:
|
|
495
|
+
print(f"[MCP Watcher] Failed to initialize ReAlign in {project_path.name}: {init_result.stderr}", file=sys.stderr)
|
|
423
496
|
return False
|
|
424
497
|
|
|
425
|
-
|
|
498
|
+
print(f"[MCP Watcher] ✓ ReAlign initialized successfully in {project_path.name}", file=sys.stderr)
|
|
426
499
|
|
|
427
|
-
#
|
|
428
|
-
|
|
500
|
+
# Use direct function call instead of subprocess
|
|
501
|
+
# This allows environment variables (like OPENAI_API_KEY) to be inherited
|
|
502
|
+
from realign.commands.commit import commit_internal
|
|
503
|
+
|
|
504
|
+
success, commit_hash, status_msg = commit_internal(
|
|
505
|
+
repo_root=project_path,
|
|
429
506
|
message=message,
|
|
430
|
-
|
|
431
|
-
stage_all=True,
|
|
507
|
+
all_files=True,
|
|
432
508
|
amend=False,
|
|
433
509
|
no_edit=False,
|
|
434
510
|
)
|
|
435
511
|
|
|
436
|
-
|
|
437
|
-
|
|
512
|
+
if success:
|
|
513
|
+
print(f"[MCP Watcher] ✓ {status_msg}", file=sys.stderr)
|
|
438
514
|
return True
|
|
439
|
-
elif
|
|
515
|
+
elif "No changes detected" in status_msg:
|
|
440
516
|
# No changes - this is expected, not an error
|
|
441
517
|
return False
|
|
442
518
|
else:
|
|
443
519
|
# Log the error for debugging
|
|
444
|
-
|
|
445
|
-
_log(f"Commit failed: {error_msg}")
|
|
520
|
+
print(f"[MCP Watcher] Commit failed for {project_path.name}: {status_msg}", file=sys.stderr)
|
|
446
521
|
return False
|
|
447
522
|
|
|
448
523
|
except Exception as e:
|
|
449
|
-
|
|
524
|
+
print(f"[MCP Watcher] Commit error for {project_path.name}: {e}", file=sys.stderr)
|
|
450
525
|
return False
|
|
451
526
|
|
|
527
|
+
def _detect_project_path(self) -> Optional[Path]:
|
|
528
|
+
"""
|
|
529
|
+
Attempt to detect the git repository root for the current process.
|
|
530
|
+
Returns None if not inside a git repo.
|
|
531
|
+
"""
|
|
532
|
+
try:
|
|
533
|
+
result = subprocess.run(
|
|
534
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
535
|
+
capture_output=True,
|
|
536
|
+
text=True,
|
|
537
|
+
check=True,
|
|
538
|
+
)
|
|
539
|
+
repo_path = Path(result.stdout.strip())
|
|
540
|
+
if repo_path.exists():
|
|
541
|
+
return repo_path
|
|
542
|
+
except subprocess.CalledProcessError:
|
|
543
|
+
current_dir = Path.cwd()
|
|
544
|
+
if (current_dir / ".git").exists():
|
|
545
|
+
return current_dir
|
|
546
|
+
except Exception as e:
|
|
547
|
+
print(f"[MCP Watcher] Could not detect project path: {e}", file=sys.stderr)
|
|
548
|
+
return None
|
|
549
|
+
|
|
452
550
|
|
|
453
551
|
# Global watcher instance
|
|
454
552
|
_watcher: Optional[DialogueWatcher] = None
|
|
455
553
|
|
|
456
554
|
|
|
457
|
-
async def start_watcher(
|
|
458
|
-
"""
|
|
555
|
+
async def start_watcher():
|
|
556
|
+
"""
|
|
557
|
+
Start the global session watcher for auto-commit on user request completion.
|
|
558
|
+
|
|
559
|
+
No longer requires a repo_path - the watcher will dynamically extract project paths
|
|
560
|
+
from session files and commit to the appropriate repositories.
|
|
561
|
+
"""
|
|
459
562
|
global _watcher
|
|
460
563
|
|
|
461
564
|
if _watcher and _watcher.running:
|
|
462
565
|
print("[MCP Watcher] Already running", file=sys.stderr)
|
|
463
566
|
return
|
|
464
567
|
|
|
465
|
-
_watcher = DialogueWatcher(
|
|
568
|
+
_watcher = DialogueWatcher()
|
|
466
569
|
asyncio.create_task(_watcher.start())
|
|
467
570
|
|
|
468
571
|
|