privateer-agent 0.12.3 → 0.12.6
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/README.md +74 -1
- package/extensions/privateer-brand.ts +79 -6
- package/extensions/privateer-hints.ts +152 -0
- package/extensions/privateer-speak.ts +124 -0
- package/package.json +4 -3
- package/src/config/moatManifest.json +4 -4
package/README.md
CHANGED
|
@@ -125,6 +125,10 @@ silently. The moat is swappable; the floor under it holds.
|
|
|
125
125
|
shots stay continuous, cut them together, then score and narrate the result. Generated media
|
|
126
126
|
is handed straight back as files on your machine; none of it is stored in our cloud. See
|
|
127
127
|
[docs/media-generation.md](docs/media-generation.md).
|
|
128
|
+
- **Talk to it.** `/speak on` reads answers aloud as they're written; **ctrl+x** is push-to-talk
|
|
129
|
+
and the mic closes when you stop talking. Your OS voice by default — nothing leaves the
|
|
130
|
+
machine — or your account's confidential-compute TTS/STT once you sign in. `/talk loop on`
|
|
131
|
+
makes it hands-free. See [Talk to it](#talk-to-it--voice-both-directions).
|
|
128
132
|
- **MCP servers, sub-agents & skills.** Connect Model Context Protocol servers (local stdio
|
|
129
133
|
or remote HTTP with OAuth) with [`/connect`](#connectors--mcp), delegate work to bounded
|
|
130
134
|
parallel sub-agents, and drop in skills — all gated like everything else.
|
|
@@ -288,6 +292,42 @@ with `/signout`; manage linked terminals from the app.
|
|
|
288
292
|
> to spend on your account. If someone sends you a code and asks you to approve it, don't —
|
|
289
293
|
> that hands *them* a billed session on *your* account.
|
|
290
294
|
|
|
295
|
+
## Talk to it — voice, both directions
|
|
296
|
+
|
|
297
|
+
`/speak on` and answers are read aloud **as they are written**, sentence by sentence, with
|
|
298
|
+
code blocks, tables and URLs stripped. Press **ctrl+x** and talk; the mic closes when you
|
|
299
|
+
stop talking and the transcript lands in the composer for you to read before Enter sends it.
|
|
300
|
+
|
|
301
|
+
```
|
|
302
|
+
/speak on read answers aloud ctrl+x push to talk (press again to send)
|
|
303
|
+
/speak voice <name> pick a speaker /talk the same thing, typed
|
|
304
|
+
/speak rate <n> 0.5–3× pace /talk loop on conversation mode, hands-free
|
|
305
|
+
/speak stream off wait for the full answer instead of speaking as it arrives
|
|
306
|
+
/speak provider list engines (→ marks the active one); /talk provider does the same
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
Out of the box it uses your **OS voice** (`say`, `espeak-ng`, System.Speech) and nothing
|
|
310
|
+
leaves the machine. Signed in, both directions quietly upgrade to your account's
|
|
311
|
+
**confidential-compute TTS and STT** — attested-enclave models, the same ones the app's voice
|
|
312
|
+
features use, inherited entitlement and billing. That is an upgrade, not a stomp: a provider
|
|
313
|
+
you picked deliberately stays picked, and `/speak provider local` pins the offline voice for
|
|
314
|
+
good. Be clear-eyed about the difference — the local voice never speaks to the network, while
|
|
315
|
+
the account voice sends the utterance text to an enclave we can't read into but that is still
|
|
316
|
+
off your machine.
|
|
317
|
+
|
|
318
|
+
One caveat while the account endpoints catch up: `/speak rate` and `/talk vocab` are honored
|
|
319
|
+
by the local and OpenAI-compatible engines, not yet by the account's TTS/STT.
|
|
320
|
+
|
|
321
|
+
Voice is **off by default and interactive-only** — harbor, ACP and channel sessions never
|
|
322
|
+
speak. The mic opens only on something you did (`/talk`, the push-to-talk key, or a
|
|
323
|
+
conversation turn you switched on); there is no wake word, capture is hard-capped, audio is
|
|
324
|
+
held in memory and never written to disk, and a mis-heard transcript is still just a prompt —
|
|
325
|
+
every tool call it leads to hits the same permission gate as anything you type. Recording
|
|
326
|
+
needs a capture tool on PATH: `sox` anywhere, or `arecord`/`parecord`/`ffmpeg` on Linux,
|
|
327
|
+
`ffmpeg` on macOS. Everything is stored in `~/.privateer/speak.json`; the engine itself is the
|
|
328
|
+
standalone [`privateer-speak`](https://www.npmjs.com/package/privateer-speak) package, usable
|
|
329
|
+
in any Pi agent.
|
|
330
|
+
|
|
291
331
|
## The Privateer app
|
|
292
332
|
|
|
293
333
|
The same account drives Privateer from **iOS, Android, [the web app](https://privateer.pro),
|
|
@@ -390,6 +430,37 @@ the app are write-only — the app can name them but never read them back. Note
|
|
|
390
430
|
live in plaintext in `config.json` on your machine, and every channel action is appended to
|
|
391
431
|
`~/.privateer/channels-audit.log`.
|
|
392
432
|
|
|
433
|
+
## Drive it from Buzz or Zed — ACP
|
|
434
|
+
|
|
435
|
+
Privateer speaks the [Agent Client Protocol](https://agentclientprotocol.com) (ACP v1 over
|
|
436
|
+
stdio). Any ACP host — [Buzz](https://buzz.xyz), Block's team messenger where agents are
|
|
437
|
+
teammates, or the [Zed](https://zed.dev) editor — can spawn `privateer acp` and drive it:
|
|
438
|
+
prompts stream back, tool activity shows live, and the host's model picker lists Privateer's
|
|
439
|
+
catalog with TEE-backed models labelled **confidential (TEE)**.
|
|
440
|
+
|
|
441
|
+
The part we care about: **the host renders the UI, but authority never leaves your machine.**
|
|
442
|
+
|
|
443
|
+
- Every action is classified by Privateer's own permission gate — ACP's
|
|
444
|
+
`session/request_permission` only delivers the prompt.
|
|
445
|
+
- The **tool ceiling** comes from your local config, not the host, and ships **read-only**
|
|
446
|
+
(`read`, `grep`, `find`, `ls`). The host cannot widen it.
|
|
447
|
+
- Filesystem access is **confined to one directory root**; out-of-tree access is refused,
|
|
448
|
+
not prompted.
|
|
449
|
+
- Every ambiguous outcome — unreachable host, cancelled dialog, unknown answer, aborted
|
|
450
|
+
turn — resolves to **deny**.
|
|
451
|
+
- "Allow for this session" lives in memory and dies with the session; dangerous shell
|
|
452
|
+
(`curl … | sh` and friends) can never become standing permission.
|
|
453
|
+
|
|
454
|
+
Zed setup (`settings.json`):
|
|
455
|
+
|
|
456
|
+
```json
|
|
457
|
+
{ "agent_servers": { "Privateer": { "command": "privateer", "args": ["acp"] } } }
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
Honest caveat for Buzz: **Buzz currently auto-approves permission prompts**, so under Buzz
|
|
461
|
+
the tool ceiling *is* the control — which is exactly why the default is read-only. Full
|
|
462
|
+
setup, config, and limitations: [`docs/acp.md`](docs/acp.md).
|
|
463
|
+
|
|
393
464
|
## Connectors — MCP
|
|
394
465
|
|
|
395
466
|
Privateer is an **MCP client**. Point it at a [Model Context Protocol](https://modelcontextprotocol.io)
|
|
@@ -538,12 +609,14 @@ drop your own into `~/.privateer/agent/extensions/` and it loads the same way, g
|
|
|
538
609
|
| `/signin` · `/signout` | sign in to a Privateer account (device flow) / sign out |
|
|
539
610
|
| `/remote-access` | link this terminal to the app and allow it to drive (off by default) |
|
|
540
611
|
| `/connect` · `/mcp` | add, enable, or remove MCP connectors / see what actually connected |
|
|
612
|
+
| `/speak` · `/talk` | read answers aloud / voice input (**ctrl+x** is push-to-talk) |
|
|
541
613
|
| `/extensions` | list loaded Pi extensions |
|
|
542
614
|
| `/init` | scaffold a starter `PRIVATEER.md` in this directory |
|
|
543
615
|
| `/update` · `/privateer` | update to the latest release / Privateer status and posture |
|
|
544
616
|
|
|
545
617
|
Shell subcommands: `privateer` (interactive), `privateer update`, `privateer harbor …`,
|
|
546
|
-
`privateer
|
|
618
|
+
`privateer acp` (serve the agent to an ACP host like Buzz or Zed — see
|
|
619
|
+
[`docs/acp.md`](docs/acp.md)), `privateer --no-quarter`, `privateer --version`.
|
|
547
620
|
|
|
548
621
|
## Develop
|
|
549
622
|
|
|
@@ -107,10 +107,80 @@ function buildMark(p: Palette): string[] {
|
|
|
107
107
|
return rows;
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
//
|
|
111
|
-
//
|
|
110
|
+
// Code points that terminals render two cells wide. Two groups matter to us: the CJK /
|
|
111
|
+
// fullwidth ranges, and the symbols Unicode gives *emoji presentation by default* — the
|
|
112
|
+
// second group is the trap, because they look like ordinary BMP dingbats. ⚓ (U+2693) is
|
|
113
|
+
// one, and counting it as one cell pushed the banner's right border a column past the
|
|
114
|
+
// frame on the PRIVATEER.md row. The list is the complete BMP Emoji_Presentation set plus
|
|
115
|
+
// the astral emoji planes, so swapping in another glyph can't quietly reintroduce that.
|
|
116
|
+
const WIDE_RANGES: Array<[number, number]> = [
|
|
117
|
+
[0x1100, 0x115f], [0x231a, 0x231b], [0x2329, 0x232a], [0x23e9, 0x23ec], [0x23f0, 0x23f0],
|
|
118
|
+
[0x23f3, 0x23f3], [0x25fd, 0x25fe], [0x2614, 0x2615], [0x2648, 0x2653], [0x267f, 0x267f],
|
|
119
|
+
[0x2693, 0x2693], [0x26a1, 0x26a1], [0x26aa, 0x26ab], [0x26bd, 0x26be], [0x26c4, 0x26c5],
|
|
120
|
+
[0x26ce, 0x26ce], [0x26d4, 0x26d4], [0x26ea, 0x26ea], [0x26f2, 0x26f3], [0x26f5, 0x26f5],
|
|
121
|
+
[0x26fa, 0x26fa], [0x26fd, 0x26fd], [0x2705, 0x2705], [0x270a, 0x270b], [0x2728, 0x2728],
|
|
122
|
+
[0x274c, 0x274c], [0x274e, 0x274e], [0x2753, 0x2755], [0x2757, 0x2757], [0x2795, 0x2797],
|
|
123
|
+
[0x27b0, 0x27b0], [0x27bf, 0x27bf], [0x2b1b, 0x2b1c], [0x2b50, 0x2b50], [0x2b55, 0x2b55],
|
|
124
|
+
[0x2e80, 0x303e], [0x3041, 0x33ff], [0x3400, 0x4dbf], [0x4e00, 0xa4cf], [0xa960, 0xa97f],
|
|
125
|
+
[0xac00, 0xd7a3], [0xf900, 0xfaff], [0xfe10, 0xfe19], [0xfe30, 0xfe6f], [0xff00, 0xff60],
|
|
126
|
+
[0xffe0, 0xffe6], [0x1f300, 0x1f64f], [0x1f680, 0x1f6ff], [0x1f900, 0x1f9ff],
|
|
127
|
+
[0x1fa70, 0x1faff],
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
function isWide(cp: number): boolean {
|
|
131
|
+
return WIDE_RANGES.some(([lo, hi]) => cp >= lo && cp <= hi);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Visible width in terminal cells: SGR escapes are free, combining marks are free, and
|
|
135
|
+
// wide glyphs cost two. A variation selector re-negotiates the *previous* glyph's
|
|
136
|
+
// presentation (FE0F → emoji/wide, FE0E → text/narrow), so it adjusts the running total
|
|
137
|
+
// rather than contributing width of its own.
|
|
112
138
|
function vlen(s: string): number {
|
|
113
|
-
|
|
139
|
+
const plain = s.replace(/\x1b\[[0-9;]*m/g, "");
|
|
140
|
+
let w = 0;
|
|
141
|
+
let prevWide = false;
|
|
142
|
+
for (const ch of plain) {
|
|
143
|
+
const cp = ch.codePointAt(0)!;
|
|
144
|
+
if (cp === 0xfe0f) {
|
|
145
|
+
if (!prevWide) { w += 1; prevWide = true; }
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (cp === 0xfe0e) {
|
|
149
|
+
if (prevWide) { w -= 1; prevWide = false; }
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
// Combining marks and zero-width joiners/spaces stack onto the previous cell.
|
|
153
|
+
if ((cp >= 0x0300 && cp <= 0x036f) || cp === 0x200b || cp === 0x200d || cp === 0xfeff) continue;
|
|
154
|
+
prevWide = isWide(cp);
|
|
155
|
+
w += prevWide ? 2 : 1;
|
|
156
|
+
}
|
|
157
|
+
return w;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Clip a colored line to `max` cells. The banner caps its inner width to the terminal, so
|
|
161
|
+
// on a narrow window a long row (a deep cwd, a long update notice) would otherwise run
|
|
162
|
+
// past the right border and wrap — the same broken frame, from the other direction. SGR
|
|
163
|
+
// escapes are copied through free of charge and a RESET is appended so the truncation
|
|
164
|
+
// can't leak a color into the border. A wide glyph straddling the boundary is dropped and
|
|
165
|
+
// replaced by a space, so the count still lands exactly on `max`.
|
|
166
|
+
function vclip(s: string, max: number, reset: string): string {
|
|
167
|
+
if (vlen(s) <= max) return s;
|
|
168
|
+
const parts = s.split(/(\x1b\[[0-9;]*m)/);
|
|
169
|
+
let out = "";
|
|
170
|
+
let w = 0;
|
|
171
|
+
for (const part of parts) {
|
|
172
|
+
if (part.startsWith("\x1b[")) {
|
|
173
|
+
out += part;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
for (const ch of part) {
|
|
177
|
+
const cw = vlen(ch);
|
|
178
|
+
if (w + cw > max) return `${out}${w < max ? " " : ""}${reset}`;
|
|
179
|
+
out += ch;
|
|
180
|
+
w += cw;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return `${out}${reset}`;
|
|
114
184
|
}
|
|
115
185
|
|
|
116
186
|
// Strip ESC/C0/C1 control bytes from any value we did NOT author before it lands in a
|
|
@@ -256,8 +326,9 @@ function renderBanner(width: number, p: Palette, mark: string[], modelProvider?:
|
|
|
256
326
|
const bar = "─".repeat(inner + 2);
|
|
257
327
|
const out = [`${p.BORDER}╭${bar}╮${p.RESET}`];
|
|
258
328
|
for (const row of rows) {
|
|
259
|
-
const
|
|
260
|
-
|
|
329
|
+
const line = vclip(row, inner, p.RESET);
|
|
330
|
+
const pad = Math.max(0, inner - vlen(line));
|
|
331
|
+
out.push(`${p.BORDER}│${p.RESET} ${line}${" ".repeat(pad)} ${p.BORDER}│${p.RESET}`);
|
|
261
332
|
}
|
|
262
333
|
out.push(`${p.BORDER}╰${bar}╯${p.RESET}`);
|
|
263
334
|
return out;
|
|
@@ -267,7 +338,9 @@ function renderBanner(width: number, p: Palette, mark: string[], modelProvider?:
|
|
|
267
338
|
// provider so the account line reflects the picked model, and the live theme so every
|
|
268
339
|
// colour tracks the terminal background (dark ink on light, light ink on dark). The
|
|
269
340
|
// palette and mark are resolved once here (per theme), not per frame.
|
|
270
|
-
|
|
341
|
+
// Exported for tests/banner.test.ts, which renders the real banner and checks the frame
|
|
342
|
+
// is square — the failure mode is a one-cell drift no typecheck can catch.
|
|
343
|
+
export function headerComponent(theme: any, modelProvider?: string) {
|
|
271
344
|
const p = paletteFor(theme);
|
|
272
345
|
const mark = buildMark(p);
|
|
273
346
|
return {
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Rotating tips in the working line. While the agent streams, the "Working..."
|
|
2
|
+
// loader is dead air for seconds at a time — the one spot a discoverability hint
|
|
3
|
+
// costs no vertical space and interrupts nothing. So while a turn is active we
|
|
4
|
+
// rotate short tips through it for the keys and commands nobody finds on their
|
|
5
|
+
// own: ctrl+t to hide thinking text (the ask that prompted this), shift+tab for
|
|
6
|
+
// the thinking level, @file mentions, /models, and so on.
|
|
7
|
+
//
|
|
8
|
+
// Mechanics: ctx.ui.setWorkingMessage(msg) both stores the message for the next
|
|
9
|
+
// working indicator AND live-updates the one currently on screen (and ONLY the
|
|
10
|
+
// "working" kind — Pi's guard means retry/compaction spinners are never touched),
|
|
11
|
+
// so a timer can rotate the text mid-stream. Calling it with no argument restores
|
|
12
|
+
// Pi's default "Working...", which is what agent_end does. Timers run strictly
|
|
13
|
+
// inside agent_start → agent_end, so an idle terminal owns no message at all.
|
|
14
|
+
//
|
|
15
|
+
// Keys render through pi-coding-agent's keyText, which reads the LIVE keybindings
|
|
16
|
+
// singleton the running app configured — a user remap shows the remapped key, and
|
|
17
|
+
// each hint carries a fallback for an unbound action. The first tip waits a beat
|
|
18
|
+
// (FIRST_MS) so short turns never see one; the cursor is module-lifetime so
|
|
19
|
+
// back-to-back turns continue the rotation instead of repeating tip #1.
|
|
20
|
+
//
|
|
21
|
+
// /hints on|off persists { hints: { enabled } } in ~/.privateer/config.json (ours;
|
|
22
|
+
// Pi never reads that file) — read-modify-write, preserving unrelated keys.
|
|
23
|
+
|
|
24
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
import { keyText } from "@earendil-works/pi-coding-agent";
|
|
27
|
+
import { configPath, globalDir } from "../src/config/paths.ts";
|
|
28
|
+
|
|
29
|
+
const FIRST_MS = 6_000; // a turn shorter than this never shows a tip
|
|
30
|
+
const EVERY_MS = 12_000;
|
|
31
|
+
|
|
32
|
+
// keyText returns "" for an unbound action and could throw if the keybindings
|
|
33
|
+
// singleton isn't initialized yet (headless surfaces) — fall back either way.
|
|
34
|
+
function key(binding: Parameters<typeof keyText>[0], fallback: string): string {
|
|
35
|
+
try {
|
|
36
|
+
return keyText(binding) || fallback;
|
|
37
|
+
} catch {
|
|
38
|
+
return fallback;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Push-to-talk is pi-speak's own binding, not a Pi keybinding action, so keyText
|
|
43
|
+
// can't see it — it lives in speak.json beside config.json and /talk key rebinds
|
|
44
|
+
// it. Read it the same defensive way (any failure = the default).
|
|
45
|
+
function talkKey(): string {
|
|
46
|
+
try {
|
|
47
|
+
const shortcut = JSON.parse(readFileSync(join(globalDir(), "speak.json"), "utf8"))?.input?.shortcut;
|
|
48
|
+
// An explicit "" is "unbound", not "missing" — only an absent field defaults.
|
|
49
|
+
return typeof shortcut === "string" ? shortcut : "ctrl+x";
|
|
50
|
+
} catch {
|
|
51
|
+
return "ctrl+x";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Lazy thunks, not strings: keys resolve at display time, after the app has
|
|
56
|
+
// loaded (and possibly remapped) its keybindings.
|
|
57
|
+
const HINTS: Array<() => string> = [
|
|
58
|
+
() => `${key("app.thinking.toggle", "ctrl+t")} hides thinking text — make it stick in /settings`,
|
|
59
|
+
() => `${key("app.thinking.cycle", "shift+tab")} cycles the thinking level`,
|
|
60
|
+
() => `${key("app.message.followUp", "alt+enter")} queues a follow-up without interrupting`,
|
|
61
|
+
() => `${key("app.tools.expand", "ctrl+o")} expands collapsed tool output`,
|
|
62
|
+
() => `type @ to reference a file in your prompt — Tab completes the path`,
|
|
63
|
+
() => `/models picks a model, with TEE/ZDR privacy shields`,
|
|
64
|
+
() => `/init writes a PRIVATEER.md so the agent knows this project`,
|
|
65
|
+
() => `/connect adds MCP connectors to this terminal`,
|
|
66
|
+
() => `${key("app.editor.external", "ctrl+g")} drafts long prompts in your $EDITOR`,
|
|
67
|
+
() => {
|
|
68
|
+
const k = talkKey();
|
|
69
|
+
return k
|
|
70
|
+
? `${k} is push-to-talk — /speak on reads the answer back`
|
|
71
|
+
: `/talk types what you say — /speak on reads the answer back`;
|
|
72
|
+
},
|
|
73
|
+
() => `these tips are /hints — /hints off silences them`,
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
// Default ON: absent file, absent block, or unreadable JSON all mean enabled.
|
|
77
|
+
// Only an explicit { hints: { enabled: false } } turns the rotation off.
|
|
78
|
+
function hintsEnabled(): boolean {
|
|
79
|
+
try {
|
|
80
|
+
return JSON.parse(readFileSync(configPath(), "utf8"))?.hints?.enabled !== false;
|
|
81
|
+
} catch {
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function persistEnabled(enabled: boolean): void {
|
|
87
|
+
let cfg: any = {};
|
|
88
|
+
try {
|
|
89
|
+
cfg = JSON.parse(readFileSync(configPath(), "utf8"));
|
|
90
|
+
} catch {
|
|
91
|
+
/* no config yet */
|
|
92
|
+
}
|
|
93
|
+
if (typeof cfg !== "object" || cfg === null || Array.isArray(cfg)) cfg = {};
|
|
94
|
+
cfg.hints = { ...(cfg.hints ?? {}), enabled };
|
|
95
|
+
try {
|
|
96
|
+
mkdirSync(globalDir(), { recursive: true });
|
|
97
|
+
writeFileSync(configPath(), JSON.stringify(cfg, null, 2) + "\n");
|
|
98
|
+
} catch {
|
|
99
|
+
/* best effort — the toggle still holds for this session */
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export default function privateerHints(pi: any): void {
|
|
104
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
105
|
+
let uiRef: any;
|
|
106
|
+
let cursor = 0;
|
|
107
|
+
|
|
108
|
+
const stop = (restoreDefault: boolean): void => {
|
|
109
|
+
if (timer !== undefined) {
|
|
110
|
+
clearTimeout(timer);
|
|
111
|
+
timer = undefined;
|
|
112
|
+
}
|
|
113
|
+
if (restoreDefault) uiRef?.setWorkingMessage?.();
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const showNext = (): void => {
|
|
117
|
+
const hint = HINTS[cursor % HINTS.length]();
|
|
118
|
+
cursor++;
|
|
119
|
+
uiRef?.setWorkingMessage?.(`Working... · tip: ${hint}`);
|
|
120
|
+
timer = setTimeout(showNext, EVERY_MS);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
pi.on("agent_start", (_e: any, ctx: any) => {
|
|
124
|
+
if (!ctx?.hasUI) return; // headless (print/rpc/harbor): no loader to write to
|
|
125
|
+
uiRef = ctx.ui;
|
|
126
|
+
stop(false); // an interrupted turn can restart without an agent_end between
|
|
127
|
+
if (!hintsEnabled()) return;
|
|
128
|
+
timer = setTimeout(showNext, FIRST_MS);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
pi.on("agent_end", () => stop(true));
|
|
132
|
+
pi.on("session_shutdown", () => stop(false));
|
|
133
|
+
|
|
134
|
+
pi.registerCommand?.("hints", {
|
|
135
|
+
description: "Rotating tips in the working line: /hints [on | off]",
|
|
136
|
+
handler: (args: string, ctx: any) => {
|
|
137
|
+
uiRef = ctx?.ui ?? uiRef;
|
|
138
|
+
const sub = String(args ?? "").trim().toLowerCase().split(/\s+/)[0];
|
|
139
|
+
if (sub === "on" || sub === "off") {
|
|
140
|
+
const enabled = sub === "on";
|
|
141
|
+
persistEnabled(enabled);
|
|
142
|
+
if (!enabled) stop(true); // takes effect mid-turn; "on" starts next turn
|
|
143
|
+
ctx?.ui?.notify?.(`Working-line tips ${enabled ? "on" : "off"}.`, "info");
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
ctx?.ui?.notify?.(
|
|
147
|
+
`Working-line tips are ${hintsEnabled() ? "on" : "off"} — /hints on|off to change.`,
|
|
148
|
+
"info",
|
|
149
|
+
);
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Spoken responses AND voice input for the TUI: the generic privateer-speak (pi-speak)
|
|
2
|
+
// extension, plus Privateer's own providers hooked into both its registries — the
|
|
3
|
+
// account's confidential-compute TTS (/api/audio/speech, same path as the app's Audio
|
|
4
|
+
// studio and generate_speech; today Tinfoil qwen3-tts in an attested enclave) and its
|
|
5
|
+
// confidential STT (/api/audio/transcribe, same tinfoil/near routing the app's voice
|
|
6
|
+
// features use). Both inherit the account's entitlement, caps and billing.
|
|
7
|
+
//
|
|
8
|
+
// STREAMING, AND WHAT IT COSTS. Since pi-speak 0.2.0 a turn is spoken while it is still
|
|
9
|
+
// being written: the stream is cut at sentence boundaries and each piece is synthesized
|
|
10
|
+
// a clip or two ahead of playback. For this provider that means SEVERAL /api/audio/speech
|
|
11
|
+
// calls per answer instead of one — same characters, same billing basis, more round
|
|
12
|
+
// trips — which is the trade that makes the reply start in about the time one sentence
|
|
13
|
+
// takes to speak. /speak stream off puts it back to one call at the end of the turn.
|
|
14
|
+
//
|
|
15
|
+
// NOT WIRED, DELIBERATELY. pi-speak passes two hints this account path has nowhere to
|
|
16
|
+
// put: `rate` (0.5–3× pace) and the transcriber's vocabulary `prompt`. /api/audio/speech
|
|
17
|
+
// takes { text, voice, format, ttsModelId } and /api/audio/transcribe takes
|
|
18
|
+
// { audioBase64, format, language, sttModelId } — neither has a speed or bias field — so
|
|
19
|
+
// sending them would be dead weight on the wire (and, for the vocabulary hint, project
|
|
20
|
+
// nouns leaving the machine to be ignored). They stay unsent until the endpoints grow
|
|
21
|
+
// them; both work today on the local and openai-compatible providers.
|
|
22
|
+
//
|
|
23
|
+
// PROVIDER ATTUNEMENT, NOT STOMPING. The provider registers with preferWhen: signed-in,
|
|
24
|
+
// which the registry ranks ABOVE the built-in local voice but BELOW a deliberate /speak
|
|
25
|
+
// provider pick — sign in and speech quietly upgrades from the OS voice to confidential
|
|
26
|
+
// TTS, exactly the resolveSignedInModel pattern, with the model-persistence lesson
|
|
27
|
+
// applied (a user who picked "local" stays on "local").
|
|
28
|
+
//
|
|
29
|
+
// HONEST FRAMING. "Confidential", never "private end-to-end": the utterance text leaves
|
|
30
|
+
// the machine for the attested TTS enclave. That is the claim the label makes and the
|
|
31
|
+
// only one it may make. And this only ever speaks in interactive UI sessions — pi-speak
|
|
32
|
+
// gates on hasUI, so harbor/daemon/ACP surfaces stay silent by design (this extension is
|
|
33
|
+
// manifest-only, like privateer-hints: buildMoat never includes it).
|
|
34
|
+
//
|
|
35
|
+
// VOICES. qwen3-tts speakers, mirrored from the server's TINFOIL_VOICES (the list is
|
|
36
|
+
// static there too; first entry is the server-side default). Tinfoil requires the voice
|
|
37
|
+
// param server-side, so the server applies "serena" when none is sent — leaving voice
|
|
38
|
+
// unset here is always safe. If the account's default TTS model ever changes, update
|
|
39
|
+
// this list with it.
|
|
40
|
+
import { join } from "node:path";
|
|
41
|
+
import { makePiSpeakExtension, registerSpeechProvider, registerTranscriptionProvider } from "privateer-speak";
|
|
42
|
+
import { apiRequest, hasCredentials } from "../src/auth/privateer.ts";
|
|
43
|
+
import { globalDir } from "../src/config/paths.ts";
|
|
44
|
+
|
|
45
|
+
const QWEN3_TTS_VOICES = ["serena", "aiden", "dylan", "eric", "ono_anna", "ryan", "sohee", "uncle_fu", "vivian"];
|
|
46
|
+
|
|
47
|
+
// Call an account audio endpoint and hand back the parsed JSON, throwing the server's
|
|
48
|
+
// own (person-readable, localized) message on failure — same policy as media.ts's
|
|
49
|
+
// callAccount: surface, never swallow.
|
|
50
|
+
async function accountJson<T>(path: string, body: unknown, signal?: AbortSignal): Promise<T> {
|
|
51
|
+
let res: Response;
|
|
52
|
+
try {
|
|
53
|
+
res = await apiRequest(path, {
|
|
54
|
+
method: "POST",
|
|
55
|
+
headers: { "Content-Type": "application/json" },
|
|
56
|
+
body: JSON.stringify(body),
|
|
57
|
+
...(signal ? { signal } : {}),
|
|
58
|
+
});
|
|
59
|
+
} catch (e) {
|
|
60
|
+
throw new Error(`could not reach Privateer: ${e instanceof Error ? e.message : String(e)}`);
|
|
61
|
+
}
|
|
62
|
+
if (!res.ok) {
|
|
63
|
+
let message = `Privateer returned ${res.status}`;
|
|
64
|
+
try {
|
|
65
|
+
const err = (await res.json()) as { message?: string; error?: { message?: string } };
|
|
66
|
+
message = err?.message ?? err?.error?.message ?? message;
|
|
67
|
+
} catch {
|
|
68
|
+
/* non-JSON body — the status message stands */
|
|
69
|
+
}
|
|
70
|
+
throw new Error(message);
|
|
71
|
+
}
|
|
72
|
+
return (await res.json()) as T;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
registerSpeechProvider(
|
|
76
|
+
{
|
|
77
|
+
id: "privateer",
|
|
78
|
+
label: "Privateer account TTS (attested enclave)",
|
|
79
|
+
privacy: "confidential",
|
|
80
|
+
defaultVoice: QWEN3_TTS_VOICES[0],
|
|
81
|
+
voices: () => QWEN3_TTS_VOICES,
|
|
82
|
+
available: () => hasCredentials(),
|
|
83
|
+
fetchSpeech: async (text, { voice, signal }) => {
|
|
84
|
+
const { audioBase64 } = await accountJson<{ audioBase64?: string }>(
|
|
85
|
+
"/api/audio/speech",
|
|
86
|
+
{ text, ...(voice ? { voice } : {}) },
|
|
87
|
+
signal,
|
|
88
|
+
);
|
|
89
|
+
if (!audioBase64) throw new Error("Privateer returned no audio");
|
|
90
|
+
return { data: Buffer.from(audioBase64, "base64"), format: "mp3" };
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
{ preferWhen: () => hasCredentials() },
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
registerTranscriptionProvider(
|
|
97
|
+
{
|
|
98
|
+
id: "privateer",
|
|
99
|
+
label: "Privateer account STT (attested enclave)",
|
|
100
|
+
privacy: "confidential",
|
|
101
|
+
available: () => hasCredentials(),
|
|
102
|
+
transcribe: async (audio, { language, signal }) => {
|
|
103
|
+
const { text } = await accountJson<{ text?: string }>(
|
|
104
|
+
"/api/audio/transcribe",
|
|
105
|
+
{
|
|
106
|
+
audioBase64: Buffer.from(audio.data).toString("base64"),
|
|
107
|
+
format: audio.format,
|
|
108
|
+
...(language ? { language } : {}),
|
|
109
|
+
},
|
|
110
|
+
signal,
|
|
111
|
+
);
|
|
112
|
+
return typeof text === "string" ? text : "";
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
{ preferWhen: () => hasCredentials() },
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
export default function privateerSpeak(pi: any): void {
|
|
119
|
+
// Ours, beside config.json — NOT ~/.pi/speak.json, so a user who also runs plain Pi
|
|
120
|
+
// with the generic package keeps two independent setups instead of a fought-over
|
|
121
|
+
// file. globalDir() is read here, not at module load, so PRIVATEER_HOME set around
|
|
122
|
+
// session creation (tests, the daemon) is honoured.
|
|
123
|
+
makePiSpeakExtension({ configFile: join(globalDir(), "speak.json") })(pi);
|
|
124
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "privateer-agent",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.6",
|
|
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",
|
|
@@ -60,7 +60,8 @@
|
|
|
60
60
|
"start": "tsx src/main.ts",
|
|
61
61
|
"chat": "node --env-file=.env --import tsx src/cli/chat.ts",
|
|
62
62
|
"channels": "node --env-file=.env --import tsx src/channels/run.ts",
|
|
63
|
-
"dev": "
|
|
63
|
+
"dev": "node bin/privateer-launch.mjs",
|
|
64
|
+
"dev:fresh": "PRIVATEER_HOME=\"$PWD/.dev-home\" node bin/privateer-launch.mjs",
|
|
64
65
|
"typecheck": "tsc --noEmit",
|
|
65
66
|
"test": "for f in tests/*.test.ts; do node --import tsx --test \"$f\" || exit 1; done"
|
|
66
67
|
},
|
|
@@ -71,7 +72,6 @@
|
|
|
71
72
|
"@earendil-works/pi-ai": "^0.80.3",
|
|
72
73
|
"@earendil-works/pi-coding-agent": "0.80.3",
|
|
73
74
|
"@earendil-works/pi-tui": "^0.80.3",
|
|
74
|
-
"@hypabolic/pi-hypa": "^0.1.6",
|
|
75
75
|
"@juicesharp/rpiv-ask-user-question": "^2.2.0",
|
|
76
76
|
"@juicesharp/rpiv-web-tools": "^1.20.0",
|
|
77
77
|
"@noble/ciphers": "^2.1.1",
|
|
@@ -84,6 +84,7 @@
|
|
|
84
84
|
"pi-privacy": "^0.11.0",
|
|
85
85
|
"pi-subagents": "^0.34.0",
|
|
86
86
|
"picomatch": "^4.0.4",
|
|
87
|
+
"privateer-speak": "0.2.0",
|
|
87
88
|
"privateer-workflow": "^0.1.0",
|
|
88
89
|
"tinfoil": "^1.1.11",
|
|
89
90
|
"tsx": "^4.16.0",
|
|
@@ -11,16 +11,16 @@
|
|
|
11
11
|
{ "name": "privateer-privacy", "entry": "extensions/privateer-privacy.ts", "note": "pi-privacy + account tier resolver" },
|
|
12
12
|
{ "name": "privateer-connect", "entry": "extensions/privateer-connect.ts", "note": "/connect — MCP connector manager" },
|
|
13
13
|
{ "name": "privateer-media", "entry": "extensions/privateer-media.ts", "note": "image/video/speech/music + ffmpeg compose" },
|
|
14
|
+
{ "name": "privateer-hints", "entry": "extensions/privateer-hints.ts", "note": "rotating tips in the working line + /hints" },
|
|
15
|
+
{ "name": "privateer-speak", "entry": "extensions/privateer-speak.ts", "note": "spoken responses (/speak) + voice input (/talk) — pi-speak + confidential account TTS/STT" },
|
|
14
16
|
{ "name": "rpiv-web-tools", "dep": ["@juicesharp/rpiv-web-tools", "index.ts"], "note": "private web tools (user's own provider key)" },
|
|
15
17
|
{ "name": "rpiv-ask-user-question", "dep": ["@juicesharp/rpiv-ask-user-question", "index.ts"], "note": "ask_user_question" },
|
|
16
18
|
{ "name": "pi-mcp-adapter", "dep": ["pi-mcp-adapter", "index.ts"], "note": "MCP servers as first-class tools" },
|
|
17
|
-
{ "name": "pi-hypa", "dep": ["@hypabolic/pi-hypa", "extensions", "index.ts"], "note": "hypa pack" },
|
|
18
19
|
{ "name": "pi-subagents", "dep": ["pi-subagents", "src", "extension", "index.ts"], "note": "bounded parallel sub-agents" }
|
|
19
20
|
],
|
|
20
|
-
"retired": ["pi-privacy", "pi-web-access"],
|
|
21
|
+
"retired": ["pi-privacy", "pi-web-access", "pi-hypa"],
|
|
21
22
|
"reservedAliases": [
|
|
22
23
|
"@juicesharp/rpiv-web-tools",
|
|
23
|
-
"@juicesharp/rpiv-ask-user-question"
|
|
24
|
-
"@hypabolic/pi-hypa"
|
|
24
|
+
"@juicesharp/rpiv-ask-user-question"
|
|
25
25
|
]
|
|
26
26
|
}
|