In your example, the definition is stored in the history of the ielm repl.
So I would suggest searching the history, e.g. with
M-r defun say-hi RET
or, a bit more programmatically,
(comint-previous-matching-input "^(defun say-hi" 1)
The whole history can be accessed from the tool bar, menu In/Out > List input history.
If you really want to get the code returned by the repl as a string, you can modify slightly
the definition of comint-previous-matching-history as follows
(defun get-source (regexp)
(interactive (comint-regexp-arg "Previous input matching (regexp): "))
(setq n (comint-search-arg 1))
(let ((pos (comint-previous-matching-input-string-position
(concat "^\\s-*(defun\\s-*" (symbol-name regexp)) n)))
(if (null pos) (error "Not found")
(if (null comint-input-ring-index)
(setq comint-stored-incomplete-input
(funcall comint-get-old-input)))
(setq comint-input-ring-index pos)
(message "History item: %d" (1+ pos))
(comint-delete-input)
(substring-no-properties(ring-ref comint-input-ring pos)))))
This gives
ELISP> (defun say-hi () "Says hi" (message "Hi"))
say-hi
ELISP> (get-source 'say-hi)
"(defun say-hi () \"Says hi\" (message \"Hi\"))"