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.
- package/bin/privateer-launch.mjs +218 -0
- package/bin/privateer-tui +8 -139
- package/bin/privateer.cmd +6 -0
- package/extensions/privateer-brand.ts +117 -72
- package/extensions/privateer-gate.ts +6 -3
- package/extensions/privateer-models.ts +429 -0
- package/extensions/privateer-posture.ts +11 -14
- package/package.json +6 -3
- package/patches/@earendil-works+pi-coding-agent+0.80.3.patch +23 -0
- package/src/cli/chat.ts +15 -2
- package/src/providers/account.ts +82 -12
- package/src/providers/defaultModel.ts +31 -6
- package/src/ui/palette.ts +135 -0
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
// The Privateer `/models` picker — a searchable model selector that puts the
|
|
2
|
+
// PRIVACY POSTURE of every model on screen. Pi's built-in `/model` picker (which
|
|
3
|
+
// this shadows via the redirect patch in patches/@earendil-works+pi-coding-agent)
|
|
4
|
+
// shows only `id [provider]`; it has no hook for a privacy shield and can't be
|
|
5
|
+
// overridden from an extension (its command name is a reserved builtin, dispatched
|
|
6
|
+
// before extension commands). So we ship our OWN picker through `ctx.ui.custom`,
|
|
7
|
+
// which renders a full pi-tui component: fuzzy search + a per-row shield (⛉ TEE /
|
|
8
|
+
// ◈ ZDR / · standard) + tier grouping + a legend, ranked strongest-privacy-first.
|
|
9
|
+
//
|
|
10
|
+
// Honest-labeling contract (pi-privacy posture/tiers.ts): a TEE row is only a
|
|
11
|
+
// *claim* (tee-unverified, yellow) until a live attestation confirms it
|
|
12
|
+
// (tee-verified, green). The picker seeds each row with the server's baseline tier
|
|
13
|
+
// (GET /api/models `privacy.tier`, via account.ts) and then attests the TEE-capable
|
|
14
|
+
// rows in the background, upgrading the shield in place. ZDR-enforced rows are never
|
|
15
|
+
// downgraded by attestation — we only attest rows that claim a TEE.
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
Container,
|
|
19
|
+
fuzzyFilter,
|
|
20
|
+
getKeybindings,
|
|
21
|
+
Input,
|
|
22
|
+
Spacer,
|
|
23
|
+
Text,
|
|
24
|
+
} from "@earendil-works/pi-tui";
|
|
25
|
+
import { TIERS, tierRank, type PrivacyTier } from "pi-privacy";
|
|
26
|
+
import { verifyModelPosture } from "pi-privacy";
|
|
27
|
+
import {
|
|
28
|
+
accountBaselineTier,
|
|
29
|
+
accountCatalogLoaded,
|
|
30
|
+
accountPosture,
|
|
31
|
+
fetchAccountCatalog,
|
|
32
|
+
} from "../src/providers/account.ts";
|
|
33
|
+
|
|
34
|
+
// A minimal view of Pi's theme (passed to the ui.custom factory) — enough to color
|
|
35
|
+
// text without pulling Pi's internal theme types into an extension.
|
|
36
|
+
interface ThemeLike {
|
|
37
|
+
fg(color: string, text: string): string;
|
|
38
|
+
bold(text: string): string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// A pi-tui TUI handle (the first arg the ui.custom factory receives). We only use
|
|
42
|
+
// requestRender; keep the surface tiny so this doesn't couple to Pi internals.
|
|
43
|
+
interface TuiLike {
|
|
44
|
+
requestRender(): void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface ModelLike {
|
|
48
|
+
provider: string;
|
|
49
|
+
id: string;
|
|
50
|
+
name?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface Row {
|
|
54
|
+
provider: string;
|
|
55
|
+
id: string;
|
|
56
|
+
name: string;
|
|
57
|
+
model: ModelLike;
|
|
58
|
+
tier: PrivacyTier;
|
|
59
|
+
// true once a live attestation has resolved this row (so we don't re-attest).
|
|
60
|
+
attested?: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// posture bucket → theme color name (Pi's palette) for the shield glyph.
|
|
64
|
+
const POSTURE_COLOR: Record<string, string> = {
|
|
65
|
+
green: "success",
|
|
66
|
+
yellow: "warning",
|
|
67
|
+
red: "error",
|
|
68
|
+
neutral: "muted",
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// The shield/marker glyph per tier family. TEE → shield; ZDR → diamond; local →
|
|
72
|
+
// house; standard → dot. Color comes from the tier's posture bucket, so verified
|
|
73
|
+
// (green) and merely-claimed (yellow) read differently at a glance — the same
|
|
74
|
+
// distinction the status-bar badge (privateer-posture.ts) draws.
|
|
75
|
+
function glyphFor(tier: PrivacyTier): string {
|
|
76
|
+
if (tier === "tee-verified" || tier === "tee-unverified") return "⛉";
|
|
77
|
+
if (tier === "local") return "⌂";
|
|
78
|
+
if (tier === "zdr-enforced" || tier === "zdr-policy") return "◈";
|
|
79
|
+
return "·";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function shield(theme: ThemeLike, tier: PrivacyTier): string {
|
|
83
|
+
const info = TIERS[tier];
|
|
84
|
+
const color = POSTURE_COLOR[info.posture] ?? "muted";
|
|
85
|
+
return theme.fg(color, glyphFor(tier));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Server/prefix baseline tier for a provider+model, before any live attestation.
|
|
89
|
+
function baselineTier(provider: string, id: string): PrivacyTier {
|
|
90
|
+
if (provider === "privateer") {
|
|
91
|
+
return (
|
|
92
|
+
accountBaselineTier(id) ??
|
|
93
|
+
(id.startsWith("near/") || id.startsWith("tinfoil/") ? "tee-unverified" : "standard")
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
if (provider === "tinfoil" || provider === "nearai") return "tee-unverified";
|
|
97
|
+
if (provider === "ollama") return "local";
|
|
98
|
+
return "standard";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// A row claims a TEE and is therefore worth attesting live (to go green — or to
|
|
102
|
+
// honestly drop to standard if the attestation fails). ZDR/standard rows are left
|
|
103
|
+
// on their server baseline; attesting them would only ever weaken an honest label.
|
|
104
|
+
function isTeeCandidate(row: Row): boolean {
|
|
105
|
+
if (row.tier !== "tee-verified" && row.tier !== "tee-unverified") return false;
|
|
106
|
+
if (row.provider === "privateer") return row.id.startsWith("near/") || row.id.startsWith("tinfoil/");
|
|
107
|
+
return row.provider === "tinfoil" || row.provider === "nearai";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Run the live attestation for one TEE-candidate row. privateer/* goes through the
|
|
111
|
+
// account server-proxy path (accountPosture); other providers use pi-privacy's
|
|
112
|
+
// direct client attestation. Returns undefined on any failure (keep the baseline).
|
|
113
|
+
async function attestRow(row: Row): Promise<PrivacyTier | undefined> {
|
|
114
|
+
try {
|
|
115
|
+
if (row.provider === "privateer") {
|
|
116
|
+
const res = await accountPosture(row.id);
|
|
117
|
+
return res.tier;
|
|
118
|
+
}
|
|
119
|
+
const apiKey =
|
|
120
|
+
row.provider === "nearai"
|
|
121
|
+
? process.env.NEARAI_API_KEY ?? process.env.NEAR_AI_API_KEY
|
|
122
|
+
: undefined;
|
|
123
|
+
const res = await verifyModelPosture(row.provider, row.id, { apiKey });
|
|
124
|
+
return res.tier;
|
|
125
|
+
} catch {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// The search text a row is matched against — id, provider, name, and the tier label
|
|
131
|
+
// so a user can type "tee" or "zdr" to filter by posture.
|
|
132
|
+
function searchText(row: Row): string {
|
|
133
|
+
return `${row.provider} ${row.provider}/${row.id} ${row.id} ${row.name} ${TIERS[row.tier].label}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Strongest-privacy-first, then provider, then id — so the safest options surface at
|
|
137
|
+
// the top of an unfiltered list.
|
|
138
|
+
function sortRows(rows: Row[]): Row[] {
|
|
139
|
+
return [...rows].sort((a, b) => {
|
|
140
|
+
const r = tierRank(a.tier) - tierRank(b.tier);
|
|
141
|
+
if (r !== 0) return r;
|
|
142
|
+
if (a.provider !== b.provider) return a.provider.localeCompare(b.provider);
|
|
143
|
+
return a.id.localeCompare(b.id);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const MAX_VISIBLE = 12;
|
|
148
|
+
|
|
149
|
+
class ModelsPicker extends Container {
|
|
150
|
+
private tui: TuiLike;
|
|
151
|
+
private theme: ThemeLike;
|
|
152
|
+
private rows: Row[];
|
|
153
|
+
private filtered: Row[];
|
|
154
|
+
private selectedIndex = 0;
|
|
155
|
+
private searchInput: Input;
|
|
156
|
+
private listContainer: Container;
|
|
157
|
+
private legendText: Text;
|
|
158
|
+
private blurbText: Text;
|
|
159
|
+
private currentId: string | undefined;
|
|
160
|
+
private onSelect: (row: Row) => void;
|
|
161
|
+
private onCancel: () => void;
|
|
162
|
+
private _focused = false;
|
|
163
|
+
|
|
164
|
+
constructor(opts: {
|
|
165
|
+
tui: TuiLike;
|
|
166
|
+
theme: ThemeLike;
|
|
167
|
+
rows: Row[];
|
|
168
|
+
current: ModelLike | undefined;
|
|
169
|
+
onSelect: (row: Row) => void;
|
|
170
|
+
onCancel: () => void;
|
|
171
|
+
}) {
|
|
172
|
+
super();
|
|
173
|
+
this.tui = opts.tui;
|
|
174
|
+
this.theme = opts.theme;
|
|
175
|
+
this.rows = sortRows(opts.rows);
|
|
176
|
+
this.filtered = this.rows;
|
|
177
|
+
this.currentId = opts.current ? `${opts.current.provider}/${opts.current.id}` : undefined;
|
|
178
|
+
this.onSelect = opts.onSelect;
|
|
179
|
+
this.onCancel = opts.onCancel;
|
|
180
|
+
|
|
181
|
+
const t = this.theme;
|
|
182
|
+
this.addChild(new Text(t.fg("accent", t.bold("Select a model")), 1, 0));
|
|
183
|
+
this.legendText = new Text(this.legend(), 1, 0);
|
|
184
|
+
this.addChild(this.legendText);
|
|
185
|
+
this.addChild(new Spacer(1));
|
|
186
|
+
|
|
187
|
+
this.searchInput = new Input();
|
|
188
|
+
this.searchInput.onSubmit = () => {
|
|
189
|
+
const row = this.filtered[this.selectedIndex];
|
|
190
|
+
if (row) this.onSelect(row);
|
|
191
|
+
};
|
|
192
|
+
this.addChild(this.searchInput);
|
|
193
|
+
this.addChild(new Spacer(1));
|
|
194
|
+
|
|
195
|
+
this.listContainer = new Container();
|
|
196
|
+
this.addChild(this.listContainer);
|
|
197
|
+
this.addChild(new Spacer(1));
|
|
198
|
+
|
|
199
|
+
this.blurbText = new Text("", 1, 0);
|
|
200
|
+
this.addChild(this.blurbText);
|
|
201
|
+
this.addChild(
|
|
202
|
+
new Text(
|
|
203
|
+
t.fg("muted", "↑↓ navigate ⏎ select type to search esc cancel"),
|
|
204
|
+
1,
|
|
205
|
+
0,
|
|
206
|
+
),
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
// Keep selection on the current model if it's in the list.
|
|
210
|
+
const cur = this.rows.findIndex((r) => `${r.provider}/${r.id}` === this.currentId);
|
|
211
|
+
if (cur >= 0) this.selectedIndex = cur;
|
|
212
|
+
|
|
213
|
+
this.updateList();
|
|
214
|
+
void this.attestTeeRows();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
get focused(): boolean {
|
|
218
|
+
return this._focused;
|
|
219
|
+
}
|
|
220
|
+
set focused(v: boolean) {
|
|
221
|
+
this._focused = v;
|
|
222
|
+
this.searchInput.focused = v;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private legend(): string {
|
|
226
|
+
const t = this.theme;
|
|
227
|
+
return (
|
|
228
|
+
`${shield(t, "tee-verified")} ${t.fg("muted", "TEE")} ` +
|
|
229
|
+
`${shield(t, "zdr-enforced")} ${t.fg("muted", "ZDR")} ` +
|
|
230
|
+
`${shield(t, "standard")} ${t.fg("muted", "Standard")} ` +
|
|
231
|
+
t.fg("muted", "— green = verified/enforced, yellow = claimed")
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Fire attestation for every TEE-candidate row in parallel; upgrade each shield in
|
|
236
|
+
// place as its result lands. Cheap in practice (the enabled catalog is mostly
|
|
237
|
+
// ZDR/standard), and never blocks the picker — the baseline renders immediately.
|
|
238
|
+
private async attestTeeRows(): Promise<void> {
|
|
239
|
+
const candidates = this.rows.filter((r) => isTeeCandidate(r) && !r.attested);
|
|
240
|
+
if (!candidates.length) return;
|
|
241
|
+
await Promise.allSettled(
|
|
242
|
+
candidates.map(async (row) => {
|
|
243
|
+
const tier = await attestRow(row);
|
|
244
|
+
row.attested = true;
|
|
245
|
+
if (tier && tier !== row.tier) {
|
|
246
|
+
row.tier = tier;
|
|
247
|
+
}
|
|
248
|
+
}),
|
|
249
|
+
);
|
|
250
|
+
// Re-sort (a newly-verified TEE may move up) and re-render once, preserving the
|
|
251
|
+
// highlighted model across the reshuffle.
|
|
252
|
+
const selected = this.filtered[this.selectedIndex];
|
|
253
|
+
this.rows = sortRows(this.rows);
|
|
254
|
+
this.applyFilter(this.searchInput.getValue(), selected);
|
|
255
|
+
this.legendText.setText(this.legend());
|
|
256
|
+
this.tui.requestRender();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private applyFilter(query: string, keep?: Row): void {
|
|
260
|
+
this.filtered = query
|
|
261
|
+
? fuzzyFilter(this.rows, query, (r: Row) => searchText(r))
|
|
262
|
+
: this.rows;
|
|
263
|
+
if (keep) {
|
|
264
|
+
const i = this.filtered.findIndex((r) => r.provider === keep.provider && r.id === keep.id);
|
|
265
|
+
this.selectedIndex = i >= 0 ? i : 0;
|
|
266
|
+
} else {
|
|
267
|
+
this.selectedIndex = 0;
|
|
268
|
+
}
|
|
269
|
+
this.updateList();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private updateList(): void {
|
|
273
|
+
const t = this.theme;
|
|
274
|
+
this.listContainer.clear();
|
|
275
|
+
if (this.filtered.length === 0) {
|
|
276
|
+
this.listContainer.addChild(new Text(t.fg("muted", " No matching models"), 1, 0));
|
|
277
|
+
this.blurbText.setText("");
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (this.selectedIndex >= this.filtered.length) this.selectedIndex = this.filtered.length - 1;
|
|
281
|
+
|
|
282
|
+
const start = Math.max(
|
|
283
|
+
0,
|
|
284
|
+
Math.min(this.selectedIndex - Math.floor(MAX_VISIBLE / 2), this.filtered.length - MAX_VISIBLE),
|
|
285
|
+
);
|
|
286
|
+
const end = Math.min(start + MAX_VISIBLE, this.filtered.length);
|
|
287
|
+
for (let i = start; i < end; i++) {
|
|
288
|
+
const row = this.filtered[i];
|
|
289
|
+
const isSel = i === this.selectedIndex;
|
|
290
|
+
const isCur = `${row.provider}/${row.id}` === this.currentId;
|
|
291
|
+
const mark = shield(t, row.tier);
|
|
292
|
+
const label = TIERS[row.tier].label;
|
|
293
|
+
const idText = isSel ? t.fg("accent", row.id) : t.fg("text", row.id);
|
|
294
|
+
const prov = t.fg("muted", `[${row.provider}]`);
|
|
295
|
+
const tierText = t.fg(POSTURE_COLOR[TIERS[row.tier].posture] ?? "muted", label);
|
|
296
|
+
const check = isCur ? t.fg("success", " ✓") : "";
|
|
297
|
+
const prefix = isSel ? t.fg("accent", "→ ") : " ";
|
|
298
|
+
this.listContainer.addChild(
|
|
299
|
+
new Text(`${prefix}${mark} ${idText} ${prov} ${tierText}${check}`, 1, 0),
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
if (start > 0 || end < this.filtered.length) {
|
|
303
|
+
this.listContainer.addChild(
|
|
304
|
+
new Text(t.fg("muted", ` (${this.selectedIndex + 1}/${this.filtered.length})`), 1, 0),
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
// Honest one-liner for the highlighted model — states the LIMIT of the claim.
|
|
308
|
+
const sel = this.filtered[this.selectedIndex];
|
|
309
|
+
if (sel) this.blurbText.setText(t.fg("muted", ` ${TIERS[sel.tier].blurb}`));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Seed the search box (from `/models <query>`) and filter to match.
|
|
313
|
+
setQuery(q: string): void {
|
|
314
|
+
this.searchInput.setValue(q);
|
|
315
|
+
this.applyFilter(q);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
handleInput(keyData: string): void {
|
|
319
|
+
const kb = getKeybindings();
|
|
320
|
+
if (kb.matches(keyData, "tui.select.up")) {
|
|
321
|
+
if (!this.filtered.length) return;
|
|
322
|
+
this.selectedIndex =
|
|
323
|
+
this.selectedIndex === 0 ? this.filtered.length - 1 : this.selectedIndex - 1;
|
|
324
|
+
this.updateList();
|
|
325
|
+
} else if (kb.matches(keyData, "tui.select.down")) {
|
|
326
|
+
if (!this.filtered.length) return;
|
|
327
|
+
this.selectedIndex =
|
|
328
|
+
this.selectedIndex === this.filtered.length - 1 ? 0 : this.selectedIndex + 1;
|
|
329
|
+
this.updateList();
|
|
330
|
+
} else if (kb.matches(keyData, "tui.select.confirm")) {
|
|
331
|
+
const row = this.filtered[this.selectedIndex];
|
|
332
|
+
if (row) this.onSelect(row);
|
|
333
|
+
} else if (kb.matches(keyData, "tui.select.cancel")) {
|
|
334
|
+
this.onCancel();
|
|
335
|
+
} else {
|
|
336
|
+
// Everything else edits the search box, then re-filters.
|
|
337
|
+
this.searchInput.handleInput(keyData);
|
|
338
|
+
this.applyFilter(this.searchInput.getValue());
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// The extension entrypoint. Registers `/models`; the redirect patch also routes
|
|
344
|
+
// `/model` here so the shielded picker is the single model surface.
|
|
345
|
+
export default function privateerModels(pi: {
|
|
346
|
+
registerCommand?: (name: string, opts: unknown) => void;
|
|
347
|
+
setModel?: (model: ModelLike) => Promise<boolean> | boolean;
|
|
348
|
+
}): void {
|
|
349
|
+
if (typeof pi.registerCommand !== "function") return;
|
|
350
|
+
|
|
351
|
+
pi.registerCommand("models", {
|
|
352
|
+
description: "Pick a model with its privacy posture (TEE / ZDR / standard) — searchable",
|
|
353
|
+
argumentHint: "[search]",
|
|
354
|
+
handler: async (args: string, ctx: any): Promise<void> => {
|
|
355
|
+
const ui = ctx?.ui;
|
|
356
|
+
if (!ui?.custom) {
|
|
357
|
+
ctx?.ui?.notify?.("The model picker needs an interactive terminal.", "warning");
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
const registry = ctx.modelRegistry;
|
|
361
|
+
// Refresh so a just-fetched account catalog / models.json edit is reflected.
|
|
362
|
+
try {
|
|
363
|
+
registry?.refresh?.();
|
|
364
|
+
} catch {
|
|
365
|
+
/* non-fatal */
|
|
366
|
+
}
|
|
367
|
+
// Make sure the account baseline tiers are loaded (first-open may race the
|
|
368
|
+
// provider's background fetch). Best-effort; falls back to prefix heuristics.
|
|
369
|
+
if (!accountCatalogLoaded()) {
|
|
370
|
+
try {
|
|
371
|
+
await fetchAccountCatalog();
|
|
372
|
+
} catch {
|
|
373
|
+
/* keep heuristics */
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
let available: ModelLike[] = [];
|
|
378
|
+
try {
|
|
379
|
+
available = (await registry.getAvailable()) as ModelLike[];
|
|
380
|
+
} catch (e) {
|
|
381
|
+
ui.notify?.(`Couldn't list models: ${(e as Error).message}`, "error");
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
if (!available.length) {
|
|
385
|
+
ui.notify?.("No models available. Use /login to add a provider.", "warning");
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const rows: Row[] = available.map((m) => ({
|
|
390
|
+
provider: m.provider,
|
|
391
|
+
id: m.id,
|
|
392
|
+
name: m.name ?? m.id,
|
|
393
|
+
model: m,
|
|
394
|
+
tier: baselineTier(m.provider, m.id),
|
|
395
|
+
}));
|
|
396
|
+
|
|
397
|
+
const initialQuery = String(args ?? "").trim();
|
|
398
|
+
const chosen: Row | undefined = await ui.custom(
|
|
399
|
+
(tui: TuiLike, theme: ThemeLike, _kb: unknown, close: (result?: Row) => void) => {
|
|
400
|
+
const picker = new ModelsPicker({
|
|
401
|
+
tui,
|
|
402
|
+
theme,
|
|
403
|
+
rows,
|
|
404
|
+
current: ctx.model,
|
|
405
|
+
onSelect: (row) => close(row),
|
|
406
|
+
onCancel: () => close(undefined),
|
|
407
|
+
});
|
|
408
|
+
if (initialQuery) picker.setQuery(initialQuery);
|
|
409
|
+
return picker;
|
|
410
|
+
},
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
if (!chosen) return;
|
|
414
|
+
try {
|
|
415
|
+
const ok = await pi.setModel!(chosen.model);
|
|
416
|
+
if (ok === false) {
|
|
417
|
+
ui.notify?.(`No API key for ${chosen.provider}/${chosen.id}.`, "warning");
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
ui.notify?.(
|
|
421
|
+
`Model: ${chosen.provider}/${chosen.id} · ${TIERS[chosen.tier].label}`,
|
|
422
|
+
"info",
|
|
423
|
+
);
|
|
424
|
+
} catch (e) {
|
|
425
|
+
ui.notify?.(`Couldn't switch model: ${(e as Error).message}`, "error");
|
|
426
|
+
}
|
|
427
|
+
},
|
|
428
|
+
});
|
|
429
|
+
}
|
|
@@ -8,31 +8,28 @@
|
|
|
8
8
|
|
|
9
9
|
import { verifyModelPosture, TIERS, type PrivacyTier } from "pi-privacy";
|
|
10
10
|
import { accountPosture } from "../src/providers/account.ts";
|
|
11
|
+
import { type Palette, paletteFor } from "../src/ui/palette.ts";
|
|
11
12
|
|
|
12
13
|
const DOT: Record<string, string> = { green: "🟢", yellow: "🟡", red: "🔴", neutral: "⚪" };
|
|
13
14
|
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// (
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
// privateer badge and swap the dot for a shield). Everything else keeps the dot.
|
|
22
|
-
function badgeLabel(tier: PrivacyTier): string | null {
|
|
23
|
-
if (tier === "tee-verified") return `${GREEN}⛉ Trusted Execution${RESET}`;
|
|
24
|
-
if (tier === "tee-unverified") return `${YELLOW}⛉ Trusted Execution (unconfirmed)${RESET}`;
|
|
15
|
+
// The shield "references the previous color": the TEE tiers show a shield tinted like the
|
|
16
|
+
// old traffic-light dot (green = verified, yellow = unconfirmed). The colours come from
|
|
17
|
+
// the active theme (paletteFor) so the badge stays legible on a light terminal too — a
|
|
18
|
+
// bare "\x1b[33m" yellow washes out on white. The status bar renders these escapes.
|
|
19
|
+
function badgeLabel(tier: PrivacyTier, p: Palette): string | null {
|
|
20
|
+
if (tier === "tee-verified") return `${p.GREEN}⛉ Trusted Execution${p.RESET}`;
|
|
21
|
+
if (tier === "tee-unverified") return `${p.YELLOW}⛉ Trusted Execution (unconfirmed)${p.RESET}`;
|
|
25
22
|
return null;
|
|
26
23
|
}
|
|
27
24
|
|
|
28
|
-
async function badgeFor(provider: string, modelId: string): Promise<string> {
|
|
25
|
+
async function badgeFor(provider: string, modelId: string, p: Palette): Promise<string> {
|
|
29
26
|
const res =
|
|
30
27
|
provider === "privateer"
|
|
31
28
|
? await accountPosture(modelId)
|
|
32
29
|
: await verifyModelPosture(provider, modelId, {
|
|
33
30
|
apiKey: provider === "nearai" ? process.env.NEARAI_API_KEY ?? process.env.NEAR_AI_API_KEY : undefined,
|
|
34
31
|
});
|
|
35
|
-
const shield = badgeLabel(res.tier as PrivacyTier);
|
|
32
|
+
const shield = badgeLabel(res.tier as PrivacyTier, p);
|
|
36
33
|
if (shield) return shield;
|
|
37
34
|
const info = TIERS[res.tier as PrivacyTier];
|
|
38
35
|
return `${DOT[info.posture] ?? "⚪"} ${info.label}`;
|
|
@@ -46,7 +43,7 @@ export default function privateerPosture(pi: any): void {
|
|
|
46
43
|
const mine = ++seq;
|
|
47
44
|
try {
|
|
48
45
|
ctx.ui.setStatus("privacy", "⛉ …"); // immediate placeholder while attesting
|
|
49
|
-
const badge = await badgeFor(provider, modelId);
|
|
46
|
+
const badge = await badgeFor(provider, modelId, paletteFor(ctx?.ui?.theme));
|
|
50
47
|
if (mine === seq) ctx.ui.setStatus("privacy", badge);
|
|
51
48
|
} catch {
|
|
52
49
|
if (mine === seq) ctx.ui.setStatus("privacy", undefined);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "privateer-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Privateer — a provider-agnostic, safe-by-default terminal coding agent with TEE/Tinfoil attestation, rebuilt on the Pi toolkit. Bring your own model across 20 providers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
"bin",
|
|
38
38
|
"src",
|
|
39
39
|
"extensions",
|
|
40
|
+
"patches",
|
|
40
41
|
"README.md",
|
|
41
42
|
"LICENSE"
|
|
42
43
|
],
|
|
@@ -46,7 +47,8 @@
|
|
|
46
47
|
"channels": "node --env-file=.env --import tsx src/channels/run.ts",
|
|
47
48
|
"dev": "tsx watch src/main.ts",
|
|
48
49
|
"typecheck": "tsc --noEmit",
|
|
49
|
-
"test": "for f in tests/*.test.ts; do node --import tsx --test \"$f\" || exit 1; done"
|
|
50
|
+
"test": "for f in tests/*.test.ts; do node --import tsx --test \"$f\" || exit 1; done",
|
|
51
|
+
"postinstall": "patch-package"
|
|
50
52
|
},
|
|
51
53
|
"engines": {
|
|
52
54
|
"node": ">=22.19.0"
|
|
@@ -60,11 +62,12 @@
|
|
|
60
62
|
"@noble/ciphers": "^2.1.1",
|
|
61
63
|
"@noble/curves": "^1.9.7",
|
|
62
64
|
"@noble/hashes": "^1.7.1",
|
|
65
|
+
"patch-package": "^8.0.1",
|
|
63
66
|
"pi-mcp-adapter": "^2.11.0",
|
|
64
67
|
"pi-privacy": "^0.3.0",
|
|
65
68
|
"pi-subagents": "^0.34.0",
|
|
66
|
-
"privateer-workflow": "^0.1.0",
|
|
67
69
|
"picomatch": "^4.0.4",
|
|
70
|
+
"privateer-workflow": "^0.1.0",
|
|
68
71
|
"tsx": "^4.16.0",
|
|
69
72
|
"typebox": "^1.3.4",
|
|
70
73
|
"undici": "^7.28.0",
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
|
|
2
|
+
index 5d65200..a997ad7 100644
|
|
3
|
+
--- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
|
|
4
|
+
+++ b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
|
|
5
|
+
@@ -2042,7 +2042,17 @@ export class InteractiveMode {
|
|
6
|
+
if (text === "/model" || text.startsWith("/model ")) {
|
|
7
|
+
const searchTerm = text.startsWith("/model ") ? text.slice(7).trim() : undefined;
|
|
8
|
+
this.editor.setText("");
|
|
9
|
+
- await this.handleModelCommand(searchTerm);
|
|
10
|
+
+ // Privateer redirect: when the shielded picker extension is loaded
|
|
11
|
+
+ // (registers `/models`), route `/model` to it so users get the
|
|
12
|
+
+ // privacy-posture picker via muscle memory. Falls back to Pi's
|
|
13
|
+
+ // built-in picker when the extension isn't present.
|
|
14
|
+
+ const privateerModels = searchTerm ? `/models ${searchTerm}` : "/models";
|
|
15
|
+
+ if (this.isExtensionCommand(privateerModels)) {
|
|
16
|
+
+ await this.session.prompt(privateerModels);
|
|
17
|
+
+ }
|
|
18
|
+
+ else {
|
|
19
|
+
+ await this.handleModelCommand(searchTerm);
|
|
20
|
+
+ }
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (text === "/export" || text.startsWith("/export ")) {
|
package/src/cli/chat.ts
CHANGED
|
@@ -10,9 +10,14 @@
|
|
|
10
10
|
|
|
11
11
|
import "../boot.ts"; // env + attestation dispatcher, before any Pi import
|
|
12
12
|
import { fileURLToPath } from "node:url"; // builtin, safe pre-boot
|
|
13
|
+
import { cliPalette } from "../ui/palette.ts"; // no Pi deps → safe pre-boot
|
|
13
14
|
import type { GateController } from "../ext/permissionGate.ts"; // type-only → erased, safe pre-boot
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
// This lean REPL has no Pi TUI (and so no Theme), so it detects the terminal background
|
|
17
|
+
// itself (COLORFGBG) and picks a palette — on a light terminal the standard "\x1b[33m"
|
|
18
|
+
// yellow / "\x1b[36m" cyan and faint "\x1b[2m" dim wash out, so cliPalette swaps in dark
|
|
19
|
+
// 256-colour indices there. On a dark terminal it's the same named colours as before.
|
|
20
|
+
const { RESET, DIM, CYAN, YELLOW, RED, GREEN } = cliPalette();
|
|
16
21
|
|
|
17
22
|
async function main() {
|
|
18
23
|
const readline = await import("node:readline");
|
|
@@ -34,7 +39,7 @@ async function main() {
|
|
|
34
39
|
const priv = await import("../auth/privateer.ts");
|
|
35
40
|
const { makeAccountProvider, accountPosture } = await import("../providers/account.ts");
|
|
36
41
|
const { agentVersion } = await import("../config/version.ts");
|
|
37
|
-
const { resolveDefaultModel } = await import("../providers/defaultModel.ts");
|
|
42
|
+
const { resolveDefaultModel, resolveSignedInModel } = await import("../providers/defaultModel.ts");
|
|
38
43
|
|
|
39
44
|
// resolveDefaultModel() already honours PRIVATEER_MODEL first, then the account
|
|
40
45
|
// default when signed in, then a BYO key — one source of truth (defaultModel.ts).
|
|
@@ -470,6 +475,14 @@ async function main() {
|
|
|
470
475
|
},
|
|
471
476
|
});
|
|
472
477
|
console.log(`${GREEN}Signed in as ${user.email ?? user.id}.${RESET}`);
|
|
478
|
+
// Move the live session onto a confidential model right away, so the next prompt
|
|
479
|
+
// doesn't dead-end on the keyless launch model ("No API key found for openrouter").
|
|
480
|
+
// resolveSignedInModel prefers Tinfoil GLM 5.2, else the account's NEAR channel;
|
|
481
|
+
// PRIVATEER_MODEL (a deliberate override) is respected and left alone.
|
|
482
|
+
if (!process.env.PRIVATEER_MODEL?.trim()) {
|
|
483
|
+
const target = resolveSignedInModel();
|
|
484
|
+
if (target !== currentSpec) await switchModel(target, false);
|
|
485
|
+
}
|
|
473
486
|
} catch (e) {
|
|
474
487
|
console.log(`${RED}${(e as Error).message}${RESET}`);
|
|
475
488
|
}
|