pi-advisor-flow 0.6.0 → 0.8.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 (93) hide show
  1. package/CHANGELOG.md +34 -17
  2. package/README.md +10 -8
  3. package/dist/index.js +2852 -868
  4. package/extensions/index.ts +2 -1
  5. package/package.json +55 -40
  6. package/src/attachments.ts +11 -10
  7. package/src/commands/activation-preparation.ts +1 -0
  8. package/src/commands/activation.ts +2 -0
  9. package/src/commands/lifecycle.ts +1 -0
  10. package/src/commands/manual-command.ts +7 -1
  11. package/src/commands/manual-consultation.ts +103 -79
  12. package/src/commands/manual-progress.ts +7 -5
  13. package/src/commands/model-commands.ts +1 -1
  14. package/src/commands/model-options.ts +14 -3
  15. package/src/commands/model-picker.ts +1 -0
  16. package/src/commands/registration.ts +1 -0
  17. package/src/commands/renderers.ts +67 -55
  18. package/src/commands/runtime.ts +16 -14
  19. package/src/commands/settings-commands.ts +10 -4
  20. package/src/commands/settings-persistence.ts +64 -2
  21. package/src/commands/types.ts +2 -0
  22. package/src/commands.ts +0 -1
  23. package/src/config/args.ts +1 -1
  24. package/src/config/defaults.ts +83 -1
  25. package/src/config/schema.ts +152 -15
  26. package/src/config/state.ts +80 -2
  27. package/src/config/storage.ts +38 -22
  28. package/src/config/types.ts +45 -3
  29. package/src/config/validation.ts +33 -21
  30. package/src/config.ts +3 -0
  31. package/src/content-utils.ts +24 -4
  32. package/src/conversation.ts +16 -15
  33. package/src/git.ts +10 -3
  34. package/src/herdr-block.ts +83 -0
  35. package/src/herdr-shared.ts +108 -0
  36. package/src/herdr.ts +30 -192
  37. package/src/jev/client.ts +338 -0
  38. package/src/jev/failure.ts +19 -0
  39. package/src/jev/key-store.ts +267 -0
  40. package/src/jev/ledger.ts +228 -0
  41. package/src/jev/questions.ts +122 -0
  42. package/src/jev/state.ts +50 -0
  43. package/src/jev/transport.ts +63 -0
  44. package/src/model-stream.ts +35 -37
  45. package/src/outcomes.ts +20 -12
  46. package/src/pi-settings.ts +4 -9
  47. package/src/preferences.ts +4 -2
  48. package/src/redaction.ts +10 -12
  49. package/src/scout-context.ts +59 -60
  50. package/src/scout-curation.ts +4 -13
  51. package/src/scout-groups.ts +178 -175
  52. package/src/scout-protocol.ts +74 -70
  53. package/src/scout-reconstruct.ts +1 -1
  54. package/src/scout.ts +115 -73
  55. package/src/session-state.ts +148 -39
  56. package/src/tool-result-cap.ts +18 -17
  57. package/src/tools/consult-context.ts +7 -15
  58. package/src/tools/consultation.ts +14 -4
  59. package/src/tools/gate-policy.ts +1 -0
  60. package/src/tools/gate-protocol.ts +36 -23
  61. package/src/tools/jev-filter.ts +204 -0
  62. package/src/tools/jev-turn-gate.ts +203 -0
  63. package/src/tools/loop-gate.ts +32 -20
  64. package/src/tools/model-access.ts +46 -0
  65. package/src/tools/outage-notifier.ts +30 -0
  66. package/src/tools/prompts.ts +20 -25
  67. package/src/tools/register-ask-advisor.ts +73 -17
  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 +149 -73
  71. package/src/tools/registration.ts +15 -0
  72. package/src/tools/render-advisor-result.ts +77 -35
  73. package/src/tools/render-common.ts +16 -19
  74. package/src/tools/scout-status.ts +42 -27
  75. package/src/tools/types.ts +11 -0
  76. package/src/tools.ts +0 -1
  77. package/src/ui/jev-setup-submenu.ts +350 -0
  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 +62 -0
  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 +200 -2
  87. package/src/ui/settings-list-adapter.ts +15 -12
  88. package/src/ui/settings-mutations.ts +169 -47
  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 +25 -0
  92. package/src/ui.ts +0 -1
  93. package/src/usage.ts +29 -20
@@ -0,0 +1,267 @@
1
+ import {
2
+ chmodSync,
3
+ existsSync,
4
+ readFileSync,
5
+ rmSync,
6
+ writeFileSync,
7
+ } from "node:fs";
8
+ import { join } from "node:path";
9
+
10
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
11
+
12
+ import { readExistingConfig, resetConfigCache } from "../config/storage.ts";
13
+ import { isString } from "../content-utils.ts";
14
+ import type { RecordValue } from "../content-utils.ts";
15
+ import { redactSecrets } from "../redaction.ts";
16
+
17
+ export type JevKeySource = "bun-secrets" | "env" | "file" | "advisor-json";
18
+
19
+ export interface JevKeyResolution {
20
+ key?: string;
21
+ /** Present whenever a key resolved. */
22
+ source?: JevKeySource;
23
+ }
24
+
25
+ export interface JevKeyStoreResult {
26
+ message: string;
27
+ ok: boolean;
28
+ }
29
+
30
+ export interface JevSecretEntry {
31
+ name: string;
32
+ service: string;
33
+ }
34
+
35
+ export interface JevSecretsLike {
36
+ delete: (options: JevSecretEntry) => Promise<boolean | undefined>;
37
+ get: (options: JevSecretEntry) => Promise<string | null | undefined>;
38
+ set: (options: JevSecretEntry & { value: string }) => Promise<void>;
39
+ }
40
+
41
+ export interface JevKeyStoreDeps {
42
+ /** Deletes the extension-managed key file; injectable for tests. */
43
+ deleteFileStore?: () => void;
44
+ env?: NodeJS.ProcessEnv;
45
+ readAdvisorJson?: () => RecordValue;
46
+ /** Reads the extension-managed 0600 key file; injectable for tests. */
47
+ readFileStore?: () => string | undefined;
48
+ /** Inject `null` to simulate a runtime without a secret store. */
49
+ secrets?: JevSecretsLike | null;
50
+ writeFileStore?: (key: string) => void;
51
+ }
52
+
53
+ export const TYPESAFE_KEY_ENV_VAR = "TYPESAFE_API_KEY";
54
+ export const TYPESAFE_KEY_SERVICE = "pi-advisor";
55
+ export const TYPESAFE_KEY_NAME = "typesafe-api-key";
56
+ export const TYPESAFE_KEY_CONFIG_FIELD = "typesafe_api_key";
57
+
58
+ const KEY_FILE_MODE = 0o600;
59
+
60
+ const keyFilePath = () => join(getAgentDir(), "typesafe_api_key");
61
+
62
+ interface BunGlobal {
63
+ Bun?: { secrets?: JevSecretsLike };
64
+ }
65
+
66
+ const runtimeSecrets = (): JevSecretsLike | undefined => {
67
+ // SAFETY: only Bun runtimes expose Bun.secrets on globalThis; others leave it undefined.
68
+ const bun = globalThis as BunGlobal;
69
+ return bun.Bun?.secrets;
70
+ };
71
+
72
+ /** Whether the current runtime offers a Bun.secrets store. */
73
+ export const hasRuntimeSecretStore = () => runtimeSecrets() !== undefined;
74
+
75
+ const normalizeKey = (value: string | null | undefined): string | undefined =>
76
+ value?.trim() || undefined;
77
+
78
+ const readAdvisorJsonConfig = (): RecordValue =>
79
+ readExistingConfig(join(getAgentDir(), "advisor.json"));
80
+
81
+ const defaultReadFileStore = (): string | undefined => {
82
+ try {
83
+ return normalizeKey(readFileSync(keyFilePath(), "utf-8"));
84
+ } catch {
85
+ return undefined;
86
+ }
87
+ };
88
+
89
+ const defaultWriteFileStore = (key: string) => {
90
+ const path = keyFilePath();
91
+ writeFileSync(path, `${key}\n`, { mode: KEY_FILE_MODE });
92
+ // writeFileSync's mode only applies at creation; re-assert it on every write.
93
+ chmodSync(path, KEY_FILE_MODE);
94
+ };
95
+
96
+ const defaultDeleteFileStore = () => {
97
+ rmSync(keyFilePath(), { force: true });
98
+ };
99
+
100
+ const messageOf = <E>(error: E) =>
101
+ redactSecrets(error instanceof Error ? error.message : String(error));
102
+
103
+ /** Resolves the TypeSafe API key: Bun.secrets → env var → the extension's
104
+ * 0600 key file → a hand-placed advisor.json string (read-only). Never
105
+ * throws; no key means undefined. */
106
+ export const resolveTypeSafeKey = async (
107
+ deps: JevKeyStoreDeps = {}
108
+ ): Promise<JevKeyResolution> => {
109
+ const secrets = deps.secrets === undefined ? runtimeSecrets() : deps.secrets;
110
+ if (secrets) {
111
+ try {
112
+ const stored = normalizeKey(
113
+ await secrets.get({
114
+ name: TYPESAFE_KEY_NAME,
115
+ service: TYPESAFE_KEY_SERVICE,
116
+ })
117
+ );
118
+ if (stored) {
119
+ return { key: stored, source: "bun-secrets" };
120
+ }
121
+ } catch {
122
+ // An unavailable secret store falls through to the next source.
123
+ }
124
+ }
125
+ const env = deps.env ?? process.env;
126
+ const fromEnv = normalizeKey(env[TYPESAFE_KEY_ENV_VAR]);
127
+ if (fromEnv) {
128
+ return { key: fromEnv, source: "env" };
129
+ }
130
+ const readFileStore = deps.readFileStore ?? defaultReadFileStore;
131
+ const fromFile = normalizeKey(readFileStore());
132
+ if (fromFile) {
133
+ return { key: fromFile, source: "file" };
134
+ }
135
+ const config = (deps.readAdvisorJson ?? readAdvisorJsonConfig)();
136
+ const staged = config[TYPESAFE_KEY_CONFIG_FIELD];
137
+ if (isString(staged)) {
138
+ const fromConfig = normalizeKey(staged);
139
+ if (fromConfig) {
140
+ return { key: fromConfig, source: "advisor-json" };
141
+ }
142
+ }
143
+ return {};
144
+ };
145
+
146
+ /** Stores the key securely: Bun.secrets when the runtime provides it,
147
+ * otherwise a dedicated 0600-mode file in the Pi agent directory. The key is
148
+ * never written to advisor.json. */
149
+ export const writeKeyTypeSafeKey = async (
150
+ key: string,
151
+ deps: JevKeyStoreDeps = {}
152
+ ): Promise<JevKeyStoreResult> => {
153
+ const normalized = normalizeKey(key);
154
+ if (!normalized) {
155
+ return { message: "The key is empty.", ok: false };
156
+ }
157
+ const secrets = deps.secrets === undefined ? runtimeSecrets() : deps.secrets;
158
+ if (secrets) {
159
+ try {
160
+ await secrets.set({
161
+ name: TYPESAFE_KEY_NAME,
162
+ service: TYPESAFE_KEY_SERVICE,
163
+ value: normalized,
164
+ });
165
+ return { message: "Key stored in Bun.secrets.", ok: true };
166
+ } catch (error) {
167
+ return {
168
+ message: `Storing the key in Bun.secrets failed: ${messageOf(error)}. Alternatively set the ${TYPESAFE_KEY_ENV_VAR} environment variable in your shell profile.`,
169
+ ok: false,
170
+ };
171
+ }
172
+ }
173
+ try {
174
+ (deps.writeFileStore ?? defaultWriteFileStore)(normalized);
175
+ return {
176
+ message: "Key stored in ~/.pi/agent/typesafe_api_key (mode 0600).",
177
+ ok: true,
178
+ };
179
+ } catch (error) {
180
+ return {
181
+ message: `Storing the key failed: ${messageOf(error)}. Alternatively set the ${TYPESAFE_KEY_ENV_VAR} environment variable in your shell profile.`,
182
+ ok: false,
183
+ };
184
+ }
185
+ };
186
+
187
+ /** Removes every stored key (Bun.secrets entry and the 0600 file); never
188
+ * touches env vars or the hand-placed advisor.json entry. */
189
+ export const clearKeyTypeSafeKey = async (
190
+ deps: JevKeyStoreDeps = {}
191
+ ): Promise<JevKeyStoreResult> => {
192
+ let clearedSomething = false;
193
+ let firstError: string | undefined;
194
+ const secrets = deps.secrets === undefined ? runtimeSecrets() : deps.secrets;
195
+ if (secrets) {
196
+ try {
197
+ await secrets.delete({
198
+ name: TYPESAFE_KEY_NAME,
199
+ service: TYPESAFE_KEY_SERVICE,
200
+ });
201
+ clearedSomething = true;
202
+ } catch (error) {
203
+ firstError = messageOf(error);
204
+ }
205
+ }
206
+ try {
207
+ if (deps.deleteFileStore) {
208
+ deps.deleteFileStore();
209
+ } else if (existsSync(keyFilePath())) {
210
+ defaultDeleteFileStore();
211
+ }
212
+ clearedSomething = true;
213
+ } catch (error) {
214
+ firstError ??= messageOf(error);
215
+ }
216
+ if (firstError) {
217
+ return {
218
+ message: `Clearing the stored key failed: ${firstError}.`,
219
+ ok: false,
220
+ };
221
+ }
222
+ return {
223
+ message: `Stored key cleared.${clearedSomething ? "" : ` Nothing was stored; unset ${TYPESAFE_KEY_ENV_VAR} and remove ${TYPESAFE_KEY_CONFIG_FIELD} from advisor.json yourself if you use them.`}`,
224
+ ok: true,
225
+ };
226
+ };
227
+
228
+ /** Removes a hand-placed advisor.json key after a successful store migration.
229
+ * Unknown keys and all config settings are preserved verbatim. */
230
+ export const removeTypeSafeKeyFromAdvisorJson = (): JevKeyStoreResult => {
231
+ try {
232
+ const path = join(getAgentDir(), "advisor.json");
233
+ const existing = readExistingConfig(path);
234
+ if (!(TYPESAFE_KEY_CONFIG_FIELD in existing)) {
235
+ return { message: "No plaintext key in advisor.json.", ok: true };
236
+ }
237
+ const retained = Object.fromEntries(
238
+ Object.entries(existing).filter(
239
+ ([key]) => key !== TYPESAFE_KEY_CONFIG_FIELD
240
+ )
241
+ );
242
+ writeFileSync(path, `${JSON.stringify(retained, null, 2)}\n`);
243
+ resetConfigCache();
244
+ return { message: "Plaintext key removed from advisor.json.", ok: true };
245
+ } catch (error) {
246
+ return {
247
+ message: `Removing the plaintext key failed: ${messageOf(error)}.`,
248
+ ok: false,
249
+ };
250
+ }
251
+ };
252
+
253
+ let warnedPlaintextKey = false;
254
+
255
+ /** Returns the plaintext-key warning once so callers can notify without spam. */
256
+ export const consumePlaintextKeyWarning = (): string | undefined => {
257
+ if (warnedPlaintextKey) {
258
+ return undefined;
259
+ }
260
+ warnedPlaintextKey = true;
261
+ return `Advisor is using a plaintext ${TYPESAFE_KEY_CONFIG_FIELD} from advisor.json; this is not recommended. Open /advisor-settings → Jev consultation filter to migrate it into a secure store, or use the ${TYPESAFE_KEY_ENV_VAR} environment variable.`;
262
+ };
263
+
264
+ /** Test-only: re-arms the one-time plaintext warning. */
265
+ export const resetPlaintextKeyWarning = () => {
266
+ warnedPlaintextKey = false;
267
+ };
@@ -0,0 +1,228 @@
1
+ import { formatTokenCount } from "../usage.ts";
2
+
3
+ export interface AdvisorJevUsageTotals {
4
+ cost: number;
5
+ inputTokens: number;
6
+ outputTokens: number;
7
+ }
8
+
9
+ export interface AdvisorJevFilterLedger {
10
+ allowed: number;
11
+ failures: number;
12
+ overrides: number;
13
+ repeatSkipped: number;
14
+ screened: number;
15
+ skipped: number;
16
+ }
17
+
18
+ export interface AdvisorJevGateLedger {
19
+ checks: number;
20
+ consultations: number;
21
+ failures: number;
22
+ usage: AdvisorJevUsageTotals;
23
+ }
24
+
25
+ export interface AdvisorJevLedger {
26
+ filter: AdvisorJevFilterLedger;
27
+ gate: AdvisorJevGateLedger;
28
+ usage: AdvisorJevUsageTotals;
29
+ }
30
+
31
+ export interface AdvisorJevUsage {
32
+ cost: number;
33
+ inputTokens: number;
34
+ outputTokens: number;
35
+ }
36
+
37
+ export interface JevSkipRecord {
38
+ normalizedQuestion?: string;
39
+ turn: number;
40
+ }
41
+
42
+ /** Invocation fields the Jev summary reads; satisfied by AdvisorInvocationRecord. */
43
+ export interface JevInvocationView {
44
+ cost?: number;
45
+ kind: string;
46
+ trigger: string;
47
+ }
48
+
49
+ const freshJevUsage = (): AdvisorJevUsageTotals => ({
50
+ cost: 0,
51
+ inputTokens: 0,
52
+ outputTokens: 0,
53
+ });
54
+
55
+ const freshJevLedger = (): AdvisorJevLedger => ({
56
+ filter: {
57
+ allowed: 0,
58
+ failures: 0,
59
+ overrides: 0,
60
+ repeatSkipped: 0,
61
+ screened: 0,
62
+ skipped: 0,
63
+ },
64
+ gate: { checks: 0, consultations: 0, failures: 0, usage: freshJevUsage() },
65
+ usage: freshJevUsage(),
66
+ });
67
+
68
+ const addJevUsage = (totals: AdvisorJevUsageTotals, usage: AdvisorJevUsage) => {
69
+ totals.cost += usage.cost;
70
+ totals.inputTokens += usage.inputTokens;
71
+ totals.outputTokens += usage.outputTokens;
72
+ };
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
+
121
+ /** One session's Jev screening and turn-gate accounting plus summary lines. */
122
+ export class AdvisorJevLedgerState {
123
+ #ledger = freshJevLedger();
124
+ #lastSkip: JevSkipRecord | undefined;
125
+
126
+ reset() {
127
+ this.#ledger = freshJevLedger();
128
+ this.#lastSkip = undefined;
129
+ }
130
+
131
+ get lastSkip() {
132
+ return this.#lastSkip;
133
+ }
134
+
135
+ recordFilterAllowed() {
136
+ this.#ledger.filter.allowed += 1;
137
+ this.#ledger.filter.screened += 1;
138
+ }
139
+
140
+ recordFilterSkipped(
141
+ repeat: boolean,
142
+ normalizedQuestion: string | undefined,
143
+ turn: number
144
+ ) {
145
+ this.#ledger.filter.skipped += 1;
146
+ this.#ledger.filter.screened += 1;
147
+ if (repeat) {
148
+ this.#ledger.filter.repeatSkipped += 1;
149
+ }
150
+ const skip: JevSkipRecord = { turn };
151
+ if (normalizedQuestion) {
152
+ skip.normalizedQuestion = normalizedQuestion;
153
+ }
154
+ this.#lastSkip = skip;
155
+ }
156
+
157
+ recordFilterOverride() {
158
+ this.#ledger.filter.overrides += 1;
159
+ }
160
+
161
+ recordFilterFailure() {
162
+ this.#ledger.filter.failures += 1;
163
+ }
164
+
165
+ recordFilterUsage(usage: AdvisorJevUsage) {
166
+ addJevUsage(this.#ledger.usage, usage);
167
+ }
168
+
169
+ recordGateCheck(usage?: AdvisorJevUsage) {
170
+ this.#ledger.gate.checks += 1;
171
+ if (usage) {
172
+ addJevUsage(this.#ledger.gate.usage, usage);
173
+ }
174
+ }
175
+
176
+ recordGateConsultation() {
177
+ this.#ledger.gate.consultations += 1;
178
+ }
179
+
180
+ recordGateFailure() {
181
+ this.#ledger.gate.failures += 1;
182
+ }
183
+
184
+ summaryLines(invocations: readonly JevInvocationView[]): string[] {
185
+ const lines: string[] = [];
186
+ const { filter, gate, usage } = this.#ledger;
187
+ const nonRepeatJevActivity =
188
+ filter.allowed +
189
+ (filter.skipped - filter.repeatSkipped) +
190
+ filter.failures;
191
+ if (nonRepeatJevActivity === 0 && filter.repeatSkipped > 0) {
192
+ // Only dedup fired — no Jev call ever happened, so the line must not
193
+ // claim Jev activity.
194
+ const parts = [
195
+ `${filter.repeatSkipped} repeat question${filter.repeatSkipped === 1 ? "" : "s"} skipped, earlier advice reattached`,
196
+ ];
197
+ if (filter.overrides > 0) {
198
+ parts.push(
199
+ `${filter.overrides} override${filter.overrides === 1 ? "" : "s"}`
200
+ );
201
+ }
202
+ lines.push(
203
+ `Consultation dedup: ${parts.join(", ")}`,
204
+ savingsLine(markdownCosts(invocations), filter.skipped)
205
+ );
206
+ } else if (this.#filterActive()) {
207
+ lines.push(filterLine(filter));
208
+ const jevTokens = usage.inputTokens + usage.outputTokens;
209
+ if (jevTokens > 0) {
210
+ lines.push(
211
+ `Jev cost: ${formatJevTokens(usage)} tokens · $${usage.cost.toFixed(4)} (input only; output free)`
212
+ );
213
+ }
214
+ if (filter.skipped > 0) {
215
+ lines.push(savingsLine(markdownCosts(invocations), filter.skipped));
216
+ }
217
+ }
218
+ if (gate.checks > 0 || gate.consultations > 0) {
219
+ lines.push(gateLine(gate, invocations));
220
+ }
221
+ return lines;
222
+ }
223
+
224
+ #filterActive() {
225
+ const { filter } = this.#ledger;
226
+ return filter.screened > 0 || filter.overrides > 0 || filter.failures > 0;
227
+ }
228
+ }
@@ -0,0 +1,122 @@
1
+ import type { Questions } from "@typesafe-ai/sdk";
2
+
3
+ import { isNumber, isRecordOf } from "../content-utils.ts";
4
+ import type { JsonValue } from "../content-utils.ts";
5
+
6
+ /** Exact rubric text for the stakes Score question; level 0 is the skip level. */
7
+ export const STAKES_RUBRIC = [
8
+ "Negligible: routine, low-risk, mechanical, or reversible; a wrong call costs little and is easy to undo.",
9
+ "Moderate: some risk or rework, but bounded and recoverable.",
10
+ "High: material consequences for correctness, security, cost, user trust, or irreversibility.",
11
+ ] as const;
12
+
13
+ const EVIDENCE_RULE =
14
+ "Judge from `executor_question` and `executor_draft` when present, otherwise from `recent_conversation`; when both are absent, `recent_conversation` is the evidence to judge from.";
15
+
16
+ export const screeningQuestions: Questions = {
17
+ self_answerable: {
18
+ criteria: {
19
+ false: "The executor needs the Advisor's second opinion.",
20
+ true: "The executor can resolve this alone with available tools and context.",
21
+ },
22
+ instructions: `Can the executor confidently resolve this request alone, using available tools and context? ${EVIDENCE_RULE}`,
23
+ type: "noul",
24
+ },
25
+ stakes: {
26
+ criteria: [...STAKES_RUBRIC],
27
+ instructions: `How material are the stakes of the decision behind this consultation request? ${EVIDENCE_RULE}`,
28
+ type: "score",
29
+ },
30
+ };
31
+
32
+ export interface TurnGateQuestions {
33
+ instructions: string;
34
+ }
35
+
36
+ export interface ScreeningCriteria {
37
+ noulMargin: number;
38
+ skipConfidence: number;
39
+ }
40
+
41
+ export type ScreeningVerdict = { skip: false } | { skip: true };
42
+
43
+ const NUMERIC_KEY_PATTERN = /^\d+$/u;
44
+
45
+ const finiteNumber = (value: JsonValue | undefined): number | undefined =>
46
+ isNumber(value) && Number.isFinite(value) ? value : undefined;
47
+
48
+ /**
49
+ * Probability mass on the lowest stakes level, resolved through the answer's
50
+ * legend (the key whose description equals level 0), falling back to the
51
+ * smallest numeric index key only when no legend resolves. Shape-independent:
52
+ * never hardcodes a level key and never uses `ceil(score)`.
53
+ */
54
+ export const lowestStakesProbability = <Answer>(
55
+ answer: Answer
56
+ ): number | undefined => {
57
+ if (!isRecordOf(answer)) {
58
+ return undefined;
59
+ }
60
+ const probabilities = isRecordOf(answer.probabilities)
61
+ ? answer.probabilities
62
+ : {};
63
+ const legend = isRecordOf(answer.legend) ? answer.legend : undefined;
64
+ if (legend) {
65
+ const exact = Object.keys(legend).find(
66
+ (key) => legend[key] === STAKES_RUBRIC[0]
67
+ );
68
+ if (exact) {
69
+ return finiteNumber(probabilities[exact]);
70
+ }
71
+ }
72
+ const numericKeys = Object.keys(probabilities).filter((key) =>
73
+ NUMERIC_KEY_PATTERN.test(key)
74
+ );
75
+ if (numericKeys.length === 0) {
76
+ return undefined;
77
+ }
78
+ let [lowest] = numericKeys;
79
+ for (const key of numericKeys) {
80
+ if (Number(key) < Number(lowest)) {
81
+ lowest = key;
82
+ }
83
+ }
84
+ return finiteNumber(probabilities[lowest]);
85
+ };
86
+
87
+ const selfAnswerableNoul = <Answer>(answer: Answer): number | undefined =>
88
+ isRecordOf(answer) ? finiteNumber(answer.noul) : undefined;
89
+
90
+ /** Pure composition: skip requires the hard conjunction of confident
91
+ * negligible stakes AND confident self-answerability. Any missing, NaN, or
92
+ * malformed input allows. Never a weighted sum. */
93
+ export const composeScreeningVerdict = <Answers>(
94
+ answers: Answers,
95
+ { noulMargin, skipConfidence }: ScreeningCriteria
96
+ ): ScreeningVerdict => {
97
+ if (!isRecordOf(answers)) {
98
+ return { skip: false };
99
+ }
100
+ const negligibleMass = lowestStakesProbability(answers.stakes);
101
+ const noul = selfAnswerableNoul(answers.self_answerable);
102
+ if (negligibleMass === undefined || noul === undefined) {
103
+ return { skip: false };
104
+ }
105
+ const confidentlySelfAnswerable = noul >= 0.5 + noulMargin;
106
+ return {
107
+ skip: negligibleMass >= skipConfidence && confidentlySelfAnswerable,
108
+ };
109
+ };
110
+
111
+ /** Confident-true only; any uncertainty means no invocation. */
112
+ export const composeTurnGateVerdict = <Answers>(
113
+ answers: Answers,
114
+ threshold: number
115
+ ): boolean => {
116
+ if (!isRecordOf(answers)) {
117
+ return false;
118
+ }
119
+ const answer = answers.should_consult;
120
+ const noul = isRecordOf(answer) ? finiteNumber(answer.noul) : undefined;
121
+ return noul !== undefined && noul >= threshold;
122
+ };
@@ -0,0 +1,50 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { JsonValue } from "@typesafe-ai/sdk";
3
+
4
+ import {
5
+ advisorJevDigestMaxCharsRef,
6
+ advisorRedactSecretsRef,
7
+ } from "../config/state.ts";
8
+ import { recentConversation } from "../conversation.ts";
9
+ import { redactAndCapText } from "../redaction.ts";
10
+
11
+ /** Per-field byte cap for executor question and draft sent to Jev. */
12
+ export const JEV_TEXT_CAP_BYTES = 8 * 1024;
13
+
14
+ export interface JevStateInput {
15
+ draft?: string;
16
+ question?: string;
17
+ }
18
+
19
+ export type JevState = Record<string, JsonValue>;
20
+
21
+ /** Builds the Jev screening state: named fields, redacted and capped through
22
+ * the existing egress pipeline, with the conversation digest as fallback
23
+ * evidence when no explicit request exists. */
24
+ export const buildJevState = (
25
+ ctx: ExtensionContext,
26
+ input: JevStateInput = {}
27
+ ) => {
28
+ const fields = new Map<string, string>([["role", "executor"]]);
29
+ if (input.question) {
30
+ fields.set(
31
+ "executor_question",
32
+ redactAndCapText(
33
+ input.question,
34
+ JEV_TEXT_CAP_BYTES,
35
+ advisorRedactSecretsRef
36
+ )
37
+ );
38
+ }
39
+ if (input.draft) {
40
+ fields.set(
41
+ "executor_draft",
42
+ redactAndCapText(input.draft, JEV_TEXT_CAP_BYTES, advisorRedactSecretsRef)
43
+ );
44
+ }
45
+ const digest = recentConversation(ctx, advisorJevDigestMaxCharsRef);
46
+ if (digest) {
47
+ fields.set("recent_conversation", digest);
48
+ }
49
+ return Object.fromEntries(fields);
50
+ };