privateer-agent 0.12.5 → 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 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),
@@ -569,6 +609,7 @@ drop your own into `~/.privateer/agent/extensions/` and it loads the same way, g
569
609
  | `/signin` · `/signout` | sign in to a Privateer account (device flow) / sign out |
570
610
  | `/remote-access` | link this terminal to the app and allow it to drive (off by default) |
571
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) |
572
613
  | `/extensions` | list loaded Pi extensions |
573
614
  | `/init` | scaffold a starter `PRIVATEER.md` in this directory |
574
615
  | `/update` · `/privateer` | update to the latest release / Privateer status and posture |
@@ -107,10 +107,80 @@ function buildMark(p: Palette): string[] {
107
107
  return rows;
108
108
  }
109
109
 
110
- // Visible width = characters after stripping SGR escapes. Everything we render inside
111
- // the box is ASCII or a BMP width-1 symbol, so a plain length is exact here.
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
- return s.replace(/\x1b\[[0-9;]*m/g, "").length;
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 pad = Math.max(0, inner - vlen(row));
260
- out.push(`${p.BORDER}│${p.RESET} ${row}${" ".repeat(pad)} ${p.BORDER}│${p.RESET}`);
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
- function headerComponent(theme: any, modelProvider?: string) {
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 {
@@ -22,6 +22,7 @@
22
22
  // Pi never reads that file) — read-modify-write, preserving unrelated keys.
23
23
 
24
24
  import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
25
+ import { join } from "node:path";
25
26
  import { keyText } from "@earendil-works/pi-coding-agent";
26
27
  import { configPath, globalDir } from "../src/config/paths.ts";
27
28
 
@@ -38,6 +39,19 @@ function key(binding: Parameters<typeof keyText>[0], fallback: string): string {
38
39
  }
39
40
  }
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
+
41
55
  // Lazy thunks, not strings: keys resolve at display time, after the app has
42
56
  // loaded (and possibly remapped) its keybindings.
43
57
  const HINTS: Array<() => string> = [
@@ -50,6 +64,12 @@ const HINTS: Array<() => string> = [
50
64
  () => `/init writes a PRIVATEER.md so the agent knows this project`,
51
65
  () => `/connect adds MCP connectors to this terminal`,
52
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
+ },
53
73
  () => `these tips are /hints — /hints off silences them`,
54
74
  ];
55
75
 
@@ -5,6 +5,21 @@
5
5
  // confidential STT (/api/audio/transcribe, same tinfoil/near routing the app's voice
6
6
  // features use). Both inherit the account's entitlement, caps and billing.
7
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
+ //
8
23
  // PROVIDER ATTUNEMENT, NOT STOMPING. The provider registers with preferWhen: signed-in,
9
24
  // which the registry ranks ABOVE the built-in local voice but BELOW a deliberate /speak
10
25
  // provider pick — sign in and speech quietly upgrades from the OS voice to confidential
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.12.5",
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",
@@ -84,7 +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.1.0",
87
+ "privateer-speak": "0.2.0",
88
88
  "privateer-workflow": "^0.1.0",
89
89
  "tinfoil": "^1.1.11",
90
90
  "tsx": "^4.16.0",