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/README.md
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# Friday
|
|
2
|
+
|
|
3
|
+
A [pi](https://github.com/nichochar/pi) package that adds a voice-enabled communications side panel. All conversation routes to a dedicated tmux pane with typewriter effect, text-to-speech, and hands-free voice input via wake word detection.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pi install git:github.com/dantetekanem/friday
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or for project-local install:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pi install -l git:github.com/dantetekanem/friday
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
To try without installing:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pi -e git:github.com/dantetekanem/friday
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Requirements
|
|
24
|
+
|
|
25
|
+
**Required** (the extension will not load without these):
|
|
26
|
+
|
|
27
|
+
- **tmux** — pi already requires this
|
|
28
|
+
- **perl** — pre-installed on macOS
|
|
29
|
+
|
|
30
|
+
**Optional — Voice output** (TTS):
|
|
31
|
+
|
|
32
|
+
- **piper-tts** — `pip3 install piper-tts`
|
|
33
|
+
- **sox** — `brew install sox` (provides the `play` command)
|
|
34
|
+
- A Piper voice model in `~/.local/share/piper-voices/` (see [Voices](#voices) below)
|
|
35
|
+
|
|
36
|
+
Without piper-tts and sox, voice output is disabled. The panel still works for text.
|
|
37
|
+
|
|
38
|
+
**Optional — Voice input** (wake word + transcription):
|
|
39
|
+
|
|
40
|
+
- **openwakeword** — `pip3 install openwakeword`
|
|
41
|
+
- **faster-whisper** — `pip3 install faster-whisper`
|
|
42
|
+
- **pyaudio** — `pip3 install pyaudio` (requires `brew install portaudio`)
|
|
43
|
+
- **sounddevice** + **numpy** — `pip3 install sounddevice numpy`
|
|
44
|
+
|
|
45
|
+
Without these, the wake word listener (`/friday listen`, `Alt+L`) is unavailable.
|
|
46
|
+
|
|
47
|
+
## Shortcuts
|
|
48
|
+
|
|
49
|
+
| Shortcut | Action |
|
|
50
|
+
|----------|--------|
|
|
51
|
+
| `Alt+M` | Toggle voice on/off |
|
|
52
|
+
| `Alt+L` | Toggle wake word listener on/off |
|
|
53
|
+
|
|
54
|
+
## Commands
|
|
55
|
+
|
|
56
|
+
| Command | Action |
|
|
57
|
+
|---------|--------|
|
|
58
|
+
| `/friday` | Toggle the extension on/off |
|
|
59
|
+
| `/friday voice` | Toggle voice output |
|
|
60
|
+
| `/friday listen` | Toggle wake word listener |
|
|
61
|
+
| `/friday settings` | Show current configuration |
|
|
62
|
+
|
|
63
|
+
The status bar shows active modes: `FRIDAY`, `VOICE`, `DAEMON ON`.
|
|
64
|
+
|
|
65
|
+
## Wake Word
|
|
66
|
+
|
|
67
|
+
Say the configured wake word (default: "hey friday") to activate hands-free voice input. After detection, Friday records your speech, transcribes it locally with faster-whisper, and sends it as a message to pi.
|
|
68
|
+
|
|
69
|
+
Friday looks for custom wake word models in `~/.pi/agent/friday/`. To set up the default "hey friday" wake word:
|
|
70
|
+
|
|
71
|
+
1. Visit [openwakeword.com/library](https://openwakeword.com/library) (free, requires sign-in)
|
|
72
|
+
2. Search for **"hey friday"**
|
|
73
|
+
3. Download the `.onnx` file to `~/.pi/agent/friday/`
|
|
74
|
+
4. Set `wakeWord.model` in `settings.json` to the filename without `.onnx`
|
|
75
|
+
|
|
76
|
+
You can use any custom wake word — just download its `.onnx` model to the same directory.
|
|
77
|
+
|
|
78
|
+
Built-in models (no download needed): `alexa`, `hey_mycroft`, `hey_jarvis`, `hey_rhasspy`, `timer`, `weather`.
|
|
79
|
+
|
|
80
|
+
## Voices
|
|
81
|
+
|
|
82
|
+
Friday uses [Piper](https://github.com/rhasspy/piper) for text-to-speech. The default voice is `en_GB-jenny_dioco-medium` (British female).
|
|
83
|
+
|
|
84
|
+
**Install the default voice:**
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
mkdir -p ~/.local/share/piper-voices
|
|
88
|
+
cd ~/.local/share/piper-voices
|
|
89
|
+
curl -sL -o en_GB-jenny_dioco-medium.onnx \
|
|
90
|
+
"https://huggingface.co/rhasspy/piper-voices/resolve/v1.0.0/en/en_GB/jenny_dioco/medium/en_GB-jenny_dioco-medium.onnx"
|
|
91
|
+
curl -sL -o en_GB-jenny_dioco-medium.onnx.json \
|
|
92
|
+
"https://huggingface.co/rhasspy/piper-voices/resolve/v1.0.0/en/en_GB/jenny_dioco/medium/en_GB-jenny_dioco-medium.onnx.json"
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
**Use a different voice:**
|
|
96
|
+
|
|
97
|
+
Browse voices at [rhasspy/piper/voices](https://huggingface.co/rhasspy/piper-voices/tree/v1.0.0). Download the `.onnx` and `.onnx.json` files to `~/.local/share/piper-voices/`, then set `voice.model` in `settings.json` to the model name (without `.onnx`).
|
|
98
|
+
|
|
99
|
+
## Settings
|
|
100
|
+
|
|
101
|
+
Edit `settings.json` in this directory:
|
|
102
|
+
|
|
103
|
+
```json
|
|
104
|
+
{
|
|
105
|
+
"name": "Friday",
|
|
106
|
+
"voice": {
|
|
107
|
+
"enabled": false,
|
|
108
|
+
"model": "en_GB-jenny_dioco-medium",
|
|
109
|
+
"speed": 0.9
|
|
110
|
+
},
|
|
111
|
+
"wakeWord": {
|
|
112
|
+
"enabled": false,
|
|
113
|
+
"model": "hey_friday",
|
|
114
|
+
"threshold": 0.3,
|
|
115
|
+
"whisperModel": "tiny.en"
|
|
116
|
+
},
|
|
117
|
+
"typewriter": {
|
|
118
|
+
"enabled": true
|
|
119
|
+
},
|
|
120
|
+
"panelWidth": 30
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
| Setting | Description |
|
|
125
|
+
|---------|-------------|
|
|
126
|
+
| `name` | Display name in status bar |
|
|
127
|
+
| `voice.model` | Piper voice model name (filename in `~/.local/share/piper-voices/` without `.onnx`) |
|
|
128
|
+
| `voice.speed` | Speech speed multiplier (0.9 = slightly slower) |
|
|
129
|
+
| `wakeWord.model` | openwakeword model name or custom `.onnx` filename without extension |
|
|
130
|
+
| `wakeWord.threshold` | Detection confidence (0.0-1.0, lower = more sensitive) |
|
|
131
|
+
| `wakeWord.whisperModel` | faster-whisper model size: `tiny.en`, `base.en`, `small.en`, `medium.en` |
|
|
132
|
+
| `typewriter.enabled` | Typewriter text effect in panel |
|
|
133
|
+
| `panelWidth` | Panel width as percentage of terminal |
|
|
134
|
+
|
|
135
|
+
## License
|
|
136
|
+
|
|
137
|
+
MIT
|
package/acks.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Friday Extension - Acknowledgment System Module
|
|
3
|
+
* Acknowledgment phrases, classification, scheduling, and delivery
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ChildProcess } from "node:child_process";
|
|
7
|
+
import type { FridaySettings } from "./settings.js";
|
|
8
|
+
|
|
9
|
+
export const PANEL_PHRASES = [
|
|
10
|
+
`Full details in the panel.`,
|
|
11
|
+
`More in the panel if you need it.`,
|
|
12
|
+
`Rest is on screen.`,
|
|
13
|
+
`Details on your screen.`,
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
export type AckCategory = "investigate" | "build" | "research" | "fix" | "general" | "question";
|
|
17
|
+
|
|
18
|
+
export const ACK_PHRASES: Record<AckCategory, string[]> = {
|
|
19
|
+
investigate: [
|
|
20
|
+
"Looking into it.", "Let me check.", "Starting the investigation.",
|
|
21
|
+
"Pulling up the details now.", "Let me trace through that.",
|
|
22
|
+
"On it. Give me a moment.", "Checking that now.",
|
|
23
|
+
],
|
|
24
|
+
build: [
|
|
25
|
+
"On it.", "Starting now.", "I'll get that set up.",
|
|
26
|
+
"Building it out.", "Consider it started.",
|
|
27
|
+
"Spinning that up now.", "Alright, putting it together.",
|
|
28
|
+
],
|
|
29
|
+
research: [
|
|
30
|
+
"Let me look that up.", "Searching now.", "I'll find out.",
|
|
31
|
+
"Running a search.", "Let me dig into that.", "Pulling up what I can find.",
|
|
32
|
+
],
|
|
33
|
+
fix: [
|
|
34
|
+
"I see the issue. Working on it.", "Let me patch that up.",
|
|
35
|
+
"On it. Should have a fix shortly.", "Addressing that now.",
|
|
36
|
+
"I'll sort that out.", "Fixing it.",
|
|
37
|
+
],
|
|
38
|
+
general: [
|
|
39
|
+
"Copy that.", "Understood.", "Right away.", "Working on it.",
|
|
40
|
+
"One moment.", "Got it.", "Acknowledged.", "Processing.",
|
|
41
|
+
],
|
|
42
|
+
question: [
|
|
43
|
+
"One sec.", "Let me check.", "I'll look into that.",
|
|
44
|
+
"Let me see.", "Hmm, let me think.", "Good question. Let me check.",
|
|
45
|
+
"Let me find out.", "Checking.",
|
|
46
|
+
],
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const ACK_PATTERNS: { pattern: RegExp; category: AckCategory }[] = [
|
|
50
|
+
{ pattern: /\b(investigat|diagnos|debug|check|look into|what.s wrong|why is|trace)\b/i, category: "investigate" },
|
|
51
|
+
{ pattern: /\b(fix|repair|patch|resolve|broken|bug|error|fail|crash)\b/i, category: "fix" },
|
|
52
|
+
{ pattern: /\b(search|find|research|look up|compare|what are|which|best|recommend)\b/i, category: "research" },
|
|
53
|
+
{ pattern: /\b(build|create|add|implement|make|set up|write|generate|scaffold|deploy)\b/i, category: "build" },
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
export function hasUnquotedQuestionMark(text: string): boolean {
|
|
57
|
+
// Strip quoted/backticked content, then check for ?
|
|
58
|
+
const stripped = text
|
|
59
|
+
.replace(/`[^`]*`/g, "")
|
|
60
|
+
.replace(/"[^"]*"/g, "")
|
|
61
|
+
.replace(/'[^']*'/g, "");
|
|
62
|
+
return stripped.includes("?");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function classifyPrompt(text: string): AckCategory {
|
|
66
|
+
if (hasUnquotedQuestionMark(text)) return "question";
|
|
67
|
+
for (const { pattern, category } of ACK_PATTERNS) {
|
|
68
|
+
if (pattern.test(text)) return category;
|
|
69
|
+
}
|
|
70
|
+
return "general";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function pickAck(
|
|
74
|
+
category: AckCategory,
|
|
75
|
+
lastAckCategory: { value: AckCategory | null },
|
|
76
|
+
lastAckIndex: { value: number },
|
|
77
|
+
): string {
|
|
78
|
+
const phrases = ACK_PHRASES[category];
|
|
79
|
+
let idx: number;
|
|
80
|
+
do {
|
|
81
|
+
idx = Math.floor(Math.random() * phrases.length);
|
|
82
|
+
} while (idx === lastAckIndex.value && category === lastAckCategory.value && phrases.length > 1);
|
|
83
|
+
lastAckCategory.value = category;
|
|
84
|
+
lastAckIndex.value = idx;
|
|
85
|
+
return phrases[idx]!;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function pickPanelPhrase(): string {
|
|
89
|
+
return PANEL_PHRASES[Math.floor(Math.random() * PANEL_PHRASES.length)]!;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function cancelAck(ackTimer: { value: ReturnType<typeof setTimeout> | null }) {
|
|
93
|
+
try {
|
|
94
|
+
if (ackTimer.value) {
|
|
95
|
+
clearTimeout(ackTimer.value);
|
|
96
|
+
ackTimer.value = null;
|
|
97
|
+
}
|
|
98
|
+
} catch {}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function scheduleAck(
|
|
102
|
+
prompt: string,
|
|
103
|
+
ackTimer: { value: ReturnType<typeof setTimeout> | null },
|
|
104
|
+
lastMessageWasQuestion: { value: boolean },
|
|
105
|
+
lastAgentEndTime: number,
|
|
106
|
+
interactionCount: { value: number },
|
|
107
|
+
lastAckCategory: { value: AckCategory | null },
|
|
108
|
+
lastAckIndex: { value: number },
|
|
109
|
+
showAndSpeak: (text: string) => void,
|
|
110
|
+
logError: (context: string, err: unknown) => void,
|
|
111
|
+
) {
|
|
112
|
+
try {
|
|
113
|
+
cancelAck(ackTimer);
|
|
114
|
+
const ackCancelled = { value: false };
|
|
115
|
+
|
|
116
|
+
if (lastMessageWasQuestion.value) {
|
|
117
|
+
lastMessageWasQuestion.value = false;
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const now = Date.now();
|
|
122
|
+
const MOMENTUM_WINDOW_MS = 30000;
|
|
123
|
+
const inMomentum = (now - lastAgentEndTime) < MOMENTUM_WINDOW_MS;
|
|
124
|
+
|
|
125
|
+
if (inMomentum) {
|
|
126
|
+
interactionCount.value++;
|
|
127
|
+
} else {
|
|
128
|
+
interactionCount.value = 0;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (interactionCount.value >= 3) return;
|
|
132
|
+
if (interactionCount.value > 0 && Math.random() > 1 / (interactionCount.value + 1)) return;
|
|
133
|
+
|
|
134
|
+
const category = classifyPrompt(prompt);
|
|
135
|
+
const ack = pickAck(category, lastAckCategory, lastAckIndex);
|
|
136
|
+
|
|
137
|
+
const ACK_DELAY_MS = 2000;
|
|
138
|
+
// CRITICAL FIX: Add .unref() to ack timer
|
|
139
|
+
ackTimer.value = setTimeout(() => {
|
|
140
|
+
try {
|
|
141
|
+
if (ackCancelled.value) return;
|
|
142
|
+
showAndSpeak(ack);
|
|
143
|
+
} catch (e) { logError("ackTimer.callback", e); }
|
|
144
|
+
}, ACK_DELAY_MS).unref();
|
|
145
|
+
} catch (e) { logError("scheduleAck", e); }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function showAndSpeak(
|
|
149
|
+
text: string,
|
|
150
|
+
voiceEnabled: boolean,
|
|
151
|
+
ensurePanelOpen: () => Promise<boolean>,
|
|
152
|
+
writeMessage: (text: string) => void,
|
|
153
|
+
enqueueVoiceWithMessage: (text: string, speed?: number) => void,
|
|
154
|
+
settings: FridaySettings,
|
|
155
|
+
logError: (context: string, err: unknown) => void,
|
|
156
|
+
) {
|
|
157
|
+
try {
|
|
158
|
+
if (voiceEnabled) {
|
|
159
|
+
enqueueVoiceWithMessage(text, settings.voice.speed);
|
|
160
|
+
} else {
|
|
161
|
+
ensurePanelOpen().then((ok) => {
|
|
162
|
+
try { if (ok) writeMessage(text); } catch (e) { logError("showAndSpeak.panel", e); }
|
|
163
|
+
}).catch((e) => logError("showAndSpeak.ensurePanel", e));
|
|
164
|
+
}
|
|
165
|
+
} catch (e) { logError("showAndSpeak", e); }
|
|
166
|
+
}
|
package/daemon.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Friday Extension - Wake Word Daemon Module
|
|
3
|
+
* Wake word daemon management, file watching, and voice command handling
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import { exec as execCb } from "node:child_process";
|
|
9
|
+
const execAsync = promisify(execCb);
|
|
10
|
+
import { writeFileSync, readFileSync, existsSync, mkdirSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
13
|
+
import type { FridaySettings } from "./settings.js";
|
|
14
|
+
import type { FSWatcher } from "node:fs";
|
|
15
|
+
|
|
16
|
+
export async function killOrphanDaemons(log: (msg: string) => void) {
|
|
17
|
+
try {
|
|
18
|
+
const { stdout } = await execAsync(
|
|
19
|
+
"ps aux | grep wake_daemon.py | grep -v grep",
|
|
20
|
+
{ encoding: "utf8", timeout: 5000 },
|
|
21
|
+
);
|
|
22
|
+
const result = stdout.trim();
|
|
23
|
+
if (!result) return;
|
|
24
|
+
for (const line of result.split("\n")) {
|
|
25
|
+
const parts = line.trim().split(/\s+/);
|
|
26
|
+
const pid = parseInt(parts[1]!, 10);
|
|
27
|
+
if (!pid || isNaN(pid)) continue;
|
|
28
|
+
try {
|
|
29
|
+
process.kill(pid, "SIGTERM");
|
|
30
|
+
log(`Killed orphan wake daemon (PID ${pid})`);
|
|
31
|
+
} catch {}
|
|
32
|
+
}
|
|
33
|
+
} catch { /* no orphans found (grep exits 1) */ }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function startWakeDaemon(
|
|
37
|
+
settings: FridaySettings,
|
|
38
|
+
commsDir: string,
|
|
39
|
+
commandFile: string,
|
|
40
|
+
log: (msg: string) => void,
|
|
41
|
+
logError: (context: string, err: unknown) => void,
|
|
42
|
+
): ChildProcess | null {
|
|
43
|
+
try {
|
|
44
|
+
mkdirSync(commsDir, { recursive: true });
|
|
45
|
+
|
|
46
|
+
const DAEMON_SCRIPT = join(
|
|
47
|
+
import.meta.dirname,
|
|
48
|
+
"wake_daemon.py",
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const dataDir = join(process.env.HOME ?? "~", ".pi/agent/friday");
|
|
52
|
+
|
|
53
|
+
const args = [
|
|
54
|
+
DAEMON_SCRIPT,
|
|
55
|
+
commandFile,
|
|
56
|
+
"--wake-word", settings.wakeWord.model,
|
|
57
|
+
"--threshold", String(settings.wakeWord.threshold),
|
|
58
|
+
"--whisper-model", settings.wakeWord.whisperModel,
|
|
59
|
+
"--data-dir", dataDir,
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
const wakeDaemon = spawn("python3", args, {
|
|
63
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
64
|
+
detached: false,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Unref so the daemon doesn't keep the event loop alive on shutdown
|
|
68
|
+
wakeDaemon.unref();
|
|
69
|
+
|
|
70
|
+
wakeDaemon.stderr?.on("data", (data: Buffer) => {
|
|
71
|
+
try { const line = data.toString().trim(); if (line) log(line); } catch {}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
wakeDaemon.on("exit", (code) => {
|
|
75
|
+
try {
|
|
76
|
+
log(`Wake daemon exited (code: ${code})`);
|
|
77
|
+
} catch (e) { logError("wakeDaemon.exit", e); }
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
log("Wake daemon started");
|
|
81
|
+
return wakeDaemon;
|
|
82
|
+
} catch (e) {
|
|
83
|
+
logError("startWakeDaemon", e);
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function stopWakeDaemon(wakeDaemon: ChildProcess | null, logError: (context: string, err: unknown) => void) {
|
|
89
|
+
try {
|
|
90
|
+
if (wakeDaemon) {
|
|
91
|
+
// Destroy stdio streams so they don't hold the event loop
|
|
92
|
+
try { wakeDaemon.stdout?.destroy(); } catch {}
|
|
93
|
+
try { wakeDaemon.stderr?.destroy(); } catch {}
|
|
94
|
+
// SIGKILL for instant death on shutdown
|
|
95
|
+
wakeDaemon.kill("SIGKILL");
|
|
96
|
+
}
|
|
97
|
+
} catch (e) { logError("stopWakeDaemon", e); }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function startWakeWatcher(
|
|
101
|
+
commandFile: string,
|
|
102
|
+
lastCommandTimestamp: { value: number },
|
|
103
|
+
killCurrentVoice: () => void,
|
|
104
|
+
handleWakeCommand: (text: string) => void,
|
|
105
|
+
logError: (context: string, err: unknown) => void,
|
|
106
|
+
): any {
|
|
107
|
+
try {
|
|
108
|
+
// CRITICAL FIX: Add .unref() to background interval timer
|
|
109
|
+
const interval = setInterval(() => {
|
|
110
|
+
try {
|
|
111
|
+
if (!existsSync(commandFile)) return;
|
|
112
|
+
const raw = readFileSync(commandFile, "utf8").trim();
|
|
113
|
+
if (!raw) return;
|
|
114
|
+
const cmd = JSON.parse(raw);
|
|
115
|
+
if (!cmd.timestamp || cmd.timestamp <= lastCommandTimestamp.value) return;
|
|
116
|
+
lastCommandTimestamp.value = cmd.timestamp;
|
|
117
|
+
|
|
118
|
+
if (cmd.type === "wake") {
|
|
119
|
+
killCurrentVoice();
|
|
120
|
+
} else if (cmd.type === "command" && cmd.text) {
|
|
121
|
+
handleWakeCommand(cmd.text);
|
|
122
|
+
}
|
|
123
|
+
} catch { /* ignore parse errors on partial writes */ }
|
|
124
|
+
}, 100).unref();
|
|
125
|
+
|
|
126
|
+
return { close: () => clearInterval(interval) } as any;
|
|
127
|
+
} catch (e) {
|
|
128
|
+
logError("startWakeWatcher", e);
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function stopWakeWatcher(wakeWatcher: any, logError: (context: string, err: unknown) => void) {
|
|
134
|
+
try {
|
|
135
|
+
if (wakeWatcher) {
|
|
136
|
+
wakeWatcher.close?.();
|
|
137
|
+
}
|
|
138
|
+
} catch (e) { logError("stopWakeWatcher", e); }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function handleWakeCommand(
|
|
142
|
+
text: string,
|
|
143
|
+
pi: ExtensionAPI,
|
|
144
|
+
log: (msg: string) => void,
|
|
145
|
+
logError: (context: string, err: unknown) => void,
|
|
146
|
+
) {
|
|
147
|
+
try {
|
|
148
|
+
log(`Voice command: ${text}`);
|
|
149
|
+
pi.sendUserMessage(text, { deliverAs: "followUp" });
|
|
150
|
+
} catch (e) {
|
|
151
|
+
// This is the most critical catch — sendUserMessage throws when
|
|
152
|
+
// the agent is already processing and deliverAs isn't accepted.
|
|
153
|
+
// We MUST swallow this or it kills the host agent.
|
|
154
|
+
logError("handleWakeCommand", e);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function isDaemonAlive(wakeDaemon: ChildProcess | null): boolean {
|
|
159
|
+
if (!wakeDaemon || !wakeDaemon.pid) return false;
|
|
160
|
+
try { process.kill(wakeDaemon.pid, 0); return true; } catch { return false; }
|
|
161
|
+
}
|