pi-advisor-flow 0.2.2 → 0.2.4

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/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,
@@ -14,6 +14,13 @@ export const DEFAULT_CONTEXT_MAX_CHARS = 15_000;
14
14
  export const MAX_CONTEXT_MAX_CHARS = Number.MAX_SAFE_INTEGER;
15
15
  export const DEFAULT_ADVISOR_TOOL_RESULT_MAX_LINES = DEFAULT_MAX_LINES;
16
16
  export const DEFAULT_ADVISOR_TOOL_RESULT_MAX_BYTES = DEFAULT_MAX_BYTES;
17
+ export type AdvisorToolPolicy = "full" | "summary" | "exclude";
18
+ export type AdvisorToolPolicies = Record<string, AdvisorToolPolicy>;
19
+ export const ADVISOR_TOOL_POLICIES: AdvisorToolPolicy[] = [
20
+ "full",
21
+ "summary",
22
+ "exclude",
23
+ ];
17
24
  export type GateFailureMode =
18
25
  | "block-session"
19
26
  | "block-tool"
@@ -38,11 +45,15 @@ export let advisorBlockOnBlockedRef = true;
38
45
  export let advisorAutoLoopGateRef = true;
39
46
  export let advisorLoopThresholdRef = 3;
40
47
  export let advisorMaxCallsPerSessionRef: number | undefined;
41
- export let advisorSessionSummaryRef = true;
48
+ export let advisorSessionSummaryRef = false;
49
+ export let simpleModeRef = false;
50
+ export let alwaysOnRef = false;
42
51
  export let advisorFailureModeRef: GateFailureMode = "block-session";
43
52
  export let advisorHerdrIntegrationRef = true;
44
53
  export let advisorToolResultMaxLinesRef = DEFAULT_ADVISOR_TOOL_RESULT_MAX_LINES;
45
54
  export let advisorToolResultMaxBytesRef = DEFAULT_ADVISOR_TOOL_RESULT_MAX_BYTES;
55
+ export let advisorRedactSecretsRef = false;
56
+ export let advisorToolPoliciesRef: AdvisorToolPolicies = {};
46
57
 
47
58
  export const setExecutorRef = (ref: string) => {
48
59
  executorRef = ref;
@@ -98,6 +109,14 @@ export const setAdvisorMaxCallsPerSessionRef = (value: number | undefined) => {
98
109
  export const setAdvisorSessionSummaryRef = (enabled: boolean) => {
99
110
  advisorSessionSummaryRef = enabled;
100
111
  };
112
+ export const setSimpleModeRef = (enabled: boolean) => {
113
+ simpleModeRef = enabled;
114
+ };
115
+ export const setAlwaysOnRef = (enabled: boolean) => {
116
+ alwaysOnRef = enabled;
117
+ };
118
+ /** Whether advanced Advisor automation is suppressed for this session. */
119
+ export const isSimpleMode = () => simpleModeRef;
101
120
  export const isValidGateFailureMode = (
102
121
  value: unknown
103
122
  ): value is GateFailureMode =>
@@ -119,12 +138,19 @@ export const setAdvisorToolResultMaxLinesRef = (value: number) => {
119
138
  export const setAdvisorToolResultMaxBytesRef = (value: number) => {
120
139
  advisorToolResultMaxBytesRef = value;
121
140
  };
141
+ export const setAdvisorRedactSecretsRef = (enabled: boolean) => {
142
+ advisorRedactSecretsRef = enabled;
143
+ };
144
+ export const setAdvisorToolPoliciesRef = (policies: AdvisorToolPolicies) => {
145
+ advisorToolPoliciesRef = { ...policies };
146
+ };
122
147
 
123
148
  /**
124
149
  * Returns the current live settings state. Use this at UI boundaries instead of
125
150
  * imported mutable bindings, which can be snapshotted by extension loaders.
126
151
  */
127
152
  export const getAdvisorSettings = () => ({
153
+ alwaysOn: alwaysOnRef,
128
154
  autoLoopGate: advisorAutoLoopGateRef,
129
155
  blockOnBlocked: advisorBlockOnBlockedRef,
130
156
  collapseResponses: advisorCollapseResponsesRef,
@@ -138,7 +164,10 @@ export const getAdvisorSettings = () => ({
138
164
  loopThreshold: advisorLoopThresholdRef,
139
165
  maxCallsPerSession: advisorMaxCallsPerSessionRef,
140
166
  planGate: advisorPlanGateRef,
167
+ redactSecrets: advisorRedactSecretsRef,
141
168
  sessionSummary: advisorSessionSummaryRef,
169
+ simpleMode: simpleModeRef,
170
+ toolPolicies: { ...advisorToolPoliciesRef },
142
171
  toolResultMaxBytes: advisorToolResultMaxBytesRef,
143
172
  toolResultMaxLines: advisorToolResultMaxLinesRef,
144
173
  });
@@ -168,13 +197,17 @@ export interface AdvisorConfig {
168
197
  advisorLoopThreshold?: number;
169
198
  advisorMaxCallsPerSession?: number;
170
199
  advisorPlanGate?: boolean;
200
+ advisorRedactSecrets?: boolean;
171
201
  advisorSessionSummary?: boolean;
202
+ advisorToolPolicies?: AdvisorToolPolicies;
172
203
  advisorToolResultMaxBytes?: number;
173
204
  advisorToolResultMaxLines?: number;
205
+ alwaysOn?: boolean;
174
206
  contextMaxChars?: number;
175
207
  executor?: string;
176
208
  executorEffort?: string;
177
209
  gateFailureMode?: GateFailureMode;
210
+ simpleMode?: boolean;
178
211
  }
179
212
 
180
213
  const CONFIG_KEYS = new Set<keyof AdvisorConfig>([
@@ -191,8 +224,12 @@ const CONFIG_KEYS = new Set<keyof AdvisorConfig>([
191
224
  "advisorMaxCallsPerSession",
192
225
  "advisorPlanGate",
193
226
  "advisorSessionSummary",
227
+ "simpleMode",
228
+ "alwaysOn",
194
229
  "advisorToolResultMaxBytes",
195
230
  "advisorToolResultMaxLines",
231
+ "advisorRedactSecrets",
232
+ "advisorToolPolicies",
196
233
  "contextMaxChars",
197
234
  "executor",
198
235
  "executorEffort",
@@ -206,7 +243,10 @@ const BOOLEAN_CONFIG_KEYS = [
206
243
  "advisorBlockOnBlocked",
207
244
  "advisorAutoLoopGate",
208
245
  "advisorSessionSummary",
246
+ "simpleMode",
247
+ "alwaysOn",
209
248
  "advisorHerdrIntegration",
249
+ "advisorRedactSecrets",
210
250
  ] as const;
211
251
  const STRING_CONFIG_KEYS = [
212
252
  "executor",
@@ -262,6 +302,20 @@ const validateBooleanValues = (config: ConfigRecord, path: string) => {
262
302
  }
263
303
  };
264
304
 
305
+ export const isValidAdvisorToolPolicies = (
306
+ value: unknown
307
+ ): value is AdvisorToolPolicies => {
308
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
309
+ return false;
310
+ }
311
+ return Object.entries(value).every(
312
+ ([toolName, policy]) =>
313
+ toolName.trim().length > 0 &&
314
+ typeof policy === "string" &&
315
+ ADVISOR_TOOL_POLICIES.includes(policy as AdvisorToolPolicy)
316
+ );
317
+ };
318
+
265
319
  const validateNumericValues = (config: ConfigRecord, path: string) => {
266
320
  const numericRules: [
267
321
  keyof AdvisorConfig,
@@ -315,6 +369,16 @@ export const validateConfig = (
315
369
  validateStringValues(config, path);
316
370
  validateBooleanValues(config, path);
317
371
  validateNumericValues(config, path);
372
+ if (
373
+ config.advisorToolPolicies !== undefined &&
374
+ !isValidAdvisorToolPolicies(config.advisorToolPolicies)
375
+ ) {
376
+ invalidConfigValue(
377
+ path,
378
+ "advisorToolPolicies",
379
+ "a JSON object with non-empty tool names and full, summary, or exclude values"
380
+ );
381
+ }
318
382
  if (
319
383
  config.gateFailureMode !== undefined &&
320
384
  !isValidGateFailureMode(config.gateFailureMode)
@@ -339,11 +403,15 @@ const resetDefaults = () => {
339
403
  advisorAutoLoopGateRef = true;
340
404
  advisorLoopThresholdRef = 3;
341
405
  advisorMaxCallsPerSessionRef = undefined;
342
- advisorSessionSummaryRef = true;
406
+ advisorSessionSummaryRef = false;
407
+ simpleModeRef = false;
408
+ alwaysOnRef = false;
343
409
  advisorFailureModeRef = "block-session";
344
410
  advisorHerdrIntegrationRef = true;
345
411
  advisorToolResultMaxLinesRef = DEFAULT_ADVISOR_TOOL_RESULT_MAX_LINES;
346
412
  advisorToolResultMaxBytesRef = DEFAULT_ADVISOR_TOOL_RESULT_MAX_BYTES;
413
+ advisorRedactSecretsRef = false;
414
+ advisorToolPoliciesRef = {};
347
415
  };
348
416
 
349
417
  const applyOptionalConfig = <Key extends keyof AdvisorConfig>(
@@ -410,6 +478,8 @@ const applyConfig = (config: AdvisorConfig) => {
410
478
  "advisorSessionSummary",
411
479
  setAdvisorSessionSummaryRef
412
480
  );
481
+ applyOptionalConfig(config, "simpleMode", setSimpleModeRef);
482
+ applyOptionalConfig(config, "alwaysOn", setAlwaysOnRef);
413
483
  applyOptionalConfig(config, "gateFailureMode", setAdvisorFailureModeRef);
414
484
  applyOptionalConfig(
415
485
  config,
@@ -426,6 +496,12 @@ const applyConfig = (config: AdvisorConfig) => {
426
496
  "advisorToolResultMaxBytes",
427
497
  setAdvisorToolResultMaxBytesRef
428
498
  );
499
+ applyOptionalConfig(
500
+ config,
501
+ "advisorRedactSecrets",
502
+ setAdvisorRedactSecretsRef
503
+ );
504
+ applyOptionalConfig(config, "advisorToolPolicies", setAdvisorToolPoliciesRef);
429
505
  };
430
506
 
431
507
  const readConfig = (path: string): AdvisorConfig => {
@@ -438,6 +514,43 @@ const readConfig = (path: string): AdvisorConfig => {
438
514
  }
439
515
  };
440
516
 
517
+ // loadConfig runs on every tool call and every consultation. Caching the parsed
518
+ // file by path and stat identity removes that read and parse from the hot path
519
+ // while still applying the full reset-then-apply sequence on each call.
520
+ let configCache:
521
+ | { config: AdvisorConfig; identity: string; path: string }
522
+ | undefined;
523
+
524
+ /** Drops the parsed-configuration cache; the next load re-reads from disk. */
525
+ export const resetConfigCache = () => {
526
+ configCache = undefined;
527
+ };
528
+
529
+ /**
530
+ * Nanosecond timestamps plus size and inode so a same-millisecond or
531
+ * same-size external rewrite cannot be served from the cache. An unstattable
532
+ * path yields a unique value, which always forces a re-read.
533
+ */
534
+ const configIdentity = (path: string): string => {
535
+ try {
536
+ const stats = statSync(path, { bigint: true });
537
+ return `${stats.mtimeNs}:${stats.ctimeNs}:${stats.size}:${stats.ino}:${stats.dev}`;
538
+ } catch {
539
+ return `unstattable:${process.hrtime.bigint()}`;
540
+ }
541
+ };
542
+
543
+ const readConfigCached = (path: string): AdvisorConfig => {
544
+ const identity = configIdentity(path);
545
+ const cached = configCache;
546
+ if (cached && cached.path === path && cached.identity === identity) {
547
+ return cached.config;
548
+ }
549
+ const config = readConfig(path);
550
+ configCache = { config, identity, path };
551
+ return config;
552
+ };
553
+
441
554
  export const loadConfig = (ctx: ExtensionContext) => {
442
555
  resetDefaults();
443
556
  const path = configPaths(ctx).find(
@@ -447,7 +560,7 @@ export const loadConfig = (ctx: ExtensionContext) => {
447
560
  if (!path) {
448
561
  return null;
449
562
  }
450
- applyConfig(readConfig(path));
563
+ applyConfig(readConfigCached(path));
451
564
  return path;
452
565
  };
453
566
 
@@ -488,12 +601,17 @@ export const saveConfig = (ctx: ExtensionContext) => {
488
601
  ? {}
489
602
  : { advisorMaxCallsPerSession: advisorMaxCallsPerSessionRef }),
490
603
  advisorHerdrIntegration: advisorHerdrIntegrationRef,
604
+ advisorRedactSecrets: advisorRedactSecretsRef,
491
605
  advisorSessionSummary: advisorSessionSummaryRef,
606
+ advisorToolPolicies: advisorToolPoliciesRef,
492
607
  advisorToolResultMaxBytes: advisorToolResultMaxBytesRef,
493
608
  advisorToolResultMaxLines: advisorToolResultMaxLinesRef,
609
+ alwaysOn: alwaysOnRef,
494
610
  gateFailureMode: advisorFailureModeRef,
611
+ simpleMode: simpleModeRef,
495
612
  };
496
613
  writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`);
614
+ resetConfigCache();
497
615
  return path;
498
616
  };
499
617
 
@@ -1,5 +1,8 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import {
3
+ type AdvisorToolPolicies,
4
+ advisorRedactSecretsRef,
5
+ advisorToolPoliciesRef,
3
6
  advisorToolResultMaxBytesRef,
4
7
  advisorToolResultMaxLinesRef,
5
8
  DEFAULT_ADVISOR_TOOL_RESULT_MAX_BYTES,
@@ -33,6 +36,29 @@ export const textFrom = (content: unknown): string =>
33
36
 
34
37
  const byteLength = (value: string) => Buffer.byteLength(value, "utf8");
35
38
 
39
+ const REDACTION_MARKER = "[REDACTED SECRET]";
40
+ const SECRET_PATTERNS = [
41
+ /-----BEGIN(?: [A-Z0-9]+)? PRIVATE KEY-----[\s\S]*?-----END(?: [A-Z0-9]+)? PRIVATE KEY-----/gi,
42
+ /\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/gi,
43
+ /\b(?:api[_-]?key|token|secret|password|passwd)\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s"'&,;)}\]]+)/gi,
44
+ /([a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+:[^\s/@]+@/gi,
45
+ /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g,
46
+ /\b(?:aws_secret_access_key|aws_session_token)\s*[:=]\s*[^\s"'&,;)}\]]+/gi,
47
+ ] as const;
48
+
49
+ /** Redacts common credential forms locally; it is not a data-classification system. */
50
+ export const redactSecrets = (value: string): string => {
51
+ let redacted = value;
52
+ for (const pattern of SECRET_PATTERNS) {
53
+ redacted = redacted.replace(pattern, (_match, scheme) =>
54
+ typeof scheme === "string"
55
+ ? `${scheme}${REDACTION_MARKER}@`
56
+ : REDACTION_MARKER
57
+ );
58
+ }
59
+ return redacted;
60
+ };
61
+
36
62
  export interface ToolResultTruncation {
37
63
  content: string;
38
64
  omittedLines: number;
@@ -122,18 +148,35 @@ export const capToolResult = (
122
148
  };
123
149
  };
124
150
 
125
- const assistantEntry = (message: RecordValue): string | undefined => {
151
+ const assistantEntry = (
152
+ message: RecordValue,
153
+ policies: AdvisorToolPolicies,
154
+ redact: boolean
155
+ ): string | undefined => {
126
156
  const parts: string[] = [];
127
157
  const text = textFrom(message.content);
128
158
  if (text) {
129
- parts.push(text);
159
+ parts.push(redact ? redactSecrets(text) : text);
130
160
  }
131
161
  for (const part of contentParts(message.content)) {
132
162
  if (!isRecord(part) || part.type !== "toolCall") {
133
163
  continue;
134
164
  }
165
+ const toolName = typeof part.name === "string" ? part.name : "unknown";
166
+ const policy = policies[toolName] ?? "full";
167
+ if (policy === "exclude") {
168
+ parts.push(`[Tool Call: ${toolName}] (excluded by Advisor tool policy)`);
169
+ continue;
170
+ }
171
+ if (policy === "summary") {
172
+ parts.push(
173
+ `[Tool Call: ${toolName}] (arguments omitted by Advisor tool policy: summary)`
174
+ );
175
+ continue;
176
+ }
177
+ const argumentsText = JSON.stringify(part.arguments) ?? "undefined";
135
178
  parts.push(
136
- `[Tool Call: ${String(part.name)}(${JSON.stringify(part.arguments)})]`
179
+ `[Tool Call: ${toolName}(${redact ? redactSecrets(argumentsText) : argumentsText})]`
137
180
  );
138
181
  }
139
182
  return parts.length > 0 ? `Executor: ${parts.join("\n")}` : undefined;
@@ -142,29 +185,47 @@ const assistantEntry = (message: RecordValue): string | undefined => {
142
185
  const toolResultEntry = (
143
186
  message: RecordValue,
144
187
  toolResultMaxLines: number,
145
- toolResultMaxBytes: number
188
+ toolResultMaxBytes: number,
189
+ policies: AdvisorToolPolicies,
190
+ redact: boolean
146
191
  ): string => {
147
- const status = message.isError ? "Error " : "";
192
+ const status = message.isError ? "error" : "success";
193
+ const toolName =
194
+ typeof message.toolName === "string" ? message.toolName : "unknown";
195
+ const policy = policies[toolName] ?? "full";
196
+ const source = textFrom(message.content);
197
+ if (policy === "exclude") {
198
+ return `[Tool Result for ${toolName}] (excluded by Advisor tool policy)`;
199
+ }
200
+ if (policy === "summary") {
201
+ const capped = capToolResult(
202
+ source,
203
+ toolResultMaxLines,
204
+ toolResultMaxBytes
205
+ );
206
+ return `[Tool Result for ${toolName}] (output omitted by Advisor tool policy: summary; status: ${status}; ${capped.totalLines} lines, ${capped.totalBytes} bytes; source output was${capped.truncated ? "" : " not"} truncated)`;
207
+ }
208
+ const disclosed = redact ? redactSecrets(source) : source;
148
209
  const capped = capToolResult(
149
- textFrom(message.content),
210
+ disclosed,
150
211
  toolResultMaxLines,
151
212
  toolResultMaxBytes
152
213
  );
153
- const toolName =
154
- typeof message.toolName === "string" ? message.toolName : "unknown";
155
- return `[Tool Result for ${toolName}] (${status}output):\n${capped.content}`;
214
+ return `[Tool Result for ${toolName}] (${message.isError ? "Error " : ""}output):\n${capped.content}`;
156
215
  };
157
216
 
158
217
  const conversationEntry = (
159
218
  entry: unknown,
160
219
  toolResultMaxLines: number,
161
- toolResultMaxBytes: number
220
+ toolResultMaxBytes: number,
221
+ policies: AdvisorToolPolicies,
222
+ redact: boolean
162
223
  ): string | undefined => {
163
224
  if (!isRecord(entry)) {
164
225
  return;
165
226
  }
166
227
  if (entry.type === "compaction" && typeof entry.summary === "string") {
167
- return `[System Compaction Summary]: ${entry.summary}`;
228
+ return `[System Compaction Summary]: ${redact ? redactSecrets(entry.summary) : entry.summary}`;
168
229
  }
169
230
  if (entry.type !== "message" || !isRecord(entry.message)) {
170
231
  return;
@@ -172,13 +233,19 @@ const conversationEntry = (
172
233
  const { message } = entry;
173
234
  if (message.role === "user") {
174
235
  const text = textFrom(message.content);
175
- return text ? `User: ${text}` : undefined;
236
+ return text ? `User: ${redact ? redactSecrets(text) : text}` : undefined;
176
237
  }
177
238
  if (message.role === "assistant") {
178
- return assistantEntry(message);
239
+ return assistantEntry(message, policies, redact);
179
240
  }
180
241
  if (message.role === "toolResult" || message.role === "tool") {
181
- return toolResultEntry(message, toolResultMaxLines, toolResultMaxBytes);
242
+ return toolResultEntry(
243
+ message,
244
+ toolResultMaxLines,
245
+ toolResultMaxBytes,
246
+ policies,
247
+ redact
248
+ );
182
249
  }
183
250
  };
184
251
 
@@ -189,10 +256,18 @@ const selectRecentEntries = (entries: string[], maxChars: number): string => {
189
256
  return joined;
190
257
  }
191
258
  const selected: string[] = [];
259
+ // Track the joined length instead of re-joining the accumulator each step;
260
+ // long branches with a large maxChars are otherwise quadratic in bytes.
261
+ let selectedLength = 0;
192
262
  for (let index = entries.length - 1; index >= 0; index -= 1) {
193
- const candidate = [entries[index], ...selected].join(separator);
194
- if (candidate.length <= maxChars || selected.length === 0) {
195
- selected.unshift(entries[index]);
263
+ const entry = entries[index];
264
+ const candidateLength =
265
+ selectedLength +
266
+ entry.length +
267
+ (selected.length === 0 ? 0 : separator.length);
268
+ if (candidateLength <= maxChars || selected.length === 0) {
269
+ selected.unshift(entry);
270
+ selectedLength = candidateLength;
196
271
  } else {
197
272
  break;
198
273
  }
@@ -208,7 +283,9 @@ export const recentConversation = (
208
283
  ctx: ExtensionContext,
209
284
  maxChars = 15_000,
210
285
  toolResultMaxLines = advisorToolResultMaxLinesRef,
211
- toolResultMaxBytes = advisorToolResultMaxBytesRef
286
+ toolResultMaxBytes = advisorToolResultMaxBytesRef,
287
+ policies = advisorToolPoliciesRef,
288
+ redact = advisorRedactSecretsRef
212
289
  ): string => {
213
290
  if (maxChars === 0) {
214
291
  return "";
@@ -216,7 +293,13 @@ export const recentConversation = (
216
293
  const entries = ctx.sessionManager
217
294
  .getBranch()
218
295
  .map((entry) =>
219
- conversationEntry(entry, toolResultMaxLines, toolResultMaxBytes)
296
+ conversationEntry(
297
+ entry,
298
+ toolResultMaxLines,
299
+ toolResultMaxBytes,
300
+ policies,
301
+ redact
302
+ )
220
303
  )
221
304
  .filter((entry): entry is string => entry !== undefined);
222
305
  return selectRecentEntries(entries, maxChars);
package/src/tools.ts CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  advisorRef,
31
31
  advisorSessionSummaryRef,
32
32
  contextMaxCharsRef,
33
+ isSimpleMode,
33
34
  loadConfig,
34
35
  splitRef,
35
36
  } from "./config.js";
@@ -69,8 +70,19 @@ export const SPINNER_FRAMES = [
69
70
  ];
70
71
  export const resolveAdvisorRequest = (question?: string) =>
71
72
  question?.trim() || undefined;
72
- export const advisorMessageText = (conversation: string, question?: string) =>
73
- `${conversation ? `<conversation>\n${conversation}\n</conversation>` : ""}${question ? `\n\nTargeted focus:\n${question}` : ""}`;
73
+ export const advisorMessageText = (conversation: string, question?: string) => {
74
+ const text = `${conversation ? `<conversation>\n${conversation}\n</conversation>` : ""}${question ? `\n\nTargeted focus:\n${question}` : ""}`;
75
+ // A zero context limit with no targeted focus would otherwise send an empty
76
+ // user message, which several providers reject outright.
77
+ return (
78
+ text.trim() ||
79
+ "No conversation context is available. State that you cannot review without context."
80
+ );
81
+ };
82
+
83
+ /** The sole reconstructed-context boundary for outgoing Advisor requests. */
84
+ export const advisorRequestConversation = (ctx: ExtensionContext) =>
85
+ recentConversation(ctx, contextMaxCharsRef);
74
86
 
75
87
  export const renderAdvisorCallBox = (
76
88
  question: string | undefined,
@@ -92,6 +104,19 @@ export const renderAdvisorCallBox = (
92
104
  };
93
105
 
94
106
  const COLLAPSED_ADVICE_LINES = 12;
107
+ // The system prompt requires this exact first line, so the match is exact too.
108
+ const SOUND_VERDICT = /^Verdict:\s*sound$/;
109
+
110
+ export const hasSoundVerdict = (advice: string) =>
111
+ SOUND_VERDICT.test(
112
+ (advice.split("\n").find((line) => line.trim()) ?? "").trim()
113
+ );
114
+
115
+ /** The single Advisor response header shared by tool and manual renderers. */
116
+ export const renderAdvisorResponseHeader = (sound: boolean, theme: Theme) =>
117
+ sound
118
+ ? theme.fg("accent", theme.bold("◆ ADVISOR · SOUND"))
119
+ : theme.fg("warning", theme.bold("◆ ADVISOR RESPONSE"));
95
120
 
96
121
  export const adviceForDisplay = (advice: string, expanded: boolean) => {
97
122
  if (!advisorCollapseResponsesRef || expanded) {
@@ -105,6 +130,11 @@ export const adviceForDisplay = (advice: string, expanded: boolean) => {
105
130
  };
106
131
 
107
132
  export const advisorInvocationGuidelines = () => {
133
+ if (isSimpleMode()) {
134
+ return [
135
+ "When uncertain and normal available tools cannot resolve it, call ask_advisor for a second opinion.",
136
+ ];
137
+ }
108
138
  const guidelines: string[] = [];
109
139
  if (advisorPlanGateRef) {
110
140
  guidelines.push(
@@ -137,6 +167,7 @@ export const ADVISOR_SYSTEM = [
137
167
  "You already have the relevant reconstructed conversation context. No question or other input from the Executor is needed for a general review.",
138
168
  "When no targeted focus is supplied, proactively review the task, risks, proposed direction, and validation from the context. Do not ask the Executor for a question, clarification, more input, or confirmation.",
139
169
  "The context may be truncated, so state any material uncertainty and make the best recommendation you can from what is present.",
170
+ "When the implementation is fully sound based on the supplied evidence and you have no material concern or recommended change, begin with exactly `Verdict: sound`. Do not use that verdict when uncertainty, a risk, or a recommendation remains.",
140
171
  "You do not act or take over planning. Answer the Executor's request directly in concise, human-readable Markdown. State uncertainty plainly and never claim verification that the supplied evidence does not show.",
141
172
  ].join(" ");
142
173
 
@@ -189,6 +220,7 @@ export const advisorUsageCost = (usage: unknown): number | undefined => {
189
220
 
190
221
  const DECISION_LINE = /^Decision\s*:\s*(proceed|revise|blocked)\s*$/i;
191
222
  const ANY_DECISION_LINE = /^Decision\s*:\s*(.*?)\s*$/i;
223
+ const CODE_FENCE = /^(?:```|~~~)/;
192
224
  const LINE_BREAK = /\r?\n/;
193
225
 
194
226
  export const parseAutomaticDecision = (
@@ -217,8 +249,19 @@ export const parseAutomaticDecision = (
217
249
  };
218
250
  }
219
251
  const decision = match[1].toLowerCase() as GateDecision;
252
+ let insideFence = false;
220
253
  for (const line of lines.slice(nonEmpty + 1)) {
221
- const subsequent = ANY_DECISION_LINE.exec(line.trim());
254
+ const trimmed = line.trim();
255
+ // A decision quoted inside a fenced example is illustrative, not a second
256
+ // decision, and must not fail the gate.
257
+ if (CODE_FENCE.test(trimmed)) {
258
+ insideFence = !insideFence;
259
+ continue;
260
+ }
261
+ if (insideFence) {
262
+ continue;
263
+ }
264
+ const subsequent = ANY_DECISION_LINE.exec(trimmed);
222
265
  if (!subsequent) {
223
266
  continue;
224
267
  }
@@ -272,7 +315,7 @@ const collectAdvisorResponse = async (
272
315
  throw new Error(`No API key for ${advisorRef}`);
273
316
  }
274
317
 
275
- const conversation = recentConversation(ctx, contextMaxCharsRef);
318
+ const conversation = advisorRequestConversation(ctx);
276
319
  const messages: Message[] = [
277
320
  {
278
321
  content: [
@@ -449,7 +492,7 @@ const reserveAdvisorCall = (
449
492
  session: AdvisorSessionState,
450
493
  reservedCalls: Set<string>
451
494
  ): ToolCallEventResult | undefined => {
452
- if (event.toolName !== "ask_advisor") {
495
+ if (event.toolName !== "ask_advisor" || isSimpleMode()) {
453
496
  return;
454
497
  }
455
498
  if (!session.canConsult(advisorMaxCallsPerSessionRef)) {
@@ -517,6 +560,7 @@ const handleAutomaticGate = async (
517
560
  session: AdvisorSessionState
518
561
  ): Promise<ToolCallEventResult | undefined> => {
519
562
  if (
563
+ isSimpleMode() ||
520
564
  event.toolName === "ask_advisor" ||
521
565
  !advisorAutoLoopGateRef ||
522
566
  !session.recordToolCall(
@@ -661,7 +705,8 @@ const renderFinalAdvisorResult = (
661
705
  context.state.timerId = undefined;
662
706
  }
663
707
  const details = advisorResultDetails(result);
664
- const lines = [theme.fg("warning", theme.bold("◆ ADVISOR RESPONSE"))];
708
+ const advice = details?.text || textFrom(result.content);
709
+ const lines = [renderAdvisorResponseHeader(hasSoundVerdict(advice), theme)];
665
710
  if (details?.advisor) {
666
711
  lines.push(theme.fg("dim", ` ${details.advisor}`));
667
712
  }
@@ -674,13 +719,15 @@ const renderFinalAdvisorResult = (
674
719
  )
675
720
  );
676
721
  }
677
- const advice =
678
- details?.text ||
679
- textFrom(result.content) ||
680
- "(Advisor returned no advice.)";
722
+ const displayAdvice = advice || "(Advisor returned no advice.)";
681
723
  box.addChild(new Text(lines.join("\n"), 0, 0));
682
724
  box.addChild(
683
- new Markdown(adviceForDisplay(advice, expanded), 0, 0, getMarkdownTheme())
725
+ new Markdown(
726
+ adviceForDisplay(displayAdvice, expanded),
727
+ 0,
728
+ 0,
729
+ getMarkdownTheme()
730
+ )
684
731
  );
685
732
  };
686
733
 
@@ -775,7 +822,9 @@ export const registerAdvisorTool = (pi: ExtensionAPI) => {
775
822
  }
776
823
  loadConfig(ctx);
777
824
  const guidelines = advisorInvocationGuidelines();
778
- const budget = session.remainingCalls(advisorMaxCallsPerSessionRef);
825
+ const budget = isSimpleMode()
826
+ ? undefined
827
+ : session.remainingCalls(advisorMaxCallsPerSessionRef);
779
828
  if (budget !== undefined) {
780
829
  guidelines.push(
781
830
  `Advisor calls remaining this session: ${budget}.\nReserve calls for material decisions, repeated failures, or final review.`
@@ -793,7 +842,14 @@ export const registerAdvisorTool = (pi: ExtensionAPI) => {
793
842
  return;
794
843
  }
795
844
  loadConfig(ctx);
796
- if (session.blocked) {
845
+ if (isSimpleMode()) {
846
+ // Simple mode does not gate, so a block recorded before it was enabled must
847
+ // not linger in session or Herdr state and misreport the session as blocked.
848
+ if (session.blocked) {
849
+ session.clearBlocked();
850
+ herdrAdvisorBlock.clear();
851
+ }
852
+ } else if (session.blocked) {
797
853
  return {
798
854
  block: true,
799
855
  reason: session.blockedReason ?? "Advisor session is blocked.",
@@ -807,7 +863,7 @@ export const registerAdvisorTool = (pi: ExtensionAPI) => {
807
863
  });
808
864
 
809
865
  pi.on("agent_settled", (_event, ctx) => {
810
- if (session.blocked || !advisorSessionSummaryRef) {
866
+ if (isSimpleMode() || session.blocked || !advisorSessionSummaryRef) {
811
867
  return;
812
868
  }
813
869
  const summary = session.summary(advisorMaxCallsPerSessionRef);
@@ -825,7 +881,7 @@ export const registerAdvisorTool = (pi: ExtensionAPI) => {
825
881
  description:
826
882
  "Consult the on-demand Advisor model for strategic guidance. Call with an empty object for a context-aware review; add question only for a genuinely targeted focus.",
827
883
  async execute(_id, params, signal, onUpdate, ctx) {
828
- if (!reservedCalls.delete(_id)) {
884
+ if (!(reservedCalls.delete(_id) || isSimpleMode())) {
829
885
  if (!session.canConsult(advisorMaxCallsPerSessionRef)) {
830
886
  throw new Error("Advisor call budget exhausted for this session.");
831
887
  }