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,19 +1,20 @@
1
+ import { AdvisorJevLedgerState, type AdvisorJevUsage } from "./jev/ledger.ts";
1
2
  import {
2
3
  type AdvisorUsageTotals,
3
4
  addAdvisorUsage,
4
5
  emptyAdvisorUsageTotals,
5
6
  formatAdvisorUsageStatus,
6
7
  formatAdvisorUsageTotals,
7
- } from "./usage.js";
8
+ } from "./usage.ts";
8
9
 
9
10
  export type GateDecision = "proceed" | "revise" | "blocked";
10
- export type ConsultationTrigger = "manual" | "executor-requested";
11
+ export type ConsultationTrigger = "manual" | "executor-requested" | "turn-gate";
11
12
  export type GateTrigger =
12
13
  | "repeated-tool-call"
13
14
  | "completion-review"
14
15
  | "custom-rule";
15
- export type AdvisorTrigger = ConsultationTrigger | GateTrigger;
16
- export type ExecutionEffect = "continued" | "tool-blocked" | "session-blocked";
16
+ type AdvisorTrigger = ConsultationTrigger | GateTrigger;
17
+ type ExecutionEffect = "continued" | "tool-blocked" | "session-blocked";
17
18
 
18
19
  export interface AdvisorInvocationRecord {
19
20
  cost?: number;
@@ -113,46 +114,77 @@ export const normalizeToolInput = (
113
114
  export const normalizedToolSignature = (toolName: string, input: unknown) =>
114
115
  `${toolName}:${JSON.stringify(normalizeToolInput(toolName, input))}`;
115
116
 
117
+ interface RepetitionState {
118
+ count: number;
119
+ interventions: number;
120
+ previousSignature?: string;
121
+ }
122
+
123
+ interface AdviceLedger {
124
+ draftConsultations: number;
125
+ issued: Map<
126
+ string,
127
+ {
128
+ advice: string;
129
+ normalizedQuestion?: string;
130
+ trigger: ConsultationTrigger;
131
+ }
132
+ >;
133
+ lastAdvice?: string;
134
+ outcomes: number;
135
+ pending: Set<string>;
136
+ reported: Set<string>;
137
+ }
138
+
139
+ interface UsageState {
140
+ invocations: AdvisorInvocationRecord[];
141
+ totals: AdvisorUsageTotals;
142
+ }
143
+
144
+ const freshRepetition = (): RepetitionState => ({
145
+ count: 0,
146
+ interventions: 0,
147
+ });
148
+ const freshAdviceLedger = (): AdviceLedger => ({
149
+ draftConsultations: 0,
150
+ issued: new Map(),
151
+ outcomes: 0,
152
+ pending: new Set(),
153
+ reported: new Set(),
154
+ });
155
+ const freshUsage = (): UsageState => ({
156
+ invocations: [],
157
+ totals: emptyAdvisorUsageTotals(),
158
+ });
159
+
116
160
  export class AdvisorSessionState {
117
- #previousSignature?: string;
118
- #repetitions = 0;
161
+ #repetition = freshRepetition();
119
162
  #blockedReason?: string;
120
- #invocations: AdvisorInvocationRecord[] = [];
121
- #loopInterventions = 0;
163
+ #ledger = freshAdviceLedger();
164
+ #usage = freshUsage();
122
165
  #consumedCalls = 0;
123
- readonly #issuedAdvice = new Map<
124
- string,
125
- { advice: string; trigger: ConsultationTrigger }
126
- >();
127
- readonly #pendingAdvice = new Set<string>();
128
- readonly #reportedAdvice = new Set<string>();
129
- #draftConsultations = 0;
130
- #outcomes = 0;
131
- #lastAdvice?: string;
132
- #usage = emptyAdvisorUsageTotals();
166
+ readonly #jev = new AdvisorJevLedgerState();
167
+ #sessionTurnOrdinal = 0;
168
+ #turnsSinceConsultation = 0;
133
169
 
134
170
  resetTask() {
135
- this.#previousSignature = undefined;
136
- this.#repetitions = 0;
171
+ this.#repetition = freshRepetition();
137
172
  this.#blockedReason = undefined;
138
- this.#invocations = [];
139
- this.#loopInterventions = 0;
173
+ this.#ledger = freshAdviceLedger();
174
+ this.#usage = freshUsage();
140
175
  this.#consumedCalls = 0;
141
- this.#issuedAdvice.clear();
142
- this.#pendingAdvice.clear();
143
- this.#reportedAdvice.clear();
144
- this.#draftConsultations = 0;
145
- this.#outcomes = 0;
146
- this.#lastAdvice = undefined;
147
- this.#usage = emptyAdvisorUsageTotals();
176
+ this.#jev.reset();
177
+ this.#sessionTurnOrdinal = 0;
178
+ this.#turnsSinceConsultation = 0;
148
179
  }
149
180
 
150
181
  clearBlocked() {
151
182
  this.#blockedReason = undefined;
152
183
  }
153
184
  resetRepetition() {
154
- this.#previousSignature = undefined;
155
- this.#repetitions = 0;
185
+ // Cumulative interventions feed the session summary and must survive this reset.
186
+ this.#repetition.count = 0;
187
+ this.#repetition.previousSignature = undefined;
156
188
  }
157
189
  get blocked() {
158
190
  return this.#blockedReason !== undefined;
@@ -169,13 +201,15 @@ export class AdvisorSessionState {
169
201
  return false;
170
202
  }
171
203
  const signature = normalizedToolSignature(toolName, input);
172
- this.#repetitions =
173
- signature === this.#previousSignature ? this.#repetitions + 1 : 1;
174
- this.#previousSignature = signature;
175
- if (this.#repetitions < threshold) {
204
+ this.#repetition.count =
205
+ signature === this.#repetition.previousSignature
206
+ ? this.#repetition.count + 1
207
+ : 1;
208
+ this.#repetition.previousSignature = signature;
209
+ if (this.#repetition.count < threshold) {
176
210
  return false;
177
211
  }
178
- this.#loopInterventions += 1;
212
+ this.#repetition.interventions += 1;
179
213
  return true;
180
214
  }
181
215
 
@@ -194,34 +228,75 @@ export class AdvisorSessionState {
194
228
  return this.#consumedCalls;
195
229
  }
196
230
 
231
+ /** Counts one completed executor turn on both turn counters. The ordinal
232
+ * feeds the filter override window and summary positions and is never reset
233
+ * by anything; turnsSinceConsultation resets on every consultation. */
234
+ recordCompletedTurn() {
235
+ this.#sessionTurnOrdinal += 1;
236
+ this.#turnsSinceConsultation += 1;
237
+ }
238
+
239
+ resetTurnsSinceConsultation() {
240
+ this.#turnsSinceConsultation = 0;
241
+ }
242
+
243
+ get sessionTurnOrdinal() {
244
+ return this.#sessionTurnOrdinal;
245
+ }
246
+
247
+ get turnsSinceConsultation() {
248
+ return this.#turnsSinceConsultation;
249
+ }
250
+
197
251
  /** Returns a copy of cumulative direct Advisor usage for this session. */
198
252
  get usageTotals(): AdvisorUsageTotals {
199
- return { ...this.#usage };
253
+ return { ...this.#usage.totals };
200
254
  }
201
255
 
202
256
  /** Returns the footer-ready direct Advisor usage status for this session. */
203
257
  usageStatus() {
204
- return formatAdvisorUsageStatus(this.#usage);
258
+ return formatAdvisorUsageStatus(this.#usage.totals);
205
259
  }
206
260
 
207
261
  recordInvocation(record: AdvisorInvocationRecord) {
208
- this.#invocations.push(record);
209
- addAdvisorUsage(this.#usage, record.usage);
262
+ this.#usage.invocations.push(record);
263
+ addAdvisorUsage(this.#usage.totals, record.usage);
210
264
  }
211
265
  issueAdvice(
212
266
  id: string,
213
267
  advice: string,
214
268
  trigger: ConsultationTrigger,
215
- draft = false
269
+ draft = false,
270
+ normalizedQuestion?: string
216
271
  ) {
217
- this.#issuedAdvice.set(id, { advice, trigger });
218
- this.#lastAdvice = advice;
272
+ this.#ledger.issued.set(id, {
273
+ advice,
274
+ ...(normalizedQuestion ? { normalizedQuestion } : {}),
275
+ trigger,
276
+ });
277
+ this.#ledger.lastAdvice = advice;
219
278
  if (draft) {
220
- this.#draftConsultations += 1;
279
+ this.#ledger.draftConsultations += 1;
221
280
  }
222
281
  }
282
+
283
+ /** Returns earlier advice for an exactly-matching normalized question. */
284
+ reattachedAdviceFor(
285
+ normalizedQuestion: string | undefined
286
+ ): string | undefined {
287
+ if (!normalizedQuestion) {
288
+ return undefined;
289
+ }
290
+ for (const entry of this.#ledger.issued.values()) {
291
+ if (entry.normalizedQuestion === normalizedQuestion) {
292
+ return entry.advice;
293
+ }
294
+ }
295
+ return undefined;
296
+ }
223
297
  claimTrackedFiles(paths: string[]) {
224
- if (!this.#lastAdvice || paths.length === 0) {
298
+ const advice = this.#ledger.lastAdvice;
299
+ if (!advice || paths.length === 0) {
225
300
  return false;
226
301
  }
227
302
  const mentioned = paths.every((path) => {
@@ -230,38 +305,38 @@ export class AdvisorSessionState {
230
305
  "(^|[\\s\\\"'`()\\[])" +
231
306
  escaped +
232
307
  "(?=$|[\\s\\\"'`),;:!?\\]]|\\.(?=\\s|$))";
233
- return new RegExp(boundary).test(this.#lastAdvice as string);
308
+ return new RegExp(boundary).test(advice);
234
309
  });
235
310
  if (!mentioned) {
236
311
  return false;
237
312
  }
238
- this.#lastAdvice = undefined;
313
+ this.#ledger.lastAdvice = undefined;
239
314
  return true;
240
315
  }
241
316
 
242
317
  reserveAdvice(id: string) {
243
- if (this.#reportedAdvice.has(id) || this.#pendingAdvice.has(id)) {
318
+ if (this.#ledger.reported.has(id) || this.#ledger.pending.has(id)) {
244
319
  return;
245
320
  }
246
- const advice = this.#issuedAdvice.get(id);
321
+ const advice = this.#ledger.issued.get(id);
247
322
  if (!advice) {
248
323
  return;
249
324
  }
250
- this.#pendingAdvice.add(id);
325
+ this.#ledger.pending.add(id);
251
326
  return advice;
252
327
  }
253
328
 
254
329
  commitAdvice(id: string) {
255
- if (!this.#pendingAdvice.delete(id)) {
330
+ if (!this.#ledger.pending.delete(id)) {
256
331
  return false;
257
332
  }
258
- this.#reportedAdvice.add(id);
259
- this.#outcomes += 1;
333
+ this.#ledger.reported.add(id);
334
+ this.#ledger.outcomes += 1;
260
335
  return true;
261
336
  }
262
337
 
263
338
  releaseAdvice(id: string) {
264
- this.#pendingAdvice.delete(id);
339
+ this.#ledger.pending.delete(id);
265
340
  }
266
341
 
267
342
  /** Compatibility helper for synchronous callers that can commit immediately. */
@@ -274,17 +349,11 @@ export class AdvisorSessionState {
274
349
  return advice;
275
350
  }
276
351
 
277
- summary(limit: number | undefined) {
278
- if (this.#invocations.length === 0 && this.#loopInterventions === 0) {
279
- return;
280
- }
281
- const markdown = this.#invocations.filter(
282
- (item) => item.kind === "markdown"
352
+ #decisionsLine() {
353
+ const gates = this.#usage.invocations.filter(
354
+ (item) => item.kind === "gate"
283
355
  );
284
- const gates = this.#invocations.filter((item) => item.kind === "gate");
285
- const countTrigger = (trigger: AdvisorTrigger) =>
286
- this.#invocations.filter((item) => item.trigger === trigger).length;
287
- const decisions =
356
+ return (
288
357
  (["proceed", "revise", "blocked"] as GateDecision[])
289
358
  .map(
290
359
  (decision) =>
@@ -295,34 +364,102 @@ export class AdvisorSessionState {
295
364
  )
296
365
  .filter(([, count]) => count > 0)
297
366
  .map(([decision, count]) => `${count} ${decision}`)
298
- .join(", ") || "none";
367
+ .join(", ") || "none"
368
+ );
369
+ }
370
+
371
+ #countTrigger(trigger: AdvisorTrigger) {
372
+ return this.#usage.invocations.filter((item) => item.trigger === trigger)
373
+ .length;
374
+ }
375
+
376
+ #triggersLine() {
377
+ return (
378
+ [
379
+ "manual",
380
+ "executor-requested",
381
+ "turn-gate",
382
+ "repeated-tool-call",
383
+ "completion-review",
384
+ "custom-rule",
385
+ ]
386
+ .filter((trigger) => this.#countTrigger(trigger as AdvisorTrigger) > 0)
387
+ .join(", ") || "none"
388
+ );
389
+ }
390
+
391
+ recordJevFilterAllowed() {
392
+ this.#jev.recordFilterAllowed();
393
+ }
394
+ recordJevFilterSkipped(repeat: boolean, normalizedQuestion?: string) {
395
+ this.#jev.recordFilterSkipped(
396
+ repeat,
397
+ normalizedQuestion,
398
+ this.#sessionTurnOrdinal
399
+ );
400
+ }
401
+ get lastJevSkip() {
402
+ return this.#jev.lastSkip;
403
+ }
404
+ recordJevFilterOverride() {
405
+ this.#jev.recordFilterOverride();
406
+ }
407
+ recordJevFilterFailure() {
408
+ this.#jev.recordFilterFailure();
409
+ }
410
+ recordJevFilterUsage(usage: AdvisorJevUsage) {
411
+ this.#jev.recordFilterUsage(usage);
412
+ }
413
+
414
+ recordJevGateCheck(usage?: AdvisorJevUsage) {
415
+ this.#jev.recordGateCheck(usage);
416
+ }
417
+ recordJevGateConsultation() {
418
+ this.#jev.recordGateConsultation();
419
+ }
420
+ recordJevGateFailure() {
421
+ this.#jev.recordGateFailure();
422
+ }
423
+
424
+ summary(limit: number | undefined) {
425
+ const { invocations, totals } = this.#usage;
426
+ const jevLines = this.#jev.summaryLines(invocations);
427
+ if (
428
+ invocations.length === 0 &&
429
+ this.#repetition.interventions === 0 &&
430
+ jevLines.length === 0
431
+ ) {
432
+ return;
433
+ }
434
+ const markdown = invocations.filter((item) => item.kind === "markdown");
435
+ const gates = invocations.filter((item) => item.kind === "gate");
299
436
  const effects = (effect: ExecutionEffect) =>
300
- this.#invocations.filter((item) => item.executionEffect === effect)
301
- .length;
302
- const failures = this.#invocations
437
+ invocations.filter((item) => item.executionEffect === effect).length;
438
+ const failures = invocations
303
439
  .filter((item) => item.failure)
304
440
  .map((item) => item.failure);
305
441
  const models =
306
- [
307
- ...new Set(this.#invocations.map((item) => item.model).filter(Boolean)),
308
- ].join(", ") || "unknown";
442
+ [...new Set(invocations.map((item) => item.model).filter(Boolean))].join(
443
+ ", "
444
+ ) || "unknown";
309
445
  const budget =
310
446
  limit === undefined
311
447
  ? `${this.#consumedCalls} used; unlimited remaining`
312
448
  : `${this.#consumedCalls} / ${limit} used; ${Math.max(0, limit - this.#consumedCalls)} remaining`;
313
449
  return [
314
450
  "[Session Advisor Summary]",
315
- `Consultations: ${markdown.length} Markdown (${countTrigger("manual")} manual, ${countTrigger("executor-requested")} executor-requested), automatic gates: ${gates.length}`,
316
- `Triggers: ${["manual", "executor-requested", "repeated-tool-call", "completion-review", "custom-rule"].filter((trigger) => countTrigger(trigger as AdvisorTrigger) > 0).join(", ") || "none"}`,
451
+ `Consultations: ${markdown.length} Markdown (${this.#countTrigger("manual")} manual, ${this.#countTrigger("executor-requested")} executor-requested), automatic gates: ${gates.length}`,
452
+ `Triggers: ${this.#triggersLine()}`,
317
453
  `Models: ${models}`,
318
454
  `Budget: ${budget}`,
319
- `Usage: ${formatAdvisorUsageTotals(this.#usage)}`,
320
- `Markdown advice: ${markdown.length} responses (${this.#draftConsultations} with drafts)`,
321
- `Outcome reports: ${this.#outcomes}`,
322
- `Gate decisions: ${decisions}`,
323
- `Loop matching: normalized tool signatures; ${this.#loopInterventions} gate intervention${this.#loopInterventions === 1 ? "" : "s"}`,
455
+ `Usage: ${formatAdvisorUsageTotals(totals)}`,
456
+ `Markdown advice: ${markdown.length} responses (${this.#ledger.draftConsultations} with drafts)`,
457
+ `Outcome reports: ${this.#ledger.outcomes}`,
458
+ `Gate decisions: ${this.#decisionsLine()}`,
459
+ `Loop matching: normalized tool signatures; ${this.#repetition.interventions} gate intervention${this.#repetition.interventions === 1 ? "" : "s"}`,
324
460
  `Execution effects: ${effects("tool-blocked")} tool blocked, ${effects("session-blocked")} sessions blocked, ${effects("continued")} continued`,
325
461
  `Failures: ${failures.length ? failures.join(", ") : "none"}`,
462
+ ...jevLines,
326
463
  ].join("\n");
327
464
  }
328
465
  }
@@ -0,0 +1,88 @@
1
+ import {
2
+ DEFAULT_ADVISOR_TOOL_RESULT_MAX_BYTES,
3
+ DEFAULT_ADVISOR_TOOL_RESULT_MAX_LINES,
4
+ } from "./config/types.ts";
5
+ import { byteLength, capUtf8Bytes } from "./content-utils.ts";
6
+
7
+ export interface ToolResultTruncation {
8
+ content: string;
9
+ omittedLines: number;
10
+ totalBytes: number;
11
+ totalLines: number;
12
+ truncated: boolean;
13
+ }
14
+
15
+ const OMITTED_MARKER = "[... omitted tool-result section ...]";
16
+
17
+ export const capToolResult = (
18
+ value: string,
19
+ maxLines = DEFAULT_ADVISOR_TOOL_RESULT_MAX_LINES,
20
+ maxBytes = DEFAULT_ADVISOR_TOOL_RESULT_MAX_BYTES
21
+ ): ToolResultTruncation => {
22
+ const lines = value.split("\n");
23
+ const totalLines = lines.length;
24
+ const totalBytes = byteLength(value);
25
+ if ((maxLines === 0 || maxBytes === 0) && value.length > 0) {
26
+ return {
27
+ content: "[Tool result omitted: configured limit is zero]",
28
+ omittedLines: totalLines,
29
+ totalBytes,
30
+ totalLines,
31
+ truncated: true,
32
+ };
33
+ }
34
+ if (totalLines <= maxLines && totalBytes <= maxBytes) {
35
+ return {
36
+ content: value,
37
+ omittedLines: 0,
38
+ totalBytes,
39
+ totalLines,
40
+ truncated: false,
41
+ };
42
+ }
43
+
44
+ const markerBytes = byteLength(OMITTED_MARKER);
45
+ if (maxBytes < markerBytes || maxLines === 1) {
46
+ return {
47
+ content: capUtf8Bytes(OMITTED_MARKER, maxBytes),
48
+ omittedLines: totalLines,
49
+ totalBytes,
50
+ totalLines,
51
+ truncated: true,
52
+ };
53
+ }
54
+ const headCount = Math.floor((maxLines - 1) / 2);
55
+ const tailCount = maxLines - 1 - headCount;
56
+ const collect = (
57
+ candidates: string[],
58
+ maxEntries: number,
59
+ maxContentBytes: number
60
+ ) => {
61
+ const selected: string[] = [];
62
+ let used = 0;
63
+ for (const line of candidates.slice(0, maxEntries)) {
64
+ const next = used + byteLength(line) + (selected.length ? 1 : 0);
65
+ if (next > maxContentBytes) {
66
+ break;
67
+ }
68
+ selected.push(line);
69
+ used = next;
70
+ }
71
+ return selected;
72
+ };
73
+ const availableBytes = maxBytes - markerBytes - 2;
74
+ const head = collect(lines, headCount, Math.floor(availableBytes / 2));
75
+ const tail = collect(
76
+ lines.slice(Math.max(head.length, lines.length - tailCount)),
77
+ tailCount,
78
+ availableBytes - byteLength(head.join("\n"))
79
+ );
80
+ const content = [...head, OMITTED_MARKER, ...tail].join("\n");
81
+ return {
82
+ content,
83
+ omittedLines: Math.max(0, totalLines - head.length - tail.length),
84
+ totalBytes,
85
+ totalLines,
86
+ truncated: true,
87
+ };
88
+ };
@@ -0,0 +1,155 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ readTrackedFiles,
4
+ readUntrackedFiles,
5
+ type UntrackedAttachment,
6
+ } from "../attachments.ts";
7
+ import {
8
+ advisorGitContextMaxCharsRef,
9
+ advisorGitContextRef,
10
+ advisorRedactSecretsRef,
11
+ advisorScoutEnabledRef,
12
+ advisorTrackedFileContentRef,
13
+ advisorUntrackedContentRef,
14
+ contextMaxCharsRef,
15
+ } from "../config/state.ts";
16
+ import {
17
+ clampGitContextLevel,
18
+ collectGitContext,
19
+ type GitContextLevel,
20
+ } from "../git.ts";
21
+ import { readProjectPreferences } from "../preferences.ts";
22
+ import { redactAndCapText, redactSecrets } from "../redaction.ts";
23
+ import {
24
+ runAdvisorScout,
25
+ type ScoutLifecycleEvent,
26
+ type ScoutOutcome,
27
+ } from "../scout.ts";
28
+ import { curateAdvisorConversation } from "../scout-curation.ts";
29
+ import {
30
+ advisorGitContextBudget,
31
+ advisorRepositoryContext,
32
+ advisorRequestConversation,
33
+ } from "./prompts.ts";
34
+
35
+ /** Maximum bytes for project preferences and the redacted draft. */
36
+ const ATTACHMENT_TEXT_MAX_BYTES = 8 * 1024;
37
+ /** Combined budget for untracked plus tracked file attachments. */
38
+ const ATTACHMENTS_TOTAL_MAX_BYTES = 24 * 1024;
39
+
40
+ export interface ConsultationContextOptions {
41
+ ctx: ExtensionContext;
42
+ currentInvocationId?: string;
43
+ draft?: string;
44
+ gitContext?: GitContextLevel;
45
+ includeTracked?: string[];
46
+ includeUntracked?: string[];
47
+ onScout?: (event: ScoutLifecycleEvent) => void;
48
+ signal?: AbortSignal;
49
+ }
50
+
51
+ export interface ConsultationContext {
52
+ /** Disclosed repository changes header. */
53
+ changeText: string;
54
+ /** Curated (or legacy) conversation body. */
55
+ conversation: string;
56
+ /** Redacted draft text, if a draft was supplied. */
57
+ draftText?: string;
58
+ /** Redacted project preferences, if present. */
59
+ preferences?: { bytes: number; text: string };
60
+ /** Non-cancelling Scout outcome, when Scout ran. */
61
+ scout?: Exclude<ScoutOutcome, { cancelled: true }>;
62
+ /** Redacted tracked-file attachments. */
63
+ tracked: UntrackedAttachment[];
64
+ /** Redacted untracked-file attachments. */
65
+ untracked: UntrackedAttachment[];
66
+ }
67
+
68
+ /** Assembles every context region the Advisor request embeds: repository
69
+ * changes (spending the shared budget first), the curated conversation,
70
+ * project preferences, and consented file attachments. */
71
+ export const assembleConsultationContext = async (
72
+ options: ConsultationContextOptions
73
+ ): Promise<ConsultationContext> => {
74
+ const { ctx } = options;
75
+
76
+ // The user setting is the ceiling; the Executor may only narrow it.
77
+ const allowed = advisorGitContextRef;
78
+ const level = clampGitContextLevel(options.gitContext ?? allowed, allowed);
79
+ const gitBudget = advisorGitContextBudget(
80
+ contextMaxCharsRef,
81
+ advisorGitContextMaxCharsRef
82
+ );
83
+ const changes = collectGitContext(
84
+ ctx.cwd,
85
+ level,
86
+ gitBudget,
87
+ advisorRedactSecretsRef ? redactSecrets : undefined
88
+ );
89
+ // The disclosure warning is control metadata, not repository payload. Keep it
90
+ // outside the zero-byte Git budget so disabling disclosure cannot erase it.
91
+ const changeText = advisorRepositoryContext(
92
+ changes,
93
+ options.gitContext ?? allowed,
94
+ level,
95
+ gitBudget
96
+ );
97
+ // Repository context spends part of the shared budget, so a large patch
98
+ // cannot silently push the conversation past the model's context window.
99
+ const conversationBudget = Math.max(
100
+ 0,
101
+ contextMaxCharsRef - changeText.length
102
+ );
103
+ const legacyConversation = advisorRequestConversation(
104
+ ctx,
105
+ conversationBudget
106
+ );
107
+ const curated = await curateAdvisorConversation(
108
+ ctx,
109
+ legacyConversation,
110
+ options.signal,
111
+ options.onScout,
112
+ advisorScoutEnabledRef,
113
+ runAdvisorScout,
114
+ options.currentInvocationId,
115
+ conversationBudget
116
+ );
117
+ const preferences = await readProjectPreferences(
118
+ ctx,
119
+ ATTACHMENT_TEXT_MAX_BYTES,
120
+ advisorRedactSecretsRef
121
+ );
122
+ const draftText = options.draft
123
+ ? redactAndCapText(
124
+ options.draft,
125
+ ATTACHMENT_TEXT_MAX_BYTES,
126
+ advisorRedactSecretsRef
127
+ )
128
+ : undefined;
129
+ const untracked = await readUntrackedFiles(
130
+ ctx.cwd,
131
+ options.includeUntracked ?? [],
132
+ advisorUntrackedContentRef,
133
+ advisorRedactSecretsRef
134
+ );
135
+ const tracked = await readTrackedFiles(
136
+ ctx.cwd,
137
+ options.includeTracked ?? [],
138
+ advisorTrackedFileContentRef,
139
+ advisorRedactSecretsRef,
140
+ Math.max(
141
+ 0,
142
+ ATTACHMENTS_TOTAL_MAX_BYTES -
143
+ untracked.reduce((sum, item) => sum + item.bytes, 0)
144
+ )
145
+ );
146
+ return {
147
+ changeText,
148
+ conversation: curated.conversation,
149
+ draftText,
150
+ preferences,
151
+ scout: curated.scout,
152
+ tracked,
153
+ untracked,
154
+ };
155
+ };