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,27 +1,26 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
2
3
  import {
3
4
  advisorRedactSecretsRef,
4
5
  advisorToolPoliciesRef,
5
6
  advisorToolResultMaxBytesRef,
6
7
  advisorToolResultMaxLinesRef,
7
8
  } from "./config/state.ts";
8
- import {
9
- buildGroups,
10
- type DisclosureCaps,
11
- type GroupPass,
12
- groupWireBytes,
13
- } from "./scout-groups.ts";
9
+ import { buildGroups, groupWireBytes } from "./scout-groups.ts";
10
+ import type { DisclosureCaps, GroupPass } from "./scout-groups.ts";
14
11
  import { indexToolCalls } from "./scout-protocol.ts";
15
12
  import {
16
- type BuildScoutManifestOptions,
17
13
  SCOUT_GROUP_MAX_BYTES,
18
14
  SCOUT_MANIFEST_MAX_BYTES,
19
15
  SCOUT_MANIFEST_MAX_GROUPS,
20
- type ScoutContextGroup,
21
- type ScoutManifestResult,
16
+ } from "./scout-types.ts";
17
+ import type {
18
+ BuildScoutManifestOptions,
19
+ ScoutContextGroup,
20
+ ScoutManifestResult,
22
21
  } from "./scout-types.ts";
23
22
 
24
- // biome-ignore lint/performance/noBarrelFile: re-export preserves scout-context's historical public import surface.
23
+ // Re-exports preserve scout-context's historical public import surface.
25
24
  export { reconstructScoutConversation } from "./scout-reconstruct.ts";
26
25
  export type {
27
26
  BuildScoutManifestOptions,
@@ -54,21 +53,44 @@ const resolveCaps = (
54
53
  });
55
54
 
56
55
  /** Builds disclosed, indivisible history groups from Pi's compaction-aware branch. */
57
- export const buildScoutManifest = (
58
- ctx: ExtensionContext,
59
- options: BuildScoutManifestOptions = {}
60
- ): ScoutManifestResult => {
61
- const entries = ctx.sessionManager.buildContextEntries();
62
- const caps = resolveCaps(options);
63
- const indexed = indexToolCalls(entries);
64
- if (!indexed.ok) {
65
- return indexed;
56
+ const contentChars = (items: ScoutContextGroup[]) =>
57
+ items.reduce((sum, group) => sum + group.content.length, 0) +
58
+ Math.max(0, items.length - 1) * 2;
59
+
60
+ const requiredOverflow = (
61
+ required: ScoutContextGroup[],
62
+ caps: {
63
+ maxConversationChars?: number;
64
+ maxGroupBytes: number;
65
+ maxGroups: number;
66
+ maxManifestBytes: number;
66
67
  }
67
- const built = buildGroups(entries, indexed.index, caps);
68
- if (!built.ok) {
69
- return built;
68
+ ): ScoutManifestResult | undefined => {
69
+ if (
70
+ required.some((group) => group.bytes > caps.maxGroupBytes) ||
71
+ required.length > caps.maxGroups ||
72
+ required.reduce((sum, group) => sum + groupWireBytes(group), 0) >
73
+ caps.maxManifestBytes
74
+ ) {
75
+ return {
76
+ message:
77
+ "Required Scout context exceeds the Scout manifest transport limit.",
78
+ ok: false,
79
+ reason: "required-group-overflow",
80
+ };
70
81
  }
71
- return fitToBudget(built, caps);
82
+ if (
83
+ caps.maxConversationChars !== undefined &&
84
+ contentChars(required) > caps.maxConversationChars
85
+ ) {
86
+ return {
87
+ message:
88
+ "Required Scout context exceeds the Advisor conversation budget.",
89
+ ok: false,
90
+ reason: "required-group-overflow",
91
+ };
92
+ }
93
+ return undefined;
72
94
  };
73
95
 
74
96
  const fits = (
@@ -86,10 +108,6 @@ const fits = (
86
108
  (caps.maxConversationChars === undefined ||
87
109
  contentChars(selected) <= caps.maxConversationChars);
88
110
 
89
- const contentChars = (items: ScoutContextGroup[]) =>
90
- items.reduce((sum, group) => sum + group.content.length, 0) +
91
- Math.max(0, items.length - 1) * 2;
92
-
93
111
  const fitToBudget = (
94
112
  built: GroupPass,
95
113
  caps: {
@@ -126,7 +144,7 @@ const fitToBudget = (
126
144
  );
127
145
  while (!fits(selected, caps)) {
128
146
  const optionalIndex = selected.findIndex((group) => !group.required);
129
- if (optionalIndex < 0) {
147
+ if (optionalIndex === -1) {
130
148
  return {
131
149
  message: "Required Scout context exceeds fixed manifest limits.",
132
150
  ok: false,
@@ -151,38 +169,19 @@ const fitToBudget = (
151
169
  };
152
170
  };
153
171
 
154
- const requiredOverflow = (
155
- required: ScoutContextGroup[],
156
- caps: {
157
- maxConversationChars?: number;
158
- maxGroupBytes: number;
159
- maxGroups: number;
160
- maxManifestBytes: number;
161
- }
162
- ): ScoutManifestResult | undefined => {
163
- if (
164
- required.some((group) => group.bytes > caps.maxGroupBytes) ||
165
- required.length > caps.maxGroups ||
166
- required.reduce((sum, group) => sum + groupWireBytes(group), 0) >
167
- caps.maxManifestBytes
168
- ) {
169
- return {
170
- message:
171
- "Required Scout context exceeds the Scout manifest transport limit.",
172
- ok: false,
173
- reason: "required-group-overflow",
174
- };
172
+ export const buildScoutManifest = (
173
+ ctx: ExtensionContext,
174
+ options: BuildScoutManifestOptions = {}
175
+ ): ScoutManifestResult => {
176
+ const entries = ctx.sessionManager.buildContextEntries();
177
+ const caps = resolveCaps(options);
178
+ const indexed = indexToolCalls(entries);
179
+ if (!indexed.ok) {
180
+ return indexed;
175
181
  }
176
- if (
177
- caps.maxConversationChars !== undefined &&
178
- contentChars(required) > caps.maxConversationChars
179
- ) {
180
- return {
181
- message:
182
- "Required Scout context exceeds the Advisor conversation budget.",
183
- ok: false,
184
- reason: "required-group-overflow",
185
- };
182
+ const built = buildGroups(entries, indexed.index, caps);
183
+ if (!built.ok) {
184
+ return built;
186
185
  }
187
- return undefined;
186
+ return fitToBudget(built, caps);
188
187
  };
@@ -1,15 +1,13 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
2
3
  import { advisorScoutEnabledRef, executorRef } from "./config/state.ts";
3
- import {
4
- runAdvisorScout,
5
- type ScoutLifecycleEvent,
6
- type ScoutOutcome,
7
- } from "./scout.ts";
8
4
  import {
9
5
  buildScoutManifest,
10
6
  reconstructScoutConversation,
11
7
  SCOUT_MANIFEST_MAX_BYTES,
12
8
  } from "./scout-context.ts";
9
+ import { runAdvisorScout } from "./scout.ts";
10
+ import type { ScoutLifecycleEvent, ScoutOutcome } from "./scout.ts";
13
11
 
14
12
  /** Runs Scout over the manifested branch and reconstructs the curated
15
13
  * conversation, falling back to the exact legacy conversation on any
@@ -55,14 +53,7 @@ export const curateAdvisorConversation = async (
55
53
  onScout?.({ outcome: scout, type: "fallback" });
56
54
  return { conversation: legacyConversation, scout };
57
55
  }
58
- const outcome = await runScout(
59
- ctx,
60
- built.manifest,
61
- signal,
62
- onScout,
63
- undefined,
64
- undefined
65
- );
56
+ const outcome = await runScout(ctx, built.manifest, signal, onScout);
66
57
  if (!outcome.ok && outcome.cancelled) {
67
58
  throw signal?.reason instanceof Error
68
59
  ? signal.reason
@@ -1,18 +1,20 @@
1
1
  import { createHash } from "node:crypto";
2
+
2
3
  import type { AdvisorToolPolicies } from "./config/types.ts";
3
4
  import {
4
5
  byteLength,
5
6
  contentParts,
6
7
  isRecord,
7
- type RecordValue,
8
+ isString,
8
9
  } from "./content-utils.ts";
10
+ import type { RecordValue } from "./content-utils.ts";
9
11
  import { conversationEntry } from "./conversation.ts";
10
12
  import { invalid, toolCallId, toolCalls } from "./scout-protocol.ts";
11
- import {
12
- type InvalidProtocolResult,
13
- SCOUT_LABEL_MAX_CHARS,
14
- type ScoutContextGroup,
15
- type ScoutGroupKind,
13
+ import { SCOUT_LABEL_MAX_CHARS } from "./scout-types.ts";
14
+ import type {
15
+ InvalidProtocolResult,
16
+ ScoutContextGroup,
17
+ ScoutGroupKind,
16
18
  } from "./scout-types.ts";
17
19
 
18
20
  export interface GroupPass {
@@ -22,10 +24,10 @@ export interface GroupPass {
22
24
  protocolOmittedCount: number;
23
25
  }
24
26
 
25
- const SPEAKER_PREFIX = /^(User|Executor):\s*/;
27
+ const SPEAKER_PREFIX = /^(?<speaker>User|Executor):\s*/u;
26
28
 
27
29
  const boundedLabel = (value: string) =>
28
- [...value.replace(/\s+/g, " ").trim()]
30
+ [...value.replaceAll(/\s+/gu, " ").trim()]
29
31
  .slice(0, SCOUT_LABEL_MAX_CHARS)
30
32
  .join("");
31
33
 
@@ -81,10 +83,8 @@ const createGroup = (
81
83
  required,
82
84
  });
83
85
 
84
- const pendingAdvisorArguments = (value: unknown): RecordValue => {
85
- if (!isRecord(value)) {
86
- return {};
87
- }
86
+ const pendingAdvisorArguments = (part: RecordValue): RecordValue => {
87
+ const value = isRecord(part.arguments) ? part.arguments : {};
88
88
  const allowed: RecordValue = {};
89
89
  for (const key of ["gitContext", "question"]) {
90
90
  if (key in value) {
@@ -106,7 +106,8 @@ const pendingInvocationDisclosure = (
106
106
  if (!isRecord(entry.message)) {
107
107
  return disclosed;
108
108
  }
109
- const content = contentParts(entry.message.content).map((part) => {
109
+ const message: RecordValue = entry.message;
110
+ const content = contentParts(message.content).map((part) => {
110
111
  if (
111
112
  !isRecord(part) ||
112
113
  part.type !== "toolCall" ||
@@ -115,10 +116,10 @@ const pendingInvocationDisclosure = (
115
116
  ) {
116
117
  return part;
117
118
  }
118
- return { ...part, arguments: pendingAdvisorArguments(part.arguments) };
119
+ return { ...part, arguments: pendingAdvisorArguments(part) };
119
120
  });
120
121
  return conversationEntry(
121
- { ...entry, message: { ...entry.message, content } },
122
+ { ...entry, message: { ...message, content } },
122
123
  toolResultMaxLines,
123
124
  toolResultMaxBytes,
124
125
  policies,
@@ -139,88 +140,19 @@ type ToolExchangeOutcome =
139
140
  | { kind: "omitted"; bytes: number }
140
141
  | { kind: "invalid"; message: string };
141
142
 
142
- /** Groups an assistant message with its tool calls and matched results into
143
- * one indivisible tool-exchange (or pending-invocation) group. */
144
- const toolExchangeGroup = (
145
- entry: RecordValue,
146
- index: number,
147
- entryId: string,
148
- disclosed: string,
149
- immediate: unknown,
150
- indexed: {
151
- callOwners: Map<string, { index: number; name: string }>;
152
- resultsByCall: Map<string, Array<{ entry: RecordValue; index: number }>>;
153
- },
154
- consumedResultIndexes: Set<number>,
155
- caps: DisclosureCaps
156
- ): ToolExchangeOutcome => {
157
- const calls = toolCalls(entry.message as RecordValue);
158
- const callIds = calls.map(toolCallId) as string[];
159
- const adjacentFailure = adjacentResultMismatch(
160
- immediate,
161
- index,
162
- callIds,
163
- indexed.callOwners
164
- );
165
- if (adjacentFailure) {
166
- return { kind: "invalid", message: adjacentFailure };
167
- }
168
- const missing = new Set<string>();
169
- const resultParts: string[] = [];
170
- const resultEntryIds: string[] = [];
171
- const matchFailure = collectResults(
172
- calls,
173
- callIds,
174
- index,
175
- indexed.resultsByCall,
176
- consumedResultIndexes,
177
- caps,
178
- missing,
179
- resultParts,
180
- resultEntryIds
181
- );
182
- if (matchFailure) {
183
- return { kind: "invalid", message: matchFailure };
184
- }
185
- if (missing.size > 0) {
186
- return missingOutcome(
187
- entry,
188
- index,
189
- entryId,
190
- disclosed,
191
- callIds,
192
- missing,
193
- resultParts,
194
- resultEntryIds,
195
- caps
196
- );
197
- }
198
- return {
199
- group: createGroup(
200
- index,
201
- [entryId, ...resultEntryIds],
202
- "tool-exchange",
203
- [disclosed, ...resultParts].join("\n\n"),
204
- false
205
- ),
206
- kind: "group",
207
- };
208
- };
209
-
210
143
  const adjacentResultMismatch = (
211
- immediate: unknown,
144
+ immediate: RecordValue | undefined,
212
145
  index: number,
213
146
  callIds: string[],
214
147
  callOwners: Map<string, { index: number; name: string }>
215
148
  ): string | undefined => {
216
- const next = immediate as RecordValue | undefined;
217
149
  if (
218
- next?.type === "message" &&
219
- isRecord(next.message) &&
220
- next.message.role === "toolResult" &&
221
- typeof next.message.toolCallId === "string" &&
222
- !callIds.includes(next.message.toolCallId) &&
223
- !callOwners.has(next.message.toolCallId)
150
+ immediate?.type === "message" &&
151
+ isRecord(immediate.message) &&
152
+ immediate.message.role === "toolResult" &&
153
+ isString(immediate.message.toolCallId) &&
154
+ !callIds.includes(immediate.message.toolCallId) &&
155
+ !callOwners.has(immediate.message.toolCallId)
224
156
  ) {
225
157
  return `Tool result at context entry ${index + 1} does not match its adjacent assistant group.`;
226
158
  }
@@ -231,7 +163,7 @@ const collectResults = (
231
163
  calls: RecordValue[],
232
164
  callIds: string[],
233
165
  index: number,
234
- resultsByCall: Map<string, Array<{ entry: RecordValue; index: number }>>,
166
+ resultsByCall: Map<string, { entry: RecordValue; index: number }[]>,
235
167
  consumedResultIndexes: Set<number>,
236
168
  caps: DisclosureCaps,
237
169
  missing: Set<string>,
@@ -248,10 +180,8 @@ const collectResults = (
248
180
  return `Tool result at context entry ${resultMatch.index} precedes its assistant call.`;
249
181
  }
250
182
  const resultMessage = resultMatch.entry.message;
251
- const expectedName =
252
- typeof calls[callIndex].name === "string"
253
- ? calls[callIndex].name
254
- : "unknown";
183
+ const call = calls[callIndex];
184
+ const expectedName = isString(call.name) ? call.name : "unknown";
255
185
  if (!isRecord(resultMessage) || resultMessage.toolName !== expectedName) {
256
186
  return `Tool result at context entry ${resultMatch.index} conflicts with call ${callId}.`;
257
187
  }
@@ -267,7 +197,7 @@ const collectResults = (
267
197
  resultParts.push(resultText);
268
198
  }
269
199
  resultEntryIds.push(
270
- typeof resultMatch.entry.id === "string"
200
+ isString(resultMatch.entry.id)
271
201
  ? resultMatch.entry.id
272
202
  : String(resultMatch.index)
273
203
  );
@@ -319,82 +249,130 @@ const missingOutcome = (
319
249
  };
320
250
  };
321
251
 
322
- /** Second entry pass: groups each disclosed entry, delegating
323
- * assistant-with-calls entries to the tool-exchange builder. */
324
- export const buildGroups = (
325
- entries: unknown[],
252
+ /** Groups an assistant message with its tool calls and matched results into
253
+ * one indivisible tool-exchange (or pending-invocation) group. */
254
+ const toolExchangeGroup = (
255
+ entry: RecordValue,
256
+ message: RecordValue,
257
+ index: number,
258
+ entryId: string,
259
+ disclosed: string,
260
+ immediate: RecordValue | undefined,
326
261
  indexed: {
327
262
  callOwners: Map<string, { index: number; name: string }>;
328
- latestUserIndex: number;
329
- resultsByCall: Map<string, Array<{ entry: RecordValue; index: number }>>;
263
+ resultsByCall: Map<string, { entry: RecordValue; index: number }[]>;
330
264
  },
331
- caps: DisclosureCaps & {
332
- maxGroupBytes: number;
265
+ consumedResultIndexes: Set<number>,
266
+ caps: DisclosureCaps
267
+ ): ToolExchangeOutcome => {
268
+ const calls = toolCalls(message);
269
+ const callIds = calls.map(toolCallId).filter(isString);
270
+ const adjacentFailure = adjacentResultMismatch(
271
+ immediate,
272
+ index,
273
+ callIds,
274
+ indexed.callOwners
275
+ );
276
+ if (adjacentFailure) {
277
+ return { kind: "invalid", message: adjacentFailure };
333
278
  }
334
- ): GroupPass | InvalidProtocolResult => {
335
- const groups: ScoutContextGroup[] = [];
336
- const consumedResultIndexes = new Set<number>();
337
- let protocolOmittedBytes = 0;
338
- let protocolOmittedCount = 0;
339
- for (let index = 0; index < entries.length; index += 1) {
340
- const entry = entries[index] as unknown as RecordValue;
341
- const disclosed = conversationEntry(
342
- entry,
343
- caps.toolResultMaxLines,
344
- caps.toolResultMaxBytes,
345
- caps.policies,
346
- caps.redact
347
- );
348
- if (!disclosed) {
349
- continue;
350
- }
351
- const outcome = groupForEntry(
279
+ const missing = new Set<string>();
280
+ const resultParts: string[] = [];
281
+ const resultEntryIds: string[] = [];
282
+ const matchFailure = collectResults(
283
+ calls,
284
+ callIds,
285
+ index,
286
+ indexed.resultsByCall,
287
+ consumedResultIndexes,
288
+ caps,
289
+ missing,
290
+ resultParts,
291
+ resultEntryIds
292
+ );
293
+ if (matchFailure) {
294
+ return { kind: "invalid", message: matchFailure };
295
+ }
296
+ if (missing.size > 0) {
297
+ return missingOutcome(
352
298
  entry,
353
299
  index,
300
+ entryId,
354
301
  disclosed,
355
- entries[index + 1],
356
- indexed,
357
- consumedResultIndexes,
302
+ callIds,
303
+ missing,
304
+ resultParts,
305
+ resultEntryIds,
358
306
  caps
359
307
  );
360
- if (outcome.kind === "invalid") {
361
- return invalid(outcome.message);
362
- }
363
- if (outcome.kind === "omitted") {
364
- protocolOmittedCount += 1;
365
- protocolOmittedBytes += outcome.bytes;
366
- } else if (outcome.kind === "group") {
367
- groups.push(outcome.group);
368
- }
369
308
  }
370
309
  return {
371
- groups,
372
- ok: true,
373
- protocolOmittedBytes,
374
- protocolOmittedCount,
310
+ group: createGroup(
311
+ index,
312
+ [entryId, ...resultEntryIds],
313
+ "tool-exchange",
314
+ [disclosed, ...resultParts].join("\n\n"),
315
+ false
316
+ ),
317
+ kind: "group",
375
318
  };
376
319
  };
377
320
 
321
+ const ownerOf = (
322
+ message: RecordValue,
323
+ callOwners: Map<string, { index: number; name: string }>
324
+ ) =>
325
+ isString(message.toolCallId) ? callOwners.get(message.toolCallId) : undefined;
326
+
327
+ const contentPartsOf = (message: RecordValue) =>
328
+ Array.isArray(message.content) ? message.content : [];
329
+
330
+ const hasCalls = (message: RecordValue) =>
331
+ contentPartsOf(message).some(
332
+ (part) => isRecord(part) && part.type === "toolCall"
333
+ );
334
+
378
335
  type EntryOutcome =
379
336
  | { kind: "group"; group: ScoutContextGroup }
380
337
  | { kind: "omitted"; bytes: number }
381
338
  | { kind: "invalid"; message: string }
382
339
  | { kind: "skipped" };
383
340
 
341
+ const toolResultOutcome = (
342
+ message: RecordValue,
343
+ index: number,
344
+ disclosed: string,
345
+ callOwners: Map<string, { index: number; name: string }>,
346
+ consumedResultIndexes: Set<number>
347
+ ): EntryOutcome => {
348
+ if (consumedResultIndexes.has(index)) {
349
+ return { kind: "skipped" };
350
+ }
351
+ if (ownerOf(message, callOwners)) {
352
+ return {
353
+ kind: "invalid",
354
+ message: `Tool result at context entry ${index} precedes or conflicts with its retained call.`,
355
+ };
356
+ }
357
+ // Retained results without their call are unavailable optional evidence
358
+ // and are never offered to Scout.
359
+ return { bytes: byteLength(disclosed), kind: "omitted" };
360
+ };
361
+
384
362
  const groupForEntry = (
385
363
  entry: RecordValue,
386
364
  index: number,
387
365
  disclosed: string,
388
- immediate: unknown,
366
+ immediate: RecordValue | undefined,
389
367
  indexed: {
390
368
  callOwners: Map<string, { index: number; name: string }>;
391
369
  latestUserIndex: number;
392
- resultsByCall: Map<string, Array<{ entry: RecordValue; index: number }>>;
370
+ resultsByCall: Map<string, { entry: RecordValue; index: number }[]>;
393
371
  },
394
372
  consumedResultIndexes: Set<number>,
395
373
  caps: DisclosureCaps
396
374
  ): EntryOutcome => {
397
- const entryId = typeof entry.id === "string" ? entry.id : String(index);
375
+ const entryId = isString(entry.id) ? entry.id : String(index);
398
376
  if (entry.type !== "message" || !isRecord(entry.message)) {
399
377
  return {
400
378
  group: createGroup(index, [entryId], "compaction", disclosed, false),
@@ -434,6 +412,7 @@ const groupForEntry = (
434
412
  }
435
413
  return toolExchangeGroup(
436
414
  entry,
415
+ message,
437
416
  index,
438
417
  entryId,
439
418
  disclosed,
@@ -444,42 +423,66 @@ const groupForEntry = (
444
423
  );
445
424
  };
446
425
 
447
- const toolResultOutcome = (
448
- message: RecordValue,
449
- index: number,
450
- disclosed: string,
451
- callOwners: Map<string, { index: number; name: string }>,
452
- consumedResultIndexes: Set<number>
453
- ): EntryOutcome => {
454
- if (consumedResultIndexes.has(index)) {
455
- return { kind: "skipped" };
426
+ /** Second entry pass: groups each disclosed entry, delegating
427
+ * assistant-with-calls entries to the tool-exchange builder. */
428
+ export const buildGroups = (
429
+ entries: unknown[],
430
+ indexed: {
431
+ callOwners: Map<string, { index: number; name: string }>;
432
+ latestUserIndex: number;
433
+ resultsByCall: Map<string, { entry: RecordValue; index: number }[]>;
434
+ },
435
+ caps: DisclosureCaps & {
436
+ maxGroupBytes: number;
456
437
  }
457
- if (ownerOf(message, callOwners)) {
458
- return {
459
- kind: "invalid",
460
- message: `Tool result at context entry ${index} precedes or conflicts with its retained call.`,
461
- };
438
+ ): GroupPass | InvalidProtocolResult => {
439
+ const groups: ScoutContextGroup[] = [];
440
+ const consumedResultIndexes = new Set<number>();
441
+ let protocolOmittedBytes = 0;
442
+ let protocolOmittedCount = 0;
443
+ for (let index = 0; index < entries.length; index += 1) {
444
+ const entry = entries[index];
445
+ if (!isRecord(entry)) {
446
+ continue;
447
+ }
448
+ const disclosed = conversationEntry(
449
+ entry,
450
+ caps.toolResultMaxLines,
451
+ caps.toolResultMaxBytes,
452
+ caps.policies,
453
+ caps.redact
454
+ );
455
+ if (!disclosed) {
456
+ continue;
457
+ }
458
+ const next = entries[index + 1];
459
+ const immediate = isRecord(next) ? next : undefined;
460
+ const outcome = groupForEntry(
461
+ entry,
462
+ index,
463
+ disclosed,
464
+ immediate,
465
+ indexed,
466
+ consumedResultIndexes,
467
+ caps
468
+ );
469
+ if (outcome.kind === "invalid") {
470
+ return invalid(outcome.message);
471
+ }
472
+ if (outcome.kind === "omitted") {
473
+ protocolOmittedCount += 1;
474
+ protocolOmittedBytes += outcome.bytes;
475
+ } else if (outcome.kind === "group") {
476
+ groups.push(outcome.group);
477
+ }
462
478
  }
463
- // Retained results without their call are unavailable optional evidence
464
- // and are never offered to Scout.
465
- return { bytes: byteLength(disclosed), kind: "omitted" };
479
+ return {
480
+ groups,
481
+ ok: true,
482
+ protocolOmittedBytes,
483
+ protocolOmittedCount,
484
+ };
466
485
  };
467
486
 
468
- const ownerOf = (
469
- message: RecordValue,
470
- callOwners: Map<string, { index: number; name: string }>
471
- ) =>
472
- typeof message.toolCallId === "string"
473
- ? callOwners.get(message.toolCallId)
474
- : undefined;
475
-
476
- const hasCalls = (message: RecordValue) =>
477
- contentPartsOf(message).some(
478
- (part) => isRecord(part) && part.type === "toolCall"
479
- );
480
-
481
- const contentPartsOf = (message: RecordValue) =>
482
- Array.isArray(message.content) ? message.content : [];
483
-
484
487
  /** Final pass: selects groups within the transport and conversation budgets,
485
488
  * keeping every required group. */