pi-friday 0.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/README.md +137 -0
- package/acks.ts +166 -0
- package/daemon.ts +161 -0
- package/index.ts +509 -0
- package/package.json +19 -0
- package/panel.ts +338 -0
- package/prompt.ts +34 -0
- package/settings.json +18 -0
- package/settings.ts +75 -0
- package/voice.ts +400 -0
- package/wake_daemon.py +318 -0
package/index.ts
ADDED
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F.R.I.D.A.Y. — Voice-enabled Communications Panel
|
|
3
|
+
* Main entry point - wires together all modules
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
7
|
+
import { Type } from "@sinclair/typebox";
|
|
8
|
+
import { Text } from "@mariozechner/pi-tui";
|
|
9
|
+
import { mkdirSync, writeFileSync, appendFileSync, existsSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
13
|
+
|
|
14
|
+
// Module imports
|
|
15
|
+
import { loadSettings, saveSettings, type FridaySettings } from "./settings.js";
|
|
16
|
+
import {
|
|
17
|
+
killCurrentVoice, killOrphanTTS, speakText, enqueueVoiceWithMessage,
|
|
18
|
+
processVoiceQueueSynced, deriveVoiceText, setLogFunctions,
|
|
19
|
+
voiceQueue, voicePlaying
|
|
20
|
+
} from "./voice.js";
|
|
21
|
+
import {
|
|
22
|
+
openPanel, killPane, isPaneAlive, ensurePanelOpen, cleanupFiles,
|
|
23
|
+
writeMessage, writeMessagePassthrough
|
|
24
|
+
} from "./panel.js";
|
|
25
|
+
import {
|
|
26
|
+
killOrphanDaemons, startWakeDaemon, stopWakeDaemon, startWakeWatcher,
|
|
27
|
+
stopWakeWatcher, handleWakeCommand, isDaemonAlive
|
|
28
|
+
} from "./daemon.js";
|
|
29
|
+
import { scheduleAck, cancelAck, showAndSpeak } from "./acks.js";
|
|
30
|
+
import { buildSystemPrompt } from "./prompt.js";
|
|
31
|
+
|
|
32
|
+
export default function (pi: ExtensionAPI) {
|
|
33
|
+
|
|
34
|
+
// Spawned agents must not use Friday — no communicate tool, no panel, no voice, no acks
|
|
35
|
+
if (process.env.PI_AGENT_NAME) return;
|
|
36
|
+
|
|
37
|
+
// Friday requires tmux — the panel, voice, and daemon all depend on it
|
|
38
|
+
if (!process.env.TMUX) return;
|
|
39
|
+
|
|
40
|
+
// Dependency detection — check what's available on this system
|
|
41
|
+
const { execSync } = require("node:child_process");
|
|
42
|
+
function hasCommand(cmd: string): boolean {
|
|
43
|
+
try { execSync(`which ${cmd}`, { stdio: "ignore" }); return true; } catch { return false; }
|
|
44
|
+
}
|
|
45
|
+
const hasPiper = hasCommand("piper");
|
|
46
|
+
const hasSox = hasCommand("play");
|
|
47
|
+
const hasVoiceDeps = hasPiper && hasSox;
|
|
48
|
+
const hasPython = hasCommand("python3");
|
|
49
|
+
|
|
50
|
+
// State variables
|
|
51
|
+
let settings = loadSettings();
|
|
52
|
+
let enabled = true;
|
|
53
|
+
let voiceEnabled = hasVoiceDeps && settings.voice.enabled;
|
|
54
|
+
let paneId: string | null = null;
|
|
55
|
+
let paneWidth = 40;
|
|
56
|
+
let communicateCalledThisTurn = false;
|
|
57
|
+
let wakeDaemon: ChildProcess | null = null;
|
|
58
|
+
let wakeWatcher: any = null;
|
|
59
|
+
let lastCommandTimestamp = { value: 0 };
|
|
60
|
+
let lastMessageTime = { value: 0 };
|
|
61
|
+
let lastAgentEndTime = 0;
|
|
62
|
+
let interactionCount = { value: 0 };
|
|
63
|
+
let lastAckCategory = { value: null as any };
|
|
64
|
+
let lastAckIndex = { value: -1 };
|
|
65
|
+
let lastMessageWasQuestion = { value: false };
|
|
66
|
+
let lastFullMessageText = "";
|
|
67
|
+
let lastSpokenText = "";
|
|
68
|
+
let ackTimer = { value: null as ReturnType<typeof setTimeout> | null };
|
|
69
|
+
|
|
70
|
+
// Capture our own tmux pane so all tmux commands target the correct window
|
|
71
|
+
const ownerPaneId: string | null = process.env.TMUX_PANE ?? null;
|
|
72
|
+
const commsDir = join(tmpdir(), `pi-friday-${process.pid}`);
|
|
73
|
+
const messagesFile = join(commsDir, "messages.dat");
|
|
74
|
+
const commandFile = join(commsDir, "wake_command.json");
|
|
75
|
+
|
|
76
|
+
// Logging functions
|
|
77
|
+
const logFile = join(commsDir, "friday.log");
|
|
78
|
+
function log(msg: string) {
|
|
79
|
+
try { appendFileSync(logFile, `${new Date().toISOString()} ${msg}\n`); } catch {}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function logError(context: string, err: unknown): void {
|
|
83
|
+
try {
|
|
84
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
85
|
+
log(`ERROR [${context}]: ${msg}`);
|
|
86
|
+
} catch { /* absolute last resort — swallow silently */ }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Set up logging for voice module
|
|
90
|
+
setLogFunctions(log, logError);
|
|
91
|
+
|
|
92
|
+
// Helper functions
|
|
93
|
+
function sleep(ms: number): Promise<void> {
|
|
94
|
+
return new Promise((resolve) => {
|
|
95
|
+
const t = setTimeout(resolve, ms);
|
|
96
|
+
t.unref();
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function ensurePanelOpenWrapper(): Promise<boolean> {
|
|
101
|
+
const result = await ensurePanelOpen(
|
|
102
|
+
pi, settings, commsDir, messagesFile, ownerPaneId,
|
|
103
|
+
paneId, sleep, logError
|
|
104
|
+
);
|
|
105
|
+
if (result.success) {
|
|
106
|
+
paneId = result.paneId;
|
|
107
|
+
paneWidth = result.paneWidth;
|
|
108
|
+
}
|
|
109
|
+
return result.success;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function writeMessageWrapper(text: string) {
|
|
113
|
+
writeMessage(text, messagesFile, paneWidth, settings, lastMessageTime, logError);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function writeMessagePassthroughWrapper(text: string) {
|
|
117
|
+
writeMessagePassthrough(text, messagesFile, paneWidth, logError);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function enqueueVoiceWithMessageWrapper(text: string, speed?: number) {
|
|
121
|
+
enqueueVoiceWithMessage(text, log, logError, speed);
|
|
122
|
+
if (!voicePlaying) {
|
|
123
|
+
processVoiceQueueSynced(
|
|
124
|
+
ensurePanelOpenWrapper,
|
|
125
|
+
writeMessageWrapper,
|
|
126
|
+
settings,
|
|
127
|
+
commsDir,
|
|
128
|
+
wakeDaemon,
|
|
129
|
+
lastFullMessageText,
|
|
130
|
+
lastSpokenText,
|
|
131
|
+
lastMessageWasQuestion.value,
|
|
132
|
+
log,
|
|
133
|
+
logError
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function showAndSpeakWrapper(text: string) {
|
|
139
|
+
showAndSpeak(
|
|
140
|
+
text, voiceEnabled, ensurePanelOpenWrapper, writeMessageWrapper,
|
|
141
|
+
enqueueVoiceWithMessageWrapper, settings, logError
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function handleWakeCommandWrapper(text: string) {
|
|
146
|
+
handleWakeCommand(text, pi, log, logError);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Status helpers
|
|
150
|
+
function updateStatus(ui: any) {
|
|
151
|
+
try {
|
|
152
|
+
if (!enabled) { ui.setStatus("friday", undefined); return; }
|
|
153
|
+
const name = settings.name.toUpperCase();
|
|
154
|
+
let status = ui.theme.fg("accent", ` ${name} `);
|
|
155
|
+
if (hasVoiceDeps && voiceEnabled) status += ui.theme.fg("success", " VOICE ");
|
|
156
|
+
if (hasPython) {
|
|
157
|
+
const daemonAlive = isDaemonAlive(wakeDaemon);
|
|
158
|
+
if (daemonAlive) {
|
|
159
|
+
status += ui.theme.fg("warning", " DAEMON ON ");
|
|
160
|
+
} else if (settings.wakeWord.enabled) {
|
|
161
|
+
status += ui.theme.fg("error", " DAEMON OFF ");
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
ui.setStatus("friday", status);
|
|
165
|
+
} catch (e) { logError("updateStatus", e); }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Daemon management
|
|
169
|
+
function startWakeDaemonWrapper() {
|
|
170
|
+
try {
|
|
171
|
+
if (wakeDaemon) return;
|
|
172
|
+
wakeDaemon = startWakeDaemon(settings, commsDir, commandFile, log, logError);
|
|
173
|
+
if (wakeDaemon) {
|
|
174
|
+
wakeDaemon.on("exit", (code) => {
|
|
175
|
+
try {
|
|
176
|
+
log(`Wake daemon exited (code: ${code})`);
|
|
177
|
+
wakeDaemon = null;
|
|
178
|
+
stopWakeWatcherWrapper();
|
|
179
|
+
} catch (e) { logError("wakeDaemon.exit", e); }
|
|
180
|
+
});
|
|
181
|
+
startWakeWatcherWrapper();
|
|
182
|
+
}
|
|
183
|
+
} catch (e) { logError("startWakeDaemon", e); }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function stopWakeDaemonWrapper() {
|
|
187
|
+
try {
|
|
188
|
+
stopWakeWatcherWrapper();
|
|
189
|
+
if (wakeDaemon) {
|
|
190
|
+
stopWakeDaemon(wakeDaemon, logError);
|
|
191
|
+
wakeDaemon = null;
|
|
192
|
+
}
|
|
193
|
+
} catch (e) { logError("stopWakeDaemon", e); }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function startWakeWatcherWrapper() {
|
|
197
|
+
try {
|
|
198
|
+
stopWakeWatcherWrapper();
|
|
199
|
+
wakeWatcher = startWakeWatcher(
|
|
200
|
+
commandFile, lastCommandTimestamp, killCurrentVoice,
|
|
201
|
+
handleWakeCommandWrapper, logError
|
|
202
|
+
);
|
|
203
|
+
} catch (e) { logError("startWakeWatcher", e); }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function stopWakeWatcherWrapper() {
|
|
207
|
+
try {
|
|
208
|
+
if (wakeWatcher) {
|
|
209
|
+
stopWakeWatcher(wakeWatcher, logError);
|
|
210
|
+
wakeWatcher = null;
|
|
211
|
+
}
|
|
212
|
+
} catch (e) { logError("stopWakeWatcher", e); }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Custom Tool: communicate
|
|
216
|
+
pi.registerTool({
|
|
217
|
+
name: "communicate",
|
|
218
|
+
label: "Comm",
|
|
219
|
+
description: "Send a direct message to the user via the communications side panel.",
|
|
220
|
+
promptSnippet: "Send direct messages to the user via the side communications panel",
|
|
221
|
+
promptGuidelines: [
|
|
222
|
+
"ALL text goes through communicate. Every word directed at the user. No exceptions.",
|
|
223
|
+
"The main window is ONLY for visual data: tables, code blocks, SQL, file contents, command output, diffs.",
|
|
224
|
+
"Messages must be plain text only -- no markdown, no emojis. Write as natural spoken prose.",
|
|
225
|
+
"You can call communicate multiple times in one turn for separate points.",
|
|
226
|
+
"Be concise in your responses",
|
|
227
|
+
],
|
|
228
|
+
parameters: Type.Object({
|
|
229
|
+
message: Type.String({ description: "The message to display to the user" }),
|
|
230
|
+
new_topic: Type.Optional(Type.Boolean({
|
|
231
|
+
description: "Set true when the subject has changed from the previous message."
|
|
232
|
+
})),
|
|
233
|
+
...(hasVoiceDeps ? {
|
|
234
|
+
voice_summary: Type.Optional(Type.String({
|
|
235
|
+
description: "Optional: a short 1-2 sentence spoken summary for voice output."
|
|
236
|
+
})),
|
|
237
|
+
} : {}),
|
|
238
|
+
}),
|
|
239
|
+
|
|
240
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
241
|
+
try {
|
|
242
|
+
communicateCalledThisTurn = true;
|
|
243
|
+
|
|
244
|
+
if (!enabled) {
|
|
245
|
+
return {
|
|
246
|
+
content: [{ type: "text" as const, text: params.message }],
|
|
247
|
+
details: { delivered: false },
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (!paneId || !(await isPaneAlive(pi, paneId))) {
|
|
252
|
+
const result = await openPanel(pi, settings, commsDir, messagesFile, ownerPaneId, logError);
|
|
253
|
+
if (!result.success) {
|
|
254
|
+
return {
|
|
255
|
+
content: [{ type: "text" as const, text: params.message }],
|
|
256
|
+
details: { delivered: false },
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
paneId = result.paneId;
|
|
260
|
+
paneWidth = result.paneWidth;
|
|
261
|
+
await sleep(500);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (params.new_topic) {
|
|
265
|
+
lastMessageTime.value = 0;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
cancelAck(ackTimer);
|
|
269
|
+
|
|
270
|
+
if (voiceEnabled) {
|
|
271
|
+
if (voicePlaying || voiceQueue.length > 0) {
|
|
272
|
+
killCurrentVoice();
|
|
273
|
+
}
|
|
274
|
+
const spoken = deriveVoiceText(params.message, params.voice_summary);
|
|
275
|
+
lastFullMessageText = params.message;
|
|
276
|
+
lastSpokenText = spoken;
|
|
277
|
+
|
|
278
|
+
// Wait briefly for playback to start. Cap at 2s to avoid blocking shutdown.
|
|
279
|
+
let wrote = false;
|
|
280
|
+
const writeOnce = () => {
|
|
281
|
+
if (wrote) return;
|
|
282
|
+
wrote = true;
|
|
283
|
+
try { writeMessageWrapper(params.message); } catch (e) { logError("communicate.writeMessage", e); }
|
|
284
|
+
};
|
|
285
|
+
await new Promise<void>((resolve) => {
|
|
286
|
+
const timer = setTimeout(() => { writeOnce(); resolve(); }, 2000);
|
|
287
|
+
timer.unref();
|
|
288
|
+
const onStart = () => { clearTimeout(timer); writeOnce(); resolve(); };
|
|
289
|
+
speakText(
|
|
290
|
+
spoken, settings, commsDir, wakeDaemon, lastFullMessageText,
|
|
291
|
+
lastSpokenText, lastMessageWasQuestion.value, log, logError, onStart
|
|
292
|
+
).finally(() => { clearTimeout(timer); writeOnce(); resolve(); });
|
|
293
|
+
});
|
|
294
|
+
} else {
|
|
295
|
+
writeMessageWrapper(params.message);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
content: [{ type: "text" as const, text: "Message delivered to comms panel." }],
|
|
300
|
+
details: { delivered: true },
|
|
301
|
+
};
|
|
302
|
+
} catch (e) {
|
|
303
|
+
logError("communicate.execute", e);
|
|
304
|
+
return {
|
|
305
|
+
content: [{ type: "text" as const, text: params.message }],
|
|
306
|
+
details: { delivered: false },
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
},
|
|
310
|
+
|
|
311
|
+
renderCall(_args, theme, _context) {
|
|
312
|
+
return new Text(theme.fg("dim", theme.italic("communicating...")), 0, 0);
|
|
313
|
+
},
|
|
314
|
+
|
|
315
|
+
renderResult(result, _options, theme, _context) {
|
|
316
|
+
try {
|
|
317
|
+
const delivered = (result as any).details?.delivered;
|
|
318
|
+
if (delivered) return new Text(theme.fg("dim", "✓ delivered"), 0, 0);
|
|
319
|
+
return new Text(theme.fg("warning", "⚠ delivered inline"), 0, 0);
|
|
320
|
+
} catch {
|
|
321
|
+
return new Text(theme.fg("dim", "✓"), 0, 0);
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// Event handlers and commands
|
|
327
|
+
pi.on("before_agent_start", async (event) => {
|
|
328
|
+
try {
|
|
329
|
+
if (!enabled) return;
|
|
330
|
+
const result = { systemPrompt: event.systemPrompt + buildSystemPrompt(hasVoiceDeps) };
|
|
331
|
+
|
|
332
|
+
// Schedule acknowledgment
|
|
333
|
+
const prompt = event.prompt ?? "";
|
|
334
|
+
if (prompt && !prompt.startsWith("/")) {
|
|
335
|
+
scheduleAck(
|
|
336
|
+
prompt, ackTimer, lastMessageWasQuestion, lastAgentEndTime,
|
|
337
|
+
interactionCount, lastAckCategory, lastAckIndex,
|
|
338
|
+
showAndSpeakWrapper, logError
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
return result;
|
|
342
|
+
} catch (e) { logError("before_agent_start", e); }
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
pi.on("agent_end", async () => {
|
|
346
|
+
try { lastAgentEndTime = Date.now(); } catch {}
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
pi.on("turn_start", async () => {
|
|
350
|
+
try { communicateCalledThisTurn = false; } catch {}
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
pi.on("turn_end", async (event) => {
|
|
354
|
+
try {
|
|
355
|
+
if (!enabled || communicateCalledThisTurn) return;
|
|
356
|
+
|
|
357
|
+
const msg = event.message;
|
|
358
|
+
if (!msg || msg.role !== "assistant") return;
|
|
359
|
+
|
|
360
|
+
const textParts: string[] = [];
|
|
361
|
+
for (const block of msg.content) {
|
|
362
|
+
if (block.type === "text" && block.text?.trim()) {
|
|
363
|
+
textParts.push(block.text.trim());
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
if (textParts.length === 0) return;
|
|
367
|
+
|
|
368
|
+
const text = textParts.join("\n\n");
|
|
369
|
+
const ok = await ensurePanelOpenWrapper();
|
|
370
|
+
if (ok) writeMessagePassthroughWrapper(text);
|
|
371
|
+
} catch (e) { logError("turn_end.passthrough", e); }
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// Commands and shortcuts
|
|
375
|
+
pi.registerCommand("friday", {
|
|
376
|
+
description: "Usage: /friday [voice|listen|settings]",
|
|
377
|
+
handler: async (args, ctx) => {
|
|
378
|
+
try {
|
|
379
|
+
const arg = (args ?? "").trim().toLowerCase();
|
|
380
|
+
|
|
381
|
+
if (arg === "voice") {
|
|
382
|
+
if (!hasVoiceDeps) {
|
|
383
|
+
ctx.ui.notify("Voice unavailable — piper and sox (play) required", "error");
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
voiceEnabled = !voiceEnabled;
|
|
387
|
+
settings.voice.enabled = voiceEnabled;
|
|
388
|
+
saveSettings(settings);
|
|
389
|
+
updateStatus(ctx.ui);
|
|
390
|
+
ctx.ui.notify(voiceEnabled ? "Voice on" : "Voice off", "info");
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (arg === "listen") {
|
|
395
|
+
if (!hasPython) {
|
|
396
|
+
ctx.ui.notify("Wake word listener unavailable — python3 required", "error");
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
if (wakeDaemon) {
|
|
400
|
+
stopWakeDaemonWrapper();
|
|
401
|
+
settings.wakeWord.enabled = false;
|
|
402
|
+
saveSettings(settings);
|
|
403
|
+
updateStatus(ctx.ui);
|
|
404
|
+
ctx.ui.notify("Wake word listener off", "info");
|
|
405
|
+
} else {
|
|
406
|
+
startWakeDaemonWrapper();
|
|
407
|
+
settings.wakeWord.enabled = true;
|
|
408
|
+
saveSettings(settings);
|
|
409
|
+
updateStatus(ctx.ui);
|
|
410
|
+
ctx.ui.notify(`Listening for "${settings.wakeWord.model}"`, "info");
|
|
411
|
+
}
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (arg === "settings") {
|
|
416
|
+
settings = loadSettings();
|
|
417
|
+
const wakeStatus = wakeDaemon ? "on" : "off";
|
|
418
|
+
const info = [
|
|
419
|
+
`Name: ${settings.name}`,
|
|
420
|
+
`Voice: ${voiceEnabled ? "on" : "off"} (model: ${settings.voice.model})`,
|
|
421
|
+
`Wake word: ${wakeStatus} (model: ${settings.wakeWord.model})`,
|
|
422
|
+
`Settings file: ${commsDir}/settings.json`,
|
|
423
|
+
].join("\n");
|
|
424
|
+
ctx.ui.notify(info, "info");
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
enabled = !enabled;
|
|
429
|
+
if (!enabled) {
|
|
430
|
+
if (paneId && (await isPaneAlive(pi, paneId))) await killPane(pi, paneId);
|
|
431
|
+
voiceEnabled = false;
|
|
432
|
+
stopWakeDaemonWrapper();
|
|
433
|
+
updateStatus(ctx.ui);
|
|
434
|
+
ctx.ui.notify(`${settings.name} offline`, "info");
|
|
435
|
+
} else {
|
|
436
|
+
updateStatus(ctx.ui);
|
|
437
|
+
ctx.ui.notify(`${settings.name} online`, "info");
|
|
438
|
+
}
|
|
439
|
+
} catch (e) { logError("command.friday", e); }
|
|
440
|
+
},
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
if (hasVoiceDeps) {
|
|
444
|
+
pi.registerShortcut("alt+m", {
|
|
445
|
+
description: "Toggle Friday voice",
|
|
446
|
+
handler: async (ctx) => {
|
|
447
|
+
try {
|
|
448
|
+
killCurrentVoice();
|
|
449
|
+
voiceEnabled = !voiceEnabled;
|
|
450
|
+
settings.voice.enabled = voiceEnabled;
|
|
451
|
+
saveSettings(settings);
|
|
452
|
+
updateStatus(ctx.ui);
|
|
453
|
+
ctx.ui.notify(voiceEnabled ? "Voice on" : "Voice off", "info");
|
|
454
|
+
} catch (e) { logError("shortcut.alt+m", e); }
|
|
455
|
+
},
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (hasPython) {
|
|
460
|
+
pi.registerShortcut("alt+l", {
|
|
461
|
+
description: "Toggle Friday wake word listener",
|
|
462
|
+
handler: async (ctx) => {
|
|
463
|
+
try {
|
|
464
|
+
if (wakeDaemon) {
|
|
465
|
+
stopWakeDaemonWrapper();
|
|
466
|
+
settings.wakeWord.enabled = false;
|
|
467
|
+
saveSettings(settings);
|
|
468
|
+
updateStatus(ctx.ui);
|
|
469
|
+
ctx.ui.notify("Wake word listener off", "info");
|
|
470
|
+
} else {
|
|
471
|
+
startWakeDaemonWrapper();
|
|
472
|
+
settings.wakeWord.enabled = true;
|
|
473
|
+
saveSettings(settings);
|
|
474
|
+
updateStatus(ctx.ui);
|
|
475
|
+
ctx.ui.notify(`Listening for "${settings.wakeWord.model}"`, "info");
|
|
476
|
+
}
|
|
477
|
+
} catch (e) { logError("shortcut.alt+l", e); }
|
|
478
|
+
},
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Cleanup
|
|
483
|
+
pi.on("session_shutdown", async () => {
|
|
484
|
+
try { killCurrentVoice(); } catch (e) { logError("shutdown.killVoice", e); }
|
|
485
|
+
try { stopWakeDaemonWrapper(); } catch (e) { logError("shutdown.stopDaemon", e); }
|
|
486
|
+
try {
|
|
487
|
+
if (paneId) {
|
|
488
|
+
const p = spawn("tmux", ["kill-pane", "-t", paneId], { stdio: "ignore" });
|
|
489
|
+
p.unref();
|
|
490
|
+
paneId = null;
|
|
491
|
+
}
|
|
492
|
+
} catch (e) { logError("shutdown.killPane", e); }
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
// Set initial status
|
|
496
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
497
|
+
try {
|
|
498
|
+
settings = loadSettings();
|
|
499
|
+
voiceEnabled = hasVoiceDeps && settings.voice.enabled;
|
|
500
|
+
if (hasVoiceDeps) await killOrphanTTS();
|
|
501
|
+
if (hasPython && settings.wakeWord.enabled) {
|
|
502
|
+
await killOrphanDaemons(log);
|
|
503
|
+
await sleep(500);
|
|
504
|
+
startWakeDaemonWrapper();
|
|
505
|
+
}
|
|
506
|
+
updateStatus(ctx.ui);
|
|
507
|
+
} catch (e) { logError("session_start", e); }
|
|
508
|
+
});
|
|
509
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-friday",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Voice-enabled communications side panel for pi — wake word detection, TTS, and typewriter text output via a dedicated tmux pane. Stores user data (wake word models, settings) in ~/.pi/agent/friday/.",
|
|
5
|
+
"keywords": ["pi-package"],
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/dantetekanem/friday.git"
|
|
10
|
+
},
|
|
11
|
+
"pi": {
|
|
12
|
+
"extensions": ["./index.ts"]
|
|
13
|
+
},
|
|
14
|
+
"peerDependencies": {
|
|
15
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
16
|
+
"@mariozechner/pi-tui": "*",
|
|
17
|
+
"@sinclair/typebox": "*"
|
|
18
|
+
}
|
|
19
|
+
}
|