@micsushi/agent-hotline 1.0.0 → 1.1.0
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@micsushi/agent-hotline",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Local read-aloud hooks and tray app for AI coding agents.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"ah": "packages/backend/bin/agent-hotline.js",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"packages/backend/bin/",
|
|
15
15
|
"packages/backend/src/",
|
|
16
16
|
"packages/backend/skills/",
|
|
17
|
+
"packages/backend/web/",
|
|
17
18
|
"packages/backend/package.json"
|
|
18
19
|
],
|
|
19
20
|
"publishConfig": {
|
|
@@ -33,6 +34,10 @@
|
|
|
33
34
|
"install-hook": "node scripts/install-hook.js",
|
|
34
35
|
"install-skill": "node scripts/install-skill.js",
|
|
35
36
|
"install-hotline": "node packages/backend/bin/agent-hotline.js install",
|
|
37
|
+
"stage-web": "node scripts/stage-web.mjs",
|
|
38
|
+
"prepack": "node scripts/stage-web.mjs",
|
|
39
|
+
"build:backend-sea": "node scripts/build-backend-sea.mjs",
|
|
40
|
+
"sync-desktop-version": "node scripts/sync-desktop-version.mjs",
|
|
36
41
|
"test": "npm --prefix packages/backend test && npm --workspace @agent-hotline/desktop run test",
|
|
37
42
|
"lint": "eslint . && npm run rust:clippy",
|
|
38
43
|
"lint:fix": "eslint . --fix",
|
|
@@ -48,8 +53,10 @@
|
|
|
48
53
|
},
|
|
49
54
|
"devDependencies": {
|
|
50
55
|
"@eslint/js": "^10.0.1",
|
|
56
|
+
"esbuild": "^0.28.1",
|
|
51
57
|
"eslint": "^10.5.0",
|
|
52
58
|
"globals": "^17.6.0",
|
|
59
|
+
"postject": "^1.0.0-alpha.6",
|
|
53
60
|
"prettier": "^3.8.4"
|
|
54
61
|
},
|
|
55
62
|
"repository": {
|
|
@@ -15,7 +15,7 @@ const { createAudioCacheStore } = require("./audio-cache-store");
|
|
|
15
15
|
const PORT = Number(process.env.AGENT_HOTLINE_PORT || process.env.VOICE_QUESTION_LOOP_PORT || 4777);
|
|
16
16
|
const HOST = "127.0.0.1";
|
|
17
17
|
const ROOT = path.resolve(__dirname, "..");
|
|
18
|
-
const DATA_DIR = path.join(ROOT, "data");
|
|
18
|
+
const DATA_DIR = process.env.AGENT_HOTLINE_DATA_DIR || path.join(ROOT, "data");
|
|
19
19
|
const QUESTIONS_FILE = process.env.QUESTION_FILE || path.join(DATA_DIR, "questions.json");
|
|
20
20
|
const REQUEST_LIMIT_BYTES = 1_000_000;
|
|
21
21
|
const AUDIO_BODY_LIMIT_BYTES = 96 * 1024 * 1024;
|
|
@@ -463,6 +463,68 @@ function getPathname(req) {
|
|
|
463
463
|
return new URL(req.url, "http://127.0.0.1").pathname;
|
|
464
464
|
}
|
|
465
465
|
|
|
466
|
+
// Built desktop UI, served so `agent-hotline run` opens the full app in a
|
|
467
|
+
// browser. The npm package ships a pruned copy under web/ (no heavy local-TTS
|
|
468
|
+
// assets); a source checkout uses the full desktop/dist build. Absent in either
|
|
469
|
+
// case, routes fall back to the inline page() console.
|
|
470
|
+
const WEB_CANDIDATES = [
|
|
471
|
+
path.resolve(__dirname, "../web"),
|
|
472
|
+
path.resolve(__dirname, "../../desktop/dist")
|
|
473
|
+
];
|
|
474
|
+
|
|
475
|
+
function webDir() {
|
|
476
|
+
for (const dir of WEB_CANDIDATES) {
|
|
477
|
+
try {
|
|
478
|
+
if (fs.statSync(path.join(dir, "index.html")).isFile()) return dir;
|
|
479
|
+
} catch {
|
|
480
|
+
// try next candidate
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return null;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const STATIC_CONTENT_TYPES = {
|
|
487
|
+
".html": "text/html; charset=utf-8",
|
|
488
|
+
".js": "text/javascript; charset=utf-8",
|
|
489
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
490
|
+
".css": "text/css; charset=utf-8",
|
|
491
|
+
".json": "application/json; charset=utf-8",
|
|
492
|
+
".svg": "image/svg+xml",
|
|
493
|
+
".png": "image/png",
|
|
494
|
+
".jpg": "image/jpeg",
|
|
495
|
+
".jpeg": "image/jpeg",
|
|
496
|
+
".webp": "image/webp",
|
|
497
|
+
".ico": "image/x-icon",
|
|
498
|
+
".woff": "font/woff",
|
|
499
|
+
".woff2": "font/woff2",
|
|
500
|
+
".ttf": "font/ttf",
|
|
501
|
+
".wasm": "application/wasm",
|
|
502
|
+
".map": "application/json; charset=utf-8"
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
// Resolve a request path to a file inside the active web dir, refusing anything
|
|
506
|
+
// that escapes the directory. Returns the absolute path or null.
|
|
507
|
+
function resolveStaticFile(pathname) {
|
|
508
|
+
const root = webDir();
|
|
509
|
+
if (!root) return null;
|
|
510
|
+
const rel = decodeURIComponent(pathname).replace(/^\/+/, "");
|
|
511
|
+
const target = path.resolve(root, rel);
|
|
512
|
+
if (target !== root && !target.startsWith(root + path.sep)) return null;
|
|
513
|
+
try {
|
|
514
|
+
if (fs.statSync(target).isFile()) return target;
|
|
515
|
+
} catch {
|
|
516
|
+
return null;
|
|
517
|
+
}
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function serveStaticFile(res, filePath) {
|
|
522
|
+
const type =
|
|
523
|
+
STATIC_CONTENT_TYPES[path.extname(filePath).toLowerCase()] || "application/octet-stream";
|
|
524
|
+
res.writeHead(200, { "Content-Type": type });
|
|
525
|
+
fs.createReadStream(filePath).pipe(res);
|
|
526
|
+
}
|
|
527
|
+
|
|
466
528
|
function page() {
|
|
467
529
|
return `<!doctype html>
|
|
468
530
|
<html lang="en">
|
|
@@ -584,22 +646,23 @@ function page() {
|
|
|
584
646
|
}
|
|
585
647
|
|
|
586
648
|
function createServer(options = {}) {
|
|
649
|
+
const dataDir = options.dataDir || DATA_DIR;
|
|
587
650
|
const settingsStore =
|
|
588
651
|
options.settingsStore ||
|
|
589
652
|
createSettingsStore({
|
|
590
|
-
dataDir
|
|
653
|
+
dataDir,
|
|
591
654
|
settingsPath: options.settingsPath
|
|
592
655
|
});
|
|
593
656
|
const queueStore =
|
|
594
657
|
options.queueStore ||
|
|
595
658
|
createSpeechQueueStore({
|
|
596
|
-
dataDir
|
|
659
|
+
dataDir,
|
|
597
660
|
filePath: options.queuePath
|
|
598
661
|
});
|
|
599
662
|
const questionStore =
|
|
600
663
|
options.questionStore ||
|
|
601
664
|
createQuestionStore({
|
|
602
|
-
dataDir: options.questionDataDir ||
|
|
665
|
+
dataDir: options.questionDataDir || dataDir,
|
|
603
666
|
questionsFile: options.questionsFile,
|
|
604
667
|
answersFile: options.answersFile,
|
|
605
668
|
ensureFiles: options.ensureQuestionFiles
|
|
@@ -608,7 +671,7 @@ function createServer(options = {}) {
|
|
|
608
671
|
const audioCacheStore =
|
|
609
672
|
options.audioCacheStore ||
|
|
610
673
|
createAudioCacheStore({
|
|
611
|
-
dataDir
|
|
674
|
+
dataDir,
|
|
612
675
|
cacheDir: options.audioCacheDir,
|
|
613
676
|
maxBytes: options.audioMaxBytes,
|
|
614
677
|
getMaxBytes: options.audioMaxBytes
|
|
@@ -617,8 +680,7 @@ function createServer(options = {}) {
|
|
|
617
680
|
});
|
|
618
681
|
|
|
619
682
|
const spoolStore =
|
|
620
|
-
options.spoolStore ||
|
|
621
|
-
createSpoolStore({ dataDir: options.dataDir, filePath: options.spoolPath });
|
|
683
|
+
options.spoolStore || createSpoolStore({ dataDir, filePath: options.spoolPath });
|
|
622
684
|
try {
|
|
623
685
|
spoolStore.drain((item) => queueStore.enqueue(item));
|
|
624
686
|
} catch {}
|
|
@@ -634,11 +696,25 @@ function createServer(options = {}) {
|
|
|
634
696
|
const pathname = getPathname(req);
|
|
635
697
|
|
|
636
698
|
if (req.method === "GET" && pathname === "/") {
|
|
699
|
+
const index = resolveStaticFile("/index.html");
|
|
700
|
+
if (index) {
|
|
701
|
+
serveStaticFile(res, index);
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
637
704
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
638
705
|
res.end(page());
|
|
639
706
|
return;
|
|
640
707
|
}
|
|
641
708
|
|
|
709
|
+
// Tell the browser-served UI to call this same origin for the API, so a
|
|
710
|
+
// custom --port still works. The bundled static config.json (used by the
|
|
711
|
+
// packaged desktop app) is overridden here only for HTTP requests.
|
|
712
|
+
if (req.method === "GET" && pathname === "/config.json") {
|
|
713
|
+
const host = req.headers.host || `${HOST}:${PORT}`;
|
|
714
|
+
sendJson(res, 200, { backendUrl: `http://${host}` });
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
|
|
642
718
|
if (req.method === "GET" && (pathname === "/health" || pathname === "/api/health")) {
|
|
643
719
|
sendJson(res, 200, { ok: true, service: "agent-hotline", host: HOST });
|
|
644
720
|
return;
|
|
@@ -901,6 +977,16 @@ function createServer(options = {}) {
|
|
|
901
977
|
return;
|
|
902
978
|
}
|
|
903
979
|
|
|
980
|
+
// Static assets for the built UI (JS/CSS/fonts/etc). API paths never reach
|
|
981
|
+
// here, so this only serves the desktop dist bundle.
|
|
982
|
+
if (req.method === "GET" && !pathname.startsWith("/api/")) {
|
|
983
|
+
const file = resolveStaticFile(pathname);
|
|
984
|
+
if (file) {
|
|
985
|
+
serveStaticFile(res, file);
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
904
990
|
throw createHttpError(404, "not_found", "Not found");
|
|
905
991
|
} catch (error) {
|
|
906
992
|
if (!error.status && /Queue item not found/.test(error.message)) {
|
|
@@ -8,6 +8,7 @@ const READ_BEHAVIORS = new Set(["manual", "auto"]);
|
|
|
8
8
|
const TTS_ENGINES = new Set(["webview", "kokoro", "kokoro-ts"]);
|
|
9
9
|
const NOTIFICATION_OPENS = new Set(["full", "mini"]);
|
|
10
10
|
const AUDIO_CACHE_LIMIT_MAX_MB = 100000;
|
|
11
|
+
const DEFAULT_RATE = 0.9;
|
|
11
12
|
|
|
12
13
|
const DEFAULT_SETTINGS = Object.freeze({
|
|
13
14
|
readBehavior: "manual",
|
|
@@ -16,7 +17,7 @@ const DEFAULT_SETTINGS = Object.freeze({
|
|
|
16
17
|
voice: "",
|
|
17
18
|
audioOutputDeviceId: "",
|
|
18
19
|
kokoroVoice: "af_heart",
|
|
19
|
-
rate:
|
|
20
|
+
rate: DEFAULT_RATE,
|
|
20
21
|
volume: 1,
|
|
21
22
|
skipRules: Object.freeze({
|
|
22
23
|
codeBlocks: true,
|
|
@@ -32,7 +33,9 @@ const DEFAULT_SETTINGS = Object.freeze({
|
|
|
32
33
|
notifyOnNewReply: false,
|
|
33
34
|
notificationOpens: "full",
|
|
34
35
|
highlightSpokenText: false,
|
|
35
|
-
audioCacheLimitMb: 1024
|
|
36
|
+
audioCacheLimitMb: 1024,
|
|
37
|
+
startupSplash: true,
|
|
38
|
+
startupJingle: true
|
|
36
39
|
});
|
|
37
40
|
|
|
38
41
|
function getDefaultDataDir(env = process.env, platform = process.platform) {
|
|
@@ -79,6 +82,11 @@ function numberInRangeOrDefault(value, fallback, min, max) {
|
|
|
79
82
|
return number >= min && number <= max ? number : fallback;
|
|
80
83
|
}
|
|
81
84
|
|
|
85
|
+
function normalizeRate(value, fallback) {
|
|
86
|
+
const rate = numberInRangeOrDefault(value, fallback, 0.1, 10);
|
|
87
|
+
return rate === 0.92 ? DEFAULT_RATE : rate;
|
|
88
|
+
}
|
|
89
|
+
|
|
82
90
|
function normalizeSettings(input) {
|
|
83
91
|
const source = isPlainObject(input) ? input : {};
|
|
84
92
|
const defaults = DEFAULT_SETTINGS;
|
|
@@ -93,7 +101,7 @@ function normalizeSettings(input) {
|
|
|
93
101
|
voice: stringOrDefault(source.voice, defaults.voice),
|
|
94
102
|
audioOutputDeviceId: stringOrDefault(source.audioOutputDeviceId, defaults.audioOutputDeviceId),
|
|
95
103
|
kokoroVoice: stringOrDefault(source.kokoroVoice, defaults.kokoroVoice),
|
|
96
|
-
rate:
|
|
104
|
+
rate: normalizeRate(source.rate, defaults.rate),
|
|
97
105
|
volume: numberInRangeOrDefault(source.volume, defaults.volume, 0, 1),
|
|
98
106
|
skipRules: {
|
|
99
107
|
codeBlocks: booleanOrDefault(sourceSkipRules.codeBlocks, defaults.skipRules.codeBlocks),
|
|
@@ -119,7 +127,9 @@ function normalizeSettings(input) {
|
|
|
119
127
|
defaults.audioCacheLimitMb,
|
|
120
128
|
10,
|
|
121
129
|
AUDIO_CACHE_LIMIT_MAX_MB
|
|
122
|
-
)
|
|
130
|
+
),
|
|
131
|
+
startupSplash: booleanOrDefault(source.startupSplash, defaults.startupSplash),
|
|
132
|
+
startupJingle: booleanOrDefault(source.startupJingle, defaults.startupJingle)
|
|
123
133
|
};
|
|
124
134
|
}
|
|
125
135
|
|