pi-advisor-flow 0.8.3 → 0.9.1

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 (40) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +6 -5
  3. package/dist/index.js +685 -96
  4. package/package.json +14 -14
  5. package/src/attachments.ts +21 -5
  6. package/src/commands/activation.ts +6 -1
  7. package/src/commands/lifecycle.ts +6 -0
  8. package/src/commands/manual-command.ts +44 -12
  9. package/src/commands/manual-consultation.ts +7 -3
  10. package/src/commands/model-commands.ts +1 -0
  11. package/src/commands/runtime.ts +36 -10
  12. package/src/commands/settings-commands.ts +2 -0
  13. package/src/commands/settings-persistence.ts +2 -0
  14. package/src/commands/types.ts +5 -0
  15. package/src/config/defaults.ts +7 -0
  16. package/src/config/schema.ts +7 -0
  17. package/src/config/state.ts +5 -0
  18. package/src/config/types.ts +1 -0
  19. package/src/conversation.ts +29 -7
  20. package/src/image-attachments.ts +118 -0
  21. package/src/image-validation.ts +288 -0
  22. package/src/images.ts +151 -0
  23. package/src/scout-context.ts +1 -0
  24. package/src/scout-curation.ts +11 -4
  25. package/src/scout-groups.ts +13 -3
  26. package/src/scout-types.ts +2 -0
  27. package/src/tools/consult-context.ts +78 -2
  28. package/src/tools/consultation.ts +36 -4
  29. package/src/tools/jev-turn-gate.ts +4 -1
  30. package/src/tools/model-access.ts +21 -2
  31. package/src/tools/prompts.ts +14 -2
  32. package/src/tools/register-ask-advisor.ts +28 -1
  33. package/src/tools/register-lifecycle.ts +8 -2
  34. package/src/tools/render-advisor-result.ts +33 -9
  35. package/src/tools/scout-status.ts +11 -8
  36. package/src/tools/types.ts +9 -0
  37. package/src/ui/settings-items.ts +17 -1
  38. package/src/ui/settings-mutations.ts +1 -0
  39. package/src/ui/types.ts +1 -0
  40. package/src/ui-guard.ts +31 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-advisor-flow",
3
- "version": "0.8.3",
3
+ "version": "0.9.1",
4
4
  "description": "Advanced Executor/Advisor flow for Pi, fully configurable and extendable.",
5
5
  "keywords": [
6
6
  "advisor",
@@ -51,26 +51,26 @@
51
51
  "format:check": "oxfmt --check"
52
52
  },
53
53
  "devDependencies": {
54
- "@earendil-works/pi-ai": "0.87.1",
55
- "@earendil-works/pi-coding-agent": "0.87.1",
56
- "@earendil-works/pi-tui": "0.87.1",
57
- "@oxlint/plugins": "1.85.0",
54
+ "@earendil-works/pi-ai": "0.99.1",
55
+ "@earendil-works/pi-coding-agent": "0.99.1",
56
+ "@earendil-works/pi-tui": "0.99.1",
57
+ "@oxlint/plugins": "1.86.0",
58
58
  "@types/node": "^26.6.3",
59
59
  "@typesafe-ai/sdk": "^0.6.0",
60
60
  "bun-types": "1.4.2",
61
61
  "husky": "^9.1.7",
62
- "knip": "^6.38.0",
62
+ "knip": "6.39.0",
63
63
  "lint-staged": "^17.6.0",
64
- "oxfmt": "0.70.0",
65
- "oxlint": "1.85.0",
64
+ "oxfmt": "0.71.0",
65
+ "oxlint": "1.86.0",
66
66
  "typebox": "^1.3.34",
67
67
  "typescript": "^7.0.2",
68
- "ultracite": "7.12.0"
68
+ "ultracite": "7.12.2"
69
69
  },
70
70
  "peerDependencies": {
71
- "@earendil-works/pi-ai": "^0.87.1",
72
- "@earendil-works/pi-coding-agent": "^0.87.1",
73
- "@earendil-works/pi-tui": "^0.87.1",
71
+ "@earendil-works/pi-ai": "^0.87.1 || ^0.99.1",
72
+ "@earendil-works/pi-coding-agent": "^0.87.1 || ^0.99.1",
73
+ "@earendil-works/pi-tui": "^0.87.1 || ^0.99.1",
74
74
  "typebox": "^1.1.38"
75
75
  },
76
76
  "peerDependenciesMeta": {
@@ -92,10 +92,10 @@
92
92
  "safe-buffer": "npm:@socketregistry/safe-buffer@1.0.9"
93
93
  },
94
94
  "overrides": {
95
- "brace-expansion": "5.0.9",
95
+ "brace-expansion": "5.0.12",
96
96
  "is-unicode-supported": "npm:@socketregistry/is-unicode-supported@1.0.5",
97
97
  "safe-buffer": "npm:@socketregistry/safe-buffer@1.0.9",
98
- "undici": "8.9.0"
98
+ "undici": "8.11.2"
99
99
  },
100
100
  "lint-staged": {
101
101
  "*.{ts,tsx}": [
@@ -14,16 +14,28 @@ export interface UntrackedAttachment {
14
14
  text: string;
15
15
  }
16
16
 
17
+ const imageExtensions = new Map([
18
+ [".png", "image/png"],
19
+ [".jpg", "image/jpeg"],
20
+ [".jpeg", "image/jpeg"],
21
+ [".gif", "image/gif"],
22
+ [".webp", "image/webp"],
23
+ ]);
24
+ export const imageMime = (path: string) =>
25
+ imageExtensions.get(path.slice(path.lastIndexOf(".")).toLowerCase());
26
+ export const imageFileCandidate = (path: string) =>
27
+ Boolean(imageMime(path)) || /\.(?:avif|bmp|heic|svg|tiff?)$/iu.test(path);
28
+
17
29
  const PATH_SEGMENTS = /[\\/]/u;
18
30
 
19
- const within = (root: string, candidate: string) => {
31
+ export const within = (root: string, candidate: string) => {
20
32
  const path = relative(root, candidate);
21
33
  return path !== "" && !path.startsWith("..") && !path.includes("../");
22
34
  };
23
35
  const normalizeRelativePath = (root: string, path: string) =>
24
36
  relative(root, resolve(root, path));
25
37
 
26
- const normalizeRequestedPath = (root: string, value: string) => {
38
+ export const normalizeRequestedPath = (root: string, value: string) => {
27
39
  if (
28
40
  !isString(value) ||
29
41
  !value ||
@@ -46,7 +58,7 @@ const git = (cwd: string, args: string[]) =>
46
58
  windowsHide: true,
47
59
  });
48
60
 
49
- const repositoryRoot = (cwd: string) => {
61
+ export const repositoryRoot = (cwd: string) => {
50
62
  try {
51
63
  return realpath(git(cwd, ["rev-parse", "--show-toplevel"]).trim());
52
64
  } catch {
@@ -86,7 +98,7 @@ const tracked = (cwd: string, path: string) => {
86
98
  });
87
99
  };
88
100
 
89
- const isPermitted = (
101
+ export const isPermitted = (
90
102
  root: string,
91
103
  path: string,
92
104
  kind: "tracked" | "untracked"
@@ -158,7 +170,11 @@ const readFiles = async (
158
170
  let total = 0;
159
171
  for (const name of requested) {
160
172
  const normalizedName = normalizeRequestedPath(root, name);
161
- if (!normalizedName || unique.has(normalizedName)) {
173
+ if (
174
+ !normalizedName ||
175
+ unique.has(normalizedName) ||
176
+ imageFileCandidate(normalizedName)
177
+ ) {
162
178
  continue;
163
179
  }
164
180
  unique.add(normalizedName);
@@ -19,6 +19,7 @@ import {
19
19
  setExecutorRef,
20
20
  } from "../config/state.ts";
21
21
  import { saveConfig } from "../config/storage.ts";
22
+ import { sameModelAdvisorDisabled } from "../tools/model-access.ts";
22
23
  import {
23
24
  loadCommandConfig,
24
25
  prepareActivationModels,
@@ -132,12 +133,16 @@ export const activateAdvisor = async (
132
133
  "record_advisor_outcome",
133
134
  ]);
134
135
  }
136
+ const activeModel = isMarkedSubagent()
137
+ ? ctx.model
138
+ : findConfiguredModel(ctx, executorRef);
139
+ runtime.updateSameModelNotice(ctx, activeModel);
135
140
  if (announce) {
136
141
  const activeExecutorRef = effectiveExecutorRef(ctx);
137
142
  const activeExecutorEffort = effectiveExecutorEffort(ctx);
138
143
  notify(
139
144
  ctx,
140
- `${ADVISOR_ACTIVATION_EXPLANATION}\n\nAdvisor flow ready — Executor: ${activeExecutorRef} (thinking: ${activeExecutorEffort || "default"}) · Advisor: ${advisorRef} (thinking: ${advisorEffortRef || "default"})`,
145
+ `${ADVISOR_ACTIVATION_EXPLANATION}\n\nAdvisor flow ${sameModelAdvisorDisabled(ctx, activeModel) ? "configured" : "ready"} — Executor: ${activeExecutorRef} (thinking: ${activeExecutorEffort || "default"}) · Advisor: ${advisorRef} (thinking: ${advisorEffortRef || "default"})`,
141
146
  "info"
142
147
  );
143
148
  }
@@ -23,12 +23,15 @@ export const registerCommandLifecycle = (
23
23
  ) => {
24
24
  runtime.pi.on("session_start", async (_event, ctx) => {
25
25
  runtime.pendingExecutorModelRef = undefined;
26
+ runtime.resetSameModelNotice();
26
27
  // A malformed advisor.json or a provider auth failure must not reject a
27
28
  // lifecycle handler and break session startup.
28
29
  try {
29
30
  loadConfig(ctx);
30
31
  if (alwaysOnRef) {
31
32
  await activateAdvisor("", ctx, false);
33
+ } else {
34
+ runtime.updateSameModelNotice(ctx);
32
35
  }
33
36
  } catch (error) {
34
37
  const message = error instanceof Error ? error.message : String(error);
@@ -37,6 +40,9 @@ export const registerCommandLifecycle = (
37
40
  });
38
41
 
39
42
  runtime.pi.on("model_select", (event, ctx) => {
43
+ if (!runtime.suppressModelSelectionSync) {
44
+ runtime.updateSameModelNotice(ctx, event.model);
45
+ }
40
46
  // "restore" replays a stored session model and "cycle" changes the active
41
47
  // model without an explicit `/model` choice. Neither should redefine the
42
48
  // configured Executor.
@@ -4,7 +4,11 @@ import {
4
4
  isSimpleMode,
5
5
  } from "../config/state.ts";
6
6
  import type { GitContextLevel } from "../git.ts";
7
- import { advisorModelAccessReason } from "../tools/model-access.ts";
7
+ import {
8
+ advisorModelAccessReason,
9
+ sameModelAdvisorDisabled,
10
+ sameModelAdvisorNotice,
11
+ } from "../tools/model-access.ts";
8
12
  import { resolveAdvisorRequest } from "../tools/render-common.ts";
9
13
  import { ManualAdvisorDialog } from "../ui/manual-dialog.ts";
10
14
  import type { ManualAdvisorRequest } from "../ui/types.ts";
@@ -21,6 +25,10 @@ export const registerManualCommand = (runtime: CommandRuntime) => {
21
25
  if (!loadCommandConfig(ctx)) {
22
26
  return;
23
27
  }
28
+ if (sameModelAdvisorDisabled(ctx)) {
29
+ notify(ctx, sameModelAdvisorNotice, "info");
30
+ return;
31
+ }
24
32
  const accessReason = advisorModelAccessReason(ctx);
25
33
  if (accessReason) {
26
34
  notify(ctx, accessReason, "warning");
@@ -82,6 +90,10 @@ export const registerManualCommand = (runtime: CommandRuntime) => {
82
90
  question = resolveAdvisorRequest(args);
83
91
  }
84
92
 
93
+ if (sameModelAdvisorDisabled(ctx)) {
94
+ notify(ctx, sameModelAdvisorNotice, "info");
95
+ return;
96
+ }
85
97
  if (!isSimpleMode()) {
86
98
  runtime.advisorSessionState.consumeCall();
87
99
  }
@@ -100,18 +112,38 @@ export const registerManualCommand = (runtime: CommandRuntime) => {
100
112
  runtime.scoutStatus.register(scoutStatusToken);
101
113
  runtime.manualConsultations.set(controller, scoutStatusToken);
102
114
  runtime.pi.appendEntry?.("advisor-manual-call", { progressId, question });
115
+ // Print mode tears the session down when the handler returns, so the
116
+ // consultation must complete inside the handler to keep its ctx valid.
117
+ if (ctx.mode === "print") {
118
+ await startManualConsultation(
119
+ runtime,
120
+ ctx,
121
+ question,
122
+ controller,
123
+ scoutStatusToken,
124
+ progress,
125
+ gitContext
126
+ );
127
+ return;
128
+ }
103
129
  // Intentional fire-and-forget: the consultation streams after the command
104
- // handler returns. void satisfies noFloatingPromises; noVoid is ignored here
105
- // because this Biome version offers no ignoreVoidAsExpression option.
106
- void startManualConsultation(
107
- runtime,
108
- ctx,
109
- question,
110
- controller,
111
- scoutStatusToken,
112
- progress,
113
- gitContext
114
- );
130
+ // handler returns. A rejected consultation must not escape as an
131
+ // unhandled rejection; its error paths already notify and record.
132
+ void (async () => {
133
+ try {
134
+ await startManualConsultation(
135
+ runtime,
136
+ ctx,
137
+ question,
138
+ controller,
139
+ scoutStatusToken,
140
+ progress,
141
+ gitContext
142
+ );
143
+ } catch {
144
+ // Consultation error paths already notify and record; nothing to add.
145
+ }
146
+ })();
115
147
  },
116
148
  });
117
149
  };
@@ -7,6 +7,7 @@ import { notifyHerdrAdvisorFailure } from "../herdr.ts";
7
7
  import { normalizeScreeningQuestion } from "../tools/jev-filter.ts";
8
8
  import { appendScoutLifecycleEntry } from "../tools/scout-status.ts";
9
9
  import type { ScoutToolDetails } from "../tools/types.ts";
10
+ import { uiAvailable } from "../ui-guard.ts";
10
11
  import { advisorUsageCost, snapshotAdvisorUsage } from "../usage.ts";
11
12
  import type { AdvisorUsageSnapshot } from "../usage.ts";
12
13
  import { notify } from "./runtime.ts";
@@ -28,9 +29,10 @@ export const startManualConsultation = async (
28
29
  progress: ManualAdvisorProgressState,
29
30
  gitContext?: GitContextLevel
30
31
  ) => {
32
+ const isPrintMode = ctx.mode === "print";
31
33
  progress.phase = "preparing";
32
34
  runtime.requestManualRender(ctx);
33
- if (ctx.hasUI) {
35
+ if (uiAvailable(ctx)) {
34
36
  const timer = setInterval(() => {
35
37
  if (
36
38
  controller.signal.aborted ||
@@ -118,8 +120,10 @@ export const startManualConsultation = async (
118
120
  {
119
121
  // Steer lets the current turn finish its active work; the Executor sees
120
122
  // the result before its next model call rather than being interrupted.
123
+ // Print mode has no follow-up turn; triggering one trips pi's turn_end
124
+ // entry resolution during teardown.
121
125
  deliverAs: "steer",
122
- triggerTurn: true,
126
+ triggerTurn: !isPrintMode,
123
127
  }
124
128
  );
125
129
  } catch (error) {
@@ -147,7 +151,7 @@ export const startManualConsultation = async (
147
151
  },
148
152
  display: true,
149
153
  },
150
- { deliverAs: "steer", triggerTurn: true }
154
+ { deliverAs: "steer", triggerTurn: !isPrintMode }
151
155
  );
152
156
  notify(ctx, `Advisor consultation failed: ${message}`, "error");
153
157
  notifyHerdrAdvisorFailure("Advisor consultation failed", message);
@@ -57,6 +57,7 @@ export const registerModelCommands = (runtime: CommandRuntime) => {
57
57
  persistExecutor: true,
58
58
  });
59
59
  runtime.pendingExecutorModelRef = undefined;
60
+ runtime.updateSameModelNotice(ctx);
60
61
  ctx.ui.notify(
61
62
  `Saved Executor + Advisor configurations to ${path}`,
62
63
  "info"
@@ -7,8 +7,13 @@ import { getAdvisorSettings } from "../config/state.ts";
7
7
  import { herdrAdvisorActivity, notifyHerdrAdvisorFailure } from "../herdr.ts";
8
8
  import type { AdvisorSessionState } from "../session-state.ts";
9
9
  import { consultAdvisor } from "../tools/consultation.ts";
10
+ import {
11
+ sameModelAdvisorDisabled,
12
+ sameModelAdvisorNotice,
13
+ } from "../tools/model-access.ts";
10
14
  import { ScoutStatusManager } from "../tools/scout-status.ts";
11
15
  import { advisorSessionState as defaultAdvisorSessionState } from "../tools/session.ts";
16
+ import { uiAction } from "../ui-guard.ts";
12
17
  import type {
13
18
  CommandDependencies,
14
19
  CommandRuntime as CommandRuntimeContract,
@@ -21,9 +26,7 @@ export const notify = (
21
26
  message: string,
22
27
  level: "error" | "info" | "warning"
23
28
  ) => {
24
- if (ctx.hasUI) {
25
- ctx.ui.notify(message, level);
26
- }
29
+ uiAction(ctx, (ui) => ui.notify(message, level));
27
30
  };
28
31
 
29
32
  const reportManualBudgetExhausted = (ctx: ExtensionContext) => {
@@ -33,9 +36,7 @@ const reportManualBudgetExhausted = (ctx: ExtensionContext) => {
33
36
  };
34
37
 
35
38
  const requestManualRender = (ctx: ExtensionContext) => {
36
- if (ctx.hasUI) {
37
- ctx.ui.setStatus("advisor-manual", undefined);
38
- }
39
+ uiAction(ctx, (ui) => ui.setStatus("advisor-manual", undefined));
39
40
  };
40
41
 
41
42
  class CommandRuntime implements CommandRuntimeContract {
@@ -55,6 +56,7 @@ class CommandRuntime implements CommandRuntimeContract {
55
56
  manualProgressSequence = 0;
56
57
  pendingExecutorModelRef: string | undefined;
57
58
  suppressModelSelectionSync = false;
59
+ private lastSameModelDisabled: boolean | undefined;
58
60
 
59
61
  constructor(pi: ExtensionAPI, dependencies: CommandDependencies = {}) {
60
62
  this.pi = pi;
@@ -84,6 +86,30 @@ class CommandRuntime implements CommandRuntimeContract {
84
86
  return this.pi.getActiveTools().includes("ask_advisor");
85
87
  }
86
88
 
89
+ resetSameModelNotice() {
90
+ this.lastSameModelDisabled = undefined;
91
+ }
92
+
93
+ updateSameModelNotice(
94
+ ctx: ExtensionContext,
95
+ model: { id: string; provider: string } | undefined = ctx.model
96
+ ) {
97
+ if (!this.flowEnabled()) {
98
+ return;
99
+ }
100
+ const disabled = sameModelAdvisorDisabled(ctx, model);
101
+ if (disabled && this.lastSameModelDisabled !== true) {
102
+ notify(ctx, sameModelAdvisorNotice, "info");
103
+ } else if (!disabled && this.lastSameModelDisabled === true) {
104
+ notify(
105
+ ctx,
106
+ "Advisor re-enabled: executor and advisor models differ.",
107
+ "info"
108
+ );
109
+ }
110
+ this.lastSameModelDisabled = disabled;
111
+ }
112
+
87
113
  nextManualProgressId() {
88
114
  this.manualProgressSequence += 1;
89
115
  return `manual-${this.manualProgressSequence}`;
@@ -101,14 +127,14 @@ class CommandRuntime implements CommandRuntimeContract {
101
127
  }
102
128
 
103
129
  updateAdvisorUsageStatus(ctx: ExtensionContext) {
104
- if (ctx.hasUI) {
105
- ctx.ui.setStatus(
130
+ uiAction(ctx, (ui) =>
131
+ ui.setStatus(
106
132
  "advisor-usage",
107
133
  getAdvisorSettings().showUsageFooter
108
134
  ? this.advisorSessionState.usageStatus()
109
135
  : undefined
110
- );
111
- }
136
+ )
137
+ );
112
138
  }
113
139
  }
114
140
 
@@ -36,6 +36,7 @@ export const registerSettingsCommands = (runtime: CommandRuntime) => {
36
36
  onChange: (settings) => {
37
37
  try {
38
38
  saveAdvisorSettings(ctx, settings);
39
+ runtime.updateSameModelNotice(ctx);
39
40
  runtime.updateAdvisorUsageStatus(ctx);
40
41
  } catch (error) {
41
42
  const message =
@@ -65,6 +66,7 @@ export const registerSettingsCommands = (runtime: CommandRuntime) => {
65
66
  name !== "ask_advisor" && name !== "record_advisor_outcome"
66
67
  )
67
68
  );
69
+ runtime.resetSameModelNotice();
68
70
  // Leaving alwaysOn set would silently reactivate the flow next session.
69
71
  const wasAlwaysOn = alwaysOnRef;
70
72
  if (wasAlwaysOn) {
@@ -7,6 +7,7 @@ import {
7
7
  setAdvisorCollapseResponsesRef,
8
8
  setAdvisorCompletionGateRef,
9
9
  setAdvisorCustomInvocationRef,
10
+ setAdvisorDisableSameModelRef,
10
11
  setAdvisorEffortRef,
11
12
  setAdvisorFailureGateRef,
12
13
  setAdvisorFailureModeRef,
@@ -68,6 +69,7 @@ const applySessionSettings = (settings: AdvisorSettings) => {
68
69
  setAdvisorPlanGateRef(settings.planGate);
69
70
  setAdvisorFailureGateRef(settings.failureGate);
70
71
  setAdvisorCompletionGateRef(settings.completionGate);
72
+ setAdvisorDisableSameModelRef(settings.disableSameModel ?? true);
71
73
  setAdvisorCollapseResponsesRef(settings.collapseResponses);
72
74
  setAdvisorCustomInvocationRef(settings.customRule);
73
75
  setAdvisorBlockOnBlockedRef(settings.blockOnBlocked ?? true);
@@ -71,5 +71,10 @@ export interface CommandRuntime {
71
71
  model: Parameters<ExtensionAPI["setModel"]>[0]
72
72
  ) => ReturnType<ExtensionAPI["setModel"]>;
73
73
  suppressModelSelectionSync: boolean;
74
+ resetSameModelNotice: () => void;
75
+ updateSameModelNotice: (
76
+ ctx: ExtensionContext,
77
+ model?: { id: string; provider: string }
78
+ ) => void;
74
79
  updateAdvisorUsageStatus: (ctx: ExtensionContext) => void;
75
80
  }
@@ -4,6 +4,7 @@ import {
4
4
  setAdvisorCollapseResponsesRef,
5
5
  setAdvisorCompletionGateRef,
6
6
  setAdvisorCustomInvocationRef,
7
+ setAdvisorDisableSameModelRef,
7
8
  setAdvisorEffortRef,
8
9
  setAdvisorFailureGateRef,
9
10
  setAdvisorFailureModeRef,
@@ -75,6 +76,7 @@ export const resetDefaults = () => {
75
76
  setAdvisorFailureGateRef(true);
76
77
  setAdvisorCompletionGateRef(true);
77
78
  setAdvisorCustomInvocationRef(undefined);
79
+ setAdvisorDisableSameModelRef(true);
78
80
  setAdvisorCollapseResponsesRef(false);
79
81
  setAdvisorBlockOnBlockedRef(true);
80
82
  setAdvisorAutoLoopGateRef(true);
@@ -150,6 +152,11 @@ export const applyConfig = (config: AdvisorConfig) => {
150
152
  "advisorCustomInvocation",
151
153
  setAdvisorCustomInvocationRef
152
154
  );
155
+ applyOptionalConfig(
156
+ config,
157
+ "advisorDisableSameModel",
158
+ setAdvisorDisableSameModelRef
159
+ );
153
160
  applyOptionalConfig(
154
161
  config,
155
162
  "advisorCollapseResponses",
@@ -7,6 +7,7 @@ import {
7
7
  advisorCollapseResponsesRef,
8
8
  advisorCompletionGateRef,
9
9
  advisorCustomInvocationRef,
10
+ advisorDisableSameModelRef,
10
11
  advisorEffortRef,
11
12
  advisorFailureGateRef,
12
13
  advisorFailureModeRef,
@@ -200,6 +201,12 @@ export const CONFIG_SCHEMA = {
200
201
  persisted: true,
201
202
  type: "string",
202
203
  },
204
+ advisorDisableSameModel: {
205
+ accepted: "true or false",
206
+ current: () => advisorDisableSameModelRef,
207
+ persisted: true,
208
+ type: "boolean",
209
+ },
203
210
  advisorEffort: {
204
211
  accepted: "a string",
205
212
  current: () => advisorEffortRef,
@@ -34,6 +34,7 @@ export let advisorPlanGateRef = true;
34
34
  export let advisorFailureGateRef = true;
35
35
  export let advisorCompletionGateRef = true;
36
36
  export let advisorCustomInvocationRef: string | undefined;
37
+ export let advisorDisableSameModelRef = true;
37
38
  export let advisorCollapseResponsesRef = false;
38
39
  export let advisorBlockOnBlockedRef = true;
39
40
  export let advisorAutoLoopGateRef = true;
@@ -115,6 +116,9 @@ export const setAdvisorCompletionGateRef = (enabled: boolean) => {
115
116
  export const setAdvisorCustomInvocationRef = (rule: string | undefined) => {
116
117
  advisorCustomInvocationRef = rule?.trim() || undefined;
117
118
  };
119
+ export const setAdvisorDisableSameModelRef = (enabled: boolean) => {
120
+ advisorDisableSameModelRef = enabled;
121
+ };
118
122
  export const setAdvisorCollapseResponsesRef = (enabled: boolean) => {
119
123
  advisorCollapseResponsesRef = enabled;
120
124
  };
@@ -237,6 +241,7 @@ export const getAdvisorSettings = () => ({
237
241
  completionGate: advisorCompletionGateRef,
238
242
  contextMaxChars: contextMaxCharsRef,
239
243
  customRule: advisorCustomInvocationRef,
244
+ disableSameModel: advisorDisableSameModelRef,
240
245
  effort: advisorEffortRef,
241
246
  failureGate: advisorFailureGateRef,
242
247
  failureMode: advisorFailureModeRef,
@@ -65,6 +65,7 @@ export interface AdvisorConfig {
65
65
  advisorCollapseResponses?: boolean;
66
66
  advisorCompletionGate?: boolean;
67
67
  advisorCustomInvocation?: string;
68
+ advisorDisableSameModel?: boolean;
68
69
  advisorEffort?: string;
69
70
  advisorFailureGate?: boolean;
70
71
  advisorGitContext?: GitContextLevel;
@@ -9,6 +9,7 @@ import {
9
9
  import type { AdvisorToolPolicies } from "./config/types.ts";
10
10
  import { contentParts, isRecordOf, isString } from "./content-utils.ts";
11
11
  import type { RecordValue } from "./content-utils.ts";
12
+ import { imageMarker } from "./images.ts";
12
13
  import { redactSecrets } from "./redaction.ts";
13
14
  import { capToolResult } from "./tool-result-cap.ts";
14
15
 
@@ -25,6 +26,11 @@ const textFromPart = <Part>(part: Part): string => {
25
26
  export const textFrom = <Content>(content: Content): string =>
26
27
  contentParts(content).map(textFromPart).join("\n").trim();
27
28
 
29
+ const imageNotes = (content: RecordValue["content"], nonce: string) =>
30
+ contentParts(content)
31
+ .map((part) => (isRecordOf(part) ? imageMarker(part, nonce) : undefined))
32
+ .filter(isString);
33
+
28
34
  const assistantEntry = (
29
35
  message: RecordValue,
30
36
  policies: AdvisorToolPolicies,
@@ -64,12 +70,17 @@ const toolResultEntry = (
64
70
  toolResultMaxLines: number,
65
71
  toolResultMaxBytes: number,
66
72
  policies: AdvisorToolPolicies,
67
- redact: boolean
73
+ redact: boolean,
74
+ describeImages: boolean,
75
+ imageNonce: string
68
76
  ): string => {
69
77
  const status = message.isError ? "error" : "success";
70
78
  const toolName = isString(message.toolName) ? message.toolName : "unknown";
71
79
  const policy = policies[toolName] ?? "full";
72
- const source = textFrom(message.content);
80
+ const notes = describeImages ? imageNotes(message.content, imageNonce) : [];
81
+ const source = [textFrom(message.content), ...notes]
82
+ .filter(Boolean)
83
+ .join("\n");
73
84
  if (policy === "exclude") {
74
85
  return `[Tool Result for ${toolName}] (excluded by Advisor tool policy)`;
75
86
  }
@@ -95,7 +106,9 @@ export const conversationEntry = <Entry>(
95
106
  toolResultMaxLines: number,
96
107
  toolResultMaxBytes: number,
97
108
  policies: AdvisorToolPolicies,
98
- redact: boolean
109
+ redact: boolean,
110
+ describeImages = false,
111
+ imageNonce = ""
99
112
  ): string | undefined => {
100
113
  if (!isRecordOf(entry)) {
101
114
  return;
@@ -108,7 +121,10 @@ export const conversationEntry = <Entry>(
108
121
  }
109
122
  const { message } = entry;
110
123
  if (message.role === "user") {
111
- const text = textFrom(message.content);
124
+ const notes = describeImages ? imageNotes(message.content, imageNonce) : [];
125
+ const text = [textFrom(message.content), ...notes]
126
+ .filter(Boolean)
127
+ .join("\n");
112
128
  return text ? `User: ${redact ? redactSecrets(text) : text}` : undefined;
113
129
  }
114
130
  if (message.role === "assistant") {
@@ -120,7 +136,9 @@ export const conversationEntry = <Entry>(
120
136
  toolResultMaxLines,
121
137
  toolResultMaxBytes,
122
138
  policies,
123
- redact
139
+ redact,
140
+ describeImages,
141
+ imageNonce
124
142
  );
125
143
  }
126
144
  };
@@ -179,7 +197,9 @@ export const recentConversation = (
179
197
  toolResultMaxLines = advisorToolResultMaxLinesRef,
180
198
  toolResultMaxBytes = advisorToolResultMaxBytesRef,
181
199
  policies = advisorToolPoliciesRef,
182
- redact = advisorRedactSecretsRef
200
+ redact = advisorRedactSecretsRef,
201
+ describeImages = false,
202
+ imageNonce = ""
183
203
  ): string => {
184
204
  if (maxChars === 0) {
185
205
  return "";
@@ -192,7 +212,9 @@ export const recentConversation = (
192
212
  toolResultMaxLines,
193
213
  toolResultMaxBytes,
194
214
  policies,
195
- redact
215
+ redact,
216
+ describeImages,
217
+ imageNonce
196
218
  )
197
219
  )
198
220
  .filter((entry): entry is string => entry !== undefined);