pi-revit 0.3.0 → 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.
Files changed (48) hide show
  1. package/CHANGELOG.md +394 -351
  2. package/README.md +438 -25
  3. package/bin/pi-revit.js +3 -5
  4. package/extensions/pi-revit/index.ts +151 -92
  5. package/extensions/pi-revit/instance-router.ts +86 -0
  6. package/extensions/pi-revit/script-library.ts +146 -0
  7. package/extensions/pi-revit/tool-catalog.ts +67 -0
  8. package/package.json +58 -57
  9. package/scripts/build.ps1 +9 -3
  10. package/scripts/check-sdk.ps1 +66 -0
  11. package/scripts/deploy.ps1 +16 -4
  12. package/skills/pi-revit/SKILL.md +181 -17
  13. package/skills/pi-revit/references/model-audit-export.md +29 -0
  14. package/skills/pi-revit/references/room-documentation.md +28 -0
  15. package/src/Revit/BridgeServer.cs +79 -29
  16. package/src/Revit/OperationStore.cs +178 -0
  17. package/src/Revit/ToolRegistry.cs +26 -4
  18. package/src/Revit/Tools/CaptureView.cs +2 -1
  19. package/src/Revit/Tools/ChangeElementTypes.cs +60 -0
  20. package/src/Revit/Tools/CreateTags.cs +95 -0
  21. package/src/Revit/Tools/DeleteElements.cs +44 -0
  22. package/src/Revit/Tools/DocumentGuard.cs +2 -2
  23. package/src/Revit/Tools/ElementQueryScope.cs +27 -0
  24. package/src/Revit/Tools/ExecuteCsharp.cs +23 -13
  25. package/src/Revit/Tools/ExportDocuments.cs +2 -1
  26. package/src/Revit/Tools/GetElementDetails.cs +32 -2
  27. package/src/Revit/Tools/GetElementRelationships.cs +76 -0
  28. package/src/Revit/Tools/GetElements.cs +21 -7
  29. package/src/Revit/Tools/GetLinkedElements.cs +82 -0
  30. package/src/Revit/Tools/GetLinkedModels.cs +66 -0
  31. package/src/Revit/Tools/GetModelCoordinates.cs +49 -0
  32. package/src/Revit/Tools/GetScheduleFields.cs +37 -0
  33. package/src/Revit/Tools/GetSchedules.cs +89 -0
  34. package/src/Revit/Tools/ManageElementSets.cs +106 -0
  35. package/src/Revit/Tools/ManageSchedules.cs +164 -0
  36. package/src/Revit/Tools/ManageSelection.cs +2 -1
  37. package/src/Revit/Tools/ManageSheetPlacements.cs +97 -0
  38. package/src/Revit/Tools/ManageSheets.cs +63 -0
  39. package/src/Revit/Tools/ManageViews.cs +100 -0
  40. package/src/Revit/Tools/MeasureGeometry.cs +54 -0
  41. package/src/Revit/Tools/ModelEditBatch.cs +102 -0
  42. package/src/Revit/Tools/ModelEditInputs.cs +49 -0
  43. package/src/Revit/Tools/OpenView.cs +2 -1
  44. package/src/Revit/Tools/QuerySpatialElements.cs +63 -0
  45. package/src/Revit/Tools/SetParameters.cs +43 -93
  46. package/src/Revit/Tools/SpatialBounds.cs +30 -0
  47. package/src/Revit/Tools/SummarizeElements.cs +87 -0
  48. package/src/Revit/Tools/TransformElements.cs +58 -0
@@ -5,17 +5,15 @@ 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
- interface BridgeInfo {
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: string;
15
+ interface ContentBlock {
16
+ type: "text";
19
17
  text: string;
20
18
  }
21
19
 
@@ -30,21 +28,6 @@ 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;
@@ -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
 
@@ -243,33 +226,64 @@ function registerResultReader(pi: ExtensionAPI) {
243
226
  });
244
227
  }
245
228
 
246
- async function runBridgeTool(name: string, args: unknown, signal: AbortSignal | undefined, timeoutMs: number) {
247
- const payload = (await bridgeRequest(
248
- `/tools/${encodeURIComponent(name)}/execute`,
249
- {
250
- method: "POST",
251
- body: JSON.stringify(args ?? {}),
252
- query: { timeout_ms: String(timeoutMs) },
253
- },
254
- signal,
255
- timeoutMs,
256
- )) as BridgeToolResponse;
257
-
258
- return { content: await modelContent(name, payload), details: payload.details };
259
- }
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
+ }
260
272
 
261
- function registerBridgeTool(pi: ExtensionAPI, descriptor: BridgeToolDescriptor) {
262
- const timeoutMs = toolTimeoutMs(descriptor.name);
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((descriptor.parameters ?? { type: "object", properties: {} }) as TSchema),
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.
@@ -385,30 +399,70 @@ function registerPing(pi: ExtensionAPI, onBridgeAlive?: () => Promise<"ready" |
385
399
  const REDISCOVERY_INTERVAL_MS = 15_000;
386
400
 
387
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>);
388
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
- async function discoverAndRegister(): Promise<boolean> {
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
- for (const descriptor of descriptors) {
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 (descriptor.name === "ping" || descriptor.name === "read_revit_result") continue;
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
- if (await discoverAndRegister()) return;
449
-
450
- // Never block pi startup on Revit: keep retrying quietly in the background
451
- // and stop the moment discovery succeeds.
452
- const timer = setInterval(async () => {
453
- if (!(await discoverAndRegister())) return;
454
- clearInterval(timer);
455
- // The ping path announces newly registered tools in its result text; this path
456
- // must speak too. Without it the tools appear silently in the next system
457
- // prompt while nothing in the conversation contradicts an earlier "Revit is
458
- // not running" — the session's belief goes stale. Custom messages participate
459
- // in LLM context; deliverAs "nextTurn" queues it for the next user prompt
460
- // without interrupting or triggering anything.
461
- try {
462
- pi.sendMessage(
463
- {
464
- customType: "pi-revit",
465
- content:
466
- "Revit is now reachable: the Revit bridge tools (get_elements, set_parameters, execute_csharp, ...) were just registered in this session and are available from now on.",
467
- display: true,
468
- },
469
- { deliverAs: "nextTurn" },
470
- );
471
- } catch {
472
- // An older pi without sendMessage, or a torn-down session: the
473
- // registration itself succeeded and must never be undone by the announcer.
474
- }
475
- }, REDISCOVERY_INTERVAL_MS);
476
- timer.unref?.();
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
+ }
@@ -0,0 +1,146 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import { mkdir, readdir, readFile, writeFile, rename } from "node:fs/promises";
4
+ import { createHash, randomUUID } from "node:crypto";
5
+ import path from "node:path";
6
+ import os from "node:os";
7
+
8
+ type InputKind = "string" | "number" | "integer" | "boolean" | "object" | "array";
9
+ interface Definition { name: string; description: string; code: string; input_types: Record<string, InputKind>; }
10
+ interface Version extends Definition { version: string; created_at: string; }
11
+ interface Receipt { operation_id: string; bridge_id: string; }
12
+ type Result = { content: { type: "text"; text: string }[]; details?: unknown };
13
+ type Runner = (args: Record<string, unknown>, signal: AbortSignal | undefined, prepared: (receipt: Receipt) => Promise<void>) => Promise<Result>;
14
+ const kinds = ["string", "number", "integer", "boolean", "object", "array"] as const;
15
+ const object = (value: unknown): value is Record<string, unknown> => value !== null && typeof value === "object" && !Array.isArray(value);
16
+ const hash = (value: unknown) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
17
+ const scriptName = (name: unknown) => {
18
+ if (typeof name !== "string" || !/^[a-z][a-z0-9_-]{0,63}$/.test(name)) throw new Error("name must be 1–64 lowercase letters, digits, underscores or hyphens, starting with a letter.");
19
+ return name;
20
+ };
21
+ const versionId = (version: unknown) => {
22
+ if (typeof version !== "string" || !/^[a-f0-9]{64}$/.test(version)) throw new Error("version must be the exact saved 64-character hash.");
23
+ return version;
24
+ };
25
+
26
+ export function createScriptLibrary(root: string) {
27
+ const versions = path.join(root, "versions"), history = path.join(root, "history");
28
+ const definition = (value: Record<string, unknown>): Definition => {
29
+ const name = scriptName(value.name);
30
+ if (typeof value.code !== "string" || !value.code.trim() || value.code.length > 100000) throw new Error("code must contain 1–100,000 characters.");
31
+ if (typeof value.description !== "string" || value.description.length > 2000) throw new Error("description must be a string of at most 2,000 characters.");
32
+ if (!object(value.input_types) || Object.keys(value.input_types).length > 40) throw new Error("input_types must declare at most 40 named inputs.");
33
+ const input_types: Record<string, InputKind> = Object.create(null);
34
+ for (const key of Object.keys(value.input_types).sort()) {
35
+ if (!/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(key) || !kinds.includes(value.input_types[key] as InputKind)) throw new Error(`Invalid input declaration: ${key}.`);
36
+ input_types[key] = value.input_types[key] as InputKind;
37
+ }
38
+ return { name, description: value.description, code: value.code, input_types };
39
+ };
40
+ async function files(directory: string) {
41
+ try { return (await readdir(directory)).filter(file => file.endsWith(".json")); }
42
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; throw error; }
43
+ }
44
+ async function read(name: unknown, version: unknown): Promise<Version> {
45
+ const value = JSON.parse(await readFile(path.join(versions, `${scriptName(name)}--${versionId(version)}.json`), "utf8"));
46
+ const content = definition(value);
47
+ if (content.name !== name || hash(content) !== version || value.version !== version) throw new Error("Saved script integrity check failed; no code was executed.");
48
+ return { ...content, version: value.version, created_at: value.created_at };
49
+ }
50
+ async function save(args: Record<string, unknown>) {
51
+ const content = definition(args), version = hash(content);
52
+ await mkdir(versions, { recursive: true });
53
+ const file = path.join(versions, `${content.name}--${version}.json`);
54
+ const value = { ...content, version, created_at: new Date().toISOString() };
55
+ try { await writeFile(file, JSON.stringify(value, null, 2), { encoding: "utf8", flag: "wx" }); }
56
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
57
+ const saved = await read(content.name, version);
58
+ return { name: saved.name, version, created_at: saved.created_at, file_path: file, executed: false };
59
+ }
60
+ function validateInputs(saved: Version, input: unknown) {
61
+ if (!object(input)) throw new Error("inputs must be a JSON object.");
62
+ if (JSON.stringify(input).length > 100000) throw new Error("inputs exceed 100,000 JSON characters.");
63
+ for (const name of Object.keys(input)) if (!Object.hasOwn(saved.input_types, name)) throw new Error(`Undeclared input: ${name}.`);
64
+ for (const [name, kind] of Object.entries(saved.input_types)) {
65
+ const value = input[name];
66
+ const valid = Object.hasOwn(input, name) && (kind === "array" ? Array.isArray(value) : kind === "object" ? object(value)
67
+ : kind === "integer" ? Number.isSafeInteger(value) : kind === "number" ? typeof value === "number" && Number.isFinite(value) : typeof value === kind);
68
+ if (!valid) throw new Error(`Input '${name}' is required and must be ${kind}.`);
69
+ }
70
+ return input;
71
+ }
72
+ async function list(name: unknown, offset: number, limit: number) {
73
+ const filter = name === undefined ? undefined : scriptName(name);
74
+ const names = (await files(versions)).filter(file => /^[a-z][a-z0-9_-]{0,63}--[a-f0-9]{64}\.json$/.test(file) && (!filter || file.slice(0, file.lastIndexOf("--")) === filter)).sort();
75
+ const entries = [];
76
+ for (const file of names.slice(offset, offset + limit)) {
77
+ const split = file.lastIndexOf("--");
78
+ const value = await read(file.slice(0, split), file.slice(split + 2, -5));
79
+ entries.push({ name: value.name, version: value.version, description: value.description, input_types: value.input_types, created_at: value.created_at });
80
+ }
81
+ return { total_count: names.length, offset, entries, next_offset: offset + entries.length < names.length ? offset + entries.length : null };
82
+ }
83
+ async function writeHistory(record: Record<string, unknown>) {
84
+ await mkdir(history, { recursive: true });
85
+ const file = path.join(history, `${record.run_id}.json`), temp = `${file}.${randomUUID()}.tmp`;
86
+ await writeFile(temp, JSON.stringify(record, null, 2), { encoding: "utf8", flag: "wx" });
87
+ await rename(temp, file);
88
+ }
89
+ async function readHistory(name: unknown, offset: number, limit: number) {
90
+ const filter = name === undefined ? undefined : scriptName(name);
91
+ const entries = [];
92
+ for (const file of await files(history)) {
93
+ if (!/^[a-f0-9-]{36}\.json$/.test(file)) continue;
94
+ const value = JSON.parse(await readFile(path.join(history, file), "utf8"));
95
+ if (!filter || value.name === filter) entries.push(value);
96
+ }
97
+ entries.sort((a, b) => b.started_at.localeCompare(a.started_at) || a.run_id.localeCompare(b.run_id));
98
+ return { total_count: entries.length, offset, entries: entries.slice(offset, offset + limit), next_offset: offset + limit < entries.length ? offset + limit : null };
99
+ }
100
+ return { root, read, save, list, validateInputs, writeHistory, readHistory };
101
+ }
102
+
103
+ export function registerScriptLibrary(pi: ExtensionAPI, run: Runner, render: (value: unknown) => Promise<Result>) {
104
+ const library = createScriptLibrary(path.join(process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming"), "pi-revit", "scripts"));
105
+ pi.registerTool({
106
+ name: "manage_revit_scripts", label: "Manage Revit Scripts",
107
+ description: "Save, list, read, run and inspect local reusable C# scripts. Saving never executes code. Versions are immutable content hashes; read a version before explicitly running that exact hash. Every declared input is required; extra inputs are rejected. input_types validates top-level JSON kinds only; scripts validate nested contents and domain rules. Runs use execute_csharp with separate inputs JsonElement, exact expected_document_id and operation receipts. Scripts have the same unrestricted model/UI/file/external effects and transaction behavior as execute_csharp. Local history records version, document, input hash and receipt, not input values or results. After interruption, inspect get_revit_operation before retrying with the same _operation_id and identical inputs. No automatic runs or model saving.",
108
+ parameters: Type.Object({
109
+ action: Type.Union(["list", "save", "read", "run", "history"].map(value => Type.Literal(value))),
110
+ name: Type.Optional(Type.String()), version: Type.Optional(Type.String()), description: Type.Optional(Type.String({ maxLength: 2000 })),
111
+ code: Type.Optional(Type.String({ minLength: 1, maxLength: 100000 })),
112
+ input_types: Type.Optional(Type.Record(Type.String(), Type.Union(kinds.map(value => Type.Literal(value))))),
113
+ inputs: Type.Optional(Type.Record(Type.String(), Type.Unknown())), expected_document_id: Type.Optional(Type.String()), _operation_id: Type.Optional(Type.String()),
114
+ offset: Type.Optional(Type.Integer({ minimum: 0 })), limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
115
+ }),
116
+ executionMode: "sequential",
117
+ async execute(_id, args, signal) {
118
+ const offset = Math.max(0, args.offset ?? 0), limit = Math.max(1, Math.min(100, args.limit ?? 20));
119
+ if (args.action === "save") return render(await library.save(args));
120
+ if (args.action === "list") return render(await library.list(args.name, offset, limit));
121
+ if (args.action === "history") return render(await library.readHistory(args.name, offset, limit));
122
+ if (args.action !== "read" && args.action !== "run") throw new Error("Unknown script library action.");
123
+ const saved = await library.read(args.name, args.version);
124
+ if (args.action === "read") return render(saved);
125
+ if (typeof args.expected_document_id !== "string" || !args.expected_document_id) throw new Error("Run requires expected_document_id from get_model_overview.");
126
+ const inputs = library.validateInputs(saved, args.inputs ?? {});
127
+ const record: Record<string, unknown> = { run_id: randomUUID(), name: saved.name, version: saved.version, started_at: new Date().toISOString(), expected_document_id: args.expected_document_id, inputs_hash: hash(inputs), state: "prepared" };
128
+ let dispatched = false;
129
+ try {
130
+ const result = await run({ code: saved.code, inputs, expected_document_id: args.expected_document_id, ...(args._operation_id !== undefined ? { _operation_id: args._operation_id } : {}) }, signal, async receipt => {
131
+ Object.assign(record, receipt); await library.writeHistory(record); dispatched = true;
132
+ });
133
+ Object.assign(record, { state: "response_received", completed_at: new Date().toISOString() });
134
+ try { await library.writeHistory(record); }
135
+ catch { result.content.push({ type: "text", text: "The script response was received, but the final local history update failed. Use the operation receipt for its outcome." }); }
136
+ return result;
137
+ } catch (error) {
138
+ if (dispatched) {
139
+ Object.assign(record, { state: "outcome_unconfirmed", completed_at: new Date().toISOString() });
140
+ try { await library.writeHistory(record); } catch { /* Preserve the original error and operation ID. */ }
141
+ }
142
+ throw error;
143
+ }
144
+ },
145
+ });
146
+ }
@@ -0,0 +1,67 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+
4
+ export interface BridgeToolDescriptor {
5
+ name: string;
6
+ label?: string;
7
+ description?: string;
8
+ category?: string;
9
+ tier?: string;
10
+ parameters?: unknown;
11
+ executionMode?: string;
12
+ write?: boolean;
13
+ effects?: string[];
14
+ requiresDocument?: boolean;
15
+ promptSnippet?: string | null;
16
+ promptGuidelines?: string[] | null;
17
+ }
18
+
19
+ /** The bridge registry is the source of truth; discovery never duplicates its schemas. */
20
+ export function createToolCatalog(pi: ExtensionAPI, discover: () => Promise<boolean>) {
21
+ const entries = new Map<string, BridgeToolDescriptor>();
22
+ function hideAdvanced(names: string[] = [...entries.keys()]) {
23
+ const advanced = new Set(names.filter(name => entries.get(name)?.tier === "advanced"));
24
+ pi.setActiveTools(pi.getActiveTools().filter(name => !advanced.has(name)));
25
+ }
26
+
27
+ pi.registerTool({
28
+ name: "find_revit_tools",
29
+ label: "Find Revit Tools",
30
+ description: "Search the installed Revit tool catalogue and activate matching tools for this Pi session. Search words match tool names and descriptions. Supply exact names to activate known tools. With no query or names, list tools without activating them. Activation is additive and does not run any Revit operation. Read the returned schemas on the next model turn before calling a newly activated tool.",
31
+ promptSnippet: "Find and activate specialist Revit tools for links, schedules, relationships, inspection and editing.",
32
+ promptGuidelines: ["Use find_revit_tools to discover and activate specialist Revit tools before using execute_csharp for a task that may already have a dedicated tool."],
33
+ parameters: Type.Object({
34
+ query: Type.Optional(Type.String({ description: "Search words, such as linked elements or schedules." })),
35
+ names: Type.Optional(Type.Array(Type.String(), { maxItems: 20, description: "Exact tool names; takes precedence over query." })),
36
+ activate: Type.Optional(Type.Boolean({ description: "Activate returned matches. Default true for a query or exact names; false for browsing." })),
37
+ offset: Type.Optional(Type.Integer({ minimum: 0 })),
38
+ limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 20 })),
39
+ }),
40
+ executionMode: "sequential",
41
+ async execute(_id, args) {
42
+ if (!entries.size && !(await discover())) throw new Error("The Revit tool catalogue is unavailable. Start Revit with the bridge loaded and retry.");
43
+ const names = args.names?.length ? [...new Set(args.names)] : undefined;
44
+ if (names?.some(name => !entries.has(name))) throw new Error(`Unknown Revit tool names: ${names.filter(name => !entries.has(name)).join(", ")}`);
45
+ const words = (args.query ?? "").trim().toLowerCase().split(/[\s_]+/).filter(Boolean);
46
+ const matches = [...entries.values()].filter(entry => names ? names.includes(entry.name)
47
+ : words.every(word => `${entry.name.replaceAll("_", " ")} ${entry.description ?? ""}`.toLowerCase().includes(word)))
48
+ .sort((a, b) => a.name.localeCompare(b.name));
49
+ const offset = Math.max(0, args.offset ?? 0);
50
+ const page = matches.slice(offset, offset + Math.max(1, Math.min(20, args.limit ?? 10)));
51
+ const activate = args.activate ?? Boolean(names || words.length);
52
+ if (activate && page.length) pi.setActiveTools([...new Set([...pi.getActiveTools(), ...page.map(entry => entry.name)])]);
53
+ const active = new Set(pi.getActiveTools());
54
+ const result = {
55
+ total_count: matches.length, offset, returned_count: page.length,
56
+ next_offset: offset + page.length < matches.length ? offset + page.length : null,
57
+ tools: page.map(entry => ({ name: entry.name, description: entry.description, tier: entry.tier ?? "core", effects: entry.effects ?? (entry.write ? ["model"] : []), active: active.has(entry.name) })),
58
+ };
59
+ return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: result };
60
+ },
61
+ });
62
+ function reset() {
63
+ pi.setActiveTools(pi.getActiveTools().filter(name => !entries.has(name)));
64
+ entries.clear();
65
+ }
66
+ return { add: (entry: BridgeToolDescriptor) => entries.set(entry.name, entry), hideAdvanced, reset };
67
+ }