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.
- package/CHANGELOG.md +8 -1
- package/README.md +6 -5
- package/dist/index.js +631 -76
- package/package.json +1 -1
- package/src/attachments.ts +21 -5
- package/src/commands/activation.ts +6 -1
- package/src/commands/lifecycle.ts +6 -0
- package/src/commands/manual-command.ts +13 -1
- package/src/commands/model-commands.ts +1 -0
- package/src/commands/runtime.ts +29 -0
- package/src/commands/settings-commands.ts +2 -0
- package/src/commands/settings-persistence.ts +2 -0
- package/src/commands/types.ts +5 -0
- package/src/config/defaults.ts +7 -0
- package/src/config/schema.ts +7 -0
- package/src/config/state.ts +5 -0
- package/src/config/types.ts +1 -0
- package/src/conversation.ts +29 -7
- package/src/image-attachments.ts +118 -0
- package/src/image-validation.ts +288 -0
- package/src/images.ts +151 -0
- package/src/scout-context.ts +1 -0
- package/src/scout-curation.ts +11 -4
- package/src/scout-groups.ts +13 -3
- package/src/scout-types.ts +2 -0
- package/src/tools/consult-context.ts +78 -2
- package/src/tools/consultation.ts +36 -4
- package/src/tools/jev-turn-gate.ts +4 -1
- package/src/tools/model-access.ts +21 -2
- package/src/tools/prompts.ts +14 -2
- package/src/tools/register-ask-advisor.ts +28 -1
- package/src/tools/register-lifecycle.ts +8 -2
- package/src/tools/render-advisor-result.ts +24 -8
- package/src/tools/types.ts +9 -0
- package/src/ui/settings-items.ts +17 -1
- package/src/ui/settings-mutations.ts +1 -0
- package/src/ui/types.ts +1 -0
package/package.json
CHANGED
package/src/attachments.ts
CHANGED
|
@@ -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 (
|
|
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 {
|
|
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
|
}
|
|
@@ -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"
|
package/src/commands/runtime.ts
CHANGED
|
@@ -7,6 +7,10 @@ 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";
|
|
12
16
|
import type {
|
|
@@ -55,6 +59,7 @@ class CommandRuntime implements CommandRuntimeContract {
|
|
|
55
59
|
manualProgressSequence = 0;
|
|
56
60
|
pendingExecutorModelRef: string | undefined;
|
|
57
61
|
suppressModelSelectionSync = false;
|
|
62
|
+
private lastSameModelDisabled: boolean | undefined;
|
|
58
63
|
|
|
59
64
|
constructor(pi: ExtensionAPI, dependencies: CommandDependencies = {}) {
|
|
60
65
|
this.pi = pi;
|
|
@@ -84,6 +89,30 @@ class CommandRuntime implements CommandRuntimeContract {
|
|
|
84
89
|
return this.pi.getActiveTools().includes("ask_advisor");
|
|
85
90
|
}
|
|
86
91
|
|
|
92
|
+
resetSameModelNotice() {
|
|
93
|
+
this.lastSameModelDisabled = undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
updateSameModelNotice(
|
|
97
|
+
ctx: ExtensionContext,
|
|
98
|
+
model: { id: string; provider: string } | undefined = ctx.model
|
|
99
|
+
) {
|
|
100
|
+
if (!this.flowEnabled()) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const disabled = sameModelAdvisorDisabled(ctx, model);
|
|
104
|
+
if (disabled && this.lastSameModelDisabled !== true) {
|
|
105
|
+
notify(ctx, sameModelAdvisorNotice, "info");
|
|
106
|
+
} else if (!disabled && this.lastSameModelDisabled === true) {
|
|
107
|
+
notify(
|
|
108
|
+
ctx,
|
|
109
|
+
"Advisor re-enabled: executor and advisor models differ.",
|
|
110
|
+
"info"
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
this.lastSameModelDisabled = disabled;
|
|
114
|
+
}
|
|
115
|
+
|
|
87
116
|
nextManualProgressId() {
|
|
88
117
|
this.manualProgressSequence += 1;
|
|
89
118
|
return `manual-${this.manualProgressSequence}`;
|
|
@@ -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);
|
package/src/commands/types.ts
CHANGED
|
@@ -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
|
}
|
package/src/config/defaults.ts
CHANGED
|
@@ -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",
|
package/src/config/schema.ts
CHANGED
|
@@ -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,
|
package/src/config/state.ts
CHANGED
|
@@ -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,
|
package/src/config/types.ts
CHANGED
|
@@ -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;
|
package/src/conversation.ts
CHANGED
|
@@ -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
|
|
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
|
|
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);
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { lstat, open, realpath } from "node:fs/promises";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
import type { ImageContent } from "@earendil-works/pi-ai/compat";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
imageFileCandidate,
|
|
9
|
+
imageMime,
|
|
10
|
+
isPermitted,
|
|
11
|
+
normalizeRequestedPath,
|
|
12
|
+
repositoryRoot,
|
|
13
|
+
within,
|
|
14
|
+
} from "./attachments.ts";
|
|
15
|
+
import { isString } from "./content-utils.ts";
|
|
16
|
+
import { ADVISOR_IMAGE_MAX_BYTES, imageFromBytes } from "./images.ts";
|
|
17
|
+
|
|
18
|
+
export interface ImageAttachment {
|
|
19
|
+
bytes: number;
|
|
20
|
+
image: ImageContent;
|
|
21
|
+
path: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const readImageAttachment = async (
|
|
25
|
+
root: string,
|
|
26
|
+
name: string,
|
|
27
|
+
mimeType: string,
|
|
28
|
+
remaining: number
|
|
29
|
+
): Promise<ImageAttachment | undefined> => {
|
|
30
|
+
const absolute = resolve(root, name);
|
|
31
|
+
if (!within(root, absolute)) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const stats = await lstat(absolute);
|
|
35
|
+
if (stats.isSymbolicLink() || !stats.isFile()) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const resolved = await realpath(absolute);
|
|
39
|
+
if (!within(root, resolved)) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const flags = constants.O_NOFOLLOW
|
|
43
|
+
? constants.O_RDONLY + constants.O_NOFOLLOW
|
|
44
|
+
: constants.O_RDONLY;
|
|
45
|
+
const file = await open(resolved, flags);
|
|
46
|
+
try {
|
|
47
|
+
const opened = await file.stat();
|
|
48
|
+
if (
|
|
49
|
+
!opened.isFile() ||
|
|
50
|
+
opened.size > ADVISOR_IMAGE_MAX_BYTES ||
|
|
51
|
+
opened.size > remaining
|
|
52
|
+
) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const buffer = Buffer.alloc(opened.size + 1);
|
|
56
|
+
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
|
|
57
|
+
if (bytesRead !== opened.size) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const image = imageFromBytes(buffer.subarray(0, bytesRead), mimeType);
|
|
61
|
+
return image ? { bytes: bytesRead, image, path: name } : undefined;
|
|
62
|
+
} finally {
|
|
63
|
+
await file.close();
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const readImageFiles = async (
|
|
68
|
+
cwd: string,
|
|
69
|
+
requested: string[],
|
|
70
|
+
enabled: boolean,
|
|
71
|
+
kind: "tracked" | "untracked",
|
|
72
|
+
remainingBytes: number,
|
|
73
|
+
remainingCount: number
|
|
74
|
+
): Promise<{ images: ImageAttachment[]; omitted: number }> => {
|
|
75
|
+
const names = Array.isArray(requested)
|
|
76
|
+
? requested.filter((path) => isString(path) && imageFileCandidate(path))
|
|
77
|
+
: [];
|
|
78
|
+
if (!enabled || !names.length) {
|
|
79
|
+
return { images: [], omitted: names.length };
|
|
80
|
+
}
|
|
81
|
+
const root = await repositoryRoot(cwd);
|
|
82
|
+
if (!root) {
|
|
83
|
+
return { images: [], omitted: names.length };
|
|
84
|
+
}
|
|
85
|
+
const seen = new Set<string>();
|
|
86
|
+
const images: ImageAttachment[] = [];
|
|
87
|
+
let omitted = 0;
|
|
88
|
+
let bytes = 0;
|
|
89
|
+
for (const path of names) {
|
|
90
|
+
const name = normalizeRequestedPath(root, path);
|
|
91
|
+
if (name && seen.has(name)) {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (name) {
|
|
95
|
+
seen.add(name);
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
const mime = name && imageMime(name);
|
|
99
|
+
const image =
|
|
100
|
+
name &&
|
|
101
|
+
mime &&
|
|
102
|
+
images.length < remainingCount &&
|
|
103
|
+
remainingBytes - bytes > 0 &&
|
|
104
|
+
isPermitted(root, name, kind)
|
|
105
|
+
? await readImageAttachment(root, name, mime, remainingBytes - bytes)
|
|
106
|
+
: undefined;
|
|
107
|
+
if (image) {
|
|
108
|
+
images.push(image);
|
|
109
|
+
bytes += image.bytes;
|
|
110
|
+
} else {
|
|
111
|
+
omitted += 1;
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
omitted += 1;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { images, omitted };
|
|
118
|
+
};
|