fathom-mcp 0.1.2 → 0.1.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fathom-mcp",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "MCP server for Fathom — vault operations, search, rooms, and cross-workspace communication",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,183 @@
1
+ #!/bin/bash
2
+ # Fathom Vault recall — BM25 keyword + cached semantic search on every user message.
3
+ # JSON output: systemMessage (user sees count) + additionalContext (model sees details).
4
+ #
5
+ # Search strategy:
6
+ # - BM25 keyword search runs synchronously (<2s) for immediate results
7
+ # - Vector semantic search runs asynchronously in the background
8
+ # - Cached vsearch results from the previous query are shown alongside BM25
9
+ # - This gives us the best of both: fast keywords + deep semantics (one message behind)
10
+
11
+ set -o pipefail
12
+
13
+ HOOK_DIR="$(cd "$(dirname "$0")" && pwd)"
14
+ VSEARCH_CACHE="/tmp/fathom-vsearch-cache.json"
15
+ VSEARCH_LOCK="/tmp/fathom-vsearch.lock"
16
+ STALE_LOCK_SECONDS=180 # 3 minutes — lock older than this is considered stale
17
+ CACHE_TTL_SECONDS=300 # 5 minutes — cached results older than this are ignored
18
+
19
+ # Walk up to find .fathom.json
20
+ find_config() {
21
+ local dir="$PWD"
22
+ while [ "$dir" != "/" ]; do
23
+ if [ -f "$dir/.fathom.json" ]; then
24
+ echo "$dir/.fathom.json"
25
+ return 0
26
+ fi
27
+ dir="$(dirname "$dir")"
28
+ done
29
+ return 1
30
+ }
31
+
32
+ CONFIG_FILE=$(find_config 2>/dev/null) || exit 0
33
+
34
+ WORKSPACE=$(python3 -c "import json; print(json.load(open('$CONFIG_FILE')).get('workspace',''))" 2>/dev/null || echo "")
35
+ SERVER=$(python3 -c "import json; print(json.load(open('$CONFIG_FILE')).get('server','http://localhost:4243'))" 2>/dev/null || echo "http://localhost:4243")
36
+ API_KEY=$(python3 -c "import json; print(json.load(open('$CONFIG_FILE')).get('apiKey',''))" 2>/dev/null || echo "")
37
+
38
+ AUTH_HEADER=""
39
+ if [ -n "$API_KEY" ]; then
40
+ AUTH_HEADER="Authorization: Bearer $API_KEY"
41
+ fi
42
+
43
+ # Compact parser: converts verbose qmd output to one-line-per-result format
44
+ compact_results() {
45
+ python3 -c "
46
+ import sys, re
47
+ lines = sys.stdin.read().strip().split('\n')
48
+ current = {}
49
+ for line in lines:
50
+ m = re.match(r'qmd://[^/]+/(.+?)(?::\d+)?\s+#\w+', line)
51
+ if m:
52
+ if current and current.get('path'):
53
+ score = current.get('score', '?')
54
+ title = current.get('title', '(untitled)')
55
+ print(f\" {current['path']} ({score}) — {title}\")
56
+ current = {'path': m.group(1)}
57
+ elif line.startswith('Title: '):
58
+ current['title'] = line[7:]
59
+ elif line.startswith('Score:'):
60
+ parts = line.split()
61
+ current['score'] = parts[-1] if parts else '?'
62
+ if current and current.get('path'):
63
+ score = current.get('score', '?')
64
+ title = current.get('title', '(untitled)')
65
+ print(f\" {current['path']} ({score}) — {title}\")
66
+ " 2>/dev/null
67
+ }
68
+
69
+ INPUT=$(cat)
70
+ USER_MESSAGE=$(echo "$INPUT" | jq -r '.prompt // empty' 2>/dev/null)
71
+
72
+ if [ -z "$USER_MESSAGE" ] || [ ${#USER_MESSAGE} -lt 10 ]; then
73
+ exit 0
74
+ fi
75
+
76
+ QUERY="${USER_MESSAGE:0:500}"
77
+
78
+ # --- Phase 1: Read cached vsearch results from previous query ---
79
+ CACHED_VSEARCH=""
80
+ if [ -f "$VSEARCH_CACHE" ]; then
81
+ CACHE_AGE=$(( $(date +%s) - $(stat -c %Y "$VSEARCH_CACHE" 2>/dev/null || echo 0) ))
82
+ if [ "$CACHE_AGE" -lt "$CACHE_TTL_SECONDS" ]; then
83
+ RAW_VSEARCH=$(python3 -c "
84
+ import json, sys
85
+ try:
86
+ with open(sys.argv[1]) as f:
87
+ data = json.load(f)
88
+ if data.get('results'):
89
+ print(data['results'])
90
+ except Exception:
91
+ pass
92
+ " "$VSEARCH_CACHE" 2>/dev/null)
93
+ if [ -n "$RAW_VSEARCH" ]; then
94
+ CACHED_VSEARCH=$(echo "$RAW_VSEARCH" | compact_results)
95
+ fi
96
+ fi
97
+ fi
98
+
99
+ # --- Phase 2: Run search via API ---
100
+ ENCODED_Q=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1]))" "$QUERY")
101
+
102
+ CURL_ARGS=(-sf)
103
+ [ -n "$AUTH_HEADER" ] && CURL_ARGS+=(-H "$AUTH_HEADER")
104
+
105
+ API_RESPONSE=$(timeout 5 curl "${CURL_ARGS[@]}" "${SERVER}/api/search?q=${ENCODED_Q}&n=5&mode=bm25&workspace=${WORKSPACE}" 2>/dev/null)
106
+ BM25_RESULTS=""
107
+
108
+ if [ -n "$API_RESPONSE" ]; then
109
+ BM25_RESULTS=$(echo "$API_RESPONSE" | python3 -c "
110
+ import json, sys
111
+ try:
112
+ data = json.load(sys.stdin)
113
+ for r in data.get('results', []):
114
+ score = str(r.get('score', '?')) + '%'
115
+ title = r.get('title', '(untitled)')
116
+ path = r.get('file', '')
117
+ print(f' {path} ({score}) — {title}')
118
+ except Exception:
119
+ pass
120
+ " 2>/dev/null)
121
+ fi
122
+
123
+ # Combine vault results
124
+ ALL_RESULTS=""
125
+ [ -n "$BM25_RESULTS" ] && ALL_RESULTS="$BM25_RESULTS"
126
+ if [ -n "$CACHED_VSEARCH" ]; then
127
+ if [ -n "$ALL_RESULTS" ]; then
128
+ ALL_RESULTS="$ALL_RESULTS"$'\n'"$CACHED_VSEARCH"
129
+ else
130
+ ALL_RESULTS="$CACHED_VSEARCH"
131
+ fi
132
+ fi
133
+
134
+ # --- Output ---
135
+ if [ -n "$BM25_RESULTS" ] || [ -n "$CACHED_VSEARCH" ]; then
136
+ VAULT_COUNT=0
137
+ [ -n "$BM25_RESULTS" ] && VAULT_COUNT=$(echo "$BM25_RESULTS" | grep -c '^\s' || true)
138
+ [ -n "$CACHED_VSEARCH" ] && VAULT_COUNT=$((VAULT_COUNT + $(echo "$CACHED_VSEARCH" | grep -c '^\s' || true)))
139
+
140
+ DETAIL_TEXT="Fathom Vault: ${VAULT_COUNT} results"
141
+ DETAIL_TEXT="$DETAIL_TEXT"$'\n'
142
+ if [ -n "$BM25_RESULTS" ] && [ -n "$CACHED_VSEARCH" ]; then
143
+ DETAIL_TEXT="$DETAIL_TEXT"$'\n'"Vault (keyword):"
144
+ DETAIL_TEXT="$DETAIL_TEXT"$'\n'"$BM25_RESULTS"
145
+ DETAIL_TEXT="$DETAIL_TEXT"$'\n'$'\n'"Vault (semantic, previous query):"
146
+ DETAIL_TEXT="$DETAIL_TEXT"$'\n'"$CACHED_VSEARCH"
147
+ elif [ -n "$BM25_RESULTS" ]; then
148
+ DETAIL_TEXT="$DETAIL_TEXT"$'\n'"$BM25_RESULTS"
149
+ elif [ -n "$CACHED_VSEARCH" ]; then
150
+ DETAIL_TEXT="$DETAIL_TEXT"$'\n'"$CACHED_VSEARCH"
151
+ fi
152
+
153
+ SUMMARY="Fathom Vault: ${VAULT_COUNT} memories"
154
+ python3 -c "
155
+ import json, sys
156
+ summary = sys.argv[1]
157
+ detail = sys.argv[2]
158
+ print(json.dumps({
159
+ 'systemMessage': summary,
160
+ 'hookSpecificOutput': {
161
+ 'hookEventName': 'UserPromptSubmit',
162
+ 'additionalContext': detail
163
+ }
164
+ }))
165
+ " "$SUMMARY" "$DETAIL_TEXT"
166
+ fi
167
+
168
+ # --- Phase 3: Launch background vsearch for current query ---
169
+ SHOULD_LAUNCH=true
170
+
171
+ if [ -f "$VSEARCH_LOCK" ]; then
172
+ LOCK_AGE=$(( $(date +%s) - $(stat -c %Y "$VSEARCH_LOCK" 2>/dev/null || echo 0) ))
173
+ if [ "$LOCK_AGE" -lt "$STALE_LOCK_SECONDS" ]; then
174
+ SHOULD_LAUNCH=false
175
+ else
176
+ rm -f "$VSEARCH_LOCK"
177
+ fi
178
+ fi
179
+
180
+ if [ "$SHOULD_LAUNCH" = true ]; then
181
+ nohup "$HOOK_DIR/fathom-vsearch-background.sh" "$QUERY" "$WORKSPACE" >/dev/null 2>&1 &
182
+ disown
183
+ fi
@@ -0,0 +1,46 @@
1
+ #!/bin/bash
2
+ # Background vector search worker for fathom-recall hook.
3
+ # Launched via nohup/disown — runs asynchronously after the hook returns.
4
+ #
5
+ # Takes a search query as $1 and workspace as $2, runs qmd vsearch,
6
+ # and writes results as JSON to /tmp/fathom-vsearch-cache.json.
7
+
8
+ set -o pipefail
9
+
10
+ LOCK_FILE="/tmp/fathom-vsearch.lock"
11
+ CACHE_FILE="/tmp/fathom-vsearch-cache.json"
12
+
13
+ cleanup() {
14
+ rm -f "$LOCK_FILE"
15
+ }
16
+ trap cleanup EXIT
17
+
18
+ if [ -z "$1" ]; then
19
+ exit 1
20
+ fi
21
+
22
+ WORKSPACE="${2:-fathom}"
23
+
24
+ echo $$ > "$LOCK_FILE"
25
+
26
+ VSEARCH_QUERY="${1:0:200}"
27
+
28
+ # Run vsearch with 180-second hard timeout
29
+ RESULTS=$(timeout 180 qmd vsearch "$VSEARCH_QUERY" -n 5 -c "$WORKSPACE" --min-score 0.5 2>/dev/null)
30
+
31
+ TMPFILE=$(mktemp /tmp/fathom-vsearch-cache.XXXXXX)
32
+
33
+ python3 -c "
34
+ import json, sys, time
35
+ query = sys.argv[1][:200]
36
+ results = sys.stdin.read().strip()
37
+ has_results = bool(results) and 'No results found' not in results
38
+ with open(sys.argv[2], 'w') as f:
39
+ json.dump({
40
+ 'query': query,
41
+ 'timestamp': int(time.time()),
42
+ 'results': results if has_results else None
43
+ }, f)
44
+ " "$VSEARCH_QUERY" "$TMPFILE" <<< "$RESULTS"
45
+
46
+ mv "$TMPFILE" "$CACHE_FILE"
package/src/cli.js CHANGED
@@ -166,6 +166,7 @@ async function runInit() {
166
166
  }
167
167
 
168
168
  // 5. Hooks
169
+ const enableRecallHook = await askYesNo(rl, " Enable vault recall on every message (UserPromptSubmit)?", true);
169
170
  const enablePrecompactHook = await askYesNo(rl, " Enable PreCompact vault snapshot hook?", true);
170
171
 
171
172
  rl.close();
@@ -181,6 +182,7 @@ async function runInit() {
181
182
  server: serverUrl,
182
183
  apiKey,
183
184
  hooks: {
185
+ "vault-recall": { enabled: enableRecallHook },
184
186
  "precompact-snapshot": { enabled: enablePrecompactHook },
185
187
  },
186
188
  };
@@ -224,6 +226,20 @@ async function runInit() {
224
226
  // Claude Code hooks use matcher + hooks array format:
225
227
  // { hooks: [{ type: "command", command: "...", timeout: N }] }
226
228
  const hooks = {};
229
+ if (enableRecallHook) {
230
+ hooks["UserPromptSubmit"] = [
231
+ ...(claudeSettings.hooks?.["UserPromptSubmit"] || []),
232
+ ];
233
+ const recallCmd = "bash .fathom/scripts/fathom-recall.sh";
234
+ const hasFathomRecall = hooks["UserPromptSubmit"].some((entry) =>
235
+ entry.hooks?.some((h) => h.command === recallCmd)
236
+ );
237
+ if (!hasFathomRecall) {
238
+ hooks["UserPromptSubmit"].push({
239
+ hooks: [{ type: "command", command: recallCmd, timeout: 10000 }],
240
+ });
241
+ }
242
+ }
227
243
  if (enablePrecompactHook) {
228
244
  hooks["PreCompact"] = [
229
245
  ...(claudeSettings.hooks?.["PreCompact"] || []),