claude-code-hub 1.26.0 → 1.27.1
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/features/voice/feature.json +42 -0
- package/lib/ext.js +50 -0
- package/lib/keymap.js +1 -0
- package/lib/voice/grammar.js +77 -0
- package/lib/voice/hex-stream.js +155 -0
- package/lib/voice/hex.js +53 -0
- package/lib/voice/index.js +111 -0
- package/lib/voice/title.js +77 -0
- package/package.json +6 -5
- package/packages/claude-hub-sdk/src/keys.js +1 -1
- package/public/app.js +519 -3
- package/public/index.html +89 -0
- package/server.js +67 -0
package/server.js
CHANGED
|
@@ -160,6 +160,7 @@ function resolveHubConfig() {
|
|
|
160
160
|
if (saved.terminal && typeof saved.terminal === 'object') config.terminal = saved.terminal;
|
|
161
161
|
if (Array.isArray(saved.apps)) config.apps = saved.apps;
|
|
162
162
|
if (saved.keys !== undefined) config.keys = saved.keys;
|
|
163
|
+
if (saved.voice && typeof saved.voice === 'object') config.voice = saved.voice;
|
|
163
164
|
copyLive(saved, config);
|
|
164
165
|
const savedPrevious = typeof saved.previousConfigDir === 'string' ? canonical(saved.previousConfigDir) : null;
|
|
165
166
|
if (dirs.includes(savedPrevious) && savedPrevious !== active) config.previousConfigDir = savedPrevious;
|
|
@@ -964,6 +965,7 @@ app.get('/api/config', (_req, res) => {
|
|
|
964
965
|
activeConfigDir: hubConfig.activeConfigDir,
|
|
965
966
|
defaultConfigDir,
|
|
966
967
|
configDirSwitch: configDirSwitch(hubConfig),
|
|
968
|
+
voice: voice && { provider: voice.provider, auto: voice.auto, commands: voice.commands },
|
|
967
969
|
version: HUB_VERSION,
|
|
968
970
|
});
|
|
969
971
|
});
|
|
@@ -1325,6 +1327,71 @@ app.get('/api/projects', async (_req, res) => {
|
|
|
1325
1327
|
}
|
|
1326
1328
|
});
|
|
1327
1329
|
|
|
1330
|
+
// Voice control, off unless config.json has voice.enabled; lib/voice loads only then.
|
|
1331
|
+
const VOICE_LOG = path.join(HUB_DIR, 'voice.jsonl');
|
|
1332
|
+
const voice = hubConfig.voice?.enabled === true ? loadVoice() : null;
|
|
1333
|
+
|
|
1334
|
+
function loadVoice() {
|
|
1335
|
+
try {
|
|
1336
|
+
const active = () => hubConfig.activeConfigDir;
|
|
1337
|
+
const ctx = () => ({
|
|
1338
|
+
apps: appsConfig(),
|
|
1339
|
+
actions: ACTIONS,
|
|
1340
|
+
configDir: active() && active() !== defaultConfigDir ? active() : null,
|
|
1341
|
+
});
|
|
1342
|
+
return require('./lib/voice').createVoice(hubConfig.voice, ctx);
|
|
1343
|
+
} catch (err) {
|
|
1344
|
+
console.log(`Voice is off: ${err.message}`);
|
|
1345
|
+
return null;
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
const heldOn = (req) => (typeof req.query.app === 'string' ? req.query.app : undefined);
|
|
1350
|
+
|
|
1351
|
+
// The browser provider: the page posts the recognized text so far while the key is held, and the
|
|
1352
|
+
// final text on release. It sits above /api/voice/:op, which would take "text" as an op.
|
|
1353
|
+
app.post('/api/voice/text', express.json({ limit: '64kb' }), async (req, res) => {
|
|
1354
|
+
if (!voice) return res.status(404).json({ error: 'voice is off' });
|
|
1355
|
+
if (typeof req.body?.text !== 'string') return res.status(400).json({ error: 'text is required' });
|
|
1356
|
+
const t0 = Date.now();
|
|
1357
|
+
const r = await voice.text(req.body.text, req.body.final === true, heldOn(req));
|
|
1358
|
+
if (req.body.final === true) logVoice(r, t0, { provider: 'browser' });
|
|
1359
|
+
res.json(r);
|
|
1360
|
+
});
|
|
1361
|
+
|
|
1362
|
+
app.post('/api/voice/:op', async (req, res) => {
|
|
1363
|
+
if (!voice) return res.status(404).json({ error: 'voice is off' });
|
|
1364
|
+
const { op } = req.params;
|
|
1365
|
+
if (!['start', 'stop', 'dictate', 'cancel'].includes(op)) return res.status(404).json({ error: 'unknown op' });
|
|
1366
|
+
const t0 = Date.now();
|
|
1367
|
+
const r = await voice[op](heldOn(req));
|
|
1368
|
+
if ((op === 'stop' || op === 'dictate') && r.ok) logVoice(r, t0);
|
|
1369
|
+
res.status(r.ok ? 200 : 502).json(r);
|
|
1370
|
+
});
|
|
1371
|
+
|
|
1372
|
+
function logVoice(r, t0, extra) {
|
|
1373
|
+
const entry = { at: new Date().toISOString(), text: r.text, command: r.command, ms: Date.now() - t0, ...extra };
|
|
1374
|
+
fs.appendFile(VOICE_LOG, `${JSON.stringify(entry)}\n`, () => {});
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
// Live speech: the page sends mic audio while the key is held, about 5 posts a second, and each
|
|
1378
|
+
// answer carries the text so far. 501 sends the page back to /api/voice/start and /stop.
|
|
1379
|
+
app.post(
|
|
1380
|
+
'/api/voice/stream/:op',
|
|
1381
|
+
express.raw({ type: 'application/octet-stream', limit: '256kb' }),
|
|
1382
|
+
async (req, res) => {
|
|
1383
|
+
if (!voice) return res.status(404).json({ error: 'voice is off' });
|
|
1384
|
+
const { op } = req.params;
|
|
1385
|
+
if (!['start', 'audio', 'finish', 'cancel'].includes(op)) return res.status(404).json({ error: 'unknown op' });
|
|
1386
|
+
if (!voice.stream) return res.status(501).json({ error: 'no live speech' });
|
|
1387
|
+
if (op === 'audio' && !Buffer.isBuffer(req.body)) return res.status(400).json({ error: 'audio is required' });
|
|
1388
|
+
const t0 = Date.now();
|
|
1389
|
+
const r = await voice.stream[op](req.body, heldOn(req));
|
|
1390
|
+
if (op === 'finish' && r.ok) logVoice(r, t0, { live: true });
|
|
1391
|
+
res.status(r.ok ? 200 : r.unsupported ? 501 : 502).json(r);
|
|
1392
|
+
},
|
|
1393
|
+
);
|
|
1394
|
+
|
|
1328
1395
|
// The tray stops the hub through this rather than taskkill, so the children and hub.json are cleaned up.
|
|
1329
1396
|
app.post('/api/shutdown', (_req, res) => {
|
|
1330
1397
|
console.log('Shutdown asked for over the API');
|