@100xprompt/chitta 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/LICENSE +21 -0
- package/README.md +203 -0
- package/assets/rules/claude-md.md +9 -0
- package/assets/skill/SKILL.md +47 -0
- package/package.json +48 -0
- package/src/README.md +124 -0
- package/src/arango-client.ts +67 -0
- package/src/arango-graph-provider.ts +364 -0
- package/src/bin.ts +27 -0
- package/src/config-env.ts +53 -0
- package/src/embedded/authorizer.ts +89 -0
- package/src/embedded/cli.ts +86 -0
- package/src/embedded/code-extractor.ts +9 -0
- package/src/embedded/demo.ts +36 -0
- package/src/embedded/extract.ts +12 -0
- package/src/embedded/extractors/code.ts +308 -0
- package/src/embedded/extractors/deterministic.ts +63 -0
- package/src/embedded/extractors/llm.ts +151 -0
- package/src/embedded/extractors/text-hygiene.ts +54 -0
- package/src/embedded/extractors/types.ts +34 -0
- package/src/embedded/graph/acl-paths.ts +96 -0
- package/src/embedded/graph/adjacency.ts +61 -0
- package/src/embedded/graph/centrality.ts +23 -0
- package/src/embedded/graph/communities.ts +46 -0
- package/src/embedded/graph/cypher.ts +17 -0
- package/src/embedded/graph/impact.ts +24 -0
- package/src/embedded/graph/knowledge-graph.ts +108 -0
- package/src/embedded/graph/pagerank.ts +57 -0
- package/src/embedded/graph/sql-access.ts +13 -0
- package/src/embedded/graph/traversal.ts +73 -0
- package/src/embedded/graph/types.ts +35 -0
- package/src/embedded/graph-query.ts +126 -0
- package/src/embedded/index.ts +171 -0
- package/src/embedded/ingest.ts +262 -0
- package/src/embedded/kgqa/answer-paths.ts +197 -0
- package/src/embedded/kgqa/entity-link.ts +13 -0
- package/src/embedded/kgqa/intent.ts +14 -0
- package/src/embedded/kgqa/predicates.ts +9 -0
- package/src/embedded/kgqa/preference.ts +20 -0
- package/src/embedded/kgqa/select.ts +99 -0
- package/src/embedded/kgqa/text.ts +16 -0
- package/src/embedded/kgqa/types.ts +6 -0
- package/src/embedded/kgqa-service.ts +122 -0
- package/src/embedded/llm-extractor.ts +10 -0
- package/src/embedded/local-embeddings.ts +36 -0
- package/src/embedded/personal.ts +100 -0
- package/src/embedded/reranker.ts +62 -0
- package/src/embedded/retrieval/decay-stage.ts +59 -0
- package/src/embedded/retrieval/diversity.ts +37 -0
- package/src/embedded/retrieval/fuse.ts +52 -0
- package/src/embedded/retrieval/graph-stage.ts +45 -0
- package/src/embedded/retrieval/hybrid-retriever.ts +80 -0
- package/src/embedded/retrieval/keyword-stage.ts +27 -0
- package/src/embedded/retrieval/passage.ts +44 -0
- package/src/embedded/retrieval/rerank-stage.ts +31 -0
- package/src/embedded/retrieval/trace.ts +31 -0
- package/src/embedded/retrieval/vector-stage.ts +15 -0
- package/src/embedded/sqlite-graph-provider.ts +119 -0
- package/src/embedded/sqlite-store.ts +95 -0
- package/src/embedded/sqlite-vec-service.ts +122 -0
- package/src/embedded/store/chunks.ts +61 -0
- package/src/embedded/store/fts.ts +50 -0
- package/src/embedded/store/nodes-edges.ts +112 -0
- package/src/embedded/store/salience.ts +37 -0
- package/src/embedded/store/schema.ts +109 -0
- package/src/embedded/transformers-embeddings.ts +100 -0
- package/src/embeddings.ts +51 -0
- package/src/eval/goldset.ts +46 -0
- package/src/eval/harness.ts +65 -0
- package/src/eval/metrics.ts +38 -0
- package/src/http/server.ts +93 -0
- package/src/index.ts +44 -0
- package/src/install/index.ts +139 -0
- package/src/install/platforms.ts +126 -0
- package/src/install/skill.ts +46 -0
- package/src/install/writers.ts +82 -0
- package/src/mcp/backend.ts +129 -0
- package/src/mcp/server.ts +83 -0
- package/src/mcp/tools/context-about.ts +69 -0
- package/src/mcp/tools/context-graph.ts +23 -0
- package/src/mcp/tools/context-ingest.ts +88 -0
- package/src/mcp/tools/context-rebuild.ts +22 -0
- package/src/mcp/tools/context-relate.ts +88 -0
- package/src/mcp/tools/get-context.ts +52 -0
- package/src/mcp/tools/index.ts +40 -0
- package/src/mcp/tools/types.ts +33 -0
- package/src/permission.ts +72 -0
- package/src/provider.ts +65 -0
- package/src/qdrant-vector.ts +76 -0
- package/src/retrieval.ts +218 -0
- package/src/service.ts +40 -0
- package/src/types.ts +91 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Real semantic embeddings via transformers.js (ONNX, in-process). Optional +
|
|
2
|
+
// lazy: the package is only imported on first use, so the project runs fine
|
|
3
|
+
// without it (falls back to LocalHashEmbeddings). Install to enable:
|
|
4
|
+
// bun add @huggingface/transformers
|
|
5
|
+
// Select at runtime: CONTEXT_EMBEDDINGS=transformers [CONTEXT_EMBED_MODEL=...].
|
|
6
|
+
|
|
7
|
+
import type { EmbeddingProvider } from "../provider"
|
|
8
|
+
|
|
9
|
+
// Asymmetric models need a TASK PREFIX that differs for queries vs documents.
|
|
10
|
+
// EmbeddingGemma (the current best sub-500M model, +8 MTEB over bge-small) is the
|
|
11
|
+
// headline case; symmetric models (bge/gte) get no prefix.
|
|
12
|
+
function prefixesFor(model: string): { query: string; doc: string } | null {
|
|
13
|
+
const m = model.toLowerCase()
|
|
14
|
+
if (m.includes("embeddinggemma")) return { query: "task: search result | query: ", doc: "title: none | text: " }
|
|
15
|
+
if (m.includes("e5") || m.includes("multilingual-e5")) return { query: "query: ", doc: "passage: " }
|
|
16
|
+
return null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class TransformersEmbeddings implements EmbeddingProvider {
|
|
20
|
+
private extractor: ((text: string, opts: unknown) => Promise<{ data: ArrayLike<number> }>) | null = null
|
|
21
|
+
private readonly prefix: { query: string; doc: string } | null
|
|
22
|
+
// Matryoshka: truncate to CONTEXT_EMBED_DIM then re-normalize - big storage/speed
|
|
23
|
+
// win at minimal quality loss. EmbeddingGemma (native 768) defaults to 256; 0 ⇒ full.
|
|
24
|
+
private readonly dim: number
|
|
25
|
+
constructor(private readonly model = "Xenova/bge-small-en-v1.5") {
|
|
26
|
+
this.prefix = prefixesFor(model)
|
|
27
|
+
const envDim = Number(process.env.CONTEXT_EMBED_DIM ?? 0) || 0
|
|
28
|
+
this.dim = envDim || (model.toLowerCase().includes("embeddinggemma") ? 256 : 0)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private async pipe() {
|
|
32
|
+
if (this.extractor) return this.extractor
|
|
33
|
+
// Specifier typed as `string` so tsc doesn't require the optional dep to resolve.
|
|
34
|
+
const spec = "@huggingface/transformers"
|
|
35
|
+
const mod: any = await import(spec as string).catch(() => import("@xenova/transformers" as string))
|
|
36
|
+
if (mod?.env) mod.env.allowLocalModels = false
|
|
37
|
+
this.extractor = await mod.pipeline("feature-extraction", this.model)
|
|
38
|
+
return this.extractor!
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private async embed(text: string, kind: "query" | "doc"): Promise<number[]> {
|
|
42
|
+
const ex = await this.pipe()
|
|
43
|
+
const input = this.prefix ? this.prefix[kind] + text : text
|
|
44
|
+
const out = await ex(input, { pooling: "mean", normalize: true })
|
|
45
|
+
let v = Array.from(out.data as ArrayLike<number>)
|
|
46
|
+
if (this.dim && this.dim < v.length) {
|
|
47
|
+
v = v.slice(0, this.dim) // Matryoshka truncation
|
|
48
|
+
let n = 0
|
|
49
|
+
for (const x of v) n += x * x
|
|
50
|
+
n = Math.sqrt(n) || 1
|
|
51
|
+
v = v.map((x) => x / n) // re-normalize the truncated vector
|
|
52
|
+
}
|
|
53
|
+
return v
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async embedDense(text: string): Promise<number[]> {
|
|
57
|
+
return this.embed(text, "doc")
|
|
58
|
+
}
|
|
59
|
+
async embedQuery(text: string): Promise<number[]> {
|
|
60
|
+
return this.embed(text, "query")
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// No native sparse vector from this model - retrieval runs dense-only here.
|
|
64
|
+
async embedSparse(): Promise<{ indices: number[]; values: number[] }> {
|
|
65
|
+
return { indices: [], values: [] }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Default embedder: real semantic (transformers) when it can load, else the
|
|
70
|
+
// offline keyword-hash. The first call decides and sticks, so ingest + query in a
|
|
71
|
+
// session always use the SAME embedder (consistent vector space).
|
|
72
|
+
import { LocalHashEmbeddings } from "./local-embeddings"
|
|
73
|
+
export class AutoEmbeddings implements EmbeddingProvider {
|
|
74
|
+
private chosen: EmbeddingProvider | null = null
|
|
75
|
+
private readonly real: TransformersEmbeddings
|
|
76
|
+
private readonly fallback = new LocalHashEmbeddings()
|
|
77
|
+
constructor(model?: string) {
|
|
78
|
+
this.real = new TransformersEmbeddings(model)
|
|
79
|
+
}
|
|
80
|
+
private async pick(): Promise<EmbeddingProvider> {
|
|
81
|
+
if (this.chosen) return this.chosen
|
|
82
|
+
try {
|
|
83
|
+
await this.real.embedDense("warmup")
|
|
84
|
+
this.chosen = this.real
|
|
85
|
+
} catch {
|
|
86
|
+
this.chosen = this.fallback // no package / offline → keyword hash
|
|
87
|
+
}
|
|
88
|
+
return this.chosen
|
|
89
|
+
}
|
|
90
|
+
async embedDense(q: string) {
|
|
91
|
+
return (await this.pick()).embedDense(q)
|
|
92
|
+
}
|
|
93
|
+
async embedQuery(q: string) {
|
|
94
|
+
const e = await this.pick()
|
|
95
|
+
return e.embedQuery ? e.embedQuery(q) : e.embedDense(q)
|
|
96
|
+
}
|
|
97
|
+
async embedSparse(q: string) {
|
|
98
|
+
return (await this.pick()).embedSparse(q)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// EmbeddingProvider adapter. Dense embeddings come from an OpenAI-compatible
|
|
2
|
+
// endpoint (PipesHub's air-gapped embedding server exposes /v1/embeddings); sparse
|
|
3
|
+
// (BM25) comes from a configurable sparse endpoint. Both over fetch - no SDK,
|
|
4
|
+
// nothing leaves the boundary if the endpoints are local.
|
|
5
|
+
|
|
6
|
+
import type { EmbeddingProvider } from "./provider"
|
|
7
|
+
|
|
8
|
+
export interface EmbeddingConfig {
|
|
9
|
+
/** OpenAI-compatible base, e.g. http://localhost:8002 */
|
|
10
|
+
denseEndpoint: string
|
|
11
|
+
denseModel: string
|
|
12
|
+
denseApiKey?: string
|
|
13
|
+
/** Endpoint returning { indices, values } for a BM25/sparse vector. Optional;
|
|
14
|
+
* if absent, embedSparse throws (callers may run dense-only). */
|
|
15
|
+
sparseEndpoint?: string
|
|
16
|
+
fetchImpl?: typeof fetch
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class HttpEmbeddingProvider implements EmbeddingProvider {
|
|
20
|
+
private readonly fetch: typeof fetch
|
|
21
|
+
constructor(private readonly cfg: EmbeddingConfig) {
|
|
22
|
+
this.fetch = cfg.fetchImpl ?? fetch
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async embedDense(query: string): Promise<number[]> {
|
|
26
|
+
const res = await this.fetch(`${this.cfg.denseEndpoint.replace(/\/$/, "")}/v1/embeddings`, {
|
|
27
|
+
method: "POST",
|
|
28
|
+
headers: {
|
|
29
|
+
"content-type": "application/json",
|
|
30
|
+
...(this.cfg.denseApiKey ? { authorization: `Bearer ${this.cfg.denseApiKey}` } : {}),
|
|
31
|
+
},
|
|
32
|
+
body: JSON.stringify({ input: query, model: this.cfg.denseModel }),
|
|
33
|
+
})
|
|
34
|
+
const body = (await res.json()) as { data?: Array<{ embedding: number[] }>; error?: unknown }
|
|
35
|
+
const embedding = body.data?.[0]?.embedding
|
|
36
|
+
if (!embedding) throw new Error(`dense embed failed: ${JSON.stringify(body.error ?? res.status)}`)
|
|
37
|
+
return embedding
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async embedSparse(query: string): Promise<{ indices: number[]; values: number[] }> {
|
|
41
|
+
if (!this.cfg.sparseEndpoint) throw new Error("no sparse endpoint configured")
|
|
42
|
+
const res = await this.fetch(this.cfg.sparseEndpoint, {
|
|
43
|
+
method: "POST",
|
|
44
|
+
headers: { "content-type": "application/json" },
|
|
45
|
+
body: JSON.stringify({ input: query }),
|
|
46
|
+
})
|
|
47
|
+
const body = (await res.json()) as { indices?: number[]; values?: number[] }
|
|
48
|
+
if (!body.indices || !body.values) throw new Error(`sparse embed failed: ${res.status}`)
|
|
49
|
+
return { indices: body.indices, values: body.values }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Gold-set generation - build a Q→relevant-record eval set from your OWN stored data,
|
|
2
|
+
// so retrieval quality can be measured without hand-labeling. Two paths:
|
|
3
|
+
// • generateGoldSet (here): DETERMINISTIC - for each record, form a query from its
|
|
4
|
+
// chunk's salient terms; the source record is the gold label. Zero LLM, great for
|
|
5
|
+
// regression detection (does a query built from a record's own content retrieve it?).
|
|
6
|
+
// • LLM path (the frontier model): generate natural / multi-hop questions whose source
|
|
7
|
+
// chunk(s) are the gold labels - richer, but needs the calling model. (Doc only.)
|
|
8
|
+
|
|
9
|
+
import type { SqliteStore } from "../embedded/sqlite-store"
|
|
10
|
+
import type { GoldItem } from "./harness"
|
|
11
|
+
|
|
12
|
+
const STOP = new Set([
|
|
13
|
+
"the", "a", "an", "and", "or", "but", "for", "with", "from", "into", "of", "on", "in", "at", "to", "by", "as",
|
|
14
|
+
"is", "are", "was", "were", "be", "this", "that", "these", "those", "it", "its", "will", "has", "have", "had",
|
|
15
|
+
"more", "after", "over", "across", "than", "their", "they", "you", "your", "our", "we",
|
|
16
|
+
])
|
|
17
|
+
|
|
18
|
+
/** One query per record (default), built from the most salient terms of its first chunk. */
|
|
19
|
+
export function generateGoldSet(store: SqliteStore, opts: { terms?: number; perRecord?: number } = {}): GoldItem[] {
|
|
20
|
+
const nTerms = opts.terms ?? 6
|
|
21
|
+
const rows = store.db
|
|
22
|
+
.query(
|
|
23
|
+
`SELECT c.virtual_record_id v, c.content content
|
|
24
|
+
FROM chunks c
|
|
25
|
+
GROUP BY c.virtual_record_id`,
|
|
26
|
+
)
|
|
27
|
+
.all() as Array<{ v: string; content: string }>
|
|
28
|
+
const gold: GoldItem[] = []
|
|
29
|
+
for (const r of rows) {
|
|
30
|
+
const terms = salientTerms(r.content, nTerms)
|
|
31
|
+
if (terms.length >= 2) gold.push({ query: terms.join(" "), gold: [r.v] })
|
|
32
|
+
}
|
|
33
|
+
return gold
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Top-N distinct content words by length (rare/specific terms first), boilerplate-free. */
|
|
37
|
+
function salientTerms(text: string, n: number): string[] {
|
|
38
|
+
const seen = new Set<string>()
|
|
39
|
+
const words: string[] = []
|
|
40
|
+
for (const w of (text.toLowerCase().match(/[a-z0-9][a-z0-9-]{2,}/g) ?? [])) {
|
|
41
|
+
if (STOP.has(w) || seen.has(w)) continue
|
|
42
|
+
seen.add(w)
|
|
43
|
+
words.push(w)
|
|
44
|
+
}
|
|
45
|
+
return words.sort((a, b) => b.length - a.length).slice(0, n)
|
|
46
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Eval harness - runs a gold Q→relevant-id set through any retrieval function and
|
|
2
|
+
// reports aggregate metrics, so every retrieval change is MEASURED, not eyeballed.
|
|
3
|
+
// The gold set is meant to be auto-synthesized from your OWN corpus/graph (the
|
|
4
|
+
// frontier model that calls the MCP generates questions whose source record id IS the
|
|
5
|
+
// gold label; 2-hop graph walks give multi-hop gold pairs). Here we keep the harness
|
|
6
|
+
// retrieval-agnostic - pass any `retrieve(query) → ranked ids`.
|
|
7
|
+
|
|
8
|
+
import { recallAtK, precisionAtK, reciprocalRank, ndcgAtK } from "./metrics"
|
|
9
|
+
|
|
10
|
+
export interface GoldItem {
|
|
11
|
+
query: string
|
|
12
|
+
/** Ids that SHOULD be retrieved (record ids by default). */
|
|
13
|
+
gold: string[]
|
|
14
|
+
/** Optional graded relevance per id (0..3) for nDCG. */
|
|
15
|
+
grades?: Record<string, number>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface EvalReport {
|
|
19
|
+
n: number
|
|
20
|
+
k: number
|
|
21
|
+
recall: number
|
|
22
|
+
ndcg: number
|
|
23
|
+
mrr: number
|
|
24
|
+
precision: number
|
|
25
|
+
perQuery: Array<{ query: string; recall: number; precision: number; ndcg: number; rr: number; missed: boolean }>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const mean = (xs: number[]) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0)
|
|
29
|
+
|
|
30
|
+
/** Run the gold set through `retrieve` and aggregate recall@k / nDCG@k / MRR / P@k. */
|
|
31
|
+
export async function evaluate(
|
|
32
|
+
gold: GoldItem[],
|
|
33
|
+
retrieve: (query: string) => Promise<string[]>,
|
|
34
|
+
k = 10,
|
|
35
|
+
): Promise<EvalReport> {
|
|
36
|
+
const per: EvalReport["perQuery"] = []
|
|
37
|
+
for (const item of gold) {
|
|
38
|
+
// dedupe the ranked ids (a record can yield several chunks/passages) - record-level
|
|
39
|
+
// metrics care about each record's FIRST position, so dupes must not double-count.
|
|
40
|
+
const seen = new Set<string>()
|
|
41
|
+
const ranked = (await retrieve(item.query)).filter((id) => (seen.has(id) ? false : (seen.add(id), true)))
|
|
42
|
+
const goldSet = new Set(item.gold)
|
|
43
|
+
const grades = item.grades ? new Map(Object.entries(item.grades)) : undefined
|
|
44
|
+
const recall = recallAtK(ranked, goldSet, k)
|
|
45
|
+
const precision = precisionAtK(ranked, goldSet, k)
|
|
46
|
+
const ndcg = ndcgAtK(ranked, goldSet, k, grades)
|
|
47
|
+
const rr = reciprocalRank(ranked, goldSet)
|
|
48
|
+
per.push({ query: item.query, recall, precision, ndcg, rr, missed: recall === 0 })
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
n: gold.length,
|
|
52
|
+
k,
|
|
53
|
+
recall: mean(per.map((p) => p.recall)),
|
|
54
|
+
ndcg: mean(per.map((p) => p.ndcg)),
|
|
55
|
+
mrr: mean(per.map((p) => p.rr)),
|
|
56
|
+
precision: mean(per.map((p) => p.precision)),
|
|
57
|
+
perQuery: per,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Pretty one-line summary for CLI/CI diffing against a baseline. */
|
|
62
|
+
export function formatReport(r: EvalReport): string {
|
|
63
|
+
const missed = r.perQuery.filter((p) => p.missed).length
|
|
64
|
+
return `eval n=${r.n} k=${r.k} | recall@${r.k}=${r.recall.toFixed(3)} nDCG@${r.k}=${r.ndcg.toFixed(3)} MRR=${r.mrr.toFixed(3)} | misses=${missed}`
|
|
65
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Retrieval metrics - pure functions over a ranked list of ids + the gold-relevant
|
|
2
|
+
// set. No LLM, deterministic, repeatable: this is what replaces "tuning by eyeballing".
|
|
3
|
+
// Track recall@k (did the right item come back?) + nDCG@k (is it ranked well?) as the
|
|
4
|
+
// two headline numbers, per the 2026 RAG-eval consensus.
|
|
5
|
+
|
|
6
|
+
/** Fraction of gold items present in the top-k ranked list. */
|
|
7
|
+
export function recallAtK(ranked: string[], gold: Set<string>, k: number): number {
|
|
8
|
+
if (gold.size === 0) return 1
|
|
9
|
+
const top = ranked.slice(0, k)
|
|
10
|
+
let hit = 0
|
|
11
|
+
for (const g of gold) if (top.includes(g)) hit++
|
|
12
|
+
return hit / gold.size
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Fraction of the top-k that are gold-relevant. */
|
|
16
|
+
export function precisionAtK(ranked: string[], gold: Set<string>, k: number): number {
|
|
17
|
+
const top = ranked.slice(0, k)
|
|
18
|
+
if (top.length === 0) return 0
|
|
19
|
+
return top.filter((id) => gold.has(id)).length / top.length
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Mean reciprocal rank: 1 / (rank of first gold hit), else 0. */
|
|
23
|
+
export function reciprocalRank(ranked: string[], gold: Set<string>): number {
|
|
24
|
+
for (let i = 0; i < ranked.length; i++) if (gold.has(ranked[i])) return 1 / (i + 1)
|
|
25
|
+
return 0
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** nDCG@k with optional graded relevance (default binary). Rank-aware. */
|
|
29
|
+
export function ndcgAtK(ranked: string[], gold: Set<string>, k: number, grades?: Map<string, number>): number {
|
|
30
|
+
const rel = (id: string) => (grades ? grades.get(id) ?? 0 : gold.has(id) ? 1 : 0)
|
|
31
|
+
let dcg = 0
|
|
32
|
+
for (let i = 0; i < Math.min(k, ranked.length); i++) dcg += (2 ** rel(ranked[i]) - 1) / Math.log2(i + 2)
|
|
33
|
+
// ideal DCG: sort all known-relevant grades descending
|
|
34
|
+
const ideal = [...(grades ? grades.values() : [...gold].map(() => 1))].sort((a, b) => b - a).slice(0, k)
|
|
35
|
+
let idcg = 0
|
|
36
|
+
for (let i = 0; i < ideal.length; i++) idcg += (2 ** ideal[i] - 1) / Math.log2(i + 2)
|
|
37
|
+
return idcg === 0 ? 0 : dcg / idcg
|
|
38
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// HTTP API over the REAL embedded backend - the dashboard's live wire. The backend
|
|
3
|
+
// uses bun:sqlite + in-process transformers/reranker, so it can't run inside the
|
|
4
|
+
// Next.js (Node) dashboard; this small bun server exposes it over HTTP (CORS-open for
|
|
5
|
+
// localhost) so the UI drives genuine retrieval: hybrid BM25+dense+graph → RRF →
|
|
6
|
+
// reranker → passage, KGQA exact answers, Personalized PageRank, and the eval harness.
|
|
7
|
+
// Identity comes from the same CONTEXT_USER_ID/ORG_ID env as the MCP (single-user by
|
|
8
|
+
// default). Run: bun run src/http/server.ts (port CONTEXT_HTTP_PORT, default 4318).
|
|
9
|
+
|
|
10
|
+
import { personalContext } from "../embedded/personal"
|
|
11
|
+
import { generateGoldSet } from "../eval/goldset"
|
|
12
|
+
import { evaluate } from "../eval/harness"
|
|
13
|
+
|
|
14
|
+
const ctx = personalContext()
|
|
15
|
+
const U = ctx.userId
|
|
16
|
+
const O = ctx.orgId
|
|
17
|
+
const PORT = Number(process.env.CONTEXT_HTTP_PORT ?? 4318)
|
|
18
|
+
|
|
19
|
+
const CORS = {
|
|
20
|
+
"Access-Control-Allow-Origin": "*",
|
|
21
|
+
"Access-Control-Allow-Headers": "*",
|
|
22
|
+
"Access-Control-Allow-Methods": "GET,POST,OPTIONS",
|
|
23
|
+
"content-type": "application/json",
|
|
24
|
+
}
|
|
25
|
+
const json = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status, headers: CORS })
|
|
26
|
+
|
|
27
|
+
Bun.serve({
|
|
28
|
+
port: PORT,
|
|
29
|
+
idleTimeout: 60, // retrieval can download a model on first call
|
|
30
|
+
async fetch(req) {
|
|
31
|
+
if (req.method === "OPTIONS") return new Response(null, { headers: CORS })
|
|
32
|
+
const url = new URL(req.url)
|
|
33
|
+
const body: any = req.method === "POST" ? await req.json().catch(() => ({})) : {}
|
|
34
|
+
try {
|
|
35
|
+
switch (url.pathname) {
|
|
36
|
+
case "/api/health":
|
|
37
|
+
return json({ ok: true, user: U, org: O })
|
|
38
|
+
|
|
39
|
+
// hybrid retrieval (BM25 + dense + graph → RRF → reranker → passage) + TRACE
|
|
40
|
+
case "/api/search": {
|
|
41
|
+
const { response, trace } = await ctx.searchTraced(String(body.query ?? ""), U, O)
|
|
42
|
+
return json({
|
|
43
|
+
query: body.query,
|
|
44
|
+
results: response.searchResults.map((r) => ({
|
|
45
|
+
content: r.content,
|
|
46
|
+
recordName: r.metadata.recordName ?? "untitled",
|
|
47
|
+
recordId: r.metadata.recordId,
|
|
48
|
+
score: r.score,
|
|
49
|
+
citationType: r.citationType,
|
|
50
|
+
})),
|
|
51
|
+
trace, // how the query flowed through the pipeline
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// KGQA exact answer from the typed graph (null ⇒ fell back to ranked)
|
|
56
|
+
case "/api/ask":
|
|
57
|
+
return json({ answer: await ctx.ask(String(body.query ?? ""), U, O) })
|
|
58
|
+
|
|
59
|
+
// Personalized PageRank multi-hop walk
|
|
60
|
+
case "/api/walk": {
|
|
61
|
+
const seeds = Array.isArray(body.seeds)
|
|
62
|
+
? body.seeds
|
|
63
|
+
: String(body.seed ?? "").split(/\s*,\s*/).filter(Boolean)
|
|
64
|
+
return json({ ranked: await ctx.graphQuery.walk(seeds, U, O, { limit: 25 }) })
|
|
65
|
+
}
|
|
66
|
+
case "/api/neighbors":
|
|
67
|
+
return json({ result: await ctx.graphQuery.neighbors(String(body.entity ?? ""), U, O) })
|
|
68
|
+
case "/api/path":
|
|
69
|
+
return json({ result: await ctx.graphQuery.pathBetween(String(body.a ?? ""), String(body.b ?? ""), U, O) })
|
|
70
|
+
case "/api/communities":
|
|
71
|
+
return json({ communities: await ctx.graphQuery.communities(U, O) })
|
|
72
|
+
|
|
73
|
+
// measure retrieval quality on a gold set auto-built from the user's own data
|
|
74
|
+
case "/api/eval": {
|
|
75
|
+
const gold = generateGoldSet(ctx.store, { terms: 6 })
|
|
76
|
+
const report = await evaluate(
|
|
77
|
+
gold,
|
|
78
|
+
async (q) => (await ctx.searchWithGraph(q, U, O)).searchResults.map((r) => r.metadata.recordId as string),
|
|
79
|
+
10,
|
|
80
|
+
)
|
|
81
|
+
return json({ report })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
default:
|
|
85
|
+
return json({ error: "not found", paths: ["/api/health", "/api/search", "/api/ask", "/api/walk", "/api/neighbors", "/api/path", "/api/communities", "/api/eval"] }, 404)
|
|
86
|
+
}
|
|
87
|
+
} catch (e) {
|
|
88
|
+
return json({ error: String(e) }, 500)
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
console.error(`[context-http] backend API ready on http://localhost:${PORT} (user=${U}, org=${O})`)
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Context layer - the permission-aware retrieval moat, ported natively to TS.
|
|
2
|
+
// See ./README.md for the port provenance and what still plugs in behind the seams.
|
|
3
|
+
|
|
4
|
+
export * from "./permission"
|
|
5
|
+
export * from "./types"
|
|
6
|
+
export * from "./provider"
|
|
7
|
+
export { ArangoGraphProvider } from "./arango-graph-provider"
|
|
8
|
+
export { RetrievalService, type RetrievalDeps } from "./retrieval"
|
|
9
|
+
export { ArangoHttpClient, type ArangoConfig } from "./arango-client"
|
|
10
|
+
export { QdrantVectorService, type QdrantConfig } from "./qdrant-vector"
|
|
11
|
+
export { HttpEmbeddingProvider, type EmbeddingConfig } from "./embeddings"
|
|
12
|
+
export { buildContextService, type ContextConfig, type ContextService, type ContextLog } from "./service"
|
|
13
|
+
|
|
14
|
+
import { ArangoGraphProvider } from "./arango-graph-provider"
|
|
15
|
+
import { RetrievalService } from "./retrieval"
|
|
16
|
+
import type { ArangoClient, EmbeddingProvider, VectorDBService } from "./provider"
|
|
17
|
+
|
|
18
|
+
/** Build a ready retrieval service from the three backend seams. */
|
|
19
|
+
export function createContext(opts: {
|
|
20
|
+
arango: ArangoClient
|
|
21
|
+
vector: VectorDBService
|
|
22
|
+
embeddings: EmbeddingProvider
|
|
23
|
+
collectionName: string
|
|
24
|
+
log?: RetrievalServiceLog
|
|
25
|
+
}) {
|
|
26
|
+
const graph = new ArangoGraphProvider(opts.arango, opts.log)
|
|
27
|
+
const retrieval = new RetrievalService({
|
|
28
|
+
graph,
|
|
29
|
+
vector: opts.vector,
|
|
30
|
+
embeddings: opts.embeddings,
|
|
31
|
+
collectionName: opts.collectionName,
|
|
32
|
+
log: opts.log,
|
|
33
|
+
})
|
|
34
|
+
return { graph, retrieval }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type RetrievalServiceLog = {
|
|
38
|
+
info: (m: string) => void
|
|
39
|
+
debug: (m: string) => void
|
|
40
|
+
error: (m: string, ...a: unknown[]) => void
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Re-export the backend seam types for callers wiring adapters.
|
|
44
|
+
export type { ArangoClient, EmbeddingProvider, VectorDBService } from "./provider"
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// `chitta install` — wire Chitta into AI tools as an MCP server (everywhere) and a Skill
|
|
2
|
+
// (where supported). One command; merges into existing config; idempotent.
|
|
3
|
+
//
|
|
4
|
+
// chitta install # auto-detect installed tools (global), install to each
|
|
5
|
+
// chitta install --platform cursor,zed # specific tools
|
|
6
|
+
// chitta install --all # every supported tool
|
|
7
|
+
// chitta install --platform claude-code --project [--project-dir .] # project-scoped
|
|
8
|
+
// chitta install --print # print the generic MCP snippet, write nothing
|
|
9
|
+
// chitta install --list # list supported tools
|
|
10
|
+
// chitta install --user-id alice --org-id acme # bake identity into the config env
|
|
11
|
+
// chitta uninstall [--platform ...] [--project] # remove the chitta entry/skill
|
|
12
|
+
import { existsSync, readFileSync, writeFileSync, rmSync } from "node:fs"
|
|
13
|
+
import { dirname, join, resolve } from "node:path"
|
|
14
|
+
import { PLATFORMS, byId, type Platform } from "./platforms"
|
|
15
|
+
import { serverEntry, writeJsonConfig, writeCodexToml, printSnippet } from "./writers"
|
|
16
|
+
import { installSkill } from "./skill"
|
|
17
|
+
|
|
18
|
+
const argv = process.argv.slice(2)
|
|
19
|
+
const action = argv[0] // "install" | "uninstall"
|
|
20
|
+
const flag = (name: string): string | undefined => {
|
|
21
|
+
const i = argv.indexOf(`--${name}`)
|
|
22
|
+
return i >= 0 && i + 1 < argv.length && !argv[i + 1].startsWith("--") ? argv[i + 1] : undefined
|
|
23
|
+
}
|
|
24
|
+
const has = (name: string) => argv.includes(`--${name}`)
|
|
25
|
+
|
|
26
|
+
const env: Record<string, string> = {}
|
|
27
|
+
if (flag("user-id")) env.CONTEXT_USER_ID = flag("user-id")!
|
|
28
|
+
if (flag("org-id")) env.CONTEXT_ORG_ID = flag("org-id")!
|
|
29
|
+
|
|
30
|
+
const project = has("project")
|
|
31
|
+
const projectDir = resolve(flag("project-dir") ?? ".")
|
|
32
|
+
const withSkill = !has("no-skill")
|
|
33
|
+
|
|
34
|
+
function targetConfigPath(p: Platform): string | null {
|
|
35
|
+
if (project) return p.project ? join(projectDir, p.project) : null
|
|
36
|
+
return p.global
|
|
37
|
+
}
|
|
38
|
+
function targetSkillDir(p: Platform): string | null {
|
|
39
|
+
if (!withSkill) return null
|
|
40
|
+
if (project) return p.skillProject ? join(projectDir, p.skillProject) : null
|
|
41
|
+
return p.skillGlobal ?? null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A tool counts as "present" if its config dir/file already exists on this machine. */
|
|
45
|
+
function detected(p: Platform): boolean {
|
|
46
|
+
if (!p.global) return false
|
|
47
|
+
return existsSync(p.global) || existsSync(dirname(p.global))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function resolvePlatforms(): Platform[] {
|
|
51
|
+
if (has("all")) return PLATFORMS
|
|
52
|
+
const csv = flag("platform")
|
|
53
|
+
if (csv) {
|
|
54
|
+
const ids = csv.split(",").map((s) => s.trim()).filter(Boolean)
|
|
55
|
+
const out: Platform[] = []
|
|
56
|
+
for (const id of ids) {
|
|
57
|
+
const p = byId(id)
|
|
58
|
+
if (!p) { console.error(`unknown platform "${id}" — run \`chitta install --list\``); process.exit(1) }
|
|
59
|
+
out.push(p)
|
|
60
|
+
}
|
|
61
|
+
return out
|
|
62
|
+
}
|
|
63
|
+
// no --platform: project mode defaults to Claude Code; global mode auto-detects.
|
|
64
|
+
if (project) return [byId("claude-code")!]
|
|
65
|
+
const found = PLATFORMS.filter(detected)
|
|
66
|
+
if (found.length === 0) {
|
|
67
|
+
console.error("No supported AI tools detected. Use --platform <id> or --all, or --list.")
|
|
68
|
+
process.exit(1)
|
|
69
|
+
}
|
|
70
|
+
return found
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function doInstall(p: Platform): string {
|
|
74
|
+
const skillDir = targetSkillDir(p)
|
|
75
|
+
const skillNote = skillDir ? ` + skill → ${installSkill(skillDir)}` : ""
|
|
76
|
+
if (p.format === "manual" || (!project && p.global === null)) {
|
|
77
|
+
return `~ ${p.label}: no stable config path — add manually (see --print).${skillNote}`
|
|
78
|
+
}
|
|
79
|
+
const path = targetConfigPath(p)
|
|
80
|
+
if (!path) return `~ ${p.label}: no ${project ? "project" : "global"} config path.${skillNote}`
|
|
81
|
+
if (p.format === "toml") {
|
|
82
|
+
writeCodexToml(path, env)
|
|
83
|
+
} else {
|
|
84
|
+
writeJsonConfig(path, p.key!, serverEntry(p.entry!, env), p.format === "json-array")
|
|
85
|
+
}
|
|
86
|
+
return `✓ ${p.label} → ${path}${p.note ? `\n (${p.note})` : ""}${skillNote}`
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function doUninstall(p: Platform): string {
|
|
90
|
+
const path = targetConfigPath(p)
|
|
91
|
+
const lines: string[] = []
|
|
92
|
+
if (path && existsSync(path)) {
|
|
93
|
+
if (p.format === "toml") {
|
|
94
|
+
const t = readFileSync(path, "utf8").replace(/\n*\[mcp_servers\.chitta\][\s\S]*?(?=\n\[[^.\]]|\s*$)/g, "\n")
|
|
95
|
+
writeFileSync(path, t.replace(/\n{3,}/g, "\n\n").trimStart())
|
|
96
|
+
} else {
|
|
97
|
+
try {
|
|
98
|
+
const cfg = JSON.parse(readFileSync(path, "utf8"))
|
|
99
|
+
const c = cfg[p.key!]
|
|
100
|
+
if (Array.isArray(c)) cfg[p.key!] = c.filter((e: any) => e?.name !== "chitta")
|
|
101
|
+
else if (c && typeof c === "object") delete c["chitta"]
|
|
102
|
+
writeFileSync(path, JSON.stringify(cfg, null, 2) + "\n")
|
|
103
|
+
} catch { /* ignore malformed */ }
|
|
104
|
+
}
|
|
105
|
+
lines.push(`✓ removed from ${path}`)
|
|
106
|
+
}
|
|
107
|
+
const skillDir = targetSkillDir(p)
|
|
108
|
+
if (skillDir && existsSync(join(skillDir, "chitta"))) {
|
|
109
|
+
rmSync(join(skillDir, "chitta"), { recursive: true, force: true })
|
|
110
|
+
lines.push(`✓ removed skill ${join(skillDir, "chitta")}`)
|
|
111
|
+
}
|
|
112
|
+
return `${p.label}:\n ${lines.length ? lines.join("\n ") : "(nothing to remove)"}`
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── main ───────────────────────────────────────────────────────────────────────
|
|
116
|
+
if (has("list")) {
|
|
117
|
+
console.log("Supported platforms:\n")
|
|
118
|
+
for (const p of PLATFORMS) {
|
|
119
|
+
const tags = [p.skillGlobal || p.skillProject ? "skill" : "", p.format].filter(Boolean).join(", ")
|
|
120
|
+
console.log(` ${p.id.padEnd(15)} ${p.label.padEnd(22)} (${tags})`)
|
|
121
|
+
}
|
|
122
|
+
console.log(`\nUsage: chitta install --platform <id>[,<id>...] | --all [--project] [--user-id X --org-id Y]`)
|
|
123
|
+
process.exit(0)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (has("print")) {
|
|
127
|
+
console.log("Add this to your MCP client's config (key is usually `mcpServers`):\n")
|
|
128
|
+
console.log(printSnippet(env))
|
|
129
|
+
process.exit(0)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const platforms = resolvePlatforms()
|
|
133
|
+
const run = action === "uninstall" ? doUninstall : doInstall
|
|
134
|
+
console.log(`Chitta ${action} — ${project ? "project" : "global"} scope${project ? ` (${projectDir})` : ""}\n`)
|
|
135
|
+
for (const p of platforms) console.log(run(p) + "\n")
|
|
136
|
+
if (action === "install") {
|
|
137
|
+
console.log("Done. Restart the tool (or reload its MCP config) to pick up Chitta's tools:")
|
|
138
|
+
console.log(" context_ingest · get_context · context_graph")
|
|
139
|
+
}
|