pi-revit 0.1.0

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.
@@ -0,0 +1,419 @@
1
+ using System.IO;
2
+ using System.Net;
3
+ using System.Net.Sockets;
4
+ using System.Text;
5
+ using System.Text.Json;
6
+
7
+ namespace RevitBridge
8
+ {
9
+ /// <summary>
10
+ /// Minimal localhost HTTP bridge. Routes:
11
+ ///
12
+ /// GET /ping -> bridge availability
13
+ /// GET /tools -> tool metadata list
14
+ /// GET /tools/{name} -> one tool's metadata
15
+ /// POST /tools/{name}/execute -> execute a tool on the Revit API thread
16
+ ///
17
+ /// Every request must carry ?token=... matching the per-start token written
18
+ /// to %APPDATA%\RevitBridge\bridge.json. The HTTP handling is deliberately
19
+ /// minimal (loopback only) with byte-accurate UTF-8 body reads.
20
+ /// </summary>
21
+ internal sealed class BridgeServer : IDisposable
22
+ {
23
+ private const int DefaultPort = 47777;
24
+ private const int MaxPort = 47797;
25
+ private const int MaxRequestBytes = 4_000_000;
26
+ private const int MaxContentChars = 12_000;
27
+
28
+ private readonly CommandQueue _queue;
29
+ private readonly ToolRegistry _registry;
30
+ private readonly string _revitVersion;
31
+ private readonly Func<bool> _hasOpenDocument;
32
+
33
+ private TcpListener? _listener;
34
+ private CancellationTokenSource? _cts;
35
+ private string _token = string.Empty;
36
+ private int _port;
37
+
38
+ public BridgeServer(CommandQueue queue, ToolRegistry registry, string revitVersion, Func<bool> hasOpenDocument)
39
+ {
40
+ _queue = queue;
41
+ _registry = registry;
42
+ _revitVersion = revitVersion;
43
+ _hasOpenDocument = hasOpenDocument;
44
+ }
45
+
46
+ public void Start()
47
+ {
48
+ if (_listener != null) return;
49
+
50
+ _token = Guid.NewGuid().ToString("N");
51
+ _cts = new CancellationTokenSource();
52
+
53
+ for (int port = ResolveStartPort(); port <= MaxPort; port++)
54
+ {
55
+ try
56
+ {
57
+ _listener = new TcpListener(IPAddress.Loopback, port);
58
+ _listener.Start();
59
+ _port = port;
60
+ break;
61
+ }
62
+ catch
63
+ {
64
+ _listener = null;
65
+ }
66
+ }
67
+
68
+ if (_listener is null)
69
+ throw new InvalidOperationException($"Unable to start the Revit bridge on localhost ports {DefaultPort}-{MaxPort}.");
70
+
71
+ WriteBridgeInfo();
72
+ _ = Task.Run(() => AcceptLoopAsync(_cts.Token));
73
+ }
74
+
75
+ public void Stop()
76
+ {
77
+ try { _cts?.Cancel(); } catch { }
78
+ try { _listener?.Stop(); } catch { }
79
+ _listener = null;
80
+ _cts?.Dispose();
81
+ _cts = null;
82
+ DeleteBridgeInfoIfOwned();
83
+ }
84
+
85
+ public void Dispose() => Stop();
86
+
87
+ private static int ResolveStartPort()
88
+ {
89
+ var raw = Environment.GetEnvironmentVariable("REVIT_BRIDGE_PORT");
90
+ return int.TryParse(raw, out int port) && port > 0 ? port : DefaultPort;
91
+ }
92
+
93
+ internal static string BridgeInfoDirectory() => Path.Combine(
94
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
95
+ "RevitBridge");
96
+
97
+ public static string BridgeInfoPath() => Path.Combine(BridgeInfoDirectory(), "bridge.json");
98
+
99
+ private void WriteBridgeInfo()
100
+ {
101
+ Directory.CreateDirectory(BridgeInfoDirectory());
102
+ File.WriteAllText(BridgeInfoPath(), JsonSerializer.Serialize(new
103
+ {
104
+ baseUrl = $"http://127.0.0.1:{_port}",
105
+ token = _token,
106
+ pid = Environment.ProcessId,
107
+ revitVersion = _revitVersion,
108
+ startedAtUtc = DateTime.UtcNow.ToString("o"),
109
+ }, new JsonSerializerOptions { WriteIndented = true }));
110
+ }
111
+
112
+ private static void DeleteBridgeInfoIfOwned()
113
+ {
114
+ try
115
+ {
116
+ string path = BridgeInfoPath();
117
+ if (!File.Exists(path)) return;
118
+ using var doc = JsonDocument.Parse(File.ReadAllText(path));
119
+ if (doc.RootElement.TryGetProperty("pid", out var pid) && pid.TryGetInt32(out int value) && value == Environment.ProcessId)
120
+ File.Delete(path);
121
+ }
122
+ catch
123
+ {
124
+ // Never let cleanup break Revit shutdown.
125
+ }
126
+ }
127
+
128
+ private async Task AcceptLoopAsync(CancellationToken token)
129
+ {
130
+ while (!token.IsCancellationRequested && _listener != null)
131
+ {
132
+ TcpClient? client = null;
133
+ try
134
+ {
135
+ client = await _listener.AcceptTcpClientAsync(token);
136
+ _ = Task.Run(() => HandleClientAsync(client, token), token);
137
+ }
138
+ catch (OperationCanceledException)
139
+ {
140
+ client?.Dispose();
141
+ break;
142
+ }
143
+ catch
144
+ {
145
+ client?.Dispose();
146
+ }
147
+ }
148
+ }
149
+
150
+ private async Task HandleClientAsync(TcpClient client, CancellationToken token)
151
+ {
152
+ using var _ = client;
153
+ NetworkStream? stream = null;
154
+ try
155
+ {
156
+ stream = client.GetStream();
157
+ var request = await ReadRequestAsync(stream, token);
158
+ if (request is null) return;
159
+
160
+ var (method, target, body) = request.Value;
161
+ var uri = new Uri("http://127.0.0.1" + target);
162
+ var query = ParseQuery(uri.Query);
163
+
164
+ if (!query.TryGetValue("token", out var tokenValue) || !string.Equals(tokenValue, _token, StringComparison.Ordinal))
165
+ {
166
+ await WriteJsonAsync(stream, 403, new { error = true, message = "Invalid Revit bridge token." }, token);
167
+ return;
168
+ }
169
+
170
+ var (status, response) = await RouteAsync(method, uri.AbsolutePath, query, body, token);
171
+ await WriteJsonAsync(stream, status, response, token);
172
+ }
173
+ catch (Exception ex)
174
+ {
175
+ if (stream != null)
176
+ {
177
+ try { await WriteJsonAsync(stream, 500, new { error = true, message = ex.Message }, token); }
178
+ catch { }
179
+ }
180
+ }
181
+ }
182
+
183
+ /// <summary>Reads one HTTP request with a byte-accurate Content-Length body.</summary>
184
+ private static async Task<(string Method, string Target, string Body)?> ReadRequestAsync(NetworkStream stream, CancellationToken token)
185
+ {
186
+ var buffer = new byte[8192];
187
+ using var data = new MemoryStream();
188
+ int headerEnd = -1;
189
+
190
+ while (headerEnd < 0)
191
+ {
192
+ int n = await stream.ReadAsync(buffer, token);
193
+ if (n <= 0) return null;
194
+ data.Write(buffer, 0, n);
195
+ if (data.Length > MaxRequestBytes)
196
+ throw new InvalidOperationException("Request too large.");
197
+ headerEnd = FindHeaderEnd(data.GetBuffer(), (int)data.Length);
198
+ }
199
+
200
+ string headerText = Encoding.ASCII.GetString(data.GetBuffer(), 0, headerEnd);
201
+ string[] lines = headerText.Split("\r\n");
202
+ string[] requestParts = lines[0].Split(' ', 3, StringSplitOptions.RemoveEmptyEntries);
203
+ if (requestParts.Length < 2)
204
+ throw new InvalidOperationException("Invalid HTTP request line.");
205
+
206
+ string method = requestParts[0].ToUpperInvariant();
207
+ if (method != "GET" && method != "POST")
208
+ throw new InvalidOperationException("Only GET and POST are supported.");
209
+
210
+ int contentLength = 0;
211
+ foreach (string line in lines.Skip(1))
212
+ {
213
+ int separator = line.IndexOf(':');
214
+ if (separator <= 0) continue;
215
+ if (line.AsSpan(0, separator).Trim().Equals("Content-Length", StringComparison.OrdinalIgnoreCase))
216
+ int.TryParse(line.AsSpan(separator + 1).Trim(), out contentLength);
217
+ }
218
+
219
+ if (contentLength is < 0 or > MaxRequestBytes)
220
+ throw new InvalidOperationException("Invalid Content-Length.");
221
+
222
+ int bodyStart = headerEnd + 4;
223
+ var bodyBytes = new byte[contentLength];
224
+ int alreadyRead = Math.Min((int)data.Length - bodyStart, contentLength);
225
+ Array.Copy(data.GetBuffer(), bodyStart, bodyBytes, 0, alreadyRead);
226
+
227
+ int read = alreadyRead;
228
+ while (read < contentLength)
229
+ {
230
+ int n = await stream.ReadAsync(bodyBytes.AsMemory(read, contentLength - read), token);
231
+ if (n <= 0) break;
232
+ read += n;
233
+ }
234
+
235
+ return (method, requestParts[1], Encoding.UTF8.GetString(bodyBytes, 0, read));
236
+ }
237
+
238
+ private static int FindHeaderEnd(byte[] data, int length)
239
+ {
240
+ for (int i = 3; i < length; i++)
241
+ {
242
+ if (data[i - 3] == '\r' && data[i - 2] == '\n' && data[i - 1] == '\r' && data[i] == '\n')
243
+ return i - 3;
244
+ }
245
+ return -1;
246
+ }
247
+
248
+ private static Dictionary<string, string> ParseQuery(string query)
249
+ {
250
+ var result = new Dictionary<string, string>(StringComparer.Ordinal);
251
+ foreach (string pair in query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries))
252
+ {
253
+ int separator = pair.IndexOf('=');
254
+ if (separator <= 0) continue;
255
+ result[WebUtility.UrlDecode(pair[..separator])] = WebUtility.UrlDecode(pair[(separator + 1)..]);
256
+ }
257
+ return result;
258
+ }
259
+
260
+ private async Task<(int Status, object Response)> RouteAsync(string method, string path, Dictionary<string, string> query, string body, CancellationToken token)
261
+ {
262
+ if (method == "GET" && path == "/ping")
263
+ return (200, new { ok = true, service = "revit-bridge", revitVersion = _revitVersion, pid = Environment.ProcessId });
264
+
265
+ if (method == "GET" && path == "/tools")
266
+ {
267
+ var tools = _registry.DescribeAll();
268
+ return (200, new { protocolVersion = 1, tools, count = tools.Count });
269
+ }
270
+
271
+ const string toolPrefix = "/tools/";
272
+ if (path.StartsWith(toolPrefix, StringComparison.OrdinalIgnoreCase))
273
+ {
274
+ string tail = path[toolPrefix.Length..].Trim('/');
275
+
276
+ if (tail.EndsWith("/execute", StringComparison.OrdinalIgnoreCase))
277
+ {
278
+ if (method != "POST")
279
+ return (405, new { error = true, message = "Tool execution requires POST." });
280
+
281
+ string name = WebUtility.UrlDecode(tail[..^"/execute".Length].Trim('/'));
282
+ return await ExecuteToolAsync(name, body, query, token);
283
+ }
284
+
285
+ if (method != "GET")
286
+ return (405, new { error = true, message = "Tool description requires GET." });
287
+
288
+ string describeName = WebUtility.UrlDecode(tail);
289
+ var tool = _registry.Get(describeName);
290
+ return tool is null
291
+ ? (404, new { error = true, message = $"Unknown tool: {describeName}" })
292
+ : (200, _registry.Describe(tool));
293
+ }
294
+
295
+ return (404, new { error = true, message = $"Unknown Revit bridge endpoint: {path}" });
296
+ }
297
+
298
+ private async Task<(int Status, object Response)> ExecuteToolAsync(string name, string body, Dictionary<string, string> query, CancellationToken token)
299
+ {
300
+ var tool = _registry.Get(name);
301
+ if (tool is null)
302
+ return (404, new { error = true, message = $"Unknown tool: {name}" });
303
+
304
+ // Pre-check before enqueueing: with zero documents open Revit does not
305
+ // pump ExternalEvents, so a queued call would hang instead of failing.
306
+ // The in-queue NoActiveDocumentException below remains as the backstop
307
+ // for a document closing between this check and execution. Tools that
308
+ // never touch the Revit API (RequiresDocument = false) skip the gate.
309
+ if (tool.RequiresDocument && !_hasOpenDocument())
310
+ return (409, new { error = true, hasActiveDocument = false, message = "No active Revit document is open." });
311
+
312
+ JsonElement args;
313
+ try
314
+ {
315
+ using var doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(body) ? "{}" : body);
316
+ if (doc.RootElement.ValueKind != JsonValueKind.Object)
317
+ return (400, new { error = true, message = "Tool arguments must be a JSON object." });
318
+ args = doc.RootElement.Clone();
319
+ }
320
+ catch (JsonException ex)
321
+ {
322
+ return (400, new { error = true, message = $"Invalid JSON tool arguments: {ex.Message}" });
323
+ }
324
+
325
+ int timeoutMs = ResolveTimeoutMs(query);
326
+
327
+ try
328
+ {
329
+ object? output = tool.RequiresDocument
330
+ ? await _queue.RunAsync(uiApp =>
331
+ {
332
+ var document = uiApp.ActiveUIDocument?.Document ?? throw new NoActiveDocumentException();
333
+ return tool.Execute(args, new ToolContext(document, uiApp));
334
+ }, TimeSpan.FromMilliseconds(timeoutMs))
335
+ // RequiresDocument = false tools never touch the Revit API, so they
336
+ // run right here on the server task instead of the CommandQueue.
337
+ : tool.Execute(args, new ToolContext(null, null));
338
+
339
+ return (200, BuildToolResponse(name, output));
340
+ }
341
+ catch (NoActiveDocumentException ex)
342
+ {
343
+ return (409, new { error = true, hasActiveDocument = false, message = ex.Message });
344
+ }
345
+ catch (ArgumentException ex)
346
+ {
347
+ return (400, new { error = true, toolName = name, message = ex.Message });
348
+ }
349
+ catch (Exception ex)
350
+ {
351
+ return (500, new { error = true, toolName = name, message = ex.Message });
352
+ }
353
+ }
354
+
355
+ /// <summary>Optional timeout_ms query param, clamped to 1s..10min; default 30s.</summary>
356
+ private static int ResolveTimeoutMs(Dictionary<string, string> query)
357
+ => query.TryGetValue("timeout_ms", out var raw) && int.TryParse(raw, out int value)
358
+ ? Math.Clamp(value, 1_000, 600_000)
359
+ : 30_000;
360
+
361
+ /// <summary>Compact text for model context; full payload in details. Tools may
362
+ /// return a ToolOutput (CompactText -> content, Payload -> details.payload) or
363
+ /// any plain object (serialized to both).</summary>
364
+ private static object BuildToolResponse(string toolName, object? output)
365
+ {
366
+ object? payload = output;
367
+ string? compact = null;
368
+ if (output is ToolOutput toolOutput)
369
+ {
370
+ payload = toolOutput.Payload;
371
+ compact = toolOutput.CompactText;
372
+ }
373
+
374
+ string text = compact ?? JsonSerializer.Serialize(payload ?? new { });
375
+ bool truncated = text.Length > MaxContentChars;
376
+ if (truncated)
377
+ {
378
+ string suffix = $"... [truncated at {MaxContentChars} chars; full payload is in details]";
379
+ text = text[..Math.Max(0, MaxContentChars - suffix.Length)] + suffix;
380
+ }
381
+
382
+ return new
383
+ {
384
+ success = true,
385
+ toolName,
386
+ content = new[] { new { type = "text", text } },
387
+ details = new { payload, contentTruncated = truncated },
388
+ isError = false,
389
+ };
390
+ }
391
+
392
+ private static async Task WriteJsonAsync(NetworkStream stream, int status, object value, CancellationToken token)
393
+ {
394
+ string reason = status switch
395
+ {
396
+ 200 => "OK",
397
+ 400 => "Bad Request",
398
+ 403 => "Forbidden",
399
+ 404 => "Not Found",
400
+ 405 => "Method Not Allowed",
401
+ 409 => "Conflict",
402
+ 500 => "Internal Server Error",
403
+ _ => "OK",
404
+ };
405
+
406
+ byte[] body = JsonSerializer.SerializeToUtf8Bytes(value);
407
+ string header =
408
+ $"HTTP/1.1 {status} {reason}\r\n" +
409
+ "Content-Type: application/json; charset=utf-8\r\n" +
410
+ $"Content-Length: {body.Length}\r\n" +
411
+ "Connection: close\r\n" +
412
+ "\r\n";
413
+
414
+ await stream.WriteAsync(Encoding.ASCII.GetBytes(header), token);
415
+ await stream.WriteAsync(body, token);
416
+ await stream.FlushAsync(token);
417
+ }
418
+ }
419
+ }
@@ -0,0 +1,93 @@
1
+ using System.Collections.Concurrent;
2
+ using System.Diagnostics;
3
+ using Autodesk.Revit.UI;
4
+
5
+ namespace RevitBridge
6
+ {
7
+ /// <summary>
8
+ /// Thrown by queued work when no Revit document is open.
9
+ /// The bridge maps this to HTTP 409 with hasActiveDocument = false.
10
+ /// </summary>
11
+ internal sealed class NoActiveDocumentException : InvalidOperationException
12
+ {
13
+ public NoActiveDocumentException()
14
+ : base("No active Revit document is open.")
15
+ {
16
+ }
17
+ }
18
+
19
+ /// <summary>
20
+ /// Schedules work onto the Revit API thread via an ExternalEvent and returns
21
+ /// a Task that completes with the result (standard ExternalEvent +
22
+ /// TaskCompletionSource dispatch).
23
+ /// </summary>
24
+ internal sealed class CommandQueue : IExternalEventHandler
25
+ {
26
+ private sealed class WorkItem
27
+ {
28
+ public required Func<UIApplication, object?> Action { get; init; }
29
+ public required TaskCompletionSource<object?> Completion { get; init; }
30
+
31
+ /// <summary>Stopwatch timestamp after which the item must not start; 0 = no deadline.</summary>
32
+ public long DeadlineTimestamp { get; init; }
33
+ }
34
+
35
+ private readonly ConcurrentQueue<WorkItem> _queue = new();
36
+
37
+ /// <summary>Set once from Application.OnStartup (a valid Revit API context).</summary>
38
+ public ExternalEvent? ExternalEvent { get; set; }
39
+
40
+ public Task<T> RunAsync<T>(Func<UIApplication, T> action, TimeSpan? timeout = null)
41
+ {
42
+ var completion = new TaskCompletionSource<object?>(TaskCreationOptions.RunContinuationsAsynchronously);
43
+ _queue.Enqueue(new WorkItem
44
+ {
45
+ Action = app => action(app),
46
+ Completion = completion,
47
+ DeadlineTimestamp = timeout is { } budget
48
+ ? Stopwatch.GetTimestamp() + (long)(budget.TotalSeconds * Stopwatch.Frequency)
49
+ : 0,
50
+ });
51
+
52
+ var externalEvent = ExternalEvent;
53
+ if (externalEvent is null)
54
+ {
55
+ completion.TrySetException(new InvalidOperationException("Revit bridge command queue is not initialized."));
56
+ }
57
+ else
58
+ {
59
+ externalEvent.Raise();
60
+ }
61
+
62
+ return Await<T>(completion.Task);
63
+ }
64
+
65
+ private static async Task<T> Await<T>(Task<object?> task) => (T)(await task.ConfigureAwait(false))!;
66
+
67
+ public void Execute(UIApplication app)
68
+ {
69
+ while (_queue.TryDequeue(out var item))
70
+ {
71
+ // The HTTP caller abandons the request at the same deadline, so running
72
+ // the item late would mutate the model behind the agent's back.
73
+ if (item.DeadlineTimestamp != 0 && Stopwatch.GetTimestamp() > item.DeadlineTimestamp)
74
+ {
75
+ item.Completion.TrySetException(new TimeoutException(
76
+ "Tool call expired before execution; no work was performed. Revit stayed busy past the timeout_ms deadline; retry, optionally with a larger timeout_ms."));
77
+ continue;
78
+ }
79
+
80
+ try
81
+ {
82
+ item.Completion.TrySetResult(item.Action(app));
83
+ }
84
+ catch (Exception ex)
85
+ {
86
+ item.Completion.TrySetException(ex);
87
+ }
88
+ }
89
+ }
90
+
91
+ public string GetName() => "Revit Bridge Command Queue";
92
+ }
93
+ }
@@ -0,0 +1,11 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <RevitAddIns>
3
+ <AddIn Type="Application">
4
+ <Name>Revit Bridge</Name>
5
+ <Assembly>RevitBridge\RevitBridge.dll</Assembly>
6
+ <FullClassName>RevitBridge.Application</FullClassName>
7
+ <AddInId>5E4AA11A-BBAF-4B2C-8649-EC6C541FABA1</AddInId>
8
+ <VendorId>pi-revit</VendorId>
9
+ <VendorDescription>Headless local bridge that exposes Revit API tools to a Pi coding-agent session.</VendorDescription>
10
+ </AddIn>
11
+ </RevitAddIns>
@@ -0,0 +1,48 @@
1
+ <Project Sdk="Microsoft.NET.Sdk">
2
+
3
+ <PropertyGroup>
4
+ <!-- net8.0-windows -> Revit 2025/2026; net10.0-windows -> Revit 2027. The build picks
5
+ per-TFM defaults below; deploy.ps1 builds the single TFM that matches its -RevitVersion. -->
6
+ <TargetFrameworks>net8.0-windows;net10.0-windows</TargetFrameworks>
7
+ <LangVersion>latest</LangVersion>
8
+ <Nullable>enable</Nullable>
9
+ <ImplicitUsings>enable</ImplicitUsings>
10
+ <AssemblyName>RevitBridge</AssemblyName>
11
+ <RootNamespace>RevitBridge</RootNamespace>
12
+ <PlatformTarget>x64</PlatformTarget>
13
+ <!-- MSB3277: harmless version-conflict noise from RevitAPIUI transitive deps; nothing is copied local. -->
14
+ <MSBuildWarningsAsMessages>MSB3277</MSBuildWarningsAsMessages>
15
+ <!-- execute_csharp: copy the Roslyn package closure into the build output so
16
+ scripts/deploy.ps1 can ship it next to RevitBridge.dll. -->
17
+ <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
18
+ <!-- Compiler messages stay English-only; skips ~13 localized Roslyn resource subfolders. -->
19
+ <SatelliteResourceLanguages>en</SatelliteResourceLanguages>
20
+ </PropertyGroup>
21
+
22
+ <!-- RevitAPI.dll location, defaulted per target framework. Override either target with:
23
+ dotnet build -p:TargetFramework=net10.0-windows -p:RevitApiPath="D:\Autodesk\Revit 2027" -->
24
+ <PropertyGroup Condition="'$(RevitApiPath)' == '' and '$(TargetFramework)' == 'net10.0-windows'">
25
+ <RevitApiPath>C:\Program Files\Autodesk\Revit 2027</RevitApiPath>
26
+ </PropertyGroup>
27
+ <PropertyGroup Condition="'$(RevitApiPath)' == ''">
28
+ <RevitApiPath>C:\Program Files\Autodesk\Revit 2025</RevitApiPath>
29
+ </PropertyGroup>
30
+
31
+ <!-- Revit API assemblies are provided by Revit at runtime; never copy them local. -->
32
+ <ItemGroup>
33
+ <Reference Include="RevitAPI">
34
+ <HintPath>$(RevitApiPath)\RevitAPI.dll</HintPath>
35
+ <Private>false</Private>
36
+ </Reference>
37
+ <Reference Include="RevitAPIUI">
38
+ <HintPath>$(RevitApiPath)\RevitAPIUI.dll</HintPath>
39
+ <Private>false</Private>
40
+ </Reference>
41
+ </ItemGroup>
42
+
43
+ <!-- Roslyn scripting engine for the execute_csharp tool. -->
44
+ <ItemGroup>
45
+ <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Scripting" Version="4.8.0" />
46
+ </ItemGroup>
47
+
48
+ </Project>
@@ -0,0 +1,109 @@
1
+ using System.Text.Json;
2
+ using Autodesk.Revit.DB;
3
+ using Autodesk.Revit.UI;
4
+ using RevitBridge.Tools;
5
+
6
+ namespace RevitBridge
7
+ {
8
+ /// <summary>
9
+ /// Execution context handed to tools. Document and UIApplication are non-null when the
10
+ /// tool runs on the Revit API thread (RequiresDocument = true, the default). Tools with
11
+ /// RequiresDocument = false run on the bridge's server task with both set to null and
12
+ /// must not touch the Revit API.
13
+ /// </summary>
14
+ internal sealed record ToolContext(Document? Document, UIApplication? UIApplication);
15
+
16
+ /// <summary>
17
+ /// Optional tool return shape: compact text for model context plus the full payload for
18
+ /// details. Tools may instead return any plain JSON-serializable object, which the bridge
19
+ /// serializes into both channels (capped in content).
20
+ /// </summary>
21
+ internal sealed record ToolOutput(object? Payload, string? CompactText = null);
22
+
23
+ /// <summary>A single Revit tool exposed through the bridge.</summary>
24
+ internal interface ITool
25
+ {
26
+ string Name { get; }
27
+ string Label { get; }
28
+ string Description { get; }
29
+
30
+ /// <summary>JSON schema (anonymous object) describing the tool arguments.</summary>
31
+ object ParametersSchema { get; }
32
+
33
+ /// <summary>True only for tools that mutate the model. Write tools own their transaction.</summary>
34
+ bool Write => false;
35
+
36
+ /// <summary>
37
+ /// False only for tools that never touch the Revit API. They skip the bridge's
38
+ /// no-document 409 gate and run directly on the server task instead of the Revit
39
+ /// API thread, so ToolContext.Document/UIApplication are null for them.
40
+ /// </summary>
41
+ bool RequiresDocument => true;
42
+
43
+ /// <summary>Activation tier metadata: "core" (default) or "advanced".</summary>
44
+ string Tier => "core";
45
+
46
+ /// <summary>Optional one-line "Available tools" system prompt entry.</summary>
47
+ string? PromptSnippet => null;
48
+
49
+ /// <summary>Optional system prompt guideline bullets. Each bullet must name the tool.</summary>
50
+ IReadOnlyList<string>? PromptGuidelines => null;
51
+
52
+ /// <summary>
53
+ /// Runs on the Revit API thread (or the server task when RequiresDocument is false).
54
+ /// Returns a ToolOutput or any plain JSON-serializable payload.
55
+ /// </summary>
56
+ object? Execute(JsonElement args, ToolContext context);
57
+ }
58
+
59
+ /// <summary>Name -> tool map plus the metadata projection served by GET /tools.</summary>
60
+ internal sealed class ToolRegistry
61
+ {
62
+ private readonly SortedDictionary<string, ITool> _tools = new(StringComparer.Ordinal);
63
+
64
+ public static ToolRegistry CreateDefault()
65
+ {
66
+ var registry = new ToolRegistry();
67
+ registry.Add(new SearchApiDocs());
68
+ registry.Add(new ExecuteCsharp());
69
+ registry.Add(new GetModelOverview());
70
+ registry.Add(new GetElements());
71
+ registry.Add(new GetElementTypes());
72
+ registry.Add(new GetElementDetails());
73
+ registry.Add(new ManageSelection());
74
+ registry.Add(new SetParameters());
75
+ registry.Add(new CaptureView());
76
+ registry.Add(new ExportDocuments());
77
+ registry.Add(new GetModelHealth());
78
+ return registry;
79
+ }
80
+
81
+ public void Add(ITool tool)
82
+ {
83
+ if (_tools.ContainsKey(tool.Name))
84
+ throw new InvalidOperationException($"Duplicate tool name: {tool.Name}");
85
+ _tools[tool.Name] = tool;
86
+ }
87
+
88
+ public ITool? Get(string name)
89
+ => _tools.TryGetValue(name, out var tool) ? tool : null;
90
+
91
+ public IReadOnlyList<object> DescribeAll()
92
+ => _tools.Values.Select(Describe).ToArray();
93
+
94
+ public object Describe(ITool tool) => new
95
+ {
96
+ name = tool.Name,
97
+ label = tool.Label,
98
+ description = tool.Description,
99
+ category = tool.Write ? "write" : "read",
100
+ tier = tool.Tier,
101
+ parameters = tool.ParametersSchema,
102
+ executionMode = "sequential",
103
+ write = tool.Write,
104
+ requiresDocument = tool.RequiresDocument,
105
+ promptSnippet = tool.PromptSnippet,
106
+ promptGuidelines = tool.PromptGuidelines,
107
+ };
108
+ }
109
+ }