specpi 0.27.0 → 0.29.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.
@@ -1,11 +1,12 @@
1
1
  import fs from "node:fs";
2
2
  import { type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
3
- import { SYSTEM_NAMES, keyPresent, loadSettings, saveSettings, settingsPath } from "./config.mjs";
3
+ import { SYSTEM_NAMES, loadSettings, saveSettings, settingsPath } from "./config.mjs";
4
+ import { keySources } from "./key-source.mjs";
5
+ import { applyLayer, layerScopeLine, layerToPersist, startupToPersist } from "./layer.mjs";
4
6
  import { consentPath, granted, revokeConsent } from "./consent.mjs";
5
7
  import { createBroker } from "./broker.mjs";
6
8
  import { ledgerPath, read as readLedger } from "./ledger.mjs";
7
9
  import { usagePath } from "./usage.mjs";
8
- import { applyConfig as applyGuardConfig, statusLine as guardStatusLine } from "./guard.mjs";
9
10
  import * as retention from "./questions/retention.mjs";
10
11
  import * as compaction from "./questions/compaction.mjs";
11
12
  import * as gap from "./questions/gap.mjs";
@@ -70,14 +71,12 @@ export default function jevAdvisor(pi: ExtensionAPI) {
70
71
  let capabilityAsked = false;
71
72
  const capabilityDeclined = new Set<string>();
72
73
 
73
- let guardEnabled = false;
74
- const syncGuard = () => {
74
+ /** Persist the session's switches, or report that it could not be done. */
75
+ const persistLayer = (result: { settings: any }) => {
75
76
  try {
76
- return applyGuardConfig(guardEnabled);
77
+ return saveSettings(layerToPersist(result, loadSettings()));
77
78
  } catch {
78
- // A guard that cannot be reconfigured keeps whatever posture it has, which
79
- // /jev status reports rather than hides.
80
- return { applied: false, reason: "unwritable" };
79
+ return undefined;
81
80
  }
82
81
  };
83
82
 
@@ -92,12 +91,6 @@ export default function jevAdvisor(pi: ExtensionAPI) {
92
91
  resetHistory();
93
92
  capabilityAsked = false;
94
93
  capabilityDeclined.clear();
95
- guardEnabled = settings.guard.startup === true;
96
- // Deliberately outside the master switch. The guard is a separate package with its own
97
- // gate, and whether it is inert is a property of the install rather than a feature of the
98
- // advisor, so its configuration is rewritten every session either way. Off is the default
99
- // and is a real written configuration, not an absence of one.
100
- syncGuard();
101
94
  });
102
95
 
103
96
  pi.on("session_shutdown", () => {
@@ -251,6 +244,15 @@ export default function jevAdvisor(pi: ExtensionAPI) {
251
244
  }
252
245
  }
253
246
 
247
+ // Every result, not only the ones retention asked about. It was written in one place --
248
+ // inside retention's success path -- so a session with retention off, or with retention's
249
+ // budget spent, handed every other system an empty history for its whole length while
250
+ // their question sets said history was what they weighed.
251
+ recent.push({ tool: String(event?.toolName ?? ""), outcome: event?.isError === true ? "error" : "ok" });
252
+ if (recent.length > MAX_RECENT) {
253
+ recent.shift();
254
+ }
255
+
254
256
  // Two systems share this hook. Retention wants large read-only results; system 7 wants
255
257
  // externally fetched ones whatever their size, because an injected instruction can be two
256
258
  // hundred bytes. When both want the same result they are one call: questions are evaluated
@@ -310,11 +312,12 @@ export default function jevAdvisor(pi: ExtensionAPI) {
310
312
  }
311
313
 
312
314
  const { verdict, replacement } = result.decision;
313
- if (wantRetention) {
314
- recent.push({ tool: event.toolName, outcome: verdict.elide ? "spent" : "kept" });
315
- if (recent.length > MAX_RECENT) {
316
- recent.shift();
317
- }
315
+ // Retention knows something the bookkeeping above does not -- whether the result was
316
+ // spent -- so it refines its own entry rather than appending a second one for the same
317
+ // call. If anything has been recorded since, the entry is gone and so is the chance.
318
+ const latest = recent[recent.length - 1];
319
+ if (wantRetention && latest?.tool === event.toolName) {
320
+ latest.outcome = verdict.elide ? "spent" : "kept";
318
321
  }
319
322
 
320
323
  if (replacement === undefined) {
@@ -653,7 +656,7 @@ export default function jevAdvisor(pi: ExtensionAPI) {
653
656
  pi.registerCommand("jev", {
654
657
  description: "Show or change the Jev advisor: master switch, per-system switches and the transmission ledger",
655
658
  getArgumentCompletions: (prefix: string) =>
656
- ["status", "on", "off", "startup", "enable", "disable", "guard", "ledger", "forget"]
659
+ ["status", "on", "off", "startup", "enable", "disable", "ledger", "forget"]
657
660
  .filter((value) => value.startsWith(prefix.trim().toLowerCase()))
658
661
  .map((value) => ({ value, label: value })),
659
662
  handler: async (args: string, ctx: ExtensionContext) => {
@@ -661,14 +664,33 @@ export default function jevAdvisor(pi: ExtensionAPI) {
661
664
  const action = actionRaw.toLowerCase();
662
665
  try {
663
666
  if (action === "on" || action === "off") {
664
- settings = { ...settings, master: action === "on" };
665
- const active = SYSTEM_NAMES.filter((name) => settings.systems[name]);
666
- ctx.ui.notify(
667
- action === "on"
668
- ? `Jev advisor on for this session with ${active.length} of ${SYSTEM_NAMES.length} systems enabled${active.length === 0 ? " (enable one with /jev enable <system>)" : `: ${active.join(", ")}`}.`
669
- : "Jev advisor off for this session. No state leaves this machine.",
670
- "info",
671
- );
667
+ const on = action === "on";
668
+ // `--session` is the old behaviour, kept for the case it was the right one: a
669
+ // one-off try that must not change what the next session does.
670
+ const sessionOnly = rest.some((value) => /^--?(session|once)$/u.test(value.toLowerCase()));
671
+ const unknown = rest.filter((value) => !/^--?(session|once)$/u.test(value.toLowerCase()));
672
+ if (unknown.length > 0) {
673
+ throw new Error(`Usage: /jev ${action} [--session]`);
674
+ }
675
+
676
+ const result = applyLayer({ on }, { settings }, { keySources: () => keySources() });
677
+ settings = result.settings;
678
+ // Persisting is the default because a switch that forgets is not a switch. The
679
+ // old rule -- that only /jev startup may write -- protected against a session
680
+ // toggle silently changing tomorrow's sessions, but the cost of that protection
681
+ // was a layer people turned on repeatedly and never actually ran.
682
+ const persisted = !sessionOnly && ctx.hasUI ? persistLayer(result) : undefined;
683
+ const lines = [
684
+ ...result.lines,
685
+ layerScopeLine({
686
+ sessionOnly,
687
+ interactive: ctx.hasUI,
688
+ persisted,
689
+ stored: loadSettings(),
690
+ settingsFile: settingsPath(),
691
+ }),
692
+ ];
693
+ ctx.ui.notify(lines.join("\n"), "info");
672
694
 
673
695
  return;
674
696
  }
@@ -680,14 +702,41 @@ export default function jevAdvisor(pi: ExtensionAPI) {
680
702
  throw new Error(`Usage: /jev ${action} <${SYSTEM_NAMES.join("|")}>`);
681
703
  }
682
704
 
683
- const systems = { ...settings.systems };
684
- for (const name of names) {
685
- systems[name] = action === "enable";
686
- }
687
-
688
- settings = { ...settings, systems };
705
+ const changes = Object.fromEntries(names.map((name) => [name, action === "enable"]));
706
+ const systems = { ...settings.systems, ...changes };
707
+ // Disabling the last system while the layer is on leaves it running and doing
708
+ // nothing -- the state `enableSystems`, `startupToPersist`, `couple` and the
709
+ // Chat panel's save check all exist to prevent, reachable through the one path
710
+ // that did not check it. Switching the layer off is the honest reading of
711
+ // "disable everything", and it is announced rather than inferred.
712
+ const emptied = settings.master && SYSTEM_NAMES.every((name) => !systems[name]);
713
+ settings = { ...settings, systems, master: emptied ? false : settings.master };
714
+ // Persisted like every other switch here, and merged into the stored map rather
715
+ // than overwriting it: this session's copy may predate systems enabled on disk
716
+ // since it started, and writing it whole turned those back off silently.
717
+ const kept = ctx.hasUI
718
+ ? (() => {
719
+ try {
720
+ const current = loadSettings();
721
+ const merged = { ...current.systems, ...changes };
722
+ const dead = current.master && SYSTEM_NAMES.every((name) => !merged[name]);
723
+
724
+ return saveSettings({
725
+ ...current,
726
+ systems: merged,
727
+ master: dead ? false : current.master,
728
+ startup: dead ? false : current.startup,
729
+ });
730
+ } catch {
731
+ return undefined;
732
+ }
733
+ })()
734
+ : undefined;
689
735
  ctx.ui.notify(
690
- `${action === "enable" ? "Enabled" : "Disabled"} for this session: ${names.join(", ")}.${settings.master ? "" : " The master switch is still off; run /jev on."}`,
736
+ `${action === "enable" ? "Enabled" : "Disabled"}: ${names.join(", ")}.` +
737
+ `${kept ? " Remembered for new sessions." : " This session only."}` +
738
+ `${emptied ? " That was the last system, so the layer was switched off; it would otherwise run and do nothing." : ""}` +
739
+ `${!emptied && !settings.master ? " The layer is still off; run /jev on." : ""}`,
691
740
  "info",
692
741
  );
693
742
 
@@ -697,8 +746,9 @@ export default function jevAdvisor(pi: ExtensionAPI) {
697
746
  if (action === "startup") {
698
747
  const [choice] = rest;
699
748
  if (!choice) {
749
+ const current = loadSettings();
700
750
  ctx.ui.notify(
701
- `Jev starts ${loadSettings().startup ? "on" : "off"} in new sessions. Preference: ${settingsPath()}`,
751
+ `Jev starts ${current.startup && current.master ? "on" : "off"} in new sessions. Preference: ${settingsPath()}`,
702
752
  "info",
703
753
  );
704
754
 
@@ -713,70 +763,20 @@ export default function jevAdvisor(pi: ExtensionAPI) {
713
763
  throw new Error("Usage: /jev startup [on|off]");
714
764
  }
715
765
 
716
- const saved = saveSettings({ ...loadSettings(), startup: choice.toLowerCase() === "on" });
717
- ctx.ui.notify(
718
- saved.startup
719
- ? "New Pi sessions will start with the Jev advisor on. This session is unchanged."
720
- : "New Pi sessions will start with the Jev advisor off. This session is unchanged.",
721
- "info",
722
- );
723
-
724
- return;
725
- }
726
-
727
- if (action === "guard") {
728
- const [verb, choice] = rest.map((value) => value.toLowerCase());
729
- if (!verb) {
730
- ctx.ui.notify(guardStatusLine(), "info");
731
-
732
- return;
733
- }
734
-
735
- if (verb === "on" || verb === "off") {
736
- guardEnabled = verb === "on";
737
- const result = syncGuard();
738
- ctx.ui.notify(
739
- result.reason === "not-installed"
740
- ? "specpi-jev-guard is not installed, so there is nothing to switch. Command policy stays with the permission system."
741
- : guardEnabled
742
- ? "Jev guard on for this session. It scores shell and file calls and defers to the permission system whenever Jev is unavailable or unconfident."
743
- : "Jev guard off for this session. Every tool call goes straight to the permission system.",
744
- "info",
745
- );
746
-
747
- return;
748
- }
749
-
750
- if (verb !== "startup") {
751
- throw new Error("Usage: /jev guard [on|off|startup [on|off]]");
752
- }
753
-
754
- if (!choice) {
755
- ctx.ui.notify(
756
- `The Jev guard starts ${loadSettings().guard.startup ? "on" : "off"} in new sessions.`,
757
- "info",
758
- );
759
-
760
- return;
761
- }
762
-
763
- if (!ctx.hasUI) {
764
- throw new Error("Startup changes require a human interactive command");
765
- }
766
-
767
- if (!["on", "off"].includes(choice)) {
768
- throw new Error("Usage: /jev guard startup [on|off]");
769
- }
770
-
771
- const stored = loadSettings();
772
- const saved = saveSettings({
773
- ...stored,
774
- guard: { ...stored.guard, startup: choice === "on" },
775
- });
766
+ // Both keys, and the systems with them. Writing `startup` alone was the whole
767
+ // two-keys-for-one-intention trap, left in the command named after it: the
768
+ // advisor's session_start keeps a stored `master` only when `startup` is true,
769
+ // so `startup: true, master: false` starts every future session with the layer
770
+ // off while this command cheerfully reported it would start on. And a layer
771
+ // that starts on with no system enabled runs and does nothing, so the same rule
772
+ // `/jev on` uses applies here: fill them in only when none are chosen.
773
+ const wanted = choice.toLowerCase() === "on";
774
+ const saved = saveSettings(startupToPersist(wanted, loadSettings()));
775
+ const enabled = SYSTEM_NAMES.filter((name) => saved.systems[name]);
776
776
  ctx.ui.notify(
777
- saved.guard.startup
778
- ? "New Pi sessions will start with the Jev guard on. This session is unchanged."
779
- : "New Pi sessions will start with the Jev guard off. This session is unchanged.",
777
+ saved.startup && saved.master
778
+ ? `New Pi sessions will start with the Jev layer on, with ${enabled.length} of ${SYSTEM_NAMES.length} systems: ${enabled.join(", ")}. This session is unchanged; run /jev on to switch it on now.`
779
+ : "New Pi sessions will start with the Jev layer off. This session is unchanged.",
780
780
  "info",
781
781
  );
782
782
 
@@ -817,21 +817,29 @@ export default function jevAdvisor(pi: ExtensionAPI) {
817
817
 
818
818
  if (action !== "status") {
819
819
  throw new Error(
820
- "Usage: /jev [status|on|off|startup [on|off]|enable <system>|disable <system>|guard [on|off|startup [on|off]]|ledger [n]|forget]",
820
+ "Usage: /jev [status|on [--session]|off [--session]|startup [on|off]|enable <system>|disable <system>|ledger [n]|forget]",
821
821
  );
822
822
  }
823
823
 
824
824
  const state = broker.status();
825
+ const sources = keySources();
826
+ const activeSource = sources.find((source: { present: boolean }) => source.present)?.name;
827
+ const stored = loadSettings();
825
828
  const lines = [
826
- `master: ${settings.master ? "on" : "off"} (new sessions start ${loadSettings().startup ? "on" : "off"})`,
829
+ `master: ${settings.master ? "on" : "off"} (new sessions start ${stored.startup && stored.master ? "on" : "off"})`,
827
830
  ...SYSTEM_NAMES.map((name) => ` ${name}: ${settings.systems[name] ? "on" : "off"}`),
828
- // Both names, because the default route is OpenRouter and naming only the
829
- // other one sends a reader to set the key that returns a bare 401.
830
- `key: ${keyPresent() ? "present" : "missing"} (OPENROUTER_API_KEY, or TYPESAFE_API_KEY with JEV_BACKEND=typesafe)`,
831
+ // Every place a key could come from, in the order they are consulted, with the
832
+ // one in force marked. A bare "missing" was actively misleading here: it is
833
+ // what someone saw who had a perfectly good OpenRouter key stored by /login,
834
+ // and it gave them nothing to act on. Names only -- no key is ever printed.
835
+ `key: ${activeSource ? `in use from ${activeSource}` : "none found"}`,
836
+ ...sources.map(
837
+ (source: { name: string; label: string; detail: string; present: boolean }) =>
838
+ ` ${source.present ? "found" : " - "} ${source.label} (${source.detail})`,
839
+ ),
831
840
  `consent: ${granted() ? "granted" : "not granted"}`,
832
841
  `calls this session: ${state.callsUsed}/${state.budgets.total} total`,
833
842
  ...SYSTEM_NAMES.map((name) => ` ${name}: ${state.usedBySystem[name] ?? 0}/${state.budgets[name]}`),
834
- guardStatusLine(),
835
843
  `settings: ${settingsPath()}`,
836
844
  `consent file: ${consentPath()}`,
837
845
  `ledger: ${ledgerPath()}`,
@@ -0,0 +1,252 @@
1
+ // Where the Jev layer's key comes from, and the one place that answers it.
2
+ //
3
+ // This file exists because the layer used to answer it wrongly. Jev read `OPENROUTER_API_KEY` from
4
+ // the environment and nothing else, while Pi itself had already resolved an OpenRouter credential
5
+ // for the session through `/login openrouter` and stored it where every other provider stores one.
6
+ // The result was a layer that reported "key: missing" to someone who had a working OpenRouter key
7
+ // sitting in `auth.json`, with no interface anywhere that would have explained the gap. Reading the
8
+ // credential Pi already has is not a new integration; it is stopping an old one from opting out.
9
+ //
10
+ // Pi's documented resolution order (docs/providers.md, "Resolution Order") is:
11
+ //
12
+ // 1. the `--api-key` CLI flag -- Pi's own, scoped to Pi's model calls, not ours
13
+ // 2. `<agent-dir>/auth.json` -- what `/login` writes
14
+ // 3. the provider environment variable
15
+ // 4. custom provider keys in models.json
16
+ //
17
+ // We implement 2 then 3, in that order, so a person who has logged in once is served and a person
18
+ // who exports the variable is still served. Step 1 is Pi's alone and step 4 describes provider
19
+ // catalogue entries the decisions endpoint has no equivalent of.
20
+ //
21
+ // Two rules hold everywhere below.
22
+ //
23
+ // The key is returned by exactly one function, `resolveKey()`, and callers pass it straight to a
24
+ // request header. Nothing else here ever sees the value: `keySource()` returns a label so status
25
+ // output, the Chat panel and the ledger can say where a key came from without any of them being a
26
+ // place a key could leak from. That split is the whole reason this is a module rather than two
27
+ // lines in client.mjs.
28
+ //
29
+ // And a missing or malformed credential is never an error. Every failure path returns undefined,
30
+ // the caller reports `unavailable("no-key")`, and the harness does what it did before the layer
31
+ // existed. A credential store that can throw is a credential store that can take the session down.
32
+
33
+ import fs from "node:fs";
34
+ import path from "node:path";
35
+ import { agentDirectory } from "./config.mjs";
36
+
37
+ /** Pi's provider id for OpenRouter, from the provider table in its own docs. */
38
+ export const OPENROUTER_PROVIDER = "openrouter";
39
+
40
+ // auth.json holds one entry per provider and OAuth entries carry refresh and access tokens, so it
41
+ // is meaningfully larger than the 4 KiB settings bound. This is still small enough that anything
42
+ // above it was not written by Pi, and reading it is what stops a hostile or corrupt file from
43
+ // costing the session a multi-megabyte synchronous read on the tool path.
44
+ const MAX_AUTH_BYTES = 256 * 1024;
45
+
46
+ export function authPath() {
47
+ return path.join(agentDirectory(), "auth.json");
48
+ }
49
+
50
+ /**
51
+ * Pi strips a BOM before parsing and so do we: an auth.json written by a Windows editor parses for
52
+ * Pi and would otherwise fail here, which is the worst kind of difference -- the key works for
53
+ * every model call and appears missing to this layer alone.
54
+ */
55
+ function stripBom(text) {
56
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
57
+ }
58
+
59
+ /**
60
+ * The parsed store, cached against the file's own identity.
61
+ *
62
+ * `ask()` resolves a key per request and retention fires on every large read-only tool result, so an
63
+ * uncached read put a synchronous stat, read and JSON parse of up to 256 KiB on the tool path inside
64
+ * a 1500 ms latency budget -- where it used to be one `process.env` lookup. The cache key is the
65
+ * file's size and modification time, so `/login` writing a new credential mid-session invalidates it
66
+ * on the next call rather than being masked until restart, which a plain memo would have done.
67
+ *
68
+ * A file that will not parse is cached as firmly as one that will. Recording only successes left the
69
+ * worst case uncached: a truncated or hand-edited auth.json threw on every call, so every request
70
+ * paid a fresh stat, a 256 KiB read and a failing parse inside the same latency budget the cache
71
+ * exists to protect -- forever, since nothing about it would change until the file did.
72
+ */
73
+ let parsedStore = { key: "", data: undefined };
74
+
75
+ function readStore(file, stat) {
76
+ const identity = `${stat.mtimeMs}:${stat.size}:${file}`;
77
+ if (parsedStore.key === identity) {
78
+ return parsedStore.data;
79
+ }
80
+
81
+ let data;
82
+ try {
83
+ const parsed = JSON.parse(stripBom(fs.readFileSync(file, "utf8")));
84
+ data = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : undefined;
85
+ } catch {
86
+ // An unreadable or unparseable store is "no credential", which is the same answer the caller
87
+ // would have reached by catching this; caching it is what stops it being recomputed.
88
+ data = undefined;
89
+ }
90
+
91
+ parsedStore = { key: identity, data };
92
+
93
+ return data;
94
+ }
95
+
96
+ /**
97
+ * The raw stored entry for one provider, or undefined. Deliberately not exported: an entry is a
98
+ * credential, and the only thing outside this file that needs one is the request header.
99
+ */
100
+ function storedCredential(providerId) {
101
+ try {
102
+ const file = authPath();
103
+ // `stat`, not `lstat`: a symlinked auth.json has to resolve, because dotfile managers like
104
+ // chezmoi and stow routinely link it into a managed directory. Refusing links here would
105
+ // recreate the exact divergence this module was written to remove -- Pi resolves the
106
+ // credential and every model call works, while this layer alone reports "key: none found"
107
+ // and gives no way to tell that from a missing key.
108
+ //
109
+ // config.mjs refuses links on its own files for a reason that does not apply here: those
110
+ // are writes, where a link can redirect a trusted write somewhere it was not meant to go.
111
+ // This is a bounded read of a file Pi owns, so an unsupported shape reads as "no credential"
112
+ // rather than throwing. It is not our file to have opinions about.
113
+ const stat = fs.statSync(file, { throwIfNoEntry: false });
114
+ if (!stat || !stat.isFile() || stat.size > MAX_AUTH_BYTES) {
115
+ return undefined;
116
+ }
117
+
118
+ return readStore(file, stat)?.[providerId];
119
+ } catch {
120
+ return undefined;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * An api_key credential's key, or undefined for anything else.
126
+ *
127
+ * An `oauth` entry is ignored rather than unwrapped. Pi refreshes OAuth tokens inside a lock in its
128
+ * own credential store, and a second process reading an access token out of the file would be
129
+ * reading a value that may already have been rotated -- and would be doing it without the lock.
130
+ * OpenRouter's own login mints a durable `api_key` anyway, so the case this skips is not the case
131
+ * anyone reaches.
132
+ */
133
+ function apiKeyOf(credential) {
134
+ if (!credential || typeof credential !== "object" || credential.type !== "api_key") {
135
+ return undefined;
136
+ }
137
+
138
+ const key = credential.key;
139
+
140
+ return typeof key === "string" && key.trim().length > 0 ? key.trim() : undefined;
141
+ }
142
+
143
+ function environmentKey(name) {
144
+ const value = process.env[name];
145
+
146
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
147
+ }
148
+
149
+ /**
150
+ * Whether the credential store is consulted at all.
151
+ *
152
+ * `JEV_KEY_SOURCE=environment` restricts resolution to the environment variables. That exists for
153
+ * this repository's own scripts, and it is a correctness fix rather than a convenience: the
154
+ * calibration and triage runs load a key from the gitignored `evals/.env` and AGENTS.md promises
155
+ * "a variable already set in the shell always wins". Once `auth.json` was consulted first, those
156
+ * runs would silently bill a developer's personal `/login openrouter` account instead of the eval
157
+ * key, and `--probe` would verify a key the run then did not use.
158
+ */
159
+ function environmentOnly() {
160
+ return process.env.JEV_KEY_SOURCE === "environment";
161
+ }
162
+
163
+ /**
164
+ * Jev is reached through OpenRouter by default: that is where it is published, it is what
165
+ * specpi-jev-guard uses, and an OpenRouter key (`sk-or-...`) is rejected by the direct
166
+ * TypeSafe API with a bare 401. `JEV_BACKEND=typesafe` selects the direct API for a TypeSafe key.
167
+ *
168
+ * It lives here rather than in client.mjs because everything below has to bind it. A parameter
169
+ * defaulting to the string "openrouter" is not a binding: re-exporting such a function under a name
170
+ * whose previous version read the backend itself silently rebound every no-arg caller to the wrong
171
+ * route, which is how `keyPresent()` came to report a key that `resolveKey()` would not return.
172
+ */
173
+ export function backend() {
174
+ return process.env.JEV_BACKEND === "typesafe" ? "typesafe" : "openrouter";
175
+ }
176
+
177
+ export function keyEnvName(route = backend()) {
178
+ return route === "typesafe" ? "TYPESAFE_API_KEY" : "OPENROUTER_API_KEY";
179
+ }
180
+
181
+ /**
182
+ * Every place a key for this backend could come from, in the order they are consulted, each with
183
+ * whether it currently holds one. This is what `/jev status`, `specpi doctor` and the Chat panel
184
+ * render, and it carries labels only -- never a key, not even a truncated one.
185
+ *
186
+ * The list is returned whole rather than filtered to the winner because "which of these do I need
187
+ * to fix" is the question someone with no key is actually asking, and a bare "missing" has never
188
+ * answered it.
189
+ */
190
+ export function keySources(route = backend()) {
191
+ const variable = keyEnvName(route);
192
+ const sources = [];
193
+ // Only the OpenRouter route has a provider entry to read: `auth.json` is keyed by Pi provider
194
+ // id, and the direct TypeSafe API is not one of Pi's providers.
195
+ if (route !== "typesafe" && !environmentOnly()) {
196
+ sources.push({
197
+ name: "auth.json",
198
+ label: `Pi credential store (${OPENROUTER_PROVIDER})`,
199
+ detail: "/login openrouter",
200
+ present: apiKeyOf(storedCredential(OPENROUTER_PROVIDER)) !== undefined,
201
+ });
202
+ }
203
+
204
+ sources.push({
205
+ name: variable,
206
+ label: `${variable} in the environment`,
207
+ detail: `export ${variable}=...`,
208
+ present: environmentKey(variable) !== undefined,
209
+ });
210
+
211
+ // Accepted on the OpenRouter route so an env file predating the OpenRouter default keeps
212
+ // working. Listed last because it is a compatibility path, and listed at all because a person
213
+ // whose key is only here should be able to see that that is why it still works.
214
+ if (route !== "typesafe") {
215
+ sources.push({
216
+ name: "TYPESAFE_API_KEY",
217
+ label: "TYPESAFE_API_KEY in the environment (legacy)",
218
+ detail: "export TYPESAFE_API_KEY=...",
219
+ present: environmentKey("TYPESAFE_API_KEY") !== undefined,
220
+ });
221
+ }
222
+
223
+ return sources;
224
+ }
225
+
226
+ /** The name of the source a key would be taken from, or undefined when there is none. */
227
+ export function keySource(route = backend()) {
228
+ return keySources(route).find((source) => source.present)?.name;
229
+ }
230
+
231
+ /**
232
+ * The key itself. The only function here that returns one, and the only caller is the request
233
+ * header in client.mjs.
234
+ */
235
+ export function resolveKey(route = backend()) {
236
+ if (route !== "typesafe" && !environmentOnly()) {
237
+ const stored = apiKeyOf(storedCredential(OPENROUTER_PROVIDER));
238
+ if (stored) {
239
+ return stored;
240
+ }
241
+ }
242
+
243
+ return environmentKey(keyEnvName(route)) ?? (route !== "typesafe" ? environmentKey("TYPESAFE_API_KEY") : undefined);
244
+ }
245
+
246
+ /**
247
+ * Whether any source holds a key. Callers only ever ask this; the client reads the value itself at
248
+ * call time, so a key never has to exist inside a structure that something might log or serialize.
249
+ */
250
+ export function keyPresent(route = backend()) {
251
+ return keySources(route).some((source) => source.present);
252
+ }