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,343 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join, extname, basename } from "node:path";
|
|
3
|
+
|
|
4
|
+
// The model-input modalities Privateer can attach and route on. `text` files are not
|
|
5
|
+
// a modality here — they're inlined as plain text (see resolveAttachments), never
|
|
6
|
+
// routed — so this union only covers the binary kinds that need a capable model.
|
|
7
|
+
export type Modality = "image" | "document" | "audio" | "video";
|
|
8
|
+
|
|
9
|
+
// Extension → { mediaType, modality }. Drives both detection and the media type we
|
|
10
|
+
// hand the provider. Inferred from the extension alone (no magic-byte sniffing).
|
|
11
|
+
const MEDIA_TYPES: Record<string, { mediaType: string; modality: Modality }> = {
|
|
12
|
+
".png": { mediaType: "image/png", modality: "image" },
|
|
13
|
+
".jpg": { mediaType: "image/jpeg", modality: "image" },
|
|
14
|
+
".jpeg": { mediaType: "image/jpeg", modality: "image" },
|
|
15
|
+
".gif": { mediaType: "image/gif", modality: "image" },
|
|
16
|
+
".webp": { mediaType: "image/webp", modality: "image" },
|
|
17
|
+
".pdf": { mediaType: "application/pdf", modality: "document" },
|
|
18
|
+
".mp3": { mediaType: "audio/mpeg", modality: "audio" },
|
|
19
|
+
".wav": { mediaType: "audio/wav", modality: "audio" },
|
|
20
|
+
".m4a": { mediaType: "audio/mp4", modality: "audio" },
|
|
21
|
+
".ogg": { mediaType: "audio/ogg", modality: "audio" },
|
|
22
|
+
".flac": { mediaType: "audio/flac", modality: "audio" },
|
|
23
|
+
".mp4": { mediaType: "video/mp4", modality: "video" },
|
|
24
|
+
".mov": { mediaType: "video/quicktime", modality: "video" },
|
|
25
|
+
".webm": { mediaType: "video/webm", modality: "video" },
|
|
26
|
+
".mkv": { mediaType: "video/x-matroska", modality: "video" },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Magic-byte checks per media type, used to reject placeholder/corrupt files at capture
|
|
30
|
+
// time. The motivating case: macOS delivers a drag from a screenshot thumbnail as a
|
|
31
|
+
// *file promise*, so the terminal's …/T/drop-XXXXXX/ file can be a 4-byte stub holding
|
|
32
|
+
// only the start of the PNG signature (0x89 'P' 'N' 'G') — never the real bytes. Reading
|
|
33
|
+
// that into an attachment yields a broken "[Image #n]" the model can't use, so we drop
|
|
34
|
+
// it here and leave the raw path in the buffer (a visible signal the capture failed).
|
|
35
|
+
const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
36
|
+
const MAGIC: Record<string, (b: Buffer) => boolean> = {
|
|
37
|
+
"image/png": (b) => b.length >= 8 && b.subarray(0, 8).equals(PNG_SIG),
|
|
38
|
+
"image/jpeg": (b) => b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff,
|
|
39
|
+
"image/gif": (b) => b.length >= 6 && /^GIF8[79]a$/.test(b.toString("latin1", 0, 6)),
|
|
40
|
+
"image/webp": (b) =>
|
|
41
|
+
b.length >= 12 && b.toString("latin1", 0, 4) === "RIFF" && b.toString("latin1", 8, 12) === "WEBP",
|
|
42
|
+
"application/pdf": (b) => b.length >= 5 && b.toString("latin1", 0, 5) === "%PDF-",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// True when `buf` plausibly holds a real file of `mediaType`. For types with a known
|
|
46
|
+
// signature we check it; for the rest (audio/video containers vary too much to sniff
|
|
47
|
+
// cheaply) we only reject an empty buffer. The aim is to catch promise stubs and
|
|
48
|
+
// zero-byte drops, not to fully validate the format.
|
|
49
|
+
export function validateBytes(buf: Buffer, mediaType: string): boolean {
|
|
50
|
+
const check = MAGIC[mediaType];
|
|
51
|
+
return check ? check(buf) : buf.length > 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ImageDims {
|
|
55
|
+
w: number;
|
|
56
|
+
h: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Pull pixel dimensions straight from an image's header, or null when the format
|
|
60
|
+
// isn't one we parse (or the header is too short). Cheap header reads only — no
|
|
61
|
+
// decode. This drives the provenance shown at drop time so a wrong-but-complete
|
|
62
|
+
// capture (e.g. a stale 1412×496 banner where you meant a full-height screenshot)
|
|
63
|
+
// is visible before the prompt is sent. Validation already rejected truncated stubs.
|
|
64
|
+
export function readImageSize(buf: Buffer, mediaType: string): ImageDims | null {
|
|
65
|
+
try {
|
|
66
|
+
if (mediaType === "image/png") {
|
|
67
|
+
// 8-byte sig + 4-byte length + "IHDR" → width@16, height@20, big-endian.
|
|
68
|
+
if (buf.length < 24) return null;
|
|
69
|
+
return { w: buf.readUInt32BE(16), h: buf.readUInt32BE(20) };
|
|
70
|
+
}
|
|
71
|
+
if (mediaType === "image/gif") {
|
|
72
|
+
if (buf.length < 10) return null;
|
|
73
|
+
return { w: buf.readUInt16LE(6), h: buf.readUInt16LE(8) };
|
|
74
|
+
}
|
|
75
|
+
if (mediaType === "image/jpeg") {
|
|
76
|
+
// Walk segments to the first Start-Of-Frame marker, which carries the size.
|
|
77
|
+
let off = 2; // skip SOI (0xFFD8)
|
|
78
|
+
while (off + 9 < buf.length) {
|
|
79
|
+
if (buf[off] !== 0xff) {
|
|
80
|
+
off++;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const marker = buf[off + 1];
|
|
84
|
+
// SOF0–SOF15 hold the frame size; skip DHT(C4)/JPG(C8)/DAC(CC) which share the range.
|
|
85
|
+
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
|
86
|
+
return { w: buf.readUInt16BE(off + 7), h: buf.readUInt16BE(off + 5) };
|
|
87
|
+
}
|
|
88
|
+
const segLen = buf.readUInt16BE(off + 2);
|
|
89
|
+
if (segLen < 2) return null; // malformed → give up
|
|
90
|
+
off += 2 + segLen;
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
return null; // short/corrupt header → no dimensions, not fatal
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Text/code/data files: read-as-text (inlined into the prompt), never attached as a
|
|
101
|
+
// binary or routed. Anything not here and not in MEDIA_TYPES is left as literal text.
|
|
102
|
+
const TEXT_EXTS = new Set([
|
|
103
|
+
".txt", ".md", ".markdown", ".csv", ".tsv", ".json", ".jsonl", ".yaml", ".yml", ".toml",
|
|
104
|
+
".ini", ".env", ".xml", ".html", ".css", ".js", ".jsx", ".ts", ".tsx", ".py", ".rb",
|
|
105
|
+
".go", ".rs", ".java", ".c", ".h", ".cpp", ".hpp", ".cs", ".php", ".sh", ".bash", ".zsh",
|
|
106
|
+
".sql", ".log", ".diff", ".patch", ".lua", ".swift", ".kt", ".scala", ".r", ".pl",
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
// Upper bound on an inlined text file (bytes). Larger files are left as a path token
|
|
110
|
+
// for the agent's read tool rather than dumping the whole thing into the prompt.
|
|
111
|
+
const DEFAULT_INLINE_MAX_BYTES = 65_536;
|
|
112
|
+
|
|
113
|
+
export interface Attachment {
|
|
114
|
+
data: string; // base64-encoded file content
|
|
115
|
+
mediaType: string;
|
|
116
|
+
modality: Modality;
|
|
117
|
+
path: string; // the token as written, for display
|
|
118
|
+
n?: number; // session reference number, when resolved as a "[Kind #n]" chip
|
|
119
|
+
bytes?: number; // decoded size, for the drop-time provenance line
|
|
120
|
+
dims?: ImageDims | null; // pixel dimensions for images we can parse, else null
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Back-compat alias: callers that predate multimodal still import AttachedImage.
|
|
124
|
+
export type AttachedImage = Attachment;
|
|
125
|
+
|
|
126
|
+
// The chip a resolved attachment collapses to in the prompt/transcript. The kind
|
|
127
|
+
// label is derived from the modality so the user (and the model) can tell them apart.
|
|
128
|
+
const CHIP_LABEL: Record<Modality, string> = {
|
|
129
|
+
image: "Image",
|
|
130
|
+
document: "PDF",
|
|
131
|
+
audio: "Audio",
|
|
132
|
+
video: "Video",
|
|
133
|
+
};
|
|
134
|
+
export function chipFor(att: Pick<Attachment, "modality" | "n">): string {
|
|
135
|
+
return `[${CHIP_LABEL[att.modality]} #${att.n}]`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Compact, human size: "217 KB", "3.4 MB", "812 B". For provenance lines, not exactness.
|
|
139
|
+
function formatBytes(n: number): string {
|
|
140
|
+
if (n < 1024) return `${n} B`;
|
|
141
|
+
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
|
|
142
|
+
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// A one-line "what did we actually capture" description for a staged attachment:
|
|
146
|
+
// "screenshot.png · 1412×496 · 217 KB". Filename + dimensions + size are exactly the
|
|
147
|
+
// signals that expose a wrong-file drop before the prompt is sent.
|
|
148
|
+
export function describeAttachment(att: Attachment): string {
|
|
149
|
+
const parts = [basename(att.path)];
|
|
150
|
+
if (att.dims) parts.push(`${att.dims.w}×${att.dims.h}`);
|
|
151
|
+
if (att.bytes != null) parts.push(formatBytes(att.bytes));
|
|
152
|
+
return parts.join(" · ");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// A path-like token and the slice of the original text it occupies, so callers can
|
|
156
|
+
// substitute it in place (e.g. with a chip) without re-matching the quoted/escaped
|
|
157
|
+
// raw form.
|
|
158
|
+
interface Span {
|
|
159
|
+
value: string; // unescaped/unquoted token text
|
|
160
|
+
start: number; // inclusive index into the original string
|
|
161
|
+
end: number; // exclusive index into the original string
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Split text into path-like spans, honoring the shell-style quoting people reach
|
|
165
|
+
// for when a path contains spaces: '"a b.png"', "'a b.png'", and backslash escapes
|
|
166
|
+
// ("a\ b.png"). Without this, a pasted path like
|
|
167
|
+
// /Users/me/Screenshot\ 2026.png
|
|
168
|
+
// would shatter on whitespace and never match a real file.
|
|
169
|
+
function tokenizeSpans(text: string): Span[] {
|
|
170
|
+
const spans: Span[] = [];
|
|
171
|
+
let cur = "";
|
|
172
|
+
let quote: '"' | "'" | null = null;
|
|
173
|
+
let started = false; // distinguishes an empty quoted token from no token
|
|
174
|
+
let tokenStart = 0;
|
|
175
|
+
const flush = (end: number) => {
|
|
176
|
+
if (started) spans.push({ value: cur, start: tokenStart, end });
|
|
177
|
+
cur = "";
|
|
178
|
+
started = false;
|
|
179
|
+
};
|
|
180
|
+
const begin = (i: number) => {
|
|
181
|
+
if (!started) {
|
|
182
|
+
tokenStart = i;
|
|
183
|
+
started = true;
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
for (let i = 0; i < text.length; i++) {
|
|
187
|
+
const ch = text[i];
|
|
188
|
+
if (quote) {
|
|
189
|
+
if (ch === quote) quote = null;
|
|
190
|
+
else cur += ch;
|
|
191
|
+
} else if (ch === '"' || ch === "'") {
|
|
192
|
+
begin(i);
|
|
193
|
+
quote = ch;
|
|
194
|
+
} else if (ch === "\\" && i + 1 < text.length) {
|
|
195
|
+
begin(i);
|
|
196
|
+
cur += text[++i]; // escaped char joins the token literally
|
|
197
|
+
} else if (/\s/.test(ch)) {
|
|
198
|
+
flush(i);
|
|
199
|
+
} else {
|
|
200
|
+
begin(i);
|
|
201
|
+
cur += ch;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
flush(text.length);
|
|
205
|
+
return spans;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function resolvePath(token: string, cwd: string): string {
|
|
209
|
+
return isAbsolute(token) ? token : join(cwd, token);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Read the binary-modality file behind a path-like token (image/document/audio/video),
|
|
213
|
+
// or null when the token isn't such a file or can't be read. Capability of the target
|
|
214
|
+
// model is the router's concern, not this function's.
|
|
215
|
+
function readAttachment(
|
|
216
|
+
token: string,
|
|
217
|
+
cwd: string,
|
|
218
|
+
): { data: string; mediaType: string; modality: Modality; abs: string; bytes: number; dims: ImageDims | null } | null {
|
|
219
|
+
const meta = MEDIA_TYPES[extname(token).toLowerCase()];
|
|
220
|
+
if (!meta) return null;
|
|
221
|
+
const abs = resolvePath(token, cwd);
|
|
222
|
+
if (!existsSync(abs)) return null;
|
|
223
|
+
try {
|
|
224
|
+
const buf = readFileSync(abs);
|
|
225
|
+
if (!validateBytes(buf, meta.mediaType)) return null; // promise stub / corrupt → skip
|
|
226
|
+
const dims = meta.modality === "image" ? readImageSize(buf, meta.mediaType) : null;
|
|
227
|
+
return { data: buf.toString("base64"), ...meta, abs, bytes: buf.length, dims };
|
|
228
|
+
} catch {
|
|
229
|
+
return null; // unreadable → skip
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Find binary-modality file paths referenced in a prompt (optionally as @mentions) and
|
|
234
|
+
// read them as base64 so they can be attached to the model message. Handles quoted and
|
|
235
|
+
// backslash-escaped paths with spaces. Tokens that don't resolve to a readable
|
|
236
|
+
// image/document/audio/video file are ignored.
|
|
237
|
+
export function extractAttachments(text: string, cwd: string): Attachment[] {
|
|
238
|
+
const out: Attachment[] = [];
|
|
239
|
+
const seen = new Set<string>();
|
|
240
|
+
for (const span of tokenizeSpans(text)) {
|
|
241
|
+
const token = span.value.replace(/^@/, "");
|
|
242
|
+
const att = readAttachment(token, cwd);
|
|
243
|
+
if (!att || seen.has(att.abs)) continue;
|
|
244
|
+
out.push({
|
|
245
|
+
data: att.data,
|
|
246
|
+
mediaType: att.mediaType,
|
|
247
|
+
modality: att.modality,
|
|
248
|
+
path: token,
|
|
249
|
+
bytes: att.bytes,
|
|
250
|
+
dims: att.dims,
|
|
251
|
+
});
|
|
252
|
+
seen.add(att.abs);
|
|
253
|
+
}
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Back-compat: the old image-only entry point.
|
|
258
|
+
export const extractImages = extractAttachments;
|
|
259
|
+
|
|
260
|
+
// Read a text/code/data file for inlining, or null when it isn't a text file, doesn't
|
|
261
|
+
// exist, or exceeds the size cap (left for the agent's read tool instead).
|
|
262
|
+
function readTextFile(token: string, cwd: string, maxBytes: number): string | null {
|
|
263
|
+
if (!TEXT_EXTS.has(extname(token).toLowerCase())) return null;
|
|
264
|
+
const abs = resolvePath(token, cwd);
|
|
265
|
+
if (!existsSync(abs)) return null;
|
|
266
|
+
try {
|
|
267
|
+
if (statSync(abs).size > maxBytes) return null; // too big → leave the path alone
|
|
268
|
+
return readFileSync(abs, "utf8");
|
|
269
|
+
} catch {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export interface ResolvedAttachments {
|
|
275
|
+
text: string; // prompt with binary paths rewritten to chips and text paths to [file: …]
|
|
276
|
+
attachments: Attachment[]; // image/document/audio/video, each carrying its [Kind #n]
|
|
277
|
+
inlinedText: string; // concatenated contents of any read-as-text files
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Rewrite a prompt's referenced files in place: binary-modality paths become stable
|
|
281
|
+
// "[Kind #n]" chips (and their base64 is collected as attachments), and recognized
|
|
282
|
+
// text/code files are inlined — their path replaced with "[file: name]" and their
|
|
283
|
+
// contents appended to `inlinedText`. Chip numbers are assigned from `startSeq` and
|
|
284
|
+
// shared across the session; the same file referenced twice reuses its number. Edits
|
|
285
|
+
// are applied right-to-left so indices stay valid.
|
|
286
|
+
export function resolveAttachments(
|
|
287
|
+
text: string,
|
|
288
|
+
cwd: string,
|
|
289
|
+
startSeq: number,
|
|
290
|
+
inlineMaxBytes: number = DEFAULT_INLINE_MAX_BYTES,
|
|
291
|
+
): ResolvedAttachments {
|
|
292
|
+
const spans = tokenizeSpans(text);
|
|
293
|
+
const byAbs = new Map<string, Attachment>(); // abs path → attachment (dedupe)
|
|
294
|
+
const attachments: Attachment[] = [];
|
|
295
|
+
const inlinedParts: string[] = [];
|
|
296
|
+
const inlinedSeen = new Set<string>();
|
|
297
|
+
const edits: { start: number; end: number; replacement: string }[] = [];
|
|
298
|
+
let seq = startSeq;
|
|
299
|
+
|
|
300
|
+
for (const span of spans) {
|
|
301
|
+
const token = span.value.replace(/^@/, "");
|
|
302
|
+
const abs = resolvePath(token, cwd);
|
|
303
|
+
|
|
304
|
+
const existing = byAbs.get(abs);
|
|
305
|
+
if (existing) {
|
|
306
|
+
edits.push({ start: span.start, end: span.end, replacement: chipFor(existing) });
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const att = readAttachment(token, cwd);
|
|
311
|
+
if (att) {
|
|
312
|
+
const resolved: Attachment = {
|
|
313
|
+
data: att.data,
|
|
314
|
+
mediaType: att.mediaType,
|
|
315
|
+
modality: att.modality,
|
|
316
|
+
path: token,
|
|
317
|
+
n: ++seq,
|
|
318
|
+
bytes: att.bytes,
|
|
319
|
+
dims: att.dims,
|
|
320
|
+
};
|
|
321
|
+
byAbs.set(abs, resolved);
|
|
322
|
+
attachments.push(resolved);
|
|
323
|
+
edits.push({ start: span.start, end: span.end, replacement: chipFor(resolved) });
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const body = readTextFile(token, cwd, inlineMaxBytes);
|
|
328
|
+
if (body !== null) {
|
|
329
|
+
const name = basename(token);
|
|
330
|
+
if (!inlinedSeen.has(abs)) {
|
|
331
|
+
inlinedSeen.add(abs);
|
|
332
|
+
inlinedParts.push(`--- ${name} ---\n${body}`);
|
|
333
|
+
}
|
|
334
|
+
edits.push({ start: span.start, end: span.end, replacement: `[file: ${name}]` });
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
let out = text;
|
|
339
|
+
for (const e of edits.sort((a, b) => b.start - a.start)) {
|
|
340
|
+
out = out.slice(0, e.start) + e.replacement + out.slice(e.end);
|
|
341
|
+
}
|
|
342
|
+
return { text: out, attachments, inlinedText: inlinedParts.join("\n\n") };
|
|
343
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// A minimal async concurrency limiter. `run(task)` resolves the task's value but
|
|
2
|
+
// never lets more than `max` tasks run at once; the rest queue FIFO. Used to bound
|
|
3
|
+
// how many `task` sub-agents execute in parallel when the model fans them out.
|
|
4
|
+
export type Limiter = <T>(task: () => Promise<T>) => Promise<T>;
|
|
5
|
+
|
|
6
|
+
export function createLimiter(max: number): Limiter {
|
|
7
|
+
let active = 0;
|
|
8
|
+
const queue: (() => void)[] = [];
|
|
9
|
+
|
|
10
|
+
function release(): void {
|
|
11
|
+
const next = queue.shift();
|
|
12
|
+
if (next) next(); // hand the slot straight to the next waiter (active unchanged)
|
|
13
|
+
else active--; // no waiter → free the slot
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function acquire(): Promise<void> {
|
|
17
|
+
if (active < max) {
|
|
18
|
+
active++;
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
await new Promise<void>((resolve) => queue.push(resolve)); // slot handed to us
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return async function run<T>(task: () => Promise<T>): Promise<T> {
|
|
25
|
+
await acquire();
|
|
26
|
+
try {
|
|
27
|
+
return await task();
|
|
28
|
+
} finally {
|
|
29
|
+
release();
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Secret redaction for anything that leaves the process as text — error
|
|
2
|
+
// messages, exported transcripts, future telemetry. Provider SDKs sometimes
|
|
3
|
+
// echo the request (auth header included) inside an error, so we scrub before
|
|
4
|
+
// any of that reaches the UI or disk.
|
|
5
|
+
|
|
6
|
+
const PLACEHOLDER = "«redacted»";
|
|
7
|
+
|
|
8
|
+
// Common API-key shapes, masked even when we don't have the exact value on hand:
|
|
9
|
+
// OpenAI `sk-…`, Anthropic `sk-ant-…`, OpenRouter `sk-or-v1-…`, and bare
|
|
10
|
+
// "Bearer <token>" / "x-api-key: <token>" header fragments.
|
|
11
|
+
const KEY_PATTERNS: RegExp[] = [
|
|
12
|
+
/\bsk-(ant|or|proj|live|test)?-?[A-Za-z0-9_-]{16,}\b/g,
|
|
13
|
+
/\b(authorization|x-api-key)\b\s*[:=]\s*(bearer\s+)?["']?[A-Za-z0-9_\-.]{16,}["']?/gi,
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
// Exact secret strings to mask, gathered from the resolved config + environment.
|
|
17
|
+
// Only values of a meaningful length are included, so we never blank out e.g. a
|
|
18
|
+
// one-character placeholder key.
|
|
19
|
+
export function collectSecrets(providers?: Record<string, { apiKey?: string } | undefined>): string[] {
|
|
20
|
+
const out = new Set<string>();
|
|
21
|
+
const add = (v?: string) => {
|
|
22
|
+
if (v && v.trim().length >= 8) out.add(v.trim());
|
|
23
|
+
};
|
|
24
|
+
if (providers) for (const p of Object.values(providers)) add(p?.apiKey);
|
|
25
|
+
for (const k of [
|
|
26
|
+
"OPENROUTER_API_KEY",
|
|
27
|
+
"ANTHROPIC_API_KEY",
|
|
28
|
+
"OPENAI_API_KEY",
|
|
29
|
+
"NEAR_AI_API_KEY",
|
|
30
|
+
"NEARAI_API_KEY",
|
|
31
|
+
])
|
|
32
|
+
add(process.env[k]);
|
|
33
|
+
return [...out];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Mask any known secret substrings and key-shaped tokens inside free text.
|
|
37
|
+
export function redactText(text: string, secrets: string[] = collectSecrets()): string {
|
|
38
|
+
let out = text;
|
|
39
|
+
for (const s of secrets) {
|
|
40
|
+
if (s) out = out.split(s).join(PLACEHOLDER);
|
|
41
|
+
}
|
|
42
|
+
for (const re of KEY_PATTERNS) out = out.replace(re, PLACEHOLDER);
|
|
43
|
+
return out;
|
|
44
|
+
}
|
package/src/version.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Single source of truth for the app's name/version, read from package.json.
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { dirname, resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const pkg = JSON.parse(
|
|
8
|
+
readFileSync(resolve(__dirname, "../package.json"), "utf8"),
|
|
9
|
+
) as { name: string; version: string; description: string };
|
|
10
|
+
|
|
11
|
+
export const NAME = "privateer";
|
|
12
|
+
export const VERSION = pkg.version;
|
|
13
|
+
export const DESCRIPTION = pkg.description;
|