pi-advisor-flow 0.3.1 → 0.3.4

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.
@@ -0,0 +1,526 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import {
4
+ type AdvisorToolPolicies,
5
+ advisorRedactSecretsRef,
6
+ advisorToolPoliciesRef,
7
+ advisorToolResultMaxBytesRef,
8
+ advisorToolResultMaxLinesRef,
9
+ } from "./config.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
+ maxBytes?: number;
173
+ maxGroupBytes?: number;
174
+ maxGroups?: number;
175
+ policies?: AdvisorToolPolicies;
176
+ redact?: boolean;
177
+ toolResultMaxBytes?: number;
178
+ toolResultMaxLines?: number;
179
+ }
180
+
181
+ /** Builds disclosed, indivisible history groups from Pi's compaction-aware branch. */
182
+ export const buildScoutManifest = (
183
+ ctx: ExtensionContext,
184
+ options: BuildScoutManifestOptions = {}
185
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: protocol validation keeps fail-open branches visible in one deterministic pass.
186
+ ): ScoutManifestResult => {
187
+ const entries = ctx.sessionManager.buildContextEntries();
188
+ const policies = options.policies ?? advisorToolPoliciesRef;
189
+ const redact = options.redact ?? advisorRedactSecretsRef;
190
+ const toolResultMaxLines =
191
+ options.toolResultMaxLines ?? advisorToolResultMaxLinesRef;
192
+ const toolResultMaxBytes =
193
+ options.toolResultMaxBytes ?? advisorToolResultMaxBytesRef;
194
+ const maxBytes = options.maxBytes ?? SCOUT_MANIFEST_MAX_BYTES;
195
+ const maxGroups = options.maxGroups ?? SCOUT_MANIFEST_MAX_GROUPS;
196
+ const maxGroupBytes = options.maxGroupBytes ?? SCOUT_GROUP_MAX_BYTES;
197
+
198
+ let latestUserIndex = -1;
199
+ const callOwners = new Map<string, { index: number; name: string }>();
200
+ const resultsByCall = new Map<
201
+ string,
202
+ Array<{ entry: RecordValue; index: number }>
203
+ >();
204
+ for (let index = 0; index < entries.length; index += 1) {
205
+ const entry = entries[index] as unknown as RecordValue;
206
+ if (entry.type !== "message" || !isRecord(entry.message)) {
207
+ continue;
208
+ }
209
+ if (entry.message.role === "user" && textFrom(entry.message.content)) {
210
+ latestUserIndex = index;
211
+ }
212
+ if (entry.message.role === "assistant") {
213
+ for (const call of toolCalls(entry.message)) {
214
+ const id = toolCallId(call);
215
+ if (!id || callOwners.has(id)) {
216
+ return {
217
+ message: `Assistant tool calls at context entry ${index} have missing or duplicate IDs.`,
218
+ ok: false,
219
+ reason: "invalid-protocol",
220
+ };
221
+ }
222
+ callOwners.set(id, {
223
+ index,
224
+ name: typeof call.name === "string" ? call.name : "unknown",
225
+ });
226
+ }
227
+ } else if (entry.message.role === "toolResult") {
228
+ const id = entry.message.toolCallId;
229
+ if (typeof id !== "string") {
230
+ return {
231
+ message: `Tool result at context entry ${index} has no tool-call ID.`,
232
+ ok: false,
233
+ reason: "invalid-protocol",
234
+ };
235
+ }
236
+ const results = resultsByCall.get(id) ?? [];
237
+ results.push({ entry, index });
238
+ resultsByCall.set(id, results);
239
+ }
240
+ }
241
+ for (const [id, results] of resultsByCall) {
242
+ if (results.length > 1) {
243
+ return {
244
+ message: `Tool call ${id} has duplicate result messages.`,
245
+ ok: false,
246
+ reason: "invalid-protocol",
247
+ };
248
+ }
249
+ }
250
+
251
+ const groups: ScoutContextGroup[] = [];
252
+ const consumedResultIndexes = new Set<number>();
253
+ let protocolOmittedBytes = 0;
254
+ let protocolOmittedCount = 0;
255
+ for (let index = 0; index < entries.length; index += 1) {
256
+ const entry = entries[index] as unknown as RecordValue;
257
+ const disclosed = conversationEntry(
258
+ entry,
259
+ toolResultMaxLines,
260
+ toolResultMaxBytes,
261
+ policies,
262
+ redact
263
+ );
264
+ if (!disclosed) {
265
+ continue;
266
+ }
267
+ const entryId = typeof entry.id === "string" ? entry.id : String(index);
268
+ if (entry.type !== "message" || !isRecord(entry.message)) {
269
+ groups.push(
270
+ createGroup(index, [entryId], "compaction", disclosed, false)
271
+ );
272
+ continue;
273
+ }
274
+ const { message } = entry;
275
+ if (message.role === "user") {
276
+ groups.push(
277
+ createGroup(
278
+ index,
279
+ [entryId],
280
+ "user",
281
+ disclosed,
282
+ index === latestUserIndex
283
+ )
284
+ );
285
+ continue;
286
+ }
287
+ if (message.role === "toolResult") {
288
+ if (consumedResultIndexes.has(index)) {
289
+ continue;
290
+ }
291
+ const resultId = message.toolCallId;
292
+ const owner =
293
+ typeof resultId === "string" ? callOwners.get(resultId) : undefined;
294
+ if (owner) {
295
+ return {
296
+ message: `Tool result at context entry ${index} precedes or conflicts with its retained call.`,
297
+ ok: false,
298
+ reason: "invalid-protocol",
299
+ };
300
+ }
301
+ // Compaction or interrupted history can retain a result without its call.
302
+ // It is unavailable optional evidence and is never offered to Scout.
303
+ protocolOmittedCount += 1;
304
+ protocolOmittedBytes += byteLength(disclosed);
305
+ continue;
306
+ }
307
+ if (message.role !== "assistant") {
308
+ continue;
309
+ }
310
+
311
+ const calls = toolCalls(message);
312
+ if (calls.length === 0) {
313
+ groups.push(createGroup(index, [entryId], "assistant", disclosed, false));
314
+ continue;
315
+ }
316
+ const callIds = calls.map(toolCallId) as string[];
317
+ const immediate = entries[index + 1] as unknown as RecordValue | undefined;
318
+ if (
319
+ immediate?.type === "message" &&
320
+ isRecord(immediate.message) &&
321
+ immediate.message.role === "toolResult" &&
322
+ typeof immediate.message.toolCallId === "string" &&
323
+ !callIds.includes(immediate.message.toolCallId) &&
324
+ !callOwners.has(immediate.message.toolCallId)
325
+ ) {
326
+ return {
327
+ message: `Tool result at context entry ${index + 1} does not match its adjacent assistant group.`,
328
+ ok: false,
329
+ reason: "invalid-protocol",
330
+ };
331
+ }
332
+ const missing = new Set<string>();
333
+ const resultParts: string[] = [];
334
+ const resultEntryIds: string[] = [];
335
+ for (const [callIndex, callId] of callIds.entries()) {
336
+ const resultMatch = resultsByCall.get(callId)?.[0];
337
+ if (!resultMatch) {
338
+ missing.add(callId);
339
+ continue;
340
+ }
341
+ if (resultMatch.index <= index) {
342
+ return {
343
+ message: `Tool result at context entry ${resultMatch.index} precedes its assistant call.`,
344
+ ok: false,
345
+ reason: "invalid-protocol",
346
+ };
347
+ }
348
+ const resultMessage = resultMatch.entry.message;
349
+ const expectedName =
350
+ typeof calls[callIndex].name === "string"
351
+ ? calls[callIndex].name
352
+ : "unknown";
353
+ if (!isRecord(resultMessage) || resultMessage.toolName !== expectedName) {
354
+ return {
355
+ message: `Tool result at context entry ${resultMatch.index} conflicts with call ${callId}.`,
356
+ ok: false,
357
+ reason: "invalid-protocol",
358
+ };
359
+ }
360
+ consumedResultIndexes.add(resultMatch.index);
361
+ const resultText = conversationEntry(
362
+ resultMatch.entry,
363
+ toolResultMaxLines,
364
+ toolResultMaxBytes,
365
+ policies,
366
+ redact
367
+ );
368
+ if (resultText) {
369
+ resultParts.push(resultText);
370
+ }
371
+ resultEntryIds.push(
372
+ typeof resultMatch.entry.id === "string"
373
+ ? resultMatch.entry.id
374
+ : String(resultMatch.index)
375
+ );
376
+ }
377
+ if (missing.size > 0) {
378
+ const { currentInvocationId } = options;
379
+ const pendingCurrentInvocation =
380
+ currentInvocationId !== undefined &&
381
+ missing.size === 1 &&
382
+ missing.has(currentInvocationId) &&
383
+ callIds.includes(currentInvocationId);
384
+ if (!pendingCurrentInvocation) {
385
+ protocolOmittedCount += 1;
386
+ protocolOmittedBytes += byteLength(
387
+ [disclosed, ...resultParts].join("\n\n")
388
+ );
389
+ continue;
390
+ }
391
+ const pendingDisclosed = pendingInvocationDisclosure(
392
+ entry,
393
+ currentInvocationId,
394
+ toolResultMaxLines,
395
+ toolResultMaxBytes,
396
+ policies,
397
+ redact,
398
+ disclosed
399
+ );
400
+ groups.push(
401
+ createGroup(
402
+ index,
403
+ [entryId, ...resultEntryIds],
404
+ "pending-invocation",
405
+ [pendingDisclosed ?? disclosed, ...resultParts].join("\n\n"),
406
+ true
407
+ )
408
+ );
409
+ continue;
410
+ }
411
+ groups.push(
412
+ createGroup(
413
+ index,
414
+ [entryId, ...resultEntryIds],
415
+ "tool-exchange",
416
+ [disclosed, ...resultParts].join("\n\n"),
417
+ false
418
+ )
419
+ );
420
+ }
421
+
422
+ const availableCount = groups.length + protocolOmittedCount;
423
+ const availableBytes =
424
+ groups.reduce((sum, group) => sum + groupWireBytes(group), 0) +
425
+ protocolOmittedBytes;
426
+ if (maxBytes <= 0) {
427
+ return {
428
+ manifest: {
429
+ availableBytes: 0,
430
+ availableCount: 0,
431
+ groups: [],
432
+ omittedBytes: 0,
433
+ omittedCount: 0,
434
+ },
435
+ ok: true,
436
+ };
437
+ }
438
+ const required = groups.filter((group) => group.required);
439
+ if (
440
+ required.some((group) => group.bytes > maxGroupBytes) ||
441
+ required.length > maxGroups ||
442
+ required.reduce((sum, group) => sum + groupWireBytes(group), 0) > maxBytes
443
+ ) {
444
+ return {
445
+ message:
446
+ "Required Scout context exceeds a fixed manifest or per-group limit.",
447
+ ok: false,
448
+ reason: "required-group-overflow",
449
+ };
450
+ }
451
+
452
+ const selected = groups.filter(
453
+ (group) => group.required || group.bytes <= maxGroupBytes
454
+ );
455
+ const fits = () =>
456
+ selected.length <= maxGroups &&
457
+ selected.reduce((sum, group) => sum + groupWireBytes(group), 0) <= maxBytes;
458
+ while (!fits()) {
459
+ const optionalIndex = selected.findIndex((group) => !group.required);
460
+ if (optionalIndex < 0) {
461
+ return {
462
+ message: "Required Scout context exceeds fixed manifest limits.",
463
+ ok: false,
464
+ reason: "required-group-overflow",
465
+ };
466
+ }
467
+ selected.splice(optionalIndex, 1);
468
+ }
469
+ const selectedIds = new Set(selected.map((group) => group.id));
470
+ const omitted = groups.filter((group) => !selectedIds.has(group.id));
471
+ return {
472
+ manifest: {
473
+ availableBytes,
474
+ availableCount,
475
+ groups: selected,
476
+ omittedBytes:
477
+ protocolOmittedBytes +
478
+ omitted.reduce((sum, group) => sum + group.bytes, 0),
479
+ omittedCount: protocolOmittedCount + omitted.length,
480
+ },
481
+ ok: true,
482
+ };
483
+ };
484
+
485
+ const prefixWithinCharBudget = (value: string, maxChars: number) => {
486
+ let result = "";
487
+ for (const character of value) {
488
+ if (result.length + character.length > maxChars) {
489
+ break;
490
+ }
491
+ result += character;
492
+ }
493
+ return result;
494
+ };
495
+
496
+ export const reconstructScoutConversation = (
497
+ manifest: ScoutManifest,
498
+ selectedIds: string[],
499
+ synthesis?: string,
500
+ maxChars = Number.MAX_SAFE_INTEGER
501
+ ): string => {
502
+ const selected = new Set(selectedIds);
503
+ const evidence = manifest.groups
504
+ .filter((group) => group.required || selected.has(group.id))
505
+ .sort((left, right) => left.originalIndex - right.originalIndex)
506
+ .map((group) => group.content);
507
+ const evidenceText = evidence.join("\n\n");
508
+ if (maxChars <= 0) {
509
+ return "";
510
+ }
511
+ if (evidenceText.length >= maxChars) {
512
+ return prefixWithinCharBudget(evidenceText, maxChars);
513
+ }
514
+ const inference = synthesis?.trim()
515
+ ? `[Scout synthesis — untrusted, non-authoritative inference; not evidence]\n${synthesis.trim()}`
516
+ : undefined;
517
+ if (!inference) {
518
+ return evidenceText;
519
+ }
520
+ const separator = evidenceText ? "\n\n" : "";
521
+ const remaining = maxChars - evidenceText.length - separator.length;
522
+ if (remaining <= 0) {
523
+ return evidenceText;
524
+ }
525
+ return `${evidenceText}${separator}${prefixWithinCharBudget(inference, remaining)}`;
526
+ };