@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,45 @@
1
+ ---
2
+ name: agent-hotline-spoken
3
+ description: >
4
+ Formats agent responses for Agent Hotline read-aloud TTS. Triggered when the
5
+ user says "hotline on", "read aloud on", "start read-aloud", or "spoken mode"
6
+ in this session. Stays active until "hotline off" or "stop read-aloud".
7
+ ---
8
+
9
+ # Agent Hotline Spoken Skill
10
+
11
+ When the read-aloud skill is active, structure every response in exactly this
12
+ layout, in this order:
13
+
14
+ ```text
15
+ Spoken:
16
+ <2 to 6 short spoken sentences>
17
+
18
+ ==========
19
+
20
+ Displayed:
21
+ <full normal answer>
22
+ ```
23
+
24
+ ## Spoken Section
25
+
26
+ - Use 2 to 6 short sentences of natural spoken English.
27
+ - Do not include code, file paths, commands, symbols, or markdown.
28
+ - Keep one idea at a time.
29
+ - Ask at most one question.
30
+ - Make this section stand on its own for a listener.
31
+
32
+ ## Displayed Section
33
+
34
+ Use this for the full visible answer: code, commands, file paths, diffs, steps,
35
+ and detail.
36
+
37
+ ## Required Format
38
+
39
+ - `Spoken:` sits alone on its own line.
40
+ - `Displayed:` sits alone on its own line.
41
+ - `==========` sits alone on its own line between the two sections.
42
+ - Both sections are always present while the skill is active.
43
+
44
+ Agent Hotline reads only the `Spoken:` section. The `Displayed:` section stays
45
+ visible in the chat and is never spoken.
@@ -0,0 +1,246 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+
4
+ const { getDefaultDataDir } = require("./settings-store");
5
+
6
+ const DEFAULT_MAX_BYTES = 1024 * 1024 * 1024;
7
+ const CACHE_DIR_NAME = "audio-cache";
8
+ const INDEX_FILE = "index.json";
9
+
10
+ function sanitize(value) {
11
+ return String(value || "")
12
+ .replace(/[^A-Za-z0-9._-]/g, "_")
13
+ .slice(0, 120);
14
+ }
15
+
16
+ function keyFor(itemId, engine, voice) {
17
+ return `${sanitize(itemId)}__${sanitize(engine)}__${sanitize(voice)}`;
18
+ }
19
+
20
+ function emptyIndex() {
21
+ return { totalBytes: 0, entries: {} };
22
+ }
23
+
24
+ function safeReadJson(filePath) {
25
+ try {
26
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
27
+ if (parsed && typeof parsed === "object" && parsed.entries) return parsed;
28
+ } catch {}
29
+ return emptyIndex();
30
+ }
31
+
32
+ function createAudioCacheStore(options = {}) {
33
+ const dataDir = options.dataDir || getDefaultDataDir();
34
+ const cacheDir = options.cacheDir || path.join(dataDir, CACHE_DIR_NAME);
35
+ const indexPath = path.join(cacheDir, INDEX_FILE);
36
+ const staticMax = Number.isFinite(options.maxBytes) ? options.maxBytes : DEFAULT_MAX_BYTES;
37
+ const getMax = typeof options.getMaxBytes === "function" ? options.getMaxBytes : () => staticMax;
38
+ function currentMax() {
39
+ const value = Number(getMax());
40
+ return Number.isFinite(value) && value > 0 ? value : DEFAULT_MAX_BYTES;
41
+ }
42
+ const now = options.now || (() => new Date().toISOString());
43
+
44
+ let index = loadAndReconcile();
45
+
46
+ function wavPath(key) {
47
+ return path.join(cacheDir, `${key}.wav`);
48
+ }
49
+
50
+ function loadAndReconcile() {
51
+ const loaded = safeReadJson(indexPath);
52
+ const entries = {};
53
+ let totalBytes = 0;
54
+ for (const [key, entry] of Object.entries(loaded.entries || {})) {
55
+ if (!entry || typeof entry !== "object") continue;
56
+ if (!fs.existsSync(path.join(cacheDir, `${key}.wav`))) continue;
57
+ entries[key] = entry;
58
+ totalBytes += Number(entry.bytes) || 0;
59
+ }
60
+ return { totalBytes, entries };
61
+ }
62
+
63
+ function persist() {
64
+ fs.mkdirSync(cacheDir, { recursive: true });
65
+ const tempFile = `${indexPath}.${process.pid}.tmp`;
66
+ fs.writeFileSync(tempFile, `${JSON.stringify(index, null, 2)}\n`, "utf8");
67
+ fs.renameSync(tempFile, indexPath);
68
+ }
69
+
70
+ function recomputeTotal() {
71
+ index.totalBytes = Object.values(index.entries).reduce(
72
+ (sum, entry) => sum + (Number(entry.bytes) || 0),
73
+ 0
74
+ );
75
+ }
76
+
77
+ function evictToFit(keepKey) {
78
+ const cap = currentMax();
79
+ const ordered = Object.values(index.entries)
80
+ .filter((entry) => entry.key !== keepKey)
81
+ .sort((a, b) => String(a.lastAccessedAt).localeCompare(String(b.lastAccessedAt)));
82
+
83
+ let i = 0;
84
+ while (index.totalBytes > cap && i < ordered.length) {
85
+ const victim = ordered[i];
86
+ i += 1;
87
+ removeKey(victim.key, { persist: false });
88
+ }
89
+ }
90
+
91
+ function enforceLimit() {
92
+ evictToFit(null);
93
+ persist();
94
+ }
95
+
96
+ function removeKey(key, { persist: shouldPersist = true } = {}) {
97
+ const entry = index.entries[key];
98
+ if (!entry) return false;
99
+ try {
100
+ fs.rmSync(wavPath(key), { force: true });
101
+ } catch {}
102
+ delete index.entries[key];
103
+ recomputeTotal();
104
+ if (shouldPersist) persist();
105
+ return true;
106
+ }
107
+
108
+ function touch(key) {
109
+ const entry = index.entries[key];
110
+ if (!entry) return;
111
+ entry.lastAccessedAt = now();
112
+ persist();
113
+ }
114
+
115
+ function has(itemId, engine, voice) {
116
+ return Boolean(index.entries[keyFor(itemId, engine, voice)]);
117
+ }
118
+
119
+ function getManifest(itemId, engine, voice) {
120
+ const key = keyFor(itemId, engine, voice);
121
+ const entry = index.entries[key];
122
+ if (!entry) return null;
123
+ touch(key);
124
+ return {
125
+ sampleRate: entry.sampleRate,
126
+ durationSec: entry.durationSec,
127
+ segments: Array.isArray(entry.segments) ? entry.segments : [],
128
+ wordAccurate: Boolean(entry.wordAccurate)
129
+ };
130
+ }
131
+
132
+ function getAudioPath(itemId, engine, voice) {
133
+ const key = keyFor(itemId, engine, voice);
134
+ const entry = index.entries[key];
135
+ if (!entry) return null;
136
+ const filePath = wavPath(key);
137
+ if (!fs.existsSync(filePath)) {
138
+ removeKey(key);
139
+ return null;
140
+ }
141
+ touch(key);
142
+ return filePath;
143
+ }
144
+
145
+ function put(itemId, engine, voice, payload) {
146
+ const wavBuffer = payload.wavBuffer;
147
+ if (!Buffer.isBuffer(wavBuffer) || wavBuffer.length === 0) {
148
+ throw new Error("wavBuffer must be a non-empty Buffer");
149
+ }
150
+ if (wavBuffer.length > currentMax()) {
151
+ return null;
152
+ }
153
+
154
+ const key = keyFor(itemId, engine, voice);
155
+ fs.mkdirSync(cacheDir, { recursive: true });
156
+ fs.writeFileSync(wavPath(key), wavBuffer);
157
+
158
+ const timestamp = now();
159
+ const existing = index.entries[key];
160
+ index.entries[key] = {
161
+ key,
162
+ itemId: String(itemId),
163
+ engine: String(engine),
164
+ voice: String(voice),
165
+ bytes: wavBuffer.length,
166
+ sampleRate: Number(payload.sampleRate) || 24000,
167
+ durationSec: Number(payload.durationSec) || 0,
168
+ wordAccurate: Boolean(payload.wordAccurate),
169
+ segments: Array.isArray(payload.segments) ? payload.segments : [],
170
+ createdAt: existing?.createdAt || timestamp,
171
+ lastAccessedAt: timestamp
172
+ };
173
+
174
+ recomputeTotal();
175
+ evictToFit(key);
176
+ persist();
177
+ return JSON.parse(JSON.stringify(index.entries[key]));
178
+ }
179
+
180
+ function removeOne(itemId, engine, voice) {
181
+ if (engine && voice) {
182
+ return removeKey(keyFor(itemId, engine, voice)) ? 1 : 0;
183
+ }
184
+ const id = String(itemId);
185
+ return removeWhere((entry) => entry.itemId === id);
186
+ }
187
+
188
+ function removeWhere(predicate) {
189
+ const keys = Object.values(index.entries)
190
+ .filter(predicate)
191
+ .map((entry) => entry.key);
192
+ let removed = 0;
193
+ for (const key of keys) {
194
+ if (removeKey(key, { persist: false })) removed += 1;
195
+ }
196
+ if (removed > 0) persist();
197
+ return removed;
198
+ }
199
+
200
+ function removeByItemIds(itemIds) {
201
+ const wanted = new Set(Array.from(itemIds || []).map(String));
202
+ return removeWhere((entry) => wanted.has(entry.itemId));
203
+ }
204
+
205
+ function clearAll() {
206
+ const count = Object.keys(index.entries).length;
207
+ for (const key of Object.keys(index.entries)) {
208
+ removeKey(key, { persist: false });
209
+ }
210
+ index = emptyIndex();
211
+ persist();
212
+ return count;
213
+ }
214
+
215
+ function list() {
216
+ return {
217
+ entries: Object.values(index.entries).map((entry) => ({ ...entry })),
218
+ totalBytes: index.totalBytes,
219
+ maxBytes: currentMax()
220
+ };
221
+ }
222
+
223
+ return {
224
+ cacheDir,
225
+ get maxBytes() {
226
+ return currentMax();
227
+ },
228
+ keyFor,
229
+ has,
230
+ getManifest,
231
+ getAudioPath,
232
+ put,
233
+ removeOne,
234
+ removeByItemIds,
235
+ removeWhere,
236
+ clearAll,
237
+ enforceLimit,
238
+ list
239
+ };
240
+ }
241
+
242
+ module.exports = {
243
+ createAudioCacheStore,
244
+ DEFAULT_MAX_BYTES,
245
+ keyFor
246
+ };
@@ -0,0 +1,320 @@
1
+ const fs = require("fs");
2
+ const { parseHookInput, SOURCE_APPS } = require("./hook-input-parser");
3
+ const { filterSpeakableText } = require("./speakable-filter");
4
+ const { createSpoolStore } = require("./spool-store");
5
+
6
+ const DEFAULT_HOST = "127.0.0.1";
7
+ const DEFAULT_PORT = 4777;
8
+ const REQUEST_TIMEOUT_MS = 750;
9
+
10
+ function getDefaultBaseUrl(env = process.env) {
11
+ if (typeof env.AGENT_HOTLINE_URL === "string" && env.AGENT_HOTLINE_URL.trim()) {
12
+ return trimTrailingSlash(env.AGENT_HOTLINE_URL.trim());
13
+ }
14
+
15
+ const port = env.AGENT_HOTLINE_PORT || env.VOICE_QUESTION_LOOP_PORT || DEFAULT_PORT;
16
+ return `http://${DEFAULT_HOST}:${port}`;
17
+ }
18
+
19
+ async function readStdin(stream = process.stdin) {
20
+ stream.setEncoding("utf8");
21
+
22
+ let input = "";
23
+ for await (const chunk of stream) {
24
+ input += chunk;
25
+ }
26
+
27
+ return input;
28
+ }
29
+
30
+ async function runHookCommand(options = {}) {
31
+ const input = typeof options.input === "string" ? options.input : await readStdin(options.stdin);
32
+ const baseUrl = trimTrailingSlash(options.baseUrl || getDefaultBaseUrl(options.env));
33
+ const fetchImpl = options.fetchImpl || globalThis.fetch;
34
+ const timeoutMs = options.timeoutMs || REQUEST_TIMEOUT_MS;
35
+ const env = options.env || process.env;
36
+ const spoolStore =
37
+ options.spoolStore ||
38
+ createSpoolStore({ dataDir: options.dataDir || env.AGENT_HOTLINE_DATA_DIR });
39
+
40
+ function bufferOffline(item, reason, message, error) {
41
+ try {
42
+ spoolStore.append(item);
43
+ return recoverable(reason, `${message} Buffered offline for the next backend start.`, error);
44
+ } catch (spoolError) {
45
+ return recoverable(reason, message, error || { message: spoolError.message });
46
+ }
47
+ }
48
+
49
+ if (typeof fetchImpl !== "function") {
50
+ return recoverable("fetch_unavailable", "This Node runtime does not expose fetch.");
51
+ }
52
+
53
+ const parsed = parseHookInput(input);
54
+ if (!parsed.ok) {
55
+ return skipped(parsed.reason, parsed.message);
56
+ }
57
+
58
+ if (!isSupportedSource(parsed.sourceApp)) {
59
+ return skipped("unsupported_source", "Hook source must be Codex or Claude.");
60
+ }
61
+
62
+ const filtered = filterSpeakableText(parsed.assistantText);
63
+ if (!filtered.shouldSpeak) {
64
+ return skipped(filtered.reason, "No speakable text after filtering.");
65
+ }
66
+ if (filtered.source !== "spoken") {
67
+ return skipped("skill_not_triggered", "No Spoken section; read-aloud skill is not active.");
68
+ }
69
+
70
+ const enqueueBody = {
71
+ rawSource: parsed.assistantText,
72
+ speakableText: filtered.text,
73
+ sourceApp: parsed.sourceApp,
74
+ threadId: parsed.threadId,
75
+ threadLabel: parsed.threadLabel,
76
+ sessionName: resolveSessionName(parsed),
77
+ projectPath: parsed.projectPath,
78
+ projectName: parsed.projectName,
79
+ userMessages: parsed.userMessages
80
+ };
81
+
82
+ const settingsResult = await requestJson(fetchImpl, `${baseUrl}/api/settings`, {
83
+ method: "GET",
84
+ timeoutMs
85
+ });
86
+ if (!settingsResult.ok) {
87
+ return bufferOffline(
88
+ enqueueBody,
89
+ "backend_unavailable",
90
+ "Agent Hotline backend is not available.",
91
+ settingsResult.error
92
+ );
93
+ }
94
+
95
+ const settings = settingsResult.body && settingsResult.body.settings;
96
+ if (!isSourceEnabled(settings, parsed.sourceApp)) {
97
+ return skipped("source_disabled", `${parsed.sourceApp} hook playback is disabled.`);
98
+ }
99
+
100
+ const enqueueResult = await requestJson(fetchImpl, `${baseUrl}/api/queue`, {
101
+ method: "POST",
102
+ timeoutMs,
103
+ body: enqueueBody
104
+ });
105
+ if (!enqueueResult.ok) {
106
+ return bufferOffline(
107
+ enqueueBody,
108
+ "enqueue_failed",
109
+ "Speakable text could not be queued.",
110
+ enqueueResult.error
111
+ );
112
+ }
113
+
114
+ return {
115
+ ok: true,
116
+ action: "enqueued",
117
+ sourceApp: parsed.sourceApp,
118
+ reason: filtered.reason,
119
+ item: enqueueResult.body && enqueueResult.body.item
120
+ };
121
+ }
122
+
123
+ async function requestJson(fetchImpl, url, options = {}) {
124
+ const controller = new AbortController();
125
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs || REQUEST_TIMEOUT_MS);
126
+
127
+ try {
128
+ const response = await fetchImpl(url, {
129
+ method: options.method || "GET",
130
+ signal: controller.signal,
131
+ headers: options.body ? { "Content-Type": "application/json" } : undefined,
132
+ body: options.body ? JSON.stringify(options.body) : undefined
133
+ });
134
+ const body = await readResponseJson(response);
135
+
136
+ if (!response.ok) {
137
+ return {
138
+ ok: false,
139
+ status: response.status,
140
+ body,
141
+ error: responseError(response, body)
142
+ };
143
+ }
144
+
145
+ return { ok: true, status: response.status, body };
146
+ } catch (error) {
147
+ return {
148
+ ok: false,
149
+ error: {
150
+ name: error.name,
151
+ message: error.message
152
+ }
153
+ };
154
+ } finally {
155
+ clearTimeout(timeout);
156
+ }
157
+ }
158
+
159
+ async function readResponseJson(response) {
160
+ try {
161
+ return await response.json();
162
+ } catch {
163
+ return null;
164
+ }
165
+ }
166
+
167
+ function responseError(response, body) {
168
+ const apiError = body && body.error;
169
+ if (apiError && typeof apiError.message === "string") {
170
+ return {
171
+ status: response.status,
172
+ code: apiError.code,
173
+ message: apiError.message
174
+ };
175
+ }
176
+
177
+ return {
178
+ status: response.status,
179
+ message: `HTTP ${response.status}`
180
+ };
181
+ }
182
+
183
+ function isSupportedSource(sourceApp) {
184
+ return (
185
+ sourceApp === SOURCE_APPS.CODEX ||
186
+ sourceApp === SOURCE_APPS.CLAUDE ||
187
+ sourceApp === SOURCE_APPS.ANTIGRAVITY
188
+ );
189
+ }
190
+
191
+ function isSourceEnabled(settings, sourceApp) {
192
+ if (!settings || typeof settings !== "object") {
193
+ return true;
194
+ }
195
+
196
+ if (sourceApp === SOURCE_APPS.CODEX) {
197
+ return settings.codexEnabled !== false;
198
+ }
199
+
200
+ if (sourceApp === SOURCE_APPS.CLAUDE) {
201
+ return settings.claudeEnabled !== false;
202
+ }
203
+
204
+ if (sourceApp === SOURCE_APPS.ANTIGRAVITY) {
205
+ return settings.antigravityEnabled !== false;
206
+ }
207
+
208
+ return false;
209
+ }
210
+
211
+ function skipped(reason, message) {
212
+ return {
213
+ ok: true,
214
+ action: "skipped",
215
+ reason,
216
+ message
217
+ };
218
+ }
219
+
220
+ function recoverable(reason, message, error) {
221
+ return {
222
+ ok: true,
223
+ action: "recoverable_failure",
224
+ reason,
225
+ message,
226
+ ...(error ? { error } : {})
227
+ };
228
+ }
229
+
230
+ function trimTrailingSlash(value) {
231
+ return String(value || "").replace(/\/+$/, "");
232
+ }
233
+
234
+ function resolveSessionName(parsed) {
235
+ if (parsed.sessionName) {
236
+ return truncateName(parsed.sessionName);
237
+ }
238
+ const transcriptPath = parsed.payload && parsed.payload.transcript_path;
239
+ if (typeof transcriptPath === "string" && transcriptPath) {
240
+ return truncateName(readFirstUserMessage(transcriptPath));
241
+ }
242
+ return undefined;
243
+ }
244
+
245
+ function truncateName(name) {
246
+ const clean = String(name || "")
247
+ .replace(/\s+/g, " ")
248
+ .trim();
249
+ if (!clean) return undefined;
250
+ return clean.length > 60 ? `${clean.slice(0, 57)}...` : clean;
251
+ }
252
+
253
+ function readFirstUserMessage(transcriptPath) {
254
+ try {
255
+ const fd = fs.openSync(transcriptPath, "r");
256
+ try {
257
+ const buffer = Buffer.alloc(262144);
258
+ const bytes = fs.readSync(fd, buffer, 0, buffer.length, 0);
259
+ const text = buffer.toString("utf8", 0, bytes);
260
+ for (const line of text.split(/\r?\n/)) {
261
+ if (!line.trim()) continue;
262
+ let entry;
263
+ try {
264
+ entry = JSON.parse(line);
265
+ } catch {
266
+ continue;
267
+ }
268
+ if (!entry || entry.type !== "user" || !entry.message || entry.message.role !== "user") {
269
+ continue;
270
+ }
271
+ const content = entry.message.content;
272
+ let candidate = "";
273
+ if (typeof content === "string") {
274
+ candidate = content;
275
+ } else if (Array.isArray(content)) {
276
+ const part = content.find((p) => p && p.type === "text" && typeof p.text === "string");
277
+ candidate = part ? part.text : "";
278
+ }
279
+ if (candidate.trim()) return candidate.trim();
280
+ }
281
+ } finally {
282
+ fs.closeSync(fd);
283
+ }
284
+ } catch {}
285
+ return "";
286
+ }
287
+
288
+ async function main(options = {}) {
289
+ const env = options.env || process.env;
290
+ const result = await runHookCommand({
291
+ ...options,
292
+ env
293
+ });
294
+
295
+ if (env.AGENT_HOTLINE_HOOK_DEBUG === "1" && result.action !== "enqueued") {
296
+ const stderr = options.stderr || process.stderr;
297
+ stderr.write(`agent-hotline-hook: ${result.action} ${result.reason}\n`);
298
+ }
299
+
300
+ return 0;
301
+ }
302
+
303
+ if (require.main === module) {
304
+ main()
305
+ .then((exitCode) => {
306
+ process.exitCode = exitCode;
307
+ })
308
+ .catch(() => {
309
+ process.exitCode = 0;
310
+ });
311
+ }
312
+
313
+ module.exports = {
314
+ getDefaultBaseUrl,
315
+ isSourceEnabled,
316
+ main,
317
+ readStdin,
318
+ requestJson,
319
+ runHookCommand
320
+ };