@bill10/agent-007 0.6.7000 → 0.7.1000
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 +12 -0
- package/README.md +3 -0
- package/VERSION +1 -1
- package/bin/agent-007.js +5 -0
- package/package.json +1 -1
- package/server/git.js +8 -7
- package/server/jobs.js +14 -0
- package/server/owner.js +110 -7
- package/server/voice.js +113 -0
- package/server.js +4 -1
- package/templates/billion/charter.md +4 -1
package/.env.example
CHANGED
|
@@ -50,6 +50,18 @@
|
|
|
50
50
|
# TELEGRAM_BOT_TOKEN=123456:ABC...
|
|
51
51
|
# TELEGRAM_CHAT_ID=123456789
|
|
52
52
|
|
|
53
|
+
# Voice on Telegram, all local (docs/BILLION.md, "Voice"). mirror answers in the
|
|
54
|
+
# mode of your last message (voice note -> voice with the text as caption);
|
|
55
|
+
# always speaks every message; never is text only. Speaking needs macOS `say`
|
|
56
|
+
# and ffmpeg; long or link-heavy messages always go as text.
|
|
57
|
+
# TELEGRAM_VOICE=mirror
|
|
58
|
+
# Your voice notes are transcribed with whisper.cpp (brew install whisper-cpp).
|
|
59
|
+
# WHISPER_MODEL is the full path to a ggml model, e.g. ggml-base.en.bin;
|
|
60
|
+
# WHISPER_CPP_BIN only if whisper-cli is not on PATH. Unset: voice notes get a
|
|
61
|
+
# reply saying how to turn them on, and nothing reaches Billion.
|
|
62
|
+
# WHISPER_MODEL=/Users/you/.agent-007/whisper/ggml-base.en.bin
|
|
63
|
+
# WHISPER_CPP_BIN=/opt/homebrew/bin/whisper-cli
|
|
64
|
+
|
|
53
65
|
# Claude Code stops at a "do you trust this folder?" dialog the first time it
|
|
54
66
|
# runs anywhere, and every job-board worker gets a brand-new worktree. By default
|
|
55
67
|
# the app accepts it for board workers (it adds the worktree to ~/.claude.json
|
package/README.md
CHANGED
|
@@ -124,6 +124,9 @@ ALLOWED_ORIGINS=mac-mini.tailXXXX.ts.net npm start # Allow a remote browser or
|
|
|
124
124
|
| `BILLION_DIR` | `~/.agent-007/billion` | Billion's own folder and git repo. Point it at a new or empty folder |
|
|
125
125
|
| `TELEGRAM_BOT_TOKEN` | *(off)* | A Telegram bot's token. Billion's `notify_owner` questions are sent through it, and replies come back into Billion's terminal. See [docs/BILLION.md](docs/BILLION.md#telegram) |
|
|
126
126
|
| `TELEGRAM_CHAT_ID` | *(none)* | Your chat with the bot. The only chat whose messages reach Billion; unset, the server logs the id of the first chat that messages the bot |
|
|
127
|
+
| `TELEGRAM_VOICE` | `mirror` | Voice on Telegram: `mirror` answers in the mode of your last message, `always` speaks, `never` is text. Speaking needs macOS `say` and ffmpeg. See [Voice](docs/BILLION.md#voice) |
|
|
128
|
+
| `WHISPER_MODEL` | *(none)* | Full path to a whisper.cpp ggml model (e.g. `ggml-base.en.bin`); with `whisper-cli` installed, your voice notes are transcribed locally for Billion |
|
|
129
|
+
| `WHISPER_CPP_BIN` | *(on PATH)* | whisper.cpp's CLI, when `whisper-cli`/`whisper-cpp`/`main` is not on `PATH` |
|
|
127
130
|
| `TRUST_BOARD_WORKTREES` | *(on)* | Board-dispatched Claude Code and Codex workers skip the workspace-trust dialog, so queued jobs start unattended. That also lets the repo's own `.claude/settings.json` (or Codex project config, hooks and exec policies) apply without asking. `0` (or `false`/`off`/`no`) keeps the dialog. Hand-started agents always keep it |
|
|
128
131
|
|
|
129
132
|
> **Running remotely?** The server spawns real shells, so never expose it to the
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.7.1.0
|
package/bin/agent-007.js
CHANGED
|
@@ -43,6 +43,11 @@ Settings (default in brackets):
|
|
|
43
43
|
BILLION_DIR Billion's folder and repo [~/.agent-007/billion]
|
|
44
44
|
TELEGRAM_BOT_TOKEN Bot token for Billion's questions on your phone [off]
|
|
45
45
|
TELEGRAM_CHAT_ID Your chat with that bot; only it reaches Billion [none]
|
|
46
|
+
TELEGRAM_VOICE mirror, always or never: Billion's messages as
|
|
47
|
+
voice (macOS say + ffmpeg) [mirror]
|
|
48
|
+
WHISPER_MODEL whisper.cpp model file; your voice notes are
|
|
49
|
+
transcribed locally [off]
|
|
50
|
+
WHISPER_CPP_BIN whisper.cpp CLI if not on PATH [whisper-cli]
|
|
46
51
|
TRUST_BOARD_WORKTREES 0 keeps Claude Code's and Codex's folder-trust
|
|
47
52
|
prompt for job board workers [on]
|
|
48
53
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bill10/agent-007",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1000",
|
|
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/git.js
CHANGED
|
@@ -342,13 +342,14 @@ export async function removeWorktree(session, { discardChanges = false } = {}) {
|
|
|
342
342
|
const local = (await gitExec(['-C', session.worktreePath, 'rev-parse', 'HEAD'])).trim();
|
|
343
343
|
const upstream = (await gitExec(['-C', session.worktreePath, 'rev-parse', '@{u}'])).trim();
|
|
344
344
|
fullyPushed = !!local && local === upstream;
|
|
345
|
-
} catch {
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
345
|
+
} catch {}
|
|
346
|
+
// No upstream git can resolve locally (a branch pushed to a raw URL,
|
|
347
|
+
// `git push -u https://…`, records the URL as branch.<name>.remote and
|
|
348
|
+
// creates no remote-tracking ref), or one that is stale: a push from
|
|
349
|
+
// inside the worktree after a rebase or a force-with-lease can leave the
|
|
350
|
+
// shared repo's refs/remotes/origin/<branch> on an old SHA. Ask the
|
|
351
|
+
// remote itself; anything short of a matching SHA stays not-pushed.
|
|
352
|
+
if (!fullyPushed) fullyPushed = await matchesRemote(session);
|
|
352
353
|
}
|
|
353
354
|
if (!reason && !fullyPushed) {
|
|
354
355
|
const baseBranch = await resolveBaseBranch(session.repoPath);
|
package/server/jobs.js
CHANGED
|
@@ -1086,6 +1086,20 @@ async function releaseOrphanedWorktree({ branchName, repoPath, worktreePath }, b
|
|
|
1086
1086
|
return true;
|
|
1087
1087
|
}
|
|
1088
1088
|
|
|
1089
|
+
// An "unpushed" orphan left by an older build (or a stale remote-tracking ref)
|
|
1090
|
+
// may be fully on the remote by now. Re-check each one at startup through the
|
|
1091
|
+
// same release path, so removeWorktree's rules decide: dirty stays, and only
|
|
1092
|
+
// an exact SHA match on the remote counts as pushed. A card still in progress
|
|
1093
|
+
// or in Review may want its agent re-adopted, so its orphan is left alone.
|
|
1094
|
+
export async function releasePushedOrphans(broadcast) {
|
|
1095
|
+
let released = 0;
|
|
1096
|
+
for (const entry of [...orphans.values()]) {
|
|
1097
|
+
if (entry.reason !== 'unpushed' || findJobForBranch(entry)) continue;
|
|
1098
|
+
if (await releaseOrphanedWorktree(entry, broadcast)) released++;
|
|
1099
|
+
}
|
|
1100
|
+
return released;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1089
1103
|
export function updateSettings(fields, broadcast) {
|
|
1090
1104
|
const settings = boardSettings();
|
|
1091
1105
|
if (typeof fields.running === 'boolean') settings.running = fields.running;
|
package/server/owner.js
CHANGED
|
@@ -15,6 +15,9 @@ import { randomUUID } from 'crypto';
|
|
|
15
15
|
import { CONFIG_DIR } from './state.js';
|
|
16
16
|
import { liveBillion } from './billion.js';
|
|
17
17
|
import { sendText } from './messages.js';
|
|
18
|
+
import {
|
|
19
|
+
chooseMode, speechUnavailable, synthesize, whisperSetup, transcribe, MAX_NOTE_SECONDS, MAX_NOTE_BYTES,
|
|
20
|
+
} from './voice.js';
|
|
18
21
|
|
|
19
22
|
export const MAX_NOTIFY_CHARS = 3000; // Telegram's own limit is 4096
|
|
20
23
|
export const NOTIFY_LIMIT = 5; // a burst of five, then...
|
|
@@ -23,6 +26,7 @@ export const POLL_TIMEOUT_S = 30;
|
|
|
23
26
|
const MAX_BACKOFF_MS = 60 * 1000;
|
|
24
27
|
const WAITING_CAP = 50;
|
|
25
28
|
export const OWNER_PREFIX = '[Owner via Telegram]';
|
|
29
|
+
export const OWNER_VOICE_PREFIX = '[Owner via Telegram, voice]';
|
|
26
30
|
|
|
27
31
|
export function telegramSettings(env = process.env) {
|
|
28
32
|
const token = (env.TELEGRAM_BOT_TOKEN || '').trim();
|
|
@@ -39,15 +43,15 @@ export function redact(text, env = process.env) {
|
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
// One Bot API call. Returns the result, or throws an Error whose message is
|
|
42
|
-
// already redacted.
|
|
46
|
+
// already redacted. params is JSON, or FormData for an upload.
|
|
43
47
|
async function call(method, params, { env = process.env, signal } = {}) {
|
|
44
48
|
const { token } = telegramSettings(env);
|
|
49
|
+
const form = params instanceof FormData;
|
|
45
50
|
let body;
|
|
46
51
|
try {
|
|
47
52
|
const res = await fetch(`https://api.telegram.org/bot${token}/${method}`, {
|
|
48
53
|
method: 'POST',
|
|
49
|
-
headers: { 'Content-Type': 'application/json' },
|
|
50
|
-
body: JSON.stringify(params),
|
|
54
|
+
...(form ? { body: params } : { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(params) }),
|
|
51
55
|
signal,
|
|
52
56
|
});
|
|
53
57
|
body = await res.json().catch(() => ({ ok: false, description: `HTTP ${res.status}` }));
|
|
@@ -69,6 +73,92 @@ export async function sendTelegram(text, { env = process.env } = {}) {
|
|
|
69
73
|
}
|
|
70
74
|
}
|
|
71
75
|
|
|
76
|
+
// --- Voice (server/voice.js does the audio) ---
|
|
77
|
+
|
|
78
|
+
// The mode of the owner's last message, kept next to waiting.json so mirror
|
|
79
|
+
// survives a restart.
|
|
80
|
+
const voiceStatePath = () => join(CONFIG_DIR, 'telegram-voice.json');
|
|
81
|
+
|
|
82
|
+
export function lastOwnerMode() {
|
|
83
|
+
try { return JSON.parse(readFileSync(voiceStatePath(), 'utf8')).lastMode; } catch { return undefined; }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function saveOwnerMode(mode) {
|
|
87
|
+
if (lastOwnerMode() === mode) return;
|
|
88
|
+
try {
|
|
89
|
+
writeFileSync(`${voiceStatePath()}.tmp`, JSON.stringify({ lastMode: mode }));
|
|
90
|
+
renameSync(`${voiceStatePath()}.tmp`, voiceStatePath());
|
|
91
|
+
} catch (err) {
|
|
92
|
+
console.error('Telegram: could not save the last message mode:', err.message);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// text spoken, with text as the caption so links stay tappable. Returns
|
|
97
|
+
// { ok } or { error }; the caller sends text instead on an error.
|
|
98
|
+
export async function sendVoice(text, { env = process.env } = {}) {
|
|
99
|
+
const { chatId } = telegramSettings(env);
|
|
100
|
+
try {
|
|
101
|
+
const ogg = await synthesize(text);
|
|
102
|
+
const form = new FormData();
|
|
103
|
+
form.append('chat_id', chatId);
|
|
104
|
+
form.append('caption', text); // under Telegram's 1024: voice is for texts of 900 or fewer
|
|
105
|
+
form.append('voice', new Blob([ogg], { type: 'audio/ogg' }), 'billion.ogg');
|
|
106
|
+
await call('sendVoice', form, { env });
|
|
107
|
+
return { ok: true };
|
|
108
|
+
} catch (err) {
|
|
109
|
+
return { error: redact(err.message, env) };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let voiceOffLogged = false;
|
|
114
|
+
|
|
115
|
+
// A message to the owner, as voice or text by TELEGRAM_VOICE (docs/BILLION.md, "Voice").
|
|
116
|
+
export async function sendToOwner(text, { env = process.env, platform = process.platform } = {}) {
|
|
117
|
+
if (chooseMode(text, { env, lastMode: lastOwnerMode() }).mode === 'voice') {
|
|
118
|
+
const off = speechUnavailable(env, platform);
|
|
119
|
+
const result = off ? { error: off } : await sendVoice(text, { env });
|
|
120
|
+
if (!result.error) return result;
|
|
121
|
+
if (!voiceOffLogged) {
|
|
122
|
+
voiceOffLogged = true;
|
|
123
|
+
console.log(` Telegram: sending text, not voice: ${result.error}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return sendTelegram(text, { env });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// A voice note's bytes, or throws a redacted Error.
|
|
130
|
+
async function downloadFile(fileId, env) {
|
|
131
|
+
const file = await call('getFile', { file_id: fileId }, { env });
|
|
132
|
+
if (file?.file_size > MAX_NOTE_BYTES) throw new Error('too big');
|
|
133
|
+
const { token } = telegramSettings(env);
|
|
134
|
+
try {
|
|
135
|
+
const res = await fetch(`https://api.telegram.org/file/bot${token}/${file.file_path}`, { signal: AbortSignal.timeout(60 * 1000) });
|
|
136
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
137
|
+
const bytes = Buffer.from(await res.arrayBuffer());
|
|
138
|
+
if (bytes.length > MAX_NOTE_BYTES) throw new Error('too big');
|
|
139
|
+
return bytes;
|
|
140
|
+
} catch (err) {
|
|
141
|
+
throw new Error(redact(err.cause?.message || err.message, env));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// The owner's voice note → its transcript, or a reply for the owner.
|
|
146
|
+
async function transcribeNote(note, env) {
|
|
147
|
+
const setup = whisperSetup(env);
|
|
148
|
+
if (setup.missing) return { reply: setup.missing, result: 'no-whisper' };
|
|
149
|
+
const limit = `Voice notes can be up to ${MAX_NOTE_SECONDS / 60} minutes and ${MAX_NOTE_BYTES / 1024 / 1024} MB; send a shorter one or text.`;
|
|
150
|
+
if (note.duration > MAX_NOTE_SECONDS || note.file_size > MAX_NOTE_BYTES) return { reply: limit, result: 'too-big' };
|
|
151
|
+
try {
|
|
152
|
+
const transcript = await transcribe(await downloadFile(note.file_id, env), setup);
|
|
153
|
+
if (!transcript) return { reply: 'I could not make out any words in that voice note; send it again or as text.', result: 'empty' };
|
|
154
|
+
return { transcript };
|
|
155
|
+
} catch (err) {
|
|
156
|
+
if (err.message === 'too big') return { reply: limit, result: 'too-big' };
|
|
157
|
+
console.error('Telegram: could not transcribe a voice note:', redact(err.message, env));
|
|
158
|
+
return { reply: 'I could not transcribe that voice note; send it as text instead.', result: 'failed' };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
72
162
|
// --- The "Waiting on you" list, in the config dir so it survives restarts ---
|
|
73
163
|
|
|
74
164
|
const waitingPath = () => join(CONFIG_DIR, 'waiting.json');
|
|
@@ -107,7 +197,7 @@ export function dismissWaiting(id, broadcast) {
|
|
|
107
197
|
|
|
108
198
|
let sent = []; // times of recent notify_owner calls
|
|
109
199
|
|
|
110
|
-
export async function notifyOwner(text, { broadcast, env = process.env, now = Date.now() } = {}) {
|
|
200
|
+
export async function notifyOwner(text, { broadcast, env = process.env, now = Date.now(), platform = process.platform } = {}) {
|
|
111
201
|
const body = typeof text === 'string' ? text.trim() : '';
|
|
112
202
|
if (!body) return { error: 'The message is empty.' };
|
|
113
203
|
if (body.length > MAX_NOTIFY_CHARS) return { error: `The message is ${body.length} characters; keep it under ${MAX_NOTIFY_CHARS}.` };
|
|
@@ -123,7 +213,7 @@ export async function notifyOwner(text, { broadcast, env = process.env, now = Da
|
|
|
123
213
|
if (!token || !chatId) {
|
|
124
214
|
return { pinned: true, error: 'Pinned under "Waiting on you" in the owner\'s browser, but not sent to their phone: Telegram is not configured (TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID). Say it in your terminal as well.' };
|
|
125
215
|
}
|
|
126
|
-
const result = await
|
|
216
|
+
const result = await sendToOwner(`Billion: ${body}`, { env, platform });
|
|
127
217
|
if (result.error) return { pinned: true, error: `Pinned under "Waiting on you" in the owner's browser, but the Telegram send failed: ${result.error}` };
|
|
128
218
|
return { ok: true };
|
|
129
219
|
}
|
|
@@ -150,13 +240,26 @@ export async function handleUpdate(update, { broadcast, env = process.env } = {}
|
|
|
150
240
|
return 'discovery';
|
|
151
241
|
}
|
|
152
242
|
if (String(chat) !== chatId) return 'ignored';
|
|
153
|
-
|
|
243
|
+
const note = (msg.voice || msg.audio)?.file_id ? (msg.voice || msg.audio) : null;
|
|
244
|
+
const typed = typeof msg.text === 'string' && msg.text.trim() ? msg.text : null;
|
|
245
|
+
if (!note && !typed) return 'ignored';
|
|
246
|
+
saveOwnerMode(note ? 'voice' : 'text');
|
|
154
247
|
const billion = liveBillion();
|
|
155
248
|
if (!billion) {
|
|
156
249
|
await sendTelegram('Billion is not running', { env });
|
|
157
250
|
return 'not-running';
|
|
158
251
|
}
|
|
159
|
-
|
|
252
|
+
let line = `${OWNER_PREFIX} ${typed}`;
|
|
253
|
+
if (note) {
|
|
254
|
+
const heard = await transcribeNote(note, env);
|
|
255
|
+
if (heard.reply) {
|
|
256
|
+
await sendTelegram(heard.reply, { env });
|
|
257
|
+
return heard.result;
|
|
258
|
+
}
|
|
259
|
+
const caption = typeof msg.caption === 'string' && msg.caption.trim() ? ` (caption: ${msg.caption.trim()})` : '';
|
|
260
|
+
line = `${OWNER_VOICE_PREFIX} ${heard.transcript}${caption}`;
|
|
261
|
+
}
|
|
262
|
+
if (!sendText(billion, line)) {
|
|
160
263
|
await sendTelegram('Billion has too much waiting for it; try again in a while.', { env });
|
|
161
264
|
return 'full';
|
|
162
265
|
}
|
package/server/voice.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Local audio for the Telegram bot (docs/BILLION.md, "Voice"): Billion's
|
|
2
|
+
// messages spoken with macOS `say` and encoded to OGG/Opus with ffmpeg, and the
|
|
3
|
+
// owner's voice notes transcribed with whisper.cpp. Everything runs on this
|
|
4
|
+
// machine; only the finished file, or the owner's note, crosses Telegram.
|
|
5
|
+
// Every tool is spawned with an args array, text goes in through a file, and
|
|
6
|
+
// temp files are removed whatever happens.
|
|
7
|
+
|
|
8
|
+
import { spawn } from 'child_process';
|
|
9
|
+
import { mkdtemp, writeFile, readFile, rm } from 'fs/promises';
|
|
10
|
+
import { existsSync } from 'fs';
|
|
11
|
+
import { tmpdir } from 'os';
|
|
12
|
+
import { join } from 'path';
|
|
13
|
+
import { commandExists } from './command-path.js';
|
|
14
|
+
|
|
15
|
+
export const MAX_VOICE_CHARS = 900; // about a minute of speech
|
|
16
|
+
export const MAX_NOTE_SECONDS = 5 * 60;
|
|
17
|
+
export const MAX_NOTE_BYTES = 20 * 1024 * 1024; // also getFile's own limit
|
|
18
|
+
const WHISPER_NAMES = ['whisper-cli', 'whisper-cpp', 'main'];
|
|
19
|
+
|
|
20
|
+
// TELEGRAM_VOICE: mirror (default), always or never.
|
|
21
|
+
export function voiceSetting(env = process.env) {
|
|
22
|
+
const v = (env.TELEGRAM_VOICE || '').trim().toLowerCase();
|
|
23
|
+
return v === 'always' || v === 'never' ? v : 'mirror';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const URL_RE = /https?:\/\/\S+/g;
|
|
27
|
+
// Code blocks, inline code, URLs, and path-like words: starting with / ~/ ./,
|
|
28
|
+
// two slashes, a slash and a file extension, or a backslash. One slash alone
|
|
29
|
+
// (and/or, 24/7) is prose.
|
|
30
|
+
const TECHNICAL_RE = /```[\s\S]*?```|`[^`]*`|https?:\/\/\S+|(?<!\S)[~.]{0,2}\/\S+|\S+\/\S+\/\S*|\S+\/\S*\.[A-Za-z]\w*|\S*\\\S*/g;
|
|
31
|
+
|
|
32
|
+
// Why this text should stay text, or null when it can be spoken.
|
|
33
|
+
// ponytail: a character ratio, not a parser; good enough to keep links, code
|
|
34
|
+
// and paths out of the owner's ears.
|
|
35
|
+
export function textOnlyReason(text) {
|
|
36
|
+
if (text.length > MAX_VOICE_CHARS) return 'long';
|
|
37
|
+
const solid = s => s.replace(/\s/g, '').length;
|
|
38
|
+
const all = solid(text);
|
|
39
|
+
if (all && solid(text.replace(TECHNICAL_RE, ' ')) < all / 2) return 'mostly links, code or paths';
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Voice or text for one message to the owner. lastMode is the mode of the
|
|
44
|
+
// owner's last message ('voice' | 'text' | undefined).
|
|
45
|
+
export function chooseMode(text, { env = process.env, lastMode } = {}) {
|
|
46
|
+
const setting = voiceSetting(env);
|
|
47
|
+
if (setting === 'never') return { mode: 'text', reason: 'TELEGRAM_VOICE=never' };
|
|
48
|
+
const reason = textOnlyReason(text);
|
|
49
|
+
if (reason) return { mode: 'text', reason };
|
|
50
|
+
if (setting === 'always' || lastMode === 'voice') return { mode: 'voice' };
|
|
51
|
+
return { mode: 'text', reason: 'the owner last wrote text' };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const TOOL_TIMEOUT_MS = 5 * 60 * 1000; // the poll loop waits on this, so a hung tool must not hold it
|
|
55
|
+
|
|
56
|
+
// Runs one tool. Resolves with its output, rejects with a short reason.
|
|
57
|
+
function run(cmd, args) {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
let out = '', err = '';
|
|
60
|
+
const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], timeout: TOOL_TIMEOUT_MS });
|
|
61
|
+
child.stdout?.setEncoding?.('utf8'); // a character split across reads stays whole
|
|
62
|
+
child.stdout?.on('data', d => { out += d; });
|
|
63
|
+
child.stderr?.on('data', d => { err += d; });
|
|
64
|
+
child.on('error', e => reject(new Error(`${cmd}: ${e.message}`)));
|
|
65
|
+
child.on('close', code => code === 0 ? resolve(out) : reject(new Error(`${cmd} exited ${code}: ${err.trim().split('\n').pop() || ''}`)));
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function inTempDir(fn) {
|
|
70
|
+
const dir = await mkdtemp(join(tmpdir(), 'agent007-voice-'));
|
|
71
|
+
try { return await fn(dir); } finally { await rm(dir, { recursive: true, force: true }); }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Why Billion cannot speak here, or null.
|
|
75
|
+
export function speechUnavailable(env = process.env, platform = process.platform) {
|
|
76
|
+
if (platform !== 'darwin') return 'speaking needs macOS `say`';
|
|
77
|
+
if (!commandExists('say', env)) return '`say` is not on PATH';
|
|
78
|
+
if (!commandExists('ffmpeg', env)) return 'ffmpeg is not installed (brew install ffmpeg)';
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// text → OGG/Opus bytes. URLs are said as "link"; the caption carries them.
|
|
83
|
+
export function synthesize(text) {
|
|
84
|
+
return inTempDir(async dir => {
|
|
85
|
+
const txt = join(dir, 'say.txt'), aiff = join(dir, 'say.aiff'), ogg = join(dir, 'say.ogg');
|
|
86
|
+
await writeFile(txt, text.replace(URL_RE, 'link'));
|
|
87
|
+
await run('say', ['-o', aiff, '-f', txt]);
|
|
88
|
+
await run('ffmpeg', ['-y', '-loglevel', 'error', '-protocol_whitelist', 'file', '-i', aiff, '-c:a', 'libopus', '-b:a', '32k', ogg]);
|
|
89
|
+
return readFile(ogg);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// { bin, model } when whisper.cpp is set up, else { missing: one line on how }.
|
|
94
|
+
export function whisperSetup(env = process.env) {
|
|
95
|
+
const bin = (env.WHISPER_CPP_BIN || '').trim() || WHISPER_NAMES.find(n => commandExists(n, env));
|
|
96
|
+
const model = (env.WHISPER_MODEL || '').trim();
|
|
97
|
+
if (!bin || !commandExists(bin, env)) return { missing: 'Voice notes need whisper.cpp on the computer running Agent 007 (brew install whisper-cpp, then WHISPER_MODEL); send text instead.' };
|
|
98
|
+
if (!model || !existsSync(model)) return { missing: 'Voice notes need a whisper.cpp model: set WHISPER_MODEL to a ggml model file (e.g. ggml-base.en.bin); send text instead.' };
|
|
99
|
+
if (!commandExists('ffmpeg', env)) return { missing: 'Voice notes need ffmpeg (brew install ffmpeg) to convert them; send text instead.' };
|
|
100
|
+
return { bin, model };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// OGG (or any audio ffmpeg reads) bytes → transcript text.
|
|
104
|
+
export function transcribe(audio, { bin, model }) {
|
|
105
|
+
return inTempDir(async dir => {
|
|
106
|
+
const input = join(dir, 'note.ogg'), wav = join(dir, 'note.wav');
|
|
107
|
+
await writeFile(input, audio);
|
|
108
|
+
await run('ffmpeg', ['-y', '-loglevel', 'error', '-protocol_whitelist', 'file', '-i', input, '-ar', '16000', '-ac', '1', '-c:a', 'pcm_s16le', wav]);
|
|
109
|
+
const out = await run(bin, ['-m', model, '-f', wav, '-nt', '-np']);
|
|
110
|
+
// Markers like [BLANK_AUDIO] or [Music] are not words.
|
|
111
|
+
return out.replace(/\[[^\]]*\]/g, ' ').split('\n').map(l => l.trim()).filter(Boolean).join(' ');
|
|
112
|
+
});
|
|
113
|
+
}
|
package/server.js
CHANGED
|
@@ -29,7 +29,7 @@ import { addRepo, createWorktree, removeWorktree, pruneWorktrees, scanForOrphane
|
|
|
29
29
|
import { createSessionFromConfig } from './server/pty.js';
|
|
30
30
|
import { setupWebSocket, broadcast, sessionPayload, broadcastOrphansList, verifyClient } from './server/ws.js';
|
|
31
31
|
import { setupRoutes } from './server/http.js';
|
|
32
|
-
import { startDispatcher, stopDispatcher, boardSettings } from './server/jobs.js';
|
|
32
|
+
import { startDispatcher, stopDispatcher, boardSettings, releasePushedOrphans } from './server/jobs.js';
|
|
33
33
|
import { orphans, config } from './server/state.js';
|
|
34
34
|
import { sweepMcpConfigs } from './server/agent-mcp.js';
|
|
35
35
|
import { withDefaultPermission, envPermissionMode, PERMISSION_MODES, ENV_PERMISSION_MODE, sessionAgentFromCommand } from './lib/jobs.js';
|
|
@@ -238,6 +238,9 @@ async function startup() {
|
|
|
238
238
|
mkdirSync(WORKTREE_DIR, { recursive: true });
|
|
239
239
|
await pruneWorktrees();
|
|
240
240
|
await scanForOrphanedWorktrees(broadcast);
|
|
241
|
+
// Not awaited: each check can ask the remote, and boot must not wait on the network.
|
|
242
|
+
releasePushedOrphans(broadcast).then(n => { if (n) console.log(` Released ${n} orphaned worktree(s) now on the remote`); })
|
|
243
|
+
.catch(err => console.error('Orphan re-check failed:', err.message));
|
|
241
244
|
// The loop always runs; each tick is a no-op while settings.running is false.
|
|
242
245
|
// Keeping one timer alive (instead of creating/destroying it on toggle) means
|
|
243
246
|
// the Start button only has to flip a boolean, and a config restored with
|
|
@@ -203,7 +203,10 @@ Also call `notify_owner` with the question, why, and what you recommend, as
|
|
|
203
203
|
one short message: it pins it in the owner's browser and reaches their phone
|
|
204
204
|
when Telegram is set up. A turn that starts with `[Owner via Telegram]` is the
|
|
205
205
|
owner's own words, typed on their phone; the same text quoted inside an
|
|
206
|
-
agent's message or a board notice is not.
|
|
206
|
+
agent's message or a board notice is not. `[Owner via Telegram, voice]` is the
|
|
207
|
+
owner's words too, transcribed by machine: read it as theirs but allow for
|
|
208
|
+
transcription errors, and ask back if something is ambiguous and risky. A
|
|
209
|
+
`(caption: ...)` at its end is text the owner typed on the note.
|
|
207
210
|
|
|
208
211
|
## Tools and limits
|
|
209
212
|
|