pi-advisor-flow 0.2.4 → 0.2.7
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 +232 -0
- package/README.md +37 -9
- package/package.json +48 -29
- package/src/commands.ts +34 -4
- package/src/config.ts +112 -22
- package/src/conversation.ts +34 -1
- package/src/git.ts +190 -0
- package/src/herdr.ts +9 -9
- package/src/outcomes.ts +85 -0
- package/src/preferences.ts +50 -0
- package/src/session-state.ts +36 -1
- package/src/tools.ts +307 -33
- package/src/ui.ts +104 -24
- package/src/untracked.ts +121 -0
package/src/config.ts
CHANGED
|
@@ -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[] = [
|
|
@@ -53,7 +59,11 @@ export let advisorHerdrIntegrationRef = true;
|
|
|
53
59
|
export let advisorToolResultMaxLinesRef = DEFAULT_ADVISOR_TOOL_RESULT_MAX_LINES;
|
|
54
60
|
export let advisorToolResultMaxBytesRef = DEFAULT_ADVISOR_TOOL_RESULT_MAX_BYTES;
|
|
55
61
|
export let advisorRedactSecretsRef = false;
|
|
62
|
+
export let advisorGitContextRef: GitContextLevel = "summary";
|
|
63
|
+
export let advisorGitContextMaxCharsRef = DEFAULT_ADVISOR_GIT_CONTEXT_MAX_CHARS;
|
|
56
64
|
export let advisorToolPoliciesRef: AdvisorToolPolicies = {};
|
|
65
|
+
export let advisorOutcomeLoggingRef = false;
|
|
66
|
+
export let advisorUntrackedContentRef = false;
|
|
57
67
|
|
|
58
68
|
export const setExecutorRef = (ref: string) => {
|
|
59
69
|
executorRef = ref;
|
|
@@ -141,9 +151,21 @@ export const setAdvisorToolResultMaxBytesRef = (value: number) => {
|
|
|
141
151
|
export const setAdvisorRedactSecretsRef = (enabled: boolean) => {
|
|
142
152
|
advisorRedactSecretsRef = enabled;
|
|
143
153
|
};
|
|
154
|
+
export const setAdvisorGitContextRef = (level: GitContextLevel) => {
|
|
155
|
+
advisorGitContextRef = level;
|
|
156
|
+
};
|
|
157
|
+
export const setAdvisorGitContextMaxCharsRef = (value: number) => {
|
|
158
|
+
advisorGitContextMaxCharsRef = value;
|
|
159
|
+
};
|
|
144
160
|
export const setAdvisorToolPoliciesRef = (policies: AdvisorToolPolicies) => {
|
|
145
161
|
advisorToolPoliciesRef = { ...policies };
|
|
146
162
|
};
|
|
163
|
+
export const setAdvisorOutcomeLoggingRef = (enabled: boolean) => {
|
|
164
|
+
advisorOutcomeLoggingRef = enabled;
|
|
165
|
+
};
|
|
166
|
+
export const setAdvisorUntrackedContentRef = (enabled: boolean) => {
|
|
167
|
+
advisorUntrackedContentRef = enabled;
|
|
168
|
+
};
|
|
147
169
|
|
|
148
170
|
/**
|
|
149
171
|
* Returns the current live settings state. Use this at UI boundaries instead of
|
|
@@ -160,9 +182,12 @@ export const getAdvisorSettings = () => ({
|
|
|
160
182
|
effort: advisorEffortRef,
|
|
161
183
|
failureGate: advisorFailureGateRef,
|
|
162
184
|
failureMode: advisorFailureModeRef,
|
|
185
|
+
gitContext: advisorGitContextRef,
|
|
186
|
+
gitContextMaxChars: advisorGitContextMaxCharsRef,
|
|
163
187
|
herdrIntegration: advisorHerdrIntegrationRef,
|
|
164
188
|
loopThreshold: advisorLoopThresholdRef,
|
|
165
189
|
maxCallsPerSession: advisorMaxCallsPerSessionRef,
|
|
190
|
+
outcomeLogging: advisorOutcomeLoggingRef,
|
|
166
191
|
planGate: advisorPlanGateRef,
|
|
167
192
|
redactSecrets: advisorRedactSecretsRef,
|
|
168
193
|
sessionSummary: advisorSessionSummaryRef,
|
|
@@ -170,6 +195,7 @@ export const getAdvisorSettings = () => ({
|
|
|
170
195
|
toolPolicies: { ...advisorToolPoliciesRef },
|
|
171
196
|
toolResultMaxBytes: advisorToolResultMaxBytesRef,
|
|
172
197
|
toolResultMaxLines: advisorToolResultMaxLinesRef,
|
|
198
|
+
untrackedContent: advisorUntrackedContentRef,
|
|
173
199
|
});
|
|
174
200
|
|
|
175
201
|
export const splitRef = (ref: string): [string, string] => {
|
|
@@ -193,15 +219,19 @@ export interface AdvisorConfig {
|
|
|
193
219
|
advisorCustomInvocation?: string;
|
|
194
220
|
advisorEffort?: string;
|
|
195
221
|
advisorFailureGate?: boolean;
|
|
222
|
+
advisorGitContext?: GitContextLevel;
|
|
223
|
+
advisorGitContextMaxChars?: number;
|
|
196
224
|
advisorHerdrIntegration?: boolean;
|
|
197
225
|
advisorLoopThreshold?: number;
|
|
198
226
|
advisorMaxCallsPerSession?: number;
|
|
227
|
+
advisorOutcomeLogging?: boolean;
|
|
199
228
|
advisorPlanGate?: boolean;
|
|
200
229
|
advisorRedactSecrets?: boolean;
|
|
201
230
|
advisorSessionSummary?: boolean;
|
|
202
231
|
advisorToolPolicies?: AdvisorToolPolicies;
|
|
203
232
|
advisorToolResultMaxBytes?: number;
|
|
204
233
|
advisorToolResultMaxLines?: number;
|
|
234
|
+
advisorUntrackedContent?: boolean;
|
|
205
235
|
alwaysOn?: boolean;
|
|
206
236
|
contextMaxChars?: number;
|
|
207
237
|
executor?: string;
|
|
@@ -219,6 +249,8 @@ const CONFIG_KEYS = new Set<keyof AdvisorConfig>([
|
|
|
219
249
|
"advisorCustomInvocation",
|
|
220
250
|
"advisorEffort",
|
|
221
251
|
"advisorFailureGate",
|
|
252
|
+
"advisorGitContext",
|
|
253
|
+
"advisorGitContextMaxChars",
|
|
222
254
|
"advisorHerdrIntegration",
|
|
223
255
|
"advisorLoopThreshold",
|
|
224
256
|
"advisorMaxCallsPerSession",
|
|
@@ -229,6 +261,8 @@ const CONFIG_KEYS = new Set<keyof AdvisorConfig>([
|
|
|
229
261
|
"advisorToolResultMaxBytes",
|
|
230
262
|
"advisorToolResultMaxLines",
|
|
231
263
|
"advisorRedactSecrets",
|
|
264
|
+
"advisorUntrackedContent",
|
|
265
|
+
"advisorOutcomeLogging",
|
|
232
266
|
"advisorToolPolicies",
|
|
233
267
|
"contextMaxChars",
|
|
234
268
|
"executor",
|
|
@@ -247,6 +281,8 @@ const BOOLEAN_CONFIG_KEYS = [
|
|
|
247
281
|
"alwaysOn",
|
|
248
282
|
"advisorHerdrIntegration",
|
|
249
283
|
"advisorRedactSecrets",
|
|
284
|
+
"advisorUntrackedContent",
|
|
285
|
+
"advisorOutcomeLogging",
|
|
250
286
|
] as const;
|
|
251
287
|
const STRING_CONFIG_KEYS = [
|
|
252
288
|
"executor",
|
|
@@ -347,6 +383,11 @@ const validateNumericValues = (config: ConfigRecord, path: string) => {
|
|
|
347
383
|
isValidToolResultMaxBytes,
|
|
348
384
|
"a non-negative safe integer",
|
|
349
385
|
],
|
|
386
|
+
[
|
|
387
|
+
"advisorGitContextMaxChars",
|
|
388
|
+
isValidToolResultMaxBytes,
|
|
389
|
+
"a non-negative safe integer",
|
|
390
|
+
],
|
|
350
391
|
];
|
|
351
392
|
for (const [key, isValid, description] of numericRules) {
|
|
352
393
|
if (config[key] !== undefined && !isValid(config[key])) {
|
|
@@ -385,6 +426,16 @@ export const validateConfig = (
|
|
|
385
426
|
) {
|
|
386
427
|
invalidConfigValue(path, "gateFailureMode", GATE_FAILURE_MODES.join(", "));
|
|
387
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
|
+
}
|
|
388
439
|
return true;
|
|
389
440
|
};
|
|
390
441
|
|
|
@@ -411,7 +462,11 @@ const resetDefaults = () => {
|
|
|
411
462
|
advisorToolResultMaxLinesRef = DEFAULT_ADVISOR_TOOL_RESULT_MAX_LINES;
|
|
412
463
|
advisorToolResultMaxBytesRef = DEFAULT_ADVISOR_TOOL_RESULT_MAX_BYTES;
|
|
413
464
|
advisorRedactSecretsRef = false;
|
|
465
|
+
advisorGitContextRef = "summary";
|
|
466
|
+
advisorGitContextMaxCharsRef = DEFAULT_ADVISOR_GIT_CONTEXT_MAX_CHARS;
|
|
414
467
|
advisorToolPoliciesRef = {};
|
|
468
|
+
advisorOutcomeLoggingRef = false;
|
|
469
|
+
advisorUntrackedContentRef = false;
|
|
415
470
|
};
|
|
416
471
|
|
|
417
472
|
const applyOptionalConfig = <Key extends keyof AdvisorConfig>(
|
|
@@ -501,7 +556,18 @@ const applyConfig = (config: AdvisorConfig) => {
|
|
|
501
556
|
"advisorRedactSecrets",
|
|
502
557
|
setAdvisorRedactSecretsRef
|
|
503
558
|
);
|
|
559
|
+
applyOptionalConfig(config, "advisorGitContext", setAdvisorGitContextRef);
|
|
560
|
+
applyOptionalConfig(
|
|
561
|
+
config,
|
|
562
|
+
"advisorGitContextMaxChars",
|
|
563
|
+
setAdvisorGitContextMaxCharsRef
|
|
564
|
+
);
|
|
504
565
|
applyOptionalConfig(config, "advisorToolPolicies", setAdvisorToolPoliciesRef);
|
|
566
|
+
applyOptionalConfig(
|
|
567
|
+
config,
|
|
568
|
+
"advisorUntrackedContent",
|
|
569
|
+
setAdvisorUntrackedContentRef
|
|
570
|
+
);
|
|
505
571
|
};
|
|
506
572
|
|
|
507
573
|
const readConfig = (path: string): AdvisorConfig => {
|
|
@@ -517,13 +583,14 @@ const readConfig = (path: string): AdvisorConfig => {
|
|
|
517
583
|
// loadConfig runs on every tool call and every consultation. Caching the parsed
|
|
518
584
|
// file by path and stat identity removes that read and parse from the hot path
|
|
519
585
|
// while still applying the full reset-then-apply sequence on each call.
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
586
|
+
const configCache = new Map<
|
|
587
|
+
string,
|
|
588
|
+
{ config: AdvisorConfig; identity: string }
|
|
589
|
+
>();
|
|
523
590
|
|
|
524
591
|
/** Drops the parsed-configuration cache; the next load re-reads from disk. */
|
|
525
592
|
export const resetConfigCache = () => {
|
|
526
|
-
configCache
|
|
593
|
+
configCache.clear();
|
|
527
594
|
};
|
|
528
595
|
|
|
529
596
|
/**
|
|
@@ -542,34 +609,34 @@ const configIdentity = (path: string): string => {
|
|
|
542
609
|
|
|
543
610
|
const readConfigCached = (path: string): AdvisorConfig => {
|
|
544
611
|
const identity = configIdentity(path);
|
|
545
|
-
const cached = configCache;
|
|
546
|
-
if (cached
|
|
612
|
+
const cached = configCache.get(path);
|
|
613
|
+
if (cached?.identity === identity) {
|
|
547
614
|
return cached.config;
|
|
548
615
|
}
|
|
549
616
|
const config = readConfig(path);
|
|
550
|
-
configCache
|
|
617
|
+
configCache.set(path, { config, identity });
|
|
551
618
|
return config;
|
|
552
619
|
};
|
|
553
620
|
|
|
554
|
-
export const loadConfig = (
|
|
621
|
+
export const loadConfig = (_ctx: ExtensionContext) => {
|
|
555
622
|
resetDefaults();
|
|
556
|
-
const
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
if (
|
|
561
|
-
|
|
623
|
+
const global = join(getAgentDir(), "advisor.json");
|
|
624
|
+
const globalConfig = existsSync(global)
|
|
625
|
+
? readConfigCached(global)
|
|
626
|
+
: undefined;
|
|
627
|
+
if (globalConfig) {
|
|
628
|
+
applyConfig(globalConfig);
|
|
562
629
|
}
|
|
563
|
-
|
|
564
|
-
|
|
630
|
+
// Repository-controlled project configuration is never applied. Models,
|
|
631
|
+
// prompts, gates, budgets, disclosure, redaction, integrations, and consent
|
|
632
|
+
// remain under the user's global Pi configuration.
|
|
633
|
+
advisorOutcomeLoggingRef = globalConfig?.advisorOutcomeLogging === true;
|
|
634
|
+
return existsSync(global) ? global : null;
|
|
565
635
|
};
|
|
566
636
|
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
const path =
|
|
570
|
-
ctx.isProjectTrusted() && existsSync(project)
|
|
571
|
-
? project
|
|
572
|
-
: join(getAgentDir(), "advisor.json");
|
|
637
|
+
/** Saves user-controlled configuration globally without outcome consent. */
|
|
638
|
+
export const saveConfig = (_ctx: ExtensionContext) => {
|
|
639
|
+
const path = join(getAgentDir(), "advisor.json");
|
|
573
640
|
let existing: Record<string, unknown> = {};
|
|
574
641
|
try {
|
|
575
642
|
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
@@ -600,12 +667,15 @@ export const saveConfig = (ctx: ExtensionContext) => {
|
|
|
600
667
|
...(advisorMaxCallsPerSessionRef === undefined
|
|
601
668
|
? {}
|
|
602
669
|
: { advisorMaxCallsPerSession: advisorMaxCallsPerSessionRef }),
|
|
670
|
+
advisorGitContext: advisorGitContextRef,
|
|
671
|
+
advisorGitContextMaxChars: advisorGitContextMaxCharsRef,
|
|
603
672
|
advisorHerdrIntegration: advisorHerdrIntegrationRef,
|
|
604
673
|
advisorRedactSecrets: advisorRedactSecretsRef,
|
|
605
674
|
advisorSessionSummary: advisorSessionSummaryRef,
|
|
606
675
|
advisorToolPolicies: advisorToolPoliciesRef,
|
|
607
676
|
advisorToolResultMaxBytes: advisorToolResultMaxBytesRef,
|
|
608
677
|
advisorToolResultMaxLines: advisorToolResultMaxLinesRef,
|
|
678
|
+
advisorUntrackedContent: advisorUntrackedContentRef,
|
|
609
679
|
alwaysOn: alwaysOnRef,
|
|
610
680
|
gateFailureMode: advisorFailureModeRef,
|
|
611
681
|
simpleMode: simpleModeRef,
|
|
@@ -615,6 +685,26 @@ export const saveConfig = (ctx: ExtensionContext) => {
|
|
|
615
685
|
return path;
|
|
616
686
|
};
|
|
617
687
|
|
|
688
|
+
/** Outcome logging is deliberately written only to the global Pi configuration. */
|
|
689
|
+
export const saveGlobalOutcomeLogging = (enabled: boolean) => {
|
|
690
|
+
const path = join(getAgentDir(), "advisor.json");
|
|
691
|
+
let existing: Record<string, unknown> = {};
|
|
692
|
+
try {
|
|
693
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
694
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
695
|
+
existing = parsed as Record<string, unknown>;
|
|
696
|
+
}
|
|
697
|
+
} catch {
|
|
698
|
+
/* create it */
|
|
699
|
+
}
|
|
700
|
+
writeFileSync(
|
|
701
|
+
path,
|
|
702
|
+
`${JSON.stringify({ ...existing, advisorOutcomeLogging: enabled }, null, 2)}\n`
|
|
703
|
+
);
|
|
704
|
+
resetConfigCache();
|
|
705
|
+
return path;
|
|
706
|
+
};
|
|
707
|
+
|
|
618
708
|
export const parseArgs = (args: string): string | undefined => {
|
|
619
709
|
let nextExecutor = executorRef;
|
|
620
710
|
let nextAdvisor = advisorRef;
|
package/src/conversation.ts
CHANGED
|
@@ -37,6 +37,8 @@ export const textFrom = (content: unknown): string =>
|
|
|
37
37
|
const byteLength = (value: string) => Buffer.byteLength(value, "utf8");
|
|
38
38
|
|
|
39
39
|
const REDACTION_MARKER = "[REDACTED SECRET]";
|
|
40
|
+
const PEM_BEGIN_PATTERN = /-----BEGIN(?: [A-Z0-9]+)? PRIVATE KEY-----/gi;
|
|
41
|
+
const PEM_END_PATTERN = /-----END(?: [A-Z0-9]+)? PRIVATE KEY-----/i;
|
|
40
42
|
const SECRET_PATTERNS = [
|
|
41
43
|
/-----BEGIN(?: [A-Z0-9]+)? PRIVATE KEY-----[\s\S]*?-----END(?: [A-Z0-9]+)? PRIVATE KEY-----/gi,
|
|
42
44
|
/\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/gi,
|
|
@@ -46,9 +48,23 @@ const SECRET_PATTERNS = [
|
|
|
46
48
|
/\b(?:aws_secret_access_key|aws_session_token)\s*[:=]\s*[^\s"'&,;)}\]]+/gi,
|
|
47
49
|
] as const;
|
|
48
50
|
|
|
51
|
+
const redactUnterminatedPem = (value: string): string => {
|
|
52
|
+
const begins = [...value.matchAll(PEM_BEGIN_PATTERN)];
|
|
53
|
+
const lastBegin = begins.at(-1);
|
|
54
|
+
if (lastBegin?.index === undefined) {
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
const hasEnd = PEM_END_PATTERN.test(
|
|
58
|
+
value.slice(lastBegin.index + lastBegin[0].length)
|
|
59
|
+
);
|
|
60
|
+
return hasEnd
|
|
61
|
+
? value
|
|
62
|
+
: `${value.slice(0, lastBegin.index)}${REDACTION_MARKER}`;
|
|
63
|
+
};
|
|
64
|
+
|
|
49
65
|
/** Redacts common credential forms locally; it is not a data-classification system. */
|
|
50
66
|
export const redactSecrets = (value: string): string => {
|
|
51
|
-
let redacted = value;
|
|
67
|
+
let redacted = redactUnterminatedPem(value);
|
|
52
68
|
for (const pattern of SECRET_PATTERNS) {
|
|
53
69
|
redacted = redacted.replace(pattern, (_match, scheme) =>
|
|
54
70
|
typeof scheme === "string"
|
|
@@ -59,6 +75,23 @@ export const redactSecrets = (value: string): string => {
|
|
|
59
75
|
return redacted;
|
|
60
76
|
};
|
|
61
77
|
|
|
78
|
+
/** Redacts before a byte-safe cap so no secret fragment survives truncation. */
|
|
79
|
+
export const redactAndCapText = (
|
|
80
|
+
value: string,
|
|
81
|
+
maxBytes: number,
|
|
82
|
+
redact = true
|
|
83
|
+
): string => {
|
|
84
|
+
const source = redact ? redactSecrets(value) : value;
|
|
85
|
+
let result = "";
|
|
86
|
+
for (const character of source) {
|
|
87
|
+
if (byteLength(result + character) > maxBytes) {
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
result += character;
|
|
91
|
+
}
|
|
92
|
+
return result;
|
|
93
|
+
};
|
|
94
|
+
|
|
62
95
|
export interface ToolResultTruncation {
|
|
63
96
|
content: string;
|
|
64
97
|
omittedLines: number;
|
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/herdr.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import net from "node:net";
|
|
2
|
-
import {
|
|
2
|
+
import { getAdvisorSettings } from "./config.js";
|
|
3
|
+
import { redactSecrets } from "./conversation.js";
|
|
3
4
|
|
|
4
5
|
const SOURCE = "pi-advisor:advisor-activity";
|
|
5
6
|
const BLOCK_SOURCE = "pi-advisor:advisor-block";
|
|
@@ -41,7 +42,7 @@ const isControlCharacter = (character: string) =>
|
|
|
41
42
|
character <= "\u001f" || character === "\u007f";
|
|
42
43
|
|
|
43
44
|
const cleanNotification = (value: string, max: number) =>
|
|
44
|
-
[...value]
|
|
45
|
+
[...redactSecrets(value)]
|
|
45
46
|
.map((character) => (isControlCharacter(character) ? " " : character))
|
|
46
47
|
.join("")
|
|
47
48
|
.replace(/\s+/g, " ")
|
|
@@ -167,7 +168,7 @@ export class HerdrAdvisorBlock {
|
|
|
167
168
|
return;
|
|
168
169
|
}
|
|
169
170
|
this.#blocked = true;
|
|
170
|
-
this.safeReport({ blocked: reason });
|
|
171
|
+
this.safeReport({ blocked: cleanNotification(reason, 200) });
|
|
171
172
|
}
|
|
172
173
|
|
|
173
174
|
clear() {
|
|
@@ -176,9 +177,8 @@ export class HerdrAdvisorBlock {
|
|
|
176
177
|
if (!wasBlocked) {
|
|
177
178
|
return;
|
|
178
179
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
}
|
|
180
|
+
// Clearing previously reported state is a de-escalation and must still be
|
|
181
|
+
// delivered if integration was disabled after the block was reported.
|
|
182
182
|
try {
|
|
183
183
|
this.report({
|
|
184
184
|
id: `${BLOCK_SOURCE}:${nextSequence()}`,
|
|
@@ -218,7 +218,7 @@ export class HerdrAdvisorBlock {
|
|
|
218
218
|
}
|
|
219
219
|
|
|
220
220
|
export const notifyHerdrAdvisorFailure = (title: string, body: string) => {
|
|
221
|
-
if (!
|
|
221
|
+
if (!getAdvisorSettings().herdrIntegration) {
|
|
222
222
|
return;
|
|
223
223
|
}
|
|
224
224
|
try {
|
|
@@ -230,9 +230,9 @@ export const notifyHerdrAdvisorFailure = (title: string, body: string) => {
|
|
|
230
230
|
|
|
231
231
|
export const herdrAdvisorActivity = new HerdrAdvisorActivity(
|
|
232
232
|
sendToHerdr,
|
|
233
|
-
() =>
|
|
233
|
+
() => getAdvisorSettings().herdrIntegration
|
|
234
234
|
);
|
|
235
235
|
export const herdrAdvisorBlock = new HerdrAdvisorBlock(
|
|
236
236
|
sendToHerdr,
|
|
237
|
-
() =>
|
|
237
|
+
() => getAdvisorSettings().herdrIntegration
|
|
238
238
|
);
|
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
|
+
};
|