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
@@ -0,0 +1,252 @@
1
+ import {
2
+ chmodSync,
3
+ existsSync,
4
+ readFileSync,
5
+ rmSync,
6
+ writeFileSync,
7
+ } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
10
+ import { readExistingConfig, resetConfigCache } from "../config/storage.ts";
11
+ import { redactSecrets } from "../redaction.ts";
12
+
13
+ export type JevKeySource = "bun-secrets" | "env" | "file" | "advisor-json";
14
+
15
+ export interface JevKeyResolution {
16
+ key?: string;
17
+ /** Present whenever a key resolved. */
18
+ source?: JevKeySource;
19
+ }
20
+
21
+ export interface JevKeyStoreResult {
22
+ message: string;
23
+ ok: boolean;
24
+ }
25
+
26
+ export interface JevSecretEntry {
27
+ name: string;
28
+ service: string;
29
+ }
30
+
31
+ export interface JevSecretsLike {
32
+ delete: (options: JevSecretEntry) => Promise<unknown>;
33
+ get: (options: JevSecretEntry) => Promise<string | null | undefined>;
34
+ set: (options: JevSecretEntry & { value: string }) => Promise<void>;
35
+ }
36
+
37
+ export interface JevKeyStoreDeps {
38
+ /** Deletes the extension-managed key file; injectable for tests. */
39
+ deleteFileStore?: () => void;
40
+ env?: Record<string, string | undefined>;
41
+ readAdvisorJson?: () => Record<string, unknown>;
42
+ /** Reads the extension-managed 0600 key file; injectable for tests. */
43
+ readFileStore?: () => string | undefined;
44
+ /** Inject `null` to simulate a runtime without a secret store. */
45
+ secrets?: JevSecretsLike | null;
46
+ writeFileStore?: (key: string) => void;
47
+ }
48
+
49
+ export const TYPESAFE_KEY_ENV_VAR = "TYPESAFE_API_KEY";
50
+ export const TYPESAFE_KEY_SERVICE = "pi-advisor";
51
+ export const TYPESAFE_KEY_NAME = "typesafe-api-key";
52
+ export const TYPESAFE_KEY_CONFIG_FIELD = "typesafe_api_key";
53
+
54
+ const KEY_FILE_MODE = 0o600;
55
+
56
+ const keyFilePath = () => join(getAgentDir(), "typesafe_api_key");
57
+
58
+ const runtimeSecrets = (): JevSecretsLike | undefined =>
59
+ (globalThis as { Bun?: { secrets?: JevSecretsLike } }).Bun?.secrets;
60
+
61
+ /** Whether the current runtime offers a Bun.secrets store. */
62
+ export const hasRuntimeSecretStore = () => runtimeSecrets() !== undefined;
63
+
64
+ const normalizeKey = (value: string | null | undefined): string | undefined =>
65
+ value?.trim() || undefined;
66
+
67
+ const readAdvisorJsonConfig = (): Record<string, unknown> =>
68
+ readExistingConfig(join(getAgentDir(), "advisor.json"));
69
+
70
+ const defaultReadFileStore = (): string | undefined => {
71
+ try {
72
+ return normalizeKey(readFileSync(keyFilePath(), "utf8"));
73
+ } catch {
74
+ return undefined;
75
+ }
76
+ };
77
+
78
+ const defaultWriteFileStore = (key: string) => {
79
+ const path = keyFilePath();
80
+ writeFileSync(path, `${key}\n`, { mode: KEY_FILE_MODE });
81
+ // writeFileSync's mode only applies at creation; re-assert it on every write.
82
+ chmodSync(path, KEY_FILE_MODE);
83
+ };
84
+
85
+ const defaultDeleteFileStore = () => {
86
+ rmSync(keyFilePath(), { force: true });
87
+ };
88
+
89
+ const messageOf = (error: unknown) =>
90
+ redactSecrets(error instanceof Error ? error.message : String(error));
91
+
92
+ /** Resolves the TypeSafe API key: Bun.secrets → env var → the extension's
93
+ * 0600 key file → a hand-placed advisor.json string (read-only). Never
94
+ * throws; no key means undefined. */
95
+ export const resolveTypeSafeKey = async (
96
+ deps: JevKeyStoreDeps = {}
97
+ ): Promise<JevKeyResolution> => {
98
+ const secrets = deps.secrets === undefined ? runtimeSecrets() : deps.secrets;
99
+ if (secrets) {
100
+ try {
101
+ const stored = normalizeKey(
102
+ await secrets.get({
103
+ name: TYPESAFE_KEY_NAME,
104
+ service: TYPESAFE_KEY_SERVICE,
105
+ })
106
+ );
107
+ if (stored) {
108
+ return { key: stored, source: "bun-secrets" };
109
+ }
110
+ } catch {
111
+ // An unavailable secret store falls through to the next source.
112
+ }
113
+ }
114
+ const env = deps.env ?? process.env;
115
+ const fromEnv = normalizeKey(env[TYPESAFE_KEY_ENV_VAR]);
116
+ if (fromEnv) {
117
+ return { key: fromEnv, source: "env" };
118
+ }
119
+ const readFileStore = deps.readFileStore ?? defaultReadFileStore;
120
+ const fromFile = normalizeKey(readFileStore());
121
+ if (fromFile) {
122
+ return { key: fromFile, source: "file" };
123
+ }
124
+ const config = (deps.readAdvisorJson ?? readAdvisorJsonConfig)();
125
+ const staged = config[TYPESAFE_KEY_CONFIG_FIELD];
126
+ if (typeof staged === "string") {
127
+ const fromConfig = normalizeKey(staged);
128
+ if (fromConfig) {
129
+ return { key: fromConfig, source: "advisor-json" };
130
+ }
131
+ }
132
+ return {};
133
+ };
134
+
135
+ /** Stores the key securely: Bun.secrets when the runtime provides it,
136
+ * otherwise a dedicated 0600-mode file in the Pi agent directory. The key is
137
+ * never written to advisor.json. */
138
+ export const writeKeyTypeSafeKey = async (
139
+ key: string,
140
+ deps: JevKeyStoreDeps = {}
141
+ ): Promise<JevKeyStoreResult> => {
142
+ const normalized = normalizeKey(key);
143
+ if (!normalized) {
144
+ return { message: "The key is empty.", ok: false };
145
+ }
146
+ const secrets = deps.secrets === undefined ? runtimeSecrets() : deps.secrets;
147
+ if (secrets) {
148
+ try {
149
+ await secrets.set({
150
+ name: TYPESAFE_KEY_NAME,
151
+ service: TYPESAFE_KEY_SERVICE,
152
+ value: normalized,
153
+ });
154
+ return { message: "Key stored in Bun.secrets.", ok: true };
155
+ } catch (error) {
156
+ return {
157
+ message: `Storing the key in Bun.secrets failed: ${messageOf(error)}. Alternatively set the ${TYPESAFE_KEY_ENV_VAR} environment variable in your shell profile.`,
158
+ ok: false,
159
+ };
160
+ }
161
+ }
162
+ try {
163
+ (deps.writeFileStore ?? defaultWriteFileStore)(normalized);
164
+ return {
165
+ message: "Key stored in ~/.pi/agent/typesafe_api_key (mode 0600).",
166
+ ok: true,
167
+ };
168
+ } catch (error) {
169
+ return {
170
+ message: `Storing the key failed: ${messageOf(error)}. Alternatively set the ${TYPESAFE_KEY_ENV_VAR} environment variable in your shell profile.`,
171
+ ok: false,
172
+ };
173
+ }
174
+ };
175
+
176
+ /** Removes every stored key (Bun.secrets entry and the 0600 file); never
177
+ * touches env vars or the hand-placed advisor.json entry. */
178
+ export const clearKeyTypeSafeKey = async (
179
+ deps: JevKeyStoreDeps = {}
180
+ ): Promise<JevKeyStoreResult> => {
181
+ let clearedSomething = false;
182
+ let firstError: string | undefined;
183
+ const secrets = deps.secrets === undefined ? runtimeSecrets() : deps.secrets;
184
+ if (secrets) {
185
+ try {
186
+ await secrets.delete({
187
+ name: TYPESAFE_KEY_NAME,
188
+ service: TYPESAFE_KEY_SERVICE,
189
+ });
190
+ clearedSomething = true;
191
+ } catch (error) {
192
+ firstError = messageOf(error);
193
+ }
194
+ }
195
+ try {
196
+ if (deps.deleteFileStore) {
197
+ deps.deleteFileStore();
198
+ } else if (existsSync(keyFilePath())) {
199
+ defaultDeleteFileStore();
200
+ }
201
+ clearedSomething = true;
202
+ } catch (error) {
203
+ firstError ??= messageOf(error);
204
+ }
205
+ if (firstError) {
206
+ return {
207
+ message: `Clearing the stored key failed: ${firstError}.`,
208
+ ok: false,
209
+ };
210
+ }
211
+ return {
212
+ 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.`}`,
213
+ ok: true,
214
+ };
215
+ };
216
+
217
+ /** Removes a hand-placed advisor.json key after a successful store migration.
218
+ * Unknown keys and all config settings are preserved verbatim. */
219
+ export const removeTypeSafeKeyFromAdvisorJson = (): JevKeyStoreResult => {
220
+ try {
221
+ const path = join(getAgentDir(), "advisor.json");
222
+ const existing = readExistingConfig(path);
223
+ if (!(TYPESAFE_KEY_CONFIG_FIELD in existing)) {
224
+ return { message: "No plaintext key in advisor.json.", ok: true };
225
+ }
226
+ delete existing[TYPESAFE_KEY_CONFIG_FIELD];
227
+ writeFileSync(path, `${JSON.stringify(existing, null, 2)}\n`);
228
+ resetConfigCache();
229
+ return { message: "Plaintext key removed from advisor.json.", ok: true };
230
+ } catch (error) {
231
+ return {
232
+ message: `Removing the plaintext key failed: ${messageOf(error)}.`,
233
+ ok: false,
234
+ };
235
+ }
236
+ };
237
+
238
+ let warnedPlaintextKey = false;
239
+
240
+ /** Returns the plaintext-key warning once so callers can notify without spam. */
241
+ export const consumePlaintextKeyWarning = (): string | undefined => {
242
+ if (warnedPlaintextKey) {
243
+ return undefined;
244
+ }
245
+ warnedPlaintextKey = true;
246
+ 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.`;
247
+ };
248
+
249
+ /** Test-only: re-arms the one-time plaintext warning. */
250
+ export const resetPlaintextKeyWarning = () => {
251
+ warnedPlaintextKey = false;
252
+ };
@@ -0,0 +1,234 @@
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
+ /** One session's Jev screening and turn-gate accounting plus summary lines. */
75
+ export class AdvisorJevLedgerState {
76
+ #ledger = freshJevLedger();
77
+ #lastSkip: JevSkipRecord | undefined;
78
+
79
+ reset() {
80
+ this.#ledger = freshJevLedger();
81
+ this.#lastSkip = undefined;
82
+ }
83
+
84
+ get lastSkip() {
85
+ return this.#lastSkip;
86
+ }
87
+
88
+ recordFilterAllowed() {
89
+ this.#ledger.filter.allowed += 1;
90
+ this.#ledger.filter.screened += 1;
91
+ }
92
+
93
+ recordFilterSkipped(
94
+ repeat: boolean,
95
+ normalizedQuestion: string | undefined,
96
+ turn: number
97
+ ) {
98
+ this.#ledger.filter.skipped += 1;
99
+ this.#ledger.filter.screened += 1;
100
+ if (repeat) {
101
+ this.#ledger.filter.repeatSkipped += 1;
102
+ }
103
+ this.#lastSkip = {
104
+ ...(normalizedQuestion ? { normalizedQuestion } : {}),
105
+ turn,
106
+ };
107
+ }
108
+
109
+ recordFilterOverride() {
110
+ this.#ledger.filter.overrides += 1;
111
+ }
112
+
113
+ recordFilterFailure() {
114
+ this.#ledger.filter.failures += 1;
115
+ }
116
+
117
+ recordFilterUsage(usage: AdvisorJevUsage) {
118
+ addJevUsage(this.#ledger.usage, usage);
119
+ }
120
+
121
+ recordGateCheck(usage?: AdvisorJevUsage) {
122
+ this.#ledger.gate.checks += 1;
123
+ if (usage) {
124
+ addJevUsage(this.#ledger.gate.usage, usage);
125
+ }
126
+ }
127
+
128
+ recordGateConsultation() {
129
+ this.#ledger.gate.consultations += 1;
130
+ }
131
+
132
+ recordGateFailure() {
133
+ this.#ledger.gate.failures += 1;
134
+ }
135
+
136
+ summaryLines(invocations: readonly JevInvocationView[]): string[] {
137
+ const lines: string[] = [];
138
+ const { filter, gate, usage } = this.#ledger;
139
+ const nonRepeatJevActivity =
140
+ filter.allowed +
141
+ (filter.skipped - filter.repeatSkipped) +
142
+ filter.failures;
143
+ if (nonRepeatJevActivity === 0 && filter.repeatSkipped > 0) {
144
+ // Only dedup fired — no Jev call ever happened, so the line must not
145
+ // claim Jev activity.
146
+ const parts = [
147
+ `${filter.repeatSkipped} repeat question${filter.repeatSkipped === 1 ? "" : "s"} skipped, earlier advice reattached`,
148
+ ];
149
+ if (filter.overrides > 0) {
150
+ parts.push(
151
+ `${filter.overrides} override${filter.overrides === 1 ? "" : "s"}`
152
+ );
153
+ }
154
+ lines.push(`Consultation dedup: ${parts.join(", ")}`);
155
+ lines.push(
156
+ this.#savingsLine(this.#markdownCosts(invocations), filter.skipped)
157
+ );
158
+ } else if (this.#filterActive()) {
159
+ lines.push(this.#filterLine(filter));
160
+ const jevTokens = usage.inputTokens + usage.outputTokens;
161
+ if (jevTokens > 0) {
162
+ lines.push(
163
+ `Jev cost: ${this.#formatJevTokens(usage)} tokens · $${usage.cost.toFixed(4)} (input only; output free)`
164
+ );
165
+ }
166
+ if (filter.skipped > 0) {
167
+ lines.push(
168
+ this.#savingsLine(this.#markdownCosts(invocations), filter.skipped)
169
+ );
170
+ }
171
+ }
172
+ if (gate.checks > 0 || gate.consultations > 0) {
173
+ lines.push(this.#gateLine(gate, invocations));
174
+ }
175
+ return lines;
176
+ }
177
+
178
+ #filterActive() {
179
+ const { filter } = this.#ledger;
180
+ return filter.screened > 0 || filter.overrides > 0 || filter.failures > 0;
181
+ }
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
+ }
@@ -0,0 +1,119 @@
1
+ import type { Questions } from "@typesafe-ai/sdk";
2
+
3
+ /** Exact rubric text for the stakes Score question; level 0 is the skip level. */
4
+ export const STAKES_RUBRIC = [
5
+ "Negligible: routine, low-risk, mechanical, or reversible; a wrong call costs little and is easy to undo.",
6
+ "Moderate: some risk or rework, but bounded and recoverable.",
7
+ "High: material consequences for correctness, security, cost, user trust, or irreversibility.",
8
+ ] as const;
9
+
10
+ const EVIDENCE_RULE =
11
+ "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.";
12
+
13
+ export const screeningQuestions: Questions = {
14
+ self_answerable: {
15
+ criteria: {
16
+ false: "The executor needs the Advisor's second opinion.",
17
+ true: "The executor can resolve this alone with available tools and context.",
18
+ },
19
+ instructions: `Can the executor confidently resolve this request alone, using available tools and context? ${EVIDENCE_RULE}`,
20
+ type: "noul",
21
+ },
22
+ stakes: {
23
+ criteria: [...STAKES_RUBRIC],
24
+ instructions: `How material are the stakes of the decision behind this consultation request? ${EVIDENCE_RULE}`,
25
+ type: "score",
26
+ },
27
+ };
28
+
29
+ export interface TurnGateQuestions {
30
+ instructions: string;
31
+ }
32
+
33
+ export interface ScreeningCriteria {
34
+ noulMargin: number;
35
+ skipConfidence: number;
36
+ }
37
+
38
+ export type ScreeningVerdict = { skip: false } | { skip: true };
39
+
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);
44
+
45
+ const finiteNumber = (value: unknown): number | undefined =>
46
+ typeof value === "number" && 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 = (
55
+ answer: unknown
56
+ ): number | undefined => {
57
+ if (!isRecord(answer)) {
58
+ return undefined;
59
+ }
60
+ const probabilities = isRecord(answer.probabilities)
61
+ ? answer.probabilities
62
+ : {};
63
+ const legend = isRecord(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
+ const lowest = numericKeys.reduce((left, right) =>
79
+ Number(left) <= Number(right) ? left : right
80
+ );
81
+ return finiteNumber(probabilities[lowest]);
82
+ };
83
+
84
+ const selfAnswerableNoul = (answer: unknown): number | undefined =>
85
+ isRecord(answer) ? finiteNumber(answer.noul) : undefined;
86
+
87
+ /** Pure composition: skip requires the hard conjunction of confident
88
+ * negligible stakes AND confident self-answerability. Any missing, NaN, or
89
+ * malformed input allows. Never a weighted sum. */
90
+ export const composeScreeningVerdict = (
91
+ answers: unknown,
92
+ { noulMargin, skipConfidence }: ScreeningCriteria
93
+ ): ScreeningVerdict => {
94
+ if (!isRecord(answers)) {
95
+ return { skip: false };
96
+ }
97
+ const negligibleMass = lowestStakesProbability(answers.stakes);
98
+ const noul = selfAnswerableNoul(answers.self_answerable);
99
+ if (negligibleMass === undefined || noul === undefined) {
100
+ return { skip: false };
101
+ }
102
+ const confidentlySelfAnswerable = noul >= 0.5 + noulMargin;
103
+ return {
104
+ skip: negligibleMass >= skipConfidence && confidentlySelfAnswerable,
105
+ };
106
+ };
107
+
108
+ /** Confident-true only; any uncertainty means no invocation. */
109
+ export const composeTurnGateVerdict = (
110
+ answers: unknown,
111
+ threshold: number
112
+ ): boolean => {
113
+ if (!isRecord(answers)) {
114
+ return false;
115
+ }
116
+ const answer = answers.should_consult;
117
+ const noul = isRecord(answer) ? finiteNumber(answer.noul) : undefined;
118
+ return noul !== undefined && noul >= threshold;
119
+ };
@@ -0,0 +1,47 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { JsonValue } from "@typesafe-ai/sdk";
3
+ import {
4
+ advisorJevDigestMaxCharsRef,
5
+ advisorRedactSecretsRef,
6
+ } from "../config/state.ts";
7
+ import { recentConversation } from "../conversation.ts";
8
+ import { redactAndCapText } from "../redaction.ts";
9
+
10
+ /** Per-field byte cap for executor question and draft sent to Jev. */
11
+ export const JEV_TEXT_CAP_BYTES = 8 * 1024;
12
+
13
+ export interface JevStateInput {
14
+ draft?: string;
15
+ question?: string;
16
+ }
17
+
18
+ export type JevState = Record<string, JsonValue>;
19
+
20
+ /** Builds the Jev screening state: named fields, redacted and capped through
21
+ * the existing egress pipeline, with the conversation digest as fallback
22
+ * evidence when no explicit request exists. */
23
+ export const buildJevState = (
24
+ ctx: ExtensionContext,
25
+ input: JevStateInput = {}
26
+ ): JevState => {
27
+ const state: JevState = { role: "executor" };
28
+ if (input.question) {
29
+ state.executor_question = redactAndCapText(
30
+ input.question,
31
+ JEV_TEXT_CAP_BYTES,
32
+ advisorRedactSecretsRef
33
+ );
34
+ }
35
+ if (input.draft) {
36
+ state.executor_draft = redactAndCapText(
37
+ input.draft,
38
+ JEV_TEXT_CAP_BYTES,
39
+ advisorRedactSecretsRef
40
+ );
41
+ }
42
+ const digest = recentConversation(ctx, advisorJevDigestMaxCharsRef);
43
+ if (digest) {
44
+ state.recent_conversation = digest;
45
+ }
46
+ return state;
47
+ };
@@ -0,0 +1,58 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { advisorJevTransportRef } from "../config/state.ts";
3
+ import { type JevKeySource, resolveTypeSafeKey } from "./key-store.ts";
4
+
5
+ export type JevTransportKind = "typesafe" | "openrouter";
6
+
7
+ export interface JevCredentials {
8
+ apiKey: string;
9
+ /** Where the TypeSafe key came from; present on the typesafe transport. */
10
+ source?: JevKeySource;
11
+ transport: JevTransportKind;
12
+ }
13
+
14
+ export interface JevTransportDeps {
15
+ /** Reads a pi-stored provider login; injectable for tests. */
16
+ getProviderKey?: (provider: string) => Promise<string | undefined>;
17
+ /** Resolves the TypeSafe key chain; injectable for tests. */
18
+ resolveTypesafe?: () => ReturnType<typeof resolveTypeSafeKey>;
19
+ }
20
+
21
+ const OPENROUTER_PROVIDER = "openrouter";
22
+
23
+ const openRouterKey = async (
24
+ ctx: ExtensionContext | undefined,
25
+ deps: JevTransportDeps
26
+ ): Promise<string | undefined> => {
27
+ const key = deps.getProviderKey
28
+ ? await deps.getProviderKey(OPENROUTER_PROVIDER)
29
+ : await ctx?.modelRegistry?.getApiKeyForProvider(OPENROUTER_PROVIDER);
30
+ return key?.trim() || undefined;
31
+ };
32
+
33
+ /** Resolves how Jev calls authenticate: a dedicated TypeSafe key first, then
34
+ * the existing pi OpenRouter login (reuse per advisorJevTransport). */
35
+ export const resolveJevTransport = async (
36
+ ctx?: ExtensionContext,
37
+ deps: JevTransportDeps = {}
38
+ ): Promise<JevCredentials | undefined> => {
39
+ const preference = advisorJevTransportRef;
40
+ if (preference !== "openrouter") {
41
+ const resolveTypesafe = deps.resolveTypesafe ?? resolveTypeSafeKey;
42
+ const resolution = await resolveTypesafe();
43
+ if (resolution.key) {
44
+ return {
45
+ apiKey: resolution.key,
46
+ ...(resolution.source ? { source: resolution.source } : {}),
47
+ transport: "typesafe",
48
+ };
49
+ }
50
+ }
51
+ if (preference === "typesafe") {
52
+ return undefined;
53
+ }
54
+ const openrouter = await openRouterKey(ctx, deps);
55
+ return openrouter
56
+ ? { apiKey: openrouter, transport: "openrouter" }
57
+ : undefined;
58
+ };