@bill10/agent-007 0.9.2000 → 0.9.3000
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 +5 -0
- package/VERSION +1 -1
- package/bin/agent-007.js +2 -0
- package/package.json +1 -1
- package/server/messages.js +34 -5
- package/server/owner.js +5 -2
- package/server/voice.js +44 -2
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.
|
|
1
|
+
0.9.3.0
|
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.
|
|
3
|
+
"version": "0.9.3000",
|
|
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/messages.js
CHANGED
|
@@ -35,6 +35,14 @@ export const USER_TYPING_HOLD_MS = 30 * 1000;
|
|
|
35
35
|
// Enter is a newline; with this gap codex-cli 0.155.1 takes paste and Enter as
|
|
36
36
|
// one turn (checked in review).
|
|
37
37
|
export const SUBMIT_DELAY_MS = 150;
|
|
38
|
+
// Claude Code (2.1.x) folds a long paste into a "[Pasted text #1]" placeholder
|
|
39
|
+
// and hands the model that part wrapped as pasted_content, which it treats as
|
|
40
|
+
// maybe not from the user: a long message got a question back, not action.
|
|
41
|
+
// Seen at 500 chars or 31 lines in one paste; 300 chars, or a line or two,
|
|
42
|
+
// comes through as plain typing. So a message goes in as many small pastes,
|
|
43
|
+
// a line or less each, still bracketed so a newline stays a newline.
|
|
44
|
+
export const PASTE_CHUNK_CHARS = 200;
|
|
45
|
+
export const PASTE_GAP_MS = 10;
|
|
38
46
|
|
|
39
47
|
const queues = new Map(); // recipient session id -> [formatted text]
|
|
40
48
|
// How many at the front of a queue the server wrote (approvals, board
|
|
@@ -44,7 +52,7 @@ const queues = new Map(); // recipient session id -> [formatted text]
|
|
|
44
52
|
const serverAhead = new Map();
|
|
45
53
|
const sends = new Map(); // `${from.id}>${to.id}` -> [timestamps]
|
|
46
54
|
|
|
47
|
-
// Everything but newline and tab. The text goes inside
|
|
55
|
+
// Everything but newline and tab. The text goes inside bracketed pastes, and a
|
|
48
56
|
// message carrying ESC[201~ would end the paste early and type the rest as raw
|
|
49
57
|
// keystrokes — arrow keys, Enter, whatever it liked.
|
|
50
58
|
const clean = (s) => String(s ?? '').replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, '');
|
|
@@ -147,7 +155,7 @@ export function formatNotice(headline, lines = []) {
|
|
|
147
155
|
// the permission rule nor the pair limit applies; the queue cap does, and
|
|
148
156
|
// text over it is refused.
|
|
149
157
|
//
|
|
150
|
-
// Cleaned here, whoever wrote it: it goes into
|
|
158
|
+
// Cleaned here, whoever wrote it: it goes into bracketed pastes, and a stray
|
|
151
159
|
// ESC[201~ anywhere in it — a tool name, a card title — would end the paste
|
|
152
160
|
// and type the rest as keystrokes of the user's own.
|
|
153
161
|
export function sendText(session, text, now = Date.now()) {
|
|
@@ -185,6 +193,8 @@ export function canDeliver(session, now = Date.now()) {
|
|
|
185
193
|
// Billion holds its mail until it says it is ready (billion_ready), so
|
|
186
194
|
// nothing lands in the middle of its introduction.
|
|
187
195
|
if (session.messagesHeld) return false;
|
|
196
|
+
// Still typing the last one: a second would interleave with its pastes.
|
|
197
|
+
if (session.messageTyping) return false;
|
|
188
198
|
// Both: the stored state is up to a second old, and a dialog that opened
|
|
189
199
|
// since is what this must not type into.
|
|
190
200
|
if (session.exited || session.state !== 'WAITING' || detectState(session, { now }) !== 'WAITING') return false;
|
|
@@ -202,23 +212,42 @@ function write(session, data) {
|
|
|
202
212
|
try { session.pty.write(data); return true; } catch { return false; }
|
|
203
213
|
}
|
|
204
214
|
|
|
215
|
+
// Lines, newline kept, cut to PASTE_CHUNK_CHARS by code point so an emoji's
|
|
216
|
+
// surrogate pair is never split across two pastes.
|
|
217
|
+
export function pasteChunks(text) {
|
|
218
|
+
return text.split(/(?<=\n)/).flatMap(line => {
|
|
219
|
+
const chars = Array.from(line);
|
|
220
|
+
const out = [];
|
|
221
|
+
for (let i = 0; i < chars.length; i += PASTE_CHUNK_CHARS) out.push(chars.slice(i, i + PASTE_CHUNK_CHARS).join(''));
|
|
222
|
+
return out;
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
205
226
|
function deliver(session, text, now) {
|
|
206
227
|
session.messageDeliveredAt = now;
|
|
207
|
-
|
|
228
|
+
session.messageTyping = true;
|
|
229
|
+
const chunks = pasteChunks(text);
|
|
230
|
+
const type = () => {
|
|
231
|
+
if (session.exited) return;
|
|
232
|
+
write(session, `\x1b[200~${chunks.shift()}\x1b[201~`);
|
|
233
|
+
setTimeout(chunks.length ? type : submit, chunks.length ? PASTE_GAP_MS : SUBMIT_DELAY_MS);
|
|
234
|
+
};
|
|
208
235
|
// Checked again at the Enter: in those 150 ms a dialog may have opened, which
|
|
209
236
|
// the Enter would answer, or a person may have started typing, whose text
|
|
210
237
|
// would go with it. Left unsent, the message sits in the composer instead.
|
|
211
238
|
// The screen is read afresh rather than through session.state, which lags a
|
|
212
239
|
// second behind and reads WORKING for three after any output — the paste's
|
|
213
240
|
// own echo included.
|
|
214
|
-
|
|
241
|
+
const submit = () => {
|
|
242
|
+
session.messageTyping = false;
|
|
215
243
|
if (session.exited) return;
|
|
216
244
|
if (detectState(session, { stateTimeoutMs: 0 }) === 'MESSAGE' || (session.lastUserInputAt || 0) > now) {
|
|
217
245
|
session.messageUnsubmittedAt = Date.now();
|
|
218
246
|
return;
|
|
219
247
|
}
|
|
220
248
|
write(session, '\r');
|
|
221
|
-
}
|
|
249
|
+
};
|
|
250
|
+
type();
|
|
222
251
|
}
|
|
223
252
|
|
|
224
253
|
/**
|
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
|
-
|
|
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
|
});
|