pi-advisor-flow 0.7.0 → 0.8.1-dev.1

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.
Files changed (93) hide show
  1. package/CHANGELOG.md +27 -18
  2. package/README.md +9 -8
  3. package/dist/index.js +1968 -1081
  4. package/extensions/index.ts +2 -1
  5. package/package.json +48 -35
  6. package/src/attachments.ts +11 -10
  7. package/src/child-session.ts +15 -0
  8. package/src/commands/activation-preparation.ts +1 -0
  9. package/src/commands/activation.ts +12 -3
  10. package/src/commands/lifecycle.ts +7 -1
  11. package/src/commands/manual-command.ts +7 -1
  12. package/src/commands/manual-consultation.ts +102 -92
  13. package/src/commands/manual-progress.ts +7 -5
  14. package/src/commands/model-commands.ts +1 -1
  15. package/src/commands/model-options.ts +14 -3
  16. package/src/commands/model-picker.ts +1 -0
  17. package/src/commands/registration.ts +1 -0
  18. package/src/commands/renderers.ts +67 -55
  19. package/src/commands/runtime.ts +16 -14
  20. package/src/commands/settings-commands.ts +10 -4
  21. package/src/commands/settings-persistence.ts +16 -2
  22. package/src/commands/types.ts +1 -0
  23. package/src/commands.ts +0 -1
  24. package/src/config/args.ts +1 -1
  25. package/src/config/defaults.ts +8 -1
  26. package/src/config/schema.ts +42 -17
  27. package/src/config/state.ts +12 -3
  28. package/src/config/storage.ts +34 -23
  29. package/src/config/types.ts +16 -3
  30. package/src/config/validation.ts +33 -21
  31. package/src/config.ts +3 -0
  32. package/src/content-utils.ts +24 -4
  33. package/src/conversation.ts +16 -15
  34. package/src/git.ts +10 -3
  35. package/src/herdr-block.ts +83 -0
  36. package/src/herdr-shared.ts +108 -0
  37. package/src/herdr.ts +30 -192
  38. package/src/jev/client.ts +75 -85
  39. package/src/jev/failure.ts +19 -0
  40. package/src/jev/key-store.ts +26 -11
  41. package/src/jev/ledger.ts +58 -64
  42. package/src/jev/questions.ts +26 -23
  43. package/src/jev/state.ts +15 -12
  44. package/src/jev/transport.ts +8 -3
  45. package/src/model-stream.ts +35 -37
  46. package/src/outcomes.ts +15 -11
  47. package/src/pi-settings.ts +4 -9
  48. package/src/preferences.ts +4 -2
  49. package/src/redaction.ts +10 -12
  50. package/src/scout-context.ts +59 -60
  51. package/src/scout-curation.ts +4 -13
  52. package/src/scout-groups.ts +178 -175
  53. package/src/scout-protocol.ts +74 -70
  54. package/src/scout-reconstruct.ts +1 -1
  55. package/src/scout.ts +128 -77
  56. package/src/session-state.ts +64 -49
  57. package/src/tool-result-cap.ts +18 -17
  58. package/src/tools/consult-context.ts +7 -15
  59. package/src/tools/consultation.ts +14 -4
  60. package/src/tools/gate-policy.ts +1 -0
  61. package/src/tools/gate-protocol.ts +36 -23
  62. package/src/tools/jev-filter.ts +43 -41
  63. package/src/tools/jev-turn-gate.ts +29 -9
  64. package/src/tools/loop-gate.ts +31 -20
  65. package/src/tools/model-access.ts +46 -0
  66. package/src/tools/prompts.ts +20 -25
  67. package/src/tools/register-ask-advisor.ts +36 -16
  68. package/src/tools/register-lifecycle.ts +22 -0
  69. package/src/tools/register-outcome.ts +2 -0
  70. package/src/tools/register-renderers.ts +146 -128
  71. package/src/tools/registration.ts +12 -14
  72. package/src/tools/render-advisor-result.ts +35 -22
  73. package/src/tools/render-common.ts +16 -19
  74. package/src/tools/scout-status.ts +42 -27
  75. package/src/tools/types.ts +1 -0
  76. package/src/tools.ts +0 -1
  77. package/src/ui/jev-setup-submenu.ts +65 -59
  78. package/src/ui/manual-dialog-render.ts +1 -0
  79. package/src/ui/manual-dialog.ts +39 -43
  80. package/src/ui/masked-input.ts +5 -11
  81. package/src/ui/model-multi-selector.ts +9 -0
  82. package/src/ui/model-selector-adapter.ts +31 -0
  83. package/src/ui/model-selector.ts +14 -161
  84. package/src/ui/searchable-model-list.ts +230 -0
  85. package/src/ui/settings-formatting.ts +24 -14
  86. package/src/ui/settings-items.ts +38 -1
  87. package/src/ui/settings-list-adapter.ts +15 -12
  88. package/src/ui/settings-mutations.ts +157 -70
  89. package/src/ui/settings-selector.ts +18 -9
  90. package/src/ui/text-setting-submenu.ts +3 -6
  91. package/src/ui/types.ts +14 -0
  92. package/src/ui.ts +0 -1
  93. package/src/usage.ts +26 -19
@@ -1,11 +1,14 @@
1
- import { AdvisorJevLedgerState, type AdvisorJevUsage } from "./jev/ledger.ts";
1
+ import { isRecordOf, isString } from "./content-utils.ts";
2
+ import type { JsonValue } from "./content-utils.ts";
3
+ import { AdvisorJevLedgerState } from "./jev/ledger.ts";
4
+ import type { AdvisorJevUsage } from "./jev/ledger.ts";
2
5
  import {
3
- type AdvisorUsageTotals,
4
6
  addAdvisorUsage,
5
7
  emptyAdvisorUsageTotals,
6
8
  formatAdvisorUsageStatus,
7
9
  formatAdvisorUsageTotals,
8
10
  } from "./usage.ts";
11
+ import type { AdvisorUsageTotals } from "./usage.ts";
9
12
 
10
13
  export type GateDecision = "proceed" | "revise" | "blocked";
11
14
  export type ConsultationTrigger = "manual" | "executor-requested" | "turn-gate";
@@ -27,7 +30,7 @@ export interface AdvisorInvocationRecord {
27
30
  usage?: unknown;
28
31
  }
29
32
 
30
- const WHITESPACE = /\s/;
33
+ const WHITESPACE = /\s/u;
31
34
  const TIMESTAMP_KEYS = new Set([
32
35
  "createdat",
33
36
  "date",
@@ -37,7 +40,8 @@ const TIMESTAMP_KEYS = new Set([
37
40
  "updatedat",
38
41
  ]);
39
42
  const REQUEST_ID_KEYS = new Set(["correlationid", "requestid", "traceid"]);
40
- const normalizedKey = (key: string) => key.replace(/[-_]/g, "").toLowerCase();
43
+ const normalizedKey = (key: string) =>
44
+ key.replaceAll(/[-_]/gu, "").toLowerCase();
41
45
  const isVolatileKey = (key: string, keys: Set<string>) =>
42
46
  keys.has(normalizedKey(key));
43
47
 
@@ -75,15 +79,24 @@ const normalizeShellWhitespace = (command: string) => {
75
79
 
76
80
  const normalizeString = (value: string) =>
77
81
  value
78
- .replace(/\/(?:private\/)?tmp\/[^\s/]+/g, "/tmp/<temporary>")
79
- .replace(/\/var\/folders\/[^\s/]+/g, "/var/folders/<temporary>");
82
+ .replaceAll(/\/(?:private\/)?tmp\/[^\s/]+/gu, "/tmp/<temporary>")
83
+ .replaceAll(/\/var\/folders\/[^\s/]+/gu, "/var/folders/<temporary>");
80
84
 
81
- export const normalizeToolInput = (
85
+ export type NormalizedToolInput =
86
+ | JsonValue
87
+ | undefined
88
+ | NormalizedToolInput[]
89
+ | { [key: string]: NormalizedToolInput };
90
+
91
+ export const normalizeToolInput = <Input>(
82
92
  toolName: string,
83
- input: unknown
84
- ): unknown => {
85
- const visit = (value: unknown, key?: string): unknown => {
86
- if (typeof value === "string") {
93
+ input: Input
94
+ ): NormalizedToolInput => {
95
+ const visit = (
96
+ value: NormalizedToolInput,
97
+ key?: string
98
+ ): NormalizedToolInput => {
99
+ if (isString(value)) {
87
100
  if (key && isVolatileKey(key, TIMESTAMP_KEYS)) {
88
101
  return "<timestamp>";
89
102
  }
@@ -98,21 +111,23 @@ export const normalizeToolInput = (
98
111
  if (Array.isArray(value)) {
99
112
  return value.map((item) => visit(item));
100
113
  }
101
- if (value && typeof value === "object") {
102
- const record = value as Record<string, unknown>;
114
+ if (isRecordOf(value)) {
103
115
  return Object.fromEntries(
104
- Object.keys(record)
105
- .sort()
106
- .map((childKey) => [childKey, visit(record[childKey], childKey)])
116
+ Object.keys(value)
117
+ .toSorted()
118
+ .map((childKey) => [childKey, visit(value[childKey], childKey)])
107
119
  );
108
120
  }
109
121
  return value;
110
122
  };
111
- return visit(input);
123
+ // SAFETY: Pi tool inputs are JSON-decoded tool arguments, so the root value is already NormalizedToolInput-shaped.
124
+ return visit(input as Input & NormalizedToolInput);
112
125
  };
113
126
 
114
- export const normalizedToolSignature = (toolName: string, input: unknown) =>
115
- `${toolName}:${JSON.stringify(normalizeToolInput(toolName, input))}`;
127
+ export const normalizedToolSignature = <Input>(
128
+ toolName: string,
129
+ input: Input
130
+ ) => `${toolName}:${JSON.stringify(normalizeToolInput(toolName, input))}`;
116
131
 
117
132
  interface RepetitionState {
118
133
  count: number;
@@ -120,16 +135,15 @@ interface RepetitionState {
120
135
  previousSignature?: string;
121
136
  }
122
137
 
138
+ interface IssuedAdvice {
139
+ advice: string;
140
+ normalizedQuestion?: string;
141
+ trigger: ConsultationTrigger;
142
+ }
143
+
123
144
  interface AdviceLedger {
124
145
  draftConsultations: number;
125
- issued: Map<
126
- string,
127
- {
128
- advice: string;
129
- normalizedQuestion?: string;
130
- trigger: ConsultationTrigger;
131
- }
132
- >;
146
+ issued: Map<string, IssuedAdvice>;
133
147
  lastAdvice?: string;
134
148
  outcomes: number;
135
149
  pending: Set<string>;
@@ -196,7 +210,7 @@ export class AdvisorSessionState {
196
210
  this.#blockedReason ??= reason;
197
211
  }
198
212
 
199
- recordToolCall(toolName: string, input: unknown, threshold: number) {
213
+ recordToolCall<Input>(toolName: string, input: Input, threshold: number) {
200
214
  if (toolName === "ask_advisor") {
201
215
  return false;
202
216
  }
@@ -269,11 +283,11 @@ export class AdvisorSessionState {
269
283
  draft = false,
270
284
  normalizedQuestion?: string
271
285
  ) {
272
- this.#ledger.issued.set(id, {
273
- advice,
274
- ...(normalizedQuestion ? { normalizedQuestion } : {}),
275
- trigger,
276
- });
286
+ const issued: IssuedAdvice = { advice, trigger };
287
+ if (normalizedQuestion) {
288
+ issued.normalizedQuestion = normalizedQuestion;
289
+ }
290
+ this.#ledger.issued.set(id, issued);
277
291
  this.#ledger.lastAdvice = advice;
278
292
  if (draft) {
279
293
  this.#ledger.draftConsultations += 1;
@@ -300,12 +314,11 @@ export class AdvisorSessionState {
300
314
  return false;
301
315
  }
302
316
  const mentioned = paths.every((path) => {
303
- const escaped = path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
304
- const boundary =
305
- "(^|[\\s\\\"'`()\\[])" +
306
- escaped +
307
- "(?=$|[\\s\\\"'`),;:!?\\]]|\\.(?=\\s|$))";
308
- return new RegExp(boundary).test(advice);
317
+ const escaped = path.replaceAll(/[.*+?^${}()|[\]\\]/gu, "\\$&");
318
+ const boundary = `(^|[\\s"'\`()\\[])${
319
+ escaped
320
+ }(?=$|[\\s"'\`),;:!?\\]]|\\.(?=\\s|$))`;
321
+ return new RegExp(boundary, "u").test(advice);
309
322
  });
310
323
  if (!mentioned) {
311
324
  return false;
@@ -354,7 +367,7 @@ export class AdvisorSessionState {
354
367
  (item) => item.kind === "gate"
355
368
  );
356
369
  return (
357
- (["proceed", "revise", "blocked"] as GateDecision[])
370
+ (["proceed", "revise", "blocked"] as const)
358
371
  .map(
359
372
  (decision) =>
360
373
  [
@@ -375,15 +388,17 @@ export class AdvisorSessionState {
375
388
 
376
389
  #triggersLine() {
377
390
  return (
378
- [
379
- "manual",
380
- "executor-requested",
381
- "turn-gate",
382
- "repeated-tool-call",
383
- "completion-review",
384
- "custom-rule",
385
- ]
386
- .filter((trigger) => this.#countTrigger(trigger as AdvisorTrigger) > 0)
391
+ (
392
+ [
393
+ "manual",
394
+ "executor-requested",
395
+ "turn-gate",
396
+ "repeated-tool-call",
397
+ "completion-review",
398
+ "custom-rule",
399
+ ] as const
400
+ )
401
+ .filter((trigger) => this.#countTrigger(trigger) > 0)
387
402
  .join(", ") || "none"
388
403
  );
389
404
  }
@@ -14,6 +14,24 @@ export interface ToolResultTruncation {
14
14
 
15
15
  const OMITTED_MARKER = "[... omitted tool-result section ...]";
16
16
 
17
+ const collect = (
18
+ candidates: string[],
19
+ maxEntries: number,
20
+ maxContentBytes: number
21
+ ) => {
22
+ const selected: string[] = [];
23
+ let used = 0;
24
+ for (const line of candidates.slice(0, maxEntries)) {
25
+ const next = used + byteLength(line) + (selected.length ? 1 : 0);
26
+ if (next > maxContentBytes) {
27
+ break;
28
+ }
29
+ selected.push(line);
30
+ used = next;
31
+ }
32
+ return selected;
33
+ };
34
+
17
35
  export const capToolResult = (
18
36
  value: string,
19
37
  maxLines = DEFAULT_ADVISOR_TOOL_RESULT_MAX_LINES,
@@ -53,23 +71,6 @@ export const capToolResult = (
53
71
  }
54
72
  const headCount = Math.floor((maxLines - 1) / 2);
55
73
  const tailCount = maxLines - 1 - headCount;
56
- const collect = (
57
- candidates: string[],
58
- maxEntries: number,
59
- maxContentBytes: number
60
- ) => {
61
- const selected: string[] = [];
62
- let used = 0;
63
- for (const line of candidates.slice(0, maxEntries)) {
64
- const next = used + byteLength(line) + (selected.length ? 1 : 0);
65
- if (next > maxContentBytes) {
66
- break;
67
- }
68
- selected.push(line);
69
- used = next;
70
- }
71
- return selected;
72
- };
73
74
  const availableBytes = maxBytes - markerBytes - 2;
74
75
  const head = collect(lines, headCount, Math.floor(availableBytes / 2));
75
76
  const tail = collect(
@@ -1,9 +1,7 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import {
3
- readTrackedFiles,
4
- readUntrackedFiles,
5
- type UntrackedAttachment,
6
- } from "../attachments.ts";
2
+
3
+ import { readTrackedFiles, readUntrackedFiles } from "../attachments.ts";
4
+ import type { UntrackedAttachment } from "../attachments.ts";
7
5
  import {
8
6
  advisorGitContextMaxCharsRef,
9
7
  advisorGitContextRef,
@@ -13,19 +11,13 @@ import {
13
11
  advisorUntrackedContentRef,
14
12
  contextMaxCharsRef,
15
13
  } from "../config/state.ts";
16
- import {
17
- clampGitContextLevel,
18
- collectGitContext,
19
- type GitContextLevel,
20
- } from "../git.ts";
14
+ import { clampGitContextLevel, collectGitContext } from "../git.ts";
15
+ import type { GitContextLevel } from "../git.ts";
21
16
  import { readProjectPreferences } from "../preferences.ts";
22
17
  import { redactAndCapText, redactSecrets } from "../redaction.ts";
23
- import {
24
- runAdvisorScout,
25
- type ScoutLifecycleEvent,
26
- type ScoutOutcome,
27
- } from "../scout.ts";
28
18
  import { curateAdvisorConversation } from "../scout-curation.ts";
19
+ import { runAdvisorScout } from "../scout.ts";
20
+ import type { ScoutLifecycleEvent, ScoutOutcome } from "../scout.ts";
29
21
  import {
30
22
  advisorGitContextBudget,
31
23
  advisorRepositoryContext,
@@ -1,6 +1,8 @@
1
1
  import { randomUUID } from "node:crypto";
2
+
2
3
  import type { Message } from "@earendil-works/pi-ai/compat";
3
4
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+
4
6
  import {
5
7
  advisorEffortRef,
6
8
  advisorRedactSecretsRef,
@@ -14,6 +16,7 @@ import type { ScoutLifecycleEvent } from "../scout.ts";
14
16
  import type { ConsultationTrigger, GateTrigger } from "../session-state.ts";
15
17
  import { assembleConsultationContext } from "./consult-context.ts";
16
18
  import { parseAutomaticDecision } from "./gate-protocol.ts";
19
+ import { advisorModelAccessReason } from "./model-access.ts";
17
20
  import {
18
21
  ADVISOR_DECISION_SYSTEM,
19
22
  ADVISOR_SYSTEM,
@@ -21,7 +24,7 @@ import {
21
24
  } from "./prompts.ts";
22
25
  import type { AdvisorConsultationResult, AdvisorGateOutcome } from "./types.ts";
23
26
 
24
- // biome-ignore lint/performance/noBarrelFile: preserves the tools facade's historical re-export of the moved curation entry point.
27
+ // Re-export preserves the tools facade's historical curation entry point.
25
28
  export { curateAdvisorConversation } from "../scout-curation.ts";
26
29
 
27
30
  /** Thrown when the Advisor produced an empty response body. */
@@ -54,6 +57,10 @@ const collectAdvisorResponse = async (
54
57
  ) => {
55
58
  const { ctx, question, signal, systemPrompt } = options;
56
59
  loadConfig(ctx);
60
+ const accessReason = advisorModelAccessReason(ctx);
61
+ if (accessReason) {
62
+ throw new Error(accessReason);
63
+ }
57
64
  const resolved = await resolveConfiguredModel(ctx, advisorRef, "Advisor");
58
65
  const context = await assembleConsultationContext(options);
59
66
 
@@ -95,9 +102,9 @@ const collectAdvisorResponse = async (
95
102
  if (!markdown.trim()) {
96
103
  throw new AdvisorNoAdviceError();
97
104
  }
98
- return {
105
+ const response: Omit<AdvisorConsultationResult, "adviceId" | "trigger"> = {
99
106
  draftBytes: context.draftText
100
- ? Buffer.byteLength(context.draftText, "utf8")
107
+ ? Buffer.byteLength(context.draftText, "utf-8")
101
108
  : undefined,
102
109
  markdown,
103
110
  model: advisorRef,
@@ -108,8 +115,11 @@ const collectAdvisorResponse = async (
108
115
  untrackedBytes:
109
116
  context.untracked.reduce((sum, item) => sum + item.bytes, 0) || undefined,
110
117
  usage: streamed.usage,
111
- ...(context.scout ? { scout: context.scout } : {}),
112
118
  };
119
+ if (context.scout) {
120
+ response.scout = context.scout;
121
+ }
122
+ return response;
113
123
  };
114
124
 
115
125
  export const consultAdvisor = async (
@@ -3,6 +3,7 @@ import type {
3
3
  ToolCallEvent,
4
4
  ToolCallEventResult,
5
5
  } from "@earendil-works/pi-coding-agent";
6
+
6
7
  import {
7
8
  advisorBlockOnBlockedRef,
8
9
  getAdvisorMaxCallsPerSession,
@@ -1,20 +1,30 @@
1
1
  import type { GateDecision } from "../session-state.ts";
2
2
  import type { AdvisorGateFailure, AdvisorGateResult } from "./types.ts";
3
3
 
4
- const DECISION_LINE = /^Decision\s*:\s*(proceed|revise|blocked)\s*$/i;
5
- const CODE_FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
6
- const LINE_BREAK = /\r?\n/;
4
+ /** Named-group accessor; exec matches of these regexes always populate groups. */
5
+ const namedGroups = (match: RegExpExecArray): Record<string, string> =>
6
+ match.groups ?? {};
7
+
8
+ const DECISION_LINE =
9
+ /^Decision\s*:\s*(?<decision>proceed|revise|blocked)\s*$/iu;
10
+ const CODE_FENCE = /^ {0,3}(?<marker>`{3,}|~{3,})(?<suffix>.*)$/u;
11
+ const LINE_BREAK = /\r?\n/u;
7
12
 
8
13
  interface FenceState {
9
14
  character: string;
10
15
  length: number;
11
16
  }
12
17
 
18
+ interface FenceAdvance {
19
+ closed: boolean;
20
+ openingFence: FenceState | undefined;
21
+ }
22
+
13
23
  const advanceFence = (
14
24
  openingFence: FenceState | undefined,
15
25
  marker: string,
16
26
  suffix: string
17
- ): { closed: boolean; openingFence: FenceState | undefined } => {
27
+ ): FenceAdvance => {
18
28
  if (!openingFence) {
19
29
  if (marker[0] === "`" && suffix.includes("`")) {
20
30
  return { closed: false, openingFence: undefined };
@@ -59,7 +69,8 @@ export const parseAutomaticDecision = (
59
69
  ok: false,
60
70
  };
61
71
  }
62
- const decision = match[1].toLowerCase() as GateDecision;
72
+ // SAFETY: DECISION_LINE's decision group only matches proceed, revise, or blocked.
73
+ const decision = namedGroups(match).decision.toLowerCase() as GateDecision;
63
74
  let openingFence: FenceState | undefined;
64
75
  const decisions: string[] = [];
65
76
  let pendingFencedDecisions: string[] = [];
@@ -72,10 +83,11 @@ export const parseAutomaticDecision = (
72
83
  // open.
73
84
  const fence = CODE_FENCE.exec(line);
74
85
  if (fence) {
86
+ const groups = namedGroups(fence);
75
87
  const { closed, openingFence: nextOpeningFence } = advanceFence(
76
88
  openingFence,
77
- fence[1],
78
- fence[2]
89
+ groups.marker,
90
+ groups.suffix
79
91
  );
80
92
  openingFence = nextOpeningFence;
81
93
  if (closed) {
@@ -87,7 +99,7 @@ export const parseAutomaticDecision = (
87
99
  if (!subsequent) {
88
100
  continue;
89
101
  }
90
- const repeated = subsequent[1].trim().toLowerCase();
102
+ const repeated = namedGroups(subsequent).decision.trim().toLowerCase();
91
103
  if (openingFence) {
92
104
  pendingFencedDecisions.push(repeated);
93
105
  } else {
@@ -97,21 +109,22 @@ export const parseAutomaticDecision = (
97
109
  if (openingFence) {
98
110
  decisions.push(...pendingFencedDecisions);
99
111
  }
100
- for (const repeated of decisions) {
101
- if (repeated === decision) {
102
- return {
103
- category: "duplicate-decision",
104
- markdown: text,
105
- message: "Advisor gate response contains duplicate decision lines.",
106
- ok: false,
107
- };
108
- }
109
- return {
110
- category: "contradictory-decision",
111
- markdown: text,
112
- message: "Advisor gate response contains contradictory decision lines.",
113
- ok: false,
114
- };
112
+ const [repeated] = decisions;
113
+ if (repeated !== undefined) {
114
+ return repeated === decision
115
+ ? {
116
+ category: "duplicate-decision",
117
+ markdown: text,
118
+ message: "Advisor gate response contains duplicate decision lines.",
119
+ ok: false,
120
+ }
121
+ : {
122
+ category: "contradictory-decision",
123
+ markdown: text,
124
+ message:
125
+ "Advisor gate response contains contradictory decision lines.",
126
+ ok: false,
127
+ };
115
128
  }
116
129
  return {
117
130
  decision,
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { Fetch } from "@typesafe-ai/sdk";
3
+
3
4
  import {
4
5
  advisorJevFilterEnabledRef,
5
6
  advisorJevFilterNoulMarginRef,
@@ -14,7 +15,8 @@ import {
14
15
  screeningQuestions,
15
16
  } from "../jev/questions.ts";
16
17
  import { buildJevState } from "../jev/state.ts";
17
- import { type JevCredentials, resolveJevTransport } from "../jev/transport.ts";
18
+ import { resolveJevTransport } from "../jev/transport.ts";
19
+ import type { JevCredentials } from "../jev/transport.ts";
18
20
  import type { AdvisorSessionState } from "../session-state.ts";
19
21
  import { createOutageNotifier } from "./outage-notifier.ts";
20
22
 
@@ -42,7 +44,7 @@ export interface ScreeningDeps {
42
44
  }
43
45
 
44
46
  export const normalizeScreeningQuestion = (question: string | undefined) =>
45
- question?.trim().toLowerCase().replace(/\s+/g, " ") || undefined;
47
+ question?.trim().toLowerCase().replaceAll(/\s+/gu, " ") || undefined;
46
48
 
47
49
  const REATTACHED_ADVICE_CAP_BYTES = 4 * 1024;
48
50
 
@@ -64,45 +66,6 @@ export const resetJevOutageNotification = outageNotifier.reset;
64
66
 
65
67
  const allow = (): ScreeningOutcome => ({ decision: "allow" });
66
68
 
67
- /**
68
- * Fail-open screening: decides run/skip for one ask_advisor call. Disabled or
69
- * simple mode, missing credentials, and every Jev failure allow the
70
- * consultation; only the hard conjunction (negligible-mass ≥ skip confidence
71
- * AND confidently self-answerable) or an exact repeat skips.
72
- */
73
- export const screenConsultation = (
74
- ctx: ExtensionContext,
75
- session: AdvisorSessionState,
76
- options: ScreenConsultationOptions,
77
- deps: ScreeningDeps = {}
78
- ): Promise<ScreeningOutcome> => {
79
- if (isSimpleMode()) {
80
- return Promise.resolve(allow());
81
- }
82
- // Repeat reattachment and its force/passthrough escape hatch are code-side
83
- // and free: they run regardless of the Jev enable flag, which only gates
84
- // the (paid, network) Jev screening call.
85
- const normalizedQuestion = normalizeScreeningQuestion(options.question);
86
- const bypass = bypassOutcome(session, options, normalizedQuestion);
87
- if (bypass) {
88
- return Promise.resolve(bypass);
89
- }
90
- const reattached = session.reattachedAdviceFor(normalizedQuestion);
91
- if (reattached) {
92
- session.recordJevFilterSkipped(true, normalizedQuestion);
93
- return Promise.resolve({
94
- decision: "skip",
95
- kind: "repeat",
96
- reason: "already answered earlier in this session",
97
- reattachedAdvice: reattached.slice(0, REATTACHED_ADVICE_CAP_BYTES),
98
- });
99
- }
100
- if (!advisorJevFilterEnabledRef) {
101
- return Promise.resolve(allow());
102
- }
103
- return screenWithJev(ctx, session, options, deps, normalizedQuestion);
104
- };
105
-
106
69
  const bypassOutcome = (
107
70
  session: AdvisorSessionState,
108
71
  options: ScreenConsultationOptions,
@@ -193,6 +156,45 @@ const screenWithJev = async (
193
156
  }
194
157
  };
195
158
 
159
+ /**
160
+ * Fail-open screening: decides run/skip for one ask_advisor call. Disabled or
161
+ * simple mode, missing credentials, and every Jev failure allow the
162
+ * consultation; only the hard conjunction (negligible-mass ≥ skip confidence
163
+ * AND confidently self-answerable) or an exact repeat skips.
164
+ */
165
+ export const screenConsultation = (
166
+ ctx: ExtensionContext,
167
+ session: AdvisorSessionState,
168
+ options: ScreenConsultationOptions,
169
+ deps: ScreeningDeps = {}
170
+ ): Promise<ScreeningOutcome> => {
171
+ if (isSimpleMode()) {
172
+ return Promise.resolve(allow());
173
+ }
174
+ // Repeat reattachment and its force/passthrough escape hatch are code-side
175
+ // and free: they run regardless of the Jev enable flag, which only gates
176
+ // the (paid, network) Jev screening call.
177
+ const normalizedQuestion = normalizeScreeningQuestion(options.question);
178
+ const bypass = bypassOutcome(session, options, normalizedQuestion);
179
+ if (bypass) {
180
+ return Promise.resolve(bypass);
181
+ }
182
+ const reattached = session.reattachedAdviceFor(normalizedQuestion);
183
+ if (reattached) {
184
+ session.recordJevFilterSkipped(true, normalizedQuestion);
185
+ return Promise.resolve({
186
+ decision: "skip",
187
+ kind: "repeat",
188
+ reason: "already answered earlier in this session",
189
+ reattachedAdvice: reattached.slice(0, REATTACHED_ADVICE_CAP_BYTES),
190
+ });
191
+ }
192
+ if (!advisorJevFilterEnabledRef) {
193
+ return Promise.resolve(allow());
194
+ }
195
+ return screenWithJev(ctx, session, options, deps, normalizedQuestion);
196
+ };
197
+
196
198
  export const screeningSkipText = (outcome: {
197
199
  kind: JevSkipKind;
198
200
  reattachedAdvice?: string;
@@ -1,5 +1,9 @@
1
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import type {
2
+ ExtensionContext,
3
+ TurnEndEvent,
4
+ } from "@earendil-works/pi-coding-agent";
2
5
  import type { Fetch } from "@typesafe-ai/sdk";
6
+
3
7
  import {
4
8
  advisorJevTurnGateEveryTurnsRef,
5
9
  advisorJevTurnGateNoulThresholdRef,
@@ -10,11 +14,13 @@ import { herdrAdvisorActivity } from "../herdr.ts";
10
14
  import { JevFailure, jevClientFromCredentials } from "../jev/client.ts";
11
15
  import { composeTurnGateVerdict } from "../jev/questions.ts";
12
16
  import { buildJevState } from "../jev/state.ts";
13
- import { type JevCredentials, resolveJevTransport } from "../jev/transport.ts";
17
+ import { resolveJevTransport } from "../jev/transport.ts";
18
+ import type { JevCredentials } from "../jev/transport.ts";
14
19
  import type { AdvisorSessionState } from "../session-state.ts";
15
20
  import { advisorUsageCost } from "../usage.ts";
16
21
  import type { consultAdvisor } from "./consultation.ts";
17
22
  import { updateAdvisorUsageStatus } from "./gate-policy.ts";
23
+ import { advisorModelIsAllowed } from "./model-access.ts";
18
24
  import { createOutageNotifier } from "./outage-notifier.ts";
19
25
 
20
26
  export const turnGateQuestion = {
@@ -34,6 +40,17 @@ export interface JevTurnGateDeps {
34
40
  resolveTransport?: () => Promise<JevCredentials | undefined>;
35
41
  }
36
42
 
43
+ export interface TurnGateCallDetails {
44
+ question: string;
45
+ turn: number;
46
+ }
47
+
48
+ export interface TurnGateResultDetails {
49
+ advisor: string;
50
+ text: string;
51
+ usage?: unknown;
52
+ }
53
+
37
54
  export interface JevTurnGateRegistration {
38
55
  activeTools: () => string[];
39
56
  consult: typeof consultAdvisor;
@@ -41,7 +58,7 @@ export interface JevTurnGateRegistration {
41
58
  send: (message: {
42
59
  content: string;
43
60
  customType: string;
44
- details: Record<string, unknown>;
61
+ details: TurnGateCallDetails | TurnGateResultDetails;
45
62
  display: boolean;
46
63
  }) => void;
47
64
  session: AdvisorSessionState;
@@ -81,6 +98,7 @@ export const handleJevTurnEnd = async (
81
98
  isSimpleMode() ||
82
99
  session.blocked ||
83
100
  !registration.activeTools().includes("ask_advisor") ||
101
+ !advisorModelIsAllowed(ctx) ||
84
102
  !session.canConsult(getAdvisorMaxCallsPerSession())
85
103
  ) {
86
104
  return;
@@ -171,13 +189,15 @@ export const handleJevTurnEnd = async (
171
189
  }
172
190
  };
173
191
 
192
+ type TurnEndRegistrar = (
193
+ event: "turn_end",
194
+ handler: (event: TurnEndEvent, ctx: ExtensionContext) => void | Promise<void>
195
+ ) => void;
196
+
174
197
  /** Wires the turn_end handler on an ExtensionAPI-like surface. */
175
- export const registerJevTurnGate = <T extends string>(
176
- on: (
177
- event: T,
178
- handler: (event: unknown, ctx: ExtensionContext) => unknown
179
- ) => void,
198
+ export const registerJevTurnGate = (
199
+ on: TurnEndRegistrar,
180
200
  registration: JevTurnGateRegistration
181
201
  ): void => {
182
- on("turn_end" as T, (_event, ctx) => handleJevTurnEnd(registration, ctx));
202
+ on("turn_end", (_event, ctx) => handleJevTurnEnd(registration, ctx));
183
203
  };