pi-advisor-flow 0.7.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 (92) hide show
  1. package/CHANGELOG.md +19 -18
  2. package/README.md +9 -8
  3. package/dist/index.js +1390 -1068
  4. package/extensions/index.ts +2 -1
  5. package/package.json +48 -35
  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 +102 -92
  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 +16 -2
  21. package/src/commands/types.ts +1 -0
  22. package/src/commands.ts +0 -1
  23. package/src/config/args.ts +1 -1
  24. package/src/config/defaults.ts +8 -1
  25. package/src/config/schema.ts +42 -17
  26. package/src/config/state.ts +12 -3
  27. package/src/config/storage.ts +34 -23
  28. package/src/config/types.ts +16 -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 +75 -85
  38. package/src/jev/failure.ts +19 -0
  39. package/src/jev/key-store.ts +26 -11
  40. package/src/jev/ledger.ts +58 -64
  41. package/src/jev/questions.ts +26 -23
  42. package/src/jev/state.ts +15 -12
  43. package/src/jev/transport.ts +8 -3
  44. package/src/model-stream.ts +35 -37
  45. package/src/outcomes.ts +15 -11
  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 +64 -49
  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 +43 -41
  62. package/src/tools/jev-turn-gate.ts +29 -9
  63. package/src/tools/loop-gate.ts +31 -20
  64. package/src/tools/model-access.ts +46 -0
  65. package/src/tools/prompts.ts +20 -25
  66. package/src/tools/register-ask-advisor.ts +36 -16
  67. package/src/tools/register-lifecycle.ts +22 -0
  68. package/src/tools/register-outcome.ts +2 -0
  69. package/src/tools/register-renderers.ts +146 -128
  70. package/src/tools/registration.ts +12 -14
  71. package/src/tools/render-advisor-result.ts +35 -22
  72. package/src/tools/render-common.ts +16 -19
  73. package/src/tools/scout-status.ts +42 -27
  74. package/src/tools/types.ts +1 -0
  75. package/src/tools.ts +0 -1
  76. package/src/ui/jev-setup-submenu.ts +65 -59
  77. package/src/ui/manual-dialog-render.ts +1 -0
  78. package/src/ui/manual-dialog.ts +39 -43
  79. package/src/ui/masked-input.ts +5 -11
  80. package/src/ui/model-multi-selector.ts +9 -0
  81. package/src/ui/model-selector-adapter.ts +31 -0
  82. package/src/ui/model-selector.ts +14 -161
  83. package/src/ui/searchable-model-list.ts +230 -0
  84. package/src/ui/settings-formatting.ts +24 -14
  85. package/src/ui/settings-items.ts +38 -1
  86. package/src/ui/settings-list-adapter.ts +15 -12
  87. package/src/ui/settings-mutations.ts +157 -70
  88. package/src/ui/settings-selector.ts +18 -9
  89. package/src/ui/text-setting-submenu.ts +3 -6
  90. package/src/ui/types.ts +14 -0
  91. package/src/ui.ts +0 -1
  92. package/src/usage.ts +26 -19
@@ -1,4 +1,5 @@
1
- import { contentParts, isRecord, type RecordValue } from "./content-utils.ts";
1
+ import { contentParts, isRecord, isString } from "./content-utils.ts";
2
+ import type { RecordValue } from "./content-utils.ts";
2
3
  import { textFrom } from "./conversation.ts";
3
4
  import type { InvalidProtocolResult } from "./scout-types.ts";
4
5
 
@@ -12,7 +13,7 @@ export const invalid = (message: string): InvalidProtocolResult => ({
12
13
  export interface ToolCallIndex {
13
14
  callOwners: Map<string, { index: number; name: string }>;
14
15
  latestUserIndex: number;
15
- resultsByCall: Map<string, Array<{ entry: RecordValue; index: number }>>;
16
+ resultsByCall: Map<string, { entry: RecordValue; index: number }[]>;
16
17
  }
17
18
 
18
19
  export const toolCalls = (message: RecordValue) =>
@@ -21,51 +22,51 @@ export const toolCalls = (message: RecordValue) =>
21
22
  );
22
23
 
23
24
  export const toolCallId = (part: RecordValue) =>
24
- typeof part.id === "string" ? part.id : undefined;
25
+ isString(part.id) ? part.id : undefined;
25
26
 
26
- /** First entry pass: indexes assistant tool calls and their results, tracking
27
- * the latest user message. Returns an invalid result on protocol violations. */
28
- export const indexToolCalls = (
29
- entries: unknown[]
30
- ): { index: ToolCallIndex; ok: true } | InvalidProtocolResult => {
31
- const callOwners = new Map<string, { index: number; name: string }>();
32
- const resultsByCall = new Map<
33
- string,
34
- Array<{ entry: RecordValue; index: number }>
35
- >();
36
- const state = { latestUserIndex: -1 };
37
- for (let index = 0; index < entries.length; index += 1) {
38
- const failure = indexEntry(
39
- entries[index] as unknown as RecordValue,
40
- index,
41
- callOwners,
42
- resultsByCall,
43
- state
44
- );
45
- if (failure) {
46
- return failure;
27
+ const indexAssistantCalls = (
28
+ message: RecordValue,
29
+ index: number,
30
+ callOwners: Map<string, { index: number; name: string }>
31
+ ): InvalidProtocolResult | undefined => {
32
+ for (const call of toolCalls(message)) {
33
+ const id = toolCallId(call);
34
+ if (!id || callOwners.has(id)) {
35
+ return invalid(
36
+ `Assistant tool calls at context entry ${index} have missing or duplicate IDs.`
37
+ );
47
38
  }
39
+ callOwners.set(id, {
40
+ index,
41
+ name: isString(call.name) ? call.name : "unknown",
42
+ });
48
43
  }
49
- for (const [id, results] of resultsByCall) {
50
- if (results.length > 1) {
51
- return invalid(`Tool call ${id} has duplicate result messages.`);
52
- }
44
+ return undefined;
45
+ };
46
+
47
+ const indexToolResult = (
48
+ entry: RecordValue,
49
+ message: RecordValue,
50
+ index: number,
51
+ resultsByCall: Map<string, { entry: RecordValue; index: number }[]>
52
+ ): InvalidProtocolResult | undefined => {
53
+ const id = message.toolCallId;
54
+ if (!isString(id)) {
55
+ return invalid(
56
+ `Tool result at context entry ${index} has no tool-call ID.`
57
+ );
53
58
  }
54
- return {
55
- index: {
56
- callOwners,
57
- latestUserIndex: state.latestUserIndex,
58
- resultsByCall,
59
- },
60
- ok: true,
61
- };
59
+ const results = resultsByCall.get(id) ?? [];
60
+ results.push({ entry, index });
61
+ resultsByCall.set(id, results);
62
+ return undefined;
62
63
  };
63
64
 
64
65
  const indexEntry = (
65
66
  entry: RecordValue,
66
67
  index: number,
67
68
  callOwners: Map<string, { index: number; name: string }>,
68
- resultsByCall: Map<string, Array<{ entry: RecordValue; index: number }>>,
69
+ resultsByCall: Map<string, { entry: RecordValue; index: number }[]>,
69
70
  state: { latestUserIndex: number }
70
71
  ): InvalidProtocolResult | undefined => {
71
72
  if (entry.type !== "message" || !isRecord(entry.message)) {
@@ -83,40 +84,43 @@ const indexEntry = (
83
84
  return undefined;
84
85
  };
85
86
 
86
- const indexToolResult = (
87
- entry: RecordValue,
88
- message: RecordValue,
89
- index: number,
90
- resultsByCall: Map<string, Array<{ entry: RecordValue; index: number }>>
91
- ): InvalidProtocolResult | undefined => {
92
- const id = message.toolCallId;
93
- if (typeof id !== "string") {
94
- return invalid(
95
- `Tool result at context entry ${index} has no tool-call ID.`
96
- );
97
- }
98
- const results = resultsByCall.get(id) ?? [];
99
- results.push({ entry, index });
100
- resultsByCall.set(id, results);
101
- return undefined;
102
- };
103
-
104
- const indexAssistantCalls = (
105
- message: RecordValue,
106
- index: number,
107
- callOwners: Map<string, { index: number; name: string }>
108
- ): InvalidProtocolResult | undefined => {
109
- for (const call of toolCalls(message)) {
110
- const id = toolCallId(call);
111
- if (!id || callOwners.has(id)) {
112
- return invalid(
113
- `Assistant tool calls at context entry ${index} have missing or duplicate IDs.`
87
+ /** First entry pass: indexes assistant tool calls and their results, tracking
88
+ * the latest user message. Returns an invalid result on protocol violations. */
89
+ export const indexToolCalls = (
90
+ entries: unknown[]
91
+ ): { index: ToolCallIndex; ok: true } | InvalidProtocolResult => {
92
+ const callOwners = new Map<string, { index: number; name: string }>();
93
+ const resultsByCall = new Map<
94
+ string,
95
+ { entry: RecordValue; index: number }[]
96
+ >();
97
+ const state = { latestUserIndex: -1 };
98
+ for (let index = 0; index < entries.length; index += 1) {
99
+ const entry = entries[index];
100
+ if (isRecord(entry)) {
101
+ const failure = indexEntry(
102
+ entry,
103
+ index,
104
+ callOwners,
105
+ resultsByCall,
106
+ state
114
107
  );
108
+ if (failure) {
109
+ return failure;
110
+ }
115
111
  }
116
- callOwners.set(id, {
117
- index,
118
- name: typeof call.name === "string" ? call.name : "unknown",
119
- });
120
112
  }
121
- return undefined;
113
+ for (const [id, results] of resultsByCall) {
114
+ if (results.length > 1) {
115
+ return invalid(`Tool call ${id} has duplicate result messages.`);
116
+ }
117
+ }
118
+ return {
119
+ index: {
120
+ callOwners,
121
+ latestUserIndex: state.latestUserIndex,
122
+ resultsByCall,
123
+ },
124
+ ok: true,
125
+ };
122
126
  };
@@ -20,7 +20,7 @@ export const reconstructScoutConversation = (
20
20
  const selected = new Set(selectedIds);
21
21
  const evidence = manifest.groups
22
22
  .filter((group) => group.required || selected.has(group.id))
23
- .sort((left, right) => left.originalIndex - right.originalIndex)
23
+ .toSorted((left, right) => left.originalIndex - right.originalIndex)
24
24
  .map((group) => group.content);
25
25
  const evidenceText = evidence.join("\n\n");
26
26
  if (maxChars <= 0) {
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,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
  }