fathom-mcp 0.6.4 → 2.0.1

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.
@@ -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
@@ -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()
@@ -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()
@@ -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);