pi-revit 0.3.1 → 0.5.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 (120) hide show
  1. package/AGENTS.md +167 -0
  2. package/CHANGELOG.md +114 -42
  3. package/README.md +598 -138
  4. package/bin/pi-revit.js +9 -9
  5. package/docs/architecture.md +271 -0
  6. package/docs/evaluation.md +434 -0
  7. package/docs/invariants.json +147 -0
  8. package/extensions/pi-revit/completion-monitor.ts +55 -0
  9. package/extensions/pi-revit/contracts.ts +146 -0
  10. package/extensions/pi-revit/discovery.ts +93 -0
  11. package/extensions/pi-revit/index.ts +231 -85
  12. package/extensions/pi-revit/instance-router.ts +86 -0
  13. package/extensions/pi-revit/platform-prompt.ts +40 -0
  14. package/extensions/pi-revit/scope-monitor.ts +114 -0
  15. package/extensions/pi-revit/script-library.ts +146 -0
  16. package/extensions/pi-revit/tool-catalog.ts +166 -0
  17. package/extensions/pi-revit/tool-documentation.ts +72 -0
  18. package/extensions/pi-revit/tool-schema.ts +8 -0
  19. package/package.json +65 -59
  20. package/scripts/build.ps1 +9 -9
  21. package/scripts/check-sdk.ps1 +66 -66
  22. package/scripts/check-tool-documentation.mjs +287 -0
  23. package/scripts/deploy.ps1 +16 -16
  24. package/scripts/generate-contracts.mjs +80 -0
  25. package/scripts/lib/platform.mjs +226 -0
  26. package/scripts/test-extension.mjs +15 -0
  27. package/skills/pi-revit/SKILL.md +39 -63
  28. package/skills/pi-revit/contracts.generated.json +3524 -0
  29. package/skills/pi-revit/references/execution-rules.md +41 -0
  30. package/skills/pi-revit/references/model-audit-export.md +40 -0
  31. package/skills/pi-revit/references/operation-recovery.md +33 -0
  32. package/skills/pi-revit/references/room-documentation.md +39 -0
  33. package/skills/pi-revit/references/tool-index.md +89 -0
  34. package/skills/pi-revit/references/tools/capture_view.md +62 -0
  35. package/skills/pi-revit/references/tools/change_element_types.md +65 -0
  36. package/skills/pi-revit/references/tools/create_tags.md +85 -0
  37. package/skills/pi-revit/references/tools/delete_elements.md +66 -0
  38. package/skills/pi-revit/references/tools/execute_csharp.md +81 -0
  39. package/skills/pi-revit/references/tools/export_documents.md +75 -0
  40. package/skills/pi-revit/references/tools/find_revit_tools.md +96 -0
  41. package/skills/pi-revit/references/tools/get_element_details.md +66 -0
  42. package/skills/pi-revit/references/tools/get_element_relationships.md +61 -0
  43. package/skills/pi-revit/references/tools/get_element_types.md +67 -0
  44. package/skills/pi-revit/references/tools/get_elements.md +87 -0
  45. package/skills/pi-revit/references/tools/get_linked_elements.md +79 -0
  46. package/skills/pi-revit/references/tools/get_linked_models.md +57 -0
  47. package/skills/pi-revit/references/tools/get_model_coordinates.md +64 -0
  48. package/skills/pi-revit/references/tools/get_model_health.md +53 -0
  49. package/skills/pi-revit/references/tools/get_model_overview.md +57 -0
  50. package/skills/pi-revit/references/tools/get_revit_operation.md +54 -0
  51. package/skills/pi-revit/references/tools/get_schedule_fields.md +62 -0
  52. package/skills/pi-revit/references/tools/get_schedules.md +71 -0
  53. package/skills/pi-revit/references/tools/manage_element_sets.md +92 -0
  54. package/skills/pi-revit/references/tools/manage_revit_instances.md +63 -0
  55. package/skills/pi-revit/references/tools/manage_revit_scripts.md +109 -0
  56. package/skills/pi-revit/references/tools/manage_schedules.md +90 -0
  57. package/skills/pi-revit/references/tools/manage_selection.md +66 -0
  58. package/skills/pi-revit/references/tools/manage_sheet_placements.md +82 -0
  59. package/skills/pi-revit/references/tools/manage_sheets.md +71 -0
  60. package/skills/pi-revit/references/tools/manage_views.md +95 -0
  61. package/skills/pi-revit/references/tools/measure_geometry.md +71 -0
  62. package/skills/pi-revit/references/tools/open_view.md +59 -0
  63. package/skills/pi-revit/references/tools/ping.md +41 -0
  64. package/skills/pi-revit/references/tools/query_spatial_elements.md +74 -0
  65. package/skills/pi-revit/references/tools/read_revit_result.md +53 -0
  66. package/skills/pi-revit/references/tools/search_api_docs.md +65 -0
  67. package/skills/pi-revit/references/tools/set_parameters.md +75 -0
  68. package/skills/pi-revit/references/tools/summarize_elements.md +64 -0
  69. package/skills/pi-revit/references/tools/transform_elements.md +79 -0
  70. package/skills/pi-revit/references/visual-verification.md +36 -0
  71. package/skills/pi-revit/tool-manifest.json +338 -0
  72. package/src/Revit/BridgeServer.cs +75 -19
  73. package/src/Revit/OperationStore.cs +178 -0
  74. package/src/Revit/ToolRegistry.cs +61 -8
  75. package/src/Revit/Tools/CaptureView.cs +9 -0
  76. package/src/Revit/Tools/ChangeElementTypes.cs +74 -0
  77. package/src/Revit/Tools/ChangeSet.cs +39 -0
  78. package/src/Revit/Tools/CreateTags.cs +107 -0
  79. package/src/Revit/Tools/DeleteElements.cs +53 -0
  80. package/src/Revit/Tools/DocumentGuard.cs +12 -2
  81. package/src/Revit/Tools/ElementNames.cs +103 -0
  82. package/src/Revit/Tools/ElementQueryScope.cs +27 -0
  83. package/src/Revit/Tools/ElementTraits.cs +53 -0
  84. package/src/Revit/Tools/ExecuteCsharp.cs +26 -7
  85. package/src/Revit/Tools/ExportDocuments.cs +9 -0
  86. package/src/Revit/Tools/FailureGuard.cs +26 -26
  87. package/src/Revit/Tools/GetElementDetails.cs +28 -2
  88. package/src/Revit/Tools/GetElementRelationships.cs +82 -0
  89. package/src/Revit/Tools/GetElementTypes.cs +8 -0
  90. package/src/Revit/Tools/GetElements.cs +58 -55
  91. package/src/Revit/Tools/GetLinkedElements.cs +89 -0
  92. package/src/Revit/Tools/GetLinkedModels.cs +73 -0
  93. package/src/Revit/Tools/GetModelCoordinates.cs +56 -0
  94. package/src/Revit/Tools/GetModelHealth.cs +7 -0
  95. package/src/Revit/Tools/GetModelOverview.cs +187 -160
  96. package/src/Revit/Tools/GetScheduleFields.cs +44 -0
  97. package/src/Revit/Tools/GetSchedules.cs +96 -0
  98. package/src/Revit/Tools/InheritedState.Summary.cs +57 -0
  99. package/src/Revit/Tools/InheritedState.cs +144 -0
  100. package/src/Revit/Tools/ManageElementSets.cs +114 -0
  101. package/src/Revit/Tools/ManageSchedules.cs +174 -0
  102. package/src/Revit/Tools/ManageSelection.cs +9 -0
  103. package/src/Revit/Tools/ManageSheetPlacements.cs +113 -0
  104. package/src/Revit/Tools/ManageSheets.cs +72 -0
  105. package/src/Revit/Tools/ManageViews.cs +115 -0
  106. package/src/Revit/Tools/MeasureGeometry.cs +60 -0
  107. package/src/Revit/Tools/ModelChanges.cs +154 -0
  108. package/src/Revit/Tools/ModelEditBatch.cs +105 -0
  109. package/src/Revit/Tools/ModelEditInputs.cs +49 -0
  110. package/src/Revit/Tools/OpenView.cs +8 -0
  111. package/src/Revit/Tools/ParameterResolver.cs +94 -0
  112. package/src/Revit/Tools/QuerySpatialElements.cs +70 -0
  113. package/src/Revit/Tools/SearchApiDocs.cs +72 -4
  114. package/src/Revit/Tools/SetParameters.cs +50 -119
  115. package/src/Revit/Tools/SpatialBounds.cs +30 -0
  116. package/src/Revit/Tools/SummarizeElements.cs +94 -0
  117. package/src/Revit/Tools/ToolContract.cs +48 -0
  118. package/src/Revit/Tools/ToolSupport.cs +4 -0
  119. package/src/Revit/Tools/TransformElements.cs +73 -0
  120. package/workspace/AGENTS.md +54 -48
@@ -1,21 +1,26 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { Type, type TSchema } from "typebox";
3
+ import { readFileSync } from "node:fs";
3
4
  import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
4
5
  import { randomUUID } from "node:crypto";
5
6
  import os from "node:os";
6
7
  import path from "node:path";
7
8
  import { fileURLToPath } from "node:url";
8
9
  import { version as packageVersion } from "../../package.json";
9
-
10
- interface BridgeInfo {
11
- baseUrl: string;
12
- token: string;
13
- pid?: number;
14
- revitVersion?: string;
15
- }
10
+ import { createToolCatalog, type BridgeToolDescriptor } from "./tool-catalog.js";
11
+ import { createInstanceRouter, type BridgeInfo } from "./instance-router.js";
12
+ import { registerScriptLibrary } from "./script-library.js";
13
+ import { documentationRevision, documentedTools, manualDirectory, skillRoot } from "./tool-documentation.js";
14
+ import { publicBridgeSchema } from "./tool-schema.js";
15
+ import { packagedContracts } from "./contracts.js";
16
+ import { buildPlatformSection, hoistSharedGuidelines } from "./platform-prompt.js";
17
+ import { createCompletionMonitor } from "./completion-monitor.js";
18
+ import { changedModel, createScopeMonitor } from "./scope-monitor.js";
19
+
20
+ type BridgeResolver = (operationId?: string) => Promise<BridgeInfo>;
16
21
 
17
22
  interface ContentBlock {
18
- type: string;
23
+ type: "text";
19
24
  text: string;
20
25
  }
21
26
 
@@ -30,21 +35,6 @@ interface BridgeToolResponse {
30
35
  hasActiveDocument?: boolean;
31
36
  }
32
37
 
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
38
  const DEFAULT_TIMEOUT_MS = 30_000;
49
39
  const LONG_TIMEOUT_MS = 120_000;
50
40
  const DISCOVERY_TIMEOUT_MS = 10_000;
@@ -113,11 +103,11 @@ function timeoutError(timeoutMs: number): Error {
113
103
 
114
104
  export async function bridgeRequest(
115
105
  pathname: string,
116
- init: { method: "GET" | "POST"; body?: string; query?: Record<string, string> },
106
+ init: { method: "GET" | "POST"; body?: string; query?: Record<string, string>; bridge?: BridgeInfo },
117
107
  signal?: AbortSignal,
118
108
  timeoutMs = DEFAULT_TIMEOUT_MS,
119
109
  ): Promise<unknown> {
120
- const info = await readBridgeInfo();
110
+ const info = init.bridge ?? await readBridgeInfo();
121
111
  const query = new URLSearchParams({ ...(init.query ?? {}), token: info.token });
122
112
  const url = `${info.baseUrl}${pathname}?${query.toString()}`;
123
113
 
@@ -243,33 +233,82 @@ function registerResultReader(pi: ExtensionAPI) {
243
233
  });
244
234
  }
245
235
 
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) },
236
+ async function runBridgeTool(name: string, args: unknown, signal: AbortSignal | undefined, timeoutMs: number, resolve: BridgeResolver,
237
+ prepared?: (receipt: { operation_id: string; bridge_id: string }) => Promise<void>) {
238
+ const body = { ...(args as Record<string, unknown> ?? {}) };
239
+ const retryId = body._operation_id;
240
+ delete body._operation_id;
241
+ if (retryId !== undefined && (typeof retryId !== "string" || !retryId)) throw new Error("_operation_id must be the exact ID of a previous request.");
242
+ const info = await resolve(retryId as string | undefined);
243
+ if (retryId && (!info.supportsOperationTracking || !info.bridgeId)) throw new Error("This bridge does not support operation receipts; the request was not sent.");
244
+ const operationId = info.supportsOperationTracking && info.bridgeId ? (retryId as string | undefined) ?? `${info.bridgeId}:${randomUUID()}` : undefined;
245
+ 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.");
246
+ if (prepared && (!operationId || !info.bridgeId)) throw new Error("Script library runs require a bridge with operation receipts. The script was not sent.");
247
+ try {
248
+ if (prepared) await prepared({ operation_id: operationId!, bridge_id: info.bridgeId! });
249
+ const payload = (await bridgeRequest(
250
+ `/tools/${encodeURIComponent(name)}/execute`,
251
+ { method: "POST", body: JSON.stringify(body), bridge: info,
252
+ query: { timeout_ms: String(timeoutMs), ...(operationId ? { operation_id: operationId } : {}) } },
253
+ signal, timeoutMs,
254
+ )) as BridgeToolResponse;
255
+ const content = await modelContent(name, payload);
256
+ 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.` });
257
+ return { content, details: operationId ? { ...(payload.details as object ?? {}), operation_id: operationId, bridge_id: info.bridgeId } : payload.details };
258
+ } catch (error) {
259
+ if (!operationId) throw error;
260
+ 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.`);
261
+ }
262
+ }
263
+
264
+ function registerOperationReader(pi: ExtensionAPI, resolve: BridgeResolver) {
265
+ pi.registerTool({
266
+ name: "get_revit_operation",
267
+ label: "Get Revit Operation",
268
+ 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.",
269
+ promptSnippet: "Check the outcome of a timed-out Revit operation before retrying an edit.",
270
+ parameters: Type.Object({ operation_id: Type.String({ minLength: 1, maxLength: 120 }) }),
271
+ executionMode: "sequential",
272
+ async execute(_id, args, signal) {
273
+ const bridge = await resolve(args.operation_id);
274
+ const result = await bridgeRequest(`/operations/${encodeURIComponent(args.operation_id)}`, { method: "GET", bridge }, signal, 10_000);
275
+ return { content: await modelContent("get_revit_operation", { details: { payload: result } }), details: result };
253
276
  },
254
- signal,
255
- timeoutMs,
256
- )) as BridgeToolResponse;
277
+ });
278
+ }
257
279
 
258
- return { content: await modelContent(name, payload), details: payload.details };
280
+ interface RequestMonitors { completion: ReturnType<typeof createCompletionMonitor>; scope: ReturnType<typeof createScopeMonitor> }
281
+
282
+ /** The bridge value of a tool result (details.payload for current and older bridges). */
283
+ function resultPayload(details: unknown): unknown {
284
+ return details !== null && typeof details === "object" && Object.hasOwn(details, "payload") ? (details as { payload: unknown }).payload : details;
259
285
  }
260
286
 
261
- function registerBridgeTool(pi: ExtensionAPI, descriptor: BridgeToolDescriptor) {
287
+ /** Notes the request monitors add to a result: scope first (it can require asking the user), then completion. */
288
+ function monitorNotes(monitors: RequestMonitors | undefined, tool: string, params: unknown, meta: { write?: boolean; effects?: string[] | null }, details: unknown): string[] {
289
+ if (!monitors) return [];
290
+ const payload = resultPayload(details);
291
+ return [monitors.scope.afterResult(payload), monitors.completion.afterCall(tool, params, meta, changedModel(payload))].filter((note): note is string => !!note);
292
+ }
293
+
294
+ /** `guidelines` are this tool's own rules; rules shared by several tools live once in the platform section. */
295
+ function registerBridgeTool(pi: ExtensionAPI, descriptor: BridgeToolDescriptor, resolve: BridgeResolver, guidelines: string[],
296
+ monitors?: RequestMonitors) {
262
297
  const timeoutMs = toolTimeoutMs(descriptor.name);
298
+ const schema = publicBridgeSchema(descriptor.parameters);
263
299
  pi.registerTool({
264
300
  name: descriptor.name,
265
301
  label: descriptor.label ?? descriptor.name,
266
302
  description: descriptor.description ?? `Revit bridge tool '${descriptor.name}'.`,
267
- parameters: Type.Unsafe((descriptor.parameters ?? { type: "object", properties: {} }) as TSchema),
303
+ parameters: Type.Unsafe(schema as TSchema),
268
304
  promptSnippet: descriptor.promptSnippet ?? undefined,
269
- promptGuidelines: descriptor.promptGuidelines ?? undefined,
305
+ promptGuidelines: guidelines,
270
306
  executionMode: descriptor.executionMode === "parallel" ? "parallel" : "sequential",
271
307
  async execute(_toolCallId, params, signal) {
272
- return runBridgeTool(descriptor.name, params, signal, timeoutMs);
308
+ const result = await runBridgeTool(descriptor.name, params, signal, timeoutMs, resolve);
309
+ // Result- and metadata-driven steering: objects that predate the request, and repeated re-verification after edits.
310
+ const notes = monitorNotes(monitors, descriptor.name, params, descriptor, result.details);
311
+ return notes.length ? { ...result, content: [...result.content, ...notes.map(text => ({ type: "text" as const, text }))] } : result;
273
312
  },
274
313
  });
275
314
  }
@@ -352,17 +391,51 @@ async function announceUpdateOnce(notify: (message: string, level: "info") => vo
352
391
  }
353
392
  }
354
393
 
355
- function registerPing(pi: ExtensionAPI, onBridgeAlive?: () => Promise<"ready" | "registered" | "failed">) {
394
+ const packageRoot = fileURLToPath(new URL("../../", import.meta.url));
395
+ const nativeToolNames = new Set(documentedTools.filter(tool => tool.source === "native").map(tool => tool.name));
396
+
397
+ /** Branch and commit when the extension runs from a source checkout; null for an installed package. */
398
+ function sourceRevision(): { branch: string | null; commit: string | null } | null {
399
+ try {
400
+ const gitDir = path.join(packageRoot, ".git");
401
+ const head = readFileSync(path.join(gitDir, "HEAD"), "utf8").trim();
402
+ if (!head.startsWith("ref: ")) return { branch: null, commit: head };
403
+ const ref = head.slice(5);
404
+ let commit: string | null = null;
405
+ try { commit = readFileSync(path.join(gitDir, ref), "utf8").trim(); }
406
+ catch { commit = readFileSync(path.join(gitDir, "packed-refs"), "utf8").split(/\r?\n/).find(line => line.endsWith(` ${ref}`))?.split(" ")[0] ?? null; }
407
+ return { branch: ref.replace(/^refs\/heads\//, ""), commit };
408
+ } catch { return null; }
409
+ }
410
+
411
+ /** What is actually loaded: package, guidance revision, source revision and per-tool contract agreement. */
412
+ function loadedStatus(live: Map<string, string>) {
413
+ const matching: string[] = [], changed: string[] = [], undocumented: string[] = [];
414
+ for (const [name, hash] of live) {
415
+ const packaged = packagedContracts.get(name);
416
+ if (!packaged) undocumented.push(name);
417
+ else if (packaged.contract_hash === hash) matching.push(name);
418
+ else changed.push(name);
419
+ }
420
+ return {
421
+ extension_package: packageVersion, extension_root: packageRoot, documentation_revision: documentationRevision, source: sourceRevision(),
422
+ contracts: live.size ? { matching: matching.length, changed, undocumented,
423
+ packaged_not_advertised: [...packagedContracts.values()].filter(tool => tool.source === "bridge" && !live.has(tool.name)).map(tool => tool.name) }
424
+ : "no bridge catalogue discovered yet",
425
+ };
426
+ }
427
+
428
+ function registerPing(pi: ExtensionAPI, resolve: BridgeResolver, onBridgeAlive?: () => Promise<"ready" | "registered" | "failed">, status?: () => unknown) {
356
429
  pi.registerTool({
357
430
  name: "ping",
358
431
  label: "Ping Revit Bridge",
359
- description: "Check that the Revit bridge is reachable and report the Revit version.",
432
+ description: "Check that the Revit bridge is reachable and report the Revit version, plus which PI-Revit extension package, guidance revision, source revision and tool contracts are loaded (changed or undocumented contracts mean manuals may not match the bridge).",
360
433
  parameters: Type.Object({}),
361
434
  promptSnippet: "Check Revit bridge availability.",
362
- promptGuidelines: ["Use ping when Revit tools fail or bridge availability is unclear."],
435
+ promptGuidelines: ["Use ping when Revit tools fail or bridge availability is unclear; it also reports which PI-Revit package, guidance revision and contracts are loaded."],
363
436
  executionMode: "sequential",
364
437
  async execute(_toolCallId, _params, signal) {
365
- const payload = await bridgeRequest("/ping", { method: "GET" }, signal, 10_000);
438
+ const payload = await bridgeRequest("/ping", { method: "GET", bridge: await resolve() }, signal, 10_000);
366
439
  const warning = versionMismatch((payload as { addinVersion?: string }).addinVersion);
367
440
  // The bridge is alive: if this session started before Revit and only has
368
441
  // ping, register the bridge tools now and tell the model they arrived.
@@ -374,9 +447,11 @@ function registerPing(pi: ExtensionAPI, onBridgeAlive?: () => Promise<"ready" |
374
447
  else if (state === "failed")
375
448
  registrationNote = "\nNOTE: Bridge tool discovery failed even though ping succeeded; retry ping or restart pi.";
376
449
  }
450
+ const loaded = status?.();
377
451
  return {
378
- content: [{ type: "text", text: JSON.stringify(payload) + (warning ? `\nWARNING: ${warning}` : "") + registrationNote }],
379
- details: payload,
452
+ content: [{ type: "text", text: JSON.stringify(payload) + (warning ? `\nWARNING: ${warning}` : "") + registrationNote
453
+ + (loaded ? `\nPI-Revit loaded: ${JSON.stringify(loaded)}` : "") }],
454
+ details: loaded ? { ...(payload as object), pi_revit: loaded } : payload,
380
455
  };
381
456
  },
382
457
  });
@@ -385,35 +460,92 @@ function registerPing(pi: ExtensionAPI, onBridgeAlive?: () => Promise<"ready" |
385
460
  const REDISCOVERY_INTERVAL_MS = 15_000;
386
461
 
387
462
  export default async function revitConnector(pi: ExtensionAPI) {
463
+ const bridgeVersions = new Map<string, string | null>();
464
+ const bridgeKey = (info: BridgeInfo) => info.bridgeId ?? `${info.baseUrl}\0${info.token}`;
465
+ const instances = createInstanceRouter(readBridgeInfo, async info => {
466
+ const payload = await bridgeRequest("/ping", { method: "GET", bridge: info }, undefined, 2000) as Record<string, unknown>;
467
+ bridgeVersions.set(bridgeKey(info), typeof payload.addinVersion === "string" ? payload.addinVersion : null);
468
+ return payload;
469
+ });
388
470
  registerResultReader(pi);
471
+ registerOperationReader(pi, instances.resolve);
472
+ const monitors: RequestMonitors = { completion: createCompletionMonitor(), scope: createScopeMonitor() };
473
+ registerScriptLibrary(pi, async (args, signal, prepared) => {
474
+ const result = await runBridgeTool("execute_csharp", args, signal, LONG_TIMEOUT_MS, instances.resolve, prepared);
475
+ const notes = monitorNotes(monitors, "manage_revit_scripts", args, { write: true }, result.details);
476
+ return notes.length ? { ...result, content: [...result.content, ...notes.map(text => ({ type: "text" as const, text }))] } : result;
477
+ },
478
+ async value => ({ content: await modelContent("manage_revit_scripts", { details: { payload: value } }), details: value }));
479
+ pi.registerTool({
480
+ name: "manage_revit_instances", label: "Manage Revit Instances",
481
+ 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.",
482
+ parameters: Type.Object({ action: Type.Optional(Type.Union([Type.Literal("list"), Type.Literal("select")])), bridge_id: Type.Optional(Type.String()) }),
483
+ executionMode: "sequential",
484
+ async execute(_id, args) {
485
+ let result: unknown;
486
+ if ((args.action ?? "list") === "list") result = { instances: await instances.list() };
487
+ else if (args.action === "select" && args.bridge_id) {
488
+ // Drain discovery for the previous target before changing selection.
489
+ if (discoveryInFlight) await discoveryInFlight;
490
+ const selection = await instances.select(args.bridge_id);
491
+ // A retry timer may have started another discovery while selection probed.
492
+ // Drain that request too, then reset synchronously before fetching anew.
493
+ if (discoveryInFlight) await discoveryInFlight;
494
+ bridgeToolsRegistered = false;
495
+ catalog.reset();
496
+ const ready = await discoverAndRegister();
497
+ result = { ...selection, tool_catalog_ready: ready };
498
+ if (!ready && sessionActive) startRetry();
499
+ } else throw new Error("select requires bridge_id from the instance list.");
500
+ return { content: [{ type: "text", text: JSON.stringify(result) }], details: result };
501
+ },
502
+ });
389
503
  // Self-healing discovery: when pi starts before Revit is ready, the initial
390
- // GET /tools fails and only ping is registered. Rather than requiring a
504
+ // GET /tools fails and only native utilities are registered. Rather than requiring a
391
505
  // fresh pi start (/reload does not reliably re-run async registration), a
392
506
  // background retry keeps probing until the bridge appears, and a successful
393
507
  // ping also triggers an immediate attempt.
394
508
  let bridgeToolsRegistered = false;
509
+ let sharedRules: string[] = [];
395
510
  let discoveryInFlight: Promise<boolean> | null = null;
511
+ let sessionActive = false;
512
+ let disposed = false;
513
+ let timer: ReturnType<typeof setInterval> | undefined;
514
+ const catalog = createToolCatalog(pi, discoverAndRegister);
396
515
 
397
516
  async function discoverAndRegister(): Promise<boolean> {
517
+ if (disposed) return false;
398
518
  if (bridgeToolsRegistered) return true;
399
519
  if (discoveryInFlight) return discoveryInFlight;
400
520
  discoveryInFlight = (async () => {
401
521
  try {
402
- const payload = (await bridgeRequest("/tools", { method: "GET" }, undefined, DISCOVERY_TIMEOUT_MS)) as {
522
+ const selectedBridge = await instances.resolve();
523
+ const payload = (await bridgeRequest("/tools", { method: "GET", bridge: selectedBridge }, undefined, DISCOVERY_TIMEOUT_MS)) as {
403
524
  tools?: BridgeToolDescriptor[];
404
525
  };
405
526
  const descriptors = Array.isArray(payload?.tools) ? payload.tools : [];
406
- if (descriptors.length === 0) return false;
407
- for (const descriptor of descriptors) {
408
- if (!descriptor || typeof descriptor.name !== "string" || !descriptor.name) continue;
409
- if (descriptor.name === "ping" || descriptor.name === "read_revit_result") continue;
410
- registerBridgeTool(pi, descriptor);
527
+ if (disposed || !Array.isArray(payload?.tools)) return false;
528
+ catalog.setBridgeVersion(bridgeVersions.get(bridgeKey(selectedBridge)) ?? null);
529
+ // Native tool names are reserved: a bridge cannot replace an extension utility.
530
+ const valid = descriptors.filter(descriptor => descriptor && typeof descriptor.name === "string" && descriptor.name
531
+ && !nativeToolNames.has(descriptor.name));
532
+ const { shared, perTool } = hoistSharedGuidelines(valid);
533
+ sharedRules = shared;
534
+ const added: string[] = [];
535
+ for (const descriptor of valid) {
536
+ registerBridgeTool(pi, descriptor, instances.resolve, perTool.get(descriptor.name) ?? [], monitors);
537
+ catalog.add(descriptor);
538
+ added.push(descriptor.name);
539
+ }
540
+ if (sessionActive) {
541
+ catalog.hideAdvanced(added);
542
+ pi.setActiveTools([...new Set([...pi.getActiveTools(), ...descriptors.filter(d => added.includes(d.name) && d.tier !== "advanced").map(d => d.name)])]);
411
543
  }
412
544
  bridgeToolsRegistered = true;
413
545
  return true;
414
546
  } catch {
415
547
  // Bridge down (Revit closed, still starting, stale bridge.json):
416
- // stay on ping only and try again later.
548
+ // keep native utilities available and try again later.
417
549
  return false;
418
550
  } finally {
419
551
  discoveryInFlight = null;
@@ -426,18 +558,30 @@ export default async function revitConnector(pi: ExtensionAPI) {
426
558
  // bridge is down, so it is never part of /tools discovery. A successful
427
559
  // ping doubles as a re-discovery trigger — the natural first call in a
428
560
  // session that finds itself without bridge tools.
429
- registerPing(pi, async () => {
561
+ registerPing(pi, instances.resolve, async () => {
430
562
  if (bridgeToolsRegistered) return "ready";
431
563
  return (await discoverAndRegister()) ? "registered" : "failed";
564
+ }, () => loadedStatus(catalog.liveContracts()));
565
+
566
+ // One always-visible platform section per run: global protocols plus rules shared
567
+ // by several tools. It does not depend on the skill being read or on tool count.
568
+ pi.on("before_agent_start", async event => {
569
+ monitors.completion.reset();
570
+ monitors.scope.reset((event as { prompt?: string }).prompt ?? "");
571
+ const options = (event as { systemPromptOptions?: { sections?: Record<string, string> } }).systemPromptOptions;
572
+ if (options?.sections) options.sections.pi_revit = buildPlatformSection({ manualDirectory, skillRoot, sharedRules });
432
573
  });
433
574
 
434
575
  // Surface an incomplete update (see versionMismatch) once per session, right
435
576
  // where the user lands after running `pi update --extensions`. Bridge down at
436
577
  // session start is the normal Revit-closed case: stay quiet.
437
578
  pi.on("session_start", async (_event, ctx) => {
579
+ sessionActive = true;
580
+ catalog.hideAdvanced();
581
+ if (!bridgeToolsRegistered) startRetry();
438
582
  await announceUpdateOnce((message, level) => ctx.ui.notify(message, level));
439
583
  try {
440
- const payload = (await bridgeRequest("/ping", { method: "GET" }, undefined, 3_000)) as { addinVersion?: string };
584
+ const payload = (await bridgeRequest("/ping", { method: "GET", bridge: await instances.resolve() }, undefined, 3_000)) as { addinVersion?: string };
441
585
  const warning = versionMismatch(payload.addinVersion);
442
586
  if (warning) ctx.ui.notify(warning, "warning");
443
587
  } catch {
@@ -445,33 +589,35 @@ export default async function revitConnector(pi: ExtensionAPI) {
445
589
  }
446
590
  });
447
591
 
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?.();
592
+ pi.on("session_shutdown", async () => {
593
+ disposed = true;
594
+ sessionActive = false;
595
+ if (timer) clearInterval(timer);
596
+ timer = undefined;
597
+ });
598
+
599
+ function startRetry() {
600
+ if (timer || disposed) return;
601
+ timer = setInterval(async () => {
602
+ if (!(await discoverAndRegister())) return;
603
+ if (timer) clearInterval(timer);
604
+ timer = undefined;
605
+ if (disposed) return;
606
+ // Refresh the model's knowledge on its next turn without interrupting the user.
607
+ try {
608
+ pi.sendMessage(
609
+ {
610
+ customType: "pi-revit",
611
+ content: "Revit is now reachable. Core bridge tools are available; use find_revit_tools to activate specialist tools.",
612
+ display: true,
613
+ },
614
+ { deliverAs: "nextTurn" },
615
+ );
616
+ } catch {
617
+ // Tool registration remains valid if the session cannot accept a message.
618
+ }
619
+ }, REDISCOVERY_INTERVAL_MS);
620
+ timer.unref?.();
621
+ }
622
+ await discoverAndRegister();
477
623
  }
@@ -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,40 @@
1
+ import path from "node:path";
2
+
3
+ /**
4
+ * The PI-Revit platform section: cross-cutting protocols stated exactly once, always in
5
+ * context (injected through before_agent_start), independent of whether the skill is read
6
+ * and of how many tools exist. Tool-specific facts stay in each tool's own guidelines.
7
+ */
8
+ export const PLATFORM_PROTOCOL = [
9
+ "PI-Revit protocol. It applies to every Revit tool, present or future, whether or not the pi-revit skill has been read.",
10
+ "1. Capability: before saying PI-Revit can or cannot do something, check. find_revit_tools (scope=documentation also works while Revit is closed) returns matching tools, their declared limits with alternatives, and related workflows. If a tool does not cover the request, follow its declared alternative. If nothing dedicated fits, verify all needed API members with search_api_docs, up to 10 names per call separated by ';' and use execute_csharp within the requested scope. Say an operation is not possible only after that check, and name what you checked; distinguish \"no dedicated tool\", \"the Revit API does not offer it\" and \"needs the user\".",
11
+ "2. Scope and completion: explanations do not change the model and inspections do not repair it. For a change, list the request's explicit requirements, do only those, and verify each with the tool's declared verification method. Then stop and report what changed, how it was verified and anything unmet. Offer further improvements as suggestions instead of making them. Never hide or remove content the request asks to show. An object made from an existing one inherits its state: results report it as inherited_state, and model_changes lists what each call added, modified or deleted, with the visibility of new views. Check that state against the request and say what you derived from.",
12
+ "3. Existing objects: objects that existed before the request are not yours. If one already has a name the request asks you to create, or a creation is rejected as a name collision, do not edit, reuse, replace or delete it: ask the user, or use a distinct name and report the collision.",
13
+ "4. Evidence: base claims about capabilities and model state on tool results, manuals or API documentation checked in this session, and say which.",
14
+ "5. Identity: for model changes, previews, exports, view activation and selection changes, pass project.documentId from get_model_overview unchanged as expected_document_id; project.documentKind says whether it is a project or a family, and tools refuse kinds they do not declare. Refresh it after reopening, restarting or switching instances. Save As keeps the ID but changes which file a later save affects.",
15
+ "6. Language: the user may write in any language, and the model's Revit UI may be localized. Reply in the user's language. Search tools and API documentation with English terms. Read localized category and parameter names from tool results, and prefer exact identities (BuiltInParameter names, guid:<GUID>) over translated display names.",
16
+ ];
17
+
18
+ export function buildPlatformSection(options: { manualDirectory: string; skillRoot: string; sharedRules: readonly string[] }): string {
19
+ const lines = [
20
+ ...PLATFORM_PROTOCOL,
21
+ `7. Guidance: each tool's manual is ${path.join(options.manualDirectory, "<tool_name>.md")}; read only the manual of a tool you will use. find_revit_tools returns exact paths and whether a manual matches the selected bridge's contract. Shared rules: ${path.join(options.skillRoot, "references", "execution-rules.md")}. Uncertain outcomes: ${path.join(options.skillRoot, "references", "operation-recovery.md")}.`,
22
+ ];
23
+ if (options.sharedRules.length) lines.push("Rules shared by several Revit tools:", ...options.sharedRules.map(rule => `- ${rule}`));
24
+ return lines.join("\n");
25
+ }
26
+
27
+ /**
28
+ * Split bridge guidelines into per-tool rules and rules shared by two or more tools.
29
+ * A tool's own name is normalized away first, so "set_parameters: use X" and
30
+ * "open_view: use X" are recognized as one shared rule. Works for any bridge version.
31
+ */
32
+ export function hoistSharedGuidelines(descriptors: readonly { name: string; promptGuidelines?: string[] | null }[]) {
33
+ const normalized = (name: string, rule: string) => rule.split(name).join("{tool}").replace(/^\{tool\}:\s*/, "").trim();
34
+ const counts = new Map<string, number>();
35
+ for (const d of descriptors) for (const rule of new Set((d.promptGuidelines ?? []).map(r => normalized(d.name, r)))) counts.set(rule, (counts.get(rule) ?? 0) + 1);
36
+ const shared = [...counts].filter(([, count]) => count >= 2).map(([rule]) => rule);
37
+ const sharedSet = new Set(shared);
38
+ const perTool = new Map(descriptors.map(d => [d.name, (d.promptGuidelines ?? []).filter(rule => !sharedSet.has(normalized(d.name, rule)))]));
39
+ return { shared: shared.map(rule => rule.replaceAll("{tool}", "the tool")), perTool };
40
+ }