@bill10/agent-007 0.9.2000 → 0.9.2001

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/.env.example CHANGED
@@ -55,6 +55,11 @@
55
55
  # always speaks every message; never is text only. Speaking needs macOS `say`
56
56
  # and ffmpeg; long or link-heavy messages always go as text.
57
57
  # TELEGRAM_VOICE=mirror
58
+ # The macOS voice Billion speaks with, as `say -v '?'` lists it. Unset: the best
59
+ # installed English voice, Premium then Enhanced (download them free in System
60
+ # Settings > Accessibility > Spoken Content > System voice > Manage Voices),
61
+ # else say's default. A voice that is not installed is logged and skipped.
62
+ # SAY_VOICE=Ava (Premium)
58
63
  # Your voice notes are transcribed with whisper.cpp (brew install whisper-cpp).
59
64
  # WHISPER_MODEL is the full path to a ggml model, e.g. ggml-base.en.bin;
60
65
  # WHISPER_CPP_BIN only if whisper-cli is not on PATH. Unset: voice notes get a
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.9.2.0
1
+ 0.9.2.1
package/bin/agent-007.js CHANGED
@@ -45,6 +45,8 @@ Settings (default in brackets):
45
45
  TELEGRAM_CHAT_ID Your chat with that bot; only it reaches Billion [none]
46
46
  TELEGRAM_VOICE mirror, always or never: Billion's messages as
47
47
  voice (macOS say + ffmpeg) [mirror]
48
+ SAY_VOICE macOS voice for that, from say -v '?' [best
49
+ installed English Premium/Enhanced voice]
48
50
  WHISPER_MODEL whisper.cpp model file; your voice notes are
49
51
  transcribed locally [off]
50
52
  WHISPER_CPP_BIN whisper.cpp CLI if not on PATH [whisper-cli]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bill10/agent-007",
3
- "version": "0.9.2000",
3
+ "version": "0.9.2001",
4
4
  "description": "From web terminals for your coding agents to a self-running agent company: Claude Code and Codex in parallel git worktrees, a job board they pick work from, and one agent that runs the board from a goal.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/server/owner.js CHANGED
@@ -16,7 +16,7 @@ import { CONFIG_DIR } from './state.js';
16
16
  import { liveBillion } from './billion.js';
17
17
  import { sendText } from './messages.js';
18
18
  import {
19
- chooseMode, speechUnavailable, synthesize, whisperSetup, transcribe, MAX_NOTE_SECONDS, MAX_NOTE_BYTES,
19
+ chooseMode, voiceSetting, speechUnavailable, synthesize, sayVoice, whisperSetup, transcribe, MAX_NOTE_SECONDS, MAX_NOTE_BYTES,
20
20
  } from './voice.js';
21
21
 
22
22
  export const MAX_NOTIFY_CHARS = 3000; // Telegram's own limit is 4096
@@ -98,7 +98,7 @@ function saveOwnerMode(mode) {
98
98
  export async function sendVoice(text, { env = process.env } = {}) {
99
99
  const { chatId } = telegramSettings(env);
100
100
  try {
101
- const ogg = await synthesize(text);
101
+ const ogg = await synthesize(text, env);
102
102
  const form = new FormData();
103
103
  form.append('chat_id', chatId);
104
104
  form.append('caption', text); // under Telegram's 1024: voice is for texts of 900 or fewer
@@ -296,6 +296,9 @@ export function startTelegram({ broadcast, env = process.env } = {}) {
296
296
  if (!token || stopper) return false;
297
297
  if (!chatId) console.log(' Telegram: send any message to your bot, then set TELEGRAM_CHAT_ID=<id> (the id is shown here when it arrives)');
298
298
  else console.log(' Telegram: on');
299
+ if (voiceSetting(env) !== 'never' && !speechUnavailable(env)) {
300
+ sayVoice(env).then(v => console.log(` Telegram: speaking with ${v ? `the ${v} voice` : "say's default voice"}`));
301
+ }
299
302
  const controller = new AbortController();
300
303
  stopper = controller;
301
304
  (async () => {
package/server/voice.js CHANGED
@@ -79,12 +79,54 @@ export function speechUnavailable(env = process.env, platform = process.platform
79
79
  return null;
80
80
  }
81
81
 
82
+ // `say -v '?'` lines, e.g. "Ava (Premium) en_US # Hello! My name is Ava."
83
+ export function parseVoices(out) {
84
+ return out.split('\n').map(l => l.match(/^(.+?)\s+([a-z]{2,3}[_-]\w+)\s+#/)).filter(Boolean)
85
+ .map(([, name, locale]) => ({ name, locale: locale.replace('-', '_') }));
86
+ }
87
+
88
+ // The first Premium voice, then Enhanced (however macOS words the name), in
89
+ // these locales in order.
90
+ function best(voices, locales) {
91
+ for (const tier of ['Premium', 'Enhanced']) {
92
+ for (const locale of locales) {
93
+ const v = voices.find(v => (!locale || v.locale === locale) && v.name.includes(tier));
94
+ if (v) return v;
95
+ }
96
+ }
97
+ return null;
98
+ }
99
+
100
+ // SAY_VOICE if it is installed, else the best English voice installed:
101
+ // Premium, then Enhanced, en_US before en_GB. null keeps say's own default.
102
+ export function pickVoice(voices, env = process.env) {
103
+ const wanted = (env.SAY_VOICE || '').trim();
104
+ if (wanted) {
105
+ // "Ava" also matches "Ava (Premium)" or "Samantha" "Samantha (English (US))",
106
+ // as `say -v` itself does, the best of them first.
107
+ const w = wanted.toLowerCase();
108
+ const named = voices.filter(v => v.name.toLowerCase().startsWith(`${w} (`));
109
+ const found = voices.find(v => v.name.toLowerCase() === w) || best(named, [null]) || named[0];
110
+ if (found) return found.name;
111
+ console.log(` Telegram: SAY_VOICE "${wanted}" is not installed (say -v '?' lists what is); using the best installed voice`);
112
+ }
113
+ return best(voices, ['en_US', 'en_GB'])?.name ?? null;
114
+ }
115
+
116
+ let voicePick;
117
+ // The voice Billion speaks with, chosen once for the process's lifetime.
118
+ export function sayVoice(env = process.env) {
119
+ voicePick ??= run('say', ['-v', '?']).catch(() => '').then(out => pickVoice(parseVoices(out), env));
120
+ return voicePick;
121
+ }
122
+
82
123
  // text → OGG/Opus bytes. URLs are said as "link"; the caption carries them.
83
- export function synthesize(text) {
124
+ export function synthesize(text, env = process.env) {
84
125
  return inTempDir(async dir => {
85
126
  const txt = join(dir, 'say.txt'), aiff = join(dir, 'say.aiff'), ogg = join(dir, 'say.ogg');
86
127
  await writeFile(txt, text.replace(URL_RE, 'link'));
87
- await run('say', ['-o', aiff, '-f', txt]);
128
+ const voice = await sayVoice(env);
129
+ await run('say', [...(voice ? ['-v', voice] : []), '-o', aiff, '-f', txt]);
88
130
  await run('ffmpeg', ['-y', '-loglevel', 'error', '-protocol_whitelist', 'file', '-i', aiff, '-c:a', 'libopus', '-b:a', '32k', ogg]);
89
131
  return readFile(ogg);
90
132
  });