@balpal4495/quorum 3.5.0 → 3.7.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 +74 -1
- package/bin/commands/compass.js +9 -3
- package/bin/commands/llm.js +108 -0
- package/bin/mcp/tools.js +10 -6
- package/bin/quorum.js +8 -0
- package/bin/shared/llm.js +244 -9
- package/bin/ui/app.html +243 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -205,6 +205,79 @@ quorum advisor brief
|
|
|
205
205
|
|
|
206
206
|
---
|
|
207
207
|
|
|
208
|
+
## LLM setup
|
|
209
|
+
|
|
210
|
+
LLM-powered commands (`advisor`, `evolve`, `check`, `compass`, `serve`) auto-detect whichever provider is available. No config file is needed — Quorum picks the first working option from the list below.
|
|
211
|
+
|
|
212
|
+
**Recommended: use a CLI tool you're already logged into.** No API key required.
|
|
213
|
+
|
|
214
|
+
### Option 1 — Claude Code CLI (recommended)
|
|
215
|
+
|
|
216
|
+
If you have [Claude Code](https://claude.ai/code) installed and are logged in, Quorum uses it automatically.
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
# Verify it's working
|
|
220
|
+
echo "say hi" | claude --print
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Quorum detects the `Claude Code-credentials` keychain entry (macOS) or a session file in `~/.claude/sessions/`. If the command above works, Quorum will use it.
|
|
224
|
+
|
|
225
|
+
### Option 2 — GitHub Copilot CLI
|
|
226
|
+
|
|
227
|
+
If you have the [GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/use-copilot-cli) installed and are logged in via VS Code, Quorum uses it automatically.
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
# Verify it's working
|
|
231
|
+
copilot -p "say hi"
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Quorum detects `~/.copilot/session-state/` having at least one session (created after first auth). If the command above works, Quorum will use it.
|
|
235
|
+
|
|
236
|
+
### Option 3 — API keys
|
|
237
|
+
|
|
238
|
+
Set any one of these environment variables:
|
|
239
|
+
|
|
240
|
+
| Variable | Provider |
|
|
241
|
+
|---|---|
|
|
242
|
+
| `ANTHROPIC_API_KEY` | Anthropic Claude |
|
|
243
|
+
| `OPENAI_API_KEY` | OpenAI |
|
|
244
|
+
| `GEMINI_API_KEY` | Google Gemini |
|
|
245
|
+
| `OPENAI_BASE_URL` | Any OpenAI-compatible endpoint (Azure, Groq, etc.) |
|
|
246
|
+
|
|
247
|
+
```bash
|
|
248
|
+
export ANTHROPIC_API_KEY=sk-ant-...
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
### Option 4 — Gemini CLI
|
|
252
|
+
|
|
253
|
+
If you have the [Gemini CLI](https://github.com/google-gemini/gemini-cli) installed and authenticated, Quorum uses it automatically.
|
|
254
|
+
|
|
255
|
+
```bash
|
|
256
|
+
# Verify it's working
|
|
257
|
+
gemini -p "say hi"
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
### Option 5 — Ollama (local, last resort)
|
|
261
|
+
|
|
262
|
+
If Ollama is running at `localhost:11434`, Quorum uses the first available model. Set `OLLAMA_MODEL` to pin a specific model, or `OLLAMA_HOST` for a non-default address.
|
|
263
|
+
|
|
264
|
+
```bash
|
|
265
|
+
ollama serve
|
|
266
|
+
export OLLAMA_MODEL=llama3.2 # optional
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
### Checking what was detected
|
|
270
|
+
|
|
271
|
+
```bash
|
|
272
|
+
quorum serve # startup line shows: LLM: Claude Code CLI
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
### No LLM — still useful
|
|
276
|
+
|
|
277
|
+
Without any provider, `advisor query`, `advisor brief`, `check`, and `coverage` all work with no LLM. Commands output Chronicle evidence and a synthesis request that your agent (Claude Code, Copilot, Codex) can answer inline.
|
|
278
|
+
|
|
279
|
+
---
|
|
280
|
+
|
|
208
281
|
## Upgrading from v1
|
|
209
282
|
|
|
210
283
|
If your project has a `quorum/modules/` folder (the v1 vendored pattern), migrate in one step:
|
|
@@ -602,7 +675,7 @@ Refuted entries always elevate risk by at least one level. Citation validation s
|
|
|
602
675
|
|
|
603
676
|
### LLM auto-detection
|
|
604
677
|
|
|
605
|
-
Quorum
|
|
678
|
+
Quorum tries providers in this order: **Claude Code CLI** → **Copilot CLI** → `ANTHROPIC_API_KEY` → `OPENAI_API_KEY` → `GEMINI_API_KEY` → `OPENAI_BASE_URL` → Gemini CLI → Ollama. See [LLM setup](#llm-setup) for how to configure each option.
|
|
606
679
|
|
|
607
680
|
---
|
|
608
681
|
|
package/bin/commands/compass.js
CHANGED
|
@@ -753,7 +753,13 @@ async function loadLastArtifact(chronicleDir) {
|
|
|
753
753
|
|
|
754
754
|
// ── Main ─────────────────────────────────────────────────────────────────────
|
|
755
755
|
|
|
756
|
-
|
|
756
|
+
/**
|
|
757
|
+
* @param {string[]} argv
|
|
758
|
+
* @param {Function|null} [injectedLlm] - Pre-detected LLM provider. When
|
|
759
|
+
* supplied, provider detection is skipped entirely (avoids the ~1.5 s Ollama
|
|
760
|
+
* probe on every MCP/serve request).
|
|
761
|
+
*/
|
|
762
|
+
export async function run(argv, injectedLlm) {
|
|
757
763
|
const [subcommand, ...rest] = argv
|
|
758
764
|
|
|
759
765
|
if (!subcommand || subcommand === "--help" || subcommand === "-h") {
|
|
@@ -794,8 +800,8 @@ export async function run(argv) {
|
|
|
794
800
|
}
|
|
795
801
|
|
|
796
802
|
const NO_LLM_CMDS = new Set(["map", "opportunities", "behavior", "propose", "outcome"])
|
|
797
|
-
const
|
|
798
|
-
|
|
803
|
+
const llm = injectedLlm
|
|
804
|
+
?? (NO_LLM_CMDS.has(subcommand) ? null : (await detectProvider())?.llm)
|
|
799
805
|
|
|
800
806
|
// ── Shared context helper ─────────────────────────────────────────────────
|
|
801
807
|
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { probeAll, detectProvider } from "../shared/llm.js"
|
|
2
|
+
import { c } from "../shared/colors.js"
|
|
3
|
+
|
|
4
|
+
function row(detected, name, note, suffix = "") {
|
|
5
|
+
const icon = detected ? c.green("✓") : c.dim("·")
|
|
6
|
+
const label = detected ? c.bold(name) : c.dim(name)
|
|
7
|
+
const right = detected
|
|
8
|
+
? (note ? c.dim(` ${note}`) : "") + (suffix ? ` ${suffix}` : "")
|
|
9
|
+
: (note ? ` ${c.dim(note)}` : "")
|
|
10
|
+
return ` ${icon} ${label.padEnd(26)}${right}`
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function run(args) {
|
|
14
|
+
const test = args.includes("--test")
|
|
15
|
+
|
|
16
|
+
console.log("")
|
|
17
|
+
console.log(`${c.bold("quorum llm")} ${c.dim("— LLM provider status")}`)
|
|
18
|
+
console.log("")
|
|
19
|
+
|
|
20
|
+
// Run all probes in parallel with active provider detection
|
|
21
|
+
const [providers, active] = await Promise.all([probeAll(), detectProvider()])
|
|
22
|
+
const activeName = active?.name ?? null
|
|
23
|
+
|
|
24
|
+
console.log(" Provider scan")
|
|
25
|
+
console.log(c.dim(" ─────────────────────────────────────────────────────"))
|
|
26
|
+
|
|
27
|
+
// Normalize names for active matching (gatherCandidates uses "Gemini", probeAll uses "Gemini API")
|
|
28
|
+
const norm = n => n.replace(/ API$/, "").replace(/ \(.*?\)$/, "").toLowerCase()
|
|
29
|
+
|
|
30
|
+
for (const p of providers) {
|
|
31
|
+
const isActive = p.detected && !!activeName && norm(p.name) === norm(activeName)
|
|
32
|
+
|
|
33
|
+
const badges = []
|
|
34
|
+
if (isActive) badges.push(c.green("← active"))
|
|
35
|
+
if (p.detected && p.id === "ollama" && p.note) badges.push(c.dim(`(${p.note})`))
|
|
36
|
+
const suffix = badges.join(" ")
|
|
37
|
+
|
|
38
|
+
console.log(row(p.detected, p.name, null, suffix))
|
|
39
|
+
if (!p.detected && p.note) {
|
|
40
|
+
console.log(` ${c.dim(p.note)}`)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
console.log("")
|
|
45
|
+
|
|
46
|
+
if (!activeName) {
|
|
47
|
+
console.log(` ${c.yellow("No provider detected.")}`)
|
|
48
|
+
console.log("")
|
|
49
|
+
printSetupGuide()
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
console.log(` Active: ${c.bold(activeName)}`)
|
|
54
|
+
console.log("")
|
|
55
|
+
|
|
56
|
+
if (!test) {
|
|
57
|
+
console.log(c.dim(" Run 'quorum llm --test' to send a live request and verify it works."))
|
|
58
|
+
console.log("")
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Live test ──────────────────────────────────────────────────────────────
|
|
63
|
+
process.stdout.write(` Testing ${c.bold(activeName)}… `)
|
|
64
|
+
const t0 = Date.now()
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const result = await active.llm([
|
|
68
|
+
{ role: "user", content: "Respond with exactly the word OK and nothing else." },
|
|
69
|
+
])
|
|
70
|
+
const ms = Date.now() - t0
|
|
71
|
+
const ok = /\bOK\b/i.test(result?.trim() ?? "")
|
|
72
|
+
if (ok) {
|
|
73
|
+
console.log(`${c.green("✓")} ${c.dim(`(${ms}ms)`)}`)
|
|
74
|
+
} else {
|
|
75
|
+
console.log(`${c.yellow("✓ (unexpected response)")} ${c.dim(`(${ms}ms)`)}`)
|
|
76
|
+
console.log(c.dim(` → ${(result ?? "").slice(0, 120)}`))
|
|
77
|
+
}
|
|
78
|
+
} catch (err) {
|
|
79
|
+
console.log(c.red("✗"))
|
|
80
|
+
console.log(` ${c.red(err.message?.slice(0, 200) ?? String(err))}`)
|
|
81
|
+
console.log("")
|
|
82
|
+
console.log(` ${c.yellow("The detected provider failed.")} Check that you're signed in, then retry.`)
|
|
83
|
+
}
|
|
84
|
+
console.log("")
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function printSetupGuide() {
|
|
88
|
+
console.log(" Quickest options:")
|
|
89
|
+
console.log("")
|
|
90
|
+
console.log(` ${c.bold("A")} ${c.bold("Claude Code CLI")} ${c.dim("(no API key needed)")}`)
|
|
91
|
+
console.log(c.dim(" Install and sign in: https://claude.ai/code"))
|
|
92
|
+
console.log(c.dim(" Quorum auto-detects it once you're signed in."))
|
|
93
|
+
console.log("")
|
|
94
|
+
console.log(` ${c.bold("B")} ${c.bold("GitHub Copilot CLI")} ${c.dim("(no API key needed)")}`)
|
|
95
|
+
console.log(c.dim(" Install VS Code + GitHub Copilot Chat extension, then sign in."))
|
|
96
|
+
console.log(c.dim(" Quorum auto-detects it once a session exists."))
|
|
97
|
+
console.log("")
|
|
98
|
+
console.log(` ${c.bold("C")} ${c.bold("API key")} ${c.dim("(Anthropic, OpenAI, or Gemini)")}`)
|
|
99
|
+
console.log(c.dim(" export ANTHROPIC_API_KEY=sk-ant-…"))
|
|
100
|
+
console.log(c.dim(" export OPENAI_API_KEY=sk-…"))
|
|
101
|
+
console.log(c.dim(" export GEMINI_API_KEY=…"))
|
|
102
|
+
console.log("")
|
|
103
|
+
console.log(` ${c.bold("D")} ${c.bold("Ollama")} ${c.dim("(local, free)")}`)
|
|
104
|
+
console.log(c.dim(" brew install ollama && ollama serve && ollama pull llama3.2"))
|
|
105
|
+
console.log("")
|
|
106
|
+
console.log(` ${c.dim("After setup, run 'quorum llm' again to confirm detection.")}`)
|
|
107
|
+
console.log("")
|
|
108
|
+
}
|
package/bin/mcp/tools.js
CHANGED
|
@@ -304,22 +304,26 @@ export async function toolCheck({ outcome, design, projectRoot } = {}) {
|
|
|
304
304
|
export async function toolCompass({ subcommand = "brief", goal, idea, projectRoot } = {}) {
|
|
305
305
|
if (!_llm) return NO_LLM("quorum_compass")
|
|
306
306
|
|
|
307
|
-
const { chronicleDir } = await resolve(projectRoot)
|
|
308
|
-
// Delegate to the compass CLI command handler for now
|
|
309
307
|
const { run: compassRun } = await import("../commands/compass.js")
|
|
310
|
-
|
|
308
|
+
|
|
309
|
+
// Capture stdout — always request JSON so there are no ANSI codes
|
|
311
310
|
const captured = []
|
|
312
311
|
const origWrite = process.stdout.write.bind(process.stdout)
|
|
313
312
|
process.stdout.write = (chunk, ...rest) => { captured.push(String(chunk)); return true }
|
|
314
313
|
try {
|
|
315
|
-
const extraArgs = []
|
|
314
|
+
const extraArgs = ["--json"]
|
|
316
315
|
if (subcommand === "pathways" && goal) extraArgs.push("--goal", goal)
|
|
317
316
|
if (subcommand === "score" && idea) extraArgs.push("--idea", idea)
|
|
318
|
-
|
|
317
|
+
// Pass _llm directly to skip the ~1.5 s provider re-detection on every request
|
|
318
|
+
await compassRun([subcommand, ...extraArgs], _llm)
|
|
319
319
|
} finally {
|
|
320
320
|
process.stdout.write = origWrite
|
|
321
321
|
}
|
|
322
|
-
|
|
322
|
+
|
|
323
|
+
const raw = captured.join("").trim()
|
|
324
|
+
let data = null
|
|
325
|
+
try { data = JSON.parse(raw) } catch { /* fallback to raw string below */ }
|
|
326
|
+
return { subcommand, data, output: data ? null : raw }
|
|
323
327
|
}
|
|
324
328
|
|
|
325
329
|
/**
|
package/bin/quorum.js
CHANGED
|
@@ -23,6 +23,8 @@ ${c.bold("Usage:")}
|
|
|
23
23
|
${c.cyan("quorum advisor")} ${c.dim('"question"')} Ask a plain-language question (uses LLM)
|
|
24
24
|
${c.cyan("quorum advisor query")} ${c.dim('"topic"')} Search Chronicle entries (no LLM)
|
|
25
25
|
${c.cyan("quorum advisor brief")} High-level Chronicle summary (no LLM)
|
|
26
|
+
${c.cyan("quorum llm")} Show LLM provider status and setup guide
|
|
27
|
+
${c.cyan("quorum llm --test")} Send a live test request to the active provider
|
|
26
28
|
${c.cyan("quorum init")} Scaffold Quorum into a project
|
|
27
29
|
${c.cyan("quorum status")} Show Chronicle health and pending proposals
|
|
28
30
|
${c.cyan("quorum check")} --outcome <x> --design <y> Preflight + risk (no LLM)
|
|
@@ -116,6 +118,12 @@ async function cli() {
|
|
|
116
118
|
return
|
|
117
119
|
}
|
|
118
120
|
|
|
121
|
+
if (command === "llm") {
|
|
122
|
+
const { run } = await import(path.join(__dirname, "commands/llm.js"))
|
|
123
|
+
await run(rest)
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
|
|
119
127
|
if (command === "init") {
|
|
120
128
|
const { run } = await import(path.join(__dirname, "commands/init.js"))
|
|
121
129
|
await run(PKG_VERSION)
|
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")
|
package/bin/ui/app.html
CHANGED
|
@@ -408,18 +408,149 @@
|
|
|
408
408
|
.hint-stalled { color: var(--red); }
|
|
409
409
|
|
|
410
410
|
/* ── Compass ── */
|
|
411
|
-
.
|
|
411
|
+
.cp-header {
|
|
412
|
+
display: flex;
|
|
413
|
+
align-items: baseline;
|
|
414
|
+
gap: 10px;
|
|
415
|
+
margin-bottom: 14px;
|
|
416
|
+
}
|
|
417
|
+
.cp-title {
|
|
418
|
+
font-size: 15px;
|
|
419
|
+
font-weight: 700;
|
|
420
|
+
color: var(--text);
|
|
421
|
+
}
|
|
422
|
+
.cp-badge {
|
|
423
|
+
font-size: 11px;
|
|
424
|
+
padding: 2px 8px;
|
|
425
|
+
border-radius: 20px;
|
|
426
|
+
font-weight: 600;
|
|
427
|
+
letter-spacing: .03em;
|
|
428
|
+
}
|
|
429
|
+
.cp-badge--hi { background: rgba(52,201,122,.15); color: var(--green); }
|
|
430
|
+
.cp-badge--mid { background: rgba(224,185,82,.15); color: var(--yellow); }
|
|
431
|
+
.cp-badge--lo { background: rgba(224,82,82,.15); color: var(--red); }
|
|
432
|
+
.cp-direction {
|
|
433
|
+
font-size: 14px;
|
|
434
|
+
font-weight: 500;
|
|
435
|
+
color: var(--text);
|
|
436
|
+
line-height: 1.6;
|
|
437
|
+
margin-bottom: 20px;
|
|
438
|
+
padding: 12px 14px;
|
|
439
|
+
background: rgba(124,110,255,.08);
|
|
440
|
+
border-left: 3px solid var(--accent);
|
|
441
|
+
border-radius: 0 var(--radius) var(--radius) 0;
|
|
442
|
+
}
|
|
443
|
+
.cp-section {
|
|
444
|
+
margin-bottom: 16px;
|
|
445
|
+
}
|
|
446
|
+
.cp-section-label {
|
|
447
|
+
font-size: 11px;
|
|
448
|
+
font-weight: 700;
|
|
449
|
+
text-transform: uppercase;
|
|
450
|
+
letter-spacing: .08em;
|
|
451
|
+
color: var(--muted);
|
|
452
|
+
margin-bottom: 6px;
|
|
453
|
+
}
|
|
454
|
+
.cp-list {
|
|
455
|
+
list-style: none;
|
|
456
|
+
display: flex;
|
|
457
|
+
flex-direction: column;
|
|
458
|
+
gap: 5px;
|
|
459
|
+
}
|
|
460
|
+
.cp-item {
|
|
461
|
+
display: flex;
|
|
462
|
+
gap: 8px;
|
|
463
|
+
font-size: 13px;
|
|
464
|
+
line-height: 1.5;
|
|
465
|
+
}
|
|
466
|
+
.cp-icon {
|
|
467
|
+
flex-shrink: 0;
|
|
468
|
+
width: 16px;
|
|
469
|
+
text-align: center;
|
|
470
|
+
margin-top: 1px;
|
|
471
|
+
}
|
|
472
|
+
.cp-known .cp-icon { color: var(--green); }
|
|
473
|
+
.cp-inferred .cp-icon { color: var(--yellow); }
|
|
474
|
+
.cp-unknown .cp-icon { color: var(--muted); }
|
|
475
|
+
.cp-next-step {
|
|
476
|
+
margin-top: 20px;
|
|
477
|
+
padding: 12px 14px;
|
|
412
478
|
background: var(--surface);
|
|
413
479
|
border: 1px solid var(--border);
|
|
414
480
|
border-radius: var(--radius);
|
|
415
|
-
|
|
416
|
-
|
|
481
|
+
display: flex;
|
|
482
|
+
flex-direction: column;
|
|
483
|
+
gap: 4px;
|
|
484
|
+
}
|
|
485
|
+
.cp-next-label {
|
|
486
|
+
font-size: 11px;
|
|
487
|
+
font-weight: 700;
|
|
488
|
+
text-transform: uppercase;
|
|
489
|
+
letter-spacing: .08em;
|
|
490
|
+
color: var(--muted);
|
|
491
|
+
}
|
|
492
|
+
.cp-next-text {
|
|
493
|
+
font-size: 13px;
|
|
494
|
+
color: var(--text);
|
|
495
|
+
line-height: 1.5;
|
|
496
|
+
}
|
|
497
|
+
/* map / bets / opportunities */
|
|
498
|
+
.cp-card {
|
|
499
|
+
background: var(--surface);
|
|
500
|
+
border: 1px solid var(--border);
|
|
501
|
+
border-radius: var(--radius);
|
|
502
|
+
padding: 14px;
|
|
503
|
+
margin-bottom: 10px;
|
|
504
|
+
}
|
|
505
|
+
.cp-card-header {
|
|
506
|
+
display: flex;
|
|
507
|
+
align-items: center;
|
|
508
|
+
gap: 10px;
|
|
509
|
+
margin-bottom: 6px;
|
|
510
|
+
}
|
|
511
|
+
.cp-card-title {
|
|
512
|
+
font-size: 13px;
|
|
513
|
+
font-weight: 600;
|
|
514
|
+
color: var(--text);
|
|
515
|
+
flex: 1;
|
|
516
|
+
}
|
|
517
|
+
.cp-card-score {
|
|
417
518
|
font-size: 12px;
|
|
418
|
-
|
|
519
|
+
font-weight: 700;
|
|
520
|
+
padding: 2px 8px;
|
|
521
|
+
border-radius: 20px;
|
|
522
|
+
}
|
|
523
|
+
.cp-score-hi { background: rgba(52,201,122,.15); color: var(--green); }
|
|
524
|
+
.cp-score-mid { background: rgba(224,185,82,.15); color: var(--yellow); }
|
|
525
|
+
.cp-score-lo { background: rgba(110,110,126,.15); color: var(--muted); }
|
|
526
|
+
.cp-card-body {
|
|
527
|
+
font-size: 13px;
|
|
419
528
|
color: var(--muted);
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
529
|
+
line-height: 1.5;
|
|
530
|
+
}
|
|
531
|
+
.cp-card-meta {
|
|
532
|
+
font-size: 12px;
|
|
533
|
+
color: var(--muted);
|
|
534
|
+
margin-top: 6px;
|
|
535
|
+
}
|
|
536
|
+
.cp-card-meta span { margin-right: 12px; }
|
|
537
|
+
.cp-area-tag {
|
|
538
|
+
display: inline-block;
|
|
539
|
+
font-size: 11px;
|
|
540
|
+
padding: 1px 6px;
|
|
541
|
+
border-radius: 4px;
|
|
542
|
+
background: rgba(82,168,224,.12);
|
|
543
|
+
color: var(--blue);
|
|
544
|
+
margin-right: 4px;
|
|
545
|
+
}
|
|
546
|
+
.cp-gap-item {
|
|
547
|
+
font-size: 13px;
|
|
548
|
+
color: var(--yellow);
|
|
549
|
+
padding: 8px 12px;
|
|
550
|
+
border-left: 2px solid var(--yellow);
|
|
551
|
+
margin-bottom: 6px;
|
|
552
|
+
background: rgba(224,185,82,.05);
|
|
553
|
+
border-radius: 0 var(--radius) var(--radius) 0;
|
|
423
554
|
}
|
|
424
555
|
|
|
425
556
|
/* ── Edit modal ── */
|
|
@@ -1072,17 +1203,117 @@ async function loadCompass() {
|
|
|
1072
1203
|
}
|
|
1073
1204
|
}
|
|
1074
1205
|
|
|
1206
|
+
function confidenceBadge(conf) {
|
|
1207
|
+
const pct = Math.round((conf ?? 0) * 100)
|
|
1208
|
+
const cls = pct >= 70 ? 'cp-badge--hi' : pct >= 45 ? 'cp-badge--mid' : 'cp-badge--lo'
|
|
1209
|
+
return `<span class="cp-badge ${cls}">${pct}% confidence</span>`
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
function cpList(items, cssClass, icon) {
|
|
1213
|
+
if (!items?.length) return ''
|
|
1214
|
+
return `<ul class="cp-list">${items.map(t => `<li class="cp-item ${cssClass}"><span class="cp-icon">${icon}</span><span>${esc(t)}</span></li>`).join('')}</ul>`
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
function scoreClass(total) {
|
|
1218
|
+
return total >= 75 ? 'cp-score-hi' : total >= 55 ? 'cp-score-mid' : 'cp-score-lo'
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
function renderBrief(d) {
|
|
1222
|
+
let html = `<div class="cp-header"><span class="cp-title">Direction Brief</span>${confidenceBadge(d.confidence)}</div>`
|
|
1223
|
+
if (d.product_direction) html += `<div class="cp-direction">${esc(d.product_direction)}</div>`
|
|
1224
|
+
|
|
1225
|
+
if (d.known_from_chronicle?.length) {
|
|
1226
|
+
html += `<div class="cp-section"><div class="cp-section-label">From Chronicle</div>${cpList(d.known_from_chronicle, 'cp-known', '✓')}</div>`
|
|
1227
|
+
}
|
|
1228
|
+
if (d.known_from_behavior?.length) {
|
|
1229
|
+
html += `<div class="cp-section"><div class="cp-section-label">From code / docs</div>${cpList(d.known_from_behavior, 'cp-known', '✓')}</div>`
|
|
1230
|
+
}
|
|
1231
|
+
if (d.inferred?.length) {
|
|
1232
|
+
html += `<div class="cp-section"><div class="cp-section-label">Inferred</div>${cpList(d.inferred, 'cp-inferred', '~')}</div>`
|
|
1233
|
+
}
|
|
1234
|
+
if (d.unknowns?.length) {
|
|
1235
|
+
html += `<div class="cp-section"><div class="cp-section-label">Unknowns</div>${cpList(d.unknowns, 'cp-unknown', '?')}</div>`
|
|
1236
|
+
}
|
|
1237
|
+
if (d.assumptions?.length) {
|
|
1238
|
+
html += `<div class="cp-section"><div class="cp-section-label">Assumptions</div>${cpList(d.assumptions, 'cp-unknown', '·')}</div>`
|
|
1239
|
+
}
|
|
1240
|
+
if (d.recommended_next_step) {
|
|
1241
|
+
html += `<div class="cp-next-step"><span class="cp-next-label">Next step</span><span class="cp-next-text">${esc(d.recommended_next_step)}</span></div>`
|
|
1242
|
+
}
|
|
1243
|
+
return html
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
function renderMap(d) {
|
|
1247
|
+
let html = `<div class="cp-header"><span class="cp-title">Behaviour Map</span>${confidenceBadge(d.confidence)}</div>`
|
|
1248
|
+
if (d.behaviors?.length) {
|
|
1249
|
+
html += `<div class="cp-section"><div class="cp-section-label">Behaviours (${d.behaviors.length})</div>`
|
|
1250
|
+
for (const b of d.behaviors.slice(0, 30)) {
|
|
1251
|
+
html += `<div class="cp-card" style="padding:10px 14px">`
|
|
1252
|
+
html += `<div class="cp-card-header"><span class="cp-card-title">${esc(b.name)}</span><span class="cp-area-tag">${esc(b.area)}</span></div>`
|
|
1253
|
+
html += `<div class="cp-card-body">${esc(b.current_behavior)}</div></div>`
|
|
1254
|
+
}
|
|
1255
|
+
html += `</div>`
|
|
1256
|
+
}
|
|
1257
|
+
if (d.gaps?.length) {
|
|
1258
|
+
html += `<div class="cp-section"><div class="cp-section-label">Gaps (${d.gaps.length})</div>`
|
|
1259
|
+
for (const g of d.gaps) html += `<div class="cp-gap-item">${esc(g.gap)}</div>`
|
|
1260
|
+
html += `</div>`
|
|
1261
|
+
}
|
|
1262
|
+
if (!d.behaviors?.length && !d.gaps?.length) html += `<div class="empty">No behaviours found in this project.</div>`
|
|
1263
|
+
return html
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
function renderBets(items) {
|
|
1267
|
+
if (!items?.length) return `<div class="empty">No bets generated.</div>`
|
|
1268
|
+
let html = `<div class="cp-header"><span class="cp-title">Strategic Bets</span></div>`
|
|
1269
|
+
for (const b of items) {
|
|
1270
|
+
const total = b.scores?.total ?? 0
|
|
1271
|
+
html += `<div class="cp-card">`
|
|
1272
|
+
html += `<div class="cp-card-header"><span class="cp-card-title">${esc(b.title)}</span><span class="cp-card-score ${scoreClass(total)}">${total}</span></div>`
|
|
1273
|
+
if (b.thesis) html += `<div class="cp-card-body">${esc(b.thesis)}</div>`
|
|
1274
|
+
if (b.first_experiment) html += `<div class="cp-card-meta"><span>First test: ${esc(b.first_experiment)}</span></div>`
|
|
1275
|
+
if (b.kill_criteria?.[0]) html += `<div class="cp-card-meta" style="color:var(--red)">Kill if: ${esc(b.kill_criteria[0])}</div>`
|
|
1276
|
+
if (b.assumptions?.length) html += `<div class="cp-card-meta">Assumes: ${esc(b.assumptions[0])}</div>`
|
|
1277
|
+
html += `</div>`
|
|
1278
|
+
}
|
|
1279
|
+
return html
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
function renderOpportunities(items) {
|
|
1283
|
+
if (!items?.length) return `<div class="empty">No gaps or opportunities found.</div>`
|
|
1284
|
+
let html = `<div class="cp-header"><span class="cp-title">Opportunities (${items.length})</span></div>`
|
|
1285
|
+
for (const o of items) {
|
|
1286
|
+
html += `<div class="cp-card">`
|
|
1287
|
+
html += `<div class="cp-card-header"><span class="cp-card-title">${esc(o.title ?? o.gap)}</span><span class="cp-area-tag">${esc(o.area)}</span></div>`
|
|
1288
|
+
if (o.why_it_matters) html += `<div class="cp-card-body">${esc(o.why_it_matters)}</div>`
|
|
1289
|
+
if (o.suggested_next_step) html += `<div class="cp-card-meta">Next: ${esc(o.suggested_next_step)}</div>`
|
|
1290
|
+
html += `</div>`
|
|
1291
|
+
}
|
|
1292
|
+
return html
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1075
1295
|
function renderCompass(data, subcommand) {
|
|
1076
1296
|
const el = document.getElementById("compassView")
|
|
1077
1297
|
if (data.status === "no-llm") {
|
|
1078
1298
|
el.innerHTML = `<div class="empty">${esc(data.message)}<small>Set ANTHROPIC_API_KEY, OPENAI_API_KEY, or GEMINI_API_KEY and restart quorum serve.</small></div>`
|
|
1079
1299
|
return
|
|
1080
1300
|
}
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1301
|
+
|
|
1302
|
+
const d = data.data // structured JSON from compass --json
|
|
1303
|
+
if (!d) {
|
|
1304
|
+
// fallback: raw text (shouldn't happen, but safe)
|
|
1305
|
+
el.innerHTML = `<pre style="font-size:12px;line-height:1.6;white-space:pre-wrap;color:var(--muted)">${esc(data.output ?? JSON.stringify(data, null, 2))}</pre>`
|
|
1306
|
+
return
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
let inner = ''
|
|
1310
|
+
if (subcommand === 'brief') inner = renderBrief(d)
|
|
1311
|
+
else if (subcommand === 'map') inner = renderMap(d)
|
|
1312
|
+
else if (subcommand === 'bets') inner = renderBets(Array.isArray(d) ? d : d.bets ?? [d])
|
|
1313
|
+
else if (subcommand === 'opportunities') inner = renderOpportunities(Array.isArray(d) ? d : [])
|
|
1314
|
+
else inner = `<pre style="font-size:12px;line-height:1.6;white-space:pre-wrap;color:var(--muted)">${esc(JSON.stringify(d, null, 2))}</pre>`
|
|
1315
|
+
|
|
1316
|
+
el.innerHTML = `<div style="padding-bottom:16px">${inner}</div>`
|
|
1086
1317
|
}
|
|
1087
1318
|
</script>
|
|
1088
1319
|
</body>
|