@micsushi/agent-hotline 0.5.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.
@@ -0,0 +1,191 @@
1
+ const fs = require("fs");
2
+ const os = require("os");
3
+ const path = require("path");
4
+
5
+ const APP_NAME = "Agent Hotline";
6
+ const SETTINGS_FILE = "settings.json";
7
+ const READ_BEHAVIORS = new Set(["manual", "auto"]);
8
+ const TTS_ENGINES = new Set(["webview", "kokoro", "kokoro-ts"]);
9
+ const NOTIFICATION_OPENS = new Set(["full", "mini"]);
10
+ const AUDIO_CACHE_LIMIT_MAX_MB = 100000;
11
+
12
+ const DEFAULT_SETTINGS = Object.freeze({
13
+ readBehavior: "manual",
14
+ mute: false,
15
+ engine: "webview",
16
+ voice: "",
17
+ kokoroVoice: "af_heart",
18
+ rate: 0.92,
19
+ volume: 1,
20
+ skipRules: Object.freeze({
21
+ codeBlocks: true,
22
+ diffs: true,
23
+ logs: true,
24
+ tables: true,
25
+ json: true,
26
+ longBulletLists: true
27
+ }),
28
+ codexEnabled: true,
29
+ claudeEnabled: true,
30
+ antigravityEnabled: true,
31
+ notifyOnNewReply: false,
32
+ notificationOpens: "full",
33
+ highlightSpokenText: false,
34
+ audioCacheLimitMb: 1024
35
+ });
36
+
37
+ function getDefaultDataDir(env = process.env, platform = process.platform) {
38
+ if (platform === "win32") {
39
+ return path.join(env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), APP_NAME);
40
+ }
41
+
42
+ if (platform === "darwin") {
43
+ return path.join(os.homedir(), "Library", "Application Support", APP_NAME);
44
+ }
45
+
46
+ return path.join(env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), APP_NAME);
47
+ }
48
+
49
+ function getSettingsPath(options = {}) {
50
+ if (options.settingsPath) {
51
+ return options.settingsPath;
52
+ }
53
+
54
+ return path.join(
55
+ options.dataDir || getDefaultDataDir(options.env, options.platform),
56
+ SETTINGS_FILE
57
+ );
58
+ }
59
+
60
+ function isPlainObject(value) {
61
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
62
+ }
63
+
64
+ function booleanOrDefault(value, fallback) {
65
+ return typeof value === "boolean" ? value : fallback;
66
+ }
67
+
68
+ function stringOrDefault(value, fallback) {
69
+ return typeof value === "string" ? value : fallback;
70
+ }
71
+
72
+ function finiteNumberOrDefault(value, fallback) {
73
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
74
+ }
75
+
76
+ function numberInRangeOrDefault(value, fallback, min, max) {
77
+ const number = finiteNumberOrDefault(value, fallback);
78
+ return number >= min && number <= max ? number : fallback;
79
+ }
80
+
81
+ function normalizeSettings(input) {
82
+ const source = isPlainObject(input) ? input : {};
83
+ const defaults = DEFAULT_SETTINGS;
84
+ const sourceSkipRules = isPlainObject(source.skipRules) ? source.skipRules : {};
85
+
86
+ return {
87
+ readBehavior: READ_BEHAVIORS.has(source.readBehavior)
88
+ ? source.readBehavior
89
+ : defaults.readBehavior,
90
+ mute: booleanOrDefault(source.mute, defaults.mute),
91
+ engine: TTS_ENGINES.has(source.engine) ? source.engine : defaults.engine,
92
+ voice: stringOrDefault(source.voice, defaults.voice),
93
+ kokoroVoice: stringOrDefault(source.kokoroVoice, defaults.kokoroVoice),
94
+ rate: numberInRangeOrDefault(source.rate, defaults.rate, 0.1, 10),
95
+ volume: numberInRangeOrDefault(source.volume, defaults.volume, 0, 1),
96
+ skipRules: {
97
+ codeBlocks: booleanOrDefault(sourceSkipRules.codeBlocks, defaults.skipRules.codeBlocks),
98
+ diffs: booleanOrDefault(sourceSkipRules.diffs, defaults.skipRules.diffs),
99
+ logs: booleanOrDefault(sourceSkipRules.logs, defaults.skipRules.logs),
100
+ tables: booleanOrDefault(sourceSkipRules.tables, defaults.skipRules.tables),
101
+ json: booleanOrDefault(sourceSkipRules.json, defaults.skipRules.json),
102
+ longBulletLists: booleanOrDefault(
103
+ sourceSkipRules.longBulletLists,
104
+ defaults.skipRules.longBulletLists
105
+ )
106
+ },
107
+ codexEnabled: booleanOrDefault(source.codexEnabled, defaults.codexEnabled),
108
+ claudeEnabled: booleanOrDefault(source.claudeEnabled, defaults.claudeEnabled),
109
+ antigravityEnabled: booleanOrDefault(source.antigravityEnabled, defaults.antigravityEnabled),
110
+ notifyOnNewReply: booleanOrDefault(source.notifyOnNewReply, defaults.notifyOnNewReply),
111
+ notificationOpens: NOTIFICATION_OPENS.has(source.notificationOpens)
112
+ ? source.notificationOpens
113
+ : defaults.notificationOpens,
114
+ highlightSpokenText: booleanOrDefault(source.highlightSpokenText, defaults.highlightSpokenText),
115
+ audioCacheLimitMb: numberInRangeOrDefault(
116
+ source.audioCacheLimitMb,
117
+ defaults.audioCacheLimitMb,
118
+ 10,
119
+ AUDIO_CACHE_LIMIT_MAX_MB
120
+ )
121
+ };
122
+ }
123
+
124
+ function readJson(file) {
125
+ try {
126
+ return JSON.parse(fs.readFileSync(file, "utf8"));
127
+ } catch {
128
+ return null;
129
+ }
130
+ }
131
+
132
+ function writeJson(file, value) {
133
+ fs.mkdirSync(path.dirname(file), { recursive: true });
134
+ const tempFile = `${file}.${process.pid}.tmp`;
135
+ fs.writeFileSync(tempFile, `${JSON.stringify(value, null, 2)}\n`, "utf8");
136
+ fs.renameSync(tempFile, file);
137
+ }
138
+
139
+ function loadSettings(options = {}) {
140
+ return normalizeSettings(readJson(getSettingsPath(options)));
141
+ }
142
+
143
+ function saveSettings(settings, options = {}) {
144
+ const nextSettings = normalizeSettings(settings);
145
+ writeJson(getSettingsPath(options), nextSettings);
146
+ return nextSettings;
147
+ }
148
+
149
+ function updateSettings(patch, options = {}) {
150
+ const current = loadSettings(options);
151
+ const source = isPlainObject(patch) ? patch : {};
152
+ const nextSettings = normalizeSettings({
153
+ ...current,
154
+ ...source,
155
+ skipRules: {
156
+ ...current.skipRules,
157
+ ...(isPlainObject(source.skipRules) ? source.skipRules : {})
158
+ }
159
+ });
160
+
161
+ return saveSettings(nextSettings, options);
162
+ }
163
+
164
+ function createSettingsStore(options = {}) {
165
+ return {
166
+ path: getSettingsPath(options),
167
+ load() {
168
+ return loadSettings(options);
169
+ },
170
+ save(settings) {
171
+ return saveSettings(settings, options);
172
+ },
173
+ update(patch) {
174
+ return updateSettings(patch, options);
175
+ }
176
+ };
177
+ }
178
+
179
+ module.exports = {
180
+ DEFAULT_SETTINGS,
181
+ AUDIO_CACHE_LIMIT_MAX_MB,
182
+ READ_BEHAVIORS: Array.from(READ_BEHAVIORS),
183
+ TTS_ENGINES: Array.from(TTS_ENGINES),
184
+ createSettingsStore,
185
+ getDefaultDataDir,
186
+ getSettingsPath,
187
+ loadSettings,
188
+ normalizeSettings,
189
+ saveSettings,
190
+ updateSettings
191
+ };
@@ -0,0 +1,251 @@
1
+ const SPOKEN_HEADING =
2
+ /^[ \t]*(?:#{1,6}[ \t]*)?(?:\*\*|__)?[ \t]*Spoken[ \t]*:?[ \t]*(?:\*\*|__)?[ \t]*$/imu;
3
+ const SECTION_HEADING =
4
+ /^[ \t]*(?:#{1,6}[ \t]*)?(?:\*\*|__)?[ \t]*(?:Displayed|Display|Details|Detail|Code|Commands?|Logs?|Output)[ \t]*:?[ \t]*(?:\*\*|__)?[ \t]*$/imu;
5
+ const SEPARATOR_LINE = /^[ \t]*[=*_-]{3,}[ \t]*$/;
6
+ const FENCED_BLOCK = /```[\s\S]*?```/g;
7
+ const INLINE_CODE = /`([^`\n]{1,120})`/g;
8
+ const TABLE_SEPARATOR = /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/;
9
+ const TABLE_ROW = /^\s*\|.*\|\s*$/;
10
+ const BULLET = /^\s*(?:[-*+]|\d+[.)])\s+/;
11
+ const STACK_TRACE = /^\s*at\s+\S.*\([^)]+:\d+:\d+\)\s*$/;
12
+ const LOG_LINE = /^\s*(?:\[[^\]]+\]\s*)?(?:TRACE|DEBUG|INFO|WARN|WARNING|ERROR|FATAL)\b/i;
13
+ const DIFF_LINE = /^\s*(?:diff --git|index [a-f0-9]|@@\s|[-+]{3}\s|[-+](?![-+]))/;
14
+
15
+ function filterSpeakableText(input) {
16
+ if (typeof input !== "string" || input.trim() === "") {
17
+ return skipResult();
18
+ }
19
+
20
+ const spoken = extractSpokenSection(input);
21
+ if (spoken) {
22
+ const text = styleSpokenText(normalizeSpeakableText(removeUnsafeBlocks(spoken)), {
23
+ maxSentences: 0
24
+ });
25
+ if (isSpeakable(text)) {
26
+ return {
27
+ shouldSpeak: true,
28
+ text,
29
+ reason: "spoken_section",
30
+ source: "spoken"
31
+ };
32
+ }
33
+ }
34
+
35
+ const text = styleSpokenText(normalizeSpeakableText(removeUnsafeBlocks(input)));
36
+ if (!isSpeakable(text)) {
37
+ return skipResult();
38
+ }
39
+
40
+ return {
41
+ shouldSpeak: true,
42
+ text,
43
+ reason: "speakable_text",
44
+ source: "prose"
45
+ };
46
+ }
47
+
48
+ function skipResult() {
49
+ return {
50
+ shouldSpeak: false,
51
+ text: "",
52
+ reason: "no_speakable_text",
53
+ source: "filtered"
54
+ };
55
+ }
56
+
57
+ function extractSpokenSection(input) {
58
+ const match = SPOKEN_HEADING.exec(input);
59
+ if (!match) {
60
+ return "";
61
+ }
62
+
63
+ const start = match.index + match[0].length;
64
+ const rest = input.slice(start);
65
+ const nextSection = SECTION_HEADING.exec(rest);
66
+ return (nextSection ? rest.slice(0, nextSection.index) : rest).trim();
67
+ }
68
+
69
+ function removeUnsafeBlocks(input) {
70
+ const withoutFences = input.replace(FENCED_BLOCK, "\n");
71
+ const lines = withoutFences.split(/\r?\n/);
72
+ const output = [];
73
+
74
+ for (let index = 0; index < lines.length; index += 1) {
75
+ const line = lines[index];
76
+
77
+ if (isJsonBlockStart(line)) {
78
+ index = skipJsonBlock(lines, index);
79
+ continue;
80
+ }
81
+
82
+ if (isLongBulletListAt(lines, index)) {
83
+ index = skipLineRun(lines, index, (candidate) => BULLET.test(candidate));
84
+ continue;
85
+ }
86
+
87
+ if (shouldSkipLine(line)) {
88
+ continue;
89
+ }
90
+
91
+ output.push(line);
92
+ }
93
+
94
+ return stripInlineMarkdown(output.join("\n").replace(INLINE_CODE, "$1"));
95
+ }
96
+
97
+ function stripInlineMarkdown(input) {
98
+ return input
99
+ .replace(/`+/g, "")
100
+ .replace(/(\*\*|__)(.*?)\1/g, "$2")
101
+ .replace(/\*\*|__/g, "")
102
+ .replace(/~~/g, "")
103
+ .replace(/\*/g, "")
104
+ .replace(/^[ \t]*#{1,6}[ \t]*/gm, "")
105
+ .replace(/^[ \t]*>[ \t]?/gm, "")
106
+ .replace(/(^|[\s(])_([^_\n]+)_(?=[\s).,!?]|$)/g, "$1$2");
107
+ }
108
+
109
+ function shouldSkipLine(line) {
110
+ return (
111
+ SEPARATOR_LINE.test(line) ||
112
+ TABLE_SEPARATOR.test(line) ||
113
+ TABLE_ROW.test(line) ||
114
+ DIFF_LINE.test(line) ||
115
+ LOG_LINE.test(line) ||
116
+ STACK_TRACE.test(line) ||
117
+ looksLikeJsonLine(line)
118
+ );
119
+ }
120
+
121
+ function isLongBulletListAt(lines, start) {
122
+ if (!BULLET.test(lines[start])) {
123
+ return false;
124
+ }
125
+
126
+ let count = 0;
127
+ for (let index = start; index < lines.length && BULLET.test(lines[index]); index += 1) {
128
+ count += 1;
129
+ }
130
+
131
+ return count >= 6;
132
+ }
133
+
134
+ function skipLineRun(lines, start, predicate) {
135
+ let index = start;
136
+ while (index + 1 < lines.length && predicate(lines[index + 1])) {
137
+ index += 1;
138
+ }
139
+ return index;
140
+ }
141
+
142
+ function isJsonBlockStart(line) {
143
+ return /^\s*[{[]\s*$/.test(line);
144
+ }
145
+
146
+ function skipJsonBlock(lines, start) {
147
+ let depth = 0;
148
+
149
+ for (let index = start; index < lines.length; index += 1) {
150
+ const line = lines[index];
151
+ depth += countMatches(line, /[{[]/g);
152
+ depth -= countMatches(line, /[}\]]/g);
153
+
154
+ if (depth <= 0 && index > start) {
155
+ return index;
156
+ }
157
+ }
158
+
159
+ return start;
160
+ }
161
+
162
+ function countMatches(value, pattern) {
163
+ return (value.match(pattern) || []).length;
164
+ }
165
+
166
+ function looksLikeJsonLine(line) {
167
+ return /^\s*"[^"]+"\s*:/.test(line) || /^\s*[}\]],?\s*$/.test(line);
168
+ }
169
+
170
+ function normalizeSpeakableText(input) {
171
+ return input
172
+ .split(/\r?\n/)
173
+ .map((line) => line.trim())
174
+ .filter(Boolean)
175
+ .join(" ")
176
+ .replace(/\s+/g, " ")
177
+ .trim();
178
+ }
179
+
180
+ function styleSpokenText(input, options = {}) {
181
+ const maxSentences = Number.isInteger(options.maxSentences) ? options.maxSentences : 3;
182
+ let text = input
183
+ .replace(/\bHere is\b/gi, "Here's")
184
+ .replace(/\bHere are\b/gi, "Here are")
185
+ .replace(/\bI have\b/g, "I've")
186
+ .replace(/\bI am\b/g, "I'm")
187
+ .replace(/\bI will\b/g, "I'll")
188
+ .replace(/\bI would\b/g, "I'd")
189
+ .replace(/(^|[.!?]\s+)It is\b/g, "$1It's")
190
+ .replace(/(^|[.!?]\s+)That is\b/g, "$1That's")
191
+ .replace(/(^|[.!?]\s+)There is\b/g, "$1There's")
192
+ .replace(/\bdo not\b/gi, "don't")
193
+ .replace(/\bdoes not\b/gi, "doesn't")
194
+ .replace(/\bcannot\b/gi, "can't")
195
+ .replace(/\bwill not\b/gi, "won't")
196
+ .replace(/\bshould not\b/gi, "shouldn't")
197
+ .replace(/\bwould not\b/gi, "wouldn't")
198
+ .replace(/\bcould not\b/gi, "couldn't")
199
+ .replace(/\butilize\b/gi, "use")
200
+ .replace(/\bapproximately\b/gi, "about")
201
+ .replace(/\btherefore\b/gi, "so")
202
+ .replace(/\bhowever\b/gi, "but")
203
+ .replace(/\bcommence\b/gi, "start")
204
+ .replace(/\bterminate\b/gi, "stop")
205
+ .replace(/\bThe following\b/gi, "This")
206
+ .replace(/\bIn conclusion,\s*/gi, "")
207
+ .replace(/\bTo summarize,\s*/gi, "")
208
+ .replace(/\s+/g, " ")
209
+ .trim();
210
+
211
+ const trimmed = maxSentences > 0 ? keepFirstUsefulSentences(text, maxSentences) : text;
212
+ return capitalizeSentenceStarts(trimmed);
213
+ }
214
+
215
+ function keepFirstUsefulSentences(input, limit) {
216
+ const sentences = input.match(/[^.!?]+[.!?]+|[^.!?]+$/g) || [];
217
+ const kept = [];
218
+
219
+ for (const sentence of sentences) {
220
+ const clean = sentence.trim();
221
+ if (!clean) continue;
222
+ kept.push(clean);
223
+ if (kept.length >= limit) break;
224
+ }
225
+
226
+ return kept.join(" ").trim();
227
+ }
228
+
229
+ function capitalizeSentenceStarts(input) {
230
+ return input.replace(/(^|[.!?]\s+)([a-z])/g, (_match, prefix, letter) => {
231
+ return `${prefix}${letter.toUpperCase()}`;
232
+ });
233
+ }
234
+
235
+ function isSpeakable(text) {
236
+ if (!text) {
237
+ return false;
238
+ }
239
+
240
+ const wordCount = text.split(/\s+/).filter(Boolean).length;
241
+ if (wordCount < 4) {
242
+ return false;
243
+ }
244
+
245
+ return !/:\s*$/.test(text);
246
+ }
247
+
248
+ module.exports = {
249
+ filterSpeakableText,
250
+ styleSpokenText
251
+ };