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/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
+ }