@balpal4495/quorum 3.4.0 → 3.6.0
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/README.md +111 -1
- package/bin/__tests__/mcp-server.test.js +5 -4
- package/bin/__tests__/mcp-tools.test.js +13 -13
- package/bin/commands/llm.js +108 -0
- package/bin/commands/serve.js +23 -4
- package/bin/mcp/server.js +36 -2
- package/bin/mcp/tools.js +80 -15
- package/bin/quorum.js +8 -0
- package/bin/shared/llm.js +244 -9
- package/bin/ui/app.html +416 -3
- package/dist/oracle/adapters/lance-db.d.ts.map +1 -1
- package/dist/oracle/adapters/lance-db.js +2 -1
- package/dist/oracle/adapters/lance-db.js.map +1 -1
- package/package.json +1 -1
package/bin/shared/llm.js
CHANGED
|
@@ -11,10 +11,12 @@ const execAsync = promisify(exec)
|
|
|
11
11
|
* Detection order (highest priority first):
|
|
12
12
|
* 1. ANTHROPIC_API_KEY → Anthropic Claude
|
|
13
13
|
* 2. OPENAI_API_KEY → OpenAI (or compatible via OPENAI_BASE_URL)
|
|
14
|
-
* 3.
|
|
15
|
-
* 4.
|
|
16
|
-
* 5.
|
|
17
|
-
* 6.
|
|
14
|
+
* 3. OPENAI_BASE_URL (no key) → OpenAI-compatible endpoint (Azure, Groq, etc.)
|
|
15
|
+
* 4. GEMINI_API_KEY → Google Gemini (API)
|
|
16
|
+
* 5. claude CLI in PATH → Claude Code (CLI subprocess, --print mode)
|
|
17
|
+
* 6. copilot CLI in PATH → GitHub Copilot CLI (-p / --prompt mode)
|
|
18
|
+
* 7. gemini CLI in PATH → Google Gemini (CLI subprocess)
|
|
19
|
+
* 8. OLLAMA_HOST / localhost:11434 → Ollama (last resort — local models)
|
|
18
20
|
*
|
|
19
21
|
* All detected providers are tried in order. A 429 / quota / rate-limit error
|
|
20
22
|
* from one provider causes a silent fallback to the next rather than a hard
|
|
@@ -39,8 +41,10 @@ export async function detectProvider() {
|
|
|
39
41
|
*/
|
|
40
42
|
async function gatherCandidates() {
|
|
41
43
|
// Probe async sources concurrently
|
|
42
|
-
const [ollamaModel, geminiCLIAvail] = await Promise.all([
|
|
44
|
+
const [ollamaModel, claudeCLIAvail, copilotCLIAvail, geminiCLIAvail] = await Promise.all([
|
|
43
45
|
probeOllama(process.env.OLLAMA_HOST || "http://localhost:11434"),
|
|
46
|
+
probeClaudeCLI(),
|
|
47
|
+
probeCopilotCLI(),
|
|
44
48
|
probeGeminiCLI(),
|
|
45
49
|
])
|
|
46
50
|
|
|
@@ -77,11 +81,17 @@ async function gatherCandidates() {
|
|
|
77
81
|
})
|
|
78
82
|
}
|
|
79
83
|
|
|
80
|
-
if (
|
|
81
|
-
const host = process.env.OLLAMA_HOST || "http://localhost:11434"
|
|
84
|
+
if (claudeCLIAvail) {
|
|
82
85
|
candidates.push({
|
|
83
|
-
llm:
|
|
84
|
-
name:
|
|
86
|
+
llm: createClaudeCLIProvider(),
|
|
87
|
+
name: "Claude Code CLI",
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (copilotCLIAvail) {
|
|
92
|
+
candidates.push({
|
|
93
|
+
llm: createCopilotCLIProvider(),
|
|
94
|
+
name: "Copilot CLI",
|
|
85
95
|
})
|
|
86
96
|
}
|
|
87
97
|
|
|
@@ -92,6 +102,15 @@ async function gatherCandidates() {
|
|
|
92
102
|
})
|
|
93
103
|
}
|
|
94
104
|
|
|
105
|
+
// Ollama is last — local models are the slowest fallback
|
|
106
|
+
if (ollamaModel) {
|
|
107
|
+
const host = process.env.OLLAMA_HOST || "http://localhost:11434"
|
|
108
|
+
candidates.push({
|
|
109
|
+
llm: createOpenAICompatProvider("", `${host}/v1`, ollamaModel),
|
|
110
|
+
name: `Ollama (${ollamaModel})`,
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
|
|
95
114
|
return candidates
|
|
96
115
|
}
|
|
97
116
|
|
|
@@ -139,6 +158,113 @@ export async function detectLLMName() {
|
|
|
139
158
|
return (await detectProvider())?.name ?? null
|
|
140
159
|
}
|
|
141
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Probe all providers and return a priority-ordered status array suitable for
|
|
163
|
+
* display (e.g. quorum llm). Each entry:
|
|
164
|
+
* { id, name, detected: bool, note: string|null }
|
|
165
|
+
* 'detected' = true → provider is ready to use right now
|
|
166
|
+
* 'note' → setup hint when not detected, or detail when detected
|
|
167
|
+
*/
|
|
168
|
+
export async function probeAll() {
|
|
169
|
+
// Run async probes in parallel
|
|
170
|
+
const [ollamaModel, claudeDetail, copilotDetail, geminiDetail] = await Promise.all([
|
|
171
|
+
probeOllama(process.env.OLLAMA_HOST || "http://localhost:11434"),
|
|
172
|
+
probeCliDetail("claude", async () => {
|
|
173
|
+
const { platform } = await import("os")
|
|
174
|
+
if (platform() === "darwin") {
|
|
175
|
+
await execAsync("security find-generic-password -s 'Claude Code-credentials' 2>/dev/null")
|
|
176
|
+
return true
|
|
177
|
+
}
|
|
178
|
+
const { readdir } = await import("fs/promises")
|
|
179
|
+
const { homedir } = await import("os")
|
|
180
|
+
const sessions = await readdir(path.join(homedir(), ".claude", "sessions")).catch(() => [])
|
|
181
|
+
return sessions.length > 0
|
|
182
|
+
}),
|
|
183
|
+
probeCliDetail("copilot", async () => {
|
|
184
|
+
const { readdir } = await import("fs/promises")
|
|
185
|
+
const { homedir } = await import("os")
|
|
186
|
+
const sessions = await readdir(path.join(homedir(), ".copilot", "session-state")).catch(() => [])
|
|
187
|
+
return sessions.length > 0
|
|
188
|
+
}),
|
|
189
|
+
probeCliDetail("gemini", async () => {
|
|
190
|
+
if (process.env.GOOGLE_GENAI_USE_VERTEXAI || process.env.GOOGLE_APPLICATION_CREDENTIALS) return true
|
|
191
|
+
const { readFile } = await import("fs/promises")
|
|
192
|
+
const { homedir } = await import("os")
|
|
193
|
+
const raw = await readFile(path.join(homedir(), ".gemini", "settings.json"), "utf8")
|
|
194
|
+
return !!JSON.parse(raw).selectedAuthType
|
|
195
|
+
}),
|
|
196
|
+
])
|
|
197
|
+
|
|
198
|
+
const hasOpenAIKey = !!process.env.OPENAI_API_KEY
|
|
199
|
+
const hasOpenAIBase = !!process.env.OPENAI_BASE_URL
|
|
200
|
+
let openAIName = "OpenAI API"
|
|
201
|
+
if (hasOpenAIBase && !hasOpenAIKey) {
|
|
202
|
+
try { openAIName = `OpenAI-compatible (${new URL(process.env.OPENAI_BASE_URL).hostname})` } catch { /**/ }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return [
|
|
206
|
+
{
|
|
207
|
+
id: "anthropic",
|
|
208
|
+
name: "Anthropic API",
|
|
209
|
+
detected: !!process.env.ANTHROPIC_API_KEY,
|
|
210
|
+
note: process.env.ANTHROPIC_API_KEY ? null : "export ANTHROPIC_API_KEY=sk-ant-…",
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
id: "openai",
|
|
214
|
+
name: openAIName,
|
|
215
|
+
detected: hasOpenAIKey || hasOpenAIBase,
|
|
216
|
+
note: hasOpenAIKey || hasOpenAIBase ? null : "export OPENAI_API_KEY=sk-…",
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
id: "gemini-api",
|
|
220
|
+
name: "Gemini API",
|
|
221
|
+
detected: !!process.env.GEMINI_API_KEY,
|
|
222
|
+
note: process.env.GEMINI_API_KEY ? null : "export GEMINI_API_KEY=…",
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
id: "claude-cli",
|
|
226
|
+
name: "Claude Code CLI",
|
|
227
|
+
detected: claudeDetail.authed,
|
|
228
|
+
note: claudeDetail.authed ? null
|
|
229
|
+
: claudeDetail.inPath ? "found in PATH but not signed in — run: claude"
|
|
230
|
+
: "install from claude.ai/code, then sign in once",
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
id: "copilot-cli",
|
|
234
|
+
name: "Copilot CLI",
|
|
235
|
+
detected: copilotDetail.authed,
|
|
236
|
+
note: copilotDetail.authed ? null
|
|
237
|
+
: copilotDetail.inPath ? "found but no session — open VS Code and sign into Copilot Chat"
|
|
238
|
+
: "install VS Code + GitHub Copilot Chat extension",
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
id: "gemini-cli",
|
|
242
|
+
name: "Gemini CLI",
|
|
243
|
+
detected: geminiDetail.authed,
|
|
244
|
+
note: geminiDetail.authed ? null
|
|
245
|
+
: geminiDetail.inPath ? "found but not authenticated — run: gemini auth"
|
|
246
|
+
: "npm install -g @google/gemini-cli, then: gemini auth",
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
id: "ollama",
|
|
250
|
+
name: "Ollama",
|
|
251
|
+
detected: !!ollamaModel,
|
|
252
|
+
note: ollamaModel ?? "run: ollama serve (then: ollama pull llama3.2)",
|
|
253
|
+
},
|
|
254
|
+
]
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Probe a CLI binary — returns { inPath, authed }.
|
|
259
|
+
* authCheck is called only when the binary is found; errors are treated as "not authed".
|
|
260
|
+
*/
|
|
261
|
+
async function probeCliDetail(binary, authCheck) {
|
|
262
|
+
let inPath = false
|
|
263
|
+
try { await execAsync(`which ${binary}`); inPath = true } catch { /**/ }
|
|
264
|
+
if (!inPath) return { inPath: false, authed: false }
|
|
265
|
+
try { const authed = await authCheck(); return { inPath: true, authed: !!authed } } catch { return { inPath: true, authed: false } }
|
|
266
|
+
}
|
|
267
|
+
|
|
142
268
|
// ── Probe Ollama ───────────────────────────────────────────────────────────────
|
|
143
269
|
|
|
144
270
|
async function probeOllama(host) {
|
|
@@ -236,6 +362,115 @@ function createGeminiProvider(apiKey) {
|
|
|
236
362
|
}
|
|
237
363
|
}
|
|
238
364
|
|
|
365
|
+
async function probeClaudeCLI() {
|
|
366
|
+
// Must be in PATH
|
|
367
|
+
try {
|
|
368
|
+
await execAsync("which claude")
|
|
369
|
+
} catch {
|
|
370
|
+
return false
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Must have an active session — Claude Code stores OAuth tokens in the macOS keychain.
|
|
374
|
+
// A quick smoke-test is cheaper than shelling out; check the keychain entry exists.
|
|
375
|
+
// On non-macOS, fall back to checking ~/.claude/sessions/ for any session file.
|
|
376
|
+
try {
|
|
377
|
+
const { platform } = await import("os")
|
|
378
|
+
if (platform() === "darwin") {
|
|
379
|
+
await execAsync("security find-generic-password -s 'Claude Code-credentials' 2>/dev/null")
|
|
380
|
+
return true
|
|
381
|
+
}
|
|
382
|
+
// Non-macOS: check for at least one session file
|
|
383
|
+
const { readdir } = await import("fs/promises")
|
|
384
|
+
const { homedir } = await import("os")
|
|
385
|
+
const sessions = await readdir(path.join(homedir(), ".claude", "sessions")).catch(() => [])
|
|
386
|
+
return sessions.length > 0
|
|
387
|
+
} catch {
|
|
388
|
+
return false
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function createClaudeCLIProvider() {
|
|
393
|
+
return function llm(messages) {
|
|
394
|
+
return new Promise((resolve, reject) => {
|
|
395
|
+
const system = messages.find(m => m.role === "system")?.content ?? ""
|
|
396
|
+
const userContent = messages.filter(m => m.role !== "system").map(m => m.content).join("\n\n")
|
|
397
|
+
|
|
398
|
+
// Combine system + user into one prompt piped via stdin; --print for non-interactive output.
|
|
399
|
+
// Do NOT use --bare — that disables keychain OAuth and requires ANTHROPIC_API_KEY instead.
|
|
400
|
+
const fullPrompt = system ? `${system}\n\n${userContent}` : userContent
|
|
401
|
+
const child = spawn("claude", ["--print", "--output-format", "text"], { stdio: ["pipe", "pipe", "pipe"] })
|
|
402
|
+
|
|
403
|
+
let out = "", err = ""
|
|
404
|
+
child.stdout.on("data", d => { out += d })
|
|
405
|
+
child.stderr.on("data", d => { err += d })
|
|
406
|
+
child.on("error", reject)
|
|
407
|
+
child.on("close", code => {
|
|
408
|
+
if (code === 0) resolve(out.trim())
|
|
409
|
+
else reject(new Error(`claude CLI exited ${code}: ${err.slice(0, 200)}`))
|
|
410
|
+
})
|
|
411
|
+
child.stdin.write(fullPrompt)
|
|
412
|
+
child.stdin.end()
|
|
413
|
+
})
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async function probeCopilotCLI() {
|
|
418
|
+
// Must be in PATH (VS Code installs a shell wrapper into PATH automatically)
|
|
419
|
+
try {
|
|
420
|
+
await execAsync("which copilot")
|
|
421
|
+
} catch {
|
|
422
|
+
return false
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Must have at least one session in ~/.copilot/session-state/ — created after first auth
|
|
426
|
+
try {
|
|
427
|
+
const { readdir } = await import("fs/promises")
|
|
428
|
+
const { homedir } = await import("os")
|
|
429
|
+
const sessions = await readdir(path.join(homedir(), ".copilot", "session-state")).catch(() => [])
|
|
430
|
+
return sessions.length > 0
|
|
431
|
+
} catch {
|
|
432
|
+
return false
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function createCopilotCLIProvider() {
|
|
437
|
+
return function llm(messages) {
|
|
438
|
+
return new Promise((resolve, reject) => {
|
|
439
|
+
const system = messages.find(m => m.role === "system")?.content ?? ""
|
|
440
|
+
const userContent = messages.filter(m => m.role !== "system").map(m => m.content).join("\n\n")
|
|
441
|
+
const fullPrompt = system ? `${system}\n\n${userContent}` : userContent
|
|
442
|
+
|
|
443
|
+
// Use --output-format json so we can parse the JSONL event stream reliably
|
|
444
|
+
const child = spawn("copilot", ["-p", fullPrompt, "--output-format", "json"], {
|
|
445
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
let out = "", err = ""
|
|
449
|
+
child.stdout.on("data", d => { out += d })
|
|
450
|
+
child.stderr.on("data", d => { err += d })
|
|
451
|
+
child.on("error", reject)
|
|
452
|
+
child.on("close", code => {
|
|
453
|
+
if (code !== 0) {
|
|
454
|
+
return reject(new Error(`copilot CLI exited ${code}: ${err.slice(0, 200)}`))
|
|
455
|
+
}
|
|
456
|
+
// Parse JSONL stream — find the assistant.message event
|
|
457
|
+
const content = out.split("\n")
|
|
458
|
+
.filter(Boolean)
|
|
459
|
+
.reduce((found, line) => {
|
|
460
|
+
if (found) return found
|
|
461
|
+
try {
|
|
462
|
+
const evt = JSON.parse(line)
|
|
463
|
+
if (evt.type === "assistant.message" && evt.data?.content) return evt.data.content
|
|
464
|
+
} catch { /* skip non-JSON lines */ }
|
|
465
|
+
return null
|
|
466
|
+
}, null)
|
|
467
|
+
if (content == null) return reject(new Error("copilot CLI: no assistant.message event in output"))
|
|
468
|
+
resolve(content)
|
|
469
|
+
})
|
|
470
|
+
})
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
239
474
|
async function probeGeminiCLI() {
|
|
240
475
|
try {
|
|
241
476
|
await execAsync("which gemini")
|