privateer-agent 0.5.0 → 0.5.1
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-tui
CHANGED
|
@@ -58,7 +58,7 @@ mkdir -p "$EXT_DIR"
|
|
|
58
58
|
# ABSOLUTE path so the target's own relative imports resolve from the repo (a plain
|
|
59
59
|
# symlink would resolve them relative to the shim's location and break). We remove
|
|
60
60
|
# any shim we previously managed first, so a dropped package can't linger and reload.
|
|
61
|
-
MANAGED="privateer-brand privateer-context privateer-gate privateer-account privateer-posture privateer-tools privateer-privacy pi-privacy pi-web-access rpiv-web-tools pi-mcp-adapter pi-hypa pi-subagents"
|
|
61
|
+
MANAGED="privateer-brand privateer-context privateer-gate privateer-account privateer-models privateer-posture privateer-tools privateer-privacy pi-privacy pi-web-access rpiv-web-tools pi-mcp-adapter pi-hypa pi-subagents"
|
|
62
62
|
for name in $MANAGED; do rm -f "$EXT_DIR/$name.ts"; done
|
|
63
63
|
shim() { printf 'export { default } from "%s";\n' "$2" > "$EXT_DIR/$1.ts"; }
|
|
64
64
|
# Branding + the account sign-in surface (banner, ⚓ badge, /signin /signout).
|
|
@@ -67,6 +67,10 @@ shim privateer-brand "$REPO/extensions/privateer-brand.ts"
|
|
|
67
67
|
shim privateer-context "$REPO/extensions/privateer-context.ts"
|
|
68
68
|
shim privateer-gate "$REPO/extensions/privateer-gate.ts"
|
|
69
69
|
shim privateer-account "$REPO/extensions/privateer-account.ts"
|
|
70
|
+
# The /models picker — searchable model selector with per-row privacy shields
|
|
71
|
+
# (TEE / ZDR / standard). Pi's built-in /model is redirected here by the
|
|
72
|
+
# patch-package patch (patches/@earendil-works+pi-coding-agent+*.patch).
|
|
73
|
+
shim privateer-models "$REPO/extensions/privateer-models.ts"
|
|
70
74
|
shim privateer-posture "$REPO/extensions/privateer-posture.ts"
|
|
71
75
|
shim privateer-tools "$REPO/extensions/privateer-tools.ts"
|
|
72
76
|
# pi-privacy wrapped with the account-channel tier resolver (privateer/near… = TEE),
|
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "privateer-agent",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.1",
|
|
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/providers/account.ts
CHANGED
|
@@ -47,20 +47,88 @@ function seedModel(id: string) {
|
|
|
47
47
|
};
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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)
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
203
|
-
|
|
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
|
});
|