@balpal4495/quorum 0.4.2 → 1.0.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/CLAUDE.md +102 -42
- package/README.md +226 -176
- package/SETUP.md +14 -4
- package/bin/commands/advisor.js +301 -0
- package/bin/commands/commit.js +42 -52
- package/bin/commands/evolve.js +285 -0
- package/bin/commands/growth.js +139 -0
- package/bin/commands/sentinel.js +1 -1
- package/bin/quorum.js +28 -0
- package/bin/shared/llm.js +228 -0
- package/modules/AGENTS.md +8 -0
- package/modules/CLAUDE.md +8 -2
- package/modules/README.md +72 -13
- package/modules/advisor/ask.ts +87 -0
- package/modules/advisor/index.ts +2 -0
- package/modules/advisor/prompt.ts +50 -0
- package/modules/advisor/types.ts +26 -0
- package/modules/setup.ts +15 -0
- package/package.json +1 -1
package/SETUP.md
CHANGED
|
@@ -117,7 +117,7 @@ Append to it:
|
|
|
117
117
|
|
|
118
118
|
## Quorum modules
|
|
119
119
|
|
|
120
|
-
See [quorum/modules/AGENTS.md](quorum/modules/AGENTS.md) for Oracle, Jury, and
|
|
120
|
+
See [quorum/modules/AGENTS.md](quorum/modules/AGENTS.md) for Advisor, Oracle, Jury, Council, and Sentinel internals.
|
|
121
121
|
```
|
|
122
122
|
|
|
123
123
|
### 4c. `CLAUDE.md`
|
|
@@ -139,7 +139,7 @@ Append to it:
|
|
|
139
139
|
|
|
140
140
|
## Quorum modules
|
|
141
141
|
|
|
142
|
-
See [quorum/modules/CLAUDE.md](quorum/modules/CLAUDE.md) for Oracle, Jury, and
|
|
142
|
+
See [quorum/modules/CLAUDE.md](quorum/modules/CLAUDE.md) for Advisor, Oracle, Jury, Council, and Sentinel internals.
|
|
143
143
|
```
|
|
144
144
|
|
|
145
145
|
---
|
|
@@ -170,13 +170,15 @@ Add the following import and call at startup, **before** any agent or workflow c
|
|
|
170
170
|
```typescript
|
|
171
171
|
import { setup } from "./quorum/modules/setup"
|
|
172
172
|
|
|
173
|
-
const { oracle, evaluate, deliberate } = await setup({
|
|
173
|
+
const { oracle, evaluate, deliberate, ask } = await setup({
|
|
174
174
|
llm: yourLLMProvider, // replace with your project's LLM provider function
|
|
175
175
|
})
|
|
176
176
|
```
|
|
177
177
|
|
|
178
178
|
`setup()` creates `.chronicle/` directories, warms the embedder, and wires all module dependencies.
|
|
179
|
-
It must be called once before any `oracle.query()`, `evaluate()`, or `
|
|
179
|
+
It must be called once before any `oracle.query()`, `evaluate()`, `deliberate()`, or `ask()` call.
|
|
180
|
+
|
|
181
|
+
`ask(question)` is the plain-language interface — it queries Oracle automatically, synthesises Chronicle evidence into a concise answer, and retries internally until the answer meets a confidence threshold. Use it to answer questions rather than to evaluate designs.
|
|
180
182
|
|
|
181
183
|
If no entry point exists yet, note that `setup()` must be called before first use — do not inline it.
|
|
182
184
|
|
|
@@ -271,6 +273,7 @@ Gemini and use it for large-context tasks.
|
|
|
271
273
|
You are now operating under Quorum. The rules in `quorum/modules/AGENTS.md` and `.github/copilot-instructions.md` apply to all subsequent work.
|
|
272
274
|
|
|
273
275
|
Key reminders:
|
|
276
|
+
- **Ask Advisor for context.** `quorum advisor "what has the team decided about X?"` before starting any meaningful work.
|
|
274
277
|
- **Query Oracle before proposing anything.** `oracle.query("what you're about to do")` first.
|
|
275
278
|
- **Never call `oracle.commit()` autonomously.** Only `oracle.propose()`. A human commits.
|
|
276
279
|
- **Chronicle entries are ground truth.** Respect `refuted` entries — do not retry what has already failed.
|
|
@@ -281,10 +284,17 @@ These commands are available globally after `npm install -g @balpal4495/quorum`:
|
|
|
281
284
|
|
|
282
285
|
| Command | What it does |
|
|
283
286
|
|---|---|
|
|
287
|
+
| `quorum advisor "question"` | Ask a plain-language question — answer synthesised from Chronicle (needs LLM) |
|
|
288
|
+
| `quorum advisor query "topic"` | Search Chronicle entries by keyword (no LLM) |
|
|
289
|
+
| `quorum advisor brief` | High-level Chronicle summary (no LLM) |
|
|
284
290
|
| `quorum status` | Chronicle health — pending proposals, committed entries |
|
|
285
291
|
| `quorum check --outcome X --design Y` | Preflight + risk classifier (no LLM) |
|
|
286
292
|
| `quorum commit --list` | List pending proposals |
|
|
287
293
|
| `quorum commit <id>` | Approve and index a proposal |
|
|
288
294
|
| `quorum sentinel coverage [--path <dir>]` | Chronicle coverage of source files |
|
|
295
|
+
| `quorum growth` | Chronicle learning health — growth rate, last commit, pending proposals |
|
|
296
|
+
| `quorum evolve` | Consolidate Chronicle — merges duplicates, resolves contradictions, promotes open entries |
|
|
289
297
|
|
|
290
298
|
`quorum check` exit codes: `0` = low/medium risk · `1` = high · `2` = critical
|
|
299
|
+
|
|
300
|
+
`quorum advisor ask` and `quorum evolve` auto-detect any available LLM: `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `OPENAI_BASE_URL`, Ollama at localhost:11434, or an authenticated `gemini` CLI. When running inside an AI agent (Claude Code, Copilot, Codex, Gemini) with no separate key, they output Chronicle evidence and a synthesis request for the agent to answer inline.
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { c } from "../shared/colors.js"
|
|
2
|
+
import { findChronicleDir, readCommitted } from "../shared/chronicle.js"
|
|
3
|
+
import { detectProvider } from "../shared/llm.js"
|
|
4
|
+
|
|
5
|
+
const SATISFACTION_THRESHOLD = 0.7
|
|
6
|
+
const MAX_RETRIES = 2
|
|
7
|
+
|
|
8
|
+
// ── Evidence helpers ──────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
function tokenize(text) {
|
|
11
|
+
return text.toLowerCase().split(/\W+/).filter(t => t.length > 2)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function scoreEntry(query, entry) {
|
|
15
|
+
const qTokens = new Set(tokenize(query))
|
|
16
|
+
const text = [
|
|
17
|
+
entry.key_insight ?? "",
|
|
18
|
+
entry.decision ?? "",
|
|
19
|
+
...(entry.affected_areas ?? []),
|
|
20
|
+
...(entry.scope ?? []),
|
|
21
|
+
].join(" ")
|
|
22
|
+
const eTokens = tokenize(text)
|
|
23
|
+
const overlap = eTokens.filter(t => qTokens.has(t)).length
|
|
24
|
+
return overlap / Math.sqrt(qTokens.size * eTokens.length + 1)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function entryText(entry) {
|
|
28
|
+
return (entry.decision ?? entry.key_insight ?? "").trim()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function findRelevant(entries, query, limit = 6) {
|
|
32
|
+
return entries
|
|
33
|
+
.map(e => ({ entry: e, score: scoreEntry(query, e) }))
|
|
34
|
+
.filter(({ score }) => score > 0)
|
|
35
|
+
.sort((a, b) => b.score - a.score)
|
|
36
|
+
.slice(0, limit)
|
|
37
|
+
.map(({ entry }) => entry)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function formatEvidenceForLLM(entries) {
|
|
41
|
+
if (entries.length === 0) return "Chronicle has no prior entries on this topic."
|
|
42
|
+
return entries.map(e => {
|
|
43
|
+
const statusTag =
|
|
44
|
+
e.status === "refuted" ? " [REJECTED]" :
|
|
45
|
+
e.status === "validated" ? " [VALIDATED]" : ""
|
|
46
|
+
return `[${(e.id ?? "").slice(0, 8)}]${statusTag} ${entryText(e)}\n Areas: ${(e.affected_areas ?? []).join(", ")}`
|
|
47
|
+
}).join("\n\n")
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── LLM + validation loop ─────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
const SYSTEM_PROMPT = `You are the Quorum Advisor — the plain-language interface to a team's collective knowledge.
|
|
53
|
+
|
|
54
|
+
You receive a question from a developer or engineering manager, along with relevant Chronicle evidence.
|
|
55
|
+
Synthesise that evidence into a clear, concise answer a human can act on.
|
|
56
|
+
|
|
57
|
+
Rules:
|
|
58
|
+
- Write for a human who does not know what "Chronicle entries" or "vector search" mean.
|
|
59
|
+
- Be direct. One clear recommendation.
|
|
60
|
+
- If Chronicle has relevant evidence, reference it plainly: "the team already decided X".
|
|
61
|
+
- If Chronicle has no evidence, say so honestly — do not invent history.
|
|
62
|
+
- Blockers are hard blockers only — things that MUST be resolved before moving forward.
|
|
63
|
+
|
|
64
|
+
Return ONLY valid JSON matching this schema (no markdown fences, no explanation):
|
|
65
|
+
{
|
|
66
|
+
"confidence": <number 0–1>,
|
|
67
|
+
"what_we_know": <string — what Chronicle knows, plain English, 1–3 sentences>,
|
|
68
|
+
"risks": [<string>],
|
|
69
|
+
"blockers": [<string — hard blockers only, empty array if none>],
|
|
70
|
+
"recommendation": <string — one clear action>,
|
|
71
|
+
"next_step": <string — specific next step or quorum command>
|
|
72
|
+
}`
|
|
73
|
+
|
|
74
|
+
async function callLLM(llm, question, evidence, attempt, previous) {
|
|
75
|
+
let userPrompt = `## Question\n${question}\n\n## Chronicle Evidence\n${formatEvidenceForLLM(evidence)}`
|
|
76
|
+
|
|
77
|
+
if (attempt > 0 && previous) {
|
|
78
|
+
const lines = [
|
|
79
|
+
"",
|
|
80
|
+
`## Previous Answer (attempt ${attempt} — quality threshold not met)`,
|
|
81
|
+
`Confidence: ${previous.confidence.toFixed(2)} (need ≥ ${SATISFACTION_THRESHOLD})`,
|
|
82
|
+
]
|
|
83
|
+
if (previous.blockers?.length > 0) {
|
|
84
|
+
lines.push(`Unresolved blockers: ${previous.blockers.join("; ")}`)
|
|
85
|
+
}
|
|
86
|
+
lines.push("Please produce a more specific and concrete answer.")
|
|
87
|
+
userPrompt += lines.join("\n")
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const raw = await llm([
|
|
91
|
+
{ role: "system", content: SYSTEM_PROMPT },
|
|
92
|
+
{ role: "user", content: userPrompt },
|
|
93
|
+
])
|
|
94
|
+
|
|
95
|
+
let parsed
|
|
96
|
+
try {
|
|
97
|
+
const cleaned = raw.replace(/^```(?:json)?\s*/m, "").replace(/\s*```$/m, "").trim()
|
|
98
|
+
parsed = JSON.parse(cleaned)
|
|
99
|
+
} catch {
|
|
100
|
+
throw new Error(`LLM returned non-JSON. Raw: ${raw.slice(0, 200)}`)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (typeof parsed.confidence !== "number" || !parsed.what_we_know || !parsed.recommendation) {
|
|
104
|
+
throw new Error("LLM response missing required fields (confidence, what_we_know, recommendation)")
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return parsed
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function runAdvisor(llm, question, evidence) {
|
|
111
|
+
let last = null
|
|
112
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
113
|
+
const answer = await callLLM(llm, question, evidence, attempt, last)
|
|
114
|
+
last = answer
|
|
115
|
+
const satisfied = answer.confidence >= SATISFACTION_THRESHOLD && (answer.blockers?.length ?? 0) === 0
|
|
116
|
+
if (satisfied || attempt === MAX_RETRIES) return { ...answer, retries: attempt }
|
|
117
|
+
}
|
|
118
|
+
return { ...last, retries: MAX_RETRIES }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── Output renderers ──────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
function renderAsk(question, result) {
|
|
124
|
+
console.log(`\n${c.bold("Advisor")}\n`)
|
|
125
|
+
console.log(` ${c.dim("Question:")} ${question}\n`)
|
|
126
|
+
|
|
127
|
+
console.log(`${c.bold("What we know")}`)
|
|
128
|
+
console.log(` ${result.what_we_know}\n`)
|
|
129
|
+
|
|
130
|
+
if (result.blockers?.length > 0) {
|
|
131
|
+
console.log(`${c.bold(c.red("Blockers"))}`)
|
|
132
|
+
for (const b of result.blockers) console.log(` ${c.red("✗")} ${b}`)
|
|
133
|
+
console.log("")
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (result.risks?.length > 0) {
|
|
137
|
+
console.log(`${c.bold("Risks")}`)
|
|
138
|
+
for (const r of result.risks) console.log(` ${c.yellow("⚠")} ${r}`)
|
|
139
|
+
console.log("")
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
console.log(`${c.bold("Recommendation")}`)
|
|
143
|
+
console.log(` ${result.recommendation}\n`)
|
|
144
|
+
|
|
145
|
+
console.log(`${c.bold("Next step")}`)
|
|
146
|
+
console.log(` ${c.cyan(result.next_step)}\n`)
|
|
147
|
+
|
|
148
|
+
if (result.retries > 0) {
|
|
149
|
+
console.log(c.dim(` (Refined over ${result.retries + 1} attempts)\n`))
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function renderQuery(topic, entries) {
|
|
154
|
+
console.log(`\n${c.bold("Chronicle")} ${c.dim(`query: "${topic}"`)}\n`)
|
|
155
|
+
|
|
156
|
+
if (entries.length === 0) {
|
|
157
|
+
console.log(` ${c.dim("No matching entries found.")}\n`)
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
for (const e of entries) {
|
|
162
|
+
const statusColor =
|
|
163
|
+
e.status === "validated" ? c.green :
|
|
164
|
+
e.status === "refuted" ? c.red : c.dim
|
|
165
|
+
console.log(` ${c.cyan((e.id ?? "").slice(0, 8))} ${statusColor(`[${e.status}]`)} ${entryText(e)}`)
|
|
166
|
+
if (e.affected_areas?.length) console.log(` ${c.dim(e.affected_areas.join(", "))}`)
|
|
167
|
+
console.log("")
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function renderBrief(allEntries) {
|
|
172
|
+
const validated = allEntries.filter(e => e.status === "validated")
|
|
173
|
+
const refuted = allEntries.filter(e => e.status === "refuted")
|
|
174
|
+
const open = allEntries.filter(e => e.status === "open")
|
|
175
|
+
const recent = allEntries.slice(0, 5)
|
|
176
|
+
|
|
177
|
+
console.log(`\n${c.bold("Chronicle Brief")}\n`)
|
|
178
|
+
console.log(` ${c.green(validated.length)} validated ${c.red(refuted.length)} refuted ${c.dim(open.length + " open")}\n`)
|
|
179
|
+
|
|
180
|
+
if (recent.length > 0) {
|
|
181
|
+
console.log(`${c.bold("Recent entries")}`)
|
|
182
|
+
for (const e of recent) {
|
|
183
|
+
const statusColor =
|
|
184
|
+
e.status === "validated" ? c.green :
|
|
185
|
+
e.status === "refuted" ? c.red : c.dim
|
|
186
|
+
console.log(` ${c.cyan((e.id ?? "").slice(0, 8))} ${statusColor(e.status)} ${entryText(e).slice(0, 70)}`)
|
|
187
|
+
}
|
|
188
|
+
console.log("")
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── Subcommand handlers ───────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
async function cmdAsk(question, chronicleDir) {
|
|
195
|
+
const allEntries = await readCommitted(chronicleDir)
|
|
196
|
+
const evidence = findRelevant(allEntries, question)
|
|
197
|
+
const provider = await detectProvider()
|
|
198
|
+
|
|
199
|
+
if (!provider) {
|
|
200
|
+
renderPassthrough(question, evidence)
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const { llm, name: llmName } = provider
|
|
205
|
+
process.stdout.write(c.dim(`\n Thinking (${llmName})…`))
|
|
206
|
+
try {
|
|
207
|
+
const result = await runAdvisor(llm, question, evidence)
|
|
208
|
+
process.stdout.write("\r" + " ".repeat(50) + "\r")
|
|
209
|
+
renderAsk(question, result)
|
|
210
|
+
} catch (err) {
|
|
211
|
+
process.stdout.write("\r" + " ".repeat(50) + "\r")
|
|
212
|
+
console.error(`\n${c.red("Advisor failed:")} ${err.message}\n`)
|
|
213
|
+
process.exit(1)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function renderPassthrough(question, evidence) {
|
|
218
|
+
console.log(`\n${c.bold("Chronicle evidence")} ${c.dim(`for: "${question}"`)}\n`)
|
|
219
|
+
if (evidence.length === 0) {
|
|
220
|
+
console.log(c.dim(" No relevant Chronicle entries found.\n"))
|
|
221
|
+
} else {
|
|
222
|
+
for (const e of evidence) {
|
|
223
|
+
console.log(` ${c.cyan(e.id.slice(0, 8))} ${c.bold(entryText(e))}`)
|
|
224
|
+
console.log(` ${c.dim(`status: ${e.status} · confidence: ${e.confidence} · areas: ${(e.affected_areas ?? []).join(", ")}`)}`)
|
|
225
|
+
console.log("")
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
console.log(c.dim("─".repeat(60)))
|
|
229
|
+
console.log(`\n${c.bold("Synthesis request")}`)
|
|
230
|
+
console.log(`\n ${question}`)
|
|
231
|
+
console.log(`\n${c.dim(" No LLM configured — answer from the Chronicle evidence above.")}`)
|
|
232
|
+
console.log(c.dim(" (Set ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, or run Ollama for auto-synthesis.)\n"))
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function cmdQuery(topic, chronicleDir) {
|
|
236
|
+
const allEntries = await readCommitted(chronicleDir)
|
|
237
|
+
const matches = findRelevant(allEntries, topic, 8)
|
|
238
|
+
renderQuery(topic, matches)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function cmdBrief(chronicleDir) {
|
|
242
|
+
const allEntries = await readCommitted(chronicleDir)
|
|
243
|
+
renderBrief(allEntries)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ── Entry point ───────────────────────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
export async function run(argv) {
|
|
249
|
+
const [subOrQuestion, ...rest] = argv
|
|
250
|
+
|
|
251
|
+
const chronicleDir = await findChronicleDir(process.cwd())
|
|
252
|
+
if (!chronicleDir) {
|
|
253
|
+
console.error(`\n${c.red("No .chronicle/ directory found.")} Run ${c.bold("quorum init")} first.\n`)
|
|
254
|
+
process.exit(1)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// `quorum advisor ask "..."` or `quorum advisor "..."` (default to ask)
|
|
258
|
+
if (subOrQuestion === "ask") {
|
|
259
|
+
const question = rest.join(" ").trim()
|
|
260
|
+
if (!question) return printUsage()
|
|
261
|
+
return cmdAsk(question, chronicleDir)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// `quorum advisor query "topic"` — Chronicle lookup, no LLM
|
|
265
|
+
if (subOrQuestion === "query") {
|
|
266
|
+
const topic = rest.join(" ").trim()
|
|
267
|
+
if (!topic) return printUsage()
|
|
268
|
+
return cmdQuery(topic, chronicleDir)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// `quorum advisor brief` — high-level Chronicle summary
|
|
272
|
+
if (subOrQuestion === "brief") {
|
|
273
|
+
return cmdBrief(chronicleDir)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// No subcommand — treat the whole argv as a question (default: ask)
|
|
277
|
+
const question = argv.join(" ").trim()
|
|
278
|
+
if (!question) return printUsage()
|
|
279
|
+
return cmdAsk(question, chronicleDir)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function printUsage() {
|
|
283
|
+
console.log(`
|
|
284
|
+
${c.bold("quorum advisor")} — ask plain-language questions about your codebase
|
|
285
|
+
|
|
286
|
+
${c.bold("Subcommands:")}
|
|
287
|
+
${c.cyan("quorum advisor")} ${c.dim('"question"')} Ask a question (default — uses LLM)
|
|
288
|
+
${c.cyan("quorum advisor ask")} ${c.dim('"question"')} Ask explicitly
|
|
289
|
+
${c.cyan("quorum advisor query")} ${c.dim('"topic"')} Search Chronicle entries (no LLM)
|
|
290
|
+
${c.cyan("quorum advisor brief")} High-level Chronicle summary (no LLM)
|
|
291
|
+
|
|
292
|
+
${c.bold("Examples:")}
|
|
293
|
+
quorum advisor "what happens if we change the auth system?"
|
|
294
|
+
quorum advisor ask "is it safe to add a NOT NULL column to users?"
|
|
295
|
+
quorum advisor query "authentication"
|
|
296
|
+
quorum advisor brief
|
|
297
|
+
|
|
298
|
+
${c.dim("ask requires ANTHROPIC_API_KEY or OPENAI_API_KEY in your environment.")}
|
|
299
|
+
`)
|
|
300
|
+
process.exit(1)
|
|
301
|
+
}
|
package/bin/commands/commit.js
CHANGED
|
@@ -119,23 +119,15 @@ export async function run(argv) {
|
|
|
119
119
|
return
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
// ── Check optional dependencies
|
|
123
|
-
console.log(`\n${c.bold("Checking dependencies")}`)
|
|
122
|
+
// ── Check optional embedding dependencies ─────────────────────────────────
|
|
124
123
|
const hasXenova = await checkDep("@xenova/transformers")
|
|
125
124
|
const hasLanceDB = await checkDep("vectordb")
|
|
125
|
+
const canEmbed = hasXenova && hasLanceDB
|
|
126
126
|
|
|
127
|
-
if (!
|
|
128
|
-
console.
|
|
129
|
-
console.
|
|
130
|
-
process.exit(1)
|
|
131
|
-
}
|
|
132
|
-
if (!hasLanceDB) {
|
|
133
|
-
console.error(`\n ${c.red("✗")} vectordb not installed`)
|
|
134
|
-
console.error(c.dim(" Run: npm install vectordb\n"))
|
|
135
|
-
process.exit(1)
|
|
127
|
+
if (!canEmbed) {
|
|
128
|
+
console.log(`\n${c.dim(" Embedding deps not found — committing to JSON store only.")}`)
|
|
129
|
+
console.log(c.dim(" Install @xenova/transformers + vectordb to enable semantic search.\n"))
|
|
136
130
|
}
|
|
137
|
-
console.log(` ${c.green("✓")} @xenova/transformers`)
|
|
138
|
-
console.log(` ${c.green("✓")} vectordb`)
|
|
139
131
|
|
|
140
132
|
// ── Build entry ────────────────────────────────────────────────────────────
|
|
141
133
|
const entry = {
|
|
@@ -144,46 +136,44 @@ export async function run(argv) {
|
|
|
144
136
|
timestamp: new Date().toISOString(),
|
|
145
137
|
}
|
|
146
138
|
|
|
147
|
-
// ── Embed
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
139
|
+
// ── Embed + index in vector store (optional) ───────────────────────────────
|
|
140
|
+
if (canEmbed) {
|
|
141
|
+
const spin = spinner("Embedding and indexing…")
|
|
142
|
+
try {
|
|
143
|
+
const { pipeline } = (await import("@xenova/transformers")).default ?? await import("@xenova/transformers")
|
|
144
|
+
const embedder = await pipeline("feature-extraction", "Xenova/all-MiniLM-L6-v2")
|
|
145
|
+
const embeddingText = [
|
|
146
|
+
entryText(entry),
|
|
147
|
+
...(entry.affected_areas ?? []),
|
|
148
|
+
...(entry.scope ?? []),
|
|
149
|
+
].join(" ")
|
|
150
|
+
const output = await embedder(embeddingText, { pooling: "mean", normalize: true })
|
|
151
|
+
const vector = Array.from(output.data)
|
|
152
|
+
spin.stop(`${c.green("✓")} Embedded (${vector.length}-dim)`)
|
|
153
|
+
|
|
154
|
+
const storeSpin = spinner("Indexing in vector store…")
|
|
155
|
+
try {
|
|
156
|
+
const lancedb = (await import("vectordb")).default ?? (await import("vectordb"))
|
|
157
|
+
const tableDir = path.join(chronicleDir, "entries")
|
|
158
|
+
const db = await lancedb.connect(tableDir)
|
|
159
|
+
const row = { id: entry.id, vector, payload: JSON.stringify(entry) }
|
|
160
|
+
const names = await db.tableNames()
|
|
161
|
+
if (names.includes("entries")) {
|
|
162
|
+
const table = await db.openTable("entries")
|
|
163
|
+
await table.delete(`id = '${entry.id.replace(/'/g, "''")}'`)
|
|
164
|
+
await table.add([row])
|
|
165
|
+
} else {
|
|
166
|
+
await db.createTable("entries", [row], { metric: "cosine" })
|
|
167
|
+
}
|
|
168
|
+
storeSpin.stop(`${c.green("✓")} Indexed in vector store`)
|
|
169
|
+
} catch (err) {
|
|
170
|
+
storeSpin.stop(`${c.yellow("⚠")} Vector store write failed — JSON commit will proceed`)
|
|
171
|
+
console.error(c.dim(` ${err.message}`))
|
|
172
|
+
}
|
|
173
|
+
} catch (err) {
|
|
174
|
+
spin.stop(`${c.yellow("⚠")} Embedding failed — JSON commit will proceed`)
|
|
175
|
+
console.error(c.dim(` ${err.message}`))
|
|
181
176
|
}
|
|
182
|
-
storeSpin.stop(`${c.green("✓")} Indexed in vector store`)
|
|
183
|
-
} catch (err) {
|
|
184
|
-
storeSpin.stop(`${c.red("✗")} Vector store write failed`)
|
|
185
|
-
console.error(c.red(`\n ${err.message}\n`))
|
|
186
|
-
process.exit(1)
|
|
187
177
|
}
|
|
188
178
|
|
|
189
179
|
// ── Write committed file ───────────────────────────────────────────────────
|