pi-advisor-flow 0.3.6 → 0.5.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.
package/src/config.ts CHANGED
@@ -13,8 +13,8 @@ import {
13
13
  isValidGitContextLevel,
14
14
  } from "./git.js";
15
15
 
16
- export const FALLBACK_EXECUTOR = "aikeys/claude-sonnet-5";
17
- export const FALLBACK_ADVISOR = "aikeys/claude-fable-5";
16
+ export const FALLBACK_EXECUTOR = "openai-codex/gpt-5.6-luna";
17
+ export const FALLBACK_ADVISOR = "openai-codex/gpt-5.6-sol";
18
18
  export const DEFAULT_CONTEXT_MAX_CHARS = 15_000;
19
19
  export const MAX_CONTEXT_MAX_CHARS = Number.MAX_SAFE_INTEGER;
20
20
  export const DEFAULT_ADVISOR_TOOL_RESULT_MAX_LINES = DEFAULT_MAX_LINES;
@@ -66,6 +66,8 @@ export let advisorOutcomeLoggingRef = false;
66
66
  export let advisorUntrackedContentRef = false;
67
67
  export let advisorTrackedFileContentRef = false;
68
68
  export let advisorScoutEnabledRef = false;
69
+ export let showUsageDetailsRef = true;
70
+ export let showUsageFooterRef = false;
69
71
 
70
72
  export const setExecutorRef = (ref: string) => {
71
73
  executorRef = ref;
@@ -174,6 +176,12 @@ export const setAdvisorTrackedFileContentRef = (enabled: boolean) => {
174
176
  export const setAdvisorScoutEnabledRef = (enabled: boolean) => {
175
177
  advisorScoutEnabledRef = enabled;
176
178
  };
179
+ export const setShowUsageDetailsRef = (enabled: boolean) => {
180
+ showUsageDetailsRef = enabled;
181
+ };
182
+ export const setShowUsageFooterRef = (enabled: boolean) => {
183
+ showUsageFooterRef = enabled;
184
+ };
177
185
 
178
186
  /**
179
187
  * Returns the current live settings state. Use this at UI boundaries instead of
@@ -200,6 +208,8 @@ export const getAdvisorSettings = () => ({
200
208
  redactSecrets: advisorRedactSecretsRef,
201
209
  scoutEnabled: advisorScoutEnabledRef,
202
210
  sessionSummary: advisorSessionSummaryRef,
211
+ showUsageDetails: showUsageDetailsRef,
212
+ showUsageFooter: showUsageFooterRef,
203
213
  simpleMode: simpleModeRef,
204
214
  toolPolicies: { ...advisorToolPoliciesRef },
205
215
  toolResultMaxBytes: advisorToolResultMaxBytesRef,
@@ -208,9 +218,13 @@ export const getAdvisorSettings = () => ({
208
218
  untrackedContent: advisorUntrackedContentRef,
209
219
  });
210
220
 
221
+ /** Reads the live session budget at each enforcement boundary. */
222
+ export const getAdvisorMaxCallsPerSession = () =>
223
+ getAdvisorSettings().maxCallsPerSession;
224
+
211
225
  export const splitRef = (ref: string): [string, string] => {
212
226
  const i = ref.indexOf("/");
213
- return i === -1 ? ["aikeys", ref] : [ref.slice(0, i), ref.slice(i + 1)];
227
+ return i === -1 ? ["openai-codex", ref] : [ref.slice(0, i), ref.slice(i + 1)];
214
228
  };
215
229
 
216
230
  export const configPaths = (ctx: ExtensionContext) => [
@@ -249,6 +263,8 @@ export interface AdvisorConfig {
249
263
  executor?: string;
250
264
  executorEffort?: string;
251
265
  gateFailureMode?: GateFailureMode;
266
+ showUsageDetails?: boolean;
267
+ showUsageFooter?: boolean;
252
268
  simpleMode?: boolean;
253
269
  }
254
270
 
@@ -269,6 +285,7 @@ const CONFIG_KEYS = new Set<keyof AdvisorConfig>([
269
285
  "advisorPlanGate",
270
286
  "advisorSessionSummary",
271
287
  "advisorScoutEnabled",
288
+ "showUsageDetails",
272
289
  "simpleMode",
273
290
  "alwaysOn",
274
291
  "advisorToolResultMaxBytes",
@@ -282,6 +299,7 @@ const CONFIG_KEYS = new Set<keyof AdvisorConfig>([
282
299
  "executor",
283
300
  "executorEffort",
284
301
  "gateFailureMode",
302
+ "showUsageFooter",
285
303
  ]);
286
304
  const BOOLEAN_CONFIG_KEYS = [
287
305
  "advisorPlanGate",
@@ -292,6 +310,8 @@ const BOOLEAN_CONFIG_KEYS = [
292
310
  "advisorAutoLoopGate",
293
311
  "advisorSessionSummary",
294
312
  "advisorScoutEnabled",
313
+ "showUsageDetails",
314
+ "showUsageFooter",
295
315
  "simpleMode",
296
316
  "alwaysOn",
297
317
  "advisorHerdrIntegration",
@@ -478,6 +498,8 @@ const resetDefaults = () => {
478
498
  advisorUntrackedContentRef = false;
479
499
  advisorTrackedFileContentRef = false;
480
500
  advisorScoutEnabledRef = false;
501
+ showUsageDetailsRef = true;
502
+ showUsageFooterRef = false;
481
503
  };
482
504
 
483
505
  const applyOptionalConfig = <Key extends keyof AdvisorConfig>(
@@ -545,6 +567,8 @@ const applyConfig = (config: AdvisorConfig) => {
545
567
  setAdvisorSessionSummaryRef
546
568
  );
547
569
  applyOptionalConfig(config, "advisorScoutEnabled", setAdvisorScoutEnabledRef);
570
+ applyOptionalConfig(config, "showUsageDetails", setShowUsageDetailsRef);
571
+ applyOptionalConfig(config, "showUsageFooter", setShowUsageFooterRef);
548
572
  applyOptionalConfig(config, "simpleMode", setSimpleModeRef);
549
573
  applyOptionalConfig(config, "alwaysOn", setAlwaysOnRef);
550
574
  applyOptionalConfig(config, "gateFailureMode", setAdvisorFailureModeRef);
@@ -712,6 +736,8 @@ export const saveConfig = (_ctx: ExtensionContext) => {
712
736
  advisorUntrackedContent: advisorUntrackedContentRef,
713
737
  alwaysOn: alwaysOnRef,
714
738
  gateFailureMode: advisorFailureModeRef,
739
+ showUsageDetails: showUsageDetailsRef,
740
+ showUsageFooter: showUsageFooterRef,
715
741
  simpleMode: simpleModeRef,
716
742
  };
717
743
  writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`);
@@ -56,6 +56,129 @@ export interface CollectedTextStream {
56
56
  usage?: unknown;
57
57
  }
58
58
 
59
+ export const ADVISOR_STREAM_UPDATE_INTERVAL_MS = 90;
60
+
61
+ export interface CoalescedUpdateResult {
62
+ error?: unknown;
63
+ failed: boolean;
64
+ }
65
+
66
+ export interface CoalescedUpdate<T> {
67
+ cancel: () => void;
68
+ flush: () => CoalescedUpdateResult;
69
+ update: (value: T) => void;
70
+ }
71
+
72
+ export interface CoalescedUpdateScheduler {
73
+ clearTimeout: (timer: ReturnType<typeof setTimeout>) => void;
74
+ now: () => number;
75
+ setTimeout: (
76
+ callback: () => void,
77
+ delay: number
78
+ ) => ReturnType<typeof setTimeout>;
79
+ }
80
+
81
+ /**
82
+ * Keep stream updates responsive without forwarding every provider delta to
83
+ * the UI. The first update in a burst is immediate; later updates are kept as
84
+ * the latest value and published at most once per interval. `flush()` closes
85
+ * the publisher and is intended for terminal success/error paths.
86
+ */
87
+ export const createCoalescedUpdate = <T>(
88
+ publish: (value: T) => void,
89
+ intervalMs = ADVISOR_STREAM_UPDATE_INTERVAL_MS,
90
+ scheduler: CoalescedUpdateScheduler = {
91
+ clearTimeout,
92
+ now: Date.now,
93
+ setTimeout: (callback, delay) => setTimeout(callback, delay),
94
+ }
95
+ ): CoalescedUpdate<T> => {
96
+ if (!Number.isFinite(intervalMs) || intervalMs <= 0) {
97
+ throw new Error("Coalesced update interval must be positive and finite.");
98
+ }
99
+
100
+ let closed = false;
101
+ let hasPending = false;
102
+ let pending: T | undefined;
103
+ let lastPublishedAt: number | undefined;
104
+ let timer: ReturnType<typeof setTimeout> | undefined;
105
+ let publishError: unknown;
106
+ let publishFailed = false;
107
+
108
+ const clearTimer = () => {
109
+ if (timer !== undefined) {
110
+ scheduler.clearTimeout(timer);
111
+ timer = undefined;
112
+ }
113
+ };
114
+
115
+ const publishPending = () => {
116
+ timer = undefined;
117
+ if (!hasPending) {
118
+ return;
119
+ }
120
+ const value = pending as T;
121
+ pending = undefined;
122
+ hasPending = false;
123
+ lastPublishedAt = scheduler.now();
124
+ try {
125
+ publish(value);
126
+ } catch (error) {
127
+ publishFailed = true;
128
+ publishError = error;
129
+ closed = true;
130
+ clearTimer();
131
+ }
132
+ };
133
+
134
+ const schedule = () => {
135
+ const elapsed =
136
+ lastPublishedAt === undefined
137
+ ? intervalMs
138
+ : scheduler.now() - lastPublishedAt;
139
+ const delay = Math.max(0, intervalMs - elapsed);
140
+ if (delay === 0) {
141
+ publishPending();
142
+ return;
143
+ }
144
+ timer = scheduler.setTimeout(publishPending, delay);
145
+ timer.unref?.();
146
+ };
147
+
148
+ return {
149
+ cancel: () => {
150
+ closed = true;
151
+ clearTimer();
152
+ pending = undefined;
153
+ hasPending = false;
154
+ },
155
+ flush: () => {
156
+ if (!closed) {
157
+ closed = true;
158
+ clearTimer();
159
+ publishPending();
160
+ }
161
+ return { error: publishError, failed: publishFailed };
162
+ },
163
+ update: (value) => {
164
+ if (closed) {
165
+ return;
166
+ }
167
+ if (publishFailed) {
168
+ throw publishError;
169
+ }
170
+ pending = value;
171
+ hasPending = true;
172
+ if (timer === undefined) {
173
+ schedule();
174
+ }
175
+ if (publishFailed) {
176
+ throw publishError;
177
+ }
178
+ },
179
+ };
180
+ };
181
+
59
182
  export const collectTextStream = async (
60
183
  resolved: ResolvedConfiguredModel,
61
184
  options: CollectTextStreamOptions,
@@ -169,9 +169,14 @@ const pendingInvocationDisclosure = (
169
169
 
170
170
  export interface BuildScoutManifestOptions {
171
171
  currentInvocationId?: string;
172
+ /** @deprecated Use maxManifestBytes for the Scout transport budget. */
172
173
  maxBytes?: number;
174
+ /** Maximum reconstructed Advisor conversation characters. */
175
+ maxConversationChars?: number;
173
176
  maxGroupBytes?: number;
174
177
  maxGroups?: number;
178
+ /** Maximum serialized group-manifest bytes sent to Scout. */
179
+ maxManifestBytes?: number;
175
180
  policies?: AdvisorToolPolicies;
176
181
  redact?: boolean;
177
182
  toolResultMaxBytes?: number;
@@ -191,9 +196,13 @@ export const buildScoutManifest = (
191
196
  options.toolResultMaxLines ?? advisorToolResultMaxLinesRef;
192
197
  const toolResultMaxBytes =
193
198
  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;
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;
197
206
 
198
207
  let latestUserIndex = -1;
199
208
  const callOwners = new Map<string, { index: number; name: string }>();
@@ -423,7 +432,7 @@ export const buildScoutManifest = (
423
432
  const availableBytes =
424
433
  groups.reduce((sum, group) => sum + groupWireBytes(group), 0) +
425
434
  protocolOmittedBytes;
426
- if (maxBytes <= 0) {
435
+ if (maxManifestBytes <= 0) {
427
436
  return {
428
437
  manifest: {
429
438
  availableBytes: 0,
@@ -439,22 +448,39 @@ export const buildScoutManifest = (
439
448
  if (
440
449
  required.some((group) => group.bytes > maxGroupBytes) ||
441
450
  required.length > maxGroups ||
442
- required.reduce((sum, group) => sum + groupWireBytes(group), 0) > maxBytes
451
+ required.reduce((sum, group) => sum + groupWireBytes(group), 0) >
452
+ maxManifestBytes
443
453
  ) {
444
454
  return {
445
455
  message:
446
- "Required Scout context exceeds a fixed manifest or per-group limit.",
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.",
447
471
  ok: false,
448
472
  reason: "required-group-overflow",
449
473
  };
450
474
  }
451
-
452
475
  const selected = groups.filter(
453
476
  (group) => group.required || group.bytes <= maxGroupBytes
454
477
  );
455
478
  const fits = () =>
456
479
  selected.length <= maxGroups &&
457
- selected.reduce((sum, group) => sum + groupWireBytes(group), 0) <= maxBytes;
480
+ selected.reduce((sum, group) => sum + groupWireBytes(group), 0) <=
481
+ maxManifestBytes &&
482
+ (maxConversationChars === undefined ||
483
+ contentChars(selected) <= maxConversationChars);
458
484
  while (!fits()) {
459
485
  const optionalIndex = selected.findIndex((group) => !group.required);
460
486
  if (optionalIndex < 0) {
package/src/scout.ts CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  SCOUT_SYNTHESIS_MAX_BYTES,
14
14
  type ScoutManifest,
15
15
  } from "./scout-context.js";
16
- import type { BenchmarkTelemetry } from "./telemetry.js";
16
+ import { snapshotAdvisorUsage } from "./usage.js";
17
17
 
18
18
  export const SCOUT_TIMEOUT_MS = 30_000;
19
19
 
@@ -224,42 +224,12 @@ export const runAdvisorScout = async (
224
224
  parentSignal?: AbortSignal,
225
225
  onEvent?: (event: ScoutLifecycleEvent) => void,
226
226
  timeoutMs = SCOUT_TIMEOUT_MS,
227
- dependencies: ScoutDependencies = defaultDependencies,
228
- telemetry?: BenchmarkTelemetry
227
+ dependencies: ScoutDependencies = defaultDependencies
229
228
  // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: cancellation, timeout, provider, and schema outcomes remain explicitly distinct.
230
229
  ): Promise<ScoutOutcome> => {
231
230
  const startedAt = Date.now();
232
231
  const publish = (event: ScoutLifecycleEvent) => {
233
232
  onEvent?.(event);
234
- if (event.type === "chunk") {
235
- return;
236
- }
237
- if (event.type === "call" || event.type === "cancelled") {
238
- telemetry?.scout(event);
239
- } else if (event.type === "success") {
240
- telemetry?.scout({
241
- availableCount: event.outcome.metrics.availableCount,
242
- latencyMs: event.outcome.metrics.latencyMs,
243
- model: event.outcome.model,
244
- omittedBeforeScout: event.outcome.metrics.omittedBeforeScout,
245
- selectedCount: event.outcome.metrics.selectedCount,
246
- selectedLabels: event.outcome.selectedLabels,
247
- synthesis: event.outcome.selection.synthesis,
248
- type: "success",
249
- usage: event.outcome.metrics.usage,
250
- });
251
- } else {
252
- telemetry?.scout({
253
- availableCount: event.outcome.metrics.availableCount,
254
- fallback: `${event.outcome.category}: ${event.outcome.message}`,
255
- latencyMs: event.outcome.metrics.latencyMs,
256
- model: event.outcome.model,
257
- omittedBeforeScout: event.outcome.metrics.omittedBeforeScout,
258
- selectedCount: event.outcome.metrics.selectedCount,
259
- type: "fallback",
260
- usage: event.outcome.metrics.usage,
261
- });
262
- }
263
233
  };
264
234
  if (parentSignal?.aborted) {
265
235
  publish({ type: "cancelled" });
@@ -361,7 +331,10 @@ export const runAdvisorScout = async (
361
331
  ? ("invalid-selection" as const)
362
332
  : ("empty-response" as const),
363
333
  message,
364
- metrics: { ...baseMetrics(manifest, startedAt), usage: streamed.usage },
334
+ metrics: {
335
+ ...baseMetrics(manifest, startedAt),
336
+ usage: snapshotAdvisorUsage(streamed.usage),
337
+ },
365
338
  model: executorRef,
366
339
  ok: false as const,
367
340
  };
@@ -383,7 +356,7 @@ export const runAdvisorScout = async (
383
356
  .filter((group) => group.required)
384
357
  .map((group) => group.id),
385
358
  ]).size,
386
- usage: streamed.usage,
359
+ usage: snapshotAdvisorUsage(streamed.usage),
387
360
  },
388
361
  model: executorRef,
389
362
  ok: true as const,
@@ -1,3 +1,11 @@
1
+ import {
2
+ type AdvisorUsageTotals,
3
+ addAdvisorUsage,
4
+ emptyAdvisorUsageTotals,
5
+ formatAdvisorUsageStatus,
6
+ formatAdvisorUsageTotals,
7
+ } from "./usage.js";
8
+
1
9
  export type GateDecision = "proceed" | "revise" | "blocked";
2
10
  export type ConsultationTrigger = "manual" | "executor-requested";
3
11
  export type GateTrigger =
@@ -116,10 +124,12 @@ export class AdvisorSessionState {
116
124
  string,
117
125
  { advice: string; trigger: ConsultationTrigger }
118
126
  >();
127
+ readonly #pendingAdvice = new Set<string>();
119
128
  readonly #reportedAdvice = new Set<string>();
120
129
  #draftConsultations = 0;
121
130
  #outcomes = 0;
122
131
  #lastAdvice?: string;
132
+ #usage = emptyAdvisorUsageTotals();
123
133
 
124
134
  resetTask() {
125
135
  this.#previousSignature = undefined;
@@ -129,10 +139,12 @@ export class AdvisorSessionState {
129
139
  this.#loopInterventions = 0;
130
140
  this.#consumedCalls = 0;
131
141
  this.#issuedAdvice.clear();
142
+ this.#pendingAdvice.clear();
132
143
  this.#reportedAdvice.clear();
133
144
  this.#draftConsultations = 0;
134
145
  this.#outcomes = 0;
135
146
  this.#lastAdvice = undefined;
147
+ this.#usage = emptyAdvisorUsageTotals();
136
148
  }
137
149
 
138
150
  clearBlocked() {
@@ -182,8 +194,19 @@ export class AdvisorSessionState {
182
194
  return this.#consumedCalls;
183
195
  }
184
196
 
197
+ /** Returns a copy of cumulative direct Advisor usage for this session. */
198
+ get usageTotals(): AdvisorUsageTotals {
199
+ return { ...this.#usage };
200
+ }
201
+
202
+ /** Returns the footer-ready direct Advisor usage status for this session. */
203
+ usageStatus() {
204
+ return formatAdvisorUsageStatus(this.#usage);
205
+ }
206
+
185
207
  recordInvocation(record: AdvisorInvocationRecord) {
186
208
  this.#invocations.push(record);
209
+ addAdvisorUsage(this.#usage, record.usage);
187
210
  }
188
211
  issueAdvice(
189
212
  id: string,
@@ -216,16 +239,38 @@ export class AdvisorSessionState {
216
239
  return true;
217
240
  }
218
241
 
219
- claimAdvice(id: string) {
220
- if (this.#reportedAdvice.has(id)) {
242
+ reserveAdvice(id: string) {
243
+ if (this.#reportedAdvice.has(id) || this.#pendingAdvice.has(id)) {
221
244
  return;
222
245
  }
223
246
  const advice = this.#issuedAdvice.get(id);
224
247
  if (!advice) {
225
248
  return;
226
249
  }
250
+ this.#pendingAdvice.add(id);
251
+ return advice;
252
+ }
253
+
254
+ commitAdvice(id: string) {
255
+ if (!this.#pendingAdvice.delete(id)) {
256
+ return false;
257
+ }
227
258
  this.#reportedAdvice.add(id);
228
259
  this.#outcomes += 1;
260
+ return true;
261
+ }
262
+
263
+ releaseAdvice(id: string) {
264
+ this.#pendingAdvice.delete(id);
265
+ }
266
+
267
+ /** Compatibility helper for synchronous callers that can commit immediately. */
268
+ claimAdvice(id: string) {
269
+ const advice = this.reserveAdvice(id);
270
+ if (!advice) {
271
+ return;
272
+ }
273
+ this.commitAdvice(id);
229
274
  return advice;
230
275
  }
231
276
 
@@ -271,6 +316,7 @@ export class AdvisorSessionState {
271
316
  `Triggers: ${["manual", "executor-requested", "repeated-tool-call", "completion-review", "custom-rule"].filter((trigger) => countTrigger(trigger as AdvisorTrigger) > 0).join(", ") || "none"}`,
272
317
  `Models: ${models}`,
273
318
  `Budget: ${budget}`,
319
+ `Usage: ${formatAdvisorUsageTotals(this.#usage)}`,
274
320
  `Markdown advice: ${markdown.length} responses (${this.#draftConsultations} with drafts)`,
275
321
  `Outcome reports: ${this.#outcomes}`,
276
322
  `Gate decisions: ${decisions}`,