privateer-agent 0.1.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/LICENSE +21 -0
- package/README.md +474 -0
- package/bin/privateer.mjs +11 -0
- package/package.json +74 -0
- package/src/agents/loader.ts +49 -0
- package/src/auth/privateer.ts +393 -0
- package/src/commands/custom.ts +75 -0
- package/src/commands/registry.ts +499 -0
- package/src/components/AgentGroupView.tsx +104 -0
- package/src/components/App.tsx +1376 -0
- package/src/components/ApprovalPrompt.tsx +38 -0
- package/src/components/Banner.tsx +58 -0
- package/src/components/Markdown.tsx +183 -0
- package/src/components/ModeHint.tsx +40 -0
- package/src/components/ModelPicker.tsx +269 -0
- package/src/components/Onboarding.tsx +203 -0
- package/src/components/PlanConfirm.tsx +37 -0
- package/src/components/PrivateerLogin.tsx +109 -0
- package/src/components/PromptInput.tsx +602 -0
- package/src/components/RewindPicker.tsx +69 -0
- package/src/components/Root.tsx +95 -0
- package/src/components/SessionPicker.tsx +64 -0
- package/src/components/StatusBar.tsx +121 -0
- package/src/components/TodoPanel.tsx +36 -0
- package/src/components/ToolCallView.tsx +109 -0
- package/src/components/Transcript.tsx +203 -0
- package/src/components/figures.ts +13 -0
- package/src/components/promptModel.ts +73 -0
- package/src/components/spinnerVerbs.ts +46 -0
- package/src/components/theme.ts +55 -0
- package/src/components/types.ts +34 -0
- package/src/components/useTeeShield.ts +104 -0
- package/src/components/useTerminalWidth.ts +24 -0
- package/src/components/useZdrShield.ts +126 -0
- package/src/config/load.ts +115 -0
- package/src/config/paths.ts +61 -0
- package/src/config/schema.ts +94 -0
- package/src/context/outputStyles.ts +42 -0
- package/src/context/projectInfo.ts +59 -0
- package/src/context/systemPrompt.ts +167 -0
- package/src/engine/QueryEngine.ts +399 -0
- package/src/engine/errors.ts +197 -0
- package/src/engine/events.ts +74 -0
- package/src/engine/router.ts +165 -0
- package/src/hooks/engine.ts +155 -0
- package/src/main.tsx +167 -0
- package/src/mcp/client.ts +236 -0
- package/src/mcp/oauth.ts +245 -0
- package/src/memory/auto.ts +146 -0
- package/src/memory/checkpoints.ts +227 -0
- package/src/memory/store.ts +127 -0
- package/src/permissions/danger.ts +56 -0
- package/src/permissions/gate.ts +38 -0
- package/src/permissions/mode.ts +39 -0
- package/src/permissions/protected.ts +29 -0
- package/src/permissions/uiGate.ts +73 -0
- package/src/providers/attestation.ts +149 -0
- package/src/providers/capabilities.ts +104 -0
- package/src/providers/catalog.ts +66 -0
- package/src/providers/models.ts +183 -0
- package/src/providers/registry.ts +71 -0
- package/src/providers/resolve.ts +78 -0
- package/src/remote/relayClient.ts +283 -0
- package/src/session.ts +264 -0
- package/src/tools/bash.ts +98 -0
- package/src/tools/context.ts +114 -0
- package/src/tools/edit.ts +67 -0
- package/src/tools/exec.ts +60 -0
- package/src/tools/glob.ts +39 -0
- package/src/tools/grep.ts +86 -0
- package/src/tools/index.ts +69 -0
- package/src/tools/memory.ts +53 -0
- package/src/tools/processRegistry.ts +77 -0
- package/src/tools/read.ts +42 -0
- package/src/tools/saveAttachment.ts +53 -0
- package/src/tools/task.ts +52 -0
- package/src/tools/todo.ts +36 -0
- package/src/tools/todoStore.ts +31 -0
- package/src/tools/walk.ts +44 -0
- package/src/tools/web.ts +145 -0
- package/src/tools/write.ts +40 -0
- package/src/util/attachmentStore.ts +72 -0
- package/src/util/images.ts +343 -0
- package/src/util/limit.ts +32 -0
- package/src/util/redact.ts +44 -0
- package/src/version.ts +13 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Pure logic behind the prompt input: mode detection and autocomplete matching.
|
|
2
|
+
// Kept free of Ink so it can be unit-tested directly.
|
|
3
|
+
|
|
4
|
+
export type InputMode = "bash" | "memory" | "command" | "prompt";
|
|
5
|
+
|
|
6
|
+
// A leading "/" only starts a slash command when the first word looks like a
|
|
7
|
+
// command name — no path separators or dots. This lets absolute file paths like
|
|
8
|
+
// "/Users/me/shot.png" be typed as a normal prompt (and attached as an image)
|
|
9
|
+
// instead of being mistaken for a command. A bare "/" still opens the menu.
|
|
10
|
+
export function isSlashCommand(value: string): boolean {
|
|
11
|
+
if (value[0] !== "/") return false;
|
|
12
|
+
const firstWord = value.slice(1).split(/\s/, 1)[0];
|
|
13
|
+
// Command names are strictly alphanumeric (with - and _); anything else in the
|
|
14
|
+
// first word — a "/", ".", "\", etc. — means it's a path, not a command.
|
|
15
|
+
return firstWord.length === 0 || /^[A-Za-z0-9_-]+$/.test(firstWord);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// What the leading character of the buffer means. `!` shells out, `#` appends to
|
|
19
|
+
// project memory, `/` runs a slash command, anything else is a model prompt.
|
|
20
|
+
export function detectMode(value: string): InputMode {
|
|
21
|
+
if (value[0] === "!") return "bash";
|
|
22
|
+
if (value[0] === "#") return "memory";
|
|
23
|
+
if (isSlashCommand(value)) return "command";
|
|
24
|
+
return "prompt";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// The command-name fragment being typed, when the cursor sits within the first
|
|
28
|
+
// word of a leading "/command". Null once the user moves on to typing arguments.
|
|
29
|
+
export function slashQuery(value: string, cursor: number): string | null {
|
|
30
|
+
if (!isSlashCommand(value)) return null;
|
|
31
|
+
const firstSpace = value.indexOf(" ");
|
|
32
|
+
const nameEnd = firstSpace === -1 ? value.length : firstSpace;
|
|
33
|
+
if (cursor > nameEnd) return null;
|
|
34
|
+
return value.slice(1, nameEnd);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The @-mention token under the cursor: the '@' index plus the text typed after
|
|
38
|
+
// it. Requires the '@' to start a word (so emails like a@b don't trigger it).
|
|
39
|
+
export function mentionAt(value: string, cursor: number): { start: number; query: string } | null {
|
|
40
|
+
let i = cursor;
|
|
41
|
+
while (i > 0 && !/\s/.test(value[i - 1])) i--;
|
|
42
|
+
if (value[i] !== "@") return null;
|
|
43
|
+
return { start: i, query: value.slice(i + 1, cursor) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface CommandItem {
|
|
47
|
+
name: string;
|
|
48
|
+
summary: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function filterCommands(all: CommandItem[], query: string): CommandItem[] {
|
|
52
|
+
const q = query.toLowerCase();
|
|
53
|
+
return all.filter((c) => c.name.toLowerCase().startsWith(q));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Substring file match, ranked: basename hits beat path hits, earlier and shorter
|
|
57
|
+
// paths win. Keeps the menu small and relevant on large trees.
|
|
58
|
+
export function filterFiles(all: string[], query: string, limit: number): string[] {
|
|
59
|
+
const q = query.toLowerCase();
|
|
60
|
+
if (!q) return all.slice(0, limit);
|
|
61
|
+
const scored: { f: string; score: number }[] = [];
|
|
62
|
+
for (const f of all) {
|
|
63
|
+
const lower = f.toLowerCase();
|
|
64
|
+
const idx = lower.indexOf(q);
|
|
65
|
+
if (idx === -1) continue;
|
|
66
|
+
const base = lower.slice(lower.lastIndexOf("/") + 1);
|
|
67
|
+
const baseIdx = base.indexOf(q);
|
|
68
|
+
const score = (baseIdx === -1 ? 1000 : baseIdx) + idx * 0.01 + f.length * 0.001;
|
|
69
|
+
scored.push({ f, score });
|
|
70
|
+
}
|
|
71
|
+
scored.sort((a, b) => a.score - b.score);
|
|
72
|
+
return scored.slice(0, limit).map((s) => s.f);
|
|
73
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// A set of gerund "thinking" verbs shown beside the spinner while a turn runs.
|
|
2
|
+
// One is chosen at random per turn.
|
|
3
|
+
const VERBS = [
|
|
4
|
+
"Cogitating",
|
|
5
|
+
"Pondering",
|
|
6
|
+
"Brewing",
|
|
7
|
+
"Conjuring",
|
|
8
|
+
"Forging",
|
|
9
|
+
"Wrangling",
|
|
10
|
+
"Synthesizing",
|
|
11
|
+
"Percolating",
|
|
12
|
+
"Ruminating",
|
|
13
|
+
"Noodling",
|
|
14
|
+
"Computing",
|
|
15
|
+
"Crafting",
|
|
16
|
+
"Composing",
|
|
17
|
+
"Deliberating",
|
|
18
|
+
"Tinkering",
|
|
19
|
+
"Hatching",
|
|
20
|
+
"Incubating",
|
|
21
|
+
"Marinating",
|
|
22
|
+
"Musing",
|
|
23
|
+
"Orchestrating",
|
|
24
|
+
"Puzzling",
|
|
25
|
+
"Scheming",
|
|
26
|
+
"Simmering",
|
|
27
|
+
"Spelunking",
|
|
28
|
+
"Stewing",
|
|
29
|
+
"Summoning",
|
|
30
|
+
"Unfurling",
|
|
31
|
+
"Whirring",
|
|
32
|
+
"Working",
|
|
33
|
+
"Wibbling",
|
|
34
|
+
"Manifesting",
|
|
35
|
+
"Channeling",
|
|
36
|
+
"Concocting",
|
|
37
|
+
"Crunching",
|
|
38
|
+
"Divining",
|
|
39
|
+
"Finagling",
|
|
40
|
+
"Mulling",
|
|
41
|
+
"Plotting",
|
|
42
|
+
"Reticulating",
|
|
43
|
+
"Transmuting",
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
export const randomVerb = (): string => VERBS[Math.floor(Math.random() * VERBS.length)];
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { PermissionMode } from "../config/schema.ts";
|
|
2
|
+
import type { ZdrPosture } from "../providers/models.ts";
|
|
3
|
+
|
|
4
|
+
// Single source of truth for TUI color. Privateer's navy/white identity drives the
|
|
5
|
+
// accent: it marks bullets, the prompt prefix, headings, and the active permission
|
|
6
|
+
// mode. Text is the terminal default (white on dark); metadata is dimmed gray.
|
|
7
|
+
//
|
|
8
|
+
// Note on navy: a true navy (#1e3a5f) is too dark to read on dark terminals, so the
|
|
9
|
+
// accent is a brighter navy/indigo. It's one knob — tune it here and the whole UI
|
|
10
|
+
// follows.
|
|
11
|
+
export const theme = {
|
|
12
|
+
accent: "#5c7cfa", // Privateer navy/indigo — the single accent hue
|
|
13
|
+
accentDim: "#3b5b8c",
|
|
14
|
+
text: undefined as string | undefined, // terminal default
|
|
15
|
+
dim: "gray",
|
|
16
|
+
success: "green",
|
|
17
|
+
error: "red",
|
|
18
|
+
warning: "yellow",
|
|
19
|
+
diffAdded: "green",
|
|
20
|
+
diffRemoved: "red",
|
|
21
|
+
} as const;
|
|
22
|
+
|
|
23
|
+
// Permission-mode accent colors (moved here from StatusBar so color lives in one place).
|
|
24
|
+
export const MODE_COLOR: Record<PermissionMode, string> = {
|
|
25
|
+
default: theme.warning,
|
|
26
|
+
acceptEdits: theme.success,
|
|
27
|
+
bypass: theme.error,
|
|
28
|
+
plan: theme.accent,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// OpenRouter ZDR posture colors — green enforced, yellow available, red retained.
|
|
32
|
+
// Shared by the status-bar shield and the per-model badges in the picker.
|
|
33
|
+
export const POSTURE_COLOR: Record<ZdrPosture, string> = {
|
|
34
|
+
green: theme.success,
|
|
35
|
+
yellow: theme.warning,
|
|
36
|
+
red: theme.error,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// Capitalized tool display names: read → Read, web_fetch → WebFetch.
|
|
40
|
+
const TOOL_DISPLAY: Record<string, string> = {
|
|
41
|
+
read: "Read",
|
|
42
|
+
write: "Write",
|
|
43
|
+
edit: "Edit",
|
|
44
|
+
glob: "Glob",
|
|
45
|
+
grep: "Grep",
|
|
46
|
+
bash: "Bash",
|
|
47
|
+
bash_output: "BashOutput",
|
|
48
|
+
kill_shell: "KillShell",
|
|
49
|
+
todo: "TodoWrite",
|
|
50
|
+
task: "Task",
|
|
51
|
+
web_fetch: "WebFetch",
|
|
52
|
+
web_search: "WebSearch",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const toolDisplayName = (name: string): string => TOOL_DISPLAY[name] ?? name;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// One rendered line/block in the conversation transcript.
|
|
2
|
+
export type ToolStatus = "running" | "done" | "error";
|
|
3
|
+
|
|
4
|
+
export interface ToolEntry {
|
|
5
|
+
kind: "tool";
|
|
6
|
+
id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
input: unknown;
|
|
9
|
+
status: ToolStatus;
|
|
10
|
+
output?: string;
|
|
11
|
+
error?: string;
|
|
12
|
+
// Set only on `task` calls: the sub-agent's short description, its type (undefined =
|
|
13
|
+
// the default read-only explorer), and run metrics filled in when it finishes. Drives
|
|
14
|
+
// the grouped "N agents finished" rendering.
|
|
15
|
+
agent?: { description: string; subagentType?: string; toolUses?: number; tokens?: number };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type Entry =
|
|
19
|
+
| { kind: "user"; text: string }
|
|
20
|
+
| { kind: "assistant"; text: string }
|
|
21
|
+
| { kind: "thinking"; text: string }
|
|
22
|
+
| ToolEntry
|
|
23
|
+
// `hint` is an optional actionable line rendered dim beneath the notice
|
|
24
|
+
// (used by error notices to suggest a next step).
|
|
25
|
+
| { kind: "notice"; text: string; tone?: "info" | "error"; hint?: string };
|
|
26
|
+
|
|
27
|
+
// A render-time row: either a single transcript entry, or a run of two-or-more
|
|
28
|
+
// concurrent `task` sub-agents collapsed into one grouped block. Grouping happens at
|
|
29
|
+
// render time (see groupRows) so the underlying transcript stays a flat entry list.
|
|
30
|
+
export interface AgentGroupRow {
|
|
31
|
+
kind: "agent-group";
|
|
32
|
+
agents: ToolEntry[];
|
|
33
|
+
}
|
|
34
|
+
export type Row = Entry | AgentGroupRow;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import type { Config } from "../config/schema.ts";
|
|
3
|
+
import { parseModelSpec, privateerChannel } from "../providers/resolve.ts";
|
|
4
|
+
import {
|
|
5
|
+
fetchAttestation,
|
|
6
|
+
fetchAttestationViaServer,
|
|
7
|
+
teePosture,
|
|
8
|
+
type Attestation,
|
|
9
|
+
type TeePosture,
|
|
10
|
+
} from "../providers/attestation.ts";
|
|
11
|
+
|
|
12
|
+
// What the status-bar shield should render for a TEE attestation. Distinct from
|
|
13
|
+
// posture so a dim "needs a key / unknown" affordance never implies a verdict.
|
|
14
|
+
export type TeeState =
|
|
15
|
+
| { kind: "hidden" } // not a TEE-backed model — no badge
|
|
16
|
+
| { kind: "no-key" } // NEAR AI (BYO key) selected but no API key to attest with
|
|
17
|
+
| { kind: "loading" } // fetching the attestation report
|
|
18
|
+
| { kind: "error" } // network / timeout / HTTP failure
|
|
19
|
+
| { kind: "ready"; posture: TeePosture; attestation: Attestation };
|
|
20
|
+
|
|
21
|
+
// Attestations are per-model (each model has its own enclave + signing key), so we
|
|
22
|
+
// cache the in-flight/resolved promise per (apiKey, baseURL, modelId) and reuse it
|
|
23
|
+
// for the session. Concurrent consumers dedupe to one fetch; a rejection is evicted
|
|
24
|
+
// so a later selection can retry rather than being stuck in error forever.
|
|
25
|
+
const cache = new Map<string, Promise<Attestation>>();
|
|
26
|
+
|
|
27
|
+
function loadAttestation(apiKey: string, baseURL: string | undefined, modelId: string): Promise<Attestation> {
|
|
28
|
+
const key = `${apiKey}::${baseURL ?? ""}::${modelId}`;
|
|
29
|
+
let pending = cache.get(key);
|
|
30
|
+
if (!pending) {
|
|
31
|
+
pending = fetchAttestation({ apiKey, baseURL }, modelId).catch((err) => {
|
|
32
|
+
cache.delete(key);
|
|
33
|
+
throw err;
|
|
34
|
+
});
|
|
35
|
+
cache.set(key, pending);
|
|
36
|
+
}
|
|
37
|
+
return pending;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Account-billed `privateer:near/*` models attest through the Privateer server
|
|
41
|
+
// proxy (the NEAR key stays server-side), so they cache by model id alone.
|
|
42
|
+
const serverCache = new Map<string, Promise<Attestation>>();
|
|
43
|
+
|
|
44
|
+
function loadServerAttestation(modelId: string): Promise<Attestation> {
|
|
45
|
+
let pending = serverCache.get(modelId);
|
|
46
|
+
if (!pending) {
|
|
47
|
+
pending = fetchAttestationViaServer(modelId).catch((err) => {
|
|
48
|
+
serverCache.delete(modelId);
|
|
49
|
+
throw err;
|
|
50
|
+
});
|
|
51
|
+
serverCache.set(modelId, pending);
|
|
52
|
+
}
|
|
53
|
+
return pending;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Resolve the TEE shield state for the currently selected model. Two paths trigger
|
|
57
|
+
// a fetch: BYO `nearai:*` models (direct gateway, needs a key) and account-billed
|
|
58
|
+
// `privateer:near/*` models (server proxy, uses the logged-in session). Everything
|
|
59
|
+
// else returns "hidden" before touching the network.
|
|
60
|
+
export function useTeeShield(modelSpec: string, config: Config): TeeState {
|
|
61
|
+
let provider = "";
|
|
62
|
+
let modelId = "";
|
|
63
|
+
try {
|
|
64
|
+
({ provider, modelId } = parseModelSpec(modelSpec));
|
|
65
|
+
} catch {
|
|
66
|
+
provider = "";
|
|
67
|
+
modelId = "";
|
|
68
|
+
}
|
|
69
|
+
const cfg = config.providers.nearai ?? {};
|
|
70
|
+
const isNearai = provider === "nearai";
|
|
71
|
+
const isPrivateerTee = provider === "privateer" && privateerChannel(modelId) === "tee";
|
|
72
|
+
const apiKey = isNearai ? cfg.apiKey : undefined;
|
|
73
|
+
const baseURL = cfg.baseURL;
|
|
74
|
+
|
|
75
|
+
const [state, setState] = useState<TeeState>({ kind: "hidden" });
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (!isNearai && !isPrivateerTee) {
|
|
79
|
+
setState({ kind: "hidden" });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (isNearai && !apiKey) {
|
|
83
|
+
setState({ kind: "no-key" });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
let ignore = false;
|
|
87
|
+
setState({ kind: "loading" });
|
|
88
|
+
const pending = isPrivateerTee
|
|
89
|
+
? loadServerAttestation(modelId)
|
|
90
|
+
: loadAttestation(apiKey!, baseURL, modelId);
|
|
91
|
+
pending
|
|
92
|
+
.then((attestation) => {
|
|
93
|
+
if (!ignore) setState({ kind: "ready", posture: teePosture(attestation), attestation });
|
|
94
|
+
})
|
|
95
|
+
.catch(() => {
|
|
96
|
+
if (!ignore) setState({ kind: "error" });
|
|
97
|
+
});
|
|
98
|
+
return () => {
|
|
99
|
+
ignore = true;
|
|
100
|
+
};
|
|
101
|
+
}, [isNearai, isPrivateerTee, apiKey, baseURL, modelId]);
|
|
102
|
+
|
|
103
|
+
return state;
|
|
104
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { useStdout } from "ink";
|
|
3
|
+
|
|
4
|
+
// Current terminal column count, re-read on every resize.
|
|
5
|
+
//
|
|
6
|
+
// The dynamic region below <Static> is erased and redrawn on each render. Ink
|
|
7
|
+
// counts the previous frame by its newlines, so any line that soft-wraps — or
|
|
8
|
+
// that the terminal reflows when dragged narrower — desyncs the erase and leaves
|
|
9
|
+
// stale copies in the scrollback (the classic stack of duplicated status bars).
|
|
10
|
+
// Subscribing to resize lets those lines re-truncate to the new width and stay a
|
|
11
|
+
// single row, which keeps Ink's erase count correct.
|
|
12
|
+
export function useTerminalWidth(): number {
|
|
13
|
+
const { stdout } = useStdout();
|
|
14
|
+
const [cols, setCols] = useState(stdout?.columns ?? 80);
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (!stdout) return;
|
|
17
|
+
const onResize = () => setCols(stdout.columns ?? 80);
|
|
18
|
+
stdout.on("resize", onResize);
|
|
19
|
+
return () => {
|
|
20
|
+
stdout.off("resize", onResize);
|
|
21
|
+
};
|
|
22
|
+
}, [stdout]);
|
|
23
|
+
return cols;
|
|
24
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import type { Config, ProviderName } from "../config/schema.ts";
|
|
3
|
+
import { parseModelSpec, privateerChannel } from "../providers/resolve.ts";
|
|
4
|
+
import { fetchZdrAccount, zdrPosture, type ZdrAccountData, type ZdrPosture } from "../providers/models.ts";
|
|
5
|
+
|
|
6
|
+
// What the status-bar shield should render. Distinct from posture so we can show a
|
|
7
|
+
// dim "needs a key / unknown" affordance without ever implying a colored verdict.
|
|
8
|
+
export type ZdrState =
|
|
9
|
+
| { kind: "hidden" } // not a ZDR-backed model — no badge
|
|
10
|
+
| { kind: "no-key" } // OpenRouter selected but no API key to query with
|
|
11
|
+
| { kind: "loading" } // fetching the account snapshot
|
|
12
|
+
| { kind: "error" } // network / timeout / HTTP failure
|
|
13
|
+
| { kind: "ready"; posture: ZdrPosture };
|
|
14
|
+
|
|
15
|
+
// The ZDR snapshot (Z, U, enforcement) is global per account, not per model, so we
|
|
16
|
+
// fetch it once per (apiKey, baseURL) and reuse it for the whole session — switching
|
|
17
|
+
// models re-evaluates synchronously. The cache holds the in-flight/resolved promise so
|
|
18
|
+
// concurrent consumers dedupe to a single fetch pair; a rejection is evicted so a later
|
|
19
|
+
// selection can retry rather than being stuck in error forever.
|
|
20
|
+
const cache = new Map<string, Promise<ZdrAccountData>>();
|
|
21
|
+
|
|
22
|
+
function loadAccount(apiKey: string, baseURL: string | undefined): Promise<ZdrAccountData> {
|
|
23
|
+
const key = `${apiKey}::${baseURL ?? ""}`;
|
|
24
|
+
let pending = cache.get(key);
|
|
25
|
+
if (!pending) {
|
|
26
|
+
pending = fetchZdrAccount({ apiKey, baseURL }).catch((err) => {
|
|
27
|
+
cache.delete(key);
|
|
28
|
+
throw err;
|
|
29
|
+
});
|
|
30
|
+
cache.set(key, pending);
|
|
31
|
+
}
|
|
32
|
+
return pending;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Resolve the ZDR shield state for the currently selected model. Only OpenRouter
|
|
36
|
+
// models trigger a fetch; everything else returns "hidden" before touching the network.
|
|
37
|
+
export function useZdrShield(modelSpec: string, config: Config): ZdrState {
|
|
38
|
+
let provider = "";
|
|
39
|
+
let modelId = "";
|
|
40
|
+
try {
|
|
41
|
+
({ provider, modelId } = parseModelSpec(modelSpec));
|
|
42
|
+
} catch {
|
|
43
|
+
provider = "";
|
|
44
|
+
modelId = "";
|
|
45
|
+
}
|
|
46
|
+
const cfg = config.providers.openrouter ?? {};
|
|
47
|
+
const isOpenRouter = provider === "openrouter";
|
|
48
|
+
// Account-billed Privateer models that aren't NEAR/TEE route through the server's
|
|
49
|
+
// ZDR-pinned OpenRouter proxy — zero retention is guaranteed server-side.
|
|
50
|
+
const isPrivateerZdr = provider === "privateer" && privateerChannel(modelId) === "zdr";
|
|
51
|
+
const apiKey = isOpenRouter ? cfg.apiKey : undefined;
|
|
52
|
+
const baseURL = cfg.baseURL;
|
|
53
|
+
const enforced = Boolean(cfg.enforceZdr);
|
|
54
|
+
|
|
55
|
+
const [state, setState] = useState<ZdrState>({ kind: "hidden" });
|
|
56
|
+
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (isPrivateerZdr) {
|
|
59
|
+
// The proxy always pins ZDR endpoints, so the posture is green without a
|
|
60
|
+
// client-side account query (there's no OpenRouter key on this side).
|
|
61
|
+
setState({ kind: "ready", posture: "green" });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (!isOpenRouter) {
|
|
65
|
+
setState({ kind: "hidden" });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (!apiKey) {
|
|
69
|
+
setState({ kind: "no-key" });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
let ignore = false;
|
|
73
|
+
setState({ kind: "loading" });
|
|
74
|
+
loadAccount(apiKey, baseURL)
|
|
75
|
+
.then((acct) => {
|
|
76
|
+
if (!ignore) setState({ kind: "ready", posture: zdrPosture(modelId, acct, enforced) });
|
|
77
|
+
})
|
|
78
|
+
.catch(() => {
|
|
79
|
+
if (!ignore) setState({ kind: "error" });
|
|
80
|
+
});
|
|
81
|
+
return () => {
|
|
82
|
+
ignore = true;
|
|
83
|
+
};
|
|
84
|
+
}, [isOpenRouter, isPrivateerZdr, apiKey, baseURL, modelId, enforced]);
|
|
85
|
+
|
|
86
|
+
return state;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The account snapshot itself, for callers that score many models at once (the
|
|
90
|
+
// OpenRouter model picker renders a per-row badge). Same fetch + cache as the
|
|
91
|
+
// shield, but it hands back the raw ZdrAccountData so the consumer can call
|
|
92
|
+
// zdrPosture(id, account) for each model synchronously, without a fetch per row.
|
|
93
|
+
export type ZdrAccountState =
|
|
94
|
+
| { kind: "idle" } // not OpenRouter, or no key — no badges
|
|
95
|
+
| { kind: "loading" }
|
|
96
|
+
| { kind: "error" }
|
|
97
|
+
| { kind: "ready"; account: ZdrAccountData };
|
|
98
|
+
|
|
99
|
+
export function useZdrAccount(provider: ProviderName | string, config: Config): ZdrAccountState {
|
|
100
|
+
const cfg = config.providers.openrouter ?? {};
|
|
101
|
+
const apiKey = provider === "openrouter" ? cfg.apiKey : undefined;
|
|
102
|
+
const baseURL = cfg.baseURL;
|
|
103
|
+
|
|
104
|
+
const [state, setState] = useState<ZdrAccountState>({ kind: "idle" });
|
|
105
|
+
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
if (provider !== "openrouter" || !apiKey) {
|
|
108
|
+
setState({ kind: "idle" });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
let ignore = false;
|
|
112
|
+
setState({ kind: "loading" });
|
|
113
|
+
loadAccount(apiKey, baseURL)
|
|
114
|
+
.then((account) => {
|
|
115
|
+
if (!ignore) setState({ kind: "ready", account });
|
|
116
|
+
})
|
|
117
|
+
.catch(() => {
|
|
118
|
+
if (!ignore) setState({ kind: "error" });
|
|
119
|
+
});
|
|
120
|
+
return () => {
|
|
121
|
+
ignore = true;
|
|
122
|
+
};
|
|
123
|
+
}, [provider, apiKey, baseURL]);
|
|
124
|
+
|
|
125
|
+
return state;
|
|
126
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { readFileSync, existsSync, mkdirSync, writeFileSync, chmodSync } from "node:fs";
|
|
2
|
+
import { Config, type Config as ConfigT, type ProviderName } from "./schema.ts";
|
|
3
|
+
import { globalPaths, projectPaths, managedSettingsPath } from "./paths.ts";
|
|
4
|
+
|
|
5
|
+
// Back-compat re-exports: existing callers import these from here.
|
|
6
|
+
export { globalDir } from "./paths.ts";
|
|
7
|
+
export function globalConfigPath(): string {
|
|
8
|
+
return globalPaths().config;
|
|
9
|
+
}
|
|
10
|
+
export function projectConfigPath(): string {
|
|
11
|
+
return projectPaths().config;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function readJsonIfExists(path: string): unknown {
|
|
15
|
+
if (!existsSync(path)) return undefined;
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
18
|
+
} catch (err) {
|
|
19
|
+
throw new Error(`Failed to parse config at ${path}: ${(err as Error).message}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
24
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Recursively merge raw config layers: objects merge per-key, everything else
|
|
28
|
+
// (scalars, arrays) is replaced by the higher-precedence layer.
|
|
29
|
+
function deepMerge(base: unknown, over: unknown): unknown {
|
|
30
|
+
if (over === undefined) return base;
|
|
31
|
+
if (!isPlainObject(base) || !isPlainObject(over)) return over;
|
|
32
|
+
const out: Record<string, unknown> = { ...base };
|
|
33
|
+
for (const [k, v] of Object.entries(over)) {
|
|
34
|
+
if (v === undefined) continue;
|
|
35
|
+
out[k] = k in out ? deepMerge(out[k], v) : v;
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ConfigLayer {
|
|
41
|
+
label: string;
|
|
42
|
+
path: string;
|
|
43
|
+
present: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The precedence chain, ordered low → high. Each scope contributes its
|
|
47
|
+
// config.json (credentials + prefs) then its settings file(s) on top; managed
|
|
48
|
+
// enterprise settings, if present, win over everything.
|
|
49
|
+
function layerSpecs(): { label: string; path: string }[] {
|
|
50
|
+
const g = globalPaths();
|
|
51
|
+
const p = projectPaths();
|
|
52
|
+
const specs = [
|
|
53
|
+
{ label: "user config", path: g.config },
|
|
54
|
+
{ label: "user settings", path: g.settings },
|
|
55
|
+
{ label: "project config", path: p.config },
|
|
56
|
+
{ label: "project settings", path: p.settings },
|
|
57
|
+
{ label: "project settings (local)", path: p.settingsLocal },
|
|
58
|
+
];
|
|
59
|
+
const managed = managedSettingsPath();
|
|
60
|
+
if (managed) specs.push({ label: "managed", path: managed });
|
|
61
|
+
return specs;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Resolved layer presence, for `/doctor` and `/config`.
|
|
65
|
+
export function configLayers(): ConfigLayer[] {
|
|
66
|
+
return layerSpecs().map(({ label, path }) => ({ label, path, present: existsSync(path) }));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Environment fallbacks for provider credentials. Applied only when config omits them.
|
|
70
|
+
function applyEnv(cfg: ConfigT): ConfigT {
|
|
71
|
+
const p = cfg.providers;
|
|
72
|
+
const set = (name: ProviderName, key: "apiKey" | "baseURL", val?: string) => {
|
|
73
|
+
if (!val) return;
|
|
74
|
+
p[name] = { ...(p[name] ?? {}), [key]: (p[name] as any)?.[key] ?? val };
|
|
75
|
+
};
|
|
76
|
+
set("openrouter", "apiKey", process.env.OPENROUTER_API_KEY);
|
|
77
|
+
set("anthropic", "apiKey", process.env.ANTHROPIC_API_KEY);
|
|
78
|
+
set("openai", "apiKey", process.env.OPENAI_API_KEY);
|
|
79
|
+
set("ollama", "baseURL", process.env.OLLAMA_BASE_URL);
|
|
80
|
+
set("nearai", "apiKey", process.env.NEAR_AI_API_KEY ?? process.env.NEARAI_API_KEY);
|
|
81
|
+
return cfg;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function loadConfig(): ConfigT {
|
|
85
|
+
// Merge raw layers first (so per-layer files stay partial), then parse once so
|
|
86
|
+
// schema defaults are applied to the resolved object rather than each layer.
|
|
87
|
+
let raw: unknown = {};
|
|
88
|
+
for (const { path } of layerSpecs()) {
|
|
89
|
+
raw = deepMerge(raw, readJsonIfExists(path));
|
|
90
|
+
}
|
|
91
|
+
const cfg = Config.parse(raw ?? {});
|
|
92
|
+
return applyEnv(cfg);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Persist the global config (used by /model, /provider, /permissions to remember
|
|
96
|
+
// choices). The file holds provider API keys, so it is written owner-only (0600)
|
|
97
|
+
// inside an owner-only directory (0700). `chmod` is best-effort: it's a no-op on
|
|
98
|
+
// filesystems/platforms (e.g. Windows) that don't honour POSIX modes.
|
|
99
|
+
export function saveGlobalConfig(cfg: ConfigT): void {
|
|
100
|
+
const g = globalPaths();
|
|
101
|
+
mkdirSync(g.dir, { recursive: true });
|
|
102
|
+
tryChmod(g.dir, 0o700);
|
|
103
|
+
// `mode` on writeFileSync only applies when creating the file, so chmod after
|
|
104
|
+
// to also tighten a pre-existing, looser config.
|
|
105
|
+
writeFileSync(g.config, JSON.stringify(cfg, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
106
|
+
tryChmod(g.config, 0o600);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function tryChmod(path: string, mode: number): void {
|
|
110
|
+
try {
|
|
111
|
+
chmodSync(path, mode);
|
|
112
|
+
} catch {
|
|
113
|
+
/* non-POSIX filesystem or insufficient perms — nothing we can do */
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
// Global config/data dir. Overridable via PRIVATEER_HOME (portability + tests).
|
|
5
|
+
// Computed lazily so the env var can be set before first use.
|
|
6
|
+
export function globalDir(): string {
|
|
7
|
+
return process.env.PRIVATEER_HOME ?? join(homedir(), ".privateer");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Project-scoped config dir (./.privateer).
|
|
11
|
+
export function projectDir(cwd: string = process.cwd()): string {
|
|
12
|
+
return join(cwd, ".privateer");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Privateer-account session credentials (JWT access/refresh + user). Kept in a
|
|
16
|
+
// SEPARATE file from config.json: these are session tokens, not BYO provider
|
|
17
|
+
// keys, and have a different lifecycle (rotated on refresh, cleared on logout).
|
|
18
|
+
export function credentialsPath(): string {
|
|
19
|
+
return join(globalDir(), "credentials.json");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Optional enterprise-managed settings file (highest precedence). Opt-in via env
|
|
23
|
+
// so it stays out of the way for individuals and tests.
|
|
24
|
+
export function managedSettingsPath(): string | undefined {
|
|
25
|
+
return process.env.PRIVATEER_MANAGED_SETTINGS || undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The standard file/dir layout under a scope's .privateer directory. Later
|
|
29
|
+
// milestones (commands, agents, mcp, output styles) resolve their roots here so
|
|
30
|
+
// the convention lives in one place.
|
|
31
|
+
export interface ScopePaths {
|
|
32
|
+
dir: string;
|
|
33
|
+
config: string; // config.json — credentials + prefs (existing single file)
|
|
34
|
+
settings: string; // settings.json — layered settings
|
|
35
|
+
settingsLocal: string; // settings.local.json — gitignored local overrides
|
|
36
|
+
commands: string; // commands/ — custom slash commands (M2)
|
|
37
|
+
agents: string; // agents/ — custom subagents (M4)
|
|
38
|
+
outputStyles: string; // output-styles/ — persona prompts (M2)
|
|
39
|
+
mcp: string; // mcp.json — MCP server declarations (M4)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function scopePaths(dir: string): ScopePaths {
|
|
43
|
+
return {
|
|
44
|
+
dir,
|
|
45
|
+
config: join(dir, "config.json"),
|
|
46
|
+
settings: join(dir, "settings.json"),
|
|
47
|
+
settingsLocal: join(dir, "settings.local.json"),
|
|
48
|
+
commands: join(dir, "commands"),
|
|
49
|
+
agents: join(dir, "agents"),
|
|
50
|
+
outputStyles: join(dir, "output-styles"),
|
|
51
|
+
mcp: join(dir, "mcp.json"),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function globalPaths(): ScopePaths {
|
|
56
|
+
return scopePaths(globalDir());
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function projectPaths(cwd: string = process.cwd()): ScopePaths {
|
|
60
|
+
return scopePaths(projectDir(cwd));
|
|
61
|
+
}
|