privateer-agent 0.12.31 → 0.12.33
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-splash.mjs +73 -11
- package/extensions/privateer-brand.ts +15 -3
- package/extensions/privateer-context.ts +42 -0
- package/package.json +1 -1
- package/src/context.ts +102 -4
package/bin/privateer-splash.mjs
CHANGED
|
@@ -37,8 +37,19 @@
|
|
|
37
37
|
// never lands in front of real output.
|
|
38
38
|
//
|
|
39
39
|
// The wave is drawn on STDERR; stdout belongs to the TUI's canvas.
|
|
40
|
+
//
|
|
41
|
+
// WHY EVERY WRITE OF OURS IS fs.writeSync. On Windows a write to a TTY stream is
|
|
42
|
+
// ASYNCHRONOUS — process.stderr.write only queues the bytes for the event loop — and the
|
|
43
|
+
// whole point of this file is that Pi's boot never gives the event loop a turn. Through
|
|
44
|
+
// process.stderr, every erase we issue during the wait would land after the output it was
|
|
45
|
+
// meant to clear, and the cursor restore on `exit` would never flush at all: a Windows
|
|
46
|
+
// console left with wave fragments in front of Pi's first frame and no cursor afterwards.
|
|
47
|
+
// fs.writeSync goes straight to fd 2, which is also what the drawing thread uses, so the
|
|
48
|
+
// two threads' output stays in the order it was issued. Pi's OWN stderr still goes
|
|
49
|
+
// through the stream — that write belongs to the caller, return value and all.
|
|
40
50
|
|
|
41
51
|
import { spawnSync } from "node:child_process";
|
|
52
|
+
import fs from "node:fs";
|
|
42
53
|
import path from "node:path";
|
|
43
54
|
import { Worker } from "node:worker_threads";
|
|
44
55
|
|
|
@@ -62,6 +73,32 @@ if (enabled && process.platform === "win32") {
|
|
|
62
73
|
}
|
|
63
74
|
}
|
|
64
75
|
|
|
76
|
+
// WHICH GLYPHS THE CONSOLE CAN ACTUALLY DRAW. Code page 65001 above settles the ENCODING;
|
|
77
|
+
// it says nothing about the FONT. Legacy conhost — a plain cmd.exe or PowerShell window,
|
|
78
|
+
// which is still what `privateer` gets when it isn't launched from Windows Terminal —
|
|
79
|
+
// defaults to Lucida Console or a raster font, and those cover exactly the CP437 block
|
|
80
|
+
// elements (█ ▄ ▀ ░ ▒ ▓) and nothing else. The eighth-block ramp, the anchor and the
|
|
81
|
+
// ellipsis are all absent there, so the "wave" drew as a row of tofu boxes that changed
|
|
82
|
+
// shape every frame. Every modern host announces itself in the environment (Windows
|
|
83
|
+
// Terminal, VS Code, ConEmu/ANSICON, anything mintty-ish that sets TERM), and on those the
|
|
84
|
+
// eighth blocks are the better picture, so the fallback is only for the ones that don't.
|
|
85
|
+
const legacyConsole =
|
|
86
|
+
process.platform === "win32" &&
|
|
87
|
+
!(
|
|
88
|
+
process.env.WT_SESSION ||
|
|
89
|
+
process.env.WT_PROFILE_ID ||
|
|
90
|
+
process.env.TERM_PROGRAM ||
|
|
91
|
+
process.env.ConEmuANSI ||
|
|
92
|
+
process.env.ANSICON ||
|
|
93
|
+
process.env.TERM
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
// Eight levels either way, so the wave keeps its shape: height where the font has the
|
|
97
|
+
// eighth blocks, density where it only has the CP437 shades.
|
|
98
|
+
const BLOCKS = legacyConsole ? " \u2591\u2591\u2592\u2592\u2593\u2593\u2588" : "\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588";
|
|
99
|
+
const ANCHOR = legacyConsole ? "~" : "\u2693";
|
|
100
|
+
const ELLIPSIS = legacyConsole ? "..." : "\u2026";
|
|
101
|
+
|
|
65
102
|
// Bytes of stdout after TUI.start() that mean "this is the first frame, not a control
|
|
66
103
|
// sequence". Everything Pi writes between raw mode and the frame is short (the paste
|
|
67
104
|
// toggle, a Kitty protocol query, the cursor hide, an OSC window title — 42 bytes all
|
|
@@ -86,10 +123,17 @@ if (enabled) {
|
|
|
86
123
|
const sab = new SharedArrayBuffer(4 * Int32Array.BYTES_PER_ELEMENT);
|
|
87
124
|
const state = new Int32Array(sab);
|
|
88
125
|
|
|
89
|
-
// Room for " ⚓ " + wave + message + elapsed, clamped so a narrow
|
|
90
|
-
// wrap (a wrapped line survives our `\r\x1b[K` erase only on its last
|
|
126
|
+
// Room for " ⚓ " + wave + " " + message + ellipsis + elapsed, clamped so a narrow
|
|
127
|
+
// terminal doesn't wrap (a wrapped line survives our `\r\x1b[K` erase only on its last
|
|
128
|
+
// row). The reserve is measured rather than guessed, because the pieces are no longer
|
|
129
|
+
// fixed: the anchor is TWO cells wherever ⚓ keeps its emoji presentation, one where it
|
|
130
|
+
// fell back to ASCII, and the ellipsis is one cell or three.
|
|
131
|
+
const MSGS = ["hoisting sail", "raising the colours"];
|
|
132
|
+
const anchorCells = ANCHOR === "\u2693" ? 2 : 1; // U+2693 carries emoji presentation
|
|
133
|
+
const reserve =
|
|
134
|
+
2 + anchorCells + 1 + 1 + Math.max(...MSGS.map((m) => m.length)) + ELLIPSIS.length + 5;
|
|
91
135
|
const cols = err.columns && err.columns > 0 ? err.columns : 80;
|
|
92
|
-
const width = Math.max(6, Math.min(28, cols -
|
|
136
|
+
const width = Math.max(6, Math.min(28, cols - reserve));
|
|
93
137
|
|
|
94
138
|
// The worker source is plain logic with no escape sequences of its own — every ANSI
|
|
95
139
|
// string is handed over in workerData, so nothing here has to survive two rounds of
|
|
@@ -122,7 +166,7 @@ if (enabled) {
|
|
|
122
166
|
const secs = Math.round((Date.now() - t0) / 1000);
|
|
123
167
|
const msg = w.msgs[Atomics.load(s, PHASE)];
|
|
124
168
|
const age = secs >= 3 ? w.dim + " " + secs + "s" + w.off : "";
|
|
125
|
-
fs.writeSync(2, w.cr + " " + w.anchor + " " + wave(frame++ * 0.35) + " " + w.dim + msg +
|
|
169
|
+
fs.writeSync(2, w.cr + " " + w.anchor + " " + wave(frame++ * 0.35) + " " + w.dim + msg + w.ellipsis + w.off + age + w.clearEol);
|
|
126
170
|
}
|
|
127
171
|
|
|
128
172
|
// Atomics.wait doubles as the sleep: an exact 80ms tick that the main thread can cut
|
|
@@ -146,9 +190,10 @@ if (enabled) {
|
|
|
146
190
|
sab,
|
|
147
191
|
width,
|
|
148
192
|
hold: HOLD_MS,
|
|
149
|
-
blocks:
|
|
150
|
-
msgs:
|
|
151
|
-
|
|
193
|
+
blocks: BLOCKS,
|
|
194
|
+
msgs: MSGS,
|
|
195
|
+
ellipsis: ELLIPSIS,
|
|
196
|
+
anchor: `\x1b[38;5;69m${ANCHOR}\x1b[0m`,
|
|
152
197
|
crest: "\x1b[38;5;109m",
|
|
153
198
|
trough: "\x1b[38;5;67m",
|
|
154
199
|
dim: "\x1b[2m",
|
|
@@ -176,8 +221,23 @@ if (enabled) {
|
|
|
176
221
|
if (!Atomics.load(state, ACK)) Atomics.wait(state, ACK, 0, 50);
|
|
177
222
|
}
|
|
178
223
|
|
|
224
|
+
// Our own control sequences, written straight to fd 2 and synchronously — see the note
|
|
225
|
+
// at the top of the file for why process.stderr will not do. A short write or an EAGAIN
|
|
226
|
+
// from a non-blocking tty is retried; anything else is swallowed, because a splash is
|
|
227
|
+
// never worth a crash.
|
|
228
|
+
function writeCtl(s) {
|
|
229
|
+
const buf = Buffer.from(s, "utf8");
|
|
230
|
+
for (let off = 0, tries = 0; off < buf.length && tries < 100; tries++) {
|
|
231
|
+
try {
|
|
232
|
+
off += fs.writeSync(2, buf, off);
|
|
233
|
+
} catch (e) {
|
|
234
|
+
if (e?.code !== "EAGAIN") return;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
179
239
|
function clearLine() {
|
|
180
|
-
if (Atomics.load(state, DREW))
|
|
240
|
+
if (Atomics.load(state, DREW)) writeCtl("\r\x1b[K");
|
|
181
241
|
}
|
|
182
242
|
|
|
183
243
|
function stop() {
|
|
@@ -187,7 +247,7 @@ if (enabled) {
|
|
|
187
247
|
clearLine();
|
|
188
248
|
// Only give the cursor back if Pi hasn't deliberately hidden it — the TUI hides it
|
|
189
249
|
// for the whole session and would never get the chance to hide it again.
|
|
190
|
-
if (Atomics.load(state, DREW) && !appHidCursor)
|
|
250
|
+
if (Atomics.load(state, DREW) && !appHidCursor) writeCtl("\x1b[?25h");
|
|
191
251
|
process.stdout.write = outWrite;
|
|
192
252
|
err.write = errWrite;
|
|
193
253
|
worker.terminate();
|
|
@@ -246,10 +306,12 @@ if (enabled) {
|
|
|
246
306
|
} catch (e) {
|
|
247
307
|
if (e?.code !== "EIO") throw e;
|
|
248
308
|
stop();
|
|
249
|
-
errWrite(
|
|
309
|
+
// writeCtl, not errWrite: process.exit() below does not flush a stream write that
|
|
310
|
+
// Windows has merely queued, and this message is the only thing the user gets.
|
|
311
|
+
writeCtl(
|
|
250
312
|
[
|
|
251
313
|
"",
|
|
252
|
-
|
|
314
|
+
` ${ANCHOR} Privateer couldn't take the helm — this terminal stopped accepting keyboard`,
|
|
253
315
|
" control while the agent was still loading (setRawMode EIO).",
|
|
254
316
|
"",
|
|
255
317
|
" That usually means the window, tab or ssh session it started in went away.",
|
|
@@ -36,7 +36,12 @@ import {
|
|
|
36
36
|
} from "../src/providers/account.ts";
|
|
37
37
|
import { resolveSignedInModel, savedPiDefaultSpec } from "../src/providers/defaultModel.ts";
|
|
38
38
|
import { canOpenBrowser, openInBrowser } from "../src/util/openBrowser.ts";
|
|
39
|
-
import {
|
|
39
|
+
import {
|
|
40
|
+
contextStats,
|
|
41
|
+
estimateTokens,
|
|
42
|
+
fmtBytes,
|
|
43
|
+
onContextChanged,
|
|
44
|
+
} from "../src/context.ts";
|
|
40
45
|
import { onPackUpdatesChanged, pendingCliUpdate, pendingPackUpdates } from "../src/updates.ts";
|
|
41
46
|
import { type Palette, paletteFor } from "../src/ui/palette.ts";
|
|
42
47
|
|
|
@@ -257,7 +262,7 @@ function packNotice(p: Palette): string {
|
|
|
257
262
|
// quiet tease that /init scaffolds one. Reads the filesystem at render time, so it
|
|
258
263
|
// reflects the current cwd and updates after /init (via onContextChanged → refresh).
|
|
259
264
|
function contextLine(p: Palette): string {
|
|
260
|
-
const files =
|
|
265
|
+
const { files, loadedBytes, truncated } = contextStats();
|
|
261
266
|
if (files.length === 0) {
|
|
262
267
|
return `${p.DIM}no PRIVATEER.md · ${p.INK}/init${p.DIM} to add project context${p.RESET}`;
|
|
263
268
|
}
|
|
@@ -265,7 +270,14 @@ function contextLine(p: Palette): string {
|
|
|
265
270
|
// with a "+N" so the header stays one line but the count isn't hidden.
|
|
266
271
|
const nearest = shortPath(files[files.length - 1].path);
|
|
267
272
|
const more = files.length > 1 ? `${p.DIM} +${files.length - 1}${p.RESET}` : "";
|
|
268
|
-
|
|
273
|
+
// What this costs on every single turn. A context file is the one thing a user adds to
|
|
274
|
+
// the agent whose price is charged per request and shown nowhere — so show it here, and
|
|
275
|
+
// say so loudly when a file was too big to load whole.
|
|
276
|
+
const tok = `${(estimateTokens(loadedBytes) / 1000).toFixed(1)}k`;
|
|
277
|
+
const cost = truncated
|
|
278
|
+
? `${p.YELLOW} · too big, ${fmtBytes(loadedBytes)} loaded · ${p.INK}/context${p.RESET}`
|
|
279
|
+
: `${p.DIM} · ~${tok} tok/turn${p.RESET}`;
|
|
280
|
+
return `${p.GREEN}⚓${p.DIM} ${p.RESET}${p.INK}${nearest}${p.RESET}${more}${cost}`;
|
|
269
281
|
}
|
|
270
282
|
|
|
271
283
|
// ── "What's New" — a tiny in-banner changelog ────────────────────────────────
|
|
@@ -15,9 +15,13 @@
|
|
|
15
15
|
|
|
16
16
|
import {
|
|
17
17
|
contextBlock,
|
|
18
|
+
contextStats,
|
|
19
|
+
estimateTokens,
|
|
20
|
+
fmtBytes,
|
|
18
21
|
writeTemplate,
|
|
19
22
|
emitContextChanged,
|
|
20
23
|
CONTEXT_BLOCK_MARKER,
|
|
24
|
+
CONTEXT_MAX_BYTES_ENV,
|
|
21
25
|
RUNTIME_GUIDELINES_MARKER,
|
|
22
26
|
runtimeGuidelinesBlock,
|
|
23
27
|
} from "../src/context.ts";
|
|
@@ -58,6 +62,44 @@ export default function privateerContext(pi: any): void {
|
|
|
58
62
|
return { systemPrompt: prompt };
|
|
59
63
|
});
|
|
60
64
|
|
|
65
|
+
// /context — what the project-context files cost, per turn.
|
|
66
|
+
//
|
|
67
|
+
// The cost of a context file is invisible: it is charged inside the system prompt of
|
|
68
|
+
// every request, so a file that grew to 117 KB reads as "the model got slow", never as
|
|
69
|
+
// "I am sending 34,000 tokens per tool call". This command is the answer to that — it
|
|
70
|
+
// names each file, what reaches the model, and what was left behind.
|
|
71
|
+
pi.registerCommand?.("context", {
|
|
72
|
+
description: "Show the PRIVATEER.md files loaded into every turn, and what they cost",
|
|
73
|
+
handler: (_args: string, ctx: any) => {
|
|
74
|
+
const { files, diskBytes, loadedBytes, truncated, maxBytes } = contextStats(process.cwd());
|
|
75
|
+
if (files.length === 0) {
|
|
76
|
+
ctx?.ui?.notify?.(
|
|
77
|
+
`No PRIVATEER.md found for ${process.cwd()} — /init writes a starter one.`,
|
|
78
|
+
"info",
|
|
79
|
+
);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const lines = files.map((f) => {
|
|
83
|
+
const cost = `${fmtBytes(f.loadedBytes)} ≈ ${estimateTokens(f.loadedBytes).toLocaleString()} tokens/turn`;
|
|
84
|
+
const cut = f.truncated ? ` ⚠ TRUNCATED from ${fmtBytes(f.bytes)}` : "";
|
|
85
|
+
return ` ${f.path}\n ${cost}${cut}`;
|
|
86
|
+
});
|
|
87
|
+
const cap = Number.isFinite(maxBytes)
|
|
88
|
+
? `${fmtBytes(maxBytes)} per file (${CONTEXT_MAX_BYTES_ENV}=off to load them whole)`
|
|
89
|
+
: `none — ${CONTEXT_MAX_BYTES_ENV} disabled the cap`;
|
|
90
|
+
const total =
|
|
91
|
+
`Total on disk ${fmtBytes(diskBytes)} · sent every turn ${fmtBytes(loadedBytes)} ` +
|
|
92
|
+
`≈ ${estimateTokens(loadedBytes).toLocaleString()} tokens.`;
|
|
93
|
+
const advice = truncated
|
|
94
|
+
? "\n\nA context file is re-sent in full on every tool call, and confidential (TEE) endpoints cannot cache it — so this is paid again on each one. Split the history out into an ARCHIVE.md the agent reads only when asked."
|
|
95
|
+
: "";
|
|
96
|
+
ctx?.ui?.notify?.(
|
|
97
|
+
`Project context loaded into every turn:\n${lines.join("\n")}\n\n${total}\nCap: ${cap}.${advice}`,
|
|
98
|
+
truncated ? "warning" : "info",
|
|
99
|
+
);
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
|
|
61
103
|
// /init — scaffold a PRIVATEER.md in the working directory. Never clobbers an existing
|
|
62
104
|
// one; on success we signal the banner so its "PRIVATEER.md loaded" line updates now
|
|
63
105
|
// (the file is picked up automatically on the next turn — no reload needed).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "privateer-agent",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.33",
|
|
4
4
|
"description": "Privacy-first terminal coding agent — bring your own model across 20 providers (Anthropic, OpenAI, OpenRouter, Google, local Ollama…). Safe-by-default permissions, MCP, sub-agents, workflows, and verifiable TEE inference. Built on the Pi toolkit.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/context.ts
CHANGED
|
@@ -28,6 +28,101 @@ const CANDIDATES = ["PRIVATEER.md", "PRIVATEER.MD"];
|
|
|
28
28
|
export interface ContextFile {
|
|
29
29
|
path: string;
|
|
30
30
|
content: string;
|
|
31
|
+
bytes: number; // full size on disk, before any budget is applied
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── per-turn context budget ──────────────────────────────────────────────────
|
|
35
|
+
//
|
|
36
|
+
// A project-context file is not read once. It is re-sent, in full, inside the system
|
|
37
|
+
// prompt of EVERY turn — so its size is not a one-off cost, it is a per-tool-call tax on
|
|
38
|
+
// latency and on price. That is invisible from the outside, and it does not stay small on
|
|
39
|
+
// its own: a PRIVATEER.md accretes changelogs and retired build notes until it is a
|
|
40
|
+
// design document.
|
|
41
|
+
//
|
|
42
|
+
// Measured, and the reason this cap exists: a 117 KB PRIVATEER.md in a game project put
|
|
43
|
+
// ~34,000 tokens into every request. On the confidential routes (Phala, Tinfoil, NEAR)
|
|
44
|
+
// there is no prompt cache to read them back out of — an enclave answers statelessly — so
|
|
45
|
+
// all 34,000 were re-processed from scratch on each of the 121 tool calls in a single
|
|
46
|
+
// turn. The turn took 17 minutes, nearly all of it time-to-first-token.
|
|
47
|
+
//
|
|
48
|
+
// So: load the head of an oversized file and tell the model, in the block itself, where
|
|
49
|
+
// the rest is. Truncation is loud (the banner and /context both say so) and it is
|
|
50
|
+
// defeatable (PRIVATEER_CONTEXT_MAX_BYTES=off) — but the default has to be a number,
|
|
51
|
+
// because the failure mode is a user who never learns why their agent got slow.
|
|
52
|
+
export const DEFAULT_CONTEXT_MAX_BYTES = 32 * 1024; // ~8k tokens per turn
|
|
53
|
+
export const CONTEXT_MAX_BYTES_ENV = "PRIVATEER_CONTEXT_MAX_BYTES";
|
|
54
|
+
|
|
55
|
+
// Never cut below this, whatever the env says — a cap small enough to amputate the first
|
|
56
|
+
// heading is worse than no cap at all.
|
|
57
|
+
const MIN_CONTEXT_MAX_BYTES = 2 * 1024;
|
|
58
|
+
|
|
59
|
+
/** The per-file byte budget: the env override when it parses, else the default. */
|
|
60
|
+
export function contextMaxBytes(): number {
|
|
61
|
+
const raw = (process.env[CONTEXT_MAX_BYTES_ENV] ?? "").trim().toLowerCase();
|
|
62
|
+
if (raw === "") return DEFAULT_CONTEXT_MAX_BYTES;
|
|
63
|
+
if (raw === "off" || raw === "false" || raw === "0" || raw === "none") {
|
|
64
|
+
return Number.POSITIVE_INFINITY; // opt out: load whatever is on disk
|
|
65
|
+
}
|
|
66
|
+
const n = Number(raw);
|
|
67
|
+
if (!Number.isFinite(n) || n <= 0) return DEFAULT_CONTEXT_MAX_BYTES; // typo → default
|
|
68
|
+
return Math.max(n, MIN_CONTEXT_MAX_BYTES);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Rough token count for a byte size. ~4 bytes/token is close enough to budget with. */
|
|
72
|
+
export function estimateTokens(bytes: number): number {
|
|
73
|
+
return Math.round(bytes / 4);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface BudgetedFile extends ContextFile {
|
|
77
|
+
loaded: string; // what actually goes into the prompt (the head, plus a footer, if cut)
|
|
78
|
+
loadedBytes: number; // size of the file's own content in `loaded`, footer excluded
|
|
79
|
+
truncated: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Cut at a line boundary inside the budget so the model never sees half a sentence, and
|
|
83
|
+
// keep the head rather than the tail: a context file opens with what the project IS.
|
|
84
|
+
function budgetFile(file: ContextFile, max: number): BudgetedFile {
|
|
85
|
+
if (file.bytes <= max) {
|
|
86
|
+
return { ...file, loaded: file.content, loadedBytes: file.bytes, truncated: false };
|
|
87
|
+
}
|
|
88
|
+
const head = Buffer.from(file.content, "utf-8").subarray(0, max).toString("utf-8");
|
|
89
|
+
const lastBreak = head.lastIndexOf("\n");
|
|
90
|
+
const kept = lastBreak > max / 2 ? head.slice(0, lastBreak) : head;
|
|
91
|
+
const keptBytes = Buffer.byteLength(kept, "utf-8");
|
|
92
|
+
const footer =
|
|
93
|
+
`\n\n[Privateer loaded the first ${fmtBytes(keptBytes)} of this ${fmtBytes(file.bytes)} file. ` +
|
|
94
|
+
`A project-context file is re-sent to the model on EVERY turn, so the rest was left ` +
|
|
95
|
+
`out to keep turns fast — read ${file.path} directly if you need what is missing, and ` +
|
|
96
|
+
`tell the user the file is worth splitting.]`;
|
|
97
|
+
return { ...file, loaded: kept + footer, loadedBytes: keptBytes, truncated: true };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Human byte size for prompts, the banner and /context. */
|
|
101
|
+
export function fmtBytes(n: number): string {
|
|
102
|
+
if (n < 1024) return `${n} B`;
|
|
103
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(n < 10 * 1024 ? 1 : 0)} KB`;
|
|
104
|
+
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface ContextStats {
|
|
108
|
+
files: BudgetedFile[];
|
|
109
|
+
diskBytes: number; // what is on disk across every discovered file
|
|
110
|
+
loadedBytes: number; // what actually reaches the model each turn
|
|
111
|
+
truncated: boolean; // at least one file was cut
|
|
112
|
+
maxBytes: number; // the budget in force
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Discovery + budget in one call — what the banner and /context both report on. */
|
|
116
|
+
export function contextStats(cwd: string = process.cwd()): ContextStats {
|
|
117
|
+
const max = contextMaxBytes();
|
|
118
|
+
const files = discoverContextFiles(cwd).map((f) => budgetFile(f, max));
|
|
119
|
+
return {
|
|
120
|
+
files,
|
|
121
|
+
diskBytes: files.reduce((n, f) => n + f.bytes, 0),
|
|
122
|
+
loadedBytes: files.reduce((n, f) => n + f.loadedBytes, 0),
|
|
123
|
+
truncated: files.some((f) => f.truncated),
|
|
124
|
+
maxBytes: max,
|
|
125
|
+
};
|
|
31
126
|
}
|
|
32
127
|
|
|
33
128
|
// The global agent dir the launcher points Pi at (PRIVATEER_HOME/agent). We read the
|
|
@@ -46,7 +141,8 @@ function readCandidate(dir: string): ContextFile | null {
|
|
|
46
141
|
const path = join(dir, name);
|
|
47
142
|
if (existsSync(path)) {
|
|
48
143
|
try {
|
|
49
|
-
|
|
144
|
+
const content = readFileSync(path, "utf-8");
|
|
145
|
+
return { path, content, bytes: Buffer.byteLength(content, "utf-8") };
|
|
50
146
|
} catch {
|
|
51
147
|
// unreadable (perms, races) — skip silently; the model just won't see it.
|
|
52
148
|
}
|
|
@@ -100,6 +196,8 @@ export function runtimeGuidelinesBlock(): string {
|
|
|
100
196
|
- Node.js environment: Node.js (v22+) is guaranteed to be available in Privateer. Prefer \`node -e "..."\` or small Node.js scripts for quick scripting, calculations, or JSON processing instead of assuming \`python\` or \`python3\` is installed.
|
|
101
197
|
- Search fallback: If \`rg\` (ripgrep) is missing or returns "command not found", fall back to standard POSIX \`grep -rn <pattern> <path>\` or \`find <path>\`.
|
|
102
198
|
- Missing system dependencies: If an essential external tool (e.g. \`git\`, \`python\`, \`rg\`) is missing and needed, check its presence (\`command -v <tool>\`), explain clearly what is missing, and offer to install it using the host package manager (e.g. \`brew install\`, \`xcode-select --install\`, \`winget install\`, \`apt install\`) upon user approval.
|
|
199
|
+
- Shell calls share no state: every shell call runs in a fresh subshell rooted at the session's working directory, so a \`cd\` (or a variable, or an activated venv) in one call is gone by the next. Use absolute paths, or move and work in ONE call (\`cd <dir> && <command>\`). Never spend a call on \`cd\` alone.
|
|
200
|
+
- Batch your steps: every tool call re-sends the whole conversation to the model, so a chain of one-line calls is far slower and more expensive than the same work combined into fewer calls. Group independent reads, searches and checks into one command; do not build test harnesses, scratch scripts or regression runs that the user did not ask for.
|
|
103
201
|
</environment_guidelines>\n`;
|
|
104
202
|
}
|
|
105
203
|
|
|
@@ -107,11 +205,11 @@ export function runtimeGuidelinesBlock(): string {
|
|
|
107
205
|
// applies to AGENTS.md (see core/system-prompt.js), so the model can't tell the two
|
|
108
206
|
// apart. Returns "" when there's nothing to inject.
|
|
109
207
|
export function contextBlock(cwd: string = process.cwd()): string {
|
|
110
|
-
const files =
|
|
208
|
+
const { files } = contextStats(cwd);
|
|
111
209
|
if (files.length === 0) return "";
|
|
112
210
|
let out = `\n\n${CONTEXT_BLOCK_MARKER}\n<project_context>\n\nProject-specific instructions and guidelines:\n\n`;
|
|
113
|
-
for (const { path,
|
|
114
|
-
out += `<project_instructions path="${path}">\n${
|
|
211
|
+
for (const { path, loaded } of files) {
|
|
212
|
+
out += `<project_instructions path="${path}">\n${loaded}\n</project_instructions>\n\n`;
|
|
115
213
|
}
|
|
116
214
|
out += "</project_context>\n";
|
|
117
215
|
return out;
|