pi-advisor-flow 0.2.3 → 0.2.6
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/README.md +45 -11
- package/package.json +33 -30
- package/src/commands.ts +141 -34
- package/src/config.ts +167 -8
- package/src/conversation.ts +28 -3
- package/src/git.ts +190 -0
- package/src/outcomes.ts +85 -0
- package/src/preferences.ts +50 -0
- package/src/session-state.ts +36 -1
- package/src/tools.ts +340 -30
- package/src/ui.ts +410 -114
- package/src/untracked.ts +98 -0
package/src/config.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import {
|
|
4
4
|
CONFIG_DIR_NAME,
|
|
@@ -7,6 +7,11 @@ import {
|
|
|
7
7
|
type ExtensionContext,
|
|
8
8
|
getAgentDir,
|
|
9
9
|
} from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import {
|
|
11
|
+
GIT_CONTEXT_LEVELS,
|
|
12
|
+
type GitContextLevel,
|
|
13
|
+
isValidGitContextLevel,
|
|
14
|
+
} from "./git.js";
|
|
10
15
|
|
|
11
16
|
export const FALLBACK_EXECUTOR = "aikeys/claude-sonnet-5";
|
|
12
17
|
export const FALLBACK_ADVISOR = "aikeys/claude-fable-5";
|
|
@@ -14,6 +19,7 @@ export const DEFAULT_CONTEXT_MAX_CHARS = 15_000;
|
|
|
14
19
|
export const MAX_CONTEXT_MAX_CHARS = Number.MAX_SAFE_INTEGER;
|
|
15
20
|
export const DEFAULT_ADVISOR_TOOL_RESULT_MAX_LINES = DEFAULT_MAX_LINES;
|
|
16
21
|
export const DEFAULT_ADVISOR_TOOL_RESULT_MAX_BYTES = DEFAULT_MAX_BYTES;
|
|
22
|
+
export const DEFAULT_ADVISOR_GIT_CONTEXT_MAX_CHARS = 20_000;
|
|
17
23
|
export type AdvisorToolPolicy = "full" | "summary" | "exclude";
|
|
18
24
|
export type AdvisorToolPolicies = Record<string, AdvisorToolPolicy>;
|
|
19
25
|
export const ADVISOR_TOOL_POLICIES: AdvisorToolPolicy[] = [
|
|
@@ -45,13 +51,19 @@ export let advisorBlockOnBlockedRef = true;
|
|
|
45
51
|
export let advisorAutoLoopGateRef = true;
|
|
46
52
|
export let advisorLoopThresholdRef = 3;
|
|
47
53
|
export let advisorMaxCallsPerSessionRef: number | undefined;
|
|
48
|
-
export let advisorSessionSummaryRef =
|
|
54
|
+
export let advisorSessionSummaryRef = false;
|
|
55
|
+
export let simpleModeRef = false;
|
|
56
|
+
export let alwaysOnRef = false;
|
|
49
57
|
export let advisorFailureModeRef: GateFailureMode = "block-session";
|
|
50
58
|
export let advisorHerdrIntegrationRef = true;
|
|
51
59
|
export let advisorToolResultMaxLinesRef = DEFAULT_ADVISOR_TOOL_RESULT_MAX_LINES;
|
|
52
60
|
export let advisorToolResultMaxBytesRef = DEFAULT_ADVISOR_TOOL_RESULT_MAX_BYTES;
|
|
53
61
|
export let advisorRedactSecretsRef = false;
|
|
62
|
+
export let advisorGitContextRef: GitContextLevel = "summary";
|
|
63
|
+
export let advisorGitContextMaxCharsRef = DEFAULT_ADVISOR_GIT_CONTEXT_MAX_CHARS;
|
|
54
64
|
export let advisorToolPoliciesRef: AdvisorToolPolicies = {};
|
|
65
|
+
export let advisorOutcomeLoggingRef = false;
|
|
66
|
+
export let advisorUntrackedContentRef = false;
|
|
55
67
|
|
|
56
68
|
export const setExecutorRef = (ref: string) => {
|
|
57
69
|
executorRef = ref;
|
|
@@ -107,6 +119,14 @@ export const setAdvisorMaxCallsPerSessionRef = (value: number | undefined) => {
|
|
|
107
119
|
export const setAdvisorSessionSummaryRef = (enabled: boolean) => {
|
|
108
120
|
advisorSessionSummaryRef = enabled;
|
|
109
121
|
};
|
|
122
|
+
export const setSimpleModeRef = (enabled: boolean) => {
|
|
123
|
+
simpleModeRef = enabled;
|
|
124
|
+
};
|
|
125
|
+
export const setAlwaysOnRef = (enabled: boolean) => {
|
|
126
|
+
alwaysOnRef = enabled;
|
|
127
|
+
};
|
|
128
|
+
/** Whether advanced Advisor automation is suppressed for this session. */
|
|
129
|
+
export const isSimpleMode = () => simpleModeRef;
|
|
110
130
|
export const isValidGateFailureMode = (
|
|
111
131
|
value: unknown
|
|
112
132
|
): value is GateFailureMode =>
|
|
@@ -131,15 +151,28 @@ export const setAdvisorToolResultMaxBytesRef = (value: number) => {
|
|
|
131
151
|
export const setAdvisorRedactSecretsRef = (enabled: boolean) => {
|
|
132
152
|
advisorRedactSecretsRef = enabled;
|
|
133
153
|
};
|
|
154
|
+
export const setAdvisorGitContextRef = (level: GitContextLevel) => {
|
|
155
|
+
advisorGitContextRef = level;
|
|
156
|
+
};
|
|
157
|
+
export const setAdvisorGitContextMaxCharsRef = (value: number) => {
|
|
158
|
+
advisorGitContextMaxCharsRef = value;
|
|
159
|
+
};
|
|
134
160
|
export const setAdvisorToolPoliciesRef = (policies: AdvisorToolPolicies) => {
|
|
135
161
|
advisorToolPoliciesRef = { ...policies };
|
|
136
162
|
};
|
|
163
|
+
export const setAdvisorOutcomeLoggingRef = (enabled: boolean) => {
|
|
164
|
+
advisorOutcomeLoggingRef = enabled;
|
|
165
|
+
};
|
|
166
|
+
export const setAdvisorUntrackedContentRef = (enabled: boolean) => {
|
|
167
|
+
advisorUntrackedContentRef = enabled;
|
|
168
|
+
};
|
|
137
169
|
|
|
138
170
|
/**
|
|
139
171
|
* Returns the current live settings state. Use this at UI boundaries instead of
|
|
140
172
|
* imported mutable bindings, which can be snapshotted by extension loaders.
|
|
141
173
|
*/
|
|
142
174
|
export const getAdvisorSettings = () => ({
|
|
175
|
+
alwaysOn: alwaysOnRef,
|
|
143
176
|
autoLoopGate: advisorAutoLoopGateRef,
|
|
144
177
|
blockOnBlocked: advisorBlockOnBlockedRef,
|
|
145
178
|
collapseResponses: advisorCollapseResponsesRef,
|
|
@@ -149,15 +182,20 @@ export const getAdvisorSettings = () => ({
|
|
|
149
182
|
effort: advisorEffortRef,
|
|
150
183
|
failureGate: advisorFailureGateRef,
|
|
151
184
|
failureMode: advisorFailureModeRef,
|
|
185
|
+
gitContext: advisorGitContextRef,
|
|
186
|
+
gitContextMaxChars: advisorGitContextMaxCharsRef,
|
|
152
187
|
herdrIntegration: advisorHerdrIntegrationRef,
|
|
153
188
|
loopThreshold: advisorLoopThresholdRef,
|
|
154
189
|
maxCallsPerSession: advisorMaxCallsPerSessionRef,
|
|
190
|
+
outcomeLogging: advisorOutcomeLoggingRef,
|
|
155
191
|
planGate: advisorPlanGateRef,
|
|
156
192
|
redactSecrets: advisorRedactSecretsRef,
|
|
157
193
|
sessionSummary: advisorSessionSummaryRef,
|
|
194
|
+
simpleMode: simpleModeRef,
|
|
158
195
|
toolPolicies: { ...advisorToolPoliciesRef },
|
|
159
196
|
toolResultMaxBytes: advisorToolResultMaxBytesRef,
|
|
160
197
|
toolResultMaxLines: advisorToolResultMaxLinesRef,
|
|
198
|
+
untrackedContent: advisorUntrackedContentRef,
|
|
161
199
|
});
|
|
162
200
|
|
|
163
201
|
export const splitRef = (ref: string): [string, string] => {
|
|
@@ -181,19 +219,25 @@ export interface AdvisorConfig {
|
|
|
181
219
|
advisorCustomInvocation?: string;
|
|
182
220
|
advisorEffort?: string;
|
|
183
221
|
advisorFailureGate?: boolean;
|
|
222
|
+
advisorGitContext?: GitContextLevel;
|
|
223
|
+
advisorGitContextMaxChars?: number;
|
|
184
224
|
advisorHerdrIntegration?: boolean;
|
|
185
225
|
advisorLoopThreshold?: number;
|
|
186
226
|
advisorMaxCallsPerSession?: number;
|
|
227
|
+
advisorOutcomeLogging?: boolean;
|
|
187
228
|
advisorPlanGate?: boolean;
|
|
188
229
|
advisorRedactSecrets?: boolean;
|
|
189
230
|
advisorSessionSummary?: boolean;
|
|
190
231
|
advisorToolPolicies?: AdvisorToolPolicies;
|
|
191
232
|
advisorToolResultMaxBytes?: number;
|
|
192
233
|
advisorToolResultMaxLines?: number;
|
|
234
|
+
advisorUntrackedContent?: boolean;
|
|
235
|
+
alwaysOn?: boolean;
|
|
193
236
|
contextMaxChars?: number;
|
|
194
237
|
executor?: string;
|
|
195
238
|
executorEffort?: string;
|
|
196
239
|
gateFailureMode?: GateFailureMode;
|
|
240
|
+
simpleMode?: boolean;
|
|
197
241
|
}
|
|
198
242
|
|
|
199
243
|
const CONFIG_KEYS = new Set<keyof AdvisorConfig>([
|
|
@@ -205,14 +249,20 @@ const CONFIG_KEYS = new Set<keyof AdvisorConfig>([
|
|
|
205
249
|
"advisorCustomInvocation",
|
|
206
250
|
"advisorEffort",
|
|
207
251
|
"advisorFailureGate",
|
|
252
|
+
"advisorGitContext",
|
|
253
|
+
"advisorGitContextMaxChars",
|
|
208
254
|
"advisorHerdrIntegration",
|
|
209
255
|
"advisorLoopThreshold",
|
|
210
256
|
"advisorMaxCallsPerSession",
|
|
211
257
|
"advisorPlanGate",
|
|
212
258
|
"advisorSessionSummary",
|
|
259
|
+
"simpleMode",
|
|
260
|
+
"alwaysOn",
|
|
213
261
|
"advisorToolResultMaxBytes",
|
|
214
262
|
"advisorToolResultMaxLines",
|
|
215
263
|
"advisorRedactSecrets",
|
|
264
|
+
"advisorUntrackedContent",
|
|
265
|
+
"advisorOutcomeLogging",
|
|
216
266
|
"advisorToolPolicies",
|
|
217
267
|
"contextMaxChars",
|
|
218
268
|
"executor",
|
|
@@ -227,8 +277,12 @@ const BOOLEAN_CONFIG_KEYS = [
|
|
|
227
277
|
"advisorBlockOnBlocked",
|
|
228
278
|
"advisorAutoLoopGate",
|
|
229
279
|
"advisorSessionSummary",
|
|
280
|
+
"simpleMode",
|
|
281
|
+
"alwaysOn",
|
|
230
282
|
"advisorHerdrIntegration",
|
|
231
283
|
"advisorRedactSecrets",
|
|
284
|
+
"advisorUntrackedContent",
|
|
285
|
+
"advisorOutcomeLogging",
|
|
232
286
|
] as const;
|
|
233
287
|
const STRING_CONFIG_KEYS = [
|
|
234
288
|
"executor",
|
|
@@ -329,6 +383,11 @@ const validateNumericValues = (config: ConfigRecord, path: string) => {
|
|
|
329
383
|
isValidToolResultMaxBytes,
|
|
330
384
|
"a non-negative safe integer",
|
|
331
385
|
],
|
|
386
|
+
[
|
|
387
|
+
"advisorGitContextMaxChars",
|
|
388
|
+
isValidToolResultMaxBytes,
|
|
389
|
+
"a non-negative safe integer",
|
|
390
|
+
],
|
|
332
391
|
];
|
|
333
392
|
for (const [key, isValid, description] of numericRules) {
|
|
334
393
|
if (config[key] !== undefined && !isValid(config[key])) {
|
|
@@ -367,6 +426,16 @@ export const validateConfig = (
|
|
|
367
426
|
) {
|
|
368
427
|
invalidConfigValue(path, "gateFailureMode", GATE_FAILURE_MODES.join(", "));
|
|
369
428
|
}
|
|
429
|
+
if (
|
|
430
|
+
config.advisorGitContext !== undefined &&
|
|
431
|
+
!isValidGitContextLevel(config.advisorGitContext)
|
|
432
|
+
) {
|
|
433
|
+
invalidConfigValue(
|
|
434
|
+
path,
|
|
435
|
+
"advisorGitContext",
|
|
436
|
+
GIT_CONTEXT_LEVELS.join(", ")
|
|
437
|
+
);
|
|
438
|
+
}
|
|
370
439
|
return true;
|
|
371
440
|
};
|
|
372
441
|
|
|
@@ -385,13 +454,19 @@ const resetDefaults = () => {
|
|
|
385
454
|
advisorAutoLoopGateRef = true;
|
|
386
455
|
advisorLoopThresholdRef = 3;
|
|
387
456
|
advisorMaxCallsPerSessionRef = undefined;
|
|
388
|
-
advisorSessionSummaryRef =
|
|
457
|
+
advisorSessionSummaryRef = false;
|
|
458
|
+
simpleModeRef = false;
|
|
459
|
+
alwaysOnRef = false;
|
|
389
460
|
advisorFailureModeRef = "block-session";
|
|
390
461
|
advisorHerdrIntegrationRef = true;
|
|
391
462
|
advisorToolResultMaxLinesRef = DEFAULT_ADVISOR_TOOL_RESULT_MAX_LINES;
|
|
392
463
|
advisorToolResultMaxBytesRef = DEFAULT_ADVISOR_TOOL_RESULT_MAX_BYTES;
|
|
393
464
|
advisorRedactSecretsRef = false;
|
|
465
|
+
advisorGitContextRef = "summary";
|
|
466
|
+
advisorGitContextMaxCharsRef = DEFAULT_ADVISOR_GIT_CONTEXT_MAX_CHARS;
|
|
394
467
|
advisorToolPoliciesRef = {};
|
|
468
|
+
advisorOutcomeLoggingRef = false;
|
|
469
|
+
advisorUntrackedContentRef = false;
|
|
395
470
|
};
|
|
396
471
|
|
|
397
472
|
const applyOptionalConfig = <Key extends keyof AdvisorConfig>(
|
|
@@ -458,6 +533,8 @@ const applyConfig = (config: AdvisorConfig) => {
|
|
|
458
533
|
"advisorSessionSummary",
|
|
459
534
|
setAdvisorSessionSummaryRef
|
|
460
535
|
);
|
|
536
|
+
applyOptionalConfig(config, "simpleMode", setSimpleModeRef);
|
|
537
|
+
applyOptionalConfig(config, "alwaysOn", setAlwaysOnRef);
|
|
461
538
|
applyOptionalConfig(config, "gateFailureMode", setAdvisorFailureModeRef);
|
|
462
539
|
applyOptionalConfig(
|
|
463
540
|
config,
|
|
@@ -479,7 +556,18 @@ const applyConfig = (config: AdvisorConfig) => {
|
|
|
479
556
|
"advisorRedactSecrets",
|
|
480
557
|
setAdvisorRedactSecretsRef
|
|
481
558
|
);
|
|
559
|
+
applyOptionalConfig(config, "advisorGitContext", setAdvisorGitContextRef);
|
|
560
|
+
applyOptionalConfig(
|
|
561
|
+
config,
|
|
562
|
+
"advisorGitContextMaxChars",
|
|
563
|
+
setAdvisorGitContextMaxCharsRef
|
|
564
|
+
);
|
|
482
565
|
applyOptionalConfig(config, "advisorToolPolicies", setAdvisorToolPoliciesRef);
|
|
566
|
+
applyOptionalConfig(
|
|
567
|
+
config,
|
|
568
|
+
"advisorUntrackedContent",
|
|
569
|
+
setAdvisorUntrackedContentRef
|
|
570
|
+
);
|
|
483
571
|
};
|
|
484
572
|
|
|
485
573
|
const readConfig = (path: string): AdvisorConfig => {
|
|
@@ -492,19 +580,64 @@ const readConfig = (path: string): AdvisorConfig => {
|
|
|
492
580
|
}
|
|
493
581
|
};
|
|
494
582
|
|
|
583
|
+
// loadConfig runs on every tool call and every consultation. Caching the parsed
|
|
584
|
+
// file by path and stat identity removes that read and parse from the hot path
|
|
585
|
+
// while still applying the full reset-then-apply sequence on each call.
|
|
586
|
+
let configCache:
|
|
587
|
+
| { config: AdvisorConfig; identity: string; path: string }
|
|
588
|
+
| undefined;
|
|
589
|
+
|
|
590
|
+
/** Drops the parsed-configuration cache; the next load re-reads from disk. */
|
|
591
|
+
export const resetConfigCache = () => {
|
|
592
|
+
configCache = undefined;
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Nanosecond timestamps plus size and inode so a same-millisecond or
|
|
597
|
+
* same-size external rewrite cannot be served from the cache. An unstattable
|
|
598
|
+
* path yields a unique value, which always forces a re-read.
|
|
599
|
+
*/
|
|
600
|
+
const configIdentity = (path: string): string => {
|
|
601
|
+
try {
|
|
602
|
+
const stats = statSync(path, { bigint: true });
|
|
603
|
+
return `${stats.mtimeNs}:${stats.ctimeNs}:${stats.size}:${stats.ino}:${stats.dev}`;
|
|
604
|
+
} catch {
|
|
605
|
+
return `unstattable:${process.hrtime.bigint()}`;
|
|
606
|
+
}
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
const readConfigCached = (path: string): AdvisorConfig => {
|
|
610
|
+
const identity = configIdentity(path);
|
|
611
|
+
const cached = configCache;
|
|
612
|
+
if (cached && cached.path === path && cached.identity === identity) {
|
|
613
|
+
return cached.config;
|
|
614
|
+
}
|
|
615
|
+
const config = readConfig(path);
|
|
616
|
+
configCache = { config, identity, path };
|
|
617
|
+
return config;
|
|
618
|
+
};
|
|
619
|
+
|
|
495
620
|
export const loadConfig = (ctx: ExtensionContext) => {
|
|
496
621
|
resetDefaults();
|
|
497
|
-
const
|
|
622
|
+
const paths = configPaths(ctx);
|
|
623
|
+
const path = paths.find(
|
|
498
624
|
(candidate): candidate is string =>
|
|
499
625
|
candidate !== null && existsSync(candidate)
|
|
500
626
|
);
|
|
501
|
-
if (
|
|
502
|
-
|
|
627
|
+
if (path) {
|
|
628
|
+
applyConfig(readConfigCached(path));
|
|
503
629
|
}
|
|
504
|
-
|
|
505
|
-
|
|
630
|
+
// Persistent outcome consent is global-only: project configuration cannot enable it.
|
|
631
|
+
const global = join(getAgentDir(), "advisor.json");
|
|
632
|
+
const globalConfig = existsSync(global)
|
|
633
|
+
? readConfigCached(global)
|
|
634
|
+
: undefined;
|
|
635
|
+
// Never apply this field through project-first configuration selection.
|
|
636
|
+
advisorOutcomeLoggingRef = globalConfig?.advisorOutcomeLogging === true;
|
|
637
|
+
return path ?? null;
|
|
506
638
|
};
|
|
507
639
|
|
|
640
|
+
/** Saves the ordinary project-preferred configuration without outcome consent. */
|
|
508
641
|
export const saveConfig = (ctx: ExtensionContext) => {
|
|
509
642
|
const project = join(ctx.cwd, CONFIG_DIR_NAME, "advisor.json");
|
|
510
643
|
const path =
|
|
@@ -541,15 +674,41 @@ export const saveConfig = (ctx: ExtensionContext) => {
|
|
|
541
674
|
...(advisorMaxCallsPerSessionRef === undefined
|
|
542
675
|
? {}
|
|
543
676
|
: { advisorMaxCallsPerSession: advisorMaxCallsPerSessionRef }),
|
|
677
|
+
advisorGitContext: advisorGitContextRef,
|
|
678
|
+
advisorGitContextMaxChars: advisorGitContextMaxCharsRef,
|
|
544
679
|
advisorHerdrIntegration: advisorHerdrIntegrationRef,
|
|
545
680
|
advisorRedactSecrets: advisorRedactSecretsRef,
|
|
546
681
|
advisorSessionSummary: advisorSessionSummaryRef,
|
|
547
682
|
advisorToolPolicies: advisorToolPoliciesRef,
|
|
548
683
|
advisorToolResultMaxBytes: advisorToolResultMaxBytesRef,
|
|
549
684
|
advisorToolResultMaxLines: advisorToolResultMaxLinesRef,
|
|
685
|
+
advisorUntrackedContent: advisorUntrackedContentRef,
|
|
686
|
+
alwaysOn: alwaysOnRef,
|
|
550
687
|
gateFailureMode: advisorFailureModeRef,
|
|
688
|
+
simpleMode: simpleModeRef,
|
|
551
689
|
};
|
|
552
690
|
writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`);
|
|
691
|
+
resetConfigCache();
|
|
692
|
+
return path;
|
|
693
|
+
};
|
|
694
|
+
|
|
695
|
+
/** Outcome logging is deliberately written only to the global Pi configuration. */
|
|
696
|
+
export const saveGlobalOutcomeLogging = (enabled: boolean) => {
|
|
697
|
+
const path = join(getAgentDir(), "advisor.json");
|
|
698
|
+
let existing: Record<string, unknown> = {};
|
|
699
|
+
try {
|
|
700
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
701
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
702
|
+
existing = parsed as Record<string, unknown>;
|
|
703
|
+
}
|
|
704
|
+
} catch {
|
|
705
|
+
/* create it */
|
|
706
|
+
}
|
|
707
|
+
writeFileSync(
|
|
708
|
+
path,
|
|
709
|
+
`${JSON.stringify({ ...existing, advisorOutcomeLogging: enabled }, null, 2)}\n`
|
|
710
|
+
);
|
|
711
|
+
resetConfigCache();
|
|
553
712
|
return path;
|
|
554
713
|
};
|
|
555
714
|
|
package/src/conversation.ts
CHANGED
|
@@ -59,6 +59,23 @@ export const redactSecrets = (value: string): string => {
|
|
|
59
59
|
return redacted;
|
|
60
60
|
};
|
|
61
61
|
|
|
62
|
+
/** Redacts before a byte-safe cap so no secret fragment survives truncation. */
|
|
63
|
+
export const redactAndCapText = (
|
|
64
|
+
value: string,
|
|
65
|
+
maxBytes: number,
|
|
66
|
+
redact = true
|
|
67
|
+
): string => {
|
|
68
|
+
const source = redact ? redactSecrets(value) : value;
|
|
69
|
+
let result = "";
|
|
70
|
+
for (const character of source) {
|
|
71
|
+
if (byteLength(result + character) > maxBytes) {
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
result += character;
|
|
75
|
+
}
|
|
76
|
+
return result;
|
|
77
|
+
};
|
|
78
|
+
|
|
62
79
|
export interface ToolResultTruncation {
|
|
63
80
|
content: string;
|
|
64
81
|
omittedLines: number;
|
|
@@ -256,10 +273,18 @@ const selectRecentEntries = (entries: string[], maxChars: number): string => {
|
|
|
256
273
|
return joined;
|
|
257
274
|
}
|
|
258
275
|
const selected: string[] = [];
|
|
276
|
+
// Track the joined length instead of re-joining the accumulator each step;
|
|
277
|
+
// long branches with a large maxChars are otherwise quadratic in bytes.
|
|
278
|
+
let selectedLength = 0;
|
|
259
279
|
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
|
|
280
|
+
const entry = entries[index];
|
|
281
|
+
const candidateLength =
|
|
282
|
+
selectedLength +
|
|
283
|
+
entry.length +
|
|
284
|
+
(selected.length === 0 ? 0 : separator.length);
|
|
285
|
+
if (candidateLength <= maxChars || selected.length === 0) {
|
|
286
|
+
selected.unshift(entry);
|
|
287
|
+
selectedLength = candidateLength;
|
|
263
288
|
} else {
|
|
264
289
|
break;
|
|
265
290
|
}
|
package/src/git.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
/** How much repository change context may leave the machine. */
|
|
4
|
+
export type GitContextLevel = "off" | "summary" | "full";
|
|
5
|
+
|
|
6
|
+
export const GIT_CONTEXT_LEVELS: GitContextLevel[] = ["off", "summary", "full"];
|
|
7
|
+
|
|
8
|
+
export const isValidGitContextLevel = (
|
|
9
|
+
value: unknown
|
|
10
|
+
): value is GitContextLevel =>
|
|
11
|
+
GIT_CONTEXT_LEVELS.includes(value as GitContextLevel);
|
|
12
|
+
|
|
13
|
+
const LEVEL_RANK: Record<GitContextLevel, number> = {
|
|
14
|
+
full: 2,
|
|
15
|
+
off: 0,
|
|
16
|
+
summary: 1,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The Executor may request no more repository context than the user configured.
|
|
21
|
+
* A model cannot widen its own disclosure allowance.
|
|
22
|
+
*/
|
|
23
|
+
export const clampGitContextLevel = (
|
|
24
|
+
requested: GitContextLevel,
|
|
25
|
+
allowed: GitContextLevel
|
|
26
|
+
): GitContextLevel =>
|
|
27
|
+
LEVEL_RANK[requested] <= LEVEL_RANK[allowed] ? requested : allowed;
|
|
28
|
+
|
|
29
|
+
export type GitContextStatus =
|
|
30
|
+
| "disabled"
|
|
31
|
+
| "no-changes"
|
|
32
|
+
| "collected"
|
|
33
|
+
| "not-a-repository"
|
|
34
|
+
| "failed";
|
|
35
|
+
|
|
36
|
+
export interface GitContextResult {
|
|
37
|
+
/** Why collection produced nothing, for diagnostics. Never sent verbatim. */
|
|
38
|
+
detail?: string;
|
|
39
|
+
/** The level actually collected after clamping. */
|
|
40
|
+
level: GitContextLevel;
|
|
41
|
+
status: GitContextStatus;
|
|
42
|
+
/** Collected context, not yet escaped or capped. Empty when nothing is disclosed. */
|
|
43
|
+
text: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Repository text is attacker-influenced and is embedded in a tagged region.
|
|
48
|
+
* A path can reproduce a closing tag verbatim: a directory named `x<` holding a
|
|
49
|
+
* file named `repository_changes>` prints as `x</repository_changes>`, which
|
|
50
|
+
* would end the region early and promote the remainder to trusted instructions.
|
|
51
|
+
*/
|
|
52
|
+
export const escapeRepositoryText = (value: string) =>
|
|
53
|
+
value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
54
|
+
|
|
55
|
+
const TRUNCATION_NOTICE =
|
|
56
|
+
"\n[Repository context truncated: it exceeded the configured limit.]";
|
|
57
|
+
|
|
58
|
+
/** Caps the fully rendered region and states plainly that content was dropped. */
|
|
59
|
+
export const capRepositoryContext = (value: string, maxChars: number) => {
|
|
60
|
+
if (value.length <= maxChars) {
|
|
61
|
+
return { text: value, truncated: false };
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
text: `${value.slice(0, Math.max(0, maxChars - TRUNCATION_NOTICE.length))}${TRUNCATION_NOTICE}`,
|
|
65
|
+
truncated: true,
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// git's own empty-tree object, used to diff a repository with no commits yet.
|
|
70
|
+
const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
71
|
+
const GIT_TOTAL_TIMEOUT_MS = 5000;
|
|
72
|
+
const GIT_MAX_BUFFER = 16 * 1024 * 1024;
|
|
73
|
+
|
|
74
|
+
export type GitRunner = (args: string[], cwd: string) => string;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Collection runs several git commands. A per-command timeout alone would let a
|
|
78
|
+
* pathological repository block for the sum of them, so the budget is shared:
|
|
79
|
+
* each command may use only the time remaining before the overall deadline.
|
|
80
|
+
*/
|
|
81
|
+
const deadlineRunner = (): GitRunner => {
|
|
82
|
+
const expiresAt = Date.now() + GIT_TOTAL_TIMEOUT_MS;
|
|
83
|
+
return (args, cwd) => {
|
|
84
|
+
const remaining = expiresAt - Date.now();
|
|
85
|
+
if (remaining <= 0) {
|
|
86
|
+
throw new Error("Git context collection exceeded its time budget.");
|
|
87
|
+
}
|
|
88
|
+
return execFileSync("git", args, {
|
|
89
|
+
cwd,
|
|
90
|
+
encoding: "utf8",
|
|
91
|
+
maxBuffer: GIT_MAX_BUFFER,
|
|
92
|
+
// Never use a shell: arguments are fixed and must not be re-parsed.
|
|
93
|
+
shell: false,
|
|
94
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
95
|
+
timeout: remaining,
|
|
96
|
+
windowsHide: true,
|
|
97
|
+
});
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** Resolves the base revision, falling back to the empty tree before any commit. */
|
|
102
|
+
const diffBase = (run: GitRunner, cwd: string): string => {
|
|
103
|
+
try {
|
|
104
|
+
run(["rev-parse", "--verify", "--quiet", "HEAD"], cwd);
|
|
105
|
+
return "HEAD";
|
|
106
|
+
} catch {
|
|
107
|
+
return EMPTY_TREE;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Collects working-tree changes relative to HEAD, covering staged and unstaged
|
|
113
|
+
* work. Untracked files are reported by name only and never by content.
|
|
114
|
+
*
|
|
115
|
+
* `summary` discloses file names, change status, and line counts. It must not
|
|
116
|
+
* include diff hunk headers: git derives those from surrounding file content,
|
|
117
|
+
* so they can reproduce a secret from a line the change never touched.
|
|
118
|
+
*/
|
|
119
|
+
export const collectGitContext = (
|
|
120
|
+
cwd: string,
|
|
121
|
+
level: GitContextLevel,
|
|
122
|
+
maxChars: number,
|
|
123
|
+
redact: (value: string) => string = (value) => value,
|
|
124
|
+
run: GitRunner = deadlineRunner()
|
|
125
|
+
): GitContextResult => {
|
|
126
|
+
if (level === "off" || maxChars <= 0) {
|
|
127
|
+
return { level: "off", status: "disabled", text: "" };
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
run(["rev-parse", "--is-inside-work-tree"], cwd);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
return {
|
|
133
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
134
|
+
level,
|
|
135
|
+
status: "not-a-repository",
|
|
136
|
+
text: "",
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
const base = diffBase(run, cwd);
|
|
142
|
+
const nameStatus = run(["diff", "--name-status", base], cwd).trim();
|
|
143
|
+
const shortstat = run(["diff", "--shortstat", base], cwd).trim();
|
|
144
|
+
const untracked = run(
|
|
145
|
+
["ls-files", "--others", "--exclude-standard"],
|
|
146
|
+
cwd
|
|
147
|
+
).trim();
|
|
148
|
+
|
|
149
|
+
if (!(nameStatus || untracked)) {
|
|
150
|
+
return { level, status: "no-changes", text: "" };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const sections = [
|
|
154
|
+
"Working-tree changes against the last commit (staged and unstaged).",
|
|
155
|
+
nameStatus ? `Changed files:\n${nameStatus}` : "",
|
|
156
|
+
shortstat ? `Totals: ${shortstat}` : "",
|
|
157
|
+
untracked
|
|
158
|
+
? `Untracked files (names only, contents withheld):\n${untracked}`
|
|
159
|
+
: "",
|
|
160
|
+
];
|
|
161
|
+
|
|
162
|
+
if (level === "full") {
|
|
163
|
+
const patch = run(["diff", base], cwd);
|
|
164
|
+
sections.push(
|
|
165
|
+
patch.trim()
|
|
166
|
+
? `Patch:\n${patch}`
|
|
167
|
+
: "Patch: (no tracked-file content changes)"
|
|
168
|
+
);
|
|
169
|
+
} else {
|
|
170
|
+
sections.push(
|
|
171
|
+
"Full patch withheld by configuration; file contents were not disclosed."
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Redaction runs before the caller caps the region, so a limit can never
|
|
176
|
+
// split a secret and leave a readable fragment behind.
|
|
177
|
+
return {
|
|
178
|
+
level,
|
|
179
|
+
status: "collected",
|
|
180
|
+
text: redact(sections.filter(Boolean).join("\n\n")),
|
|
181
|
+
};
|
|
182
|
+
} catch (error) {
|
|
183
|
+
return {
|
|
184
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
185
|
+
level,
|
|
186
|
+
status: "failed",
|
|
187
|
+
text: "",
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
};
|
package/src/outcomes.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { createHmac, randomBytes } from "node:crypto";
|
|
2
|
+
import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
export type OutcomeAdoption = "followed" | "not-followed" | "unknown";
|
|
7
|
+
export type OutcomeValidation = "passed" | "failed" | "not-run" | "unknown";
|
|
8
|
+
export type OutcomeTrigger =
|
|
9
|
+
| "manual"
|
|
10
|
+
| "executor-requested"
|
|
11
|
+
| "repeated-tool-call";
|
|
12
|
+
export const ADOPTIONS: OutcomeAdoption[] = [
|
|
13
|
+
"followed",
|
|
14
|
+
"not-followed",
|
|
15
|
+
"unknown",
|
|
16
|
+
];
|
|
17
|
+
export const VALIDATIONS: OutcomeValidation[] = [
|
|
18
|
+
"passed",
|
|
19
|
+
"failed",
|
|
20
|
+
"not-run",
|
|
21
|
+
"unknown",
|
|
22
|
+
];
|
|
23
|
+
export interface OutcomeRecord {
|
|
24
|
+
adoption: OutcomeAdoption;
|
|
25
|
+
adviceHash: string;
|
|
26
|
+
timestamp: string;
|
|
27
|
+
trigger: OutcomeTrigger;
|
|
28
|
+
v: 1;
|
|
29
|
+
validationStatus: OutcomeValidation;
|
|
30
|
+
}
|
|
31
|
+
const MAX_LOG_BYTES = 1024 * 1024;
|
|
32
|
+
const statePath = () => join(getAgentDir(), "advisor-outcomes-salt");
|
|
33
|
+
export const outcomeLogPath = () =>
|
|
34
|
+
join(getAgentDir(), "advisor-outcomes.jsonl");
|
|
35
|
+
|
|
36
|
+
const salt = async () => {
|
|
37
|
+
const path = statePath();
|
|
38
|
+
try {
|
|
39
|
+
return await readFile(path);
|
|
40
|
+
} catch {
|
|
41
|
+
await mkdir(getAgentDir(), { mode: 0o700, recursive: true });
|
|
42
|
+
const value = randomBytes(32);
|
|
43
|
+
const temporary = `${path}.${process.pid}.${Date.now()}`;
|
|
44
|
+
await writeFile(temporary, value, { mode: 0o600 });
|
|
45
|
+
await rename(temporary, path);
|
|
46
|
+
await chmod(path, 0o600);
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
export const adviceDigest = (advice: string, key: Buffer) =>
|
|
51
|
+
createHmac("sha256", key).update(advice).digest("hex").slice(0, 16);
|
|
52
|
+
|
|
53
|
+
/** Best-effort, global-only, minimal persistent telemetry. */
|
|
54
|
+
export const appendOutcome = async (
|
|
55
|
+
record: Omit<OutcomeRecord, "adviceHash" | "timestamp" | "v"> & {
|
|
56
|
+
advice: string;
|
|
57
|
+
}
|
|
58
|
+
) => {
|
|
59
|
+
const path = outcomeLogPath();
|
|
60
|
+
await mkdir(getAgentDir(), { mode: 0o700, recursive: true });
|
|
61
|
+
let previous = "";
|
|
62
|
+
try {
|
|
63
|
+
previous = await readFile(path, "utf8");
|
|
64
|
+
} catch {
|
|
65
|
+
/* new log */
|
|
66
|
+
}
|
|
67
|
+
const next: OutcomeRecord = {
|
|
68
|
+
adoption: record.adoption,
|
|
69
|
+
adviceHash: adviceDigest(record.advice, await salt()),
|
|
70
|
+
timestamp: new Date().toISOString(),
|
|
71
|
+
trigger: record.trigger,
|
|
72
|
+
v: 1,
|
|
73
|
+
validationStatus: record.validationStatus,
|
|
74
|
+
};
|
|
75
|
+
const line = `${JSON.stringify(next)}\n`;
|
|
76
|
+
const retained =
|
|
77
|
+
Buffer.byteLength(previous) + Buffer.byteLength(line) > MAX_LOG_BYTES
|
|
78
|
+
? ""
|
|
79
|
+
: previous;
|
|
80
|
+
const temporary = `${path}.${process.pid}.${Date.now()}`;
|
|
81
|
+
await writeFile(temporary, retained + line, { mode: 0o600 });
|
|
82
|
+
await rename(temporary, path);
|
|
83
|
+
await chmod(path, 0o600);
|
|
84
|
+
return next;
|
|
85
|
+
};
|