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.
- package/CHANGELOG.md +29 -0
- package/README.md +1 -0
- package/dist/index.js +3160 -1255
- package/extensions/index.ts +9 -9
- package/package.json +12 -8
- package/src/{untracked.ts → attachments.ts} +2 -5
- package/src/commands/activation-preparation.ts +6 -6
- package/src/commands/activation.ts +7 -7
- package/src/commands/lifecycle.ts +5 -5
- package/src/commands/manual-command.ts +13 -9
- package/src/commands/manual-consultation.ts +24 -10
- package/src/commands/manual-progress.ts +3 -3
- package/src/commands/model-commands.ts +6 -6
- package/src/commands/model-options.ts +4 -4
- package/src/commands/model-picker.ts +2 -2
- package/src/commands/registration.ts +8 -8
- package/src/commands/renderers.ts +5 -5
- package/src/commands/runtime.ts +8 -8
- package/src/commands/settings-commands.ts +8 -8
- package/src/commands/settings-persistence.ts +52 -4
- package/src/commands/types.ts +6 -13
- package/src/commands.ts +2 -2
- package/src/config/args.ts +3 -3
- package/src/config/defaults.ts +78 -3
- package/src/config/schema.ts +434 -0
- package/src/config/state.ts +71 -2
- package/src/config/storage.ts +27 -119
- package/src/config/types.ts +30 -1
- package/src/config/validation.ts +61 -181
- package/src/config.ts +7 -7
- package/src/content-utils.ts +30 -0
- package/src/conversation.ts +13 -168
- package/src/git.ts +4 -18
- package/src/herdr.ts +6 -8
- package/src/jev/client.ts +348 -0
- package/src/jev/key-store.ts +252 -0
- package/src/jev/ledger.ts +234 -0
- package/src/jev/questions.ts +119 -0
- package/src/jev/state.ts +47 -0
- package/src/jev/transport.ts +58 -0
- package/src/model-stream.ts +5 -8
- package/src/outcomes.ts +3 -2
- package/src/pi-settings.ts +59 -0
- package/src/preferences.ts +2 -2
- package/src/redaction.ts +50 -0
- package/src/scout-context.ts +121 -485
- package/src/scout-curation.ts +84 -0
- package/src/scout-groups.ts +485 -0
- package/src/scout-protocol.ts +122 -0
- package/src/scout-reconstruct.ts +44 -0
- package/src/scout-types.ts +66 -0
- package/src/scout.ts +53 -63
- package/src/session-state.ts +215 -78
- package/src/tool-result-cap.ts +88 -0
- package/src/tools/consult-context.ts +155 -0
- package/src/tools/consultation.ts +71 -220
- package/src/tools/gate-policy.ts +5 -5
- package/src/tools/gate-protocol.ts +2 -2
- package/src/tools/jev-filter.ts +202 -0
- package/src/tools/jev-turn-gate.ts +183 -0
- package/src/tools/loop-gate.ts +71 -61
- package/src/tools/outage-notifier.ts +30 -0
- package/src/tools/prompts.ts +4 -7
- package/src/tools/register-ask-advisor.ts +46 -15
- package/src/tools/register-lifecycle.ts +7 -7
- package/src/tools/register-outcome.ts +5 -5
- package/src/tools/register-renderers.ts +63 -5
- package/src/tools/registration.ts +27 -10
- package/src/tools/render-advisor-result.ts +54 -22
- package/src/tools/render-common.ts +25 -13
- package/src/tools/scout-status.ts +5 -5
- package/src/tools/session.ts +1 -1
- package/src/tools/types.ts +17 -7
- package/src/tools.ts +9 -9
- package/src/ui/jev-setup-submenu.ts +344 -0
- package/src/ui/manual-dialog-render.ts +2 -2
- package/src/ui/manual-dialog.ts +4 -9
- package/src/ui/masked-input.ts +68 -0
- package/src/ui/model-selector.ts +1 -1
- package/src/ui/settings-formatting.ts +2 -2
- package/src/ui/settings-items.ts +166 -5
- package/src/ui/settings-mutations.ts +37 -2
- package/src/ui/settings-selector.ts +5 -5
- package/src/ui/text-setting-submenu.ts +1 -1
- package/src/ui/types.ts +13 -2
- package/src/ui.ts +4 -4
- 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
|
+
};
|
package/src/jev/state.ts
ADDED
|
@@ -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
|
+
};
|