Skip to content

Commit e719368

Browse files
authored
Merge pull request #2 from Semantic-partners/feat/search-plugin
feat: NL tool discovery via local qwen
2 parents e1d60b5 + c518c9c commit e719368

File tree

2 files changed

+303
-5
lines changed

2 files changed

+303
-5
lines changed

plugins/spai-search

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
#!/usr/bin/env bb
2+
;; spai plugin: search
3+
;;
4+
;; Natural language → spai command recommendation via local Ollama.
5+
;; "Which spai command do I need?" answered by a tiny local model.
6+
;;
7+
;; Usage: spai search "find class predicates"
8+
;; spai search "what changed recently"
9+
;; spai search --model qwen2.5-coder:3b "who uses this file"
10+
11+
{:doap/name "search"
12+
:doap/description "NL search over spai's tool catalog via local Ollama"
13+
:dc/creator "Claude + Lance"
14+
:spai/args "\"natural language query\" [--model name]"
15+
:spai/returns "EDN: matched command(s) with usage and rationale"
16+
:spai/example "spai search \"find what predicates a class has\""
17+
:spai/tags #{:meta :discovery :nl}}
18+
19+
(require '[babashka.http-client :as http]
20+
'[babashka.process :as p]
21+
'[cheshire.core :as json]
22+
'[clojure.edn :as edn]
23+
'[clojure.string :as str]
24+
'[clojure.pprint :as pp])
25+
26+
(def ollama-url (or (System/getenv "OLLAMA_URL") "http://localhost:11434"))
27+
(def default-model "qwen2.5-coder:7b")
28+
29+
;; ---------------------------------------------------------------------------
30+
;; Catalog: read from spai help
31+
;; ---------------------------------------------------------------------------
32+
33+
(defn load-catalog
34+
"Run `spai help` and parse the EDN tool catalog."
35+
[]
36+
(let [{:keys [exit out]} (p/shell {:out :string :err :string :continue true}
37+
"spai" "help")]
38+
(when (zero? exit)
39+
;; spai help outputs a header line then EDN. Find the opening {
40+
(let [edn-start (str/index-of out "{")]
41+
(when edn-start
42+
(edn/read-string (subs out edn-start)))))))
43+
44+
(defn catalog->prompt-text
45+
"Turn the tool catalog into a compact text block for the LLM."
46+
[catalog]
47+
(str/join "\n"
48+
(for [[cmd-key info] (sort-by key catalog)
49+
:when (not= cmd-key :search)] ; don't recommend ourselves
50+
(let [name (name cmd-key)
51+
desc (or (:returns info) "")
52+
args (or (:args info) "")
53+
example (or (:example info) "")]
54+
(str name
55+
(when (seq args) (str " " args))
56+
(when (seq desc) (str "" desc))
57+
(when (seq example) (str " (e.g. " example ")")))))))
58+
59+
;; ---------------------------------------------------------------------------
60+
;; System prompt — deliberately minimal
61+
;; ---------------------------------------------------------------------------
62+
63+
(defn build-system-prompt [catalog-text]
64+
(str
65+
"You are a tool recommender for `spai`, a code exploration CLI for LLM agents.
66+
Given a natural language question, return the best matching spai command(s).
67+
68+
Output ONLY a valid EDN vector of maps. No explanation, no markdown, no prose.
69+
70+
Each map must have:
71+
:command — the spai command name (string)
72+
:invocation — exact command line to run (string)
73+
:why — one sentence explaining the match (string)
74+
75+
Return 1-3 matches, best first. If unsure, return your best guess.
76+
77+
## Available commands
78+
79+
" catalog-text "
80+
81+
Output ONLY the EDN vector."))
82+
83+
;; ---------------------------------------------------------------------------
84+
;; Ollama
85+
;; ---------------------------------------------------------------------------
86+
87+
(defn ollama-chat [model system-prompt user-prompt]
88+
(let [resp (http/post (str ollama-url "/api/chat")
89+
{:headers {"Content-Type" "application/json"}
90+
:body (json/generate-string
91+
{:model model
92+
:messages [{:role "system" :content system-prompt}
93+
{:role "user" :content user-prompt}]
94+
:stream false
95+
:options {:temperature 0.1
96+
:num_predict 256}})
97+
:throw false
98+
:timeout 30000})
99+
body (json/parse-string (:body resp) true)]
100+
{:content (get-in body [:message :content])
101+
:model (:model body)
102+
:prompt_tokens (get-in body [:prompt_eval_count])
103+
:completion_tokens (get-in body [:eval_count])
104+
:total_ms (some-> (get-in body [:total_duration]) (/ 1e6) long)
105+
:tokens_per_sec (when-let [eval-count (get-in body [:eval_count])]
106+
(when-let [eval-dur (get-in body [:eval_duration])]
107+
(when (pos? eval-dur)
108+
(-> (/ (* eval-count 1e9) eval-dur) (Math/round) (/ 1.0)))))}))
109+
110+
(defn extract-edn
111+
"Extract EDN from LLM response, stripping markdown fencing."
112+
[text]
113+
(let [text (str/trim (or text ""))]
114+
(cond
115+
(str/starts-with? text "```")
116+
(let [lines (str/split-lines text)
117+
inner (drop 1 (butlast lines))]
118+
(str/join "\n" inner))
119+
:else text)))
120+
121+
;; ---------------------------------------------------------------------------
122+
;; Main
123+
;; ---------------------------------------------------------------------------
124+
125+
(let [args *command-line-args*
126+
model (atom default-model)
127+
query-args (atom [])]
128+
129+
;; Parse args
130+
(loop [args args]
131+
(when (seq args)
132+
(cond
133+
(= (first args) "--model")
134+
(do (reset! model (second args))
135+
(recur (drop 2 args)))
136+
137+
(contains? #{"--help" "-h"} (first args))
138+
(do
139+
(println "Usage: spai search \"your question here\"")
140+
(println " spai search --model qwen2.5-coder:3b \"who uses this file\"")
141+
(println)
142+
(println "Searches spai's tool catalog using natural language via local Ollama.")
143+
(println "Returns the best matching command(s) with invocation examples.")
144+
(System/exit 0))
145+
146+
:else
147+
(do (swap! query-args conj (first args))
148+
(recur (rest args))))))
149+
150+
(when (empty? @query-args)
151+
(println "Usage: spai search \"your question here\"")
152+
(System/exit 1))
153+
154+
;; Load tool catalog
155+
(let [catalog (load-catalog)]
156+
(when-not catalog
157+
(binding [*out* *err*]
158+
(println "Error: could not load spai tool catalog (is spai installed?)"))
159+
(System/exit 1))
160+
161+
(let [catalog-text (catalog->prompt-text catalog)
162+
user-query (str/join " " @query-args)
163+
system-prompt (build-system-prompt catalog-text)]
164+
165+
;; Query ollama
166+
(binding [*out* *err*]
167+
(print (str "Searching (" @model ")... "))
168+
(flush))
169+
170+
(let [result (ollama-chat @model system-prompt user-query)
171+
raw (extract-edn (:content result))]
172+
173+
(binding [*out* *err*]
174+
(println (str "done. ("
175+
(:total_ms result) "ms, "
176+
(:prompt_tokens result) "" (:completion_tokens result) " tokens, "
177+
(:tokens_per_sec result) " tok/s)")))
178+
179+
;; Try to parse as EDN, fall back to raw
180+
(let [parsed (try (edn/read-string raw) (catch Exception _ nil))]
181+
(if (and parsed (or (vector? parsed) (seq? parsed)))
182+
(pp/pprint parsed)
183+
;; Fallback: print raw and wrap in error
184+
(pp/pprint {:error "Could not parse LLM response as EDN"
185+
:raw raw
186+
:query user-query})))))))

setup.clj

Lines changed: 117 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@
4343
(defn check-deps []
4444
(println)
4545
(let [bb-ok (= 0 (:exit (sh "which" "bb")))
46-
rg-ok (= 0 (:exit (sh "which" "rg")))]
46+
rg-ok (= 0 (:exit (sh "which" "rg")))
47+
ollama-ok (= 0 (:exit (sh "which" "ollama")))]
4748
(if bb-ok
4849
(info (str "babashka " (str/trim (:out (sh "bb" "--version")))))
4950
(do (warn "babashka (bb) not found")
@@ -52,10 +53,69 @@
5253
(println)))
5354
(if rg-ok
5455
(info (str "ripgrep " (first (str/split-lines (:out (sh "rg" "--version"))))))
55-
(do (warn "ripgrep (rg) not found — spai will use grep (slower)")
56-
(println " Install: brew install ripgrep")
57-
(println)))
58-
{:bb bb-ok :rg rg-ok}))
56+
(let [os (str/lower-case (System/getProperty "os.name"))
57+
mac? (str/includes? os "mac")
58+
win? (str/includes? os "windows")
59+
brew? (= 0 (:exit (sh "which" "brew")))
60+
apt? (= 0 (:exit (sh "which" "apt-get")))
61+
dnf? (= 0 (:exit (sh "which" "dnf")))
62+
opkg? (= 0 (:exit (sh "which" "opkg")))
63+
install-cmd (cond
64+
(and mac? brew?) ["brew" "install" "ripgrep"]
65+
apt? ["sudo" "apt-get" "install" "-y" "ripgrep"]
66+
dnf? ["sudo" "dnf" "install" "-y" "ripgrep"]
67+
opkg? ["opkg" "install" "ripgrep"]
68+
win? nil
69+
:else nil)]
70+
(warn "ripgrep (rg) not found — spai will use grep (slower)")
71+
(if install-cmd
72+
(do (print (str " Install now? (" (str/join " " install-cmd) ") [Y/n] "))
73+
(flush)
74+
(let [ans (str/trim (or (read-line) ""))]
75+
(when (or (= "" ans) (= "y" (str/lower-case ans)))
76+
(info "Installing ripgrep...")
77+
(let [r (apply sh install-cmd)]
78+
(if (zero? (:exit r))
79+
(info "ripgrep installed.")
80+
(do (warn (str "Install failed — run manually: " (str/join " " install-cmd)))
81+
(println (:err r))))))))
82+
(do (println (if win?
83+
" Install: winget install BurntSushi.ripgrep.MSVC"
84+
" Install: https://github.com/BurntSushi/ripgrep#installation"))
85+
(println)))))
86+
(if ollama-ok
87+
(do (info (str "ollama " (str/trim (:out (sh "ollama" "--version")))))
88+
(let [list-out (:out (sh "ollama" "list"))
89+
has-model? (str/includes? list-out "qwen2.5-coder:7b")]
90+
(when-not has-model?
91+
(when (ask "Pull qwen2.5-coder:7b for spai search? (2.2GB) [optional]" :y)
92+
(info "Pulling qwen2.5-coder:7b...")
93+
(let [r (sh "ollama" "pull" "qwen2.5-coder:7b")]
94+
(if (zero? (:exit r))
95+
(info "qwen2.5-coder:7b pulled.")
96+
(do (warn "Pull failed — run manually: ollama pull qwen2.5-coder:7b")
97+
(println (:err r)))))))))
98+
(let [os (str/lower-case (System/getProperty "os.name"))
99+
mac? (str/includes? os "mac")
100+
brew? (= 0 (:exit (sh "which" "brew")))
101+
install-cmd (cond
102+
(and mac? brew?) ["brew" "install" "ollama"]
103+
:else nil)]
104+
(warn "ollama not found — spai search will not be available [optional]")
105+
(if install-cmd
106+
(do (print (str " Install now? (" (str/join " " install-cmd) ") [Y/n] "))
107+
(flush)
108+
(let [ans (str/trim (or (read-line) ""))]
109+
(when (or (= "" ans) (= "y" (str/lower-case ans)))
110+
(info "Installing ollama...")
111+
(let [r (apply sh install-cmd)]
112+
(if (zero? (:exit r))
113+
(info "ollama installed.")
114+
(do (warn (str "Install failed — run manually: " (str/join " " install-cmd)))
115+
(println (:err r))))))))
116+
(do (println " Install: https://ollama.ai/download")
117+
(println)))))
118+
{:bb bb-ok :rg rg-ok :ollama ollama-ok}))
59119

60120
;; --- PATH ---
61121

@@ -176,13 +236,65 @@
176236
(println (str " spai setup --claude-hooks"))
177237
(println (str " Or manually: cp " hook-src " " hook-dst))))))
178238

239+
;; --- MCP server ---
240+
241+
(def mcp-script (str share-dir "/spai-mcp.bb"))
242+
243+
(defn find-bb []
244+
(let [candidates [(str home "/.local/bin/bb") "/usr/local/bin/bb" "/opt/homebrew/bin/bb"]
245+
on-path (let [r (sh "which" "bb")] (when (zero? (:exit r)) (str/trim (:out r))))]
246+
(or on-path
247+
(first (filter #(.exists (io/file %)) candidates)))))
248+
249+
(defn has-spai-mcp? [settings]
250+
(some? (get-in settings [:mcpServers :spai])))
251+
252+
(defn install-mcp []
253+
(let [bb (find-bb)]
254+
(if-not bb
255+
(do (warn "bb not found — cannot register MCP server")
256+
(println " Install babashka first, then re-run: spai setup"))
257+
(let [settings (if (.exists (io/file settings-file))
258+
(json/parse-string (slurp settings-file) true)
259+
{})]
260+
(if (has-spai-mcp? settings)
261+
(info "spai MCP server already registered")
262+
(let [updated (assoc-in settings [:mcpServers :spai]
263+
{:command bb :args [mcp-script]})]
264+
(spit settings-file (json/generate-string updated {:pretty true}))
265+
(info "spai MCP server registered (restart Claude Code to activate)")))))))
266+
267+
(defn setup-mcp [flags]
268+
(when (.isDirectory (io/file claude-dir))
269+
(when-not (.exists (io/file mcp-script))
270+
(warn (str "spai-mcp.bb not found at " mcp-script " — skipping MCP setup"))
271+
(System/exit 0))
272+
(cond
273+
(contains? flags "--mcp")
274+
(install-mcp)
275+
276+
interactive?
277+
(do (println)
278+
(info "spai MCP server available!")
279+
(println " Exposes spai tools (memory, shape, blast, etc.) natively in Claude Code.")
280+
(println " Claude sees them as first-class tools, not shell commands.")
281+
(println)
282+
(when (ask "Register spai MCP server?" :y)
283+
(install-mcp)))
284+
285+
:else
286+
(do (println)
287+
(info "To register spai as an MCP server:")
288+
(println " spai setup --mcp")))))
289+
179290
;; --- Main ---
180291

181292
(defn -main [& args]
182293
(let [flags (set args)]
183294
(check-deps)
184295
(ensure-path)
185296
(setup-claude-hook flags)
297+
(setup-mcp flags)
186298
(println)
187299
(info "Setup complete!")
188300
(println " spai help")

0 commit comments

Comments
 (0)