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 +1 -1
- package/scripts/fathom-recall.sh +183 -0
- package/scripts/fathom-vsearch-background.sh +46 -0
- package/src/cli.js +16 -0
package/package.json
CHANGED
|
@@ -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"] || []),
|