privateer-agent 0.5.0 → 0.6.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.
@@ -47,20 +47,88 @@ function seedModel(id: string) {
47
47
  };
48
48
  }
49
49
 
50
- // Fetch the account channel's enabled model catalog. `GET /api/models` is the server's
51
- // public list of billable models (`{ models: [{ modelId }] }`) — the same set the app
52
- // shows. (The `/api/agent/v1` base only implements chat/completions, no /models route.)
53
- // Falls back to DEFAULT_MODELS on any failure.
54
- export async function fetchAccountModels(): Promise<string[]> {
50
+ // One catalog entry with its server-asserted baseline privacy tier.
51
+ export interface AccountModelInfo {
52
+ id: string;
53
+ tier: PrivacyTier;
54
+ }
55
+
56
+ // The set of tier strings pi-privacy defines (posture/tiers.ts). We only trust a
57
+ // server-supplied tier if it's one of these — anything else falls back to a prefix
58
+ // heuristic, so a server typo or older/newer server can never inject a bogus tier.
59
+ const VALID_TIERS = new Set<PrivacyTier>([
60
+ "tee-verified",
61
+ "tee-unverified",
62
+ "local",
63
+ "zdr-enforced",
64
+ "zdr-policy",
65
+ "standard",
66
+ ]);
67
+
68
+ // Baseline tier when the server doesn't (yet) send one. Honest-labeling rule: a
69
+ // confidential-compute model is only *claimed* here (tee-unverified) — the picker
70
+ // upgrades it to tee-verified live via attestation (accountPosture). Everything
71
+ // else with no server signal is "standard": we don't assert ZDR we can't back.
72
+ function tierFromPrefix(modelId: string): PrivacyTier {
73
+ return modelId.startsWith("near/") || modelId.startsWith("tinfoil/") ? "tee-unverified" : "standard";
74
+ }
75
+
76
+ function normalizeTier(tier: string | undefined, modelId: string): PrivacyTier {
77
+ return tier && VALID_TIERS.has(tier as PrivacyTier) ? (tier as PrivacyTier) : tierFromPrefix(modelId);
78
+ }
79
+
80
+ // Baseline tiers for the account catalog, keyed by modelId. Populated by
81
+ // fetchAccountCatalog() so the /models picker can shield each row without re-fetching.
82
+ // A live NEAR attestation (accountPosture) can still upgrade a row to tee-verified.
83
+ const accountTierMap = new Map<string, PrivacyTier>();
84
+
85
+ // The server-asserted baseline tier for an account model, or undefined if we haven't
86
+ // seen it in a catalog fetch. Used by the /models picker (privateer-models.ts).
87
+ export function accountBaselineTier(modelId: string): PrivacyTier | undefined {
88
+ return accountTierMap.get(modelId);
89
+ }
90
+
91
+ // Whether a catalog fetch has populated the tier map at least once. The /models
92
+ // picker uses this to decide if it must fetch before opening (first-open race with
93
+ // the provider's background fetch) vs. render immediately from the cached tiers.
94
+ export function accountCatalogLoaded(): boolean {
95
+ return accountTierMap.size > 0;
96
+ }
97
+
98
+ // Fetch the account channel's enabled model catalog WITH per-model privacy tiers.
99
+ // `GET /api/models` is the server's public list of billable models
100
+ // (`{ models: [{ modelId, privacy: { tier } }] }`) — the same set the app shows.
101
+ // (The `/api/agent/v1` base only implements chat/completions, no /models route.)
102
+ // Falls back to DEFAULT_MODELS (prefix-derived tiers) on any failure. Side effect:
103
+ // refreshes accountTierMap.
104
+ export async function fetchAccountCatalog(): Promise<AccountModelInfo[]> {
105
+ const fallback = (): AccountModelInfo[] =>
106
+ DEFAULT_MODELS.map((id) => ({ id, tier: tierFromPrefix(id) }));
107
+ let infos: AccountModelInfo[];
55
108
  try {
56
109
  const res = await fetch(`${serverBaseUrl()}/api/models`);
57
- if (!res.ok) return DEFAULT_MODELS;
58
- const data = (await res.json()) as { models?: { modelId?: string }[] };
59
- const ids = (data.models ?? []).map((m) => m.modelId).filter((x): x is string => !!x);
60
- return ids.length ? ids : DEFAULT_MODELS;
110
+ if (!res.ok) {
111
+ infos = fallback();
112
+ } else {
113
+ const data = (await res.json()) as {
114
+ models?: { modelId?: string; privacy?: { tier?: string } }[];
115
+ };
116
+ const parsed = (data.models ?? [])
117
+ .map((m) => (m.modelId ? { id: m.modelId, tier: normalizeTier(m.privacy?.tier, m.modelId) } : null))
118
+ .filter((x): x is AccountModelInfo => !!x);
119
+ infos = parsed.length ? parsed : fallback();
120
+ }
61
121
  } catch {
62
- return DEFAULT_MODELS;
122
+ infos = fallback();
63
123
  }
124
+ accountTierMap.clear();
125
+ for (const info of infos) accountTierMap.set(info.id, info.tier);
126
+ return infos;
127
+ }
128
+
129
+ // Back-compat id-only view over fetchAccountCatalog (registerProvider only needs ids).
130
+ export async function fetchAccountModels(): Promise<string[]> {
131
+ return (await fetchAccountCatalog()).map((m) => m.id);
64
132
  }
65
133
 
66
134
  // The Pi OAuth provider (Omit<OAuthProviderInterface, "id"> — Pi supplies the id from
@@ -199,8 +267,10 @@ export function makeAccountProvider() {
199
267
  models: ids.map(seedModel),
200
268
  });
201
269
  register(DEFAULT_MODELS); // immediate: provider exists this tick
202
- void fetchAccountModels()
203
- .then((ids) => ids.length && register(ids)) // refine to the live catalog
270
+ // Refine to the live catalog. fetchAccountCatalog also populates accountTierMap
271
+ // as a side effect, so the /models picker can shield each row without re-fetching.
272
+ void fetchAccountCatalog()
273
+ .then((infos) => infos.length && register(infos.map((m) => m.id)))
204
274
  .catch(() => {
205
275
  /* keep the fallback model */
206
276
  });
@@ -16,11 +16,19 @@ import { hasCredentials } from "../auth/privateer.ts";
16
16
  import { agentDir } from "../config/paths.ts";
17
17
 
18
18
  // The signed-in default: a NEAR confidential-compute (TEE, attestable) model — the
19
- // strongest privacy tier, and the same id the app shows first. Kept here as the one
20
- // definition; providers/account.ts imports it so its seed catalog can't drift.
19
+ // strongest privacy tier the account channel offers, and the same id the app shows
20
+ // first. Kept here as the one definition; providers/account.ts imports it so its seed
21
+ // catalog can't drift.
21
22
  export const ACCOUNT_DEFAULT_MODEL_ID = "near/zai-org/GLM-5.1-FP8";
22
23
  export const ACCOUNT_DEFAULT_SPEC = `privateer/${ACCOUNT_DEFAULT_MODEL_ID}`;
23
24
 
25
+ // Tinfoil's GLM 5.2 — CLIENT-side-attested TEE inference (the live TLS key is bound to
26
+ // the enclave's quote), the strongest privacy tier we offer, stronger than the account's
27
+ // server-proxied NEAR channel. Preferred whenever a Tinfoil key is present. Kept as the
28
+ // one definition so bin/privateer-tui and this resolver agree. See extensions/privateer-
29
+ // privacy.ts, which registers `tinfoil/glm-5-2` (and friends) on the tinfoil provider.
30
+ export const TINFOIL_DEFAULT_SPEC = "tinfoil/glm-5-2";
31
+
24
32
  // Last-resort BYO default, preserved from the pre-resolver code so a user who set an
25
33
  // OpenRouter key (and isn't signed in) keeps the old behaviour. If they have no key
26
34
  // either, this still surfaces the familiar "No API key found for openrouter" — a clear
@@ -48,12 +56,14 @@ export interface ResolveDefaultModelOptions {
48
56
 
49
57
  // Resolve the model spec ("provider/id") to use when no model is named. Pure and
50
58
  // synchronous (only reads env + the credentials file), so it's safe to call from any
51
- // entry point at startup. Precedence:
59
+ // entry point at startup. Precedence (mirrors bin/privateer-tui's launch logic, so the
60
+ // launcher, the REPL, and the next-launch seed all agree):
52
61
  // 1. explicit user choice (config/channel) — deliberate, always wins
53
62
  // 2. PRIVATEER_MODEL env — dev/global override
54
- // 3. signed into Privateer → the account default — the fix: subscription users
55
- // 4. a BYO provider whose key is present — anthropic, openai, openrouter
56
- // 5. LEGACY_BYO_FALLBACK — familiar "add a key" signal
63
+ // 3. Tinfoil key present → Tinfoil GLM 5.2 — strongest (client-attested) privacy
64
+ // 4. signed into Privateer → the account default — subscription users, no BYO key
65
+ // 5. a BYO provider whose key is present — anthropic, openai, openrouter
66
+ // 6. LEGACY_BYO_FALLBACK — familiar "add a key" signal
57
67
  export function resolveDefaultModel(opts: ResolveDefaultModelOptions = {}): string {
58
68
  const env = opts.env ?? process.env;
59
69
 
@@ -63,6 +73,10 @@ export function resolveDefaultModel(opts: ResolveDefaultModelOptions = {}): stri
63
73
  const fromEnv = env.PRIVATEER_MODEL?.trim();
64
74
  if (fromEnv) return fromEnv;
65
75
 
76
+ // Privacy-first: a Tinfoil key means we can run verifiable TEE inference right now,
77
+ // which we prefer even over the account's NEAR channel — same order the launcher uses.
78
+ if (env.TINFOIL_API_KEY?.trim()) return TINFOIL_DEFAULT_SPEC;
79
+
66
80
  const signedIn = opts.signedIn ?? hasCredentials();
67
81
  if (signedIn) return ACCOUNT_DEFAULT_SPEC;
68
82
 
@@ -73,6 +87,17 @@ export function resolveDefaultModel(opts: ResolveDefaultModelOptions = {}): stri
73
87
  return LEGACY_BYO_FALLBACK;
74
88
  }
75
89
 
90
+ // The confidential model to switch the LIVE session onto the moment a user signs in.
91
+ // A terminal launched with no credentials is pinned by `--model` to the keyless
92
+ // OpenRouter fallback; without an in-session switch it stays there and the first prompt
93
+ // after /login dead-ends on "No API key found for openrouter". This resolves the model
94
+ // sign-in should activate RIGHT AWAY: Tinfoil GLM 5.2 when a key is present, otherwise
95
+ // the account's NEAR confidential channel (billable to the subscription, no BYO key).
96
+ // PRIVATEER_MODEL still wins — a deliberate override is never stomped.
97
+ export function resolveSignedInModel(env: NodeJS.ProcessEnv = process.env): string {
98
+ return resolveDefaultModel({ env, signedIn: true });
99
+ }
100
+
76
101
  // Split a "provider/id" spec on its first slash (model ids themselves contain "/", so
77
102
  // only the first delimiter separates provider from model). Returns null for a spec
78
103
  // with no provider prefix.
@@ -0,0 +1,135 @@
1
+ // Shared terminal-colour palettes — the one place that decides how Privateer's CLI
2
+ // surfaces paint on a light vs dark terminal. Two audiences:
3
+ //
4
+ // 1. Pi extensions (brand banner, posture badge, remote-access footer) run inside
5
+ // Pi's TUI, which ALREADY auto-detects the terminal background (OSC 11 / COLORFGBG)
6
+ // and picks a light or dark theme. They get a live `Theme` (via ctx.ui.setHeader's
7
+ // factory arg or ctx.ui.theme), so paletteFor(theme) reads the theme's semantic
8
+ // colours — dark ink on a light bg, light ink on a dark bg — instead of a fixed
9
+ // colour. That's the fix for the old "everything painted white → invisible on a
10
+ // white terminal" bug.
11
+ //
12
+ // 2. The lean standalone REPL (src/cli/chat.ts) runs its OWN readline loop with no Pi
13
+ // TUI and therefore no Theme. It detects the scheme itself from COLORFGBG and picks
14
+ // a matching CliPalette — on a light bg, explicit 256-colour indices that are
15
+ // guaranteed high-contrast (a pale terminal yellow on white is the classic
16
+ // unreadable case), on a dark bg the standard named colours it always used.
17
+ //
18
+ // IMPORT-SAFETY: this module imports NOTHING (no Pi, no node builtins beyond the ambient
19
+ // process global), so it's safe to load from boot-ordered entrypoints — see boot.ts's
20
+ // ORDERING CONTRACT. `theme` is typed `any` because the whole extension layer treats Pi
21
+ // objects loosely and we don't want a Pi type import here.
22
+
23
+ // ── SGR primitives ───────────────────────────────────────────────────────────
24
+ // 256-color (8-bit), NOT 24-bit truecolor: macOS Terminal.app mangles truecolor. These
25
+ // indices are universally supported.
26
+ const ESC = "\x1b[";
27
+ export const RESET = `${ESC}0m`;
28
+ export const BOLD = `${ESC}1m`;
29
+ const c = (n: number): string => `${ESC}38;5;${n}m`;
30
+
31
+ // ── theme-derived palette (Pi extensions) ────────────────────────────────────
32
+ export type Palette = {
33
+ RESET: string;
34
+ BOLD: string;
35
+ INK: string; // primary readable text (wordmark body, version, paths, labels)
36
+ ACCENT: string; // brand accent (marks, highlights, codes, command hints)
37
+ BORDER: string; // frames / box drawing
38
+ DIM: string; // secondary / muted prose
39
+ GREEN: string; // success (connected, verified, context loaded)
40
+ YELLOW: string; // warning (not-signed-in, unconfirmed, update available)
41
+ };
42
+
43
+ // Last-resort palette for when no theme is reachable (headless surfaces have no banner,
44
+ // and any Pi new enough to render UI exposes the theme — so this is belt-and-suspenders).
45
+ // Kept white so behaviour on a dark terminal is unchanged if the theme lookup ever fails.
46
+ export const FALLBACK: Palette = {
47
+ RESET,
48
+ BOLD,
49
+ INK: c(231),
50
+ ACCENT: c(231),
51
+ BORDER: c(231),
52
+ DIM: `${ESC}90m`,
53
+ GREEN: `${ESC}32m`,
54
+ YELLOW: `${ESC}33m`,
55
+ };
56
+
57
+ // Build a Palette from a Pi Theme. getFgAnsi(name) returns the raw SGR foreground escape
58
+ // for a theme colour; every lookup falls back to the white default so a theme missing a
59
+ // given colour name can never blank a surface.
60
+ export function paletteFor(theme: any): Palette {
61
+ if (!theme || typeof theme.getFgAnsi !== "function") return FALLBACK;
62
+ const g = (name: string, fallback: string): string => {
63
+ try {
64
+ const a = theme.getFgAnsi(name);
65
+ return typeof a === "string" && a.length > 0 ? a : fallback;
66
+ } catch {
67
+ return fallback;
68
+ }
69
+ };
70
+ return {
71
+ RESET,
72
+ BOLD,
73
+ INK: g("text", FALLBACK.INK),
74
+ ACCENT: g("accent", FALLBACK.ACCENT),
75
+ BORDER: g("border", FALLBACK.BORDER),
76
+ DIM: g("dim", FALLBACK.DIM),
77
+ GREEN: g("success", FALLBACK.GREEN),
78
+ YELLOW: g("warning", FALLBACK.YELLOW),
79
+ };
80
+ }
81
+
82
+ // ── standalone REPL palette (no Pi Theme) ────────────────────────────────────
83
+ export type TerminalScheme = "light" | "dark";
84
+
85
+ export type CliPalette = {
86
+ RESET: string;
87
+ BOLD: string;
88
+ DIM: string;
89
+ GREEN: string;
90
+ YELLOW: string;
91
+ RED: string;
92
+ CYAN: string; // used as the REPL's accent (prompts, app-relay echoes)
93
+ };
94
+
95
+ // Dark bg: the standard named SGR colours the REPL always used — the terminal maps them
96
+ // to its own readable palette.
97
+ const CLI_DARK: CliPalette = {
98
+ RESET,
99
+ BOLD,
100
+ DIM: `${ESC}2m`,
101
+ GREEN: `${ESC}32m`,
102
+ YELLOW: `${ESC}33m`,
103
+ RED: `${ESC}31m`,
104
+ CYAN: `${ESC}36m`,
105
+ };
106
+
107
+ // Light bg: explicit dark 256-colour indices so contrast doesn't depend on the terminal's
108
+ // ANSI palette (a pale terminal yellow/cyan on white is unreadable; faint `2m` washes
109
+ // out). These are all mid-to-dark tones that read cleanly on white.
110
+ const CLI_LIGHT: CliPalette = {
111
+ RESET,
112
+ BOLD,
113
+ DIM: c(243), // solid medium gray instead of faint
114
+ GREEN: c(28), // dark green
115
+ YELLOW: c(130), // dark amber (plain 33m is invisible-pale on white)
116
+ RED: c(124), // dark red
117
+ CYAN: c(24), // dark teal-blue accent
118
+ };
119
+
120
+ // Detect the terminal background from COLORFGBG (set by many terminals as "fg;bg", the
121
+ // last field being the background ANSI index). 7/15 = light; everything else — or no
122
+ // COLORFGBG at all — defaults to dark, matching the REPL's historical assumption.
123
+ export function detectScheme(env: Record<string, string | undefined> = process.env): TerminalScheme {
124
+ const fgbg = env.COLORFGBG;
125
+ if (fgbg) {
126
+ const parts = fgbg.split(";");
127
+ const bg = parseInt(parts[parts.length - 1], 10);
128
+ if (!Number.isNaN(bg)) return bg === 7 || bg === 15 ? "light" : "dark";
129
+ }
130
+ return "dark";
131
+ }
132
+
133
+ export function cliPalette(scheme: TerminalScheme = detectScheme()): CliPalette {
134
+ return scheme === "light" ? CLI_LIGHT : CLI_DARK;
135
+ }