pi-advisor-flow 0.7.0 → 0.8.1-dev.1

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 +27 -18
  2. package/README.md +9 -8
  3. package/dist/index.js +1968 -1081
  4. package/extensions/index.ts +2 -1
  5. package/package.json +48 -35
  6. package/src/attachments.ts +11 -10
  7. package/src/child-session.ts +15 -0
  8. package/src/commands/activation-preparation.ts +1 -0
  9. package/src/commands/activation.ts +12 -3
  10. package/src/commands/lifecycle.ts +7 -1
  11. package/src/commands/manual-command.ts +7 -1
  12. package/src/commands/manual-consultation.ts +102 -92
  13. package/src/commands/manual-progress.ts +7 -5
  14. package/src/commands/model-commands.ts +1 -1
  15. package/src/commands/model-options.ts +14 -3
  16. package/src/commands/model-picker.ts +1 -0
  17. package/src/commands/registration.ts +1 -0
  18. package/src/commands/renderers.ts +67 -55
  19. package/src/commands/runtime.ts +16 -14
  20. package/src/commands/settings-commands.ts +10 -4
  21. package/src/commands/settings-persistence.ts +16 -2
  22. package/src/commands/types.ts +1 -0
  23. package/src/commands.ts +0 -1
  24. package/src/config/args.ts +1 -1
  25. package/src/config/defaults.ts +8 -1
  26. package/src/config/schema.ts +42 -17
  27. package/src/config/state.ts +12 -3
  28. package/src/config/storage.ts +34 -23
  29. package/src/config/types.ts +16 -3
  30. package/src/config/validation.ts +33 -21
  31. package/src/config.ts +3 -0
  32. package/src/content-utils.ts +24 -4
  33. package/src/conversation.ts +16 -15
  34. package/src/git.ts +10 -3
  35. package/src/herdr-block.ts +83 -0
  36. package/src/herdr-shared.ts +108 -0
  37. package/src/herdr.ts +30 -192
  38. package/src/jev/client.ts +75 -85
  39. package/src/jev/failure.ts +19 -0
  40. package/src/jev/key-store.ts +26 -11
  41. package/src/jev/ledger.ts +58 -64
  42. package/src/jev/questions.ts +26 -23
  43. package/src/jev/state.ts +15 -12
  44. package/src/jev/transport.ts +8 -3
  45. package/src/model-stream.ts +35 -37
  46. package/src/outcomes.ts +15 -11
  47. package/src/pi-settings.ts +4 -9
  48. package/src/preferences.ts +4 -2
  49. package/src/redaction.ts +10 -12
  50. package/src/scout-context.ts +59 -60
  51. package/src/scout-curation.ts +4 -13
  52. package/src/scout-groups.ts +178 -175
  53. package/src/scout-protocol.ts +74 -70
  54. package/src/scout-reconstruct.ts +1 -1
  55. package/src/scout.ts +128 -77
  56. package/src/session-state.ts +64 -49
  57. package/src/tool-result-cap.ts +18 -17
  58. package/src/tools/consult-context.ts +7 -15
  59. package/src/tools/consultation.ts +14 -4
  60. package/src/tools/gate-policy.ts +1 -0
  61. package/src/tools/gate-protocol.ts +36 -23
  62. package/src/tools/jev-filter.ts +43 -41
  63. package/src/tools/jev-turn-gate.ts +29 -9
  64. package/src/tools/loop-gate.ts +31 -20
  65. package/src/tools/model-access.ts +46 -0
  66. package/src/tools/prompts.ts +20 -25
  67. package/src/tools/register-ask-advisor.ts +36 -16
  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 +146 -128
  71. package/src/tools/registration.ts +12 -14
  72. package/src/tools/render-advisor-result.ts +35 -22
  73. package/src/tools/render-common.ts +16 -19
  74. package/src/tools/scout-status.ts +42 -27
  75. package/src/tools/types.ts +1 -0
  76. package/src/tools.ts +0 -1
  77. package/src/ui/jev-setup-submenu.ts +65 -59
  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 +5 -11
  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 +38 -1
  87. package/src/ui/settings-list-adapter.ts +15 -12
  88. package/src/ui/settings-mutations.ts +157 -70
  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 +14 -0
  92. package/src/ui.ts +0 -1
  93. package/src/usage.ts +26 -19
package/src/jev/ledger.ts CHANGED
@@ -71,6 +71,53 @@ const addJevUsage = (totals: AdvisorJevUsageTotals, usage: AdvisorJevUsage) => {
71
71
  totals.outputTokens += usage.outputTokens;
72
72
  };
73
73
 
74
+ const savingsLine = (costs: number[], skipped: number) => {
75
+ if (costs.length === 0) {
76
+ return "Estimated saving from skips: unavailable — no observed consultation cost this session";
77
+ }
78
+ const mean = costs.reduce((sum, cost) => sum + cost, 0) / costs.length;
79
+ return `Estimated saving from skips: ≤ $${(mean * skipped).toFixed(4)} — upper bound; assumes each skipped consultation would have cost this session's mean allowed-consultation cost ($${mean.toFixed(4)}), which the skipped calls would likely have undercut`;
80
+ };
81
+
82
+ const formatJevTokens = (usage: AdvisorJevUsageTotals) =>
83
+ `↑${formatTokenCount(usage.inputTokens + usage.outputTokens)}`;
84
+
85
+ const markdownCosts = (invocations: readonly JevInvocationView[]): number[] =>
86
+ invocations
87
+ .filter(
88
+ (item): item is JevInvocationView & { cost: number } =>
89
+ item.kind === "markdown" && typeof item.cost === "number"
90
+ )
91
+ .map((item) => item.cost);
92
+
93
+ const filterLine = (filter: AdvisorJevFilterLedger) => {
94
+ const head = `${filter.screened} screened (${filter.allowed} allowed, ${filter.skipped} skipped${filter.repeatSkipped > 0 ? ` [${filter.repeatSkipped} repeat]` : ""})`;
95
+ const parts = [head];
96
+ if (filter.overrides > 0) {
97
+ parts.push(
98
+ `${filter.overrides} override${filter.overrides === 1 ? "" : "s"}`
99
+ );
100
+ }
101
+ if (filter.failures > 0) {
102
+ parts.push(`${filter.failures} failure${filter.failures === 1 ? "" : "s"}`);
103
+ }
104
+ return `Jev filter: ${parts.join(", ")}`;
105
+ };
106
+
107
+ const gateLine = (
108
+ gate: AdvisorJevGateLedger,
109
+ invocations: readonly JevInvocationView[]
110
+ ) => {
111
+ const consultationCosts = invocations
112
+ .filter(
113
+ (item): item is JevInvocationView & { cost: number } =>
114
+ item.trigger === "turn-gate" && typeof item.cost === "number"
115
+ )
116
+ .map((item) => item.cost);
117
+ const gateSpend = consultationCosts.reduce((sum, cost) => sum + cost, 0);
118
+ return `Turn gate: ${gate.checks} check${gate.checks === 1 ? "" : "s"} (Jev ${formatJevTokens(gate.usage)} · $${gate.usage.cost.toFixed(4)}), ${gate.consultations} consultation${gate.consultations === 1 ? "" : "s"} ($${gateSpend.toFixed(4)})`;
119
+ };
120
+
74
121
  /** One session's Jev screening and turn-gate accounting plus summary lines. */
75
122
  export class AdvisorJevLedgerState {
76
123
  #ledger = freshJevLedger();
@@ -100,10 +147,11 @@ export class AdvisorJevLedgerState {
100
147
  if (repeat) {
101
148
  this.#ledger.filter.repeatSkipped += 1;
102
149
  }
103
- this.#lastSkip = {
104
- ...(normalizedQuestion ? { normalizedQuestion } : {}),
105
- turn,
106
- };
150
+ const skip: JevSkipRecord = { turn };
151
+ if (normalizedQuestion) {
152
+ skip.normalizedQuestion = normalizedQuestion;
153
+ }
154
+ this.#lastSkip = skip;
107
155
  }
108
156
 
109
157
  recordFilterOverride() {
@@ -151,26 +199,24 @@ export class AdvisorJevLedgerState {
151
199
  `${filter.overrides} override${filter.overrides === 1 ? "" : "s"}`
152
200
  );
153
201
  }
154
- lines.push(`Consultation dedup: ${parts.join(", ")}`);
155
202
  lines.push(
156
- this.#savingsLine(this.#markdownCosts(invocations), filter.skipped)
203
+ `Consultation dedup: ${parts.join(", ")}`,
204
+ savingsLine(markdownCosts(invocations), filter.skipped)
157
205
  );
158
206
  } else if (this.#filterActive()) {
159
- lines.push(this.#filterLine(filter));
207
+ lines.push(filterLine(filter));
160
208
  const jevTokens = usage.inputTokens + usage.outputTokens;
161
209
  if (jevTokens > 0) {
162
210
  lines.push(
163
- `Jev cost: ${this.#formatJevTokens(usage)} tokens · $${usage.cost.toFixed(4)} (input only; output free)`
211
+ `Jev cost: ${formatJevTokens(usage)} tokens · $${usage.cost.toFixed(4)} (input only; output free)`
164
212
  );
165
213
  }
166
214
  if (filter.skipped > 0) {
167
- lines.push(
168
- this.#savingsLine(this.#markdownCosts(invocations), filter.skipped)
169
- );
215
+ lines.push(savingsLine(markdownCosts(invocations), filter.skipped));
170
216
  }
171
217
  }
172
218
  if (gate.checks > 0 || gate.consultations > 0) {
173
- lines.push(this.#gateLine(gate, invocations));
219
+ lines.push(gateLine(gate, invocations));
174
220
  }
175
221
  return lines;
176
222
  }
@@ -179,56 +225,4 @@ export class AdvisorJevLedgerState {
179
225
  const { filter } = this.#ledger;
180
226
  return filter.screened > 0 || filter.overrides > 0 || filter.failures > 0;
181
227
  }
182
-
183
- #savingsLine(markdownCosts: number[], skipped: number) {
184
- if (markdownCosts.length === 0) {
185
- return "Estimated saving from skips: unavailable — no observed consultation cost this session";
186
- }
187
- const mean =
188
- markdownCosts.reduce((sum, cost) => sum + cost, 0) / markdownCosts.length;
189
- return `Estimated saving from skips: ≤ $${(mean * skipped).toFixed(4)} — upper bound; assumes each skipped consultation would have cost this session's mean allowed-consultation cost ($${mean.toFixed(4)}), which the skipped calls would likely have undercut`;
190
- }
191
-
192
- #gateLine(
193
- gate: AdvisorJevGateLedger,
194
- invocations: readonly JevInvocationView[]
195
- ) {
196
- const consultationCosts = invocations
197
- .filter(
198
- (item): item is JevInvocationView & { cost: number } =>
199
- item.trigger === "turn-gate" && typeof item.cost === "number"
200
- )
201
- .map((item) => item.cost);
202
- const gateSpend = consultationCosts.reduce((sum, cost) => sum + cost, 0);
203
- return `Turn gate: ${gate.checks} check${gate.checks === 1 ? "" : "s"} (Jev ${this.#formatJevTokens(gate.usage)} · $${gate.usage.cost.toFixed(4)}), ${gate.consultations} consultation${gate.consultations === 1 ? "" : "s"} ($${gateSpend.toFixed(4)})`;
204
- }
205
-
206
- #formatJevTokens(usage: AdvisorJevUsageTotals) {
207
- return `↑${formatTokenCount(usage.inputTokens + usage.outputTokens)}`;
208
- }
209
-
210
- #markdownCosts(invocations: readonly JevInvocationView[]): number[] {
211
- return invocations
212
- .filter(
213
- (item): item is JevInvocationView & { cost: number } =>
214
- item.kind === "markdown" && typeof item.cost === "number"
215
- )
216
- .map((item) => item.cost);
217
- }
218
-
219
- #filterLine(filter: AdvisorJevFilterLedger) {
220
- const head = `${filter.screened} screened (${filter.allowed} allowed, ${filter.skipped} skipped${filter.repeatSkipped > 0 ? ` [${filter.repeatSkipped} repeat]` : ""})`;
221
- const parts = [head];
222
- if (filter.overrides > 0) {
223
- parts.push(
224
- `${filter.overrides} override${filter.overrides === 1 ? "" : "s"}`
225
- );
226
- }
227
- if (filter.failures > 0) {
228
- parts.push(
229
- `${filter.failures} failure${filter.failures === 1 ? "" : "s"}`
230
- );
231
- }
232
- return `Jev filter: ${parts.join(", ")}`;
233
- }
234
228
  }
@@ -1,5 +1,8 @@
1
1
  import type { Questions } from "@typesafe-ai/sdk";
2
2
 
3
+ import { isNumber, isRecordOf } from "../content-utils.ts";
4
+ import type { JsonValue } from "../content-utils.ts";
5
+
3
6
  /** Exact rubric text for the stakes Score question; level 0 is the skip level. */
4
7
  export const STAKES_RUBRIC = [
5
8
  "Negligible: routine, low-risk, mechanical, or reversible; a wrong call costs little and is easy to undo.",
@@ -37,13 +40,10 @@ export interface ScreeningCriteria {
37
40
 
38
41
  export type ScreeningVerdict = { skip: false } | { skip: true };
39
42
 
40
- const NUMERIC_KEY_PATTERN = /^\d+$/;
41
-
42
- const isRecord = (value: unknown): value is Record<string, unknown> =>
43
- Boolean(value) && typeof value === "object" && !Array.isArray(value);
43
+ const NUMERIC_KEY_PATTERN = /^\d+$/u;
44
44
 
45
- const finiteNumber = (value: unknown): number | undefined =>
46
- typeof value === "number" && Number.isFinite(value) ? value : undefined;
45
+ const finiteNumber = (value: JsonValue | undefined): number | undefined =>
46
+ isNumber(value) && Number.isFinite(value) ? value : undefined;
47
47
 
48
48
  /**
49
49
  * Probability mass on the lowest stakes level, resolved through the answer's
@@ -51,16 +51,16 @@ const finiteNumber = (value: unknown): number | undefined =>
51
51
  * smallest numeric index key only when no legend resolves. Shape-independent:
52
52
  * never hardcodes a level key and never uses `ceil(score)`.
53
53
  */
54
- export const lowestStakesProbability = (
55
- answer: unknown
54
+ export const lowestStakesProbability = <Answer>(
55
+ answer: Answer
56
56
  ): number | undefined => {
57
- if (!isRecord(answer)) {
57
+ if (!isRecordOf(answer)) {
58
58
  return undefined;
59
59
  }
60
- const probabilities = isRecord(answer.probabilities)
60
+ const probabilities = isRecordOf(answer.probabilities)
61
61
  ? answer.probabilities
62
62
  : {};
63
- const legend = isRecord(answer.legend) ? answer.legend : undefined;
63
+ const legend = isRecordOf(answer.legend) ? answer.legend : undefined;
64
64
  if (legend) {
65
65
  const exact = Object.keys(legend).find(
66
66
  (key) => legend[key] === STAKES_RUBRIC[0]
@@ -75,23 +75,26 @@ export const lowestStakesProbability = (
75
75
  if (numericKeys.length === 0) {
76
76
  return undefined;
77
77
  }
78
- const lowest = numericKeys.reduce((left, right) =>
79
- Number(left) <= Number(right) ? left : right
80
- );
78
+ let [lowest] = numericKeys;
79
+ for (const key of numericKeys) {
80
+ if (Number(key) < Number(lowest)) {
81
+ lowest = key;
82
+ }
83
+ }
81
84
  return finiteNumber(probabilities[lowest]);
82
85
  };
83
86
 
84
- const selfAnswerableNoul = (answer: unknown): number | undefined =>
85
- isRecord(answer) ? finiteNumber(answer.noul) : undefined;
87
+ const selfAnswerableNoul = <Answer>(answer: Answer): number | undefined =>
88
+ isRecordOf(answer) ? finiteNumber(answer.noul) : undefined;
86
89
 
87
90
  /** Pure composition: skip requires the hard conjunction of confident
88
91
  * negligible stakes AND confident self-answerability. Any missing, NaN, or
89
92
  * malformed input allows. Never a weighted sum. */
90
- export const composeScreeningVerdict = (
91
- answers: unknown,
93
+ export const composeScreeningVerdict = <Answers>(
94
+ answers: Answers,
92
95
  { noulMargin, skipConfidence }: ScreeningCriteria
93
96
  ): ScreeningVerdict => {
94
- if (!isRecord(answers)) {
97
+ if (!isRecordOf(answers)) {
95
98
  return { skip: false };
96
99
  }
97
100
  const negligibleMass = lowestStakesProbability(answers.stakes);
@@ -106,14 +109,14 @@ export const composeScreeningVerdict = (
106
109
  };
107
110
 
108
111
  /** Confident-true only; any uncertainty means no invocation. */
109
- export const composeTurnGateVerdict = (
110
- answers: unknown,
112
+ export const composeTurnGateVerdict = <Answers>(
113
+ answers: Answers,
111
114
  threshold: number
112
115
  ): boolean => {
113
- if (!isRecord(answers)) {
116
+ if (!isRecordOf(answers)) {
114
117
  return false;
115
118
  }
116
119
  const answer = answers.should_consult;
117
- const noul = isRecord(answer) ? finiteNumber(answer.noul) : undefined;
120
+ const noul = isRecordOf(answer) ? finiteNumber(answer.noul) : undefined;
118
121
  return noul !== undefined && noul >= threshold;
119
122
  };
package/src/jev/state.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { JsonValue } from "@typesafe-ai/sdk";
3
+
3
4
  import {
4
5
  advisorJevDigestMaxCharsRef,
5
6
  advisorRedactSecretsRef,
@@ -23,25 +24,27 @@ export type JevState = Record<string, JsonValue>;
23
24
  export const buildJevState = (
24
25
  ctx: ExtensionContext,
25
26
  input: JevStateInput = {}
26
- ): JevState => {
27
- const state: JevState = { role: "executor" };
27
+ ) => {
28
+ const fields = new Map<string, string>([["role", "executor"]]);
28
29
  if (input.question) {
29
- state.executor_question = redactAndCapText(
30
- input.question,
31
- JEV_TEXT_CAP_BYTES,
32
- advisorRedactSecretsRef
30
+ fields.set(
31
+ "executor_question",
32
+ redactAndCapText(
33
+ input.question,
34
+ JEV_TEXT_CAP_BYTES,
35
+ advisorRedactSecretsRef
36
+ )
33
37
  );
34
38
  }
35
39
  if (input.draft) {
36
- state.executor_draft = redactAndCapText(
37
- input.draft,
38
- JEV_TEXT_CAP_BYTES,
39
- advisorRedactSecretsRef
40
+ fields.set(
41
+ "executor_draft",
42
+ redactAndCapText(input.draft, JEV_TEXT_CAP_BYTES, advisorRedactSecretsRef)
40
43
  );
41
44
  }
42
45
  const digest = recentConversation(ctx, advisorJevDigestMaxCharsRef);
43
46
  if (digest) {
44
- state.recent_conversation = digest;
47
+ fields.set("recent_conversation", digest);
45
48
  }
46
- return state;
49
+ return Object.fromEntries(fields);
47
50
  };
@@ -1,6 +1,8 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
2
3
  import { advisorJevTransportRef } from "../config/state.ts";
3
- import { type JevKeySource, resolveTypeSafeKey } from "./key-store.ts";
4
+ import { resolveTypeSafeKey } from "./key-store.ts";
5
+ import type { JevKeySource } from "./key-store.ts";
4
6
 
5
7
  export type JevTransportKind = "typesafe" | "openrouter";
6
8
 
@@ -41,11 +43,14 @@ export const resolveJevTransport = async (
41
43
  const resolveTypesafe = deps.resolveTypesafe ?? resolveTypeSafeKey;
42
44
  const resolution = await resolveTypesafe();
43
45
  if (resolution.key) {
44
- return {
46
+ const credentials: JevCredentials = {
45
47
  apiKey: resolution.key,
46
- ...(resolution.source ? { source: resolution.source } : {}),
47
48
  transport: "typesafe",
48
49
  };
50
+ if (resolution.source) {
51
+ credentials.source = resolution.source;
52
+ }
53
+ return credentials;
49
54
  }
50
55
  }
51
56
  if (preference === "typesafe") {
@@ -1,11 +1,12 @@
1
- import {
2
- type Api,
3
- type AssistantMessage,
4
- type Message,
5
- type Model,
6
- stream,
1
+ import { stream } from "@earendil-works/pi-ai/compat";
2
+ import type {
3
+ Api,
4
+ AssistantMessage,
5
+ Message,
6
+ Model,
7
7
  } from "@earendil-works/pi-ai/compat";
8
8
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
9
+
9
10
  import { splitRef } from "./config/state.ts";
10
11
 
11
12
  export interface ResolvedConfiguredModel {
@@ -25,13 +26,14 @@ export const resolveConfiguredModel = async (
25
26
  throw new Error(`${label} model not configured`);
26
27
  }
27
28
  const [provider, modelId] = splitRef(ref);
28
- const model = ctx.modelRegistry.find(provider, modelId);
29
+ const lookup: [string, string] = [provider, modelId];
30
+ const model = ctx.modelRegistry.find(...lookup);
29
31
  if (!model) {
30
32
  throw new Error(`${label} model not found: ${ref}`);
31
33
  }
32
34
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
33
35
  if (!auth.ok) {
34
- throw new Error((auth as { error: string }).error);
36
+ throw new Error(auth.error);
35
37
  }
36
38
  if (!auth.apiKey) {
37
39
  throw new Error(`No API key for ${ref}`);
@@ -61,6 +63,12 @@ export interface CollectedTextStream {
61
63
 
62
64
  export const ADVISOR_STREAM_UPDATE_INTERVAL_MS = 90;
63
65
 
66
+ const defaultScheduler: CoalescedUpdateScheduler = {
67
+ clearTimeout,
68
+ now: Date.now,
69
+ setTimeout: (callback, delay) => setTimeout(callback, delay),
70
+ };
71
+
64
72
  interface CoalescedUpdateResult {
65
73
  error?: unknown;
66
74
  failed: boolean;
@@ -85,19 +93,14 @@ export interface CoalescedUpdateScheduler {
85
93
  export const createCoalescedUpdate = <T>(
86
94
  publish: (value: T) => void,
87
95
  intervalMs = ADVISOR_STREAM_UPDATE_INTERVAL_MS,
88
- scheduler: CoalescedUpdateScheduler = {
89
- clearTimeout,
90
- now: Date.now,
91
- setTimeout: (callback, delay) => setTimeout(callback, delay),
92
- }
96
+ scheduler: CoalescedUpdateScheduler | undefined = defaultScheduler
93
97
  ): CoalescedUpdate<T> => {
94
98
  if (!Number.isFinite(intervalMs) || intervalMs <= 0) {
95
99
  throw new Error("Coalesced update interval must be positive and finite.");
96
100
  }
97
101
 
98
102
  let closed = false;
99
- let hasPending = false;
100
- let pending: T | undefined;
103
+ let pending: { value: T } | undefined;
101
104
  let lastPublishedAt: number | undefined;
102
105
  let timer: ReturnType<typeof setTimeout> | undefined;
103
106
  let publishError: unknown;
@@ -112,12 +115,11 @@ export const createCoalescedUpdate = <T>(
112
115
 
113
116
  const publishPending = () => {
114
117
  timer = undefined;
115
- if (!hasPending) {
118
+ if (!pending) {
116
119
  return;
117
120
  }
118
- const value = pending as T;
121
+ const { value } = pending;
119
122
  pending = undefined;
120
- hasPending = false;
121
123
  lastPublishedAt = scheduler.now();
122
124
  try {
123
125
  publish(value);
@@ -150,7 +152,6 @@ export const createCoalescedUpdate = <T>(
150
152
  closed = true;
151
153
  clearTimer();
152
154
  pending = undefined;
153
- hasPending = false;
154
155
  },
155
156
  flush: () => {
156
157
  if (!closed) {
@@ -167,8 +168,7 @@ export const createCoalescedUpdate = <T>(
167
168
  if (publishFailed) {
168
169
  throw publishError;
169
170
  }
170
- pending = value;
171
- hasPending = true;
171
+ pending = { value };
172
172
  if (timer === undefined) {
173
173
  schedule();
174
174
  }
@@ -186,22 +186,22 @@ export const collectTextStream = async (
186
186
  ): Promise<CollectedTextStream> => {
187
187
  let thinking = "";
188
188
  let text = "";
189
+ const streamOptions: Parameters<typeof streamModel>[2] = {
190
+ apiKey: resolved.apiKey,
191
+ env: resolved.env,
192
+ headers: resolved.headers,
193
+ // SAFETY: stream() models the provider-facing reasoning field as never; options.reasoning is the Pi-facing effort string.
194
+ reasoning: options.reasoning as never,
195
+ signal: options.signal,
196
+ };
197
+ if (options.reasoning !== undefined) {
198
+ // SAFETY: reasoningEffort takes the same effort string; kept absent when reasoning is unset.
199
+ streamOptions.reasoningEffort = options.reasoning as never;
200
+ }
189
201
  const eventStream = streamModel(
190
202
  resolved.model,
191
203
  { messages: options.messages, systemPrompt: options.systemPrompt },
192
- {
193
- apiKey: resolved.apiKey,
194
- env: resolved.env,
195
- headers: resolved.headers,
196
- // `stream()` uses the provider-facing name while the extension's public
197
- // option keeps the Pi-facing `reasoning` name. Preserve both so the
198
- // configured effort reaches providers that serialize reasoning_effort.
199
- reasoning: options.reasoning as never,
200
- ...(options.reasoning === undefined
201
- ? {}
202
- : { reasoningEffort: options.reasoning as never }),
203
- signal: options.signal,
204
- }
204
+ streamOptions
205
205
  );
206
206
 
207
207
  for await (const event of eventStream) {
@@ -242,8 +242,6 @@ export const collectTextStream = async (
242
242
  return {
243
243
  text: finalText,
244
244
  thinking,
245
- usage: (
246
- lastAssistant as (AssistantMessage & { usage?: unknown }) | undefined
247
- )?.usage,
245
+ usage: lastAssistant?.usage,
248
246
  };
249
247
  };
package/src/outcomes.ts CHANGED
@@ -11,6 +11,8 @@ import {
11
11
  writeFile,
12
12
  } from "node:fs/promises";
13
13
  import { join } from "node:path";
14
+ import { setTimeout as sleep } from "node:timers/promises";
15
+
14
16
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
15
17
 
16
18
  export type OutcomeAdoption = "followed" | "not-followed" | "unknown";
@@ -44,12 +46,15 @@ const statePath = () => join(getAgentDir(), "advisor-outcomes-salt");
44
46
  export const outcomeLogPath = () =>
45
47
  join(getAgentDir(), "advisor-outcomes.jsonl");
46
48
 
49
+ const isErrnoException = (error: unknown): error is NodeJS.ErrnoException =>
50
+ error instanceof Error && "code" in error;
51
+
47
52
  const salt = async () => {
48
53
  const path = statePath();
49
54
  await mkdir(getAgentDir(), { mode: 0o700, recursive: true });
50
55
  for (let attempt = 0; attempt < 20; attempt += 1) {
51
56
  try {
52
- // biome-ignore lint/performance/noAwaitInLoops: contenders retry sequentially until one salt is atomically published.
57
+ // Contenders retry sequentially until one salt is atomically published.
53
58
  const existing = await readFile(path);
54
59
  if (existing.length === 32) {
55
60
  return existing;
@@ -57,7 +62,7 @@ const salt = async () => {
57
62
  // Recover a salt file left incomplete by an interrupted older writer.
58
63
  await unlink(path);
59
64
  } catch (error) {
60
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
65
+ if (!isErrnoException(error) || error.code !== "ENOENT") {
61
66
  throw error;
62
67
  }
63
68
  }
@@ -68,7 +73,7 @@ const salt = async () => {
68
73
  await link(temporary, path);
69
74
  return value;
70
75
  } catch (error) {
71
- if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
76
+ if (!isErrnoException(error) || error.code !== "EEXIST") {
72
77
  throw error;
73
78
  }
74
79
  } finally {
@@ -86,7 +91,7 @@ const withOutcomeLock = async <T>(run: () => Promise<T>): Promise<T> => {
86
91
  const lockPath = `${outcomeLogPath()}.lock`;
87
92
  for (let attempt = 0; attempt < 200; attempt += 1) {
88
93
  try {
89
- // biome-ignore lint/performance/noAwaitInLoops: lock acquisition must retry sequentially across processes.
94
+ // Lock acquisition must retry sequentially across processes.
90
95
  const lock = await open(lockPath, "wx", 0o600);
91
96
  const identity = await lock.stat();
92
97
  try {
@@ -99,7 +104,7 @@ const withOutcomeLock = async <T>(run: () => Promise<T>): Promise<T> => {
99
104
  }
100
105
  }
101
106
  } catch (error) {
102
- if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
107
+ if (!isErrnoException(error) || error.code !== "EEXIST") {
103
108
  throw error;
104
109
  }
105
110
  const observed = await stat(lockPath).catch(() => undefined);
@@ -110,7 +115,7 @@ const withOutcomeLock = async <T>(run: () => Promise<T>): Promise<T> => {
110
115
  }
111
116
  continue;
112
117
  }
113
- await new Promise((resolve) => setTimeout(resolve, 5));
118
+ await sleep(5);
114
119
  }
115
120
  }
116
121
  throw new Error("Timed out waiting to append an Advisor outcome.");
@@ -145,11 +150,10 @@ export const appendOutcome = async (
145
150
  }
146
151
  throw error;
147
152
  });
148
- if (currentBytes + Buffer.byteLength(line) > MAX_LOG_BYTES) {
149
- await writeFile(path, line, { encoding: "utf8", mode: 0o600 });
150
- } else {
151
- await appendFile(path, line, { encoding: "utf8", mode: 0o600 });
152
- }
153
+ const overflow = currentBytes + Buffer.byteLength(line) > MAX_LOG_BYTES;
154
+ await (overflow
155
+ ? writeFile(path, line, { encoding: "utf-8", mode: 0o600 })
156
+ : appendFile(path, line, { encoding: "utf-8", mode: 0o600 }));
153
157
  await chmod(path, 0o600);
154
158
  return next;
155
159
  });
@@ -1,10 +1,9 @@
1
1
  import { existsSync, readFileSync, statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+
3
4
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
5
 
5
- interface PiGlobalSettings {
6
- hideThinkingBlock?: boolean;
7
- }
6
+ import { isRecord } from "./content-utils.ts";
8
7
 
9
8
  interface CachedSetting {
10
9
  hidden: boolean;
@@ -27,12 +26,8 @@ const settingsIdentity = (path: string): string => {
27
26
 
28
27
  const readHideThinking = (path: string): boolean => {
29
28
  try {
30
- const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
31
- return (
32
- typeof parsed === "object" &&
33
- parsed !== null &&
34
- (parsed as PiGlobalSettings).hideThinkingBlock === true
35
- );
29
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf-8"));
30
+ return isRecord(parsed) && parsed.hideThinkingBlock === true;
36
31
  } catch {
37
32
  return false;
38
33
  }
@@ -1,6 +1,8 @@
1
1
  import { lstat, open, realpath } from "node:fs/promises";
2
2
  import { join, relative } from "node:path";
3
+
3
4
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+
4
6
  import { redactAndCapText } from "./redaction.ts";
5
7
 
6
8
  const PREFERENCES_MAX_BYTES = 8 * 1024;
@@ -40,9 +42,9 @@ export const readProjectPreferences = async (
40
42
  try {
41
43
  const buffer = Buffer.alloc(maxBytes + 1);
42
44
  const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
43
- const source = buffer.subarray(0, bytesRead).toString("utf8");
45
+ const source = buffer.subarray(0, bytesRead).toString("utf-8");
44
46
  const capped = redactAndCapText(source, maxBytes, redact);
45
- return { bytes: Buffer.byteLength(capped, "utf8"), text: capped };
47
+ return { bytes: Buffer.byteLength(capped, "utf-8"), text: capped };
46
48
  } finally {
47
49
  await file.close();
48
50
  }
package/src/redaction.ts CHANGED
@@ -1,15 +1,15 @@
1
- import { capUtf8Bytes } from "./content-utils.ts";
1
+ import { capUtf8Bytes, isString } from "./content-utils.ts";
2
2
 
3
3
  const REDACTION_MARKER = "[REDACTED SECRET]";
4
- const PEM_BEGIN_PATTERN = /-----BEGIN(?: [A-Z0-9]+)? PRIVATE KEY-----/gi;
5
- const PEM_END_PATTERN = /-----END(?: [A-Z0-9]+)? PRIVATE KEY-----/i;
4
+ const PEM_BEGIN_PATTERN = /-----BEGIN(?: [A-Z0-9]+)? PRIVATE KEY-----/giu;
5
+ const PEM_END_PATTERN = /-----END(?: [A-Z0-9]+)? PRIVATE KEY-----/iu;
6
6
  const SECRET_PATTERNS = [
7
- /-----BEGIN(?: [A-Z0-9]+)? PRIVATE KEY-----[\s\S]*?-----END(?: [A-Z0-9]+)? PRIVATE KEY-----/gi,
8
- /\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/gi,
9
- /\b(?:api[_-]?key|token|secret|password|passwd)\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s"'&,;)}\]]+)/gi,
10
- /([a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+:[^\s/@]+@/gi,
11
- /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g,
12
- /\b(?:aws_secret_access_key|aws_session_token)\s*[:=]\s*[^\s"'&,;)}\]]+/gi,
7
+ /-----BEGIN(?: [A-Z0-9]+)? PRIVATE KEY-----[\s\S]*?-----END(?: [A-Z0-9]+)? PRIVATE KEY-----/giu,
8
+ /\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/giu,
9
+ /\b(?:api[_-]?key|token|secret|password|passwd)\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s"'&,;)}\]]+)/giu,
10
+ /(?<scheme>[a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+:[^\s/@]+@/giu,
11
+ /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/gu,
12
+ /\b(?:aws_secret_access_key|aws_session_token)\s*[:=]\s*[^\s"'&,;)}\]]+/giu,
13
13
  ] as const;
14
14
 
15
15
  const redactUnterminatedPem = (value: string): string => {
@@ -31,9 +31,7 @@ export const redactSecrets = (value: string): string => {
31
31
  let redacted = redactUnterminatedPem(value);
32
32
  for (const pattern of SECRET_PATTERNS) {
33
33
  redacted = redacted.replace(pattern, (_match, scheme) =>
34
- typeof scheme === "string"
35
- ? `${scheme}${REDACTION_MARKER}@`
36
- : REDACTION_MARKER
34
+ isString(scheme) ? `${scheme}${REDACTION_MARKER}@` : REDACTION_MARKER
37
35
  );
38
36
  }
39
37
  return redacted;