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.
- package/CHANGELOG.md +394 -351
- package/README.md +438 -25
- package/bin/pi-revit.js +3 -5
- package/extensions/pi-revit/index.ts +151 -92
- 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 +58 -57
- package/scripts/build.ps1 +9 -3
- package/scripts/check-sdk.ps1 +66 -0
- package/scripts/deploy.ps1 +16 -4
- package/skills/pi-revit/SKILL.md +181 -17
- 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 +79 -29
- package/src/Revit/OperationStore.cs +178 -0
- package/src/Revit/ToolRegistry.cs +26 -4
- 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 +2 -2
- package/src/Revit/Tools/ElementQueryScope.cs +27 -0
- package/src/Revit/Tools/ExecuteCsharp.cs +23 -13
- package/src/Revit/Tools/ExportDocuments.cs +2 -1
- package/src/Revit/Tools/GetElementDetails.cs +32 -2
- package/src/Revit/Tools/GetElementRelationships.cs +76 -0
- package/src/Revit/Tools/GetElements.cs +21 -7
- 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/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 +2 -1
- 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/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
|
-
|
|
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,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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
))
|
|
257
|
-
|
|
258
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|