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
@@ -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. */
@@ -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) {