@balpal4495/quorum 0.1.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/.github/copilot-instructions.md +94 -0
- package/CLAUDE.md +86 -0
- package/GEMINI.md +73 -0
- package/LICENSE +21 -0
- package/README.md +202 -0
- package/SETUP.md +256 -0
- package/bin/init.js +366 -0
- package/modules/AGENTS.md +66 -0
- package/modules/CLAUDE.md +64 -0
- package/modules/README.md +251 -0
- package/modules/council/advisors.ts +68 -0
- package/modules/council/chairman.ts +112 -0
- package/modules/council/deliberate.ts +106 -0
- package/modules/council/frame.ts +54 -0
- package/modules/council/index.ts +4 -0
- package/modules/council/personas.ts +57 -0
- package/modules/council/reviewers.ts +81 -0
- package/modules/council/types.ts +45 -0
- package/modules/jury/evaluate.ts +112 -0
- package/modules/jury/index.ts +3 -0
- package/modules/jury/schema.ts +15 -0
- package/modules/jury/types.ts +31 -0
- package/modules/oracle/adapters/lance-db.ts +81 -0
- package/modules/oracle/adapters/xenova-embedder.ts +43 -0
- package/modules/oracle/bm25.ts +92 -0
- package/modules/oracle/index.ts +36 -0
- package/modules/oracle/log.ts +15 -0
- package/modules/oracle/propose.ts +148 -0
- package/modules/oracle/query.ts +145 -0
- package/modules/oracle/summary.ts +115 -0
- package/modules/oracle/types.ts +32 -0
- package/modules/sentinel/assert.ts +95 -0
- package/modules/sentinel/coverage.ts +106 -0
- package/modules/sentinel/drift.ts +159 -0
- package/modules/sentinel/index.ts +6 -0
- package/modules/sentinel/review.ts +207 -0
- package/modules/setup.ts +153 -0
- package/modules/shared/types.ts +148 -0
- package/package.json +47 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default advisor personas for the Council.
|
|
3
|
+
*
|
|
4
|
+
* Personas are interpretive lenses, not knowledge sources.
|
|
5
|
+
* All advisors receive the same Oracle evidence pack — their persona
|
|
6
|
+
* determines which entries they weight and how they read them.
|
|
7
|
+
*
|
|
8
|
+
* Add or replace personas in CouncilDeps to specialise for your domain.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface AdvisorPersona {
|
|
12
|
+
name: string
|
|
13
|
+
/** One-line description of this persona's evidence focus. */
|
|
14
|
+
lens: string
|
|
15
|
+
/** System prompt fragment injected into the advisor's prompt. */
|
|
16
|
+
systemFragment: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_PERSONAS: readonly AdvisorPersona[] = [
|
|
20
|
+
{
|
|
21
|
+
name: "Pragmatist",
|
|
22
|
+
lens: "Weights validated entries — what has worked in this codebase",
|
|
23
|
+
systemFragment:
|
|
24
|
+
"Focus on `validated` Oracle entries. What has already worked in this codebase? " +
|
|
25
|
+
"Weight evidence that confirms the design will succeed based on prior outcomes.",
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: "Sceptic",
|
|
29
|
+
lens: "Weights refuted entries — what has failed and why",
|
|
30
|
+
systemFragment:
|
|
31
|
+
"Focus on `refuted` Oracle entries. What has already failed in this codebase and why? " +
|
|
32
|
+
"Look for signs this design repeats past mistakes. Surface failure modes explicitly.",
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: "Systems thinker",
|
|
36
|
+
lens: "Looks for patterns across all entries — second-order effects",
|
|
37
|
+
systemFragment:
|
|
38
|
+
"Read all Oracle entries as a system. Look for patterns, dependencies, and second-order " +
|
|
39
|
+
"effects. What does the design miss about how the system as a whole behaves?",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: "Risk analyst",
|
|
43
|
+
lens: "Weights open entries — unresolved questions and unknowns",
|
|
44
|
+
systemFragment:
|
|
45
|
+
"Focus on `open` Oracle entries — unresolved questions and unknowns. " +
|
|
46
|
+
"What has not been confirmed? What uncertainty does this design carry? " +
|
|
47
|
+
"Flag every assumption that has not been validated by an outcome.",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: "Evidence auditor",
|
|
51
|
+
lens: "Focuses on gaps — what Oracle does NOT contain",
|
|
52
|
+
systemFragment:
|
|
53
|
+
"Look for what is ABSENT from the Oracle evidence. What decisions is this design making " +
|
|
54
|
+
"without any codebase evidence to support them? " +
|
|
55
|
+
"Name every gap — a gap is not a reason to reject, but it must be surfaced.",
|
|
56
|
+
},
|
|
57
|
+
]
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { LLMProvider, OracleResult } from "../shared/types"
|
|
2
|
+
import type { AdvisorResponse } from "./advisors"
|
|
3
|
+
|
|
4
|
+
export interface ReviewerResponse {
|
|
5
|
+
reviewerId: string
|
|
6
|
+
review: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Shuffle advisor responses and label them A–Z.
|
|
11
|
+
* Prevents reviewers deferring to confident responses by position or persona name.
|
|
12
|
+
*/
|
|
13
|
+
function anonymise(responses: AdvisorResponse[]): string {
|
|
14
|
+
const shuffled = [...responses].sort(() => Math.random() - 0.5)
|
|
15
|
+
return shuffled
|
|
16
|
+
.map((r, i) => `## Advisor ${String.fromCharCode(65 + i)}\n${r.response}`)
|
|
17
|
+
.join("\n\n---\n\n")
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function formatEvidenceSummary(evidence: OracleResult[]): string {
|
|
21
|
+
if (evidence.length === 0) return "No Oracle evidence available."
|
|
22
|
+
return evidence
|
|
23
|
+
.map(e => `[${e.id}] (${e.status}) ${e.key_insight}`)
|
|
24
|
+
.join("\n")
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const REVIEWER_SYSTEM_PROMPT = [
|
|
28
|
+
"You are a Council reviewer. You evaluate the quality of advisor responses.",
|
|
29
|
+
"",
|
|
30
|
+
"You are NOT deciding whether the design is correct.",
|
|
31
|
+
"You are assessing the reasoning quality of each advisor response:",
|
|
32
|
+
"",
|
|
33
|
+
"1. Does the advisor actually use the Oracle evidence, or reason from general knowledge?",
|
|
34
|
+
"2. Are Oracle entry IDs cited? Do those citations match the evidence provided?",
|
|
35
|
+
"3. Is the response internally consistent?",
|
|
36
|
+
"4. Which responses provide the strongest evidence-backed reasoning?",
|
|
37
|
+
"5. Which responses make unsupported claims?",
|
|
38
|
+
"",
|
|
39
|
+
"Be critical. Evidence quality matters more than conclusion confidence.",
|
|
40
|
+
"Keep your review under 400 words.",
|
|
41
|
+
].join("\n")
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Run all reviewers in parallel.
|
|
45
|
+
* Each reviewer receives the anonymised advisor responses and the original evidence pack.
|
|
46
|
+
* Anonymisation prevents position bias and persona deference.
|
|
47
|
+
*/
|
|
48
|
+
export async function fanOutReviewers(
|
|
49
|
+
advisorResponses: AdvisorResponse[],
|
|
50
|
+
evidence: OracleResult[],
|
|
51
|
+
reviewerCount: number,
|
|
52
|
+
llm: LLMProvider,
|
|
53
|
+
model?: string,
|
|
54
|
+
): Promise<ReviewerResponse[]> {
|
|
55
|
+
const anonymisedResponses = anonymise(advisorResponses)
|
|
56
|
+
const evidenceSummary = formatEvidenceSummary(evidence)
|
|
57
|
+
|
|
58
|
+
return Promise.all(
|
|
59
|
+
Array.from({ length: reviewerCount }, async (_, i): Promise<ReviewerResponse> => {
|
|
60
|
+
const userPrompt = [
|
|
61
|
+
"## Advisor Responses (anonymised)",
|
|
62
|
+
anonymisedResponses,
|
|
63
|
+
"",
|
|
64
|
+
"## Oracle Evidence (for cross-referencing citations)",
|
|
65
|
+
evidenceSummary,
|
|
66
|
+
"",
|
|
67
|
+
"Review each advisor response for evidence quality.",
|
|
68
|
+
].join("\n")
|
|
69
|
+
|
|
70
|
+
const review = await llm(
|
|
71
|
+
[
|
|
72
|
+
{ role: "system", content: REVIEWER_SYSTEM_PROMPT },
|
|
73
|
+
{ role: "user", content: userPrompt },
|
|
74
|
+
],
|
|
75
|
+
model,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
return { reviewerId: `reviewer-${i + 1}`, review }
|
|
79
|
+
}),
|
|
80
|
+
)
|
|
81
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { OracleResult, LLMProvider, OracleClient } from "../shared/types"
|
|
2
|
+
import type { JuryOutput } from "../jury/types"
|
|
3
|
+
|
|
4
|
+
export interface CouncilInput {
|
|
5
|
+
/** What needs to be achieved. */
|
|
6
|
+
outcome: string
|
|
7
|
+
/** Proposed approach from the Designer. */
|
|
8
|
+
design: string
|
|
9
|
+
/** Same evidence pack the Jury received. */
|
|
10
|
+
evidence: OracleResult[]
|
|
11
|
+
/** Jury output — drives the council brief and confidence. */
|
|
12
|
+
jury_output: JuryOutput
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface CouncilOutput {
|
|
16
|
+
satisfied: boolean
|
|
17
|
+
/** Chairman synthesis — every material conclusion cites Oracle entry IDs. */
|
|
18
|
+
verdict: string
|
|
19
|
+
/** What was challenged or could not be validated. */
|
|
20
|
+
challenges: string[]
|
|
21
|
+
/** Oracle entry IDs referenced in the verdict. */
|
|
22
|
+
evidence_cited: string[]
|
|
23
|
+
recommendation: "proceed" | "redesign" | "investigate-more"
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CouncilModels {
|
|
27
|
+
/** Model for the framer step. */
|
|
28
|
+
frame?: string
|
|
29
|
+
/** Model for advisors. High volume — cheaper model appropriate here. */
|
|
30
|
+
advisors?: string
|
|
31
|
+
/** Model for reviewers. Critical analysis — stronger model recommended. */
|
|
32
|
+
reviewers?: string
|
|
33
|
+
/** Model for the chairman. Synthesis — best available model recommended. */
|
|
34
|
+
chairman?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface CouncilDeps {
|
|
38
|
+
llm: LLMProvider
|
|
39
|
+
oracle: OracleClient
|
|
40
|
+
/** Number of advisors to run in parallel. Default: 5. */
|
|
41
|
+
advisorCount?: number
|
|
42
|
+
/** Number of reviewers to run in parallel. Default: 5. */
|
|
43
|
+
reviewerCount?: number
|
|
44
|
+
models?: CouncilModels
|
|
45
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { JuryInput, JuryOutput, JuryDeps } from "./types"
|
|
2
|
+
import type { OracleResult } from "../shared/types"
|
|
3
|
+
import { JuryOutputSchema } from "./schema"
|
|
4
|
+
|
|
5
|
+
const CONFIDENCE_THRESHOLD = 0.6
|
|
6
|
+
|
|
7
|
+
function formatEvidence(evidence: OracleResult[]): string {
|
|
8
|
+
if (evidence.length === 0) {
|
|
9
|
+
return "No Oracle entries found. There is no prior evidence for this codebase on this topic."
|
|
10
|
+
}
|
|
11
|
+
return evidence
|
|
12
|
+
.map(e =>
|
|
13
|
+
[
|
|
14
|
+
`[${e.id}] status=${e.status} confidence=${e.confidence.toFixed(2)} score=${e.score.toFixed(3)}`,
|
|
15
|
+
`Insight: ${e.key_insight}`,
|
|
16
|
+
`Areas: ${e.affected_areas.join(", ")}`,
|
|
17
|
+
e.outcome ? `Outcome: ${e.outcome}` : null,
|
|
18
|
+
]
|
|
19
|
+
.filter(Boolean)
|
|
20
|
+
.join("\n"),
|
|
21
|
+
)
|
|
22
|
+
.join("\n\n")
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const SYSTEM_PROMPT = `You are the Jury — an evidence-based evaluator for agentic development workflows.
|
|
26
|
+
|
|
27
|
+
Your job is to evaluate a proposed design against Oracle evidence and produce a structured confidence score.
|
|
28
|
+
You do NOT make decisions. You assess and score. Your output determines the Council's brief.
|
|
29
|
+
|
|
30
|
+
Score the design across these four dimensions (equally weighted to produce a final confidence in [0, 1]):
|
|
31
|
+
1. Evidence support — do validated Oracle entries confirm this approach works in this codebase?
|
|
32
|
+
2. Feasibility — do Oracle entries (or their absence) suggest this is achievable?
|
|
33
|
+
3. Risk — what do refuted entries reveal about failure modes? Has this been tried and failed?
|
|
34
|
+
4. Completeness — does the design address the full outcome, or only part of it?
|
|
35
|
+
|
|
36
|
+
council_brief is determined by confidence only (do not invent a value):
|
|
37
|
+
confidence < 0.6 → council_brief = "challenge"
|
|
38
|
+
confidence ≥ 0.6 → council_brief = "pressure-test"
|
|
39
|
+
|
|
40
|
+
Return ONLY valid JSON that matches this schema exactly — no markdown fences, no explanation:
|
|
41
|
+
{
|
|
42
|
+
"confidence": <number 0–1>,
|
|
43
|
+
"assessment": <string — what the evidence supports or contradicts>,
|
|
44
|
+
"gaps": [<string — each missing piece of evidence from Oracle>],
|
|
45
|
+
"council_brief": "challenge" | "pressure-test",
|
|
46
|
+
"recommendation": "proceed" | "investigate-more" | "redesign"
|
|
47
|
+
}`
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Evaluate a proposed design against Oracle evidence.
|
|
51
|
+
*
|
|
52
|
+
* Scores across four dimensions (evidence support, feasibility, risk, completeness)
|
|
53
|
+
* and returns a structured JuryOutput. The council_brief is always derived from the
|
|
54
|
+
* confidence score — the LLM value is overridden to ensure deterministic routing.
|
|
55
|
+
*
|
|
56
|
+
* Throws if the LLM returns non-JSON or a response that fails schema validation.
|
|
57
|
+
* Never silently defaults to a passing score.
|
|
58
|
+
*/
|
|
59
|
+
export async function evaluate(
|
|
60
|
+
input: JuryInput,
|
|
61
|
+
deps: JuryDeps,
|
|
62
|
+
): Promise<JuryOutput> {
|
|
63
|
+
const { llm, model } = deps
|
|
64
|
+
const evidenceText = formatEvidence(input.evidence)
|
|
65
|
+
|
|
66
|
+
const userPrompt = [
|
|
67
|
+
"## Outcome",
|
|
68
|
+
input.outcome,
|
|
69
|
+
"",
|
|
70
|
+
"## Proposed Design",
|
|
71
|
+
input.design,
|
|
72
|
+
"",
|
|
73
|
+
"## Oracle Evidence",
|
|
74
|
+
evidenceText,
|
|
75
|
+
].join("\n")
|
|
76
|
+
|
|
77
|
+
const raw = await llm(
|
|
78
|
+
[
|
|
79
|
+
{ role: "system", content: SYSTEM_PROMPT },
|
|
80
|
+
{ role: "user", content: userPrompt },
|
|
81
|
+
],
|
|
82
|
+
model,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
let parsed: unknown
|
|
86
|
+
try {
|
|
87
|
+
const cleaned = raw
|
|
88
|
+
.replace(/^```(?:json)?\s*/m, "")
|
|
89
|
+
.replace(/\s*```$/m, "")
|
|
90
|
+
.trim()
|
|
91
|
+
parsed = JSON.parse(cleaned)
|
|
92
|
+
} catch {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`Jury: LLM returned non-JSON response. Raw (first 300 chars): ${raw.slice(0, 300)}`,
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const result = JuryOutputSchema.safeParse(parsed)
|
|
99
|
+
if (!result.success) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`Jury: LLM output failed schema validation. Issues: ${JSON.stringify(result.error.issues)}`,
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const output = result.data
|
|
106
|
+
|
|
107
|
+
// Enforce council_brief from confidence — do not trust the LLM to compute this correctly
|
|
108
|
+
output.council_brief =
|
|
109
|
+
output.confidence < CONFIDENCE_THRESHOLD ? "challenge" : "pressure-test"
|
|
110
|
+
|
|
111
|
+
return output
|
|
112
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { z } from "zod"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Zod schema for the Jury's structured LLM output.
|
|
5
|
+
* evaluate() validates all LLM responses against this before returning.
|
|
6
|
+
*/
|
|
7
|
+
export const JuryOutputSchema = z.object({
|
|
8
|
+
confidence: z.number().min(0).max(1),
|
|
9
|
+
assessment: z.string().min(1),
|
|
10
|
+
gaps: z.array(z.string()),
|
|
11
|
+
council_brief: z.enum(["challenge", "pressure-test"]),
|
|
12
|
+
recommendation: z.enum(["proceed", "investigate-more", "redesign"]),
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
export type JuryOutputParsed = z.infer<typeof JuryOutputSchema>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { OracleResult, LLMProvider } from "../shared/types"
|
|
2
|
+
|
|
3
|
+
export interface JuryInput {
|
|
4
|
+
/** What needs to be achieved. */
|
|
5
|
+
outcome: string
|
|
6
|
+
/** Proposed approach from the Designer. */
|
|
7
|
+
design: string
|
|
8
|
+
/** Evidence retrieved from Oracle. */
|
|
9
|
+
evidence: OracleResult[]
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface JuryOutput {
|
|
13
|
+
/** 0–1 confidence score. Drives the Council brief. */
|
|
14
|
+
confidence: number
|
|
15
|
+
/** What the evidence supports or contradicts. */
|
|
16
|
+
assessment: string
|
|
17
|
+
/** Evidence missing from Oracle that would improve confidence. */
|
|
18
|
+
gaps: string[]
|
|
19
|
+
/**
|
|
20
|
+
* Council brief derived from confidence:
|
|
21
|
+
* < 0.6 → "challenge" (find what is wrong — broader scope)
|
|
22
|
+
* ≥ 0.6 → "pressure-test" (assume correct, try to break it)
|
|
23
|
+
*/
|
|
24
|
+
council_brief: "challenge" | "pressure-test"
|
|
25
|
+
recommendation: "proceed" | "investigate-more" | "redesign"
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface JuryDeps {
|
|
29
|
+
llm: LLMProvider
|
|
30
|
+
model?: string
|
|
31
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LanceDB vector store adapter.
|
|
3
|
+
*
|
|
4
|
+
* Required package: npm install vectordb
|
|
5
|
+
*
|
|
6
|
+
* Chronicle entries are stored in .chronicle/entries/ (LanceDB table directory).
|
|
7
|
+
* Vectors are indexed with cosine metric — no need to pre-normalise embeddings.
|
|
8
|
+
*
|
|
9
|
+
* Note: this adapter targets the `vectordb` package (LanceDB v0.x).
|
|
10
|
+
* If your project uses `@lancedb/lancedb` (v0.4+), the connect/createTable API
|
|
11
|
+
* is nearly identical but table.query() replaces table.search() for non-vector queries.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { VectorStore } from "../types"
|
|
15
|
+
import type { ChronicleEntry } from "../../shared/types"
|
|
16
|
+
import path from "path"
|
|
17
|
+
|
|
18
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
19
|
+
const lancedb = require("vectordb")
|
|
20
|
+
|
|
21
|
+
interface LanceRow {
|
|
22
|
+
id: string
|
|
23
|
+
vector: number[]
|
|
24
|
+
/** ChronicleEntry serialised as JSON string. */
|
|
25
|
+
payload: string
|
|
26
|
+
_distance?: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function createLanceDBStore(chronicleDir: string): Promise<VectorStore> {
|
|
30
|
+
const tableDir = path.join(chronicleDir, "entries")
|
|
31
|
+
const db = await lancedb.connect(tableDir)
|
|
32
|
+
let table: any = null
|
|
33
|
+
|
|
34
|
+
async function getOrCreateTable(firstRow?: LanceRow): Promise<any> {
|
|
35
|
+
if (table) return table
|
|
36
|
+
const names: string[] = await db.tableNames()
|
|
37
|
+
if (names.includes("entries")) {
|
|
38
|
+
table = await db.openTable("entries")
|
|
39
|
+
} else if (firstRow) {
|
|
40
|
+
table = await db.createTable("entries", [firstRow], { metric: "cosine" })
|
|
41
|
+
}
|
|
42
|
+
return table
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
async upsert(id, vector, metadata) {
|
|
47
|
+
const row: LanceRow = { id, vector, payload: JSON.stringify(metadata) }
|
|
48
|
+
const t = await getOrCreateTable(row)
|
|
49
|
+
if (t !== table) {
|
|
50
|
+
// table was just created with this row — already inserted
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
// LanceDB does not have native upsert — delete existing then insert
|
|
54
|
+
await t.delete(`id = '${sanitiseId(id)}'`)
|
|
55
|
+
await t.add([row])
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
async search(vector, limit) {
|
|
59
|
+
const t = await getOrCreateTable()
|
|
60
|
+
if (!t) return []
|
|
61
|
+
const rows: LanceRow[] = await t.search(vector).limit(limit).execute()
|
|
62
|
+
return rows.map(row => ({
|
|
63
|
+
entry: JSON.parse(row.payload) as ChronicleEntry,
|
|
64
|
+
// Convert L2 distance (cosine metric stores 1 - cosine_sim as distance)
|
|
65
|
+
score: row._distance !== undefined ? 1 - row._distance : 0,
|
|
66
|
+
}))
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
async getAll() {
|
|
70
|
+
const t = await getOrCreateTable()
|
|
71
|
+
if (!t) return []
|
|
72
|
+
const rows: LanceRow[] = await t.query().execute()
|
|
73
|
+
return rows.map(row => JSON.parse(row.payload) as ChronicleEntry)
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Prevent SQL injection in the delete filter. LanceDB uses SQL-like WHERE clauses. */
|
|
79
|
+
function sanitiseId(id: string): string {
|
|
80
|
+
return id.replace(/'/g, "''")
|
|
81
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local ONNX embedder using @xenova/transformers (all-MiniLM-L6-v2).
|
|
3
|
+
*
|
|
4
|
+
* Required package: npm install @xenova/transformers
|
|
5
|
+
*
|
|
6
|
+
* Runs entirely locally — no API key, no network dependency after first use.
|
|
7
|
+
* First call downloads and caches the model (~25 MB).
|
|
8
|
+
* Produces 384-dimensional unit vectors (mean pooling + L2 normalisation).
|
|
9
|
+
*
|
|
10
|
+
* For production use, pre-warm the embedder on startup:
|
|
11
|
+
* import { warmEmbedder } from "./adapters/xenova-embedder"
|
|
12
|
+
* await warmEmbedder()
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
16
|
+
const { pipeline } = require("@xenova/transformers")
|
|
17
|
+
|
|
18
|
+
let embedderPipeline: any = null
|
|
19
|
+
|
|
20
|
+
async function getPipeline(): Promise<any> {
|
|
21
|
+
if (!embedderPipeline) {
|
|
22
|
+
embedderPipeline = await pipeline(
|
|
23
|
+
"feature-extraction",
|
|
24
|
+
"Xenova/all-MiniLM-L6-v2",
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
return embedderPipeline
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Embed text using all-MiniLM-L6-v2.
|
|
32
|
+
* Returns a 384-dimensional unit vector.
|
|
33
|
+
*/
|
|
34
|
+
export async function xenovaEmbed(text: string): Promise<number[]> {
|
|
35
|
+
const embedder = await getPipeline()
|
|
36
|
+
const output = await embedder(text, { pooling: "mean", normalize: true })
|
|
37
|
+
return Array.from(output.data) as number[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Pre-warm the model so the first real query is not slow. */
|
|
41
|
+
export async function warmEmbedder(): Promise<void> {
|
|
42
|
+
await getPipeline()
|
|
43
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight BM25 implementation for Pass 2 re-ranking.
|
|
3
|
+
*
|
|
4
|
+
* k1 = 1.5 (term frequency saturation)
|
|
5
|
+
* b = 0.75 (length normalization)
|
|
6
|
+
*
|
|
7
|
+
* Formula: score(q, d) = Σ IDF(qi) * f(qi, d) * (k1 + 1) / (f(qi, d) + k1 * (1 − b + b * |d| / avgdl))
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const K1 = 1.5
|
|
11
|
+
const B = 0.75
|
|
12
|
+
|
|
13
|
+
function tokenize(text: string): string[] {
|
|
14
|
+
return text.toLowerCase().match(/\b\w+\b/g) ?? []
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Robertson–Sparck Jones IDF with smoothing. */
|
|
18
|
+
function computeIdf(N: number, df: number): number {
|
|
19
|
+
return Math.log(1 + (N - df + 0.5) / (df + 0.5))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Score each document string against the query using BM25.
|
|
24
|
+
* Returns a score array parallel to `documents`.
|
|
25
|
+
*/
|
|
26
|
+
export function bm25Score(query: string, documents: string[]): number[] {
|
|
27
|
+
if (documents.length === 0) return []
|
|
28
|
+
|
|
29
|
+
const queryTokens = tokenize(query)
|
|
30
|
+
const docTokenLists = documents.map(tokenize)
|
|
31
|
+
const totalLength = docTokenLists.reduce((sum, d) => sum + d.length, 0)
|
|
32
|
+
const avgdl = totalLength / docTokenLists.length
|
|
33
|
+
const N = documents.length
|
|
34
|
+
|
|
35
|
+
// Precompute document frequency for each unique query token
|
|
36
|
+
const df = new Map<string, number>()
|
|
37
|
+
for (const token of queryTokens) {
|
|
38
|
+
if (!df.has(token)) {
|
|
39
|
+
df.set(token, docTokenLists.filter(doc => doc.includes(token)).length)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return docTokenLists.map(docTokenList => {
|
|
44
|
+
const docLength = docTokenList.length
|
|
45
|
+
|
|
46
|
+
const tf = new Map<string, number>()
|
|
47
|
+
for (const token of docTokenList) {
|
|
48
|
+
tf.set(token, (tf.get(token) ?? 0) + 1)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let score = 0
|
|
52
|
+
for (const token of queryTokens) {
|
|
53
|
+
const termFreq = tf.get(token) ?? 0
|
|
54
|
+
if (termFreq === 0) continue
|
|
55
|
+
const idfScore = computeIdf(N, df.get(token) ?? 0)
|
|
56
|
+
const normTf =
|
|
57
|
+
(termFreq * (K1 + 1)) /
|
|
58
|
+
(termFreq + K1 * (1 - B + B * (docLength / avgdl)))
|
|
59
|
+
score += idfScore * normTf
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return score
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const BM25_STOP_WORDS = new Set([
|
|
67
|
+
"a", "an", "the", "is", "are", "was", "were", "be", "been", "being",
|
|
68
|
+
"have", "has", "had", "do", "does", "did", "will", "would", "should",
|
|
69
|
+
"could", "may", "might", "shall", "can", "to", "of", "in", "for", "on",
|
|
70
|
+
"with", "at", "by", "from", "as", "into", "through", "and", "or", "but",
|
|
71
|
+
"if", "then", "this", "that", "these", "those", "it", "its", "we", "they",
|
|
72
|
+
"their", "there", "when", "where", "what", "which", "who", "how", "not", "no",
|
|
73
|
+
])
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Extract domain terms from Chronicle key insights for Pass 2 query enrichment.
|
|
77
|
+
* Bridges the vocabulary gap between natural language queries and technical identifiers.
|
|
78
|
+
* Strips stop words, returns the most frequent distinctive tokens.
|
|
79
|
+
*/
|
|
80
|
+
export function extractDomainTerms(insights: string[]): string[] {
|
|
81
|
+
const allTokens = insights.flatMap(s => tokenize(s))
|
|
82
|
+
const freq = new Map<string, number>()
|
|
83
|
+
for (const token of allTokens) {
|
|
84
|
+
if (!BM25_STOP_WORDS.has(token) && token.length > 2) {
|
|
85
|
+
freq.set(token, (freq.get(token) ?? 0) + 1)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return [...freq.entries()]
|
|
89
|
+
.sort((a, b) => b[1] - a[1])
|
|
90
|
+
.slice(0, 10)
|
|
91
|
+
.map(([token]) => token)
|
|
92
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export { query } from "./query"
|
|
2
|
+
export { propose, commit } from "./propose"
|
|
3
|
+
export type { OracleDeps, VectorStore } from "./types"
|
|
4
|
+
export type {
|
|
5
|
+
OracleResult,
|
|
6
|
+
QueryOptions,
|
|
7
|
+
ChronicleEntry,
|
|
8
|
+
OracleClient,
|
|
9
|
+
} from "../shared/types"
|
|
10
|
+
|
|
11
|
+
export { createLanceDBStore } from "./adapters/lance-db"
|
|
12
|
+
export { xenovaEmbed, warmEmbedder } from "./adapters/xenova-embedder"
|
|
13
|
+
|
|
14
|
+
import type { OracleClient } from "../shared/types"
|
|
15
|
+
import type { OracleDeps } from "./types"
|
|
16
|
+
import { query } from "./query"
|
|
17
|
+
import { propose, commit } from "./propose"
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Create a bound OracleClient from injected deps.
|
|
21
|
+
* Pass this to Jury and Council — they only need the OracleClient interface,
|
|
22
|
+
* not the raw Oracle functions.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* const oracle = createOracleClient({
|
|
26
|
+
* embedder: xenovaEmbed,
|
|
27
|
+
* vectorStore: await createLanceDBStore(".chronicle"),
|
|
28
|
+
* })
|
|
29
|
+
*/
|
|
30
|
+
export function createOracleClient(deps: OracleDeps): OracleClient {
|
|
31
|
+
return {
|
|
32
|
+
query: (text, options) => query(text, options ?? {}, deps),
|
|
33
|
+
propose: entry => propose(entry, deps),
|
|
34
|
+
commit: proposalId => commit(proposalId, deps),
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { promises as fs } from "fs"
|
|
2
|
+
import path from "path"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Append a query log entry to .chronicle/query-log.jsonl.
|
|
6
|
+
* Best-effort — callers should swallow errors from this.
|
|
7
|
+
*/
|
|
8
|
+
export async function appendQueryLog(
|
|
9
|
+
entry: Record<string, unknown>,
|
|
10
|
+
chronicleDir: string,
|
|
11
|
+
): Promise<void> {
|
|
12
|
+
await fs.mkdir(chronicleDir, { recursive: true })
|
|
13
|
+
const logPath = path.join(chronicleDir, "query-log.jsonl")
|
|
14
|
+
await fs.appendFile(logPath, JSON.stringify(entry) + "\n", "utf8")
|
|
15
|
+
}
|