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/panel.ts
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Friday Extension - Panel Management Module
|
|
3
|
+
* Tmux panel operations, message writing, and display script
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
7
|
+
import { writeFileSync, mkdirSync, appendFileSync, rmSync, existsSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import type { FridaySettings } from "./settings.js";
|
|
10
|
+
|
|
11
|
+
export async function openPanel(
|
|
12
|
+
pi: ExtensionAPI,
|
|
13
|
+
settings: FridaySettings,
|
|
14
|
+
commsDir: string,
|
|
15
|
+
messagesFile: string,
|
|
16
|
+
ownerPaneId: string | null,
|
|
17
|
+
logError: (context: string, err: unknown) => void,
|
|
18
|
+
): Promise<{ success: boolean; paneId: string | null; paneWidth: number }> {
|
|
19
|
+
try {
|
|
20
|
+
if (!process.env.TMUX) return { success: false, paneId: null, paneWidth: 38 };
|
|
21
|
+
|
|
22
|
+
mkdirSync(commsDir, { recursive: true });
|
|
23
|
+
writeFileSync(messagesFile, "");
|
|
24
|
+
|
|
25
|
+
const displayScript = join(commsDir, "display.pl");
|
|
26
|
+
writeFileSync(displayScript, buildDisplayScript(), { mode: 0o755 });
|
|
27
|
+
|
|
28
|
+
// Use ownerPaneId so we always query/split in the correct tmux window,
|
|
29
|
+
// even when the user's focus is on a different window/tab.
|
|
30
|
+
const targetArgs = ownerPaneId ? ["-t", ownerPaneId] : [];
|
|
31
|
+
|
|
32
|
+
const layoutInfo = await pi.exec("tmux", [
|
|
33
|
+
"display-message", ...targetArgs, "-p", "#{pane_at_right}",
|
|
34
|
+
]);
|
|
35
|
+
const atRightEdge = layoutInfo.stdout.trim() === "1";
|
|
36
|
+
|
|
37
|
+
let splitArgs: string[];
|
|
38
|
+
|
|
39
|
+
if (!atRightEdge) {
|
|
40
|
+
const panesResult = await pi.exec("tmux", [
|
|
41
|
+
"list-panes", ...targetArgs, "-F", "#{pane_id} #{pane_left}",
|
|
42
|
+
]);
|
|
43
|
+
const panes = panesResult.stdout
|
|
44
|
+
.trim()
|
|
45
|
+
.split("\n")
|
|
46
|
+
.map((line) => {
|
|
47
|
+
const [id, left] = line.split(" ");
|
|
48
|
+
return { id: id!, left: parseInt(left!, 10) };
|
|
49
|
+
});
|
|
50
|
+
const rightmost = panes.reduce((a, b) =>
|
|
51
|
+
b.left > a.left ? b : a,
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
splitArgs = [
|
|
55
|
+
"split-window", "-v", "-d", "-t", rightmost.id,
|
|
56
|
+
"-p", String(settings.panelWidth), "-P", "-F", "#{pane_id}",
|
|
57
|
+
"perl", displayScript, messagesFile,
|
|
58
|
+
];
|
|
59
|
+
} else {
|
|
60
|
+
splitArgs = [
|
|
61
|
+
"split-window", "-h", "-d", ...targetArgs,
|
|
62
|
+
"-p", String(settings.panelWidth),
|
|
63
|
+
"-P", "-F", "#{pane_id}",
|
|
64
|
+
"perl", displayScript, messagesFile,
|
|
65
|
+
];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const result = await pi.exec("tmux", splitArgs);
|
|
69
|
+
const paneId = result.stdout.trim();
|
|
70
|
+
|
|
71
|
+
if (!paneId || result.code !== 0) {
|
|
72
|
+
cleanupFiles(commsDir);
|
|
73
|
+
return { success: false, paneId: null, paneWidth: 38 };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
await pi.exec("tmux", [
|
|
78
|
+
"set-option", "-p", "-t", paneId, "allow-passthrough", "on",
|
|
79
|
+
]);
|
|
80
|
+
} catch { /* non-critical */ }
|
|
81
|
+
|
|
82
|
+
let paneWidth: number;
|
|
83
|
+
try {
|
|
84
|
+
const w = await pi.exec("tmux", [
|
|
85
|
+
"display-message", "-t", paneId, "-p", "#{pane_width}",
|
|
86
|
+
]);
|
|
87
|
+
paneWidth = (parseInt(w.stdout.trim()) || 44) - 6;
|
|
88
|
+
} catch {
|
|
89
|
+
paneWidth = 38;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { success: true, paneId, paneWidth };
|
|
93
|
+
} catch (e) {
|
|
94
|
+
logError("openPanel", e);
|
|
95
|
+
return { success: false, paneId: null, paneWidth: 38 };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function killPane(pi: ExtensionAPI, paneId: string | null) {
|
|
100
|
+
if (paneId) {
|
|
101
|
+
try { await pi.exec("tmux", ["kill-pane", "-t", paneId]); } catch {}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function isPaneAlive(pi: ExtensionAPI, paneId: string | null): Promise<boolean> {
|
|
106
|
+
if (!paneId) return false;
|
|
107
|
+
try {
|
|
108
|
+
const result = await pi.exec("tmux", [
|
|
109
|
+
"display-message", "-t", paneId, "-p", "#{pane_id}",
|
|
110
|
+
]);
|
|
111
|
+
return result.code === 0 && result.stdout.trim() === paneId;
|
|
112
|
+
} catch { return false; }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function ensurePanelOpen(
|
|
116
|
+
pi: ExtensionAPI,
|
|
117
|
+
settings: FridaySettings,
|
|
118
|
+
commsDir: string,
|
|
119
|
+
messagesFile: string,
|
|
120
|
+
ownerPaneId: string | null,
|
|
121
|
+
paneId: string | null,
|
|
122
|
+
sleep: (ms: number) => Promise<void>,
|
|
123
|
+
logError: (context: string, err: unknown) => void,
|
|
124
|
+
): Promise<{ success: boolean; paneId: string | null; paneWidth: number }> {
|
|
125
|
+
try {
|
|
126
|
+
if (paneId && (await isPaneAlive(pi, paneId))) {
|
|
127
|
+
// Get current pane width
|
|
128
|
+
let paneWidth: number;
|
|
129
|
+
try {
|
|
130
|
+
const w = await pi.exec("tmux", [
|
|
131
|
+
"display-message", "-t", paneId, "-p", "#{pane_width}",
|
|
132
|
+
]);
|
|
133
|
+
paneWidth = (parseInt(w.stdout.trim()) || 44) - 6;
|
|
134
|
+
} catch {
|
|
135
|
+
paneWidth = 38;
|
|
136
|
+
}
|
|
137
|
+
return { success: true, paneId, paneWidth };
|
|
138
|
+
}
|
|
139
|
+
const result = await openPanel(pi, settings, commsDir, messagesFile, ownerPaneId, logError);
|
|
140
|
+
if (result.success) await sleep(500);
|
|
141
|
+
return result;
|
|
142
|
+
} catch (e) {
|
|
143
|
+
logError("ensurePanelOpen", e);
|
|
144
|
+
return { success: false, paneId: null, paneWidth: 38 };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function cleanupFiles(commsDir: string) {
|
|
149
|
+
try { if (existsSync(commsDir)) rmSync(commsDir, { recursive: true }); } catch {}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function writeMessage(
|
|
153
|
+
text: string,
|
|
154
|
+
messagesFile: string,
|
|
155
|
+
paneWidth: number,
|
|
156
|
+
settings: FridaySettings,
|
|
157
|
+
lastMessageTime: { value: number },
|
|
158
|
+
logError: (context: string, err: unknown) => void,
|
|
159
|
+
) {
|
|
160
|
+
try {
|
|
161
|
+
const now = new Date();
|
|
162
|
+
const nowMs = now.getTime();
|
|
163
|
+
const time = now.toLocaleTimeString("en-US", {
|
|
164
|
+
hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit",
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const FOLLOW_UP_WINDOW_MS = 2000;
|
|
168
|
+
const isFollowUp = nowMs - lastMessageTime.value < FOLLOW_UP_WINDOW_MS;
|
|
169
|
+
lastMessageTime.value = nowMs;
|
|
170
|
+
|
|
171
|
+
const dim = "\x1b[2m";
|
|
172
|
+
const cyan = "\x1b[36m";
|
|
173
|
+
const reset = "\x1b[0m";
|
|
174
|
+
const white = "\x1b[97m";
|
|
175
|
+
const TW_START = settings.typewriter.enabled ? "\x01" : "";
|
|
176
|
+
const TW_STOP = settings.typewriter.enabled ? "\x02" : "";
|
|
177
|
+
|
|
178
|
+
let out = "";
|
|
179
|
+
if (!isFollowUp) out += "\x1b[2J\x1b[H";
|
|
180
|
+
|
|
181
|
+
out += `\n${dim}${cyan} ${time}${reset}\n\n`;
|
|
182
|
+
|
|
183
|
+
const wrapped = wordWrap(text, paneWidth);
|
|
184
|
+
out += TW_START;
|
|
185
|
+
for (const line of wrapped) out += `${white} ${line}${reset}\n`;
|
|
186
|
+
out += TW_STOP;
|
|
187
|
+
out += "\n";
|
|
188
|
+
|
|
189
|
+
appendFileSync(messagesFile, out);
|
|
190
|
+
} catch (e) { logError("writeMessage", e); }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Write a passthrough message (agent text not sent via communicate).
|
|
194
|
+
* Always appends — never clears the panel. No voice. Dimmer styling. */
|
|
195
|
+
export function writeMessagePassthrough(
|
|
196
|
+
text: string,
|
|
197
|
+
messagesFile: string,
|
|
198
|
+
paneWidth: number,
|
|
199
|
+
logError: (context: string, err: unknown) => void,
|
|
200
|
+
) {
|
|
201
|
+
try {
|
|
202
|
+
const reset = "\x1b[0m";
|
|
203
|
+
const lightGray = "\x1b[38;5;249m"; // 256-color light gray, readable but distinct
|
|
204
|
+
|
|
205
|
+
const wrapped = wordWrap(text, paneWidth);
|
|
206
|
+
let out = "\n";
|
|
207
|
+
for (const line of wrapped) out += `${lightGray} ${line}${reset}\n`;
|
|
208
|
+
out += "\n";
|
|
209
|
+
|
|
210
|
+
appendFileSync(messagesFile, out);
|
|
211
|
+
} catch (e) { logError("writeMessagePassthrough", e); }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function wordWrap(text: string, width: number): string[] {
|
|
215
|
+
const lines: string[] = [];
|
|
216
|
+
const paragraphs = text.split("\n");
|
|
217
|
+
|
|
218
|
+
for (const para of paragraphs) {
|
|
219
|
+
if (para.trim() === "") { lines.push(""); continue; }
|
|
220
|
+
|
|
221
|
+
const words = para.split(/\s+/);
|
|
222
|
+
let currentLine = "";
|
|
223
|
+
|
|
224
|
+
for (const word of words) {
|
|
225
|
+
if (currentLine.length + word.length + 1 > width && currentLine.length > 0) {
|
|
226
|
+
lines.push(currentLine);
|
|
227
|
+
currentLine = word;
|
|
228
|
+
} else {
|
|
229
|
+
currentLine = currentLine ? `${currentLine} ${word}` : word;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (currentLine) lines.push(currentLine);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return lines;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function buildDisplayScript(): string {
|
|
240
|
+
return `#!/usr/bin/perl
|
|
241
|
+
use strict;
|
|
242
|
+
use warnings;
|
|
243
|
+
|
|
244
|
+
$| = 1;
|
|
245
|
+
binmode(STDOUT, ':utf8');
|
|
246
|
+
|
|
247
|
+
my $file = $ARGV[0] or die "Usage: $0 <messages-file>\\n";
|
|
248
|
+
my $pos = 0;
|
|
249
|
+
my $typewriter = 0;
|
|
250
|
+
my $in_esc = 0;
|
|
251
|
+
my $esc_buf = '';
|
|
252
|
+
my $pending_clear = 0;
|
|
253
|
+
|
|
254
|
+
# iTerm2: bump font size ~1.2x (3 increments)
|
|
255
|
+
print "\\x1bPtmux;\\x1b\\x1b]1337;ChangeFontSize=3\\a\\x1b\\\\";
|
|
256
|
+
print "\\x1b]1337;ChangeFontSize=3\\a";
|
|
257
|
+
|
|
258
|
+
# Clear screen
|
|
259
|
+
print "\\x1b[2J\\x1b[H";
|
|
260
|
+
|
|
261
|
+
# Get terminal height for scroll animation
|
|
262
|
+
my $term_rows = \`tput lines 2>/dev/null\` || 24;
|
|
263
|
+
chomp $term_rows;
|
|
264
|
+
$term_rows = int($term_rows) || 24;
|
|
265
|
+
|
|
266
|
+
while (! -f $file) {
|
|
267
|
+
select(undef, undef, undef, 0.1);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
while (1) {
|
|
271
|
+
if (open my $fh, '<', $file) {
|
|
272
|
+
binmode($fh, ':utf8');
|
|
273
|
+
seek $fh, $pos, 0;
|
|
274
|
+
|
|
275
|
+
while (read $fh, my $char, 1) {
|
|
276
|
+
if ($char eq "\\x01") { $typewriter = 1; next; }
|
|
277
|
+
if ($char eq "\\x02") { $typewriter = 0; next; }
|
|
278
|
+
|
|
279
|
+
# Intercept ANSI escapes to detect clear-screen
|
|
280
|
+
if ($in_esc) {
|
|
281
|
+
$esc_buf .= $char;
|
|
282
|
+
if ($char =~ /[A-Za-z]/) {
|
|
283
|
+
$in_esc = 0;
|
|
284
|
+
if ($esc_buf eq '[2J') {
|
|
285
|
+
# Slide animation: scroll content up rapidly
|
|
286
|
+
print "\\x1b[999;1H\\n" for (1..int($term_rows * 0.6));
|
|
287
|
+
for my $i (1..6) {
|
|
288
|
+
print "\\n" for (1..3);
|
|
289
|
+
select(undef, undef, undef, 0.018);
|
|
290
|
+
}
|
|
291
|
+
$pending_clear = 1;
|
|
292
|
+
$esc_buf = '';
|
|
293
|
+
next;
|
|
294
|
+
}
|
|
295
|
+
if ($esc_buf eq '[H' && $pending_clear) {
|
|
296
|
+
# After slide, clear and home
|
|
297
|
+
print "\\x1b[2J\\x1b[H";
|
|
298
|
+
$pending_clear = 0;
|
|
299
|
+
$esc_buf = '';
|
|
300
|
+
next;
|
|
301
|
+
}
|
|
302
|
+
# Normal escape — emit it
|
|
303
|
+
print "\\x1b" . $esc_buf;
|
|
304
|
+
$esc_buf = '';
|
|
305
|
+
}
|
|
306
|
+
next;
|
|
307
|
+
}
|
|
308
|
+
if ($char eq "\\x1b") {
|
|
309
|
+
$in_esc = 1;
|
|
310
|
+
$esc_buf = '';
|
|
311
|
+
next;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
print $char;
|
|
315
|
+
|
|
316
|
+
next unless $typewriter;
|
|
317
|
+
|
|
318
|
+
if ($char =~ /[.!?]/) {
|
|
319
|
+
select(undef, undef, undef, 0.065);
|
|
320
|
+
} elsif ($char =~ /[,;:]/) {
|
|
321
|
+
select(undef, undef, undef, 0.030);
|
|
322
|
+
} elsif ($char eq "\\n") {
|
|
323
|
+
select(undef, undef, undef, 0.020);
|
|
324
|
+
} elsif ($char eq ' ') {
|
|
325
|
+
select(undef, undef, undef, 0.010);
|
|
326
|
+
} elsif (ord($char) > 31) {
|
|
327
|
+
select(undef, undef, undef, 0.006);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
$pos = tell $fh;
|
|
332
|
+
close $fh;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
select(undef, undef, undef, 0.15);
|
|
336
|
+
}
|
|
337
|
+
`;
|
|
338
|
+
}
|
package/prompt.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Friday Extension - System Prompt Module
|
|
3
|
+
* System prompt injection for communication panel instructions
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export function buildSystemPrompt(hasVoiceDeps = true): string {
|
|
7
|
+
return `
|
|
8
|
+
|
|
9
|
+
## Communications Panel
|
|
10
|
+
|
|
11
|
+
You have a dedicated side panel for direct communication with the user. This is a conversation channel.
|
|
12
|
+
|
|
13
|
+
EVERYTHING goes through \`communicate\`. All text. All conversation. All summaries, takeaways, analysis, explanations, opinions, greetings, questions, status updates. If you are producing words for the user to read, they go through communicate.
|
|
14
|
+
|
|
15
|
+
The ONLY exception -- things that stay in the main window:
|
|
16
|
+
- Code blocks (actual code)
|
|
17
|
+
- Tables (need visual columns)
|
|
18
|
+
- SQL queries
|
|
19
|
+
- Command output
|
|
20
|
+
- File contents and diffs
|
|
21
|
+
- Any content that MUST be visually rendered as structured data
|
|
22
|
+
|
|
23
|
+
If it does not need visual formatting to be understood, it goes through communicate. The main window should be nearly empty during normal conversation -- it only lights up when there is visual data to display.
|
|
24
|
+
|
|
25
|
+
The panel opens automatically. Do not mention the panel to the user.
|
|
26
|
+
|
|
27
|
+
Messages sent through communicate must be plain text only. No markdown formatting whatsoever -- no bold (**), no italic (*/_), no headers (#), no bullet lists (- or *), no code backticks, no links. No emojis. Write naturally as spoken prose.${hasVoiceDeps ? ` The text is read aloud by TTS, so it must sound right when spoken.` : ``}
|
|
28
|
+
|
|
29
|
+
When the conversation topic changes significantly from what's currently shown in the panel, set new_topic: true to clear it. Same topic or follow-up messages: leave it false so they accumulate.${hasVoiceDeps ? `
|
|
30
|
+
|
|
31
|
+
When voice is enabled, provide a voice_summary for any message longer than two sentences. The voice_summary is what gets spoken aloud -- it must be short, direct, and conversational. One to two sentences max. Think of it as what a colleague would say out loud, not what they would write. The full message always appears in the panel for reading, so the voice_summary only needs to convey the key takeaway. Only skip voice_summary for messages that are already one or two short sentences.` : ``}
|
|
32
|
+
|
|
33
|
+
When the user's message contains a question mark (not inside quotes, single quotes, or backticks), respond with a brief intermediate thinking-aloud acknowledgment like "One sec", "I'll check", "Let me look", etc. Do NOT respond with action confirmations like "Right away", "On it", "Will do" -- those are for directives, not questions. Questions get thinking-aloud acknowledgments, not task-acceptance acknowledgments.`;
|
|
34
|
+
}
|
package/settings.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Friday",
|
|
3
|
+
"voice": {
|
|
4
|
+
"enabled": true,
|
|
5
|
+
"model": "en_GB-jenny_dioco-medium",
|
|
6
|
+
"speed": 0.9
|
|
7
|
+
},
|
|
8
|
+
"wakeWord": {
|
|
9
|
+
"enabled": true,
|
|
10
|
+
"model": "hey_friday",
|
|
11
|
+
"threshold": 0.3,
|
|
12
|
+
"whisperModel": "tiny.en"
|
|
13
|
+
},
|
|
14
|
+
"typewriter": {
|
|
15
|
+
"enabled": true
|
|
16
|
+
},
|
|
17
|
+
"panelWidth": 30
|
|
18
|
+
}
|
package/settings.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Friday Extension - Settings Management
|
|
3
|
+
* Pure settings interface, defaults, and load/save functions
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
export interface FridaySettings {
|
|
10
|
+
name: string;
|
|
11
|
+
voice: {
|
|
12
|
+
enabled: boolean;
|
|
13
|
+
model: string;
|
|
14
|
+
speed: number;
|
|
15
|
+
};
|
|
16
|
+
wakeWord: {
|
|
17
|
+
enabled: boolean;
|
|
18
|
+
model: string;
|
|
19
|
+
threshold: number;
|
|
20
|
+
whisperModel: string;
|
|
21
|
+
};
|
|
22
|
+
typewriter: {
|
|
23
|
+
enabled: boolean;
|
|
24
|
+
};
|
|
25
|
+
panelWidth: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const DEFAULT_SETTINGS: FridaySettings = {
|
|
29
|
+
name: "Friday",
|
|
30
|
+
voice: {
|
|
31
|
+
enabled: false,
|
|
32
|
+
model: "en_GB-jenny_dioco-medium",
|
|
33
|
+
speed: 1.0,
|
|
34
|
+
},
|
|
35
|
+
wakeWord: {
|
|
36
|
+
enabled: false,
|
|
37
|
+
model: "hey_jarvis",
|
|
38
|
+
threshold: 0.5,
|
|
39
|
+
whisperModel: "tiny.en",
|
|
40
|
+
},
|
|
41
|
+
typewriter: {
|
|
42
|
+
enabled: true,
|
|
43
|
+
},
|
|
44
|
+
panelWidth: 30,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export function getSettingsPath(): string {
|
|
48
|
+
return join(
|
|
49
|
+
import.meta.dirname,
|
|
50
|
+
"settings.json",
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function loadSettings(): FridaySettings {
|
|
55
|
+
const path = getSettingsPath();
|
|
56
|
+
try {
|
|
57
|
+
if (existsSync(path)) {
|
|
58
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
59
|
+
return {
|
|
60
|
+
...DEFAULT_SETTINGS,
|
|
61
|
+
...raw,
|
|
62
|
+
voice: { ...DEFAULT_SETTINGS.voice, ...(raw.voice ?? {}) },
|
|
63
|
+
wakeWord: { ...DEFAULT_SETTINGS.wakeWord, ...(raw.wakeWord ?? {}) },
|
|
64
|
+
typewriter: { ...DEFAULT_SETTINGS.typewriter, ...(raw.typewriter ?? {}) },
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
} catch { /* use defaults */ }
|
|
68
|
+
return { ...DEFAULT_SETTINGS };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function saveSettings(s: FridaySettings): void {
|
|
72
|
+
try {
|
|
73
|
+
writeFileSync(getSettingsPath(), JSON.stringify(s, null, 2) + "\n");
|
|
74
|
+
} catch {}
|
|
75
|
+
}
|