pi-advisor-flow 0.5.6 → 0.7.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 (87) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +1 -0
  3. package/dist/index.js +3160 -1255
  4. package/extensions/index.ts +9 -9
  5. package/package.json +12 -8
  6. package/src/{untracked.ts → attachments.ts} +2 -5
  7. package/src/commands/activation-preparation.ts +6 -6
  8. package/src/commands/activation.ts +7 -7
  9. package/src/commands/lifecycle.ts +5 -5
  10. package/src/commands/manual-command.ts +13 -9
  11. package/src/commands/manual-consultation.ts +24 -10
  12. package/src/commands/manual-progress.ts +3 -3
  13. package/src/commands/model-commands.ts +6 -6
  14. package/src/commands/model-options.ts +4 -4
  15. package/src/commands/model-picker.ts +2 -2
  16. package/src/commands/registration.ts +8 -8
  17. package/src/commands/renderers.ts +5 -5
  18. package/src/commands/runtime.ts +8 -8
  19. package/src/commands/settings-commands.ts +8 -8
  20. package/src/commands/settings-persistence.ts +52 -4
  21. package/src/commands/types.ts +6 -13
  22. package/src/commands.ts +2 -2
  23. package/src/config/args.ts +3 -3
  24. package/src/config/defaults.ts +78 -3
  25. package/src/config/schema.ts +434 -0
  26. package/src/config/state.ts +71 -2
  27. package/src/config/storage.ts +27 -119
  28. package/src/config/types.ts +30 -1
  29. package/src/config/validation.ts +61 -181
  30. package/src/config.ts +7 -7
  31. package/src/content-utils.ts +30 -0
  32. package/src/conversation.ts +13 -168
  33. package/src/git.ts +4 -18
  34. package/src/herdr.ts +6 -8
  35. package/src/jev/client.ts +348 -0
  36. package/src/jev/key-store.ts +252 -0
  37. package/src/jev/ledger.ts +234 -0
  38. package/src/jev/questions.ts +119 -0
  39. package/src/jev/state.ts +47 -0
  40. package/src/jev/transport.ts +58 -0
  41. package/src/model-stream.ts +5 -8
  42. package/src/outcomes.ts +3 -2
  43. package/src/pi-settings.ts +59 -0
  44. package/src/preferences.ts +2 -2
  45. package/src/redaction.ts +50 -0
  46. package/src/scout-context.ts +121 -485
  47. package/src/scout-curation.ts +84 -0
  48. package/src/scout-groups.ts +485 -0
  49. package/src/scout-protocol.ts +122 -0
  50. package/src/scout-reconstruct.ts +44 -0
  51. package/src/scout-types.ts +66 -0
  52. package/src/scout.ts +53 -63
  53. package/src/session-state.ts +215 -78
  54. package/src/tool-result-cap.ts +88 -0
  55. package/src/tools/consult-context.ts +155 -0
  56. package/src/tools/consultation.ts +71 -220
  57. package/src/tools/gate-policy.ts +5 -5
  58. package/src/tools/gate-protocol.ts +2 -2
  59. package/src/tools/jev-filter.ts +202 -0
  60. package/src/tools/jev-turn-gate.ts +183 -0
  61. package/src/tools/loop-gate.ts +71 -61
  62. package/src/tools/outage-notifier.ts +30 -0
  63. package/src/tools/prompts.ts +4 -7
  64. package/src/tools/register-ask-advisor.ts +46 -15
  65. package/src/tools/register-lifecycle.ts +7 -7
  66. package/src/tools/register-outcome.ts +5 -5
  67. package/src/tools/register-renderers.ts +63 -5
  68. package/src/tools/registration.ts +27 -10
  69. package/src/tools/render-advisor-result.ts +54 -22
  70. package/src/tools/render-common.ts +25 -13
  71. package/src/tools/scout-status.ts +5 -5
  72. package/src/tools/session.ts +1 -1
  73. package/src/tools/types.ts +17 -7
  74. package/src/tools.ts +9 -9
  75. package/src/ui/jev-setup-submenu.ts +344 -0
  76. package/src/ui/manual-dialog-render.ts +2 -2
  77. package/src/ui/manual-dialog.ts +4 -9
  78. package/src/ui/masked-input.ts +68 -0
  79. package/src/ui/model-selector.ts +1 -1
  80. package/src/ui/settings-formatting.ts +2 -2
  81. package/src/ui/settings-items.ts +166 -5
  82. package/src/ui/settings-mutations.ts +37 -2
  83. package/src/ui/settings-selector.ts +5 -5
  84. package/src/ui/text-setting-submenu.ts +1 -1
  85. package/src/ui/types.ts +13 -2
  86. package/src/ui.ts +4 -4
  87. package/src/usage.ts +4 -7
@@ -1,438 +1,110 @@
1
- import { createHash } from "node:crypto";
2
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
2
  import {
4
3
  advisorRedactSecretsRef,
5
4
  advisorToolPoliciesRef,
6
5
  advisorToolResultMaxBytesRef,
7
6
  advisorToolResultMaxLinesRef,
8
- } from "./config/state.js";
9
- import type { AdvisorToolPolicies } from "./config/types.js";
10
- import { conversationEntry, textFrom } from "./conversation.js";
11
-
12
- export const SCOUT_MANIFEST_MAX_BYTES = 64 * 1024;
13
- export const SCOUT_MANIFEST_MAX_GROUPS = 64;
14
- export const SCOUT_GROUP_MAX_BYTES = 24 * 1024;
15
- export const SCOUT_LABEL_MAX_CHARS = 160;
16
- export const SCOUT_SELECTION_MAX_IDS = 32;
17
- export const SCOUT_SYNTHESIS_MAX_BYTES = 4 * 1024;
18
-
19
- interface RecordValue {
20
- [key: string]: unknown;
21
- }
22
-
23
- export type ScoutGroupKind =
24
- | "compaction"
25
- | "user"
26
- | "assistant"
27
- | "tool-exchange"
28
- | "pending-invocation";
29
-
30
- export interface ScoutContextGroup {
31
- bytes: number;
32
- content: string;
33
- id: string;
34
- kind: ScoutGroupKind;
35
- label: string;
36
- originalIndex: number;
37
- required: boolean;
38
- }
39
-
40
- export interface ScoutManifest {
41
- availableBytes: number;
42
- availableCount: number;
43
- groups: ScoutContextGroup[];
44
- omittedBytes: number;
45
- omittedCount: number;
46
- }
47
-
48
- export type ScoutManifestResult =
49
- | { ok: true; manifest: ScoutManifest }
50
- | {
51
- ok: false;
52
- reason: "invalid-protocol" | "required-group-overflow";
53
- message: string;
54
- };
55
-
56
- const isRecord = (value: unknown): value is RecordValue =>
57
- Boolean(value) && typeof value === "object";
58
- const byteLength = (value: string) => Buffer.byteLength(value, "utf8");
59
- const SPEAKER_PREFIX = /^(User|Executor):\s*/;
60
- const contentParts = (content: unknown): unknown[] =>
61
- Array.isArray(content) ? content : [];
62
- const toolCalls = (message: RecordValue) =>
63
- contentParts(message.content).filter(
64
- (part): part is RecordValue => isRecord(part) && part.type === "toolCall"
65
- );
66
- const toolCallId = (part: RecordValue) =>
67
- typeof part.id === "string" ? part.id : undefined;
68
-
69
- const boundedLabel = (value: string) =>
70
- [...value.replace(/\s+/g, " ").trim()]
71
- .slice(0, SCOUT_LABEL_MAX_CHARS)
72
- .join("");
73
-
74
- const labelFor = (kind: ScoutGroupKind, content: string) => {
75
- const preview = boundedLabel(content.replace(SPEAKER_PREFIX, ""));
76
- const prefix: Record<ScoutGroupKind, string> = {
77
- assistant: "Executor",
78
- compaction: "Compaction summary",
79
- "pending-invocation": "Current Advisor invocation",
80
- "tool-exchange": "Tool exchange",
81
- user: "User request",
82
- };
83
- return boundedLabel(`${prefix[kind]}: ${preview || "(no text)"}`);
84
- };
85
-
86
- const stableId = (
87
- index: number,
88
- entryIds: string[],
89
- kind: ScoutGroupKind,
90
- content: string
91
- ) =>
92
- `g_${createHash("sha256")
93
- .update(JSON.stringify([index, entryIds, kind, content]))
94
- .digest("hex")
95
- .slice(0, 16)}`;
96
-
97
- const groupWireBytes = (group: ScoutContextGroup) =>
98
- byteLength(
99
- JSON.stringify({
100
- bytes: group.bytes,
101
- content: group.content,
102
- id: group.id,
103
- kind: group.kind,
104
- label: group.label,
105
- required: group.required,
106
- })
107
- );
108
-
109
- const createGroup = (
110
- originalIndex: number,
111
- entryIds: string[],
112
- kind: ScoutGroupKind,
113
- content: string,
114
- required: boolean
115
- ): ScoutContextGroup => ({
116
- bytes: byteLength(content),
117
- content,
118
- id: stableId(originalIndex, entryIds, kind, content),
119
- kind,
120
- label: labelFor(kind, content),
121
- originalIndex,
122
- required,
123
- });
124
-
125
- const pendingAdvisorArguments = (value: unknown): RecordValue => {
126
- if (!isRecord(value)) {
127
- return {};
128
- }
129
- const allowed: RecordValue = {};
130
- for (const key of ["gitContext", "question"]) {
131
- if (key in value) {
132
- allowed[key] = value[key];
133
- }
134
- }
135
- return allowed;
136
- };
137
-
138
- const pendingInvocationDisclosure = (
139
- entry: RecordValue,
140
- invocationId: string,
141
- toolResultMaxLines: number,
142
- toolResultMaxBytes: number,
143
- policies: AdvisorToolPolicies,
144
- redact: boolean,
145
- disclosed: string
146
- ) => {
147
- if (!isRecord(entry.message)) {
148
- return disclosed;
149
- }
150
- const content = contentParts(entry.message.content).map((part) => {
151
- if (
152
- !isRecord(part) ||
153
- part.type !== "toolCall" ||
154
- toolCallId(part) !== invocationId ||
155
- part.name !== "ask_advisor"
156
- ) {
157
- return part;
158
- }
159
- return { ...part, arguments: pendingAdvisorArguments(part.arguments) };
160
- });
161
- return conversationEntry(
162
- { ...entry, message: { ...entry.message, content } },
163
- toolResultMaxLines,
164
- toolResultMaxBytes,
165
- policies,
166
- redact
167
- );
168
- };
169
-
170
- export interface BuildScoutManifestOptions {
171
- currentInvocationId?: string;
172
- /** @deprecated Use maxManifestBytes for the Scout transport budget. */
173
- maxBytes?: number;
174
- /** Maximum reconstructed Advisor conversation characters. */
7
+ } from "./config/state.ts";
8
+ import {
9
+ buildGroups,
10
+ type DisclosureCaps,
11
+ type GroupPass,
12
+ groupWireBytes,
13
+ } from "./scout-groups.ts";
14
+ import { indexToolCalls } from "./scout-protocol.ts";
15
+ import {
16
+ type BuildScoutManifestOptions,
17
+ SCOUT_GROUP_MAX_BYTES,
18
+ SCOUT_MANIFEST_MAX_BYTES,
19
+ SCOUT_MANIFEST_MAX_GROUPS,
20
+ type ScoutContextGroup,
21
+ type ScoutManifestResult,
22
+ } from "./scout-types.ts";
23
+
24
+ // biome-ignore lint/performance/noBarrelFile: re-export preserves scout-context's historical public import surface.
25
+ export { reconstructScoutConversation } from "./scout-reconstruct.ts";
26
+ export type {
27
+ BuildScoutManifestOptions,
28
+ ScoutManifest,
29
+ ScoutManifestResult,
30
+ } from "./scout-types.ts";
31
+
32
+ export { SCOUT_MANIFEST_MAX_BYTES } from "./scout-types.ts";
33
+
34
+ const resolveCaps = (
35
+ options: BuildScoutManifestOptions
36
+ ): DisclosureCaps & {
175
37
  maxConversationChars?: number;
176
- maxGroupBytes?: number;
177
- maxGroups?: number;
178
- /** Maximum serialized group-manifest bytes sent to Scout. */
179
- maxManifestBytes?: number;
180
- policies?: AdvisorToolPolicies;
181
- redact?: boolean;
182
- toolResultMaxBytes?: number;
183
- toolResultMaxLines?: number;
184
- }
38
+ maxGroupBytes: number;
39
+ maxGroups: number;
40
+ maxManifestBytes: number;
41
+ } => ({
42
+ currentInvocationId: options.currentInvocationId,
43
+ maxConversationChars: options.maxConversationChars,
44
+ maxGroupBytes: options.maxGroupBytes ?? SCOUT_GROUP_MAX_BYTES,
45
+ maxGroups: options.maxGroups ?? SCOUT_MANIFEST_MAX_GROUPS,
46
+ maxManifestBytes:
47
+ options.maxManifestBytes ?? options.maxBytes ?? SCOUT_MANIFEST_MAX_BYTES,
48
+ policies: options.policies ?? advisorToolPoliciesRef,
49
+ redact: options.redact ?? advisorRedactSecretsRef,
50
+ toolResultMaxBytes:
51
+ options.toolResultMaxBytes ?? advisorToolResultMaxBytesRef,
52
+ toolResultMaxLines:
53
+ options.toolResultMaxLines ?? advisorToolResultMaxLinesRef,
54
+ });
185
55
 
186
56
  /** Builds disclosed, indivisible history groups from Pi's compaction-aware branch. */
187
57
  export const buildScoutManifest = (
188
58
  ctx: ExtensionContext,
189
59
  options: BuildScoutManifestOptions = {}
190
- // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: protocol validation keeps fail-open branches visible in one deterministic pass.
191
60
  ): ScoutManifestResult => {
192
61
  const entries = ctx.sessionManager.buildContextEntries();
193
- const policies = options.policies ?? advisorToolPoliciesRef;
194
- const redact = options.redact ?? advisorRedactSecretsRef;
195
- const toolResultMaxLines =
196
- options.toolResultMaxLines ?? advisorToolResultMaxLinesRef;
197
- const toolResultMaxBytes =
198
- options.toolResultMaxBytes ?? advisorToolResultMaxBytesRef;
199
- const {
200
- maxBytes,
201
- maxConversationChars,
202
- maxGroupBytes = SCOUT_GROUP_MAX_BYTES,
203
- maxGroups = SCOUT_MANIFEST_MAX_GROUPS,
204
- maxManifestBytes = maxBytes ?? SCOUT_MANIFEST_MAX_BYTES,
205
- } = options;
206
-
207
- let latestUserIndex = -1;
208
- const callOwners = new Map<string, { index: number; name: string }>();
209
- const resultsByCall = new Map<
210
- string,
211
- Array<{ entry: RecordValue; index: number }>
212
- >();
213
- for (let index = 0; index < entries.length; index += 1) {
214
- const entry = entries[index] as unknown as RecordValue;
215
- if (entry.type !== "message" || !isRecord(entry.message)) {
216
- continue;
217
- }
218
- if (entry.message.role === "user" && textFrom(entry.message.content)) {
219
- latestUserIndex = index;
220
- }
221
- if (entry.message.role === "assistant") {
222
- for (const call of toolCalls(entry.message)) {
223
- const id = toolCallId(call);
224
- if (!id || callOwners.has(id)) {
225
- return {
226
- message: `Assistant tool calls at context entry ${index} have missing or duplicate IDs.`,
227
- ok: false,
228
- reason: "invalid-protocol",
229
- };
230
- }
231
- callOwners.set(id, {
232
- index,
233
- name: typeof call.name === "string" ? call.name : "unknown",
234
- });
235
- }
236
- } else if (entry.message.role === "toolResult") {
237
- const id = entry.message.toolCallId;
238
- if (typeof id !== "string") {
239
- return {
240
- message: `Tool result at context entry ${index} has no tool-call ID.`,
241
- ok: false,
242
- reason: "invalid-protocol",
243
- };
244
- }
245
- const results = resultsByCall.get(id) ?? [];
246
- results.push({ entry, index });
247
- resultsByCall.set(id, results);
248
- }
62
+ const caps = resolveCaps(options);
63
+ const indexed = indexToolCalls(entries);
64
+ if (!indexed.ok) {
65
+ return indexed;
249
66
  }
250
- for (const [id, results] of resultsByCall) {
251
- if (results.length > 1) {
252
- return {
253
- message: `Tool call ${id} has duplicate result messages.`,
254
- ok: false,
255
- reason: "invalid-protocol",
256
- };
257
- }
67
+ const built = buildGroups(entries, indexed.index, caps);
68
+ if (!built.ok) {
69
+ return built;
258
70
  }
71
+ return fitToBudget(built, caps);
72
+ };
259
73
 
260
- const groups: ScoutContextGroup[] = [];
261
- const consumedResultIndexes = new Set<number>();
262
- let protocolOmittedBytes = 0;
263
- let protocolOmittedCount = 0;
264
- for (let index = 0; index < entries.length; index += 1) {
265
- const entry = entries[index] as unknown as RecordValue;
266
- const disclosed = conversationEntry(
267
- entry,
268
- toolResultMaxLines,
269
- toolResultMaxBytes,
270
- policies,
271
- redact
272
- );
273
- if (!disclosed) {
274
- continue;
275
- }
276
- const entryId = typeof entry.id === "string" ? entry.id : String(index);
277
- if (entry.type !== "message" || !isRecord(entry.message)) {
278
- groups.push(
279
- createGroup(index, [entryId], "compaction", disclosed, false)
280
- );
281
- continue;
282
- }
283
- const { message } = entry;
284
- if (message.role === "user") {
285
- groups.push(
286
- createGroup(
287
- index,
288
- [entryId],
289
- "user",
290
- disclosed,
291
- index === latestUserIndex
292
- )
293
- );
294
- continue;
295
- }
296
- if (message.role === "toolResult") {
297
- if (consumedResultIndexes.has(index)) {
298
- continue;
299
- }
300
- const resultId = message.toolCallId;
301
- const owner =
302
- typeof resultId === "string" ? callOwners.get(resultId) : undefined;
303
- if (owner) {
304
- return {
305
- message: `Tool result at context entry ${index} precedes or conflicts with its retained call.`,
306
- ok: false,
307
- reason: "invalid-protocol",
308
- };
309
- }
310
- // Compaction or interrupted history can retain a result without its call.
311
- // It is unavailable optional evidence and is never offered to Scout.
312
- protocolOmittedCount += 1;
313
- protocolOmittedBytes += byteLength(disclosed);
314
- continue;
315
- }
316
- if (message.role !== "assistant") {
317
- continue;
318
- }
319
-
320
- const calls = toolCalls(message);
321
- if (calls.length === 0) {
322
- groups.push(createGroup(index, [entryId], "assistant", disclosed, false));
323
- continue;
324
- }
325
- const callIds = calls.map(toolCallId) as string[];
326
- const immediate = entries[index + 1] as unknown as RecordValue | undefined;
327
- if (
328
- immediate?.type === "message" &&
329
- isRecord(immediate.message) &&
330
- immediate.message.role === "toolResult" &&
331
- typeof immediate.message.toolCallId === "string" &&
332
- !callIds.includes(immediate.message.toolCallId) &&
333
- !callOwners.has(immediate.message.toolCallId)
334
- ) {
335
- return {
336
- message: `Tool result at context entry ${index + 1} does not match its adjacent assistant group.`,
337
- ok: false,
338
- reason: "invalid-protocol",
339
- };
340
- }
341
- const missing = new Set<string>();
342
- const resultParts: string[] = [];
343
- const resultEntryIds: string[] = [];
344
- for (const [callIndex, callId] of callIds.entries()) {
345
- const resultMatch = resultsByCall.get(callId)?.[0];
346
- if (!resultMatch) {
347
- missing.add(callId);
348
- continue;
349
- }
350
- if (resultMatch.index <= index) {
351
- return {
352
- message: `Tool result at context entry ${resultMatch.index} precedes its assistant call.`,
353
- ok: false,
354
- reason: "invalid-protocol",
355
- };
356
- }
357
- const resultMessage = resultMatch.entry.message;
358
- const expectedName =
359
- typeof calls[callIndex].name === "string"
360
- ? calls[callIndex].name
361
- : "unknown";
362
- if (!isRecord(resultMessage) || resultMessage.toolName !== expectedName) {
363
- return {
364
- message: `Tool result at context entry ${resultMatch.index} conflicts with call ${callId}.`,
365
- ok: false,
366
- reason: "invalid-protocol",
367
- };
368
- }
369
- consumedResultIndexes.add(resultMatch.index);
370
- const resultText = conversationEntry(
371
- resultMatch.entry,
372
- toolResultMaxLines,
373
- toolResultMaxBytes,
374
- policies,
375
- redact
376
- );
377
- if (resultText) {
378
- resultParts.push(resultText);
379
- }
380
- resultEntryIds.push(
381
- typeof resultMatch.entry.id === "string"
382
- ? resultMatch.entry.id
383
- : String(resultMatch.index)
384
- );
385
- }
386
- if (missing.size > 0) {
387
- const { currentInvocationId } = options;
388
- const pendingCurrentInvocation =
389
- currentInvocationId !== undefined &&
390
- missing.size === 1 &&
391
- missing.has(currentInvocationId) &&
392
- callIds.includes(currentInvocationId);
393
- if (!pendingCurrentInvocation) {
394
- protocolOmittedCount += 1;
395
- protocolOmittedBytes += byteLength(
396
- [disclosed, ...resultParts].join("\n\n")
397
- );
398
- continue;
399
- }
400
- const pendingDisclosed = pendingInvocationDisclosure(
401
- entry,
402
- currentInvocationId,
403
- toolResultMaxLines,
404
- toolResultMaxBytes,
405
- policies,
406
- redact,
407
- disclosed
408
- );
409
- groups.push(
410
- createGroup(
411
- index,
412
- [entryId, ...resultEntryIds],
413
- "pending-invocation",
414
- [pendingDisclosed ?? disclosed, ...resultParts].join("\n\n"),
415
- true
416
- )
417
- );
418
- continue;
419
- }
420
- groups.push(
421
- createGroup(
422
- index,
423
- [entryId, ...resultEntryIds],
424
- "tool-exchange",
425
- [disclosed, ...resultParts].join("\n\n"),
426
- false
427
- )
428
- );
74
+ const fits = (
75
+ selected: ScoutContextGroup[],
76
+ caps: {
77
+ maxConversationChars?: number;
78
+ maxGroupBytes: number;
79
+ maxGroups: number;
80
+ maxManifestBytes: number;
429
81
  }
430
-
82
+ ) =>
83
+ selected.length <= caps.maxGroups &&
84
+ selected.reduce((sum, group) => sum + groupWireBytes(group), 0) <=
85
+ caps.maxManifestBytes &&
86
+ (caps.maxConversationChars === undefined ||
87
+ contentChars(selected) <= caps.maxConversationChars);
88
+
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
+ const fitToBudget = (
94
+ built: GroupPass,
95
+ caps: {
96
+ maxConversationChars?: number;
97
+ maxGroupBytes: number;
98
+ maxGroups: number;
99
+ maxManifestBytes: number;
100
+ }
101
+ ): ScoutManifestResult => {
102
+ const { groups, protocolOmittedBytes, protocolOmittedCount } = built;
431
103
  const availableCount = groups.length + protocolOmittedCount;
432
104
  const availableBytes =
433
105
  groups.reduce((sum, group) => sum + groupWireBytes(group), 0) +
434
106
  protocolOmittedBytes;
435
- if (maxManifestBytes <= 0) {
107
+ if (caps.maxManifestBytes <= 0) {
436
108
  return {
437
109
  manifest: {
438
110
  availableBytes: 0,
@@ -445,43 +117,14 @@ export const buildScoutManifest = (
445
117
  };
446
118
  }
447
119
  const required = groups.filter((group) => group.required);
448
- if (
449
- required.some((group) => group.bytes > maxGroupBytes) ||
450
- required.length > maxGroups ||
451
- required.reduce((sum, group) => sum + groupWireBytes(group), 0) >
452
- maxManifestBytes
453
- ) {
454
- return {
455
- message:
456
- "Required Scout context exceeds the Scout manifest transport limit.",
457
- ok: false,
458
- reason: "required-group-overflow",
459
- };
460
- }
461
- const contentChars = (items: ScoutContextGroup[]) =>
462
- items.reduce((sum, group) => sum + group.content.length, 0) +
463
- Math.max(0, items.length - 1) * 2;
464
- if (
465
- maxConversationChars !== undefined &&
466
- contentChars(required) > maxConversationChars
467
- ) {
468
- return {
469
- message:
470
- "Required Scout context exceeds the Advisor conversation budget.",
471
- ok: false,
472
- reason: "required-group-overflow",
473
- };
120
+ const overflow = requiredOverflow(required, caps);
121
+ if (overflow) {
122
+ return overflow;
474
123
  }
475
124
  const selected = groups.filter(
476
- (group) => group.required || group.bytes <= maxGroupBytes
125
+ (group) => group.required || group.bytes <= caps.maxGroupBytes
477
126
  );
478
- const fits = () =>
479
- selected.length <= maxGroups &&
480
- selected.reduce((sum, group) => sum + groupWireBytes(group), 0) <=
481
- maxManifestBytes &&
482
- (maxConversationChars === undefined ||
483
- contentChars(selected) <= maxConversationChars);
484
- while (!fits()) {
127
+ while (!fits(selected, caps)) {
485
128
  const optionalIndex = selected.findIndex((group) => !group.required);
486
129
  if (optionalIndex < 0) {
487
130
  return {
@@ -508,45 +151,38 @@ export const buildScoutManifest = (
508
151
  };
509
152
  };
510
153
 
511
- const prefixWithinCharBudget = (value: string, maxChars: number) => {
512
- let result = "";
513
- for (const character of value) {
514
- if (result.length + character.length > maxChars) {
515
- break;
516
- }
517
- result += character;
518
- }
519
- return result;
520
- };
521
-
522
- export const reconstructScoutConversation = (
523
- manifest: ScoutManifest,
524
- selectedIds: string[],
525
- synthesis?: string,
526
- maxChars = Number.MAX_SAFE_INTEGER
527
- ): string => {
528
- const selected = new Set(selectedIds);
529
- const evidence = manifest.groups
530
- .filter((group) => group.required || selected.has(group.id))
531
- .sort((left, right) => left.originalIndex - right.originalIndex)
532
- .map((group) => group.content);
533
- const evidenceText = evidence.join("\n\n");
534
- if (maxChars <= 0) {
535
- return "";
154
+ const requiredOverflow = (
155
+ required: ScoutContextGroup[],
156
+ caps: {
157
+ maxConversationChars?: number;
158
+ maxGroupBytes: number;
159
+ maxGroups: number;
160
+ maxManifestBytes: number;
536
161
  }
537
- if (evidenceText.length >= maxChars) {
538
- return prefixWithinCharBudget(evidenceText, maxChars);
539
- }
540
- const inference = synthesis?.trim()
541
- ? `[Scout synthesis — untrusted, non-authoritative inference; not evidence]\n${synthesis.trim()}`
542
- : undefined;
543
- if (!inference) {
544
- return evidenceText;
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
+ };
545
175
  }
546
- const separator = evidenceText ? "\n\n" : "";
547
- const remaining = maxChars - evidenceText.length - separator.length;
548
- if (remaining <= 0) {
549
- return evidenceText;
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
+ };
550
186
  }
551
- return `${evidenceText}${separator}${prefixWithinCharBudget(inference, remaining)}`;
187
+ return undefined;
552
188
  };