fathom-mcp 0.6.4 → 2.0.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.js +154 -0
- package/package.json +10 -42
- package/CHANGELOG.md +0 -39
- package/LICENSE +0 -21
- package/README.md +0 -133
- package/fathom-agents.md +0 -45
- package/scripts/fathom-instructions.sh +0 -84
- package/scripts/fathom-precompact.sh +0 -80
- package/scripts/fathom-recall.sh +0 -194
- package/scripts/fathom-sessionstart.sh +0 -72
- package/scripts/fathom-start.sh +0 -366
- package/scripts/fathom-vsearch-background.sh +0 -46
- package/scripts/hook-toast.sh +0 -139
- package/scripts/kokoro-bridge.py +0 -196
- package/scripts/kokoro-speak.py +0 -107
- package/scripts/setup-kokoro.sh +0 -29
- package/scripts/vault-frontmatter-lint.js +0 -65
- package/src/cli.js +0 -873
- package/src/config.js +0 -137
- package/src/frontmatter.js +0 -77
- package/src/index.js +0 -552
- package/src/server-client.js +0 -303
- package/src/ws-connection.js +0 -156
package/scripts/hook-toast.sh
DELETED
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
#!/bin/bash
|
|
2
|
-
# hook-toast.sh — tmux status-line notifications for agent hooks.
|
|
3
|
-
#
|
|
4
|
-
# Uses tmux display-message — non-modal, no keyboard stealing.
|
|
5
|
-
# Each toast targets the originating session via -t.
|
|
6
|
-
#
|
|
7
|
-
# Usage:
|
|
8
|
-
# hook-toast.sh <system> <message> # show toast for 3s
|
|
9
|
-
# hook-toast.sh <system> --status <id> # start a multi-stage toast (persistent)
|
|
10
|
-
# hook-toast.sh --update <id> <message> # update a running toast
|
|
11
|
-
# hook-toast.sh --close <id> # show final message, then clear
|
|
12
|
-
#
|
|
13
|
-
# Built-in systems: fathom, memento
|
|
14
|
-
# fathom → 📝
|
|
15
|
-
# memento → 🧠
|
|
16
|
-
#
|
|
17
|
-
# Any other system name works too — gets a default 📌 icon.
|
|
18
|
-
#
|
|
19
|
-
# Multi-stage example (PreCompact):
|
|
20
|
-
# hook-toast.sh memento --status precompact
|
|
21
|
-
# hook-toast.sh --update precompact "⏳ Getting context..."
|
|
22
|
-
# hook-toast.sh --update precompact "⏳ Extracting memories..."
|
|
23
|
-
# hook-toast.sh --update precompact "✓ Stored 7 memories"
|
|
24
|
-
# hook-toast.sh --close precompact
|
|
25
|
-
|
|
26
|
-
# Bail silently if not in tmux
|
|
27
|
-
if ! tmux info &>/dev/null; then
|
|
28
|
-
exit 0
|
|
29
|
-
fi
|
|
30
|
-
|
|
31
|
-
# Derive session name — used for -t targeting
|
|
32
|
-
SESSION=$(tmux display-message -p '#{session_name}' 2>/dev/null)
|
|
33
|
-
if [ -z "$SESSION" ]; then
|
|
34
|
-
exit 0
|
|
35
|
-
fi
|
|
36
|
-
|
|
37
|
-
TOAST_DIR="/tmp/hook-toast/${SESSION}"
|
|
38
|
-
mkdir -p "$TOAST_DIR"
|
|
39
|
-
|
|
40
|
-
get_icon() {
|
|
41
|
-
case "$1" in
|
|
42
|
-
memento) echo "🧠" ;;
|
|
43
|
-
fathom) echo "📝" ;;
|
|
44
|
-
*) echo "📌" ;;
|
|
45
|
-
esac
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
get_title() {
|
|
49
|
-
case "$1" in
|
|
50
|
-
memento) echo "Memento" ;;
|
|
51
|
-
fathom) echo "Fathom" ;;
|
|
52
|
-
*) echo "$1" ;;
|
|
53
|
-
esac
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
# One-shot toast — display in status line for 3 seconds
|
|
57
|
-
toast_oneshot() {
|
|
58
|
-
local system="$1"
|
|
59
|
-
local message="$2"
|
|
60
|
-
local icon title
|
|
61
|
-
icon=$(get_icon "$system")
|
|
62
|
-
title=$(get_title "$system")
|
|
63
|
-
|
|
64
|
-
tmux display-message -t "$SESSION" -d 3000 "$icon $title: $message"
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
# Start a multi-stage toast — persistent until --close
|
|
68
|
-
toast_start() {
|
|
69
|
-
local system="$1"
|
|
70
|
-
local id="$2"
|
|
71
|
-
local meta_file="$TOAST_DIR/$id"
|
|
72
|
-
local icon title
|
|
73
|
-
icon=$(get_icon "$system")
|
|
74
|
-
title=$(get_title "$system")
|
|
75
|
-
|
|
76
|
-
# Save system info for --update to use
|
|
77
|
-
echo "$icon|$title" > "$meta_file"
|
|
78
|
-
|
|
79
|
-
tmux display-message -t "$SESSION" -d 0 "$icon $title: ⏳ Starting..."
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
# Update a running toast — replace the status line message
|
|
83
|
-
toast_update() {
|
|
84
|
-
local id="$1"
|
|
85
|
-
local message="$2"
|
|
86
|
-
local meta_file="$TOAST_DIR/$id"
|
|
87
|
-
|
|
88
|
-
if [ -f "$meta_file" ]; then
|
|
89
|
-
local icon title
|
|
90
|
-
icon=$(cut -d'|' -f1 "$meta_file")
|
|
91
|
-
title=$(cut -d'|' -f2 "$meta_file")
|
|
92
|
-
tmux display-message -t "$SESSION" -d 0 "$icon $title: $message"
|
|
93
|
-
else
|
|
94
|
-
# Fallback — no meta file, just show raw message
|
|
95
|
-
tmux display-message -t "$SESSION" -d 0 "📌 $message"
|
|
96
|
-
fi
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
# Close a running toast — show final message briefly, then clear
|
|
100
|
-
toast_close() {
|
|
101
|
-
local id="$1"
|
|
102
|
-
local meta_file="$TOAST_DIR/$id"
|
|
103
|
-
|
|
104
|
-
# Let the last --update message linger for 3s, then clean up
|
|
105
|
-
sleep 3
|
|
106
|
-
rm -f "$meta_file"
|
|
107
|
-
# Clear the display-message by showing empty briefly
|
|
108
|
-
tmux display-message -t "$SESSION" -d 1 ""
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
# --- Argument parsing ---
|
|
112
|
-
case "${1:-}" in
|
|
113
|
-
--update)
|
|
114
|
-
toast_update "$2" "$3"
|
|
115
|
-
;;
|
|
116
|
-
--close)
|
|
117
|
-
toast_close "$2"
|
|
118
|
-
;;
|
|
119
|
-
--*)
|
|
120
|
-
echo "Unknown option: $1" >&2
|
|
121
|
-
exit 1
|
|
122
|
-
;;
|
|
123
|
-
"")
|
|
124
|
-
echo "Usage: hook-toast.sh <system> <message>" >&2
|
|
125
|
-
echo " hook-toast.sh <system> --status <id>" >&2
|
|
126
|
-
echo " hook-toast.sh --update <id> <message>" >&2
|
|
127
|
-
echo " hook-toast.sh --close <id>" >&2
|
|
128
|
-
exit 1
|
|
129
|
-
;;
|
|
130
|
-
*)
|
|
131
|
-
SYSTEM="$1"
|
|
132
|
-
shift
|
|
133
|
-
if [ "${1:-}" = "--status" ]; then
|
|
134
|
-
toast_start "$SYSTEM" "$2"
|
|
135
|
-
else
|
|
136
|
-
toast_oneshot "$SYSTEM" "$*"
|
|
137
|
-
fi
|
|
138
|
-
;;
|
|
139
|
-
esac
|
package/scripts/kokoro-bridge.py
DELETED
|
@@ -1,196 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Kokoro TTS bridge — persistent daemon with preloaded model.
|
|
3
|
-
|
|
4
|
-
Listens on a Unix socket for TTS requests. Model is loaded once on startup,
|
|
5
|
-
so subsequent requests skip the ~10-15s model load time.
|
|
6
|
-
|
|
7
|
-
Protocol (line-delimited JSON over Unix socket):
|
|
8
|
-
Request: {"text": "hello", "output": "/tmp/out.wav", "speed": 1.0, "play": false}
|
|
9
|
-
Response: {"ok": true, "file": "/tmp/out.wav"}
|
|
10
|
-
|
|
11
|
-
Socket path: /tmp/fathom-kokoro.sock
|
|
12
|
-
"""
|
|
13
|
-
|
|
14
|
-
import json
|
|
15
|
-
import os
|
|
16
|
-
import signal
|
|
17
|
-
import socket
|
|
18
|
-
import subprocess
|
|
19
|
-
import sys
|
|
20
|
-
import threading
|
|
21
|
-
|
|
22
|
-
import numpy as np
|
|
23
|
-
import soundfile as sf
|
|
24
|
-
from kokoro import KPipeline
|
|
25
|
-
|
|
26
|
-
SOCKET_PATH = "/tmp/fathom-kokoro.sock"
|
|
27
|
-
SAMPLE_RATE = 24000
|
|
28
|
-
MAX_CHUNK_CHARS = 500
|
|
29
|
-
VOICE_A = "am_echo"
|
|
30
|
-
VOICE_B = "bf_alice"
|
|
31
|
-
WEIGHT_A = 0.7
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def chunk_text(text):
|
|
35
|
-
"""Split text into chunks of ~MAX_CHUNK_CHARS on sentence boundaries."""
|
|
36
|
-
sentences = []
|
|
37
|
-
current = []
|
|
38
|
-
for char in text:
|
|
39
|
-
current.append(char)
|
|
40
|
-
if char in ".!?" and len(current) > 1:
|
|
41
|
-
sentences.append("".join(current).strip())
|
|
42
|
-
current = []
|
|
43
|
-
if current:
|
|
44
|
-
tail = "".join(current).strip()
|
|
45
|
-
if tail:
|
|
46
|
-
sentences.append(tail)
|
|
47
|
-
|
|
48
|
-
chunks = []
|
|
49
|
-
buf = []
|
|
50
|
-
buf_len = 0
|
|
51
|
-
for s in sentences:
|
|
52
|
-
if buf_len + len(s) > MAX_CHUNK_CHARS and buf:
|
|
53
|
-
chunks.append(" ".join(buf))
|
|
54
|
-
buf = [s]
|
|
55
|
-
buf_len = len(s)
|
|
56
|
-
else:
|
|
57
|
-
buf.append(s)
|
|
58
|
-
buf_len += len(s)
|
|
59
|
-
if buf:
|
|
60
|
-
chunks.append(" ".join(buf))
|
|
61
|
-
return chunks
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
class KokoroBridge:
|
|
65
|
-
def __init__(self):
|
|
66
|
-
print("Loading Kokoro model...", file=sys.stderr)
|
|
67
|
-
self.pipe = KPipeline(lang_code="a")
|
|
68
|
-
self.voice = self.pipe.load_voice(VOICE_A) * WEIGHT_A + self.pipe.load_voice(VOICE_B) * (
|
|
69
|
-
1 - WEIGHT_A
|
|
70
|
-
)
|
|
71
|
-
print("Model loaded.", file=sys.stderr)
|
|
72
|
-
self._lock = threading.Lock()
|
|
73
|
-
|
|
74
|
-
def speak(self, text, output, speed=1.0, play=False):
|
|
75
|
-
"""Generate WAV from text. Thread-safe (one request at a time)."""
|
|
76
|
-
with self._lock:
|
|
77
|
-
chunks = chunk_text(text)
|
|
78
|
-
all_audio = []
|
|
79
|
-
for chunk in chunks:
|
|
80
|
-
for _, _, audio in self.pipe(chunk, voice=self.voice, speed=speed):
|
|
81
|
-
audio_np = audio.numpy() if hasattr(audio, "numpy") else audio
|
|
82
|
-
all_audio.append(audio_np)
|
|
83
|
-
if play:
|
|
84
|
-
pcm = (audio_np * 32767).astype(np.int16).tobytes()
|
|
85
|
-
subprocess.run(
|
|
86
|
-
["aplay", "-q", "-r", str(SAMPLE_RATE), "-f", "S16_LE", "-c", "1"],
|
|
87
|
-
input=pcm,
|
|
88
|
-
check=True,
|
|
89
|
-
)
|
|
90
|
-
if all_audio:
|
|
91
|
-
sf.write(output, np.concatenate(all_audio), SAMPLE_RATE)
|
|
92
|
-
return output
|
|
93
|
-
|
|
94
|
-
def handle_request(self, data):
|
|
95
|
-
"""Process a single JSON request and return a response dict."""
|
|
96
|
-
text = data.get("text", "")
|
|
97
|
-
file_path = data.get("file")
|
|
98
|
-
output = data.get("output")
|
|
99
|
-
speed = data.get("speed", 1.0)
|
|
100
|
-
play = data.get("play", False)
|
|
101
|
-
|
|
102
|
-
if file_path:
|
|
103
|
-
try:
|
|
104
|
-
with open(file_path) as f:
|
|
105
|
-
text = f.read().strip()
|
|
106
|
-
except Exception as e:
|
|
107
|
-
return {"error": f"Cannot read file: {e}"}
|
|
108
|
-
|
|
109
|
-
if not text:
|
|
110
|
-
return {"error": "No text provided"}
|
|
111
|
-
if not output:
|
|
112
|
-
return {"error": "No output path provided"}
|
|
113
|
-
|
|
114
|
-
try:
|
|
115
|
-
result_path = self.speak(text, output, speed=speed, play=play)
|
|
116
|
-
return {"ok": True, "file": result_path}
|
|
117
|
-
except Exception as e:
|
|
118
|
-
return {"error": str(e)}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
def handle_client(conn, bridge):
|
|
122
|
-
"""Handle a single client connection."""
|
|
123
|
-
try:
|
|
124
|
-
data = b""
|
|
125
|
-
while True:
|
|
126
|
-
chunk = conn.recv(65536)
|
|
127
|
-
if not chunk:
|
|
128
|
-
break
|
|
129
|
-
data += chunk
|
|
130
|
-
if b"\n" in data:
|
|
131
|
-
break
|
|
132
|
-
|
|
133
|
-
if not data:
|
|
134
|
-
return
|
|
135
|
-
|
|
136
|
-
request = json.loads(data.decode("utf-8").strip())
|
|
137
|
-
response = bridge.handle_request(request)
|
|
138
|
-
conn.sendall((json.dumps(response) + "\n").encode("utf-8"))
|
|
139
|
-
except json.JSONDecodeError:
|
|
140
|
-
conn.sendall(b'{"error": "Invalid JSON"}\n')
|
|
141
|
-
except Exception as e:
|
|
142
|
-
try:
|
|
143
|
-
conn.sendall((json.dumps({"error": str(e)}) + "\n").encode("utf-8"))
|
|
144
|
-
except Exception:
|
|
145
|
-
pass
|
|
146
|
-
finally:
|
|
147
|
-
conn.close()
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
def main():
|
|
151
|
-
# Clean up stale socket
|
|
152
|
-
if os.path.exists(SOCKET_PATH):
|
|
153
|
-
os.unlink(SOCKET_PATH)
|
|
154
|
-
|
|
155
|
-
bridge = KokoroBridge()
|
|
156
|
-
|
|
157
|
-
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
158
|
-
server.bind(SOCKET_PATH)
|
|
159
|
-
os.chmod(SOCKET_PATH, 0o660)
|
|
160
|
-
server.listen(5)
|
|
161
|
-
server.settimeout(1.0) # Allow periodic signal checks
|
|
162
|
-
|
|
163
|
-
running = True
|
|
164
|
-
|
|
165
|
-
def shutdown(signum, frame):
|
|
166
|
-
nonlocal running
|
|
167
|
-
print("\nShutting down...", file=sys.stderr)
|
|
168
|
-
running = False
|
|
169
|
-
|
|
170
|
-
signal.signal(signal.SIGTERM, shutdown)
|
|
171
|
-
signal.signal(signal.SIGINT, shutdown)
|
|
172
|
-
|
|
173
|
-
print(f"Kokoro bridge listening on {SOCKET_PATH}", file=sys.stderr)
|
|
174
|
-
# Signal readiness to parent process
|
|
175
|
-
print("READY", flush=True)
|
|
176
|
-
|
|
177
|
-
while running:
|
|
178
|
-
try:
|
|
179
|
-
conn, _ = server.accept()
|
|
180
|
-
# Handle each client in a thread (but the lock serializes TTS work)
|
|
181
|
-
t = threading.Thread(target=handle_client, args=(conn, bridge), daemon=True)
|
|
182
|
-
t.start()
|
|
183
|
-
except TimeoutError:
|
|
184
|
-
continue
|
|
185
|
-
except Exception as e:
|
|
186
|
-
if running:
|
|
187
|
-
print(f"Accept error: {e}", file=sys.stderr)
|
|
188
|
-
|
|
189
|
-
server.close()
|
|
190
|
-
if os.path.exists(SOCKET_PATH):
|
|
191
|
-
os.unlink(SOCKET_PATH)
|
|
192
|
-
print("Bridge stopped.", file=sys.stderr)
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
if __name__ == "__main__":
|
|
196
|
-
main()
|
package/scripts/kokoro-speak.py
DELETED
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Kokoro TTS — generate speech to WAV, optionally play aloud.
|
|
3
|
-
|
|
4
|
-
Usage:
|
|
5
|
-
echo "Hello world" | python kokoro-speak.py --output /tmp/speech.wav
|
|
6
|
-
python kokoro-speak.py --file /path/to/text.txt --output /tmp/speech.wav
|
|
7
|
-
python kokoro-speak.py --output /tmp/speech.wav --play
|
|
8
|
-
python kokoro-speak.py --speed 1.2 --output /tmp/speech.wav --play
|
|
9
|
-
|
|
10
|
-
Always writes WAV to --output path. With --play, also streams audio to aplay
|
|
11
|
-
during generation. Prints the output path to stdout on success.
|
|
12
|
-
|
|
13
|
-
Voice: am_echo 70% + bf_alice 30% (masculine American with British warmth).
|
|
14
|
-
Audio: 24kHz, 16-bit signed LE.
|
|
15
|
-
"""
|
|
16
|
-
|
|
17
|
-
import argparse
|
|
18
|
-
import subprocess
|
|
19
|
-
import sys
|
|
20
|
-
|
|
21
|
-
import numpy as np
|
|
22
|
-
import soundfile as sf
|
|
23
|
-
from kokoro import KPipeline
|
|
24
|
-
|
|
25
|
-
SAMPLE_RATE = 24000
|
|
26
|
-
MAX_CHUNK_CHARS = 500
|
|
27
|
-
VOICE_A = "am_echo"
|
|
28
|
-
VOICE_B = "bf_alice"
|
|
29
|
-
WEIGHT_A = 0.7
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def chunk_text(text):
|
|
33
|
-
"""Split text into chunks of ~MAX_CHUNK_CHARS on sentence boundaries."""
|
|
34
|
-
sentences = []
|
|
35
|
-
current = []
|
|
36
|
-
for char in text:
|
|
37
|
-
current.append(char)
|
|
38
|
-
if char in ".!?" and len(current) > 1:
|
|
39
|
-
sentences.append("".join(current).strip())
|
|
40
|
-
current = []
|
|
41
|
-
if current:
|
|
42
|
-
tail = "".join(current).strip()
|
|
43
|
-
if tail:
|
|
44
|
-
sentences.append(tail)
|
|
45
|
-
|
|
46
|
-
chunks = []
|
|
47
|
-
buf = []
|
|
48
|
-
buf_len = 0
|
|
49
|
-
for s in sentences:
|
|
50
|
-
if buf_len + len(s) > MAX_CHUNK_CHARS and buf:
|
|
51
|
-
chunks.append(" ".join(buf))
|
|
52
|
-
buf = [s]
|
|
53
|
-
buf_len = len(s)
|
|
54
|
-
else:
|
|
55
|
-
buf.append(s)
|
|
56
|
-
buf_len += len(s)
|
|
57
|
-
if buf:
|
|
58
|
-
chunks.append(" ".join(buf))
|
|
59
|
-
return chunks
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def main():
|
|
63
|
-
parser = argparse.ArgumentParser(description="Kokoro TTS speaker")
|
|
64
|
-
parser.add_argument("--file", help="Path to text file to read aloud")
|
|
65
|
-
parser.add_argument("--speed", type=float, default=1.0, help="Speech speed (default: 1.0)")
|
|
66
|
-
parser.add_argument("--output", required=True, help="Path to write WAV file (always written)")
|
|
67
|
-
parser.add_argument(
|
|
68
|
-
"--play", action="store_true", help="Also play audio aloud via aplay during generation"
|
|
69
|
-
)
|
|
70
|
-
args = parser.parse_args()
|
|
71
|
-
|
|
72
|
-
if args.file:
|
|
73
|
-
with open(args.file) as f:
|
|
74
|
-
text = f.read().strip()
|
|
75
|
-
else:
|
|
76
|
-
text = sys.stdin.read().strip()
|
|
77
|
-
|
|
78
|
-
if not text:
|
|
79
|
-
print("No text provided.", file=sys.stderr)
|
|
80
|
-
sys.exit(1)
|
|
81
|
-
|
|
82
|
-
# Load pipeline and blend voice
|
|
83
|
-
pipe = KPipeline(lang_code="a")
|
|
84
|
-
voice = pipe.load_voice(VOICE_A) * WEIGHT_A + pipe.load_voice(VOICE_B) * (1 - WEIGHT_A)
|
|
85
|
-
|
|
86
|
-
chunks = chunk_text(text)
|
|
87
|
-
|
|
88
|
-
# Always collect audio for WAV; optionally stream to aplay during generation
|
|
89
|
-
all_audio = []
|
|
90
|
-
for chunk in chunks:
|
|
91
|
-
for _, _, audio in pipe(chunk, voice=voice, speed=args.speed):
|
|
92
|
-
audio = audio.numpy() if hasattr(audio, "numpy") else audio
|
|
93
|
-
all_audio.append(audio)
|
|
94
|
-
if args.play:
|
|
95
|
-
pcm = (audio * 32767).astype(np.int16).tobytes()
|
|
96
|
-
subprocess.run(
|
|
97
|
-
["aplay", "-q", "-r", str(SAMPLE_RATE), "-f", "S16_LE", "-c", "1"],
|
|
98
|
-
input=pcm,
|
|
99
|
-
check=True,
|
|
100
|
-
)
|
|
101
|
-
if all_audio:
|
|
102
|
-
sf.write(args.output, np.concatenate(all_audio), SAMPLE_RATE)
|
|
103
|
-
print(args.output)
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
if __name__ == "__main__":
|
|
107
|
-
main()
|
package/scripts/setup-kokoro.sh
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# One-shot setup: create venv and install Kokoro TTS dependencies.
|
|
3
|
-
# Idempotent — skips if already set up.
|
|
4
|
-
# Uses uv for fast installs and Python version management.
|
|
5
|
-
|
|
6
|
-
set -euo pipefail
|
|
7
|
-
|
|
8
|
-
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
9
|
-
VENV_DIR="$SCRIPT_DIR/.kokoro-venv"
|
|
10
|
-
PYTHON_VERSION="3.13"
|
|
11
|
-
|
|
12
|
-
if [ -d "$VENV_DIR" ] && "$VENV_DIR/bin/python" -c "import kokoro, soundfile" 2>/dev/null; then
|
|
13
|
-
echo "Kokoro venv already set up at $VENV_DIR"
|
|
14
|
-
exit 0
|
|
15
|
-
fi
|
|
16
|
-
|
|
17
|
-
echo "Creating venv at $VENV_DIR (Python $PYTHON_VERSION)..."
|
|
18
|
-
uv venv --python "$PYTHON_VERSION" "$VENV_DIR"
|
|
19
|
-
|
|
20
|
-
echo "Installing kokoro and soundfile..."
|
|
21
|
-
uv pip install --python "$VENV_DIR/bin/python" "kokoro>=0.9.4" soundfile numpy pip
|
|
22
|
-
|
|
23
|
-
echo "Downloading spacy model (needed by kokoro)..."
|
|
24
|
-
"$VENV_DIR/bin/python" -m spacy download en_core_web_sm
|
|
25
|
-
|
|
26
|
-
echo "Verifying imports..."
|
|
27
|
-
"$VENV_DIR/bin/python" -c "import kokoro, soundfile, numpy; print('OK')"
|
|
28
|
-
|
|
29
|
-
echo "Setup complete."
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Vault frontmatter lint hook for Claude Code PostToolUse (Write/Edit).
|
|
5
|
-
*
|
|
6
|
-
* Reads the tool result from stdin (JSON), checks if the file is inside a
|
|
7
|
-
* vault/ directory and ends in .md. If so, validates frontmatter.
|
|
8
|
-
* Exits 0 on pass, 1 on failure (with error message on stderr).
|
|
9
|
-
*
|
|
10
|
-
* Environment: CLAUDE_TOOL_NAME, CLAUDE_FILE_PATH are set by Claude Code hooks.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import fs from "fs";
|
|
14
|
-
import path from "path";
|
|
15
|
-
import { parseFrontmatter, validateFrontmatter } from "../src/frontmatter.js";
|
|
16
|
-
|
|
17
|
-
const toolName = process.env.CLAUDE_TOOL_NAME || "";
|
|
18
|
-
const filePath = process.env.CLAUDE_FILE_PATH || "";
|
|
19
|
-
|
|
20
|
-
// Only check Write and Edit tools
|
|
21
|
-
if (toolName !== "Write" && toolName !== "Edit") {
|
|
22
|
-
process.exit(0);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
// Only check .md files inside a vault/ directory
|
|
26
|
-
if (!filePath || !filePath.endsWith(".md")) {
|
|
27
|
-
process.exit(0);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// Check if the file is inside a vault/ directory
|
|
31
|
-
const parts = filePath.split(path.sep);
|
|
32
|
-
if (!parts.includes("vault")) {
|
|
33
|
-
process.exit(0);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// Read the file and validate
|
|
37
|
-
try {
|
|
38
|
-
const content = fs.readFileSync(filePath, "utf-8");
|
|
39
|
-
|
|
40
|
-
// Only validate if file has frontmatter
|
|
41
|
-
if (!content.startsWith("---")) {
|
|
42
|
-
process.exit(0);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const { fm } = parseFrontmatter(content);
|
|
46
|
-
if (Object.keys(fm).length === 0) {
|
|
47
|
-
process.exit(0);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
const errors = validateFrontmatter(fm);
|
|
51
|
-
if (errors.length > 0) {
|
|
52
|
-
process.stderr.write(`Vault frontmatter validation failed for ${path.basename(filePath)}:\n`);
|
|
53
|
-
for (const err of errors) {
|
|
54
|
-
process.stderr.write(` - ${err}\n`);
|
|
55
|
-
}
|
|
56
|
-
process.exit(1);
|
|
57
|
-
}
|
|
58
|
-
} catch (e) {
|
|
59
|
-
// File read errors are non-fatal for the hook
|
|
60
|
-
if (e.code !== "ENOENT") {
|
|
61
|
-
process.stderr.write(`Frontmatter lint error: ${e.message}\n`);
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
process.exit(0);
|