@oh-my-pi/pi-coding-agent 15.9.3 → 15.9.5

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 (63) hide show
  1. package/CHANGELOG.md +39 -1
  2. package/dist/types/cli/classify-install-target.d.ts +5 -1
  3. package/dist/types/config/settings-schema.d.ts +13 -4
  4. package/dist/types/modes/components/assistant-message.d.ts +11 -0
  5. package/dist/types/modes/components/custom-editor.d.ts +3 -1
  6. package/dist/types/modes/components/error-banner.d.ts +11 -0
  7. package/dist/types/modes/components/tool-execution.d.ts +15 -0
  8. package/dist/types/modes/components/transcript-container.d.ts +1 -0
  9. package/dist/types/modes/components/user-message.d.ts +1 -1
  10. package/dist/types/modes/image-references.d.ts +17 -0
  11. package/dist/types/modes/interactive-mode.d.ts +7 -0
  12. package/dist/types/modes/types.d.ts +7 -0
  13. package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
  14. package/dist/types/session/blob-store.d.ts +12 -11
  15. package/dist/types/session/session-manager.d.ts +5 -3
  16. package/dist/types/system-prompt.d.ts +2 -0
  17. package/dist/types/tiny/title-client.d.ts +16 -1
  18. package/dist/types/tool-discovery/mode.d.ts +8 -0
  19. package/dist/types/tools/archive-reader.d.ts +5 -1
  20. package/dist/types/tui/hyperlink.d.ts +12 -0
  21. package/dist/types/web/search/render.d.ts +1 -2
  22. package/package.json +9 -9
  23. package/src/cli/classify-install-target.ts +31 -5
  24. package/src/cli/plugin-cli.ts +45 -0
  25. package/src/cli/web-search-cli.ts +0 -1
  26. package/src/config/model-registry.ts +54 -4
  27. package/src/config/settings-schema.ts +14 -4
  28. package/src/eval/__tests__/agent-bridge.test.ts +72 -0
  29. package/src/eval/py/tool-bridge.ts +43 -5
  30. package/src/extensibility/custom-commands/bundled/ci-green/index.ts +31 -2
  31. package/src/internal-urls/docs-index.generated.ts +3 -3
  32. package/src/main.ts +7 -1
  33. package/src/modes/components/assistant-message.ts +22 -0
  34. package/src/modes/components/custom-editor.ts +14 -2
  35. package/src/modes/components/error-banner.ts +33 -0
  36. package/src/modes/components/tool-execution.ts +44 -0
  37. package/src/modes/components/transcript-container.ts +93 -32
  38. package/src/modes/components/user-message.ts +9 -2
  39. package/src/modes/controllers/event-controller.ts +42 -3
  40. package/src/modes/controllers/input-controller.ts +33 -1
  41. package/src/modes/image-references.ts +111 -0
  42. package/src/modes/interactive-mode.ts +48 -13
  43. package/src/modes/types.ts +10 -1
  44. package/src/modes/utils/ui-helpers.ts +23 -2
  45. package/src/prompts/ci-green-request.md +5 -3
  46. package/src/prompts/system/project-prompt.md +1 -0
  47. package/src/sdk.ts +17 -9
  48. package/src/session/agent-session.ts +37 -12
  49. package/src/session/blob-store.ts +96 -9
  50. package/src/session/session-manager.ts +19 -10
  51. package/src/system-prompt.ts +4 -0
  52. package/src/tiny/title-client.ts +7 -1
  53. package/src/tool-discovery/mode.ts +24 -0
  54. package/src/tools/archive-reader.ts +339 -31
  55. package/src/tools/fetch.ts +29 -9
  56. package/src/tools/gh.ts +65 -11
  57. package/src/tools/index.ts +6 -8
  58. package/src/tools/read.ts +58 -12
  59. package/src/tools/search-tool-bm25.ts +4 -6
  60. package/src/tools/search.ts +60 -11
  61. package/src/tui/hyperlink.ts +42 -7
  62. package/src/web/search/index.ts +2 -2
  63. package/src/web/search/render.ts +20 -52
@@ -0,0 +1,111 @@
1
+ import type { ImageContent } from "@oh-my-pi/pi-ai";
2
+ import { logger } from "@oh-my-pi/pi-utils";
3
+ import { type BlobPutResult, blobExtensionForImageMimeType } from "../session/blob-store";
4
+ import { fileHyperlink } from "../tui/hyperlink";
5
+
6
+ const IMAGE_REFERENCE_REGEX = /\[Image #([1-9]\d*)\]/g;
7
+
8
+ type ImageBlobWriter = (data: Buffer, options?: { extension?: string }) => Promise<BlobPutResult>;
9
+ type ImageBlobWriterSync = (data: Buffer, options?: { extension?: string }) => BlobPutResult;
10
+
11
+ export interface ImageReferenceRenderers {
12
+ renderText: (text: string) => string;
13
+ renderReference: (label: string, index: number) => string;
14
+ }
15
+
16
+ export function renderImageReferences(text: string, renderers: ImageReferenceRenderers): string {
17
+ IMAGE_REFERENCE_REGEX.lastIndex = 0;
18
+ let result = "";
19
+ let last = 0;
20
+ let matched = false;
21
+
22
+ for (;;) {
23
+ const match = IMAGE_REFERENCE_REGEX.exec(text);
24
+ if (match === null) break;
25
+ matched = true;
26
+ if (match.index > last) {
27
+ result += renderers.renderText(text.slice(last, match.index));
28
+ }
29
+ result += renderers.renderReference(match[0], Number(match[1]));
30
+ last = match.index + match[0].length;
31
+ }
32
+
33
+ if (!matched) {
34
+ return renderers.renderText(text);
35
+ }
36
+ if (last < text.length) {
37
+ result += renderers.renderText(text.slice(last));
38
+ }
39
+ return result;
40
+ }
41
+
42
+ export function imageReferenceHyperlink(
43
+ label: string,
44
+ index: number,
45
+ imageLinks: readonly (string | undefined)[] | undefined,
46
+ renderLabel: (text: string) => string,
47
+ ): string {
48
+ const rendered = renderLabel(label);
49
+ const target = imageLinks?.[index - 1];
50
+ return target ? fileHyperlink(target, rendered) : rendered;
51
+ }
52
+
53
+ async function materializeImageReferenceLinkAsync(
54
+ image: ImageContent,
55
+ index: number,
56
+ putBlob: ImageBlobWriter,
57
+ ): Promise<string | undefined> {
58
+ try {
59
+ const result = await putBlob(Buffer.from(image.data, "base64"), {
60
+ extension: blobExtensionForImageMimeType(image.mimeType),
61
+ });
62
+ return result.displayPath;
63
+ } catch (error) {
64
+ logger.warn("Failed to write image reference blob", {
65
+ index,
66
+ mimeType: image.mimeType,
67
+ error: error instanceof Error ? error.message : String(error),
68
+ });
69
+ return undefined;
70
+ }
71
+ }
72
+
73
+ function materializeImageReferenceLink(
74
+ image: ImageContent,
75
+ index: number,
76
+ putBlob: ImageBlobWriterSync,
77
+ ): string | undefined {
78
+ try {
79
+ const result = putBlob(Buffer.from(image.data, "base64"), {
80
+ extension: blobExtensionForImageMimeType(image.mimeType),
81
+ });
82
+ return result.displayPath;
83
+ } catch (error) {
84
+ logger.warn("Failed to write image reference blob", {
85
+ index,
86
+ mimeType: image.mimeType,
87
+ error: error instanceof Error ? error.message : String(error),
88
+ });
89
+ return undefined;
90
+ }
91
+ }
92
+
93
+ export async function materializeImageReferenceLinks(
94
+ images: readonly ImageContent[] | undefined,
95
+ putBlob: ImageBlobWriter,
96
+ ): Promise<(string | undefined)[] | undefined> {
97
+ if (!images || images.length === 0) return undefined;
98
+ const links = await Promise.all(
99
+ images.map((image, index) => materializeImageReferenceLinkAsync(image, index + 1, putBlob)),
100
+ );
101
+ return links.some(link => link !== undefined) ? links : undefined;
102
+ }
103
+
104
+ export function materializeImageReferenceLinksSync(
105
+ images: readonly ImageContent[] | undefined,
106
+ putBlob: ImageBlobWriterSync,
107
+ ): (string | undefined)[] | undefined {
108
+ if (!images || images.length === 0) return undefined;
109
+ const links = images.map((image, index) => materializeImageReferenceLink(image, index + 1, putBlob));
110
+ return links.some(link => link !== undefined) ? links : undefined;
111
+ }
@@ -96,6 +96,7 @@ import type { AssistantMessageComponent } from "./components/assistant-message";
96
96
  import type { BashExecutionComponent } from "./components/bash-execution";
97
97
  import { CustomEditor } from "./components/custom-editor";
98
98
  import { DynamicBorder } from "./components/dynamic-border";
99
+ import { ErrorBannerComponent } from "./components/error-banner";
99
100
  import type { EvalExecutionComponent } from "./components/eval-execution";
100
101
  import type { HookEditorComponent } from "./components/hook-editor";
101
102
  import type { HookInputComponent } from "./components/hook-input";
@@ -264,6 +265,7 @@ export class InteractiveMode implements InteractiveModeContext {
264
265
  todoContainer: Container;
265
266
  btwContainer: Container;
266
267
  omfgContainer: Container;
268
+ errorBannerContainer: Container;
267
269
  editor: CustomEditor;
268
270
  editorContainer: Container;
269
271
  hookWidgetContainerAbove: Container;
@@ -288,6 +290,7 @@ export class InteractiveMode implements InteractiveModeContext {
288
290
  todoPhases: TodoPhase[] = [];
289
291
  hideThinkingBlock = false;
290
292
  pendingImages: ImageContent[] = [];
293
+ pendingImageLinks: (string | undefined)[] = [];
291
294
  compactionQueuedMessages: CompactionQueuedMessage[] = [];
292
295
  pendingTools = new Map<string, ToolExecutionHandle>();
293
296
  pendingBashComponents: BashExecutionComponent[] = [];
@@ -404,6 +407,7 @@ export class InteractiveMode implements InteractiveModeContext {
404
407
  this.todoContainer = new Container();
405
408
  this.btwContainer = new Container();
406
409
  this.omfgContainer = new Container();
410
+ this.errorBannerContainer = new Container();
407
411
  this.editor = new CustomEditor(getEditorTheme());
408
412
  this.editor.setUseTerminalCursor(this.ui.getShowHardwareCursor());
409
413
  this.editor.setAutocompleteMaxVisible(settings.get("autocompleteMaxVisible"));
@@ -565,6 +569,7 @@ export class InteractiveMode implements InteractiveModeContext {
565
569
  this.ui.addChild(this.todoContainer);
566
570
  this.ui.addChild(this.btwContainer);
567
571
  this.ui.addChild(this.omfgContainer);
572
+ this.ui.addChild(this.errorBannerContainer);
568
573
  this.ui.addChild(this.statusLine); // Only renders hook statuses (main status in editor border)
569
574
  this.ui.addChild(this.hookWidgetContainerAbove);
570
575
  this.ui.addChild(this.editorContainer);
@@ -896,12 +901,14 @@ export class InteractiveMode implements InteractiveModeContext {
896
901
  startPendingSubmission(input: {
897
902
  text: string;
898
903
  images?: ImageContent[];
904
+ imageLinks?: (string | undefined)[];
899
905
  customType?: string;
900
906
  display?: boolean;
901
907
  }): SubmittedUserInput {
902
908
  const submission: SubmittedUserInput = {
903
909
  text: input.text,
904
910
  images: input.images,
911
+ imageLinks: input.imageLinks,
905
912
  customType: input.customType,
906
913
  display: input.display,
907
914
  cancelled: false,
@@ -913,22 +920,28 @@ export class InteractiveMode implements InteractiveModeContext {
913
920
  const imageCount = submission.images?.length ?? 0;
914
921
  this.optimisticUserMessageSignature = `${submission.text}\u0000${imageCount}`;
915
922
  this.#pendingSubmissionDispose = this.recordLocalSubmission(submission.text, imageCount);
916
- this.addMessageToChat({
917
- role: "user",
918
- content: [{ type: "text", text: submission.text }, ...(submission.images ?? [])],
919
- attribution: "user",
920
- timestamp: Date.now(),
921
- });
923
+ this.addMessageToChat(
924
+ {
925
+ role: "user",
926
+ content: [{ type: "text", text: submission.text }, ...(submission.images ?? [])],
927
+ attribution: "user",
928
+ timestamp: Date.now(),
929
+ },
930
+ { imageLinks: input.imageLinks },
931
+ );
922
932
  } else {
923
933
  this.optimisticUserMessageSignature = undefined;
924
934
  this.#pendingSubmissionDispose = undefined;
925
935
  }
926
936
  this.editor.setText("");
927
- // Reconciliation checkpoint: the rebuild below replays the whole transcript
928
- // into native scrollback, so retire frozen block snapshots and let every
929
- // block render its current state.
930
- this.chatContainer.thaw();
931
- this.ui.refreshNativeScrollbackIfDirty({ allowUnknownViewport: true });
937
+ this.editor.imageLinks = undefined;
938
+ // Reconciliation checkpoint: only retire frozen block snapshots after TUI
939
+ // proves the native viewport is at the tail and replays scrollback safely.
940
+ // Unknown host viewports stay frozen; thawing them would expose live rows
941
+ // over stale native history and can yank or duplicate when ED3 is unsafe.
942
+ if (this.ui.refreshNativeScrollbackIfDirty()) {
943
+ this.chatContainer.thaw();
944
+ }
932
945
  this.ensureLoadingAnimation();
933
946
  this.ui.requestRender();
934
947
  return submission;
@@ -956,6 +969,8 @@ export class InteractiveMode implements InteractiveModeContext {
956
969
  }
957
970
  if (!submission.customType) {
958
971
  this.pendingImages = submission.images ? [...submission.images] : [];
972
+ this.pendingImageLinks = submission.imageLinks ? [...submission.imageLinks] : [];
973
+ this.editor.imageLinks = this.pendingImageLinks;
959
974
  this.rebuildChatFromMessages();
960
975
  this.editor.setText(submission.text);
961
976
  }
@@ -2492,6 +2507,19 @@ export class InteractiveMode implements InteractiveModeContext {
2492
2507
  this.#uiHelpers.showError(message);
2493
2508
  }
2494
2509
 
2510
+ showPinnedError(message: string): void {
2511
+ if (this.isBackgrounded) return;
2512
+ this.errorBannerContainer.clear();
2513
+ this.errorBannerContainer.addChild(new ErrorBannerComponent(message));
2514
+ this.ui.requestRender();
2515
+ }
2516
+
2517
+ clearPinnedError(): void {
2518
+ if (this.errorBannerContainer.children.length === 0) return;
2519
+ this.errorBannerContainer.clear();
2520
+ this.ui.requestRender();
2521
+ }
2522
+
2495
2523
  showWarning(message: string): void {
2496
2524
  this.#uiHelpers.showWarning(message);
2497
2525
  }
@@ -2622,7 +2650,10 @@ export class InteractiveMode implements InteractiveModeContext {
2622
2650
  return this.#uiHelpers.isKnownSlashCommand(text);
2623
2651
  }
2624
2652
 
2625
- addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): Component[] {
2653
+ addMessageToChat(
2654
+ message: AgentMessage,
2655
+ options?: { populateHistory?: boolean; imageLinks?: readonly (string | undefined)[] },
2656
+ ): Component[] {
2626
2657
  return this.#uiHelpers.addMessageToChat(message, options);
2627
2658
  }
2628
2659
 
@@ -2633,7 +2664,10 @@ export class InteractiveMode implements InteractiveModeContext {
2633
2664
  this.#uiHelpers.renderSessionContext(sessionContext, options);
2634
2665
  }
2635
2666
 
2636
- renderInitialMessages(prebuiltContext?: SessionContext, options?: { preserveExistingChat?: boolean }): void {
2667
+ renderInitialMessages(
2668
+ prebuiltContext?: SessionContext,
2669
+ options?: { preserveExistingChat?: boolean; clearTerminalHistory?: boolean },
2670
+ ): void {
2637
2671
  this.#uiHelpers.renderInitialMessages(prebuiltContext, options);
2638
2672
  }
2639
2673
 
@@ -2706,6 +2740,7 @@ export class InteractiveMode implements InteractiveModeContext {
2706
2740
  this.#btwController.dispose();
2707
2741
  this.#omfgController.dispose();
2708
2742
  this.#extensionUiController.clearExtensionTerminalInputListeners();
2743
+ this.clearPinnedError();
2709
2744
  this.#planReviewContainer = undefined;
2710
2745
  }
2711
2746
 
@@ -40,6 +40,7 @@ export type CompactionQueuedMessage = {
40
40
  export type SubmittedUserInput = {
41
41
  text: string;
42
42
  images?: ImageContent[];
43
+ imageLinks?: (string | undefined)[];
43
44
  customType?: string;
44
45
  display?: boolean;
45
46
  cancelled: boolean;
@@ -75,6 +76,7 @@ export interface InteractiveModeContext {
75
76
  todoContainer: Container;
76
77
  btwContainer: Container;
77
78
  omfgContainer: Container;
79
+ errorBannerContainer: Container;
78
80
  editor: CustomEditor;
79
81
  editorContainer: Container;
80
82
  hookWidgetContainerAbove: Container;
@@ -106,6 +108,7 @@ export interface InteractiveModeContext {
106
108
  planModePlanFilePath?: string;
107
109
  hideThinkingBlock: boolean;
108
110
  pendingImages: ImageContent[];
111
+ pendingImageLinks: (string | undefined)[];
109
112
  compactionQueuedMessages: CompactionQueuedMessage[];
110
113
  pendingTools: Map<string, ToolExecutionHandle>;
111
114
  pendingBashComponents: BashExecutionComponent[];
@@ -157,6 +160,8 @@ export interface InteractiveModeContext {
157
160
  // UI helpers
158
161
  showStatus(message: string, options?: { dim?: boolean }): void;
159
162
  showError(message: string): void;
163
+ showPinnedError(message: string): void;
164
+ clearPinnedError(): void;
160
165
  showWarning(message: string): void;
161
166
  showNewVersionNotification(newVersion: string): void;
162
167
  clearEditor(): void;
@@ -171,6 +176,7 @@ export interface InteractiveModeContext {
171
176
  startPendingSubmission(input: {
172
177
  text: string;
173
178
  images?: ImageContent[];
179
+ imageLinks?: (string | undefined)[];
174
180
  customType?: string;
175
181
  display?: boolean;
176
182
  }): SubmittedUserInput;
@@ -191,7 +197,10 @@ export interface InteractiveModeContext {
191
197
  */
192
198
  withLocalSubmission<T>(text: string, fn: () => Promise<T>, options?: { imageCount?: number }): Promise<T>;
193
199
  isKnownSlashCommand(text: string): boolean;
194
- addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): Component[];
200
+ addMessageToChat(
201
+ message: AgentMessage,
202
+ options?: { populateHistory?: boolean; imageLinks?: readonly (string | undefined)[] },
203
+ ): Component[];
195
204
  renderSessionContext(
196
205
  sessionContext: SessionContext,
197
206
  options?: { updateFooter?: boolean; populateHistory?: boolean },
@@ -18,6 +18,7 @@ import {
18
18
  import { SkillMessageComponent } from "../../modes/components/skill-message";
19
19
  import { ToolExecutionComponent } from "../../modes/components/tool-execution";
20
20
  import { UserMessageComponent } from "../../modes/components/user-message";
21
+ import { materializeImageReferenceLinksSync } from "../../modes/image-references";
21
22
  import { theme } from "../../modes/theme/theme";
22
23
  import type { CompactionQueuedMessage, InteractiveModeContext } from "../../modes/types";
23
24
  import {
@@ -40,6 +41,18 @@ type QueuedMessages = {
40
41
  followUp: string[];
41
42
  };
42
43
 
44
+ function imageLinksForMessage(
45
+ message: Extract<AgentMessage, { role: "developer" | "user" }>,
46
+ putBlobSync: InteractiveModeContext["sessionManager"]["putBlobSync"],
47
+ ): (string | undefined)[] | undefined {
48
+ if (typeof message.content === "string") return undefined;
49
+ const images = message.content.filter(
50
+ (content): content is ImageContent =>
51
+ content.type === "image" && typeof content.data === "string" && typeof content.mimeType === "string",
52
+ );
53
+ return materializeImageReferenceLinksSync(images, putBlobSync);
54
+ }
55
+
43
56
  export class UiHelpers {
44
57
  constructor(private ctx: InteractiveModeContext) {}
45
58
 
@@ -84,7 +97,10 @@ export class UiHelpers {
84
97
  this.ctx.ui.requestRender();
85
98
  }
86
99
 
87
- addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): Component[] {
100
+ addMessageToChat(
101
+ message: AgentMessage,
102
+ options?: { populateHistory?: boolean; imageLinks?: readonly (string | undefined)[] },
103
+ ): Component[] {
88
104
  switch (message.role) {
89
105
  case "bashExecution": {
90
106
  const component = new BashExecutionComponent(message.command, this.ctx.ui, message.excludeFromContext);
@@ -253,7 +269,10 @@ export class UiHelpers {
253
269
  const textContent = this.ctx.getUserMessageText(message);
254
270
  if (textContent) {
255
271
  const isSynthetic = message.role === "developer" ? true : (message.synthetic ?? false);
256
- const userComponent = new UserMessageComponent(textContent, isSynthetic);
272
+ const imageLinks =
273
+ options?.imageLinks ??
274
+ imageLinksForMessage(message, this.ctx.sessionManager.putBlobSync.bind(this.ctx.sessionManager));
275
+ const userComponent = new UserMessageComponent(textContent, isSynthetic, imageLinks);
257
276
  this.ctx.chatContainer.addChild(userComponent);
258
277
  if (options?.populateHistory && message.role === "user" && !isSynthetic) {
259
278
  this.ctx.editor.addToHistory(textContent);
@@ -513,6 +532,8 @@ export class UiHelpers {
513
532
  }
514
533
  this.ctx.editor.setText("");
515
534
  this.ctx.pendingImages = [];
535
+ this.ctx.pendingImageLinks = [];
536
+ this.ctx.editor.imageLinks = undefined;
516
537
  this.ctx.ui.requestRender();
517
538
  }
518
539
 
@@ -14,7 +14,7 @@ Do not stop after a single fix attempt.
14
14
  2. If any run fails, inspect failing job output and logs.
15
15
  3. Identify root cause and make minimal correct fix.
16
16
  4. Run local verification if it reduces chance of another failing push.
17
- 5. Push the branch.
17
+ {{#if headTag}}5. Push the branch and tag `{{headTag}}` atomically: `git push --atomic "{{remote}}" "{{branch}}" "+refs/tags/{{headTag}}"`.{{else}}5. Push the branch.{{/if}}
18
18
  6. Watch workflow runs for new HEAD commit again.
19
19
  7. Repeat until workflow runs for latest HEAD commit succeed.
20
20
  </procedure>
@@ -26,11 +26,13 @@ Do not stop after a single fix attempt.
26
26
 
27
27
  {{#if headTag}}
28
28
  <instruction>
29
- Once CI is green, ensure the final commit is tagged `{{headTag}}` and push that tag.
29
+ Always push the branch and tag together atomically so the tag never points at an un-pushed or non-green commit:
30
+ `git push --atomic "{{remote}}" "{{branch}}" "+refs/tags/{{headTag}}"`.
31
+ The `--atomic` flag makes the branch and tag update succeed or fail as one ref transaction; `+refs/tags/{{headTag}}` force-moves the tag to the new HEAD. Do not push the branch first and retag later.
30
32
  </instruction>
31
33
  {{/if}}
32
34
 
33
35
  <critical>
34
36
  The task is complete only when the workflow runs for the latest HEAD commit succeed.
35
- {{#if headTag}}The final green commit must be tagged `{{headTag}}` and that tag must be pushed.{{/if}}
37
+ {{#if headTag}}The latest HEAD commit must carry tag `{{headTag}}`, pushed atomically with the branch via `git push --atomic`.{{/if}}
36
38
  </critical>
@@ -3,6 +3,7 @@ PROJECT
3
3
 
4
4
  <workstation>
5
5
  {{#list environment prefix="- " join="\n"}}{{label}}: {{value}}{{/list}}
6
+ {{#if model}}- Model: {{model}}{{/if}}
6
7
  </workstation>
7
8
 
8
9
  {{#if contextFiles.length}}
package/src/sdk.ts CHANGED
@@ -130,6 +130,7 @@ import {
130
130
  resolveThinkingLevelForModel,
131
131
  toReasoningEffort,
132
132
  } from "./thinking";
133
+ import { countToolsForAutoDiscovery, resolveEffectiveToolDiscoveryMode } from "./tool-discovery/mode";
133
134
  import {
134
135
  collectDiscoverableTools,
135
136
  type DiscoverableTool,
@@ -157,6 +158,7 @@ import {
157
158
  ResolveTool,
158
159
  renderSearchToolBm25Description,
159
160
  SearchTool,
161
+ SearchToolBm25Tool,
160
162
  setPreferredImageProvider,
161
163
  setPreferredSearchProvider,
162
164
  type Tool,
@@ -1687,6 +1689,19 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1687
1689
  }
1688
1690
  }
1689
1691
 
1692
+ const effectiveDiscoveryMode = resolveEffectiveToolDiscoveryMode(
1693
+ settings,
1694
+ countToolsForAutoDiscovery(toolRegistry.keys()),
1695
+ );
1696
+ if (effectiveDiscoveryMode !== "off" && !toolRegistry.has("search_tool_bm25")) {
1697
+ const searchTool: Tool = new SearchToolBm25Tool(toolSession);
1698
+ toolRegistry.set(
1699
+ searchTool.name,
1700
+ new ExtensionToolWrapper(wrapToolWithMetaNotice(searchTool), extensionRunner) as Tool,
1701
+ );
1702
+ }
1703
+ const mcpDiscoveryEnabled = effectiveDiscoveryMode !== "off"; // back-compat: true when any discovery active
1704
+
1690
1705
  const reloadSshTool = async (): Promise<AgentTool | null> => {
1691
1706
  if (!requestedToolNameSet.has("ssh")) return null;
1692
1707
  const sshTool = (await loadSshTool({
@@ -1773,6 +1788,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1773
1788
  secretsEnabled,
1774
1789
  workspaceTree: workspaceTreePromise,
1775
1790
  memoryRootEnabled: memoryBackend.id === "local",
1791
+ model: settings.get("includeModelInPrompt") ? getActiveModelString() : undefined,
1776
1792
  });
1777
1793
 
1778
1794
  if (options.systemPrompt === undefined) {
@@ -1805,15 +1821,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1805
1821
  const requestedToolNames = explicitlyRequestedToolNames ?? toolNamesFromRegistry;
1806
1822
  const normalizedRequested = requestedToolNames.filter(name => toolRegistry.has(name));
1807
1823
  const requestedToolNameSet = new Set(normalizedRequested);
1808
- // Effective discovery mode: tools.discoveryMode takes precedence; mcp.discoveryMode is back-compat alias.
1809
- const toolsDiscoveryModeSetting = settings.get("tools.discoveryMode");
1810
- const effectiveDiscoveryMode: "off" | "mcp-only" | "all" =
1811
- toolsDiscoveryModeSetting !== "off"
1812
- ? (toolsDiscoveryModeSetting as "off" | "mcp-only" | "all")
1813
- : settings.get("mcp.discoveryMode")
1814
- ? "mcp-only"
1815
- : "off";
1816
- const mcpDiscoveryEnabled = effectiveDiscoveryMode !== "off"; // back-compat: true when any discovery active
1824
+ // Effective discovery mode is resolved after the full registry exists so auto mode can count MCP/extension tools.
1817
1825
  const defaultInactiveToolNames = new Set(
1818
1826
  registeredTools.filter(tool => tool.definition.defaultInactive).map(tool => tool.definition.name),
1819
1827
  );
@@ -192,6 +192,7 @@ import {
192
192
  toReasoningEffort,
193
193
  } from "../thinking";
194
194
  import { shutdownTinyTitleClient } from "../tiny/title-client";
195
+ import { countToolsForAutoDiscovery, resolveEffectiveToolDiscoveryMode } from "../tool-discovery/mode";
195
196
  import {
196
197
  buildDiscoverableToolSearchIndex,
197
198
  collectDiscoverableTools,
@@ -960,6 +961,13 @@ export class AgentSession {
960
961
  * the dominant cause of prompt-cache invalidation in long sessions.
961
962
  */
962
963
  #lastAppliedToolSignature: string | undefined;
964
+ /**
965
+ * Model identifier (`provider/id`) currently rendered into `#baseSystemPrompt`.
966
+ * The prompt surfaces the active model to the agent, so a model switch must
967
+ * trigger a rebuild. Compared against the live model after every model change
968
+ * to decide whether the cached prompt is stale.
969
+ */
970
+ #promptModelKey: string | undefined;
963
971
  #mcpDiscoveryEnabled = false;
964
972
  #discoverableMCPTools = new Map<string, DiscoverableTool>();
965
973
  #selectedMCPToolNames = new Set<string>();
@@ -1173,6 +1181,7 @@ export class AgentSession {
1173
1181
  this.#getMcpServerInstructions = config.getMcpServerInstructions;
1174
1182
  this.#reloadSshTool = config.reloadSshTool;
1175
1183
  this.#baseSystemPrompt = this.agent.state.systemPrompt;
1184
+ this.#promptModelKey = this.#currentPromptModelKey();
1176
1185
  this.#mcpDiscoveryEnabled = config.mcpDiscoveryEnabled ?? false;
1177
1186
  this.#setDiscoverableMCPTools(this.#collectDiscoverableMCPToolsFromRegistry());
1178
1187
  this.#selectedMCPToolNames = new Set(config.initialSelectedMCPToolNames ?? []);
@@ -3264,9 +3273,21 @@ export class AgentSession {
3264
3273
  return resolveEditMode(this.#getEditModeSession());
3265
3274
  }
3266
3275
 
3267
- async #syncEditToolModeAfterModelChange(previousEditMode: EditMode): Promise<void> {
3276
+ /**
3277
+ * Model key (`provider/id`) currently surfaced in the system prompt, or
3278
+ * undefined when the model is unset or `includeModelInPrompt` is disabled.
3279
+ */
3280
+ #currentPromptModelKey(): string | undefined {
3281
+ if (!this.settings.get("includeModelInPrompt")) return undefined;
3282
+ return this.model ? formatModelString(this.model) : undefined;
3283
+ }
3284
+
3285
+ async #syncAfterModelChange(previousEditMode: EditMode): Promise<void> {
3268
3286
  const currentEditMode = this.#resolveActiveEditMode();
3269
- if (previousEditMode !== currentEditMode && this.getActiveToolNames().includes("edit")) {
3287
+ const editModeChanged = previousEditMode !== currentEditMode && this.getActiveToolNames().includes("edit");
3288
+ // The system prompt may surface the active model; a switch makes the cached prompt stale.
3289
+ const modelChanged = this.#currentPromptModelKey() !== this.#promptModelKey;
3290
+ if (editModeChanged || modelChanged) {
3270
3291
  await this.refreshBaseSystemPrompt();
3271
3292
  }
3272
3293
  }
@@ -3305,12 +3326,14 @@ export class AgentSession {
3305
3326
 
3306
3327
  // ── Generic tool discovery (covers built-in + MCP + extension) ────────────
3307
3328
 
3308
- /** Resolve effective discovery mode: tools.discoveryMode wins; mcp.discoveryMode is back-compat alias. */
3329
+ /** Resolve effective discovery mode from the current registry size. */
3309
3330
  #resolveEffectiveDiscoveryMode(): "off" | "mcp-only" | "all" {
3310
- const toolsMode = this.settings.get("tools.discoveryMode");
3311
- if (toolsMode !== "off") return toolsMode as "off" | "mcp-only" | "all";
3312
- if (this.settings.get("mcp.discoveryMode")) return "mcp-only";
3313
- return "off";
3331
+ const mode = resolveEffectiveToolDiscoveryMode(
3332
+ this.settings,
3333
+ countToolsForAutoDiscovery(this.#toolRegistry.keys()),
3334
+ );
3335
+ if (mode !== "off") return mode;
3336
+ return this.#mcpDiscoveryEnabled ? "mcp-only" : "off";
3314
3337
  }
3315
3338
 
3316
3339
  isToolDiscoveryEnabled(): boolean {
@@ -3551,6 +3574,7 @@ export class AgentSession {
3551
3574
  this.#baseSystemPrompt = built.systemPrompt;
3552
3575
  this.agent.setSystemPrompt(this.#baseSystemPrompt);
3553
3576
  this.#lastAppliedToolSignature = signature;
3577
+ this.#promptModelKey = this.#currentPromptModelKey();
3554
3578
  }
3555
3579
  }
3556
3580
  if (options?.persistMCPSelection !== false) {
@@ -3633,6 +3657,7 @@ export class AgentSession {
3633
3657
  const built = await this.#rebuildSystemPrompt(activeToolNames, this.#toolRegistry);
3634
3658
  this.#baseSystemPrompt = built.systemPrompt;
3635
3659
  this.agent.setSystemPrompt(this.#baseSystemPrompt);
3660
+ this.#promptModelKey = this.#currentPromptModelKey();
3636
3661
  // Refresh the cached signature so a subsequent `#applyActiveToolsByName` with
3637
3662
  // the same tool set does not re-rebuild on top of the explicit refresh we
3638
3663
  // just performed (and conversely, a different set forces a fresh rebuild).
@@ -3692,7 +3717,7 @@ export class AgentSession {
3692
3717
  * closure-captured ones cannot change at runtime regardless of skip behavior.
3693
3718
  * For everything else, callers must explicitly call `refreshBaseSystemPrompt()`
3694
3719
  * after side-effecting changes; see e.g. the memory hooks and
3695
- * `#syncEditToolModeAfterModelChange`.
3720
+ * `#syncAfterModelChange`.
3696
3721
  *
3697
3722
  * The current calendar date IS covered (appended as a segment) because
3698
3723
  * `buildSystemPrompt` injects it into the prompt body (`Today is '{{date}}'`).
@@ -5284,7 +5309,7 @@ export class AgentSession {
5284
5309
  // Re-apply thinking for the newly selected model. Prefer the model's
5285
5310
  // configured defaultLevel; otherwise preserve the current level (or auto).
5286
5311
  this.#reapplyThinkingLevel(model.thinking?.defaultLevel);
5287
- await this.#syncEditToolModeAfterModelChange(previousEditMode);
5312
+ await this.#syncAfterModelChange(previousEditMode);
5288
5313
  }
5289
5314
 
5290
5315
  /**
@@ -5318,7 +5343,7 @@ export class AgentSession {
5318
5343
  } else {
5319
5344
  this.#reapplyThinkingLevel(model.thinking?.defaultLevel);
5320
5345
  }
5321
- await this.#syncEditToolModeAfterModelChange(previousEditMode);
5346
+ await this.#syncAfterModelChange(previousEditMode);
5322
5347
  }
5323
5348
 
5324
5349
  /**
@@ -5462,7 +5487,7 @@ export class AgentSession {
5462
5487
 
5463
5488
  // Apply the scoped model's configured thinking level, preserving auto.
5464
5489
  this.setThinkingLevel(this.#autoThinking ? AUTO_THINKING : next.thinkingLevel);
5465
- await this.#syncEditToolModeAfterModelChange(previousEditMode);
5490
+ await this.#syncAfterModelChange(previousEditMode);
5466
5491
 
5467
5492
  return { model: next.model, thinkingLevel: this.thinkingLevel, isScoped: true };
5468
5493
  }
@@ -5491,7 +5516,7 @@ export class AgentSession {
5491
5516
  this.settings.getStorage()?.recordModelUsage(`${nextModel.provider}/${nextModel.id}`);
5492
5517
  // Re-apply the current thinking level (or auto) for the newly selected model
5493
5518
  this.#reapplyThinkingLevel();
5494
- await this.#syncEditToolModeAfterModelChange(previousEditMode);
5519
+ await this.#syncAfterModelChange(previousEditMode);
5495
5520
 
5496
5521
  return { model: nextModel, thinkingLevel: this.thinkingLevel, isScoped: false };
5497
5522
  }