pi-revit 0.3.1 → 0.4.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.
- package/CHANGELOG.md +388 -351
- package/README.md +426 -22
- package/extensions/pi-revit/index.ts +246 -187
- package/extensions/pi-revit/instance-router.ts +86 -0
- package/extensions/pi-revit/script-library.ts +146 -0
- package/extensions/pi-revit/tool-catalog.ts +67 -0
- package/package.json +59 -59
- package/skills/pi-revit/SKILL.md +196 -32
- package/skills/pi-revit/references/model-audit-export.md +29 -0
- package/skills/pi-revit/references/room-documentation.md +28 -0
- package/src/Revit/BridgeServer.cs +87 -37
- package/src/Revit/OperationStore.cs +178 -0
- package/src/Revit/ToolRegistry.cs +57 -35
- package/src/Revit/Tools/CaptureView.cs +2 -1
- package/src/Revit/Tools/ChangeElementTypes.cs +60 -0
- package/src/Revit/Tools/CreateTags.cs +95 -0
- package/src/Revit/Tools/DeleteElements.cs +44 -0
- package/src/Revit/Tools/DocumentGuard.cs +64 -64
- package/src/Revit/Tools/ElementQueryScope.cs +27 -0
- package/src/Revit/Tools/ExecuteCsharp.cs +45 -35
- package/src/Revit/Tools/ExportDocuments.cs +121 -120
- package/src/Revit/Tools/FailureGuard.cs +26 -26
- package/src/Revit/Tools/GetElementDetails.cs +42 -12
- package/src/Revit/Tools/GetElementRelationships.cs +76 -0
- package/src/Revit/Tools/GetElements.cs +37 -23
- package/src/Revit/Tools/GetLinkedElements.cs +82 -0
- package/src/Revit/Tools/GetLinkedModels.cs +66 -0
- package/src/Revit/Tools/GetModelCoordinates.cs +49 -0
- package/src/Revit/Tools/GetModelOverview.cs +2 -2
- package/src/Revit/Tools/GetScheduleFields.cs +37 -0
- package/src/Revit/Tools/GetSchedules.cs +89 -0
- package/src/Revit/Tools/ManageElementSets.cs +106 -0
- package/src/Revit/Tools/ManageSchedules.cs +164 -0
- package/src/Revit/Tools/ManageSelection.cs +37 -36
- package/src/Revit/Tools/ManageSheetPlacements.cs +97 -0
- package/src/Revit/Tools/ManageSheets.cs +63 -0
- package/src/Revit/Tools/ManageViews.cs +100 -0
- package/src/Revit/Tools/MeasureGeometry.cs +54 -0
- package/src/Revit/Tools/ModelEditBatch.cs +102 -0
- package/src/Revit/Tools/ModelEditInputs.cs +49 -0
- package/src/Revit/Tools/OpenView.cs +2 -1
- package/src/Revit/Tools/QuerySpatialElements.cs +63 -0
- package/src/Revit/Tools/SetParameters.cs +43 -93
- package/src/Revit/Tools/SpatialBounds.cs +30 -0
- package/src/Revit/Tools/SummarizeElements.cs +87 -0
- package/src/Revit/Tools/ToolSupport.cs +1 -1
- package/src/Revit/Tools/TransformElements.cs +58 -0
- package/workspace/AGENTS.md +45 -45
|
@@ -1,21 +1,19 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type, type TSchema } from "typebox";
|
|
3
|
-
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
|
4
|
-
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
5
|
import os from "node:os";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
|
-
import { version as packageVersion } from "../../package.json";
|
|
8
|
+
import { version as packageVersion } from "../../package.json";
|
|
9
|
+
import { createToolCatalog, type BridgeToolDescriptor } from "./tool-catalog.js";
|
|
10
|
+
import { createInstanceRouter, type BridgeInfo } from "./instance-router.js";
|
|
11
|
+
import { registerScriptLibrary } from "./script-library.js";
|
|
9
12
|
|
|
10
|
-
|
|
11
|
-
baseUrl: string;
|
|
12
|
-
token: string;
|
|
13
|
-
pid?: number;
|
|
14
|
-
revitVersion?: string;
|
|
15
|
-
}
|
|
13
|
+
type BridgeResolver = (operationId?: string) => Promise<BridgeInfo>;
|
|
16
14
|
|
|
17
|
-
interface ContentBlock {
|
|
18
|
-
type:
|
|
15
|
+
interface ContentBlock {
|
|
16
|
+
type: "text";
|
|
19
17
|
text: string;
|
|
20
18
|
}
|
|
21
19
|
|
|
@@ -30,31 +28,16 @@ interface BridgeToolResponse {
|
|
|
30
28
|
hasActiveDocument?: boolean;
|
|
31
29
|
}
|
|
32
30
|
|
|
33
|
-
/** One entry of GET /tools, as served by ToolRegistry.Describe() on the bridge. */
|
|
34
|
-
interface BridgeToolDescriptor {
|
|
35
|
-
name: string;
|
|
36
|
-
label?: string;
|
|
37
|
-
description?: string;
|
|
38
|
-
category?: string;
|
|
39
|
-
tier?: string;
|
|
40
|
-
parameters?: unknown;
|
|
41
|
-
executionMode?: string;
|
|
42
|
-
write?: boolean;
|
|
43
|
-
requiresDocument?: boolean;
|
|
44
|
-
promptSnippet?: string | null;
|
|
45
|
-
promptGuidelines?: string[] | null;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
31
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
49
32
|
const LONG_TIMEOUT_MS = 120_000;
|
|
50
33
|
const DISCOVERY_TIMEOUT_MS = 10_000;
|
|
51
|
-
const MAX_MODEL_CONTENT_CHARS = 12_000;
|
|
52
|
-
const MAX_RESULT_PAGE_CHARS = 8_000;
|
|
53
|
-
|
|
54
|
-
// IDs only resolve results created by this extension instance. A caller cannot
|
|
55
|
-
// turn read_revit_result into an arbitrary filesystem read by supplying a path.
|
|
56
|
-
const savedResults = new Map<string, string>();
|
|
57
|
-
let resultDirectory: Promise<string> | undefined;
|
|
34
|
+
const MAX_MODEL_CONTENT_CHARS = 12_000;
|
|
35
|
+
const MAX_RESULT_PAGE_CHARS = 8_000;
|
|
36
|
+
|
|
37
|
+
// IDs only resolve results created by this extension instance. A caller cannot
|
|
38
|
+
// turn read_revit_result into an arbitrary filesystem read by supplying a path.
|
|
39
|
+
const savedResults = new Map<string, string>();
|
|
40
|
+
let resultDirectory: Promise<string> | undefined;
|
|
58
41
|
|
|
59
42
|
/** Tools with a longer budget; everything else gets DEFAULT_TIMEOUT_MS. The same
|
|
60
43
|
* value is sent to the bridge as timeout_ms and used client-side via AbortSignal. */
|
|
@@ -113,11 +96,11 @@ function timeoutError(timeoutMs: number): Error {
|
|
|
113
96
|
|
|
114
97
|
export async function bridgeRequest(
|
|
115
98
|
pathname: string,
|
|
116
|
-
init: { method: "GET" | "POST"; body?: string; query?: Record<string, string
|
|
99
|
+
init: { method: "GET" | "POST"; body?: string; query?: Record<string, string>; bridge?: BridgeInfo },
|
|
117
100
|
signal?: AbortSignal,
|
|
118
101
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
119
102
|
): Promise<unknown> {
|
|
120
|
-
const info = await readBridgeInfo();
|
|
103
|
+
const info = init.bridge ?? await readBridgeInfo();
|
|
121
104
|
const query = new URLSearchParams({ ...(init.query ?? {}), token: info.token });
|
|
122
105
|
const url = `${info.baseUrl}${pathname}?${query.toString()}`;
|
|
123
106
|
|
|
@@ -160,116 +143,147 @@ export async function bridgeRequest(
|
|
|
160
143
|
return payload;
|
|
161
144
|
}
|
|
162
145
|
|
|
163
|
-
export function capText(text: string): string {
|
|
164
|
-
if (text.length <= MAX_MODEL_CONTENT_CHARS) return text;
|
|
165
|
-
const suffix = `... [truncated preview at ${MAX_MODEL_CONTENT_CHARS} chars]`;
|
|
166
|
-
return text.slice(0, Math.max(0, MAX_MODEL_CONTENT_CHARS - suffix.length)) + suffix;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
async function modelContent(name: string, payload: BridgeToolResponse): Promise<{ type: "text"; text: string }[]> {
|
|
170
|
-
const details = payload.details;
|
|
171
|
-
const value = details !== null && typeof details === "object" && Object.hasOwn(details, "payload")
|
|
172
|
-
? (details as { payload: unknown }).payload
|
|
173
|
-
: details;
|
|
174
|
-
// Current and older bridges both carry the full value in details.payload.
|
|
175
|
-
// Pi sends content to the model; details alone is only available to its UI.
|
|
176
|
-
const text = details !== undefined
|
|
177
|
-
? JSON.stringify(value, null, 2) ?? "null"
|
|
178
|
-
: payload.content?.map((block) => block.text).join("\n") ?? "{}";
|
|
179
|
-
if (text.length <= MAX_MODEL_CONTENT_CHARS) return [{ type: "text", text }];
|
|
180
|
-
|
|
181
|
-
const resultId = randomUUID();
|
|
182
|
-
let filePath: string;
|
|
183
|
-
try {
|
|
184
|
-
const directory = await (resultDirectory ??= mkdtemp(path.join(os.tmpdir(), "pi-revit-results-")).catch((error) => {
|
|
185
|
-
// A transient failure must not poison every later large result in this session.
|
|
186
|
-
resultDirectory = undefined;
|
|
187
|
-
throw error;
|
|
188
|
-
}));
|
|
189
|
-
filePath = path.join(directory, `${resultId}.json`);
|
|
190
|
-
await writeFile(filePath, text, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
191
|
-
} catch (error) {
|
|
192
|
-
const reason = error instanceof Error ? error.message : String(error);
|
|
193
|
-
throw new Error(`Revit completed '${name}', but its large result could not be saved locally: ${reason}. Verify model state before retrying a write.`);
|
|
194
|
-
}
|
|
195
|
-
savedResults.set(resultId, filePath);
|
|
196
|
-
return [{ type: "text", text: JSON.stringify({
|
|
197
|
-
result_id: resultId,
|
|
198
|
-
file_path: filePath,
|
|
199
|
-
total_chars: text.length,
|
|
200
|
-
complete_inline: false,
|
|
201
|
-
retrieval: { tool: "read_revit_result", result_id: resultId, offset: 0, limit: MAX_RESULT_PAGE_CHARS },
|
|
202
|
-
instructions: "The complete result is saved locally. Call read_revit_result, then follow next_offset until has_more is false. Each page is a fragment of the saved text, not a standalone result. Offsets count UTF-16 code units. The absolute file can also be opened with read; it remains available after an extension reload, when this session's result ID may no longer resolve.",
|
|
203
|
-
}) }];
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
function registerResultReader(pi: ExtensionAPI) {
|
|
207
|
-
pi.registerTool({
|
|
208
|
-
name: "read_revit_result",
|
|
209
|
-
label: "Read Saved Revit Result",
|
|
210
|
-
description: "Read a bounded fragment of a large Revit tool result using its opaque result_id. This reads a saved local result and does not contact Revit. Follow next_offset until has_more is false; text fragments concatenate to the complete saved result. Offsets count UTF-16 code units.",
|
|
211
|
-
parameters: Type.Object({
|
|
212
|
-
result_id: Type.String({ description: "Opaque result_id returned by a Revit tool in this extension session." }),
|
|
213
|
-
offset: Type.Optional(Type.Integer({ minimum: 0, description: "Character offset from the previous page's next_offset; default 0." })),
|
|
214
|
-
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_RESULT_PAGE_CHARS, description: "Maximum characters to return; default 8000. Escaping may require a smaller fragment." })),
|
|
215
|
-
}),
|
|
216
|
-
executionMode: "sequential",
|
|
217
|
-
async execute(_toolCallId, params) {
|
|
218
|
-
const offset = params.offset ?? 0;
|
|
219
|
-
const limit = params.limit ?? MAX_RESULT_PAGE_CHARS;
|
|
220
|
-
if (!Number.isSafeInteger(offset) || offset < 0) throw new Error("offset must be a non-negative integer.");
|
|
221
|
-
if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_RESULT_PAGE_CHARS)
|
|
222
|
-
throw new Error(`limit must be an integer from 1 to ${MAX_RESULT_PAGE_CHARS}.`);
|
|
223
|
-
const filePath = savedResults.get(params.result_id);
|
|
224
|
-
if (!filePath) throw new Error("Unknown result_id for this extension session. Use the original result's file_path with read if the extension was reloaded.");
|
|
225
|
-
const text = await readFile(filePath, "utf8");
|
|
226
|
-
if (offset > text.length) throw new Error(`offset exceeds this result's ${text.length} characters.`);
|
|
227
|
-
const encode = (count: number) => JSON.stringify({
|
|
228
|
-
result_id: params.result_id, offset, returned_chars: count, total_chars: text.length,
|
|
229
|
-
has_more: offset + count < text.length,
|
|
230
|
-
next_offset: offset + count < text.length ? offset + count : null,
|
|
231
|
-
fragment: true, text: text.slice(offset, offset + count),
|
|
232
|
-
});
|
|
233
|
-
// Bound the actual model message, including JSON escaping and metadata.
|
|
234
|
-
let low = 0;
|
|
235
|
-
let high = Math.min(limit, text.length - offset);
|
|
236
|
-
while (low < high) {
|
|
237
|
-
const count = Math.ceil((low + high) / 2);
|
|
238
|
-
if (encode(count).length <= MAX_MODEL_CONTENT_CHARS) low = count;
|
|
239
|
-
else high = count - 1;
|
|
240
|
-
}
|
|
241
|
-
return { content: [{ type: "text", text: encode(low) }], details: { filePath } };
|
|
242
|
-
},
|
|
243
|
-
});
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
async function runBridgeTool(name: string, args: unknown, signal: AbortSignal | undefined, timeoutMs: number
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
))
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
146
|
+
export function capText(text: string): string {
|
|
147
|
+
if (text.length <= MAX_MODEL_CONTENT_CHARS) return text;
|
|
148
|
+
const suffix = `... [truncated preview at ${MAX_MODEL_CONTENT_CHARS} chars]`;
|
|
149
|
+
return text.slice(0, Math.max(0, MAX_MODEL_CONTENT_CHARS - suffix.length)) + suffix;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function modelContent(name: string, payload: BridgeToolResponse): Promise<{ type: "text"; text: string }[]> {
|
|
153
|
+
const details = payload.details;
|
|
154
|
+
const value = details !== null && typeof details === "object" && Object.hasOwn(details, "payload")
|
|
155
|
+
? (details as { payload: unknown }).payload
|
|
156
|
+
: details;
|
|
157
|
+
// Current and older bridges both carry the full value in details.payload.
|
|
158
|
+
// Pi sends content to the model; details alone is only available to its UI.
|
|
159
|
+
const text = details !== undefined
|
|
160
|
+
? JSON.stringify(value, null, 2) ?? "null"
|
|
161
|
+
: payload.content?.map((block) => block.text).join("\n") ?? "{}";
|
|
162
|
+
if (text.length <= MAX_MODEL_CONTENT_CHARS) return [{ type: "text", text }];
|
|
163
|
+
|
|
164
|
+
const resultId = randomUUID();
|
|
165
|
+
let filePath: string;
|
|
166
|
+
try {
|
|
167
|
+
const directory = await (resultDirectory ??= mkdtemp(path.join(os.tmpdir(), "pi-revit-results-")).catch((error) => {
|
|
168
|
+
// A transient failure must not poison every later large result in this session.
|
|
169
|
+
resultDirectory = undefined;
|
|
170
|
+
throw error;
|
|
171
|
+
}));
|
|
172
|
+
filePath = path.join(directory, `${resultId}.json`);
|
|
173
|
+
await writeFile(filePath, text, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
174
|
+
} catch (error) {
|
|
175
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
176
|
+
throw new Error(`Revit completed '${name}', but its large result could not be saved locally: ${reason}. Verify model state before retrying a write.`);
|
|
177
|
+
}
|
|
178
|
+
savedResults.set(resultId, filePath);
|
|
179
|
+
return [{ type: "text", text: JSON.stringify({
|
|
180
|
+
result_id: resultId,
|
|
181
|
+
file_path: filePath,
|
|
182
|
+
total_chars: text.length,
|
|
183
|
+
complete_inline: false,
|
|
184
|
+
retrieval: { tool: "read_revit_result", result_id: resultId, offset: 0, limit: MAX_RESULT_PAGE_CHARS },
|
|
185
|
+
instructions: "The complete result is saved locally. Call read_revit_result, then follow next_offset until has_more is false. Each page is a fragment of the saved text, not a standalone result. Offsets count UTF-16 code units. The absolute file can also be opened with read; it remains available after an extension reload, when this session's result ID may no longer resolve.",
|
|
186
|
+
}) }];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function registerResultReader(pi: ExtensionAPI) {
|
|
190
|
+
pi.registerTool({
|
|
191
|
+
name: "read_revit_result",
|
|
192
|
+
label: "Read Saved Revit Result",
|
|
193
|
+
description: "Read a bounded fragment of a large Revit tool result using its opaque result_id. This reads a saved local result and does not contact Revit. Follow next_offset until has_more is false; text fragments concatenate to the complete saved result. Offsets count UTF-16 code units.",
|
|
194
|
+
parameters: Type.Object({
|
|
195
|
+
result_id: Type.String({ description: "Opaque result_id returned by a Revit tool in this extension session." }),
|
|
196
|
+
offset: Type.Optional(Type.Integer({ minimum: 0, description: "Character offset from the previous page's next_offset; default 0." })),
|
|
197
|
+
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_RESULT_PAGE_CHARS, description: "Maximum characters to return; default 8000. Escaping may require a smaller fragment." })),
|
|
198
|
+
}),
|
|
199
|
+
executionMode: "sequential",
|
|
200
|
+
async execute(_toolCallId, params) {
|
|
201
|
+
const offset = params.offset ?? 0;
|
|
202
|
+
const limit = params.limit ?? MAX_RESULT_PAGE_CHARS;
|
|
203
|
+
if (!Number.isSafeInteger(offset) || offset < 0) throw new Error("offset must be a non-negative integer.");
|
|
204
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_RESULT_PAGE_CHARS)
|
|
205
|
+
throw new Error(`limit must be an integer from 1 to ${MAX_RESULT_PAGE_CHARS}.`);
|
|
206
|
+
const filePath = savedResults.get(params.result_id);
|
|
207
|
+
if (!filePath) throw new Error("Unknown result_id for this extension session. Use the original result's file_path with read if the extension was reloaded.");
|
|
208
|
+
const text = await readFile(filePath, "utf8");
|
|
209
|
+
if (offset > text.length) throw new Error(`offset exceeds this result's ${text.length} characters.`);
|
|
210
|
+
const encode = (count: number) => JSON.stringify({
|
|
211
|
+
result_id: params.result_id, offset, returned_chars: count, total_chars: text.length,
|
|
212
|
+
has_more: offset + count < text.length,
|
|
213
|
+
next_offset: offset + count < text.length ? offset + count : null,
|
|
214
|
+
fragment: true, text: text.slice(offset, offset + count),
|
|
215
|
+
});
|
|
216
|
+
// Bound the actual model message, including JSON escaping and metadata.
|
|
217
|
+
let low = 0;
|
|
218
|
+
let high = Math.min(limit, text.length - offset);
|
|
219
|
+
while (low < high) {
|
|
220
|
+
const count = Math.ceil((low + high) / 2);
|
|
221
|
+
if (encode(count).length <= MAX_MODEL_CONTENT_CHARS) low = count;
|
|
222
|
+
else high = count - 1;
|
|
223
|
+
}
|
|
224
|
+
return { content: [{ type: "text", text: encode(low) }], details: { filePath } };
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function runBridgeTool(name: string, args: unknown, signal: AbortSignal | undefined, timeoutMs: number, resolve: BridgeResolver,
|
|
230
|
+
prepared?: (receipt: { operation_id: string; bridge_id: string }) => Promise<void>) {
|
|
231
|
+
const body = { ...(args as Record<string, unknown> ?? {}) };
|
|
232
|
+
const retryId = body._operation_id;
|
|
233
|
+
delete body._operation_id;
|
|
234
|
+
if (retryId !== undefined && (typeof retryId !== "string" || !retryId)) throw new Error("_operation_id must be the exact ID of a previous request.");
|
|
235
|
+
const info = await resolve(retryId as string | undefined);
|
|
236
|
+
if (retryId && (!info.supportsOperationTracking || !info.bridgeId)) throw new Error("This bridge does not support operation receipts; the request was not sent.");
|
|
237
|
+
const operationId = info.supportsOperationTracking && info.bridgeId ? (retryId as string | undefined) ?? `${info.bridgeId}:${randomUUID()}` : undefined;
|
|
238
|
+
if (operationId && !operationId.startsWith(`${info.bridgeId}:`)) throw new Error("This operation belongs to a different bridge session. Its outcome is unknown here; it was not replayed.");
|
|
239
|
+
if (prepared && (!operationId || !info.bridgeId)) throw new Error("Script library runs require a bridge with operation receipts. The script was not sent.");
|
|
240
|
+
try {
|
|
241
|
+
if (prepared) await prepared({ operation_id: operationId!, bridge_id: info.bridgeId! });
|
|
242
|
+
const payload = (await bridgeRequest(
|
|
243
|
+
`/tools/${encodeURIComponent(name)}/execute`,
|
|
244
|
+
{ method: "POST", body: JSON.stringify(body), bridge: info,
|
|
245
|
+
query: { timeout_ms: String(timeoutMs), ...(operationId ? { operation_id: operationId } : {}) } },
|
|
246
|
+
signal, timeoutMs,
|
|
247
|
+
)) as BridgeToolResponse;
|
|
248
|
+
const content = await modelContent(name, payload);
|
|
249
|
+
if (operationId) content.push({ type: "text", text: `Operation ID: ${operationId}. Check get_revit_operation after a timeout; retrying with this exact _operation_id and identical arguments will not repeat the action.` });
|
|
250
|
+
return { content, details: operationId ? { ...(payload.details as object ?? {}), operation_id: operationId, bridge_id: info.bridgeId } : payload.details };
|
|
251
|
+
} catch (error) {
|
|
252
|
+
if (!operationId) throw error;
|
|
253
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)}\nOperation ID: ${operationId}. Use get_revit_operation to inspect its outcome. Do not retry an edit with a new ID until its effects are known.`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function registerOperationReader(pi: ExtensionAPI, resolve: BridgeResolver) {
|
|
258
|
+
pi.registerTool({
|
|
259
|
+
name: "get_revit_operation",
|
|
260
|
+
label: "Get Revit Operation",
|
|
261
|
+
description: "Read an operation receipt without waiting for Revit's model thread. Reports queued, running, succeeded, failed, expired_before_start, result_unavailable or unknown, with the original result when retained. Unknown after restart is not proof that the edit never ran. Full results are bounded to the latest 128 receipts / 32 MiB; IDs remain reserved for up to 10,000 operations per bridge session so expired results never cause re-execution.",
|
|
262
|
+
promptSnippet: "Check the outcome of a timed-out Revit operation before retrying an edit.",
|
|
263
|
+
parameters: Type.Object({ operation_id: Type.String({ minLength: 1, maxLength: 120 }) }),
|
|
264
|
+
executionMode: "sequential",
|
|
265
|
+
async execute(_id, args, signal) {
|
|
266
|
+
const bridge = await resolve(args.operation_id);
|
|
267
|
+
const result = await bridgeRequest(`/operations/${encodeURIComponent(args.operation_id)}`, { method: "GET", bridge }, signal, 10_000);
|
|
268
|
+
return { content: await modelContent("get_revit_operation", { details: { payload: result } }), details: result };
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function registerBridgeTool(pi: ExtensionAPI, descriptor: BridgeToolDescriptor, resolve: BridgeResolver) {
|
|
274
|
+
const timeoutMs = toolTimeoutMs(descriptor.name);
|
|
275
|
+
const schema = structuredClone(descriptor.parameters ?? { type: "object", properties: {} }) as { properties?: Record<string, unknown> };
|
|
276
|
+
schema.properties = { ...schema.properties, _operation_id: { type: "string", description: "Optional exact operation ID for retrying an identical earlier request. Reuses its result without repeating the action. Omit for a new operation." } };
|
|
263
277
|
pi.registerTool({
|
|
264
278
|
name: descriptor.name,
|
|
265
279
|
label: descriptor.label ?? descriptor.name,
|
|
266
280
|
description: descriptor.description ?? `Revit bridge tool '${descriptor.name}'.`,
|
|
267
|
-
parameters: Type.Unsafe(
|
|
268
|
-
promptSnippet: descriptor.promptSnippet ?? undefined,
|
|
269
|
-
promptGuidelines: descriptor.promptGuidelines ?? undefined,
|
|
281
|
+
parameters: Type.Unsafe(schema as TSchema),
|
|
282
|
+
promptSnippet: descriptor.tier === "advanced" ? undefined : descriptor.promptSnippet ?? undefined,
|
|
283
|
+
promptGuidelines: descriptor.tier === "advanced" ? undefined : descriptor.promptGuidelines ?? undefined,
|
|
270
284
|
executionMode: descriptor.executionMode === "parallel" ? "parallel" : "sequential",
|
|
271
285
|
async execute(_toolCallId, params, signal) {
|
|
272
|
-
return runBridgeTool(descriptor.name, params, signal, timeoutMs);
|
|
286
|
+
return runBridgeTool(descriptor.name, params, signal, timeoutMs, resolve);
|
|
273
287
|
},
|
|
274
288
|
});
|
|
275
289
|
}
|
|
@@ -352,7 +366,7 @@ async function announceUpdateOnce(notify: (message: string, level: "info") => vo
|
|
|
352
366
|
}
|
|
353
367
|
}
|
|
354
368
|
|
|
355
|
-
function registerPing(pi: ExtensionAPI, onBridgeAlive?: () => Promise<"ready" | "registered" | "failed">) {
|
|
369
|
+
function registerPing(pi: ExtensionAPI, resolve: BridgeResolver, onBridgeAlive?: () => Promise<"ready" | "registered" | "failed">) {
|
|
356
370
|
pi.registerTool({
|
|
357
371
|
name: "ping",
|
|
358
372
|
label: "Ping Revit Bridge",
|
|
@@ -362,7 +376,7 @@ function registerPing(pi: ExtensionAPI, onBridgeAlive?: () => Promise<"ready" |
|
|
|
362
376
|
promptGuidelines: ["Use ping when Revit tools fail or bridge availability is unclear."],
|
|
363
377
|
executionMode: "sequential",
|
|
364
378
|
async execute(_toolCallId, _params, signal) {
|
|
365
|
-
const payload = await bridgeRequest("/ping", { method: "GET" }, signal, 10_000);
|
|
379
|
+
const payload = await bridgeRequest("/ping", { method: "GET", bridge: await resolve() }, signal, 10_000);
|
|
366
380
|
const warning = versionMismatch((payload as { addinVersion?: string }).addinVersion);
|
|
367
381
|
// The bridge is alive: if this session started before Revit and only has
|
|
368
382
|
// ping, register the bridge tools now and tell the model they arrived.
|
|
@@ -384,31 +398,71 @@ function registerPing(pi: ExtensionAPI, onBridgeAlive?: () => Promise<"ready" |
|
|
|
384
398
|
|
|
385
399
|
const REDISCOVERY_INTERVAL_MS = 15_000;
|
|
386
400
|
|
|
387
|
-
export default async function revitConnector(pi: ExtensionAPI) {
|
|
388
|
-
|
|
401
|
+
export default async function revitConnector(pi: ExtensionAPI) {
|
|
402
|
+
const instances = createInstanceRouter(readBridgeInfo, async info => await bridgeRequest("/ping", { method: "GET", bridge: info }, undefined, 2000) as Record<string, unknown>);
|
|
403
|
+
registerResultReader(pi);
|
|
404
|
+
registerOperationReader(pi, instances.resolve);
|
|
405
|
+
registerScriptLibrary(pi, (args, signal, prepared) => runBridgeTool("execute_csharp", args, signal, LONG_TIMEOUT_MS, instances.resolve, prepared),
|
|
406
|
+
async value => ({ content: await modelContent("manage_revit_scripts", { details: { payload: value } }), details: value }));
|
|
407
|
+
pi.registerTool({
|
|
408
|
+
name: "manage_revit_instances", label: "Manage Revit Instances",
|
|
409
|
+
description: "List reachable local Revit bridge sessions or select one for this Pi session. The first sole instance is bound automatically; multiple instances require explicit selection before model calls. After that session closes or restarts, select its new bridge_id: calls never fall back to another session. Selection refreshes the tool catalogue. Operation receipt lookups and identical retries use their original session. Read get_model_overview again after switching; document IDs are session-specific.",
|
|
410
|
+
parameters: Type.Object({ action: Type.Optional(Type.Union([Type.Literal("list"), Type.Literal("select")])), bridge_id: Type.Optional(Type.String()) }),
|
|
411
|
+
executionMode: "sequential",
|
|
412
|
+
async execute(_id, args) {
|
|
413
|
+
let result: unknown;
|
|
414
|
+
if ((args.action ?? "list") === "list") result = { instances: await instances.list() };
|
|
415
|
+
else if (args.action === "select" && args.bridge_id) {
|
|
416
|
+
// Drain discovery for the previous target before changing selection.
|
|
417
|
+
if (discoveryInFlight) await discoveryInFlight;
|
|
418
|
+
const selection = await instances.select(args.bridge_id);
|
|
419
|
+
// A retry timer may have started another discovery while selection probed.
|
|
420
|
+
// Drain that request too, then reset synchronously before fetching anew.
|
|
421
|
+
if (discoveryInFlight) await discoveryInFlight;
|
|
422
|
+
bridgeToolsRegistered = false;
|
|
423
|
+
catalog.reset();
|
|
424
|
+
const ready = await discoverAndRegister();
|
|
425
|
+
result = { ...selection, tool_catalog_ready: ready };
|
|
426
|
+
if (!ready && sessionActive) startRetry();
|
|
427
|
+
} else throw new Error("select requires bridge_id from the instance list.");
|
|
428
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }], details: result };
|
|
429
|
+
},
|
|
430
|
+
});
|
|
389
431
|
// Self-healing discovery: when pi starts before Revit is ready, the initial
|
|
390
432
|
// GET /tools fails and only ping is registered. Rather than requiring a
|
|
391
433
|
// fresh pi start (/reload does not reliably re-run async registration), a
|
|
392
434
|
// background retry keeps probing until the bridge appears, and a successful
|
|
393
435
|
// ping also triggers an immediate attempt.
|
|
394
|
-
let bridgeToolsRegistered = false;
|
|
395
|
-
let discoveryInFlight: Promise<boolean> | null = null;
|
|
396
|
-
|
|
397
|
-
|
|
436
|
+
let bridgeToolsRegistered = false;
|
|
437
|
+
let discoveryInFlight: Promise<boolean> | null = null;
|
|
438
|
+
let sessionActive = false;
|
|
439
|
+
let disposed = false;
|
|
440
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
441
|
+
const catalog = createToolCatalog(pi, discoverAndRegister);
|
|
442
|
+
|
|
443
|
+
async function discoverAndRegister(): Promise<boolean> {
|
|
444
|
+
if (disposed) return false;
|
|
398
445
|
if (bridgeToolsRegistered) return true;
|
|
399
446
|
if (discoveryInFlight) return discoveryInFlight;
|
|
400
447
|
discoveryInFlight = (async () => {
|
|
401
448
|
try {
|
|
402
|
-
const payload = (await bridgeRequest("/tools", { method: "GET" }, undefined, DISCOVERY_TIMEOUT_MS)) as {
|
|
449
|
+
const payload = (await bridgeRequest("/tools", { method: "GET", bridge: await instances.resolve() }, undefined, DISCOVERY_TIMEOUT_MS)) as {
|
|
403
450
|
tools?: BridgeToolDescriptor[];
|
|
404
451
|
};
|
|
405
|
-
const descriptors = Array.isArray(payload?.tools) ? payload.tools : [];
|
|
406
|
-
if (descriptors.length === 0) return false;
|
|
407
|
-
|
|
452
|
+
const descriptors = Array.isArray(payload?.tools) ? payload.tools : [];
|
|
453
|
+
if (disposed || descriptors.length === 0) return false;
|
|
454
|
+
const added: string[] = [];
|
|
455
|
+
for (const descriptor of descriptors) {
|
|
408
456
|
if (!descriptor || typeof descriptor.name !== "string" || !descriptor.name) continue;
|
|
409
|
-
if (
|
|
410
|
-
registerBridgeTool(pi, descriptor);
|
|
411
|
-
|
|
457
|
+
if (["ping", "read_revit_result", "find_revit_tools", "get_revit_operation", "manage_revit_instances", "manage_revit_scripts"].includes(descriptor.name)) continue;
|
|
458
|
+
registerBridgeTool(pi, descriptor, instances.resolve);
|
|
459
|
+
catalog.add(descriptor);
|
|
460
|
+
added.push(descriptor.name);
|
|
461
|
+
}
|
|
462
|
+
if (sessionActive) {
|
|
463
|
+
catalog.hideAdvanced(added);
|
|
464
|
+
pi.setActiveTools([...new Set([...pi.getActiveTools(), ...descriptors.filter(d => added.includes(d.name) && d.tier !== "advanced").map(d => d.name)])]);
|
|
465
|
+
}
|
|
412
466
|
bridgeToolsRegistered = true;
|
|
413
467
|
return true;
|
|
414
468
|
} catch {
|
|
@@ -426,7 +480,7 @@ export default async function revitConnector(pi: ExtensionAPI) {
|
|
|
426
480
|
// bridge is down, so it is never part of /tools discovery. A successful
|
|
427
481
|
// ping doubles as a re-discovery trigger — the natural first call in a
|
|
428
482
|
// session that finds itself without bridge tools.
|
|
429
|
-
registerPing(pi, async () => {
|
|
483
|
+
registerPing(pi, instances.resolve, async () => {
|
|
430
484
|
if (bridgeToolsRegistered) return "ready";
|
|
431
485
|
return (await discoverAndRegister()) ? "registered" : "failed";
|
|
432
486
|
});
|
|
@@ -434,10 +488,13 @@ export default async function revitConnector(pi: ExtensionAPI) {
|
|
|
434
488
|
// Surface an incomplete update (see versionMismatch) once per session, right
|
|
435
489
|
// where the user lands after running `pi update --extensions`. Bridge down at
|
|
436
490
|
// session start is the normal Revit-closed case: stay quiet.
|
|
437
|
-
pi.on("session_start", async (_event, ctx) => {
|
|
491
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
492
|
+
sessionActive = true;
|
|
493
|
+
catalog.hideAdvanced();
|
|
494
|
+
if (!bridgeToolsRegistered) startRetry();
|
|
438
495
|
await announceUpdateOnce((message, level) => ctx.ui.notify(message, level));
|
|
439
496
|
try {
|
|
440
|
-
const payload = (await bridgeRequest("/ping", { method: "GET" }, undefined, 3_000)) as { addinVersion?: string };
|
|
497
|
+
const payload = (await bridgeRequest("/ping", { method: "GET", bridge: await instances.resolve() }, undefined, 3_000)) as { addinVersion?: string };
|
|
441
498
|
const warning = versionMismatch(payload.addinVersion);
|
|
442
499
|
if (warning) ctx.ui.notify(warning, "warning");
|
|
443
500
|
} catch {
|
|
@@ -445,33 +502,35 @@ export default async function revitConnector(pi: ExtensionAPI) {
|
|
|
445
502
|
}
|
|
446
503
|
});
|
|
447
504
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
}
|
|
505
|
+
pi.on("session_shutdown", async () => {
|
|
506
|
+
disposed = true;
|
|
507
|
+
sessionActive = false;
|
|
508
|
+
if (timer) clearInterval(timer);
|
|
509
|
+
timer = undefined;
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
function startRetry() {
|
|
513
|
+
if (timer || disposed) return;
|
|
514
|
+
timer = setInterval(async () => {
|
|
515
|
+
if (!(await discoverAndRegister())) return;
|
|
516
|
+
if (timer) clearInterval(timer);
|
|
517
|
+
timer = undefined;
|
|
518
|
+
if (disposed) return;
|
|
519
|
+
// Refresh the model's knowledge on its next turn without interrupting the user.
|
|
520
|
+
try {
|
|
521
|
+
pi.sendMessage(
|
|
522
|
+
{
|
|
523
|
+
customType: "pi-revit",
|
|
524
|
+
content: "Revit is now reachable. Core bridge tools are available; use find_revit_tools to activate specialist tools.",
|
|
525
|
+
display: true,
|
|
526
|
+
},
|
|
527
|
+
{ deliverAs: "nextTurn" },
|
|
528
|
+
);
|
|
529
|
+
} catch {
|
|
530
|
+
// Tool registration remains valid if the session cannot accept a message.
|
|
531
|
+
}
|
|
532
|
+
}, REDISCOVERY_INTERVAL_MS);
|
|
533
|
+
timer.unref?.();
|
|
534
|
+
}
|
|
535
|
+
await discoverAndRegister();
|
|
536
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
|
|
6
|
+
export interface BridgeInfo {
|
|
7
|
+
baseUrl: string;
|
|
8
|
+
token: string;
|
|
9
|
+
pid?: number;
|
|
10
|
+
revitVersion?: string;
|
|
11
|
+
bridgeId?: string;
|
|
12
|
+
supportsOperationTracking?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Selection belongs to one extension instance. A selected session never silently falls back. */
|
|
16
|
+
export function createInstanceRouter(readLegacy: () => Promise<BridgeInfo>, probe: (info: BridgeInfo) => Promise<Record<string, unknown>>) {
|
|
17
|
+
let selected: string | undefined;
|
|
18
|
+
// Older bridges have no generation ID. Derive a stable opaque selector from
|
|
19
|
+
// their per-start credentials without exposing the credential itself.
|
|
20
|
+
const identity = (info: BridgeInfo) => info.bridgeId ?? createHash("sha256").update(info.baseUrl + "\0" + info.token).digest("hex").slice(0, 32);
|
|
21
|
+
const directory = () => path.join(process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming"), "RevitBridge", "instances");
|
|
22
|
+
async function candidates(): Promise<BridgeInfo[]> {
|
|
23
|
+
const entries = new Map<string, BridgeInfo>();
|
|
24
|
+
let files: string[] = [];
|
|
25
|
+
try { files = (await readdir(directory())).filter(name => /^[0-9a-f]{32}\.json$/.test(name)); } catch { }
|
|
26
|
+
// Old crash records may accumulate; probe only records whose process still exists.
|
|
27
|
+
for (const file of files) {
|
|
28
|
+
try {
|
|
29
|
+
const info = JSON.parse(await readFile(path.join(directory(), file), "utf8")) as BridgeInfo;
|
|
30
|
+
if (info.bridgeId !== file.slice(0, -5) || !info.baseUrl || !info.token || !Number.isSafeInteger(info.pid)) continue;
|
|
31
|
+
try { process.kill(info.pid!, 0); } catch { continue; }
|
|
32
|
+
entries.set(info.bridgeId, info);
|
|
33
|
+
} catch { }
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
const legacy = await readLegacy();
|
|
37
|
+
entries.set(identity(legacy), legacy);
|
|
38
|
+
} catch { }
|
|
39
|
+
return [...entries.values()];
|
|
40
|
+
}
|
|
41
|
+
async function live() {
|
|
42
|
+
const found = await Promise.all((await candidates()).map(async info => {
|
|
43
|
+
try {
|
|
44
|
+
const ping = await probe(info);
|
|
45
|
+
if (info.bridgeId && ping.bridgeId !== info.bridgeId) return null;
|
|
46
|
+
return { info, ping };
|
|
47
|
+
} catch { return null; }
|
|
48
|
+
}));
|
|
49
|
+
return found.filter((entry): entry is NonNullable<typeof entry> => entry !== null);
|
|
50
|
+
}
|
|
51
|
+
async function resolve(operationId?: string): Promise<BridgeInfo> {
|
|
52
|
+
const target = operationId?.split(":")[0] ?? selected;
|
|
53
|
+
if (target) {
|
|
54
|
+
const info = (await candidates()).find(entry => identity(entry) === target);
|
|
55
|
+
if (!info) throw new Error("The original or selected Revit bridge session is unavailable. Its outcome is unknown here; no action was sent to another instance. Use manage_revit_instances to select an available session.");
|
|
56
|
+
return info;
|
|
57
|
+
}
|
|
58
|
+
const entries = await candidates();
|
|
59
|
+
// Bind only a verified live session; a crash record must not prevent
|
|
60
|
+
// startup discovery from recovering when Revit is launched later.
|
|
61
|
+
if (entries.length === 1) {
|
|
62
|
+
const ping = await probe(entries[0]);
|
|
63
|
+
if (entries[0].bridgeId && ping.bridgeId !== entries[0].bridgeId)
|
|
64
|
+
throw new Error("Revit discovery points to a different bridge generation. Refresh the instance list.");
|
|
65
|
+
selected = identity(entries[0]); return entries[0];
|
|
66
|
+
}
|
|
67
|
+
const available = await live();
|
|
68
|
+
if (available.length > 1) throw new Error("Several Revit instances are open. Use manage_revit_instances to list and select the intended bridge_id before calling model tools.");
|
|
69
|
+
if (available.length === 1) { selected = identity(available[0].info); return available[0].info; }
|
|
70
|
+
return readLegacy();
|
|
71
|
+
}
|
|
72
|
+
async function list() {
|
|
73
|
+
return (await live()).map(({ info, ping }) => ({ bridge_id: identity(info), pid: info.pid ?? null,
|
|
74
|
+
revit_version: info.revitVersion ?? null, addin_version: ping.addinVersion ?? null,
|
|
75
|
+
selected: selected === identity(info), supports_operation_tracking: info.supportsOperationTracking === true }));
|
|
76
|
+
}
|
|
77
|
+
async function select(id: string) {
|
|
78
|
+
if (!/^[0-9a-f]{32}$/.test(id)) throw new Error("bridge_id must be an exact session ID from manage_revit_instances.");
|
|
79
|
+
const match = (await live()).find(entry => identity(entry.info) === id);
|
|
80
|
+
if (!match) throw new Error("That bridge session is no longer reachable; selection was unchanged.");
|
|
81
|
+
selected = id;
|
|
82
|
+
return { bridge_id: id, pid: match.info.pid, addin_version: match.ping.addinVersion,
|
|
83
|
+
instructions: "Selection applies to this Pi extension session. Read get_model_overview for a fresh exact document identity before editing. Operation receipts and identical retries are routed to their original bridge session." };
|
|
84
|
+
}
|
|
85
|
+
return { resolve, list, select };
|
|
86
|
+
}
|