pi-advisor-flow 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/CHANGELOG.md +34 -17
  2. package/README.md +10 -8
  3. package/dist/index.js +2852 -868
  4. package/extensions/index.ts +2 -1
  5. package/package.json +55 -40
  6. package/src/attachments.ts +11 -10
  7. package/src/commands/activation-preparation.ts +1 -0
  8. package/src/commands/activation.ts +2 -0
  9. package/src/commands/lifecycle.ts +1 -0
  10. package/src/commands/manual-command.ts +7 -1
  11. package/src/commands/manual-consultation.ts +103 -79
  12. package/src/commands/manual-progress.ts +7 -5
  13. package/src/commands/model-commands.ts +1 -1
  14. package/src/commands/model-options.ts +14 -3
  15. package/src/commands/model-picker.ts +1 -0
  16. package/src/commands/registration.ts +1 -0
  17. package/src/commands/renderers.ts +67 -55
  18. package/src/commands/runtime.ts +16 -14
  19. package/src/commands/settings-commands.ts +10 -4
  20. package/src/commands/settings-persistence.ts +64 -2
  21. package/src/commands/types.ts +2 -0
  22. package/src/commands.ts +0 -1
  23. package/src/config/args.ts +1 -1
  24. package/src/config/defaults.ts +83 -1
  25. package/src/config/schema.ts +152 -15
  26. package/src/config/state.ts +80 -2
  27. package/src/config/storage.ts +38 -22
  28. package/src/config/types.ts +45 -3
  29. package/src/config/validation.ts +33 -21
  30. package/src/config.ts +3 -0
  31. package/src/content-utils.ts +24 -4
  32. package/src/conversation.ts +16 -15
  33. package/src/git.ts +10 -3
  34. package/src/herdr-block.ts +83 -0
  35. package/src/herdr-shared.ts +108 -0
  36. package/src/herdr.ts +30 -192
  37. package/src/jev/client.ts +338 -0
  38. package/src/jev/failure.ts +19 -0
  39. package/src/jev/key-store.ts +267 -0
  40. package/src/jev/ledger.ts +228 -0
  41. package/src/jev/questions.ts +122 -0
  42. package/src/jev/state.ts +50 -0
  43. package/src/jev/transport.ts +63 -0
  44. package/src/model-stream.ts +35 -37
  45. package/src/outcomes.ts +20 -12
  46. package/src/pi-settings.ts +4 -9
  47. package/src/preferences.ts +4 -2
  48. package/src/redaction.ts +10 -12
  49. package/src/scout-context.ts +59 -60
  50. package/src/scout-curation.ts +4 -13
  51. package/src/scout-groups.ts +178 -175
  52. package/src/scout-protocol.ts +74 -70
  53. package/src/scout-reconstruct.ts +1 -1
  54. package/src/scout.ts +115 -73
  55. package/src/session-state.ts +148 -39
  56. package/src/tool-result-cap.ts +18 -17
  57. package/src/tools/consult-context.ts +7 -15
  58. package/src/tools/consultation.ts +14 -4
  59. package/src/tools/gate-policy.ts +1 -0
  60. package/src/tools/gate-protocol.ts +36 -23
  61. package/src/tools/jev-filter.ts +204 -0
  62. package/src/tools/jev-turn-gate.ts +203 -0
  63. package/src/tools/loop-gate.ts +32 -20
  64. package/src/tools/model-access.ts +46 -0
  65. package/src/tools/outage-notifier.ts +30 -0
  66. package/src/tools/prompts.ts +20 -25
  67. package/src/tools/register-ask-advisor.ts +73 -17
  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 +149 -73
  71. package/src/tools/registration.ts +15 -0
  72. package/src/tools/render-advisor-result.ts +77 -35
  73. package/src/tools/render-common.ts +16 -19
  74. package/src/tools/scout-status.ts +42 -27
  75. package/src/tools/types.ts +11 -0
  76. package/src/tools.ts +0 -1
  77. package/src/ui/jev-setup-submenu.ts +350 -0
  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 +62 -0
  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 +200 -2
  87. package/src/ui/settings-list-adapter.ts +15 -12
  88. package/src/ui/settings-mutations.ts +169 -47
  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 +25 -0
  92. package/src/ui.ts +0 -1
  93. package/src/usage.ts +29 -20
package/src/scout.ts CHANGED
@@ -1,20 +1,22 @@
1
1
  import type { Message } from "@earendil-works/pi-ai/compat";
2
2
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+
3
4
  import { executorEffortRef, executorRef } from "./config/state.ts";
4
- import {
5
- type CollectedTextStream,
6
- collectTextStream,
7
- type ResolvedConfiguredModel,
8
- resolveConfiguredModel,
5
+ import { isRecord, isString } from "./content-utils.ts";
6
+ import { collectTextStream, resolveConfiguredModel } from "./model-stream.ts";
7
+ import type {
8
+ CollectedTextStream,
9
+ ResolvedConfiguredModel,
9
10
  } from "./model-stream.ts";
10
11
  import { groupWire } from "./scout-groups.ts";
11
12
  import { reconstructScoutConversation } from "./scout-reconstruct.ts";
12
13
  import {
13
14
  SCOUT_SELECTION_MAX_IDS,
14
15
  SCOUT_SYNTHESIS_MAX_BYTES,
15
- type ScoutManifest,
16
16
  } from "./scout-types.ts";
17
+ import type { ScoutManifest } from "./scout-types.ts";
17
18
  import { snapshotAdvisorUsage } from "./usage.ts";
19
+ import type { AdvisorUsageSnapshot } from "./usage.ts";
18
20
 
19
21
  const SCOUT_TIMEOUT_MS = 30_000;
20
22
 
@@ -48,7 +50,7 @@ interface ScoutMetrics {
48
50
  latencyMs: number;
49
51
  omittedBeforeScout: number;
50
52
  selectedCount: number;
51
- usage?: unknown;
53
+ usage?: AdvisorUsageSnapshot;
52
54
  }
53
55
 
54
56
  export interface ScoutSelection {
@@ -100,8 +102,8 @@ const defaultDependencies: ScoutDependencies = {
100
102
  collect: collectTextStream,
101
103
  resolve: resolveConfiguredModel,
102
104
  };
103
- const byteLength = (value: string) => Buffer.byteLength(value, "utf8");
104
- const AUTH_ERROR_PATTERN = /api key|auth|login|credential/i;
105
+ const byteLength = (value: string) => Buffer.byteLength(value, "utf-8");
106
+ const AUTH_ERROR_PATTERN = /api key|auth|login|credential/iu;
105
107
 
106
108
  const manifestMessage = (manifest: ScoutManifest): Message => ({
107
109
  content: [
@@ -120,6 +122,9 @@ const manifestMessage = (manifest: ScoutManifest): Message => ({
120
122
  timestamp: Date.now(),
121
123
  });
122
124
 
125
+ const isStringArray = (value: unknown): value is string[] =>
126
+ Array.isArray(value) && value.every(isString);
127
+
123
128
  export const parseScoutSelection = (
124
129
  text: string,
125
130
  manifest: ScoutManifest
@@ -133,11 +138,10 @@ export const parseScoutSelection = (
133
138
  } catch (error) {
134
139
  throw new Error("Scout response is not a JSON object.", { cause: error });
135
140
  }
136
- if (!value || typeof value !== "object" || Array.isArray(value)) {
141
+ if (!isRecord(value)) {
137
142
  throw new Error("Scout response must be a JSON object.");
138
143
  }
139
- const record = value as Record<string, unknown>;
140
- const keys = Object.keys(record).sort();
144
+ const keys = Object.keys(value).toSorted();
141
145
  if (
142
146
  keys.length !== 2 ||
143
147
  keys[0] !== "selectedIds" ||
@@ -147,15 +151,10 @@ export const parseScoutSelection = (
147
151
  "Scout response must contain only selectedIds and synthesis."
148
152
  );
149
153
  }
150
- if (
151
- !(
152
- Array.isArray(record.selectedIds) &&
153
- record.selectedIds.every((id) => typeof id === "string")
154
- )
155
- ) {
154
+ if (!isStringArray(value.selectedIds)) {
156
155
  throw new Error("Scout selectedIds must be an array of strings.");
157
156
  }
158
- const selectedIds = record.selectedIds as string[];
157
+ const { selectedIds } = value;
159
158
  if (new Set(selectedIds).size !== selectedIds.length) {
160
159
  throw new Error("Scout selected duplicate group IDs.");
161
160
  }
@@ -177,17 +176,17 @@ export const parseScoutSelection = (
177
176
  const normalizedIds = manifest.groups
178
177
  .filter((group) => retained.has(group.id))
179
178
  .map((group) => group.id);
180
- if (typeof record.synthesis !== "string") {
181
- throw new Error("Scout synthesis must be a string.");
179
+ if (!isString(value.synthesis)) {
180
+ throw new TypeError("Scout synthesis must be a string.");
182
181
  }
183
- if (byteLength(record.synthesis) > SCOUT_SYNTHESIS_MAX_BYTES) {
182
+ if (byteLength(value.synthesis) > SCOUT_SYNTHESIS_MAX_BYTES) {
184
183
  throw new Error(
185
184
  `Scout synthesis exceeds ${SCOUT_SYNTHESIS_MAX_BYTES} UTF-8 bytes.`
186
185
  );
187
186
  }
188
187
  return {
189
188
  selectedIds: normalizedIds,
190
- synthesis: knownSelectedIds.length > 0 ? record.synthesis : "",
189
+ synthesis: knownSelectedIds.length > 0 ? value.synthesis : "",
191
190
  };
192
191
  };
193
192
 
@@ -212,6 +211,84 @@ const classifyResolutionError = (message: string): ScoutFallbackCategory => {
212
211
  return "provider-error";
213
212
  };
214
213
 
214
+ const setupAbortWatch = (
215
+ parentSignal: AbortSignal | undefined,
216
+ timeoutMs: number
217
+ ) => {
218
+ const controller = new AbortController();
219
+ let timedOut = false;
220
+ const abortFromParent = () => controller.abort(parentSignal?.reason);
221
+ parentSignal?.addEventListener("abort", abortFromParent, { once: true });
222
+ const timer = setTimeout(() => {
223
+ timedOut = true;
224
+ controller.abort(new Error("Scout timed out."));
225
+ }, timeoutMs);
226
+ timer.unref?.();
227
+ const { promise: abortPromise, reject: rejectOnAbort } =
228
+ Promise.withResolvers<never>();
229
+ const onControllerAbort = () =>
230
+ rejectOnAbort(controller.signal.reason ?? new Error("Scout aborted."));
231
+ controller.signal.addEventListener("abort", onControllerAbort, {
232
+ once: true,
233
+ });
234
+ return {
235
+ abortPromise,
236
+ controller,
237
+ teardown: () => {
238
+ clearTimeout(timer);
239
+ parentSignal?.removeEventListener("abort", abortFromParent);
240
+ controller.signal.removeEventListener("abort", onControllerAbort);
241
+ },
242
+ wasTimedOut: () => timedOut,
243
+ };
244
+ };
245
+
246
+ type ScoutStreamResult =
247
+ | { ok: true; streamed: CollectedTextStream }
248
+ | { ok: false; category: ScoutFallbackCategory; message: string };
249
+
250
+ const streamScoutResponse = async (
251
+ dependencies: ScoutDependencies,
252
+ resolved: ResolvedConfiguredModel,
253
+ manifest: ScoutManifest,
254
+ parentSignal: AbortSignal | undefined,
255
+ timeoutMs: number,
256
+ publish: (event: ScoutLifecycleEvent) => void
257
+ ): Promise<ScoutStreamResult> => {
258
+ const { abortPromise, controller, teardown, wasTimedOut } = setupAbortWatch(
259
+ parentSignal,
260
+ timeoutMs
261
+ );
262
+ try {
263
+ const collection = dependencies.collect(resolved, {
264
+ messages: [manifestMessage(manifest)],
265
+ onChunk: (thinking, text) => {
266
+ if (!controller.signal.aborted) {
267
+ publish({ model: executorRef, text, thinking, type: "chunk" });
268
+ }
269
+ },
270
+ reasoning: executorEffortRef,
271
+ signal: controller.signal,
272
+ systemPrompt: SCOUT_SYSTEM,
273
+ });
274
+ return {
275
+ ok: true,
276
+ streamed: await Promise.race([collection, abortPromise]),
277
+ };
278
+ } catch (error) {
279
+ const message = error instanceof Error ? error.message : String(error);
280
+ return wasTimedOut()
281
+ ? {
282
+ category: "timeout",
283
+ message: `Scout timed out after ${timeoutMs} ms.`,
284
+ ok: false,
285
+ }
286
+ : { category: "provider-error", message, ok: false };
287
+ } finally {
288
+ teardown();
289
+ }
290
+ };
291
+
215
292
  export const runAdvisorScout = async (
216
293
  ctx: ExtensionContext,
217
294
  manifest: ScoutManifest,
@@ -231,7 +308,7 @@ export const runAdvisorScout = async (
231
308
  const fallback = (
232
309
  category: ScoutFallbackCategory,
233
310
  message: string,
234
- usage?: unknown
311
+ usage?: AdvisorUsageSnapshot
235
312
  ): ScoutOutcome => {
236
313
  const outcome = {
237
314
  category,
@@ -266,69 +343,34 @@ export const runAdvisorScout = async (
266
343
  return cancelled();
267
344
  }
268
345
  publish({ model: executorRef, type: "call" });
269
- const controller = new AbortController();
270
- let timedOut = false;
271
- const abortFromParent = () => controller.abort(parentSignal?.reason);
272
- parentSignal?.addEventListener("abort", abortFromParent, { once: true });
273
- const timer = setTimeout(() => {
274
- timedOut = true;
275
- controller.abort(new Error("Scout timed out."));
276
- }, timeoutMs);
277
- timer.unref?.();
278
-
279
- let rejectOnAbort: ((reason?: unknown) => void) | undefined;
280
- const onControllerAbort = () =>
281
- rejectOnAbort?.(controller.signal.reason ?? new Error("Scout aborted."));
282
- const abortPromise = new Promise<never>((_resolve, reject) => {
283
- rejectOnAbort = reject;
284
- controller.signal.addEventListener("abort", onControllerAbort, {
285
- once: true,
286
- });
287
- });
288
- const teardown = () => {
289
- clearTimeout(timer);
290
- parentSignal?.removeEventListener("abort", abortFromParent);
291
- controller.signal.removeEventListener("abort", onControllerAbort);
292
- };
293
346
 
294
- let streamed: CollectedTextStream;
295
- try {
296
- const collection = dependencies.collect(resolved, {
297
- messages: [manifestMessage(manifest)],
298
- onChunk: (thinking, text) => {
299
- if (!controller.signal.aborted) {
300
- publish({ model: executorRef, text, thinking, type: "chunk" });
301
- }
302
- },
303
- reasoning: executorEffortRef,
304
- signal: controller.signal,
305
- systemPrompt: SCOUT_SYSTEM,
306
- });
307
- streamed = await Promise.race([collection, abortPromise]);
308
- } catch (error) {
309
- teardown();
347
+ const streamed = await streamScoutResponse(
348
+ dependencies,
349
+ resolved,
350
+ manifest,
351
+ parentSignal,
352
+ timeoutMs,
353
+ publish
354
+ );
355
+ if (!streamed.ok) {
310
356
  if (parentSignal?.aborted) {
311
357
  return cancelled();
312
358
  }
313
- const message = error instanceof Error ? error.message : String(error);
314
- return timedOut
315
- ? fallback("timeout", `Scout timed out after ${timeoutMs} ms.`)
316
- : fallback("provider-error", message);
359
+ return fallback(streamed.category, streamed.message);
317
360
  }
318
- teardown();
319
361
  if (parentSignal?.aborted) {
320
362
  return cancelled();
321
363
  }
322
364
 
323
365
  let selection: ScoutSelection;
324
366
  try {
325
- selection = parseScoutSelection(streamed.text, manifest);
367
+ selection = parseScoutSelection(streamed.streamed.text, manifest);
326
368
  } catch (error) {
327
369
  const message = error instanceof Error ? error.message : String(error);
328
370
  return fallback(
329
- streamed.text.trim() ? "invalid-selection" : "empty-response",
371
+ streamed.streamed.text.trim() ? "invalid-selection" : "empty-response",
330
372
  message,
331
- snapshotAdvisorUsage(streamed.usage)
373
+ snapshotAdvisorUsage(streamed.streamed.usage)
332
374
  );
333
375
  }
334
376
 
@@ -346,7 +388,7 @@ export const runAdvisorScout = async (
346
388
  .filter((group) => group.required)
347
389
  .map((group) => group.id),
348
390
  ]).size,
349
- usage: snapshotAdvisorUsage(streamed.usage),
391
+ usage: snapshotAdvisorUsage(streamed.streamed.usage),
350
392
  },
351
393
  model: executorRef,
352
394
  ok: true as const,
@@ -1,13 +1,17 @@
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";
1
5
  import {
2
- type AdvisorUsageTotals,
3
6
  addAdvisorUsage,
4
7
  emptyAdvisorUsageTotals,
5
8
  formatAdvisorUsageStatus,
6
9
  formatAdvisorUsageTotals,
7
10
  } from "./usage.ts";
11
+ import type { AdvisorUsageTotals } from "./usage.ts";
8
12
 
9
13
  export type GateDecision = "proceed" | "revise" | "blocked";
10
- export type ConsultationTrigger = "manual" | "executor-requested";
14
+ export type ConsultationTrigger = "manual" | "executor-requested" | "turn-gate";
11
15
  export type GateTrigger =
12
16
  | "repeated-tool-call"
13
17
  | "completion-review"
@@ -26,7 +30,7 @@ export interface AdvisorInvocationRecord {
26
30
  usage?: unknown;
27
31
  }
28
32
 
29
- const WHITESPACE = /\s/;
33
+ const WHITESPACE = /\s/u;
30
34
  const TIMESTAMP_KEYS = new Set([
31
35
  "createdat",
32
36
  "date",
@@ -36,7 +40,8 @@ const TIMESTAMP_KEYS = new Set([
36
40
  "updatedat",
37
41
  ]);
38
42
  const REQUEST_ID_KEYS = new Set(["correlationid", "requestid", "traceid"]);
39
- const normalizedKey = (key: string) => key.replace(/[-_]/g, "").toLowerCase();
43
+ const normalizedKey = (key: string) =>
44
+ key.replaceAll(/[-_]/gu, "").toLowerCase();
40
45
  const isVolatileKey = (key: string, keys: Set<string>) =>
41
46
  keys.has(normalizedKey(key));
42
47
 
@@ -74,15 +79,24 @@ const normalizeShellWhitespace = (command: string) => {
74
79
 
75
80
  const normalizeString = (value: string) =>
76
81
  value
77
- .replace(/\/(?:private\/)?tmp\/[^\s/]+/g, "/tmp/<temporary>")
78
- .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>");
79
84
 
80
- export const normalizeToolInput = (
85
+ export type NormalizedToolInput =
86
+ | JsonValue
87
+ | undefined
88
+ | NormalizedToolInput[]
89
+ | { [key: string]: NormalizedToolInput };
90
+
91
+ export const normalizeToolInput = <Input>(
81
92
  toolName: string,
82
- input: unknown
83
- ): unknown => {
84
- const visit = (value: unknown, key?: string): unknown => {
85
- if (typeof value === "string") {
93
+ input: Input
94
+ ): NormalizedToolInput => {
95
+ const visit = (
96
+ value: NormalizedToolInput,
97
+ key?: string
98
+ ): NormalizedToolInput => {
99
+ if (isString(value)) {
86
100
  if (key && isVolatileKey(key, TIMESTAMP_KEYS)) {
87
101
  return "<timestamp>";
88
102
  }
@@ -97,21 +111,23 @@ export const normalizeToolInput = (
97
111
  if (Array.isArray(value)) {
98
112
  return value.map((item) => visit(item));
99
113
  }
100
- if (value && typeof value === "object") {
101
- const record = value as Record<string, unknown>;
114
+ if (isRecordOf(value)) {
102
115
  return Object.fromEntries(
103
- Object.keys(record)
104
- .sort()
105
- .map((childKey) => [childKey, visit(record[childKey], childKey)])
116
+ Object.keys(value)
117
+ .toSorted()
118
+ .map((childKey) => [childKey, visit(value[childKey], childKey)])
106
119
  );
107
120
  }
108
121
  return value;
109
122
  };
110
- 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);
111
125
  };
112
126
 
113
- export const normalizedToolSignature = (toolName: string, input: unknown) =>
114
- `${toolName}:${JSON.stringify(normalizeToolInput(toolName, input))}`;
127
+ export const normalizedToolSignature = <Input>(
128
+ toolName: string,
129
+ input: Input
130
+ ) => `${toolName}:${JSON.stringify(normalizeToolInput(toolName, input))}`;
115
131
 
116
132
  interface RepetitionState {
117
133
  count: number;
@@ -119,9 +135,15 @@ interface RepetitionState {
119
135
  previousSignature?: string;
120
136
  }
121
137
 
138
+ interface IssuedAdvice {
139
+ advice: string;
140
+ normalizedQuestion?: string;
141
+ trigger: ConsultationTrigger;
142
+ }
143
+
122
144
  interface AdviceLedger {
123
145
  draftConsultations: number;
124
- issued: Map<string, { advice: string; trigger: ConsultationTrigger }>;
146
+ issued: Map<string, IssuedAdvice>;
125
147
  lastAdvice?: string;
126
148
  outcomes: number;
127
149
  pending: Set<string>;
@@ -155,6 +177,9 @@ export class AdvisorSessionState {
155
177
  #ledger = freshAdviceLedger();
156
178
  #usage = freshUsage();
157
179
  #consumedCalls = 0;
180
+ readonly #jev = new AdvisorJevLedgerState();
181
+ #sessionTurnOrdinal = 0;
182
+ #turnsSinceConsultation = 0;
158
183
 
159
184
  resetTask() {
160
185
  this.#repetition = freshRepetition();
@@ -162,6 +187,9 @@ export class AdvisorSessionState {
162
187
  this.#ledger = freshAdviceLedger();
163
188
  this.#usage = freshUsage();
164
189
  this.#consumedCalls = 0;
190
+ this.#jev.reset();
191
+ this.#sessionTurnOrdinal = 0;
192
+ this.#turnsSinceConsultation = 0;
165
193
  }
166
194
 
167
195
  clearBlocked() {
@@ -182,7 +210,7 @@ export class AdvisorSessionState {
182
210
  this.#blockedReason ??= reason;
183
211
  }
184
212
 
185
- recordToolCall(toolName: string, input: unknown, threshold: number) {
213
+ recordToolCall<Input>(toolName: string, input: Input, threshold: number) {
186
214
  if (toolName === "ask_advisor") {
187
215
  return false;
188
216
  }
@@ -214,6 +242,26 @@ export class AdvisorSessionState {
214
242
  return this.#consumedCalls;
215
243
  }
216
244
 
245
+ /** Counts one completed executor turn on both turn counters. The ordinal
246
+ * feeds the filter override window and summary positions and is never reset
247
+ * by anything; turnsSinceConsultation resets on every consultation. */
248
+ recordCompletedTurn() {
249
+ this.#sessionTurnOrdinal += 1;
250
+ this.#turnsSinceConsultation += 1;
251
+ }
252
+
253
+ resetTurnsSinceConsultation() {
254
+ this.#turnsSinceConsultation = 0;
255
+ }
256
+
257
+ get sessionTurnOrdinal() {
258
+ return this.#sessionTurnOrdinal;
259
+ }
260
+
261
+ get turnsSinceConsultation() {
262
+ return this.#turnsSinceConsultation;
263
+ }
264
+
217
265
  /** Returns a copy of cumulative direct Advisor usage for this session. */
218
266
  get usageTotals(): AdvisorUsageTotals {
219
267
  return { ...this.#usage.totals };
@@ -232,26 +280,45 @@ export class AdvisorSessionState {
232
280
  id: string,
233
281
  advice: string,
234
282
  trigger: ConsultationTrigger,
235
- draft = false
283
+ draft = false,
284
+ normalizedQuestion?: string
236
285
  ) {
237
- this.#ledger.issued.set(id, { advice, trigger });
286
+ const issued: IssuedAdvice = { advice, trigger };
287
+ if (normalizedQuestion) {
288
+ issued.normalizedQuestion = normalizedQuestion;
289
+ }
290
+ this.#ledger.issued.set(id, issued);
238
291
  this.#ledger.lastAdvice = advice;
239
292
  if (draft) {
240
293
  this.#ledger.draftConsultations += 1;
241
294
  }
242
295
  }
296
+
297
+ /** Returns earlier advice for an exactly-matching normalized question. */
298
+ reattachedAdviceFor(
299
+ normalizedQuestion: string | undefined
300
+ ): string | undefined {
301
+ if (!normalizedQuestion) {
302
+ return undefined;
303
+ }
304
+ for (const entry of this.#ledger.issued.values()) {
305
+ if (entry.normalizedQuestion === normalizedQuestion) {
306
+ return entry.advice;
307
+ }
308
+ }
309
+ return undefined;
310
+ }
243
311
  claimTrackedFiles(paths: string[]) {
244
312
  const advice = this.#ledger.lastAdvice;
245
313
  if (!advice || paths.length === 0) {
246
314
  return false;
247
315
  }
248
316
  const mentioned = paths.every((path) => {
249
- const escaped = path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
250
- const boundary =
251
- "(^|[\\s\\\"'`()\\[])" +
252
- escaped +
253
- "(?=$|[\\s\\\"'`),;:!?\\]]|\\.(?=\\s|$))";
254
- 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);
255
322
  });
256
323
  if (!mentioned) {
257
324
  return false;
@@ -300,7 +367,7 @@ export class AdvisorSessionState {
300
367
  (item) => item.kind === "gate"
301
368
  );
302
369
  return (
303
- (["proceed", "revise", "blocked"] as GateDecision[])
370
+ (["proceed", "revise", "blocked"] as const)
304
371
  .map(
305
372
  (decision) =>
306
373
  [
@@ -321,21 +388,62 @@ export class AdvisorSessionState {
321
388
 
322
389
  #triggersLine() {
323
390
  return (
324
- [
325
- "manual",
326
- "executor-requested",
327
- "repeated-tool-call",
328
- "completion-review",
329
- "custom-rule",
330
- ]
331
- .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)
332
402
  .join(", ") || "none"
333
403
  );
334
404
  }
335
405
 
406
+ recordJevFilterAllowed() {
407
+ this.#jev.recordFilterAllowed();
408
+ }
409
+ recordJevFilterSkipped(repeat: boolean, normalizedQuestion?: string) {
410
+ this.#jev.recordFilterSkipped(
411
+ repeat,
412
+ normalizedQuestion,
413
+ this.#sessionTurnOrdinal
414
+ );
415
+ }
416
+ get lastJevSkip() {
417
+ return this.#jev.lastSkip;
418
+ }
419
+ recordJevFilterOverride() {
420
+ this.#jev.recordFilterOverride();
421
+ }
422
+ recordJevFilterFailure() {
423
+ this.#jev.recordFilterFailure();
424
+ }
425
+ recordJevFilterUsage(usage: AdvisorJevUsage) {
426
+ this.#jev.recordFilterUsage(usage);
427
+ }
428
+
429
+ recordJevGateCheck(usage?: AdvisorJevUsage) {
430
+ this.#jev.recordGateCheck(usage);
431
+ }
432
+ recordJevGateConsultation() {
433
+ this.#jev.recordGateConsultation();
434
+ }
435
+ recordJevGateFailure() {
436
+ this.#jev.recordGateFailure();
437
+ }
438
+
336
439
  summary(limit: number | undefined) {
337
440
  const { invocations, totals } = this.#usage;
338
- if (invocations.length === 0 && this.#repetition.interventions === 0) {
441
+ const jevLines = this.#jev.summaryLines(invocations);
442
+ if (
443
+ invocations.length === 0 &&
444
+ this.#repetition.interventions === 0 &&
445
+ jevLines.length === 0
446
+ ) {
339
447
  return;
340
448
  }
341
449
  const markdown = invocations.filter((item) => item.kind === "markdown");
@@ -366,6 +474,7 @@ export class AdvisorSessionState {
366
474
  `Loop matching: normalized tool signatures; ${this.#repetition.interventions} gate intervention${this.#repetition.interventions === 1 ? "" : "s"}`,
367
475
  `Execution effects: ${effects("tool-blocked")} tool blocked, ${effects("session-blocked")} sessions blocked, ${effects("continued")} continued`,
368
476
  `Failures: ${failures.length ? failures.join(", ") : "none"}`,
477
+ ...jevLines,
369
478
  ].join("\n");
370
479
  }
371
480
  }
@@ -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,