@ian-pascoe/pi-codemode 0.4.0 → 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.
@@ -29,6 +29,8 @@ import {
29
29
  CodeModeResultParametersSchema,
30
30
  CodeModeSessionsParametersSchema,
31
31
  CodeModeSessionsResultSchema,
32
+ CodeModeToolSearchPageSchema,
33
+ CodeModeToolSearchParametersSchema,
32
34
  createCodeModeToolDefinitions,
33
35
  type CodeModeCancelParameters,
34
36
  type CodeModeErrorCode,
@@ -41,6 +43,8 @@ import {
41
43
  type CodeModeSessionsParameters,
42
44
  type CodeModeSessionsResult,
43
45
  type CodeModeToolOperations,
46
+ type CodeModeToolSearchPage,
47
+ type CodeModeToolSearchParameters,
44
48
  } from "./codemode-tool-contract.js";
45
49
 
46
50
  /** Names of the four CodeMode tools with semantic Transcript rendering. */
@@ -75,6 +79,7 @@ const CODEMODE_STATUS_PRESENTATION = {
75
79
  } satisfies Record<CodeModeCellState, CodeModeStatusPresentation>;
76
80
  const CODEMODE_COLLAPSED_SCRIPT_LINES = 8;
77
81
  const CODEMODE_PRESENTATION_MAX_BYTES = 50 * 1024;
82
+ const CODEMODE_SEARCH_DECLARATION_MAX_LINES = 2_000;
78
83
  const CodeModeJsonStringSchema = Type.String();
79
84
 
80
85
  function sanitizeCodeModeText(text: string): string {
@@ -434,6 +439,162 @@ function renderCodeModeSessionsResult(
434
439
  return container;
435
440
  }
436
441
 
442
+ function parseCodeModeToolSearchParameters(
443
+ parameters: CodeModeJsonValue,
444
+ ): CodeModeToolSearchParameters | undefined {
445
+ return Value.Check(CodeModeToolSearchParametersSchema, parameters) ? parameters : undefined;
446
+ }
447
+
448
+ /** Render a direct CodeMode tool search call with its query and explicit page controls. */
449
+ export function renderCodeModeToolSearchCall(
450
+ parameters: CodeModeJsonValue,
451
+ theme: CodeModeRenderTheme,
452
+ expanded: boolean,
453
+ ): Component {
454
+ const parsed = parseCodeModeToolSearchParameters(parameters);
455
+ const query = parsed?.query?.trim();
456
+ const container = new Container();
457
+ container.addChild(
458
+ new Text(
459
+ [
460
+ theme.fg("toolTitle", theme.bold("CodeMode")),
461
+ theme.fg("accent", "Search Tools"),
462
+ theme.fg(
463
+ "muted",
464
+ query ? boundedCodeModePreview(JSON.stringify(query), 72) : "all exposed",
465
+ ),
466
+ ].join(" "),
467
+ 0,
468
+ 0,
469
+ ),
470
+ );
471
+ if (!expanded || parsed === undefined) return container;
472
+ if (parsed.group === undefined && parsed.limit === undefined && parsed.offset === undefined) {
473
+ return container;
474
+ }
475
+ container.addChild(new Spacer(1));
476
+ if (parsed.group !== undefined) appendCodeModeField(container, theme, "Group", parsed.group);
477
+ if (parsed.limit !== undefined) appendCodeModeField(container, theme, "Limit", parsed.limit);
478
+ if (parsed.offset !== undefined) appendCodeModeField(container, theme, "Offset", parsed.offset);
479
+ return container;
480
+ }
481
+
482
+ function codeModeToolSearchOffset(
483
+ page: CodeModeToolSearchPage,
484
+ parameters: CodeModeJsonValue,
485
+ ): number {
486
+ const explicitOffset = parseCodeModeToolSearchParameters(parameters)?.offset;
487
+ if (explicitOffset !== undefined) return explicitOffset;
488
+ if (page.nextOffset !== null) return Math.max(0, page.nextOffset - page.items.length);
489
+ return Math.max(0, page.total - page.items.length);
490
+ }
491
+
492
+ function renderCodeModeToolSearchSummary(
493
+ page: CodeModeToolSearchPage,
494
+ parameters: CodeModeJsonValue,
495
+ theme: CodeModeRenderTheme,
496
+ ): string {
497
+ const offset = codeModeToolSearchOffset(page, parameters);
498
+ if (page.items.length === 0) {
499
+ return page.total === 0
500
+ ? theme.fg("dim", "No matching tools")
501
+ : `${theme.fg("warning", "! No tools on this page")} ${theme.fg("muted", `${pluralizedCodeModeCount(page.total, "match")} · offset ${offset}`)}`;
502
+ }
503
+ const unavailableCount = page.items.filter((item) => "declarationError" in item).length;
504
+ const status = unavailableCount === 0 ? theme.fg("success", "✓") : theme.fg("warning", "!");
505
+ const range =
506
+ page.items.length === page.total
507
+ ? pluralizedCodeModeCount(page.total, "tool")
508
+ : `${offset + 1}–${offset + page.items.length} of ${page.total} tools`;
509
+ return [
510
+ `${status} ${range}`,
511
+ unavailableCount === 0
512
+ ? undefined
513
+ : theme.fg(
514
+ "warning",
515
+ `${unavailableCount} declaration${unavailableCount === 1 ? "" : "s"} unavailable`,
516
+ ),
517
+ page.nextOffset === null ? undefined : theme.fg("muted", `next offset ${page.nextOffset}`),
518
+ ]
519
+ .filter((part): part is string => part !== undefined)
520
+ .join(theme.fg("dim", " · "));
521
+ }
522
+
523
+ function boundedCodeModeToolSearchDeclaration(
524
+ declaration: string,
525
+ declarationCount: number,
526
+ ): string {
527
+ const safe = sanitizeCodeModeText(declaration);
528
+ const truncated = truncateHead(safe, {
529
+ maxBytes: Math.max(1, Math.floor(CODEMODE_PRESENTATION_MAX_BYTES / declarationCount)),
530
+ maxLines: Math.max(1, Math.floor(CODEMODE_SEARCH_DECLARATION_MAX_LINES / declarationCount)),
531
+ });
532
+ return truncated.truncated
533
+ ? `${truncated.content}\n… declaration truncated in Transcript`
534
+ : truncated.content;
535
+ }
536
+
537
+ /** Render direct CodeMode tool search results as a compact page or expanded declaration list. */
538
+ export function renderCodeModeToolSearchResult(
539
+ result: AgentToolResult<unknown>,
540
+ options: ToolRenderResultOptions,
541
+ theme: CodeModeRenderTheme,
542
+ isError: boolean,
543
+ parameters: CodeModeJsonValue,
544
+ ): Component {
545
+ if (options.isPartial) return new Text(theme.fg("accent", "Searching…"), 0, 0);
546
+ if (isError || !Value.Check(CodeModeToolSearchPageSchema, result.details)) {
547
+ return renderCodeModeFallback(result, options, theme, isError);
548
+ }
549
+ const page = result.details;
550
+ const summary = renderCodeModeToolSearchSummary(page, parameters, theme);
551
+ if (!options.expanded || page.items.length === 0) {
552
+ const hint =
553
+ options.expanded || page.items.length === 0
554
+ ? ""
555
+ : ` · ${keyText("app.tools.expand")} to expand`;
556
+ return new Text(`${summary}${theme.fg("dim", hint)}`, 0, 0);
557
+ }
558
+
559
+ const container = new Container();
560
+ container.addChild(new Text(summary, 0, 0));
561
+ const declarationCount = Math.max(1, page.items.filter((item) => "declaration" in item).length);
562
+ for (const item of page.items) {
563
+ container.addChild(new Spacer(1));
564
+ container.addChild(
565
+ new Text(
566
+ `${theme.fg("toolOutput", theme.bold(boundedCodeModePreview(item.name, 256)))} ${theme.fg("muted", boundedCodeModePreview(item.group, 128))}`,
567
+ 0,
568
+ 0,
569
+ ),
570
+ );
571
+ if ("declaration" in item) {
572
+ appendHighlightedCodeModeSource(
573
+ container,
574
+ boundedCodeModeToolSearchDeclaration(item.declaration, declarationCount),
575
+ );
576
+ } else {
577
+ if (item.description !== undefined) {
578
+ container.addChild(
579
+ new Text(theme.fg("muted", boundedCodeModePreview(item.description, 240)), 0, 0),
580
+ );
581
+ }
582
+ container.addChild(
583
+ new Text(
584
+ theme.fg("warning", `! ${boundedCodeModePreview(item.declarationError, 240)}`),
585
+ 0,
586
+ 0,
587
+ ),
588
+ );
589
+ }
590
+ }
591
+ if (page.nextOffset !== null) {
592
+ container.addChild(new Spacer(1));
593
+ appendCodeModeField(container, theme, "Next offset", page.nextOffset);
594
+ }
595
+ return container;
596
+ }
597
+
437
598
  /** Render one CodeMode result in collapsed, expanded, partial, or historical form. */
438
599
  export function renderCodeModeToolResult(
439
600
  toolName: CodeModeRenderedToolName,
@@ -529,16 +690,14 @@ export function renderCodeModeToolResult(
529
690
  return container;
530
691
  }
531
692
 
532
- /** Create the four CodeMode tools with semantic call and result Transcript renderers. */
693
+ /** Create five CodeMode tools with semantic call and result Transcript rendering. */
533
694
  export function createRenderedCodeModeToolDefinitions(
534
695
  operations: CodeModeToolOperations,
535
696
  executeDescription?: string,
536
697
  formatSessionPrefix: CodeModeSessionPrefixFormatter = shortCodeModeSessionId,
537
698
  ): ReturnType<typeof createCodeModeToolDefinitions> {
538
- const [executeTool, resultTool, cancelTool, sessionsTool] = createCodeModeToolDefinitions(
539
- operations,
540
- executeDescription,
541
- );
699
+ const [executeTool, resultTool, cancelTool, sessionsTool, searchTool] =
700
+ createCodeModeToolDefinitions(operations, executeDescription);
542
701
  return [
543
702
  {
544
703
  ...executeTool,
@@ -620,5 +779,12 @@ export function createRenderedCodeModeToolDefinitions(
620
779
  formatSessionPrefix,
621
780
  ),
622
781
  },
782
+ {
783
+ ...searchTool,
784
+ renderCall: (args, theme, context) =>
785
+ renderCodeModeToolSearchCall(args, theme, context.expanded),
786
+ renderResult: (result, options, theme, context) =>
787
+ renderCodeModeToolSearchResult(result, options, theme, context.isError, context.args),
788
+ },
623
789
  ];
624
790
  }
@@ -5,7 +5,15 @@ import type {
5
5
  ExtensionContext,
6
6
  ExtensionFactory,
7
7
  } from "@earendil-works/pi-coding-agent";
8
- import { renderCodeModeToolCatalogue } from "./codemode-tool-catalog.js";
8
+ import { Type } from "typebox";
9
+ import { Value } from "typebox/value";
10
+ import { resolveKnownToolOutputSchema } from "./codemode-known-output-schemas.js";
11
+ import {
12
+ renderCodeModeToolCatalogue,
13
+ searchCodeModeToolCatalogue,
14
+ type CodeModeToolCatalogue,
15
+ type CodeModeToolSchema,
16
+ } from "./codemode-tool-catalog.js";
9
17
  import { CodeModeObserverUiController } from "./codemode-observer-ui.js";
10
18
  import {
11
19
  CodeModeSessionCoordinator,
@@ -16,6 +24,7 @@ import {
16
24
  import { CODEMODE_SYSTEM_RUNTIME } from "./codemode-runtime.js";
17
25
  import { createCodeModeSessionFiles, type CodeModeSessionFiles } from "./codemode-session-files.js";
18
26
  import {
27
+ CODEMODE_SEARCH_TOOL_NAME,
19
28
  createCodeModeFailure,
20
29
  createCodeModePending,
21
30
  isCodeModeJsonObject,
@@ -40,6 +49,11 @@ import {
40
49
 
41
50
  const CODEMODE_EXECUTE_DESCRIPTION =
42
51
  "Execute a TypeScript Cell in a persistent isolated Deno CodeMode Session. Reuse a Session ID to retain Notebook Bindings; a new Session reclaims the least-recently-used idle Session at capacity. Use the read-only tools object for registered Pi tools. Return final result data with a top-level return statement. Reserve console.log, console.info, console.warn, console.error, and console.debug for diagnostics; captured output arrives only with terminal results.";
52
+ const CODEMODE_SEARCH_BATCH_LIMIT = 20;
53
+ const CodeModeToolSchemaMetadataSchema = Type.Union([
54
+ Type.Boolean(),
55
+ Type.Object({}, { additionalProperties: true }),
56
+ ]);
43
57
 
44
58
  type PiCodeModeGeneration = {
45
59
  readonly captured: CapturedPiAgentSession;
@@ -50,6 +64,7 @@ type PiCodeModeGeneration = {
50
64
  readonly operations: CodeModeToolOperations;
51
65
  exposure?: InstalledCodeModeToolExposure;
52
66
  decision: CodeModeToolExposureDecision;
67
+ catalogue: CodeModeToolCatalogue;
53
68
  executeDescription: string;
54
69
  active: boolean;
55
70
  toolsRegistered: boolean;
@@ -58,8 +73,33 @@ type PiCodeModeGeneration = {
58
73
  catalogueWarningShown: boolean;
59
74
  };
60
75
 
61
- function catalogueDescription(catalogue: string): string {
62
- return `${CODEMODE_EXECUTE_DESCRIPTION}\n\nCurrent CodeMode tool declarations:\n\n\`\`\`ts\n${catalogue}\`\`\``;
76
+ function catalogueDescription(catalogue: CodeModeToolCatalogue): string {
77
+ const coverage = catalogue.complete
78
+ ? `COMPLETE: all ${catalogue.totalCount} declarations are shown.`
79
+ : `PARTIAL: ${catalogue.shownCount} of ${catalogue.totalCount} declarations are shown. Use \`tools.${CODEMODE_SEARCH_TOOL_NAME}({ query: "<intent or exact name>" })\` to find the rest; each result contains the exact flat name and its complete declaration when within the search response bound.`;
80
+ return `${CODEMODE_EXECUTE_DESCRIPTION}\n\nCurrent CodeMode tool declarations:\n\n${coverage}\n\n\`\`\`ts\n${catalogue.text}\`\`\``;
81
+ }
82
+
83
+ type RegisteredToolDefinition = ReturnType<CapturedPiAgentSession["session"]["getToolDefinition"]>;
84
+
85
+ function codeModeToolCatalogueGroup(name: string, source: string | undefined): string {
86
+ const mcpPrefix = "mcp__";
87
+ if (name.startsWith(mcpPrefix)) {
88
+ const serverEnd = name.indexOf("__", mcpPrefix.length);
89
+ if (serverEnd > mcpPrefix.length) return `mcp:${name.slice(mcpPrefix.length, serverEnd)}`;
90
+ }
91
+ return source === undefined || source.length === 0 ? "registered" : source;
92
+ }
93
+
94
+ function registeredOutputSchema(
95
+ definition: RegisteredToolDefinition,
96
+ ): CodeModeToolSchema | undefined {
97
+ if (definition === undefined) return undefined;
98
+ const descriptor = Object.getOwnPropertyDescriptor(definition, "outputSchema");
99
+ if (descriptor === undefined || !("value" in descriptor)) return undefined;
100
+ return Value.Check(CodeModeToolSchemaMetadataSchema, descriptor.value)
101
+ ? descriptor.value
102
+ : undefined;
63
103
  }
64
104
 
65
105
  function renderGenerationCatalogue(
@@ -67,12 +107,27 @@ function renderGenerationCatalogue(
67
107
  decision: CodeModeToolExposureDecision,
68
108
  ) {
69
109
  const registry = captured.getToolRegistry();
110
+ const toolInfoByName = new Map(
111
+ captured.session.getAllTools().map((toolInfo) => [toolInfo.name, toolInfo]),
112
+ );
70
113
  return renderCodeModeToolCatalogue(
71
114
  decision.codeModeNames.flatMap((name) => {
72
115
  const tool = registry.get(name);
116
+ const toolInfo = toolInfoByName.get(name);
117
+ const outputSchema =
118
+ registeredOutputSchema(captured.session.getToolDefinition(name)) ??
119
+ (toolInfo === undefined ? undefined : resolveKnownToolOutputSchema(toolInfo));
73
120
  return tool === undefined
74
121
  ? []
75
- : [{ name, description: tool.description, inputSchema: tool.parameters }];
122
+ : [
123
+ {
124
+ name,
125
+ group: codeModeToolCatalogueGroup(name, toolInfo?.sourceInfo.source),
126
+ description: tool.description,
127
+ inputSchema: tool.parameters,
128
+ ...(outputSchema !== undefined && { outputSchema }),
129
+ },
130
+ ];
76
131
  }),
77
132
  );
78
133
  }
@@ -153,7 +208,7 @@ class PiCodeModeLifecycleController {
153
208
  if (!initialCatalogue.ok) {
154
209
  this.notifyWarning(
155
210
  context,
156
- "Pi CodeMode disabled: registered tool names exceed the 1 MiB catalogue limit",
211
+ "Pi CodeMode disabled: generated tool catalogue exceeds the 1 MiB outer limit",
157
212
  );
158
213
  return;
159
214
  }
@@ -177,10 +232,13 @@ class PiCodeModeLifecycleController {
177
232
  resultSpillWriter: sessionFiles,
178
233
  onSnapshotChange: (snapshot) => observer.onSnapshotChange(snapshot),
179
234
  onUnexpectedFailure: (failure) => observer.onUnexpectedFailure(failure),
180
- getToolNames: () =>
235
+ getToolSnapshot: () =>
181
236
  generation.active && this.generation === generation
182
- ? generation.decision.codeModeNames
183
- : [],
237
+ ? {
238
+ names: [CODEMODE_SEARCH_TOOL_NAME, ...generation.decision.codeModeNames],
239
+ searchEntries: generation.catalogue.searchEntries,
240
+ }
241
+ : { names: [], searchEntries: [] },
184
242
  executeToolBatch: (batch) => this.executeNestedToolBatch(generation, batch),
185
243
  });
186
244
  const operations: CodeModeToolOperations = {
@@ -211,6 +269,14 @@ class PiCodeModeLifecycleController {
211
269
  result: "success",
212
270
  sessions: [...coordinator.listSessions()],
213
271
  }),
272
+ search: async (input) => {
273
+ if (!generation.active || this.generation !== generation) {
274
+ throw new Error("Pi CodeMode session generation is inactive");
275
+ }
276
+ const searched = searchCodeModeToolCatalogue(generation.catalogue.searchEntries, input);
277
+ if (!searched.ok) throw new Error(searched.message);
278
+ return searched.page;
279
+ },
214
280
  };
215
281
  generation = {
216
282
  captured,
@@ -220,7 +286,8 @@ class PiCodeModeLifecycleController {
220
286
  sessionFiles,
221
287
  operations,
222
288
  decision: initialDecision,
223
- executeDescription: catalogueDescription(initialCatalogue.text),
289
+ catalogue: initialCatalogue,
290
+ executeDescription: catalogueDescription(initialCatalogue),
224
291
  active: true,
225
292
  toolsRegistered: false,
226
293
  synchronizing: false,
@@ -257,7 +324,7 @@ class PiCodeModeLifecycleController {
257
324
  return;
258
325
  }
259
326
 
260
- const [executeTool, resultTool, cancelTool, sessionsTool] =
327
+ const [executeTool, resultTool, cancelTool, sessionsTool, searchTool] =
261
328
  createRenderedCodeModeToolDefinitions(
262
329
  operations,
263
330
  generation.executeDescription,
@@ -267,6 +334,7 @@ class PiCodeModeLifecycleController {
267
334
  this.pi.registerTool(resultTool);
268
335
  this.pi.registerTool(cancelTool);
269
336
  this.pi.registerTool(sessionsTool);
337
+ this.pi.registerTool(searchTool);
270
338
  generation.toolsRegistered = true;
271
339
  this.synchronizeGeneration(generation);
272
340
  }
@@ -282,7 +350,7 @@ class PiCodeModeLifecycleController {
282
350
  generation.catalogueWarningShown = true;
283
351
  this.notifyWarning(
284
352
  generation.context,
285
- "Pi CodeMode retained its previous exposure because registered tool names exceed the 1 MiB catalogue limit",
353
+ "Pi CodeMode retained its previous exposure because the generated tool catalogue exceeds the 1 MiB outer limit",
286
354
  );
287
355
  }
288
356
  return false;
@@ -307,7 +375,8 @@ class PiCodeModeLifecycleController {
307
375
  const catalogue = renderGenerationCatalogue(generation.captured, decision);
308
376
  if (!catalogue.ok) continue;
309
377
  generation.decision = decision;
310
- const description = catalogueDescription(catalogue.text);
378
+ generation.catalogue = catalogue;
379
+ const description = catalogueDescription(catalogue);
311
380
  if (description === generation.executeDescription) continue;
312
381
  generation.executeDescription = description;
313
382
  if (!generation.toolsRegistered) continue;
@@ -343,8 +412,29 @@ class PiCodeModeLifecycleController {
343
412
  const registry = generation.captured.getToolRegistry();
344
413
  const earlyResults = new Map<string, CodeModeNestedToolResult>();
345
414
  const bridgeCalls: PiToolBridgeCall[] = [];
415
+ let searchCallCount = 0;
346
416
  for (const call of batch.calls) {
347
- if (!exposedNames.has(call.toolName) || !registry.has(call.toolName)) {
417
+ if (call.toolName === CODEMODE_SEARCH_TOOL_NAME) {
418
+ searchCallCount += 1;
419
+ if (searchCallCount > CODEMODE_SEARCH_BATCH_LIMIT) {
420
+ earlyResults.set(
421
+ call.callId,
422
+ unavailableNestedResult(
423
+ call.callId,
424
+ "validation",
425
+ `Pi CodeMode accepts at most ${CODEMODE_SEARCH_BATCH_LIMIT} searches in one batch`,
426
+ ),
427
+ );
428
+ } else {
429
+ const searched = searchCodeModeToolCatalogue(batch.searchEntries, call.input);
430
+ earlyResults.set(
431
+ call.callId,
432
+ searched.ok
433
+ ? { callId: call.callId, outcome: "success", result: searched.page }
434
+ : unavailableNestedResult(call.callId, searched.code, searched.message),
435
+ );
436
+ }
437
+ } else if (!exposedNames.has(call.toolName) || !registry.has(call.toolName)) {
348
438
  earlyResults.set(
349
439
  call.callId,
350
440
  unavailableNestedResult(
@@ -400,9 +490,12 @@ class PiCodeModeLifecycleController {
400
490
  },
401
491
  }),
402
492
  };
403
- const bridged = await executePiToolBridgeBatch(bridgeCaptured, bridgeOptions);
493
+ const bridged =
494
+ bridgeCalls.length === 0
495
+ ? undefined
496
+ : await executePiToolBridgeBatch(bridgeCaptured, bridgeOptions);
404
497
  const bridgedResults = new Map<string, CodeModeNestedToolResult>(
405
- bridged.calls.map((outcome) => [
498
+ (bridged?.calls ?? []).map((outcome) => [
406
499
  outcome.callId,
407
500
  outcome.ok
408
501
  ? {
@@ -429,10 +522,12 @@ class PiCodeModeLifecycleController {
429
522
  );
430
523
  return {
431
524
  results,
432
- presentation: bridged.presentation,
433
- ...(bridged.usage !== undefined && { usage: bridged.usage }),
434
- ...(bridged.addedToolNames.length > 0 && { addedToolNames: bridged.addedToolNames }),
435
- ...(bridged.terminate && { terminate: true }),
525
+ ...(bridged !== undefined &&
526
+ bridged.presentation.length > 0 && { presentation: bridged.presentation }),
527
+ ...(bridged?.usage !== undefined && { usage: bridged.usage }),
528
+ ...(bridged !== undefined &&
529
+ bridged.addedToolNames.length > 0 && { addedToolNames: bridged.addedToolNames }),
530
+ ...(bridged?.terminate === true && { terminate: true }),
436
531
  };
437
532
  }
438
533