aline-ai 0.2.5__py3-none-any.whl → 0.3.0__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.2.5.dist-info → aline_ai-0.3.0.dist-info}/METADATA +3 -1
- aline_ai-0.3.0.dist-info/RECORD +41 -0
- aline_ai-0.3.0.dist-info/entry_points.txt +3 -0
- realign/__init__.py +32 -1
- realign/cli.py +203 -19
- realign/commands/__init__.py +2 -2
- realign/commands/clean.py +149 -0
- realign/commands/config.py +1 -1
- realign/commands/export_shares.py +1785 -0
- realign/commands/hide.py +112 -24
- realign/commands/import_history.py +873 -0
- realign/commands/init.py +104 -217
- realign/commands/mirror.py +131 -0
- realign/commands/pull.py +101 -0
- realign/commands/push.py +155 -245
- realign/commands/review.py +216 -54
- realign/commands/session_utils.py +139 -4
- realign/commands/share.py +965 -0
- realign/commands/status.py +559 -0
- realign/commands/sync.py +91 -0
- realign/commands/undo.py +423 -0
- realign/commands/watcher.py +805 -0
- realign/config.py +21 -10
- realign/file_lock.py +3 -1
- realign/hash_registry.py +310 -0
- realign/hooks.py +368 -384
- realign/logging_config.py +2 -2
- realign/mcp_server.py +263 -549
- realign/mcp_watcher.py +999 -142
- realign/mirror_utils.py +322 -0
- realign/prompts/__init__.py +21 -0
- realign/prompts/presets.py +238 -0
- realign/redactor.py +168 -16
- realign/tracker/__init__.py +9 -0
- realign/tracker/git_tracker.py +1123 -0
- realign/watcher_daemon.py +115 -0
- aline_ai-0.2.5.dist-info/RECORD +0 -28
- aline_ai-0.2.5.dist-info/entry_points.txt +0 -5
- realign/commands/auto_commit.py +0 -231
- realign/commands/commit.py +0 -379
- realign/commands/search.py +0 -449
- realign/commands/show.py +0 -416
- {aline_ai-0.2.5.dist-info → aline_ai-0.3.0.dist-info}/WHEEL +0 -0
- {aline_ai-0.2.5.dist-info → aline_ai-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {aline_ai-0.2.5.dist-info → aline_ai-0.3.0.dist-info}/top_level.txt +0 -0
realign/mcp_server.py
CHANGED
|
@@ -1,581 +1,305 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Aline MCP Server -
|
|
2
|
+
"""Aline MCP Server - Query shared conversations via Model Context Protocol."""
|
|
3
3
|
|
|
4
4
|
import asyncio
|
|
5
|
+
import hashlib
|
|
5
6
|
import json
|
|
6
|
-
import subprocess
|
|
7
7
|
import sys
|
|
8
|
-
from pathlib import Path
|
|
9
8
|
from typing import Any, Optional
|
|
9
|
+
from urllib.parse import urlparse
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
import httpx
|
|
13
|
+
HTTPX_AVAILABLE = True
|
|
14
|
+
except ImportError:
|
|
15
|
+
HTTPX_AVAILABLE = False
|
|
10
16
|
|
|
11
17
|
from mcp.server import Server
|
|
12
18
|
from mcp.server.stdio import stdio_server
|
|
13
19
|
from mcp.types import Tool, TextContent
|
|
14
20
|
|
|
15
|
-
from .config import ReAlignConfig
|
|
16
|
-
from .commands import init, search, show, commit
|
|
17
|
-
from .mcp_watcher import start_watcher
|
|
18
|
-
|
|
19
21
|
from . import __version__
|
|
20
22
|
|
|
21
23
|
# Initialize MCP server
|
|
22
24
|
app = Server("aline")
|
|
23
25
|
|
|
24
26
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
return [
|
|
29
|
-
Tool(
|
|
30
|
-
name="aline_init",
|
|
31
|
-
description=(
|
|
32
|
-
"Initialize Aline in a directory to enable AI conversation tracking. "
|
|
33
|
-
"This command automatically sets up the workspace and configures session tracking. "
|
|
34
|
-
"Use this to start tracking AI conversations in your project."
|
|
35
|
-
),
|
|
36
|
-
inputSchema={
|
|
37
|
-
"type": "object",
|
|
38
|
-
"properties": {
|
|
39
|
-
"repo_path": {
|
|
40
|
-
"type": "string",
|
|
41
|
-
"description": "Path to directory to initialize (default: current directory)",
|
|
42
|
-
},
|
|
43
|
-
},
|
|
44
|
-
},
|
|
45
|
-
),
|
|
46
|
-
Tool(
|
|
47
|
-
name="aline_search",
|
|
48
|
-
description=(
|
|
49
|
-
"Search through AI agent chat sessions. "
|
|
50
|
-
"Finds sessions with matching keywords in session content. "
|
|
51
|
-
"Returns session information and optionally full content. "
|
|
52
|
-
"Can search in sessions only or include metadata."
|
|
53
|
-
),
|
|
54
|
-
inputSchema={
|
|
55
|
-
"type": "object",
|
|
56
|
-
"properties": {
|
|
57
|
-
"keyword": {
|
|
58
|
-
"type": "string",
|
|
59
|
-
"description": "Keyword to search for in sessions",
|
|
60
|
-
},
|
|
61
|
-
"repo_path": {
|
|
62
|
-
"type": "string",
|
|
63
|
-
"description": "Path to workspace (default: current directory)",
|
|
64
|
-
},
|
|
65
|
-
"show_session": {
|
|
66
|
-
"type": "boolean",
|
|
67
|
-
"description": "Include full session content in results",
|
|
68
|
-
"default": False,
|
|
69
|
-
},
|
|
70
|
-
"max_results": {
|
|
71
|
-
"type": "integer",
|
|
72
|
-
"description": "Maximum number of results to return",
|
|
73
|
-
"default": 20,
|
|
74
|
-
},
|
|
75
|
-
"session_only": {
|
|
76
|
-
"type": "boolean",
|
|
77
|
-
"description": "Search only in session files. Useful for finding topics discussed in historical conversations.",
|
|
78
|
-
"default": False,
|
|
79
|
-
},
|
|
80
|
-
"commits_only": {
|
|
81
|
-
"type": "boolean",
|
|
82
|
-
"description": "Search only in metadata, not session files.",
|
|
83
|
-
"default": False,
|
|
84
|
-
},
|
|
85
|
-
},
|
|
86
|
-
"required": ["keyword"],
|
|
87
|
-
},
|
|
88
|
-
),
|
|
89
|
-
Tool(
|
|
90
|
-
name="aline_show",
|
|
91
|
-
description=(
|
|
92
|
-
"Display an AI agent chat session. "
|
|
93
|
-
"Supports navigation through the session with line ranges. "
|
|
94
|
-
"Use aline_search first to find matching lines, then use this tool to view context around those lines."
|
|
95
|
-
),
|
|
96
|
-
inputSchema={
|
|
97
|
-
"type": "object",
|
|
98
|
-
"properties": {
|
|
99
|
-
"commit_hash": {
|
|
100
|
-
"type": "string",
|
|
101
|
-
"description": "Session identifier (full or short)",
|
|
102
|
-
},
|
|
103
|
-
"repo_path": {
|
|
104
|
-
"type": "string",
|
|
105
|
-
"description": "Path to workspace (default: current directory)",
|
|
106
|
-
},
|
|
107
|
-
"session_path": {
|
|
108
|
-
"type": "string",
|
|
109
|
-
"description": "Direct path to session file (alternative to commit_hash)",
|
|
110
|
-
},
|
|
111
|
-
"format": {
|
|
112
|
-
"type": "string",
|
|
113
|
-
"enum": ["pretty", "json", "raw"],
|
|
114
|
-
"description": "Output format",
|
|
115
|
-
"default": "pretty",
|
|
116
|
-
},
|
|
117
|
-
"from_line": {
|
|
118
|
-
"type": "integer",
|
|
119
|
-
"description": "Start from line number (inclusive, 1-indexed)",
|
|
120
|
-
},
|
|
121
|
-
"to_line": {
|
|
122
|
-
"type": "integer",
|
|
123
|
-
"description": "End at line number (inclusive, 1-indexed)",
|
|
124
|
-
},
|
|
125
|
-
"around_line": {
|
|
126
|
-
"type": "integer",
|
|
127
|
-
"description": "Show lines around this line number (use with context)",
|
|
128
|
-
},
|
|
129
|
-
"context": {
|
|
130
|
-
"type": "integer",
|
|
131
|
-
"description": "Number of lines before/after when using around_line (default: 5)",
|
|
132
|
-
"default": 5,
|
|
133
|
-
},
|
|
134
|
-
"first": {
|
|
135
|
-
"type": "integer",
|
|
136
|
-
"description": "Show only first N lines",
|
|
137
|
-
},
|
|
138
|
-
"last": {
|
|
139
|
-
"type": "integer",
|
|
140
|
-
"description": "Show only last N lines",
|
|
141
|
-
},
|
|
142
|
-
},
|
|
143
|
-
},
|
|
144
|
-
),
|
|
145
|
-
Tool(
|
|
146
|
-
name="aline_get_latest_session",
|
|
147
|
-
description=(
|
|
148
|
-
"Get the path to the most recent AI agent chat session. "
|
|
149
|
-
"Automatically detects Claude Code sessions or uses configured history path. "
|
|
150
|
-
"Useful for retrieving the current conversation context."
|
|
151
|
-
),
|
|
152
|
-
inputSchema={
|
|
153
|
-
"type": "object",
|
|
154
|
-
"properties": {
|
|
155
|
-
"repo_path": {
|
|
156
|
-
"type": "string",
|
|
157
|
-
"description": "Path to workspace (for Claude auto-detection)",
|
|
158
|
-
},
|
|
159
|
-
},
|
|
160
|
-
},
|
|
161
|
-
),
|
|
162
|
-
Tool(
|
|
163
|
-
name="aline_version",
|
|
164
|
-
description=(
|
|
165
|
-
f"Get the current version of Aline ({__version__}). "
|
|
166
|
-
"Aline is a tool that tracks and preserves AI agent chat sessions."
|
|
167
|
-
),
|
|
168
|
-
inputSchema={
|
|
169
|
-
"type": "object",
|
|
170
|
-
"properties": {},
|
|
171
|
-
},
|
|
172
|
-
),
|
|
173
|
-
Tool(
|
|
174
|
-
name="aline_review",
|
|
175
|
-
description=(
|
|
176
|
-
"Review unpushed commits before pushing. "
|
|
177
|
-
"Shows commit messages, LLM summaries, session files modified, and line counts. "
|
|
178
|
-
"Helps identify what content will be made public when you push."
|
|
179
|
-
),
|
|
180
|
-
inputSchema={
|
|
181
|
-
"type": "object",
|
|
182
|
-
"properties": {
|
|
183
|
-
"repo_path": {
|
|
184
|
-
"type": "string",
|
|
185
|
-
"description": "Path to repository root (default: current directory)",
|
|
186
|
-
},
|
|
187
|
-
"verbose": {
|
|
188
|
-
"type": "boolean",
|
|
189
|
-
"description": "Show detailed information",
|
|
190
|
-
"default": False,
|
|
191
|
-
},
|
|
192
|
-
"detect_secrets": {
|
|
193
|
-
"type": "boolean",
|
|
194
|
-
"description": "Run sensitive content detection",
|
|
195
|
-
"default": False,
|
|
196
|
-
},
|
|
197
|
-
},
|
|
198
|
-
},
|
|
199
|
-
),
|
|
200
|
-
Tool(
|
|
201
|
-
name="aline_hide",
|
|
202
|
-
description=(
|
|
203
|
-
"Hide (redact) specific unpushed commits by rewriting git history. "
|
|
204
|
-
"This redacts commit messages and session content. "
|
|
205
|
-
"WARNING: This rewrites git history. Use with caution."
|
|
206
|
-
),
|
|
207
|
-
inputSchema={
|
|
208
|
-
"type": "object",
|
|
209
|
-
"properties": {
|
|
210
|
-
"indices": {
|
|
211
|
-
"type": "string",
|
|
212
|
-
"description": (
|
|
213
|
-
"Commit indices to hide (e.g., '1', '1,3,5', '2-4', or '--all'). "
|
|
214
|
-
"Use aline_review first to see the commit indices."
|
|
215
|
-
),
|
|
216
|
-
},
|
|
217
|
-
"repo_path": {
|
|
218
|
-
"type": "string",
|
|
219
|
-
"description": "Path to repository root (default: current directory)",
|
|
220
|
-
},
|
|
221
|
-
"force": {
|
|
222
|
-
"type": "boolean",
|
|
223
|
-
"description": "Skip confirmation prompt",
|
|
224
|
-
"default": False,
|
|
225
|
-
},
|
|
226
|
-
},
|
|
227
|
-
"required": ["indices"],
|
|
228
|
-
},
|
|
229
|
-
),
|
|
230
|
-
]
|
|
27
|
+
def extract_share_id(share_url: str) -> str:
|
|
28
|
+
"""
|
|
29
|
+
Extract share ID from share URL.
|
|
231
30
|
|
|
31
|
+
Examples:
|
|
32
|
+
https://realign-server.vercel.app/share/abc123 -> abc123
|
|
33
|
+
https://example.com/share/xyz789/chat -> xyz789
|
|
34
|
+
"""
|
|
35
|
+
# Remove trailing slash
|
|
36
|
+
url = share_url.rstrip('/')
|
|
232
37
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
38
|
+
# Extract path components
|
|
39
|
+
parsed = urlparse(url)
|
|
40
|
+
path_parts = [p for p in parsed.path.split('/') if p]
|
|
236
41
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
result = await handle_search(arguments)
|
|
243
|
-
elif name == "aline_show":
|
|
244
|
-
result = await handle_show(arguments)
|
|
245
|
-
elif name == "aline_get_latest_session":
|
|
246
|
-
result = await handle_get_latest_session(arguments)
|
|
247
|
-
elif name == "aline_version":
|
|
248
|
-
result = await handle_version(arguments)
|
|
249
|
-
elif name == "aline_review":
|
|
250
|
-
result = await handle_review(arguments)
|
|
251
|
-
elif name == "aline_hide":
|
|
252
|
-
result = await handle_hide(arguments)
|
|
253
|
-
else:
|
|
254
|
-
return [TextContent(type="text", text=f"Unknown tool: {name}")]
|
|
42
|
+
# Find 'share' in path and get next component
|
|
43
|
+
if 'share' in path_parts:
|
|
44
|
+
share_idx = path_parts.index('share')
|
|
45
|
+
if share_idx + 1 < len(path_parts):
|
|
46
|
+
return path_parts[share_idx + 1]
|
|
255
47
|
|
|
256
|
-
|
|
48
|
+
raise ValueError(f"Could not extract share ID from URL: {share_url}")
|
|
257
49
|
|
|
258
|
-
except Exception as e:
|
|
259
|
-
return [TextContent(type="text", text=f"Error: {str(e)}")]
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
async def handle_init(args: dict) -> list[TextContent]:
|
|
263
|
-
"""Handle aline_init tool."""
|
|
264
|
-
from .commands.init import init_repository
|
|
265
|
-
|
|
266
|
-
repo_path = args.get("repo_path", ".")
|
|
267
|
-
|
|
268
|
-
# Call Python function directly instead of subprocess
|
|
269
|
-
# This avoids PATH issues completely
|
|
270
|
-
result = init_repository(
|
|
271
|
-
repo_path=repo_path,
|
|
272
|
-
auto_init_git=True,
|
|
273
|
-
skip_commit=False,
|
|
274
|
-
)
|
|
275
|
-
|
|
276
|
-
# Format output with detailed parameters
|
|
277
|
-
if result["success"]:
|
|
278
|
-
output_lines = [
|
|
279
|
-
"✅ Aline initialized successfully\n",
|
|
280
|
-
"Parameters:",
|
|
281
|
-
f" • Workspace Path: {result.get('repo_path', 'N/A')}",
|
|
282
|
-
f" • Workspace Root: {result.get('repo_root', 'N/A')}",
|
|
283
|
-
f" • Aline Directory: {result.get('realign_dir', 'N/A')}",
|
|
284
|
-
f" • Config Path: {result.get('config_path', 'N/A')}",
|
|
285
|
-
f" • History Directory: {result.get('history_dir', 'N/A')}",
|
|
286
|
-
f" • Initialized: {result.get('git_initialized', False)}",
|
|
287
|
-
f" • Hooks Created: {', '.join(result.get('hooks_created', [])) or 'None'}",
|
|
288
|
-
f" • Auto-committed: {result.get('committed', False)}",
|
|
289
|
-
"",
|
|
290
|
-
"Aline is now tracking your AI conversations automatically.",
|
|
291
|
-
"Use aline_search to find past discussions.",
|
|
292
|
-
]
|
|
293
|
-
return [TextContent(type="text", text="\n".join(output_lines))]
|
|
294
|
-
else:
|
|
295
|
-
error_lines = [
|
|
296
|
-
"❌ Failed to initialize Aline\n",
|
|
297
|
-
f"Error: {result['message']}",
|
|
298
|
-
]
|
|
299
|
-
if result.get("errors"):
|
|
300
|
-
error_lines.append("\nDetails:")
|
|
301
|
-
for error in result["errors"]:
|
|
302
|
-
error_lines.append(f" • {error}")
|
|
303
|
-
return [TextContent(type="text", text="\n".join(error_lines))]
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
async def handle_search(args: dict) -> list[TextContent]:
|
|
307
|
-
"""Handle aline_search tool."""
|
|
308
|
-
keyword = args["keyword"]
|
|
309
|
-
repo_path = args.get("repo_path", ".")
|
|
310
|
-
show_session = args.get("show_session", False)
|
|
311
|
-
max_results = args.get("max_results", 20)
|
|
312
|
-
session_only = args.get("session_only", False)
|
|
313
|
-
commits_only = args.get("commits_only", False)
|
|
314
|
-
|
|
315
|
-
# Build command
|
|
316
|
-
cmd = ["aline", "search", keyword, "--max", str(max_results)]
|
|
317
|
-
if show_session:
|
|
318
|
-
cmd.append("--show-session")
|
|
319
|
-
if session_only:
|
|
320
|
-
cmd.append("--session-only")
|
|
321
|
-
if commits_only:
|
|
322
|
-
cmd.append("--commits-only")
|
|
323
|
-
|
|
324
|
-
# Run command
|
|
325
|
-
result = subprocess.run(
|
|
326
|
-
cmd,
|
|
327
|
-
cwd=repo_path,
|
|
328
|
-
capture_output=True,
|
|
329
|
-
text=True,
|
|
330
|
-
)
|
|
331
|
-
|
|
332
|
-
output = result.stdout
|
|
333
|
-
if result.returncode == 0:
|
|
334
|
-
return [TextContent(type="text", text=output or "No results found")]
|
|
335
|
-
else:
|
|
336
|
-
return [TextContent(
|
|
337
|
-
type="text",
|
|
338
|
-
text=f"Error searching: {result.stderr}"
|
|
339
|
-
)]
|
|
340
50
|
|
|
51
|
+
def extract_base_url(share_url: str) -> str:
|
|
52
|
+
"""
|
|
53
|
+
Extract base URL from share URL.
|
|
341
54
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
format_type = args.get("format", "pretty")
|
|
348
|
-
from_line = args.get("from_line")
|
|
349
|
-
to_line = args.get("to_line")
|
|
350
|
-
around_line = args.get("around_line")
|
|
351
|
-
context = args.get("context", 5)
|
|
352
|
-
first = args.get("first")
|
|
353
|
-
last = args.get("last")
|
|
354
|
-
|
|
355
|
-
# Build command
|
|
356
|
-
cmd = ["aline", "show"]
|
|
357
|
-
if session_path:
|
|
358
|
-
cmd.extend(["--session", session_path])
|
|
359
|
-
elif commit_hash:
|
|
360
|
-
cmd.append(commit_hash)
|
|
361
|
-
else:
|
|
362
|
-
return [TextContent(
|
|
363
|
-
type="text",
|
|
364
|
-
text="Error: Must provide either commit_hash or session_path"
|
|
365
|
-
)]
|
|
55
|
+
Examples:
|
|
56
|
+
https://realign-server.vercel.app/share/abc123 -> https://realign-server.vercel.app
|
|
57
|
+
"""
|
|
58
|
+
parsed = urlparse(share_url)
|
|
59
|
+
return f"{parsed.scheme}://{parsed.netloc}"
|
|
366
60
|
|
|
367
|
-
cmd.extend(["--format", format_type])
|
|
368
|
-
|
|
369
|
-
# Add range parameters
|
|
370
|
-
if from_line is not None:
|
|
371
|
-
cmd.extend(["--from", str(from_line)])
|
|
372
|
-
if to_line is not None:
|
|
373
|
-
cmd.extend(["--to", str(to_line)])
|
|
374
|
-
if around_line is not None:
|
|
375
|
-
cmd.extend(["--around", str(around_line)])
|
|
376
|
-
if context != 5: # Only add if not default
|
|
377
|
-
cmd.extend(["--context", str(context)])
|
|
378
|
-
if first is not None:
|
|
379
|
-
cmd.extend(["--first", str(first)])
|
|
380
|
-
if last is not None:
|
|
381
|
-
cmd.extend(["--last", str(last)])
|
|
382
|
-
|
|
383
|
-
# Run command
|
|
384
|
-
result = subprocess.run(
|
|
385
|
-
cmd,
|
|
386
|
-
cwd=repo_path,
|
|
387
|
-
capture_output=True,
|
|
388
|
-
text=True,
|
|
389
|
-
)
|
|
390
|
-
|
|
391
|
-
if result.returncode == 0:
|
|
392
|
-
return [TextContent(type="text", text=result.stdout)]
|
|
393
|
-
else:
|
|
394
|
-
return [TextContent(
|
|
395
|
-
type="text",
|
|
396
|
-
text=f"Error showing session: {result.stderr}"
|
|
397
|
-
)]
|
|
398
61
|
|
|
62
|
+
async def authenticate_share(share_url: str, password: Optional[str] = None) -> tuple[str, str, str]:
|
|
63
|
+
"""
|
|
64
|
+
Authenticate with share and get session token.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
share_url: The share URL
|
|
68
|
+
password: Optional password for encrypted shares
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
tuple of (base_url, share_id, session_token)
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
ValueError: If authentication fails
|
|
75
|
+
httpx.HTTPError: If network request fails
|
|
76
|
+
"""
|
|
77
|
+
if not HTTPX_AVAILABLE:
|
|
78
|
+
raise RuntimeError("httpx package is required. Install with: pip install httpx")
|
|
79
|
+
|
|
80
|
+
share_id = extract_share_id(share_url)
|
|
81
|
+
base_url = extract_base_url(share_url)
|
|
399
82
|
|
|
400
|
-
async
|
|
401
|
-
|
|
402
|
-
|
|
83
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
84
|
+
# Step 1: Check if password is required
|
|
85
|
+
info_resp = await client.get(f"{base_url}/api/share/{share_id}/info")
|
|
86
|
+
info_resp.raise_for_status()
|
|
87
|
+
info = info_resp.json()
|
|
403
88
|
|
|
404
|
-
|
|
405
|
-
config = ReAlignConfig.load()
|
|
89
|
+
requires_password = info.get("requires_password", False)
|
|
406
90
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
91
|
+
# Step 2: Authenticate
|
|
92
|
+
if requires_password:
|
|
93
|
+
if not password:
|
|
94
|
+
raise ValueError("This share requires a password, but none was provided")
|
|
95
|
+
|
|
96
|
+
# Hash password (SHA-256)
|
|
97
|
+
password_hash = hashlib.sha256(password.encode()).hexdigest()
|
|
98
|
+
|
|
99
|
+
auth_resp = await client.post(
|
|
100
|
+
f"{base_url}/api/share/{share_id}/auth",
|
|
101
|
+
json={"password_hash": password_hash}
|
|
102
|
+
)
|
|
103
|
+
auth_resp.raise_for_status()
|
|
104
|
+
data = auth_resp.json()
|
|
105
|
+
else:
|
|
106
|
+
# No password needed - create session directly
|
|
107
|
+
session_resp = await client.post(
|
|
108
|
+
f"{base_url}/api/share/{share_id}/session"
|
|
416
109
|
)
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
110
|
+
session_resp.raise_for_status()
|
|
111
|
+
data = session_resp.json()
|
|
112
|
+
|
|
113
|
+
session_token = data.get("session_token")
|
|
114
|
+
if not session_token:
|
|
115
|
+
raise ValueError("Failed to obtain session token")
|
|
116
|
+
|
|
117
|
+
return base_url, share_id, session_token
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
async def ask_conversation(
|
|
121
|
+
base_url: str,
|
|
122
|
+
share_id: str,
|
|
123
|
+
session_token: str,
|
|
124
|
+
question: str
|
|
125
|
+
) -> str:
|
|
126
|
+
"""
|
|
127
|
+
Send a question to the remote AI agent and receive the answer.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
base_url: Base URL of the share service
|
|
131
|
+
share_id: Share identifier
|
|
132
|
+
session_token: Session token from authentication
|
|
133
|
+
question: The question to ask
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
The answer from the remote AI agent
|
|
137
|
+
|
|
138
|
+
Raises:
|
|
139
|
+
httpx.HTTPError: If network request fails
|
|
140
|
+
"""
|
|
141
|
+
if not HTTPX_AVAILABLE:
|
|
142
|
+
raise RuntimeError("httpx package is required. Install with: pip install httpx")
|
|
143
|
+
|
|
144
|
+
async with httpx.AsyncClient(timeout=120.0) as client:
|
|
145
|
+
# Send question to chat API with proper UIMessage format
|
|
146
|
+
# UIMessage requires 'parts' instead of 'content'
|
|
147
|
+
resp = await client.post(
|
|
148
|
+
f"{base_url}/api/chat/{share_id}",
|
|
149
|
+
headers={"x-session-token": session_token},
|
|
150
|
+
json={"messages": [{"role": "user", "parts": [{"type": "text", "text": question}]}]}
|
|
151
|
+
)
|
|
152
|
+
resp.raise_for_status()
|
|
153
|
+
|
|
154
|
+
# Parse streaming response from Vercel AI SDK
|
|
155
|
+
# The AI SDK returns a UIMessageStreamResponse with multiple data chunks
|
|
156
|
+
# We only need to extract the final text content
|
|
157
|
+
text_chunks = []
|
|
158
|
+
|
|
159
|
+
async for chunk in resp.aiter_text():
|
|
160
|
+
# Split into lines and process each
|
|
161
|
+
for line in chunk.split('\n'):
|
|
162
|
+
line = line.strip()
|
|
163
|
+
if not line:
|
|
164
|
+
continue
|
|
165
|
+
|
|
166
|
+
# Remove "data: " prefix if present
|
|
167
|
+
if line.startswith('data: '):
|
|
168
|
+
line = line[6:]
|
|
169
|
+
|
|
170
|
+
# Try to parse as JSON
|
|
171
|
+
try:
|
|
172
|
+
data = json.loads(line)
|
|
173
|
+
|
|
174
|
+
# Vercel AI SDK sends different types of chunks
|
|
175
|
+
# We're looking for text deltas (type: "text-delta")
|
|
176
|
+
if isinstance(data, dict):
|
|
177
|
+
# Extract text from text-delta chunks
|
|
178
|
+
# The field name is 'delta' not 'textDelta'
|
|
179
|
+
if data.get('type') == 'text-delta':
|
|
180
|
+
delta = data.get('delta', '')
|
|
181
|
+
if delta:
|
|
182
|
+
text_chunks.append(delta)
|
|
183
|
+
|
|
184
|
+
except json.JSONDecodeError:
|
|
185
|
+
# Not JSON, skip
|
|
186
|
+
continue
|
|
187
|
+
|
|
188
|
+
# Combine all text chunks to get the final answer
|
|
189
|
+
answer = ''.join(text_chunks)
|
|
190
|
+
|
|
191
|
+
# Apply reasonable length limit to prevent overwhelming the MCP client
|
|
192
|
+
# If answer is too long, truncate and add notice
|
|
193
|
+
MAX_RESPONSE_LENGTH = 50000 # ~50KB of text
|
|
194
|
+
if len(answer) > MAX_RESPONSE_LENGTH:
|
|
195
|
+
answer = answer[:MAX_RESPONSE_LENGTH] + "\n\n[Response truncated due to length. Please ask more specific questions to get complete answers.]"
|
|
196
|
+
|
|
197
|
+
return answer if answer else "No response received from the agent."
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
async def handle_ask_tool(
|
|
201
|
+
share_url: str,
|
|
202
|
+
question: str,
|
|
203
|
+
password: Optional[str] = None
|
|
204
|
+
) -> list[TextContent]:
|
|
205
|
+
"""
|
|
206
|
+
Handle the ask_shared_conversation tool.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
share_url: URL of the shared conversation
|
|
210
|
+
question: Question to ask
|
|
211
|
+
password: Optional password for encrypted shares
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
List of TextContent with the answer or error
|
|
215
|
+
"""
|
|
216
|
+
try:
|
|
217
|
+
# Authenticate and get session token
|
|
218
|
+
base_url, share_id, token = await authenticate_share(share_url, password)
|
|
422
219
|
|
|
423
|
-
|
|
424
|
-
|
|
220
|
+
# Ask the question
|
|
221
|
+
answer = await ask_conversation(base_url, share_id, token, question)
|
|
425
222
|
|
|
426
|
-
|
|
223
|
+
# Return the answer
|
|
427
224
|
return [TextContent(
|
|
428
225
|
type="text",
|
|
429
|
-
text=
|
|
226
|
+
text=answer
|
|
430
227
|
)]
|
|
431
228
|
|
|
432
|
-
|
|
433
|
-
session_files = sorted(
|
|
434
|
-
history_path.glob("*.jsonl"),
|
|
435
|
-
key=lambda p: p.stat().st_mtime,
|
|
436
|
-
reverse=True,
|
|
437
|
-
)
|
|
438
|
-
|
|
439
|
-
if session_files:
|
|
229
|
+
except ValueError as e:
|
|
440
230
|
return [TextContent(
|
|
441
231
|
type="text",
|
|
442
|
-
text=f"
|
|
232
|
+
text=f"Authentication error: {str(e)}"
|
|
443
233
|
)]
|
|
444
|
-
|
|
234
|
+
except Exception as e:
|
|
445
235
|
return [TextContent(
|
|
446
236
|
type="text",
|
|
447
|
-
text=f"
|
|
237
|
+
text=f"Error querying shared conversation: {str(e)}"
|
|
448
238
|
)]
|
|
449
239
|
|
|
450
240
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
sys.stdout = old_stdout
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
async def handle_hide(args: dict) -> list[TextContent]:
|
|
496
|
-
"""Handle aline_hide tool."""
|
|
497
|
-
from .commands.hide import hide_command
|
|
498
|
-
from io import StringIO
|
|
499
|
-
import sys
|
|
500
|
-
|
|
501
|
-
indices = args["indices"]
|
|
502
|
-
repo_path = args.get("repo_path")
|
|
503
|
-
force = args.get("force", False)
|
|
504
|
-
|
|
505
|
-
# Convert repo_path to Path if provided
|
|
506
|
-
repo_root = Path(repo_path) if repo_path else None
|
|
241
|
+
@app.list_tools()
|
|
242
|
+
async def list_tools() -> list[Tool]:
|
|
243
|
+
"""List available tools."""
|
|
244
|
+
return [
|
|
245
|
+
Tool(
|
|
246
|
+
name="ask_shared_conversation",
|
|
247
|
+
description=(
|
|
248
|
+
"Ask a question to a shared conversation. The remote AI agent will "
|
|
249
|
+
"search through the conversation history and provide an answer. "
|
|
250
|
+
"This enables agent-to-agent communication where your local agent "
|
|
251
|
+
"can query information from a remote conversation share."
|
|
252
|
+
),
|
|
253
|
+
inputSchema={
|
|
254
|
+
"type": "object",
|
|
255
|
+
"properties": {
|
|
256
|
+
"share_url": {
|
|
257
|
+
"type": "string",
|
|
258
|
+
"description": (
|
|
259
|
+
"The full URL of the shared conversation "
|
|
260
|
+
"(e.g., https://realign-server.vercel.app/share/abc123xyz)"
|
|
261
|
+
),
|
|
262
|
+
},
|
|
263
|
+
"question": {
|
|
264
|
+
"type": "string",
|
|
265
|
+
"description": (
|
|
266
|
+
"The question to ask about the conversation. "
|
|
267
|
+
"Be specific to get better answers from the remote agent."
|
|
268
|
+
),
|
|
269
|
+
},
|
|
270
|
+
"password": {
|
|
271
|
+
"type": "string",
|
|
272
|
+
"description": (
|
|
273
|
+
"Password for encrypted shares (optional). "
|
|
274
|
+
"Leave empty for public shares."
|
|
275
|
+
),
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
"required": ["share_url", "question"],
|
|
279
|
+
},
|
|
280
|
+
),
|
|
281
|
+
]
|
|
507
282
|
|
|
508
|
-
# Capture stdout
|
|
509
|
-
old_stdout = sys.stdout
|
|
510
|
-
sys.stdout = captured_output = StringIO()
|
|
511
283
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
284
|
+
@app.call_tool()
|
|
285
|
+
async def call_tool(name: str, arguments: Any) -> list[TextContent]:
|
|
286
|
+
"""Handle tool execution."""
|
|
287
|
+
if name == "ask_shared_conversation":
|
|
288
|
+
return await handle_ask_tool(
|
|
289
|
+
share_url=arguments.get("share_url", ""),
|
|
290
|
+
question=arguments.get("question", ""),
|
|
291
|
+
password=arguments.get("password")
|
|
518
292
|
)
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
return [TextContent(type="text", text=output or "Hide operation completed.")]
|
|
525
|
-
else:
|
|
526
|
-
return [TextContent(type="text", text=f"Error: Hide failed\n{output}")]
|
|
527
|
-
finally:
|
|
528
|
-
sys.stdout = old_stdout
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
def _server_log(msg: str):
|
|
532
|
-
"""Log MCP server messages to both stderr and file."""
|
|
533
|
-
from datetime import datetime
|
|
534
|
-
timestamp = datetime.now().strftime("%H:%M:%S")
|
|
535
|
-
print(f"[MCP Server] {msg}", file=sys.stderr)
|
|
536
|
-
|
|
537
|
-
# Also write to the watcher log file for consistency
|
|
538
|
-
log_path = Path.home() / ".aline_watcher.log"
|
|
539
|
-
try:
|
|
540
|
-
with open(log_path, "a") as f:
|
|
541
|
-
f.write(f"[{timestamp}] [MCP Server] {msg}\n")
|
|
542
|
-
except Exception:
|
|
543
|
-
pass
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
# Log immediately when module is imported
|
|
547
|
-
_early_log_path = Path.home() / ".aline_mcp_startup.log"
|
|
548
|
-
try:
|
|
549
|
-
with open(_early_log_path, "a") as f:
|
|
550
|
-
from datetime import datetime
|
|
551
|
-
f.write(f"\n{'='*60}\n")
|
|
552
|
-
f.write(f"[{datetime.now()}] MCP SERVER MODULE LOADED\n")
|
|
553
|
-
f.write(f"Python: {sys.executable}\n")
|
|
554
|
-
f.write(f"CWD: {Path.cwd()}\n")
|
|
555
|
-
f.write(f"HOME: {Path.home()}\n")
|
|
556
|
-
f.write(f"Version: {__version__}\n")
|
|
557
|
-
f.write(f"{'='*60}\n")
|
|
558
|
-
except Exception as e:
|
|
559
|
-
print(f"[MCP Server] Early log failed: {e}", file=sys.stderr)
|
|
293
|
+
else:
|
|
294
|
+
return [TextContent(
|
|
295
|
+
type="text",
|
|
296
|
+
text=f"Unknown tool: {name}"
|
|
297
|
+
)]
|
|
560
298
|
|
|
561
299
|
|
|
562
300
|
async def async_main():
|
|
563
|
-
"""
|
|
564
|
-
|
|
565
|
-
_server_log(f"Current working directory: {Path.cwd()}")
|
|
566
|
-
_server_log(f"Home directory: {Path.home()}")
|
|
567
|
-
|
|
568
|
-
# Start the watcher (no repo_path needed - it will extract dynamically from sessions)
|
|
569
|
-
try:
|
|
570
|
-
await start_watcher()
|
|
571
|
-
print("[MCP Server] Watcher started (will auto-detect project paths from sessions)", file=sys.stderr)
|
|
572
|
-
except Exception as e:
|
|
573
|
-
print(f"[MCP Server] Warning: Could not start watcher: {e}", file=sys.stderr)
|
|
574
|
-
import traceback
|
|
575
|
-
traceback.print_exc(file=sys.stderr)
|
|
576
|
-
|
|
577
|
-
_server_log("MCP server ready")
|
|
578
|
-
|
|
301
|
+
"""Main async entry point for the MCP server."""
|
|
302
|
+
# Start stdio server
|
|
579
303
|
async with stdio_server() as (read_stream, write_stream):
|
|
580
304
|
await app.run(
|
|
581
305
|
read_stream,
|
|
@@ -585,28 +309,18 @@ async def async_main():
|
|
|
585
309
|
|
|
586
310
|
|
|
587
311
|
def main():
|
|
588
|
-
"""Entry point for the
|
|
589
|
-
#
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
312
|
+
"""Entry point for the aline-mcp command."""
|
|
313
|
+
# Check if httpx is available
|
|
314
|
+
if not HTTPX_AVAILABLE:
|
|
315
|
+
print(
|
|
316
|
+
"Error: httpx package is required for aline-mcp.\n"
|
|
317
|
+
"Install with: pip install httpx",
|
|
318
|
+
file=sys.stderr
|
|
319
|
+
)
|
|
320
|
+
sys.exit(1)
|
|
596
321
|
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
except Exception as e:
|
|
600
|
-
# Log any exceptions
|
|
601
|
-
try:
|
|
602
|
-
with open(_early_log_path, "a") as f:
|
|
603
|
-
from datetime import datetime
|
|
604
|
-
import traceback
|
|
605
|
-
f.write(f"[{datetime.now()}] EXCEPTION in main: {e}\n")
|
|
606
|
-
f.write(traceback.format_exc())
|
|
607
|
-
except Exception:
|
|
608
|
-
pass
|
|
609
|
-
raise
|
|
322
|
+
# Run the async main
|
|
323
|
+
asyncio.run(async_main())
|
|
610
324
|
|
|
611
325
|
|
|
612
326
|
if __name__ == "__main__":
|