pi-advisor-flow 0.8.3-dev.3 → 0.9.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.
@@ -9,6 +9,7 @@ import {
9
9
  advisorRef,
10
10
  } from "../config/state.ts";
11
11
  import { loadConfig } from "../config/storage.ts";
12
+ import { escapeRepositoryText } from "../git.ts";
12
13
  import type { GitContextLevel } from "../git.ts";
13
14
  import { collectTextStream, resolveConfiguredModel } from "../model-stream.ts";
14
15
  import { redactSecrets } from "../redaction.ts";
@@ -16,7 +17,11 @@ import type { ScoutLifecycleEvent } from "../scout.ts";
16
17
  import type { ConsultationTrigger, GateTrigger } from "../session-state.ts";
17
18
  import { assembleConsultationContext } from "./consult-context.ts";
18
19
  import { parseAutomaticDecision } from "./gate-protocol.ts";
19
- import { advisorModelAccessReason } from "./model-access.ts";
20
+ import {
21
+ advisorModelAccessReason,
22
+ sameModelAdvisorDisabled,
23
+ sameModelAdvisorNotice,
24
+ } from "./model-access.ts";
20
25
  import {
21
26
  ADVISOR_DECISION_SYSTEM,
22
27
  ADVISOR_SYSTEM,
@@ -57,12 +62,18 @@ const collectAdvisorResponse = async (
57
62
  ) => {
58
63
  const { ctx, question, signal, systemPrompt } = options;
59
64
  loadConfig(ctx);
65
+ if (sameModelAdvisorDisabled(ctx)) {
66
+ throw new Error(sameModelAdvisorNotice);
67
+ }
60
68
  const accessReason = advisorModelAccessReason(ctx);
61
69
  if (accessReason) {
62
70
  throw new Error(accessReason);
63
71
  }
64
72
  const resolved = await resolveConfiguredModel(ctx, advisorRef, "Advisor");
65
- const context = await assembleConsultationContext(options);
73
+ const context = await assembleConsultationContext({
74
+ ...options,
75
+ supportsImages: resolved.model.input.includes("image"),
76
+ });
66
77
 
67
78
  // Keep the original question available to local UI callers, but never send
68
79
  // its credential-shaped values to the provider when redaction is enabled.
@@ -70,11 +81,17 @@ const collectAdvisorResponse = async (
70
81
  advisorRedactSecretsRef && question !== undefined
71
82
  ? redactSecrets(question)
72
83
  : question;
84
+ const imageNotice =
85
+ context.imageOmissions ||
86
+ context.images.length ||
87
+ /\[Image[ :]/u.test(context.conversation)
88
+ ? `\n\nImage disclosure: ${context.supportsImages ? `${context.images.length} image(s) attached below; ${context.imageOmissions} image(s) withheld by format, consent, or size/count limits` : "Advisor model does not support image input; no pixels were forwarded"}. Only the images explicitly attached below have pixels available. Other image markers, image paths, and text descriptions are not visual evidence.`
89
+ : "";
73
90
  const messages: Message[] = [
74
91
  {
75
92
  content: [
76
93
  {
77
- text: advisorMessageText(
94
+ text: `${advisorMessageText(
78
95
  context.conversation,
79
96
  outboundQuestion,
80
97
  context.changeText,
@@ -82,9 +99,16 @@ const collectAdvisorResponse = async (
82
99
  context.preferences?.text,
83
100
  context.untracked.map(fileTag),
84
101
  context.tracked.map(fileTag)
85
- ),
102
+ )}${imageNotice}`,
86
103
  type: "text",
87
104
  },
105
+ ...context.images.flatMap(({ image, label }) => [
106
+ {
107
+ text: `\n\n${escapeRepositoryText(label)}: attached image pixels (untrusted data).`,
108
+ type: "text" as const,
109
+ },
110
+ image,
111
+ ]),
88
112
  ],
89
113
  role: "user",
90
114
  timestamp: Date.now(),
@@ -106,6 +130,14 @@ const collectAdvisorResponse = async (
106
130
  draftBytes: context.draftText
107
131
  ? Buffer.byteLength(context.draftText, "utf-8")
108
132
  : undefined,
133
+ imageBytes:
134
+ context.images.reduce(
135
+ (sum, item) => sum + Buffer.from(item.image.data, "base64").length,
136
+ 0
137
+ ) || undefined,
138
+ imageCount: context.images.length,
139
+ imageOmissions: context.imageOmissions || undefined,
140
+ imagePartsSeen: context.imagePartsSeen || undefined,
109
141
  markdown,
110
142
  model: advisorRef,
111
143
  preferenceBytes: context.preferences?.bytes,
@@ -145,7 +145,10 @@ export const handleJevTurnEnd = async (
145
145
  result.answers,
146
146
  advisorJevTurnGateNoulThresholdRef
147
147
  );
148
- if (!consumeTurnGateBudget(session, shouldConsult)) {
148
+ if (
149
+ !advisorModelIsAllowed(ctx) ||
150
+ !consumeTurnGateBudget(session, shouldConsult)
151
+ ) {
149
152
  return;
150
153
  }
151
154
  const herdrActivity = registration.herdrActivity ?? herdrAdvisorActivity;
@@ -1,6 +1,11 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
 
3
- import { advisorModelWhitelistRef } from "../config/state.ts";
3
+ import {
4
+ advisorDisableSameModelRef,
5
+ advisorModelWhitelistRef,
6
+ advisorRef,
7
+ splitRef,
8
+ } from "../config/state.ts";
4
9
 
5
10
  type AdvisorModelAccess =
6
11
  | { allowed: true; modelRef?: string }
@@ -34,9 +39,23 @@ export const advisorModelAccess = (
34
39
  return denial;
35
40
  };
36
41
 
42
+ export const sameModelAdvisorDisabled = (
43
+ ctx: Pick<ExtensionContext, "model">,
44
+ model: { id: string; provider: string } | undefined = ctx.model
45
+ ): boolean => {
46
+ if (!(advisorDisableSameModelRef && advisorRef && model)) {
47
+ return false;
48
+ }
49
+ const [provider, id] = splitRef(advisorRef);
50
+ return provider === model.provider && id === model.id;
51
+ };
52
+
53
+ export const sameModelAdvisorNotice =
54
+ "Advisor disabled: executor and advisor are the same model.";
55
+
37
56
  export const advisorModelIsAllowed = (
38
57
  ctx: Pick<ExtensionContext, "model">
39
- ): boolean => advisorModelAccess(ctx).allowed;
58
+ ): boolean => advisorModelAccess(ctx).allowed && !sameModelAdvisorDisabled(ctx);
40
59
 
41
60
  export const advisorModelAccessReason = (
42
61
  ctx: Pick<ExtensionContext, "model">
@@ -97,8 +97,20 @@ export const advisorRepositoryContext = (
97
97
  */
98
98
  export const advisorRequestConversation = (
99
99
  ctx: ExtensionContext,
100
- maxChars = contextMaxCharsRef
101
- ) => recentConversation(ctx, maxChars);
100
+ maxChars = contextMaxCharsRef,
101
+ describeImages = false,
102
+ imageNonce = ""
103
+ ) =>
104
+ recentConversation(
105
+ ctx,
106
+ maxChars,
107
+ undefined,
108
+ undefined,
109
+ undefined,
110
+ undefined,
111
+ describeImages,
112
+ imageNonce
113
+ );
102
114
 
103
115
  export const advisorInvocationGuidelines = () => {
104
116
  if (isSimpleMode()) {
@@ -10,6 +10,7 @@ import {
10
10
  getAdvisorSettings,
11
11
  isSimpleMode,
12
12
  } from "../config/state.ts";
13
+ import { loadConfig } from "../config/storage.ts";
13
14
  import { notifyHerdrAdvisorFailure } from "../herdr.ts";
14
15
  import {
15
16
  ADVISOR_STREAM_UPDATE_INTERVAL_MS,
@@ -22,7 +23,11 @@ import {
22
23
  } from "../usage.ts";
23
24
  import { notifyLocalFailure, updateAdvisorUsageStatus } from "./gate-policy.ts";
24
25
  import { normalizeScreeningQuestion, screeningSkipText } from "./jev-filter.ts";
25
- import { advisorModelAccessReason } from "./model-access.ts";
26
+ import {
27
+ advisorModelAccessReason,
28
+ sameModelAdvisorDisabled,
29
+ sameModelAdvisorNotice,
30
+ } from "./model-access.ts";
26
31
  import { renderAdvisorResult } from "./render-advisor-result.ts";
27
32
  import {
28
33
  renderAdvisorCallBox,
@@ -73,7 +78,22 @@ export const registerAskAdvisorTool = ({
73
78
  description:
74
79
  "Consult the on-demand Advisor model for strategic guidance. Call with an empty object for a contextual review; attach an optional draft for concrete plan or completion review. If the Advisor explicitly names a missing file, you may make a sequential follow-up call with includeTrackedFiles when enabled and relevant.",
75
80
  async execute(_id, params, signal, onUpdate, ctx) {
81
+ const skipSameModel = () => {
82
+ session.releaseCall(_id);
83
+ reservedCalls.delete(_id);
84
+ return {
85
+ content: [{ text: sameModelAdvisorNotice, type: "text" as const }],
86
+ details: {
87
+ skipReason: sameModelAdvisorNotice,
88
+ text: sameModelAdvisorNotice,
89
+ },
90
+ };
91
+ };
76
92
  try {
93
+ loadConfig(ctx);
94
+ if (sameModelAdvisorDisabled(ctx)) {
95
+ return skipSameModel();
96
+ }
77
97
  assertAdvisorModelAccess(ctx);
78
98
  } catch (error) {
79
99
  session.releaseCall(_id);
@@ -128,6 +148,9 @@ export const registerAskAdvisorTool = ({
128
148
  },
129
149
  };
130
150
  }
151
+ if (sameModelAdvisorDisabled(ctx)) {
152
+ return skipSameModel();
153
+ }
131
154
  if (
132
155
  !simpleMode &&
133
156
  !session.canConsult(getAdvisorMaxCallsPerSession(), _id)
@@ -217,6 +240,10 @@ export const registerAskAdvisorTool = ({
217
240
  adviceId: result.adviceId,
218
241
  advisor: result.model,
219
242
  draftBytes: result.draftBytes,
243
+ imageBytes: result.imageBytes,
244
+ imageCount: result.imageCount,
245
+ imageOmissions: result.imageOmissions,
246
+ imagePartsSeen: result.imagePartsSeen,
220
247
  preferenceBytes: result.preferenceBytes,
221
248
  question: resolveAdvisorRequest(params.question),
222
249
  scout: scoutDetails,
@@ -6,7 +6,10 @@ import {
6
6
  import { loadConfig } from "../config/storage.ts";
7
7
  import { reserveAdvisorCall } from "./gate-policy.ts";
8
8
  import { handleAutomaticGate } from "./loop-gate.ts";
9
- import { advisorModelAccess } from "./model-access.ts";
9
+ import {
10
+ advisorModelAccess,
11
+ sameModelAdvisorDisabled,
12
+ } from "./model-access.ts";
10
13
  import { advisorInvocationGuidelines } from "./prompts.ts";
11
14
  import type { ToolRegistrationContext } from "./types.ts";
12
15
 
@@ -73,7 +76,7 @@ export const registerToolLifecycle = ({
73
76
  return;
74
77
  }
75
78
  loadConfig(ctx);
76
- if (!advisorModelAccess(ctx).allowed) {
79
+ if (!advisorModelAccess(ctx).allowed || sameModelAdvisorDisabled(ctx)) {
77
80
  return;
78
81
  }
79
82
  const guidelines = advisorInvocationGuidelines();
@@ -107,6 +110,9 @@ export const registerToolLifecycle = ({
107
110
  // tool errors; every other tool must proceed without Advisor gating.
108
111
  return;
109
112
  }
113
+ if (sameModelAdvisorDisabled(ctx)) {
114
+ return;
115
+ }
110
116
  const accessBlock = modelAccessBlock(event.toolName, ctx);
111
117
  if (accessBlock) {
112
118
  return accessBlock;
@@ -30,23 +30,39 @@ const syncRenderPhase = (context: AdvisorToolContext, phase: string) => {
30
30
  context.state.phase = phase;
31
31
  };
32
32
 
33
+ const formatByteSize = (bytes: number): string => {
34
+ if (bytes >= 1024 ** 3) {
35
+ return `${(bytes / 1024 ** 3).toFixed(1)} GiB`;
36
+ }
37
+ if (bytes >= 1024 ** 2) {
38
+ return `${(bytes / 1024 ** 2).toFixed(1)} MiB`;
39
+ }
40
+ if (bytes >= 1024) {
41
+ return `${Math.round(bytes / 1024)} KiB`;
42
+ }
43
+ return `${bytes} B`;
44
+ };
45
+
33
46
  const attachmentLabels = (details: AdvisorToolDetails | undefined) =>
34
47
  [
35
48
  details?.draftBytes
36
- ? `Draft attached · ${details.draftBytes} B`
49
+ ? `Draft attached · ${formatByteSize(details.draftBytes)}`
37
50
  : undefined,
38
51
  details?.preferenceBytes
39
- ? `Project preferences attached · ${details.preferenceBytes} B`
52
+ ? `Project preferences attached · ${formatByteSize(details.preferenceBytes)}`
40
53
  : undefined,
41
54
  details?.trackedBytes
42
- ? `Tracked files attached · ${details.trackedBytes} B`
55
+ ? `Tracked files attached · ${formatByteSize(details.trackedBytes)}`
43
56
  : undefined,
44
57
  details?.untrackedBytes
45
- ? `Untracked files attached · ${details.untrackedBytes} B`
58
+ ? `Untracked files attached · ${formatByteSize(details.untrackedBytes)}`
59
+ : undefined,
60
+ details?.imageCount
61
+ ? `${details.imageCount} image${details.imageCount === 1 ? "" : "s"} attached${details.imageBytes ? ` · ${formatByteSize(details.imageBytes)}` : ""}${details.imageOmissions ? ` · ${details.imageOmissions} withheld` : ""}`
46
62
  : undefined,
47
63
  ].filter((label): label is string => label !== undefined);
48
64
 
49
- const renderJevSkipBox = (
65
+ const renderSkipBox = (
50
66
  box: Box,
51
67
  result: AgentToolResult<AdvisorToolDetails>,
52
68
  expanded: boolean,
@@ -55,7 +71,7 @@ const renderJevSkipBox = (
55
71
  const details = advisorResultDetails(result);
56
72
  const lines = [
57
73
  theme.fg("dim", theme.bold("◆ ADVISOR · SKIPPED")),
58
- theme.fg("dim", ` ${details?.jev?.reason ?? ""}`),
74
+ theme.fg("dim", ` ${details?.skipReason ?? details?.jev?.reason ?? ""}`),
59
75
  ];
60
76
  box.addChild(new Text(lines.join("\n"), 0, 0));
61
77
  box.addChild(
@@ -157,8 +173,8 @@ const renderFinalAdvisorResult = (
157
173
  context.state.timerId = undefined;
158
174
  }
159
175
  const details = advisorResultDetails(result);
160
- if (details?.jev?.skipped) {
161
- renderJevSkipBox(box, result, expanded, theme);
176
+ if (details?.jev?.skipped || details?.skipReason) {
177
+ renderSkipBox(box, result, expanded, theme);
162
178
  return;
163
179
  }
164
180
  if (details?.scout) {
@@ -47,6 +47,10 @@ export interface AdvisorGateFailure {
47
47
  export interface AdvisorConsultationResult {
48
48
  adviceId: string;
49
49
  draftBytes?: number;
50
+ imageBytes?: number;
51
+ imageCount?: number;
52
+ imageOmissions?: number;
53
+ imagePartsSeen?: number;
50
54
  markdown: string;
51
55
  model: string;
52
56
  preferenceBytes?: number;
@@ -83,6 +87,10 @@ export interface AdvisorToolDetails {
83
87
  adviceId?: string;
84
88
  advisor?: string;
85
89
  draftBytes?: number;
90
+ imageBytes?: number;
91
+ imageCount?: number;
92
+ imageOmissions?: number;
93
+ imagePartsSeen?: number;
86
94
  jev?: {
87
95
  kind: JevSkipKind;
88
96
  reason: string;
@@ -91,6 +99,7 @@ export interface AdvisorToolDetails {
91
99
  preferenceBytes?: number;
92
100
  question?: string;
93
101
  scout?: ScoutToolDetails;
102
+ skipReason?: string;
94
103
  text?: string;
95
104
  thinking?: string;
96
105
  trackedBytes?: number;
@@ -284,7 +284,16 @@ export const createSettingsItems = ({
284
284
  },
285
285
  ];
286
286
  if (settings.simpleMode) {
287
- items.push(modelWhitelist);
287
+ items.push(
288
+ modelWhitelist,
289
+ toggle(
290
+ "disableSameModel",
291
+ "Disable same-model Advisor",
292
+ "Skip advice when the active Executor and Advisor use the same provider/model; turn off to allow higher-effort same-model reviews.",
293
+ settings.disableSameModel,
294
+ true
295
+ )
296
+ );
288
297
  return items;
289
298
  }
290
299
 
@@ -297,6 +306,13 @@ export const createSettingsItems = ({
297
306
  values: withCurrentValue(currentEffort(settings.effort), effortLevels),
298
307
  },
299
308
  modelWhitelist,
309
+ toggle(
310
+ "disableSameModel",
311
+ "Disable same-model Advisor",
312
+ "Skip advice when the active Executor and Advisor use the same provider/model; turn off to allow higher-effort same-model reviews.",
313
+ settings.disableSameModel,
314
+ true
315
+ ),
300
316
  toggle(
301
317
  "scoutEnabled",
302
318
  "Experimental Advisor Scout",
@@ -6,6 +6,7 @@ const BOOLEAN_SETTING_FIELDS = [
6
6
  "blockOnBlocked",
7
7
  "collapseResponses",
8
8
  "completionGate",
9
+ "disableSameModel",
9
10
  "failureGate",
10
11
  "herdrIntegration",
11
12
  "outcomeLogging",
package/src/ui/types.ts CHANGED
@@ -74,6 +74,7 @@ export interface AdvisorSettings {
74
74
  completionGate: boolean;
75
75
  contextMaxChars: number;
76
76
  customRule?: string;
77
+ disableSameModel?: boolean;
77
78
  effort?: string;
78
79
  failureGate: boolean;
79
80
  failureMode?: GateFailureMode;