explainmyrepo 0.1.1
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 +165 -0
- package/assets/design-system/design-system.css +833 -0
- package/assets/design-system/theme-example.css +83 -0
- package/bin/explainmyrepo.mjs +115 -0
- package/kb/ask-kb.mjs +1487 -0
- package/kb/build-kb.mjs +353 -0
- package/kb/corpus-rules.mjs +341 -0
- package/kb/dep-graph.mjs +184 -0
- package/kb/entrypoints.mjs +207 -0
- package/kb/extract-symbols.mjs +322 -0
- package/kb/index-primer.mjs +255 -0
- package/kb/kb-mcp-server.mjs +186 -0
- package/kb/kb.config.mjs +1362 -0
- package/kb/make-dropin.mjs +224 -0
- package/kb/resolve-deps.mjs +126 -0
- package/package.json +52 -0
- package/src/brain.mjs +298 -0
- package/src/build-context.mjs +66 -0
- package/src/claude.mjs +97 -0
- package/src/env.mjs +77 -0
- package/src/orchestrator.mjs +419 -0
- package/src/run-tool.mjs +49 -0
- package/tools/CONTRACT.md +301 -0
- package/tools/assemble-page.mjs +631 -0
- package/tools/build-kb.mjs +159 -0
- package/tools/clone-repo.mjs +161 -0
- package/tools/deploy.mjs +160 -0
- package/tools/generate-image.mjs +280 -0
- package/tools/make-diagrams.mjs +835 -0
- package/tools/make-favicon.mjs +145 -0
- package/tools/make-pack.mjs +295 -0
- package/tools/make-social-card.mjs +198 -0
- package/tools/notify.mjs +327 -0
- package/tools/publish-repo.mjs +156 -0
- package/tools/quality-grade.mjs +746 -0
- package/tools/readme-enhance.mjs +310 -0
- package/tools/repo-seo.mjs +143 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// index-primer.mjs — TOP-DOWN ORIENTATION LAYER.
|
|
3
|
+
//
|
|
4
|
+
// Indexes a synthesized "primer" markdown INTO an existing Cognitum RVF knowledge base so the
|
|
5
|
+
// six top-down comprehension-journey questions (what is it / concepts / how each works /
|
|
6
|
+
// maturity / where are the docs / how to use end-to-end) return a whole synthesized section
|
|
7
|
+
// instead of a raw repo fragment. This is an INCREMENTAL append: it opens the existing .rvf
|
|
8
|
+
// read-write and ingests NEW ids that do not collide with existing ones, then appends matching
|
|
9
|
+
// records to the passages sidecar AND the id/meta index so PARITY holds for guard-check.
|
|
10
|
+
//
|
|
11
|
+
// Usage:
|
|
12
|
+
// node kb/index-primer.mjs ruvector # indexes ../ruvector-primer.md into ruvector-kb
|
|
13
|
+
// node kb/index-primer.mjs ruview # indexes ../ruview-primer.md into ruview-kb
|
|
14
|
+
//
|
|
15
|
+
// Section splitting: split the primer into logical documents on level-2 markdown headers
|
|
16
|
+
// (## ...), fence-aware (a '#' inside a ``` code block is NOT a header). Each ## section
|
|
17
|
+
// (with its nested ### subsections) is ONE logical document under a synthetic path
|
|
18
|
+
// `PRIMER#<slug>`. A short level-1 title/preamble at the top is folded into the first section
|
|
19
|
+
// so nothing is lost. Sections > CHUNK_CHARS are chunked, but all chunks keep the SAME
|
|
20
|
+
// synthetic path so whole-doc retrieval reassembles them.
|
|
21
|
+
//
|
|
22
|
+
// Deps resolved PORTABLY via resolve-deps.mjs (project node_modules -> env -> Mac paths).
|
|
23
|
+
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
import { loadRvf, loadTransformers, configureModel, chooseModelCache } from './resolve-deps.mjs';
|
|
28
|
+
import { targets } from './kb.config.mjs';
|
|
29
|
+
|
|
30
|
+
const KB_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
31
|
+
const ROOT = path.resolve(KB_DIR, '..');
|
|
32
|
+
|
|
33
|
+
const CHUNK_CHARS = 4000; // match the corpus chunker; sections under this stay whole
|
|
34
|
+
const OVERLAP_CHARS = 400; // same sliding-window overlap the corpus uses (stitch() de-overlaps)
|
|
35
|
+
|
|
36
|
+
// data lives in kb/stores/<store>/ when organized; flat kb/ otherwise. Indexes the primer
|
|
37
|
+
// sections into the SMALL (.small.rvf) build (the Seed default + the source of the bundles).
|
|
38
|
+
const sd = (s) => (fs.existsSync(path.join(KB_DIR, 'stores', s)) ? path.join(KB_DIR, 'stores', s) : KB_DIR);
|
|
39
|
+
|
|
40
|
+
// The index/meta sidecar + its chunk-field convention. Generic builds write <slug>-kb.ids.json
|
|
41
|
+
// with { chunk, of } ('split' style); legacy ruview used <slug>-kb.meta.json with "1/3" ('slash').
|
|
42
|
+
function resolveIndex(slug) {
|
|
43
|
+
const ids = path.join(sd(slug), `${slug}-kb.ids.json`);
|
|
44
|
+
const legacy = path.join(sd(slug), `${slug}-kb.meta.json`);
|
|
45
|
+
if (fs.existsSync(ids)) return { index: ids, chunkStyle: 'split' };
|
|
46
|
+
if (fs.existsSync(legacy)) return { index: legacy, chunkStyle: 'slash' };
|
|
47
|
+
return { index: ids, chunkStyle: 'split' }; // default for a not-yet-built store
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Primer path: kb/stores/<slug>/<slug>-primer.md (per plan §2a). Fall back to the legacy flat
|
|
51
|
+
// ROOT/<slug>-primer.md if a store-local primer is not present.
|
|
52
|
+
function primerPath(slug) {
|
|
53
|
+
const local = path.join(sd(slug), `${slug}-primer.md`);
|
|
54
|
+
const legacy = path.join(ROOT, `${slug}-primer.md`);
|
|
55
|
+
return fs.existsSync(local) ? local : legacy;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Resolve the ship variant: a single-768 build (recipe v1.3.0) writes only <slug>-kb.big.rvf;
|
|
59
|
+
// the legacy dual-variant build writes <slug>-kb.small.rvf. Prefer .big.rvf when present so the
|
|
60
|
+
// primer is embedded with — and ingested into — the SAME store/model the corpus used.
|
|
61
|
+
function resolveRvf(slug) {
|
|
62
|
+
const big = path.join(sd(slug), `${slug}-kb.big.rvf`);
|
|
63
|
+
const plain = path.join(sd(slug), `${slug}-kb.rvf`); // single-384 build (recipe v1.3.1)
|
|
64
|
+
const small = path.join(sd(slug), `${slug}-kb.small.rvf`);
|
|
65
|
+
if (fs.existsSync(big)) return big;
|
|
66
|
+
if (fs.existsSync(plain)) return plain;
|
|
67
|
+
return small;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// STORES is DERIVED from the config registry — NO hard-coded repo names.
|
|
71
|
+
const STORES = Object.fromEntries(Object.keys(targets).map((slug) => {
|
|
72
|
+
const ri = resolveIndex(slug);
|
|
73
|
+
return [slug, {
|
|
74
|
+
primer: primerPath(slug),
|
|
75
|
+
rvf: resolveRvf(slug),
|
|
76
|
+
passages: path.join(sd(slug), `${slug}-kb.passages.jsonl`),
|
|
77
|
+
index: ri.index,
|
|
78
|
+
chunkStyle: ri.chunkStyle,
|
|
79
|
+
}];
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
function slugify(s) {
|
|
83
|
+
return s.toLowerCase()
|
|
84
|
+
.replace(/[`*_~]/g, '')
|
|
85
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
86
|
+
.replace(/^-+|-+$/g, '')
|
|
87
|
+
.slice(0, 80) || 'section';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Split markdown into level-2 (##) sections, fence-aware. A level-1 (#) title or any preamble
|
|
91
|
+
// before the first ## is folded into the first section so no text is lost. Returns
|
|
92
|
+
// [{ title, body }] where body INCLUDES the heading line.
|
|
93
|
+
function splitSections(md) {
|
|
94
|
+
const lines = md.split('\n');
|
|
95
|
+
let inFence = false;
|
|
96
|
+
const sections = [];
|
|
97
|
+
let cur = null; // current ## section
|
|
98
|
+
let preamble = ''; // text before the first ## (title + intro)
|
|
99
|
+
|
|
100
|
+
for (const line of lines) {
|
|
101
|
+
// Track fenced code blocks so '#' comments inside them are never treated as headers.
|
|
102
|
+
if (/^\s*(```|~~~)/.test(line)) inFence = !inFence;
|
|
103
|
+
|
|
104
|
+
const h2 = !inFence && line.match(/^##\s+(.+?)\s*$/); // exactly level-2 (## but not ###)
|
|
105
|
+
const isH2 = h2 && !/^###/.test(line);
|
|
106
|
+
|
|
107
|
+
if (isH2) {
|
|
108
|
+
if (cur) sections.push(cur);
|
|
109
|
+
cur = { title: h2[1].trim(), body: line + '\n' };
|
|
110
|
+
} else if (cur) {
|
|
111
|
+
cur.body += line + '\n';
|
|
112
|
+
} else {
|
|
113
|
+
preamble += line + '\n';
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (cur) sections.push(cur);
|
|
117
|
+
|
|
118
|
+
// Fold the preamble (the # title + "About this document") into the first section's text so
|
|
119
|
+
// it is searchable but does not create an empty/orphan document.
|
|
120
|
+
if (sections.length && preamble.trim()) {
|
|
121
|
+
sections[0] = { title: sections[0].title, body: preamble.trimEnd() + '\n\n' + sections[0].body };
|
|
122
|
+
} else if (!sections.length && preamble.trim()) {
|
|
123
|
+
sections.push({ title: 'primer', body: preamble });
|
|
124
|
+
}
|
|
125
|
+
return sections;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Chunk a long section, mirroring the corpus chunker (paragraph-preferred, overlapping window).
|
|
129
|
+
function chunkText(text) {
|
|
130
|
+
if (text.length <= CHUNK_CHARS) return [text];
|
|
131
|
+
const out = [];
|
|
132
|
+
let i = 0;
|
|
133
|
+
while (i < text.length) {
|
|
134
|
+
let end = Math.min(i + CHUNK_CHARS, text.length);
|
|
135
|
+
if (end < text.length) {
|
|
136
|
+
const para = text.lastIndexOf('\n\n', end);
|
|
137
|
+
if (para > i + CHUNK_CHARS / 2) end = para;
|
|
138
|
+
}
|
|
139
|
+
out.push(text.slice(i, end));
|
|
140
|
+
if (end >= text.length) break;
|
|
141
|
+
i = end - OVERLAP_CHARS;
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function maxPassageId(file) {
|
|
147
|
+
let m = 0;
|
|
148
|
+
const data = fs.readFileSync(file, 'utf8');
|
|
149
|
+
for (const line of data.split('\n')) {
|
|
150
|
+
if (!line.trim()) continue;
|
|
151
|
+
try { const id = Number(JSON.parse(line).id); if (id > m) m = id; } catch { /* skip */ }
|
|
152
|
+
}
|
|
153
|
+
return m;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function main() {
|
|
157
|
+
const store = process.argv[2];
|
|
158
|
+
const conf = STORES[store];
|
|
159
|
+
if (!conf) { console.error(`Usage: node kb/index-primer.mjs <${Object.keys(STORES).join('|')}>`); process.exit(2); }
|
|
160
|
+
for (const f of [conf.primer, conf.rvf, conf.passages, conf.index]) {
|
|
161
|
+
if (!fs.existsSync(f)) { console.error(`MISSING: ${f}`); process.exit(1); }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---- build the orientation documents ----
|
|
165
|
+
const md = fs.readFileSync(conf.primer, 'utf8');
|
|
166
|
+
const sections = splitSections(md);
|
|
167
|
+
|
|
168
|
+
// entries: { synthPath, title, chunkIdx, chunkTotal, text }
|
|
169
|
+
const entries = [];
|
|
170
|
+
for (const s of sections) {
|
|
171
|
+
const synthPath = `PRIMER#${slugify(s.title)}`;
|
|
172
|
+
const chunks = chunkText(s.body);
|
|
173
|
+
chunks.forEach((c, i) => entries.push({
|
|
174
|
+
path: synthPath, title: s.title, chunkIdx: i, chunkTotal: chunks.length, text: c,
|
|
175
|
+
}));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const idx = JSON.parse(fs.readFileSync(conf.index, 'utf8'));
|
|
179
|
+
const beforeEntries = Object.keys(idx.entries).length;
|
|
180
|
+
const beforePassages = maxPassageId(conf.passages); // == line count (verified contiguous)
|
|
181
|
+
|
|
182
|
+
// NEW ids start at max-existing-id + 1 (no collision with the corpus ids).
|
|
183
|
+
const startId = Math.max(beforeEntries, beforePassages);
|
|
184
|
+
console.log(`[index-primer:${store}] sections=${sections.length} new-chunks=${entries.length} `
|
|
185
|
+
+ `start-id=${startId + 1} (before: index=${beforeEntries}, passages.maxId=${beforePassages})`);
|
|
186
|
+
|
|
187
|
+
// ---- embed (same model/pooling/normalize as the corpus build) ----
|
|
188
|
+
// Read the embedder config the build wrote next to the .rvf (<rvf>.embed.json). For a single-768
|
|
189
|
+
// bge store that is { model: bge, pooling: cls } so the primer is embedded with the SAME model
|
|
190
|
+
// (else 384-dim vectors would be rejected by a 768-dim store). PASSAGES get NO query prefix.
|
|
191
|
+
const embedCfg = (() => {
|
|
192
|
+
const p = `${conf.rvf}.embed.json`;
|
|
193
|
+
if (fs.existsSync(p)) { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { /* fall through */ } }
|
|
194
|
+
return { model: 'Xenova/all-MiniLM-L6-v2', pooling: 'mean', normalize: true };
|
|
195
|
+
})();
|
|
196
|
+
const EMBED_MODEL = embedCfg.model || 'Xenova/all-MiniLM-L6-v2';
|
|
197
|
+
const EMBED_POOLING = embedCfg.pooling || 'mean';
|
|
198
|
+
const { mod: rvfMod, via: rvfVia } = loadRvf();
|
|
199
|
+
const { RvfDatabase } = rvfMod;
|
|
200
|
+
const { T, via: tVia } = await loadTransformers();
|
|
201
|
+
const modelCache = chooseModelCache(EMBED_MODEL);
|
|
202
|
+
const { haveLocalModel } = configureModel(T, modelCache, EMBED_MODEL);
|
|
203
|
+
console.log(`[index-primer:${store}] rvf via ${rvfVia} | transformers via ${tVia} | model ${EMBED_MODEL} `
|
|
204
|
+
+ `${haveLocalModel ? 'local' : 'remote'} (${modelCache}) | pooling ${EMBED_POOLING}`);
|
|
205
|
+
const fe = await T.pipeline('feature-extraction', EMBED_MODEL, { quantized: true });
|
|
206
|
+
|
|
207
|
+
// ---- append: ingest into .rvf (read-write), passages.jsonl, and the index ----
|
|
208
|
+
const db = await RvfDatabase.open(conf.rvf);
|
|
209
|
+
const passagesFd = fs.openSync(conf.passages, 'a'); // APPEND
|
|
210
|
+
let ingested = 0, appendedPassages = 0;
|
|
211
|
+
const BATCH = 32;
|
|
212
|
+
try {
|
|
213
|
+
for (let i = 0; i < entries.length; i += BATCH) {
|
|
214
|
+
const batch = entries.slice(i, i + BATCH);
|
|
215
|
+
const out = await fe(batch.map((e) => e.text), { pooling: EMBED_POOLING, normalize: true });
|
|
216
|
+
const dim = out.dims[1];
|
|
217
|
+
const ingest = batch.map((e, j) => {
|
|
218
|
+
const id = String(startId + i + j + 1);
|
|
219
|
+
// passages sidecar line (full text)
|
|
220
|
+
fs.writeSync(passagesFd, JSON.stringify({ id, text: e.text, path: e.path, title: e.title }) + '\n');
|
|
221
|
+
appendedPassages++;
|
|
222
|
+
// index entry — match each KB's existing chunk-field convention
|
|
223
|
+
const chunkField = conf.chunkStyle === 'slash'
|
|
224
|
+
? { chunk: `${e.chunkIdx + 1}/${e.chunkTotal}` }
|
|
225
|
+
: { chunk: e.chunkIdx + 1, of: e.chunkTotal };
|
|
226
|
+
idx.entries[id] = {
|
|
227
|
+
path: e.path, kind: 'primer-orientation', title: e.title, ...chunkField,
|
|
228
|
+
preview: e.text.slice(0, 240).replace(/\s+/g, ' '),
|
|
229
|
+
};
|
|
230
|
+
return {
|
|
231
|
+
id,
|
|
232
|
+
vector: Float32Array.from(out.data.slice(j * dim, (j + 1) * dim)),
|
|
233
|
+
metadata: { path: e.path, kind: 'primer-orientation', title: e.title, chunk: e.chunkIdx },
|
|
234
|
+
};
|
|
235
|
+
});
|
|
236
|
+
const r = await db.ingestBatch(ingest);
|
|
237
|
+
ingested += r.accepted;
|
|
238
|
+
if (r.rejected) console.error('REJECTED', r.rejected, 'in batch at', i);
|
|
239
|
+
}
|
|
240
|
+
const status = await db.status();
|
|
241
|
+
console.log(`[index-primer:${store}] ingested=${ingested} | rvf totalVectors=${status.totalVectors}`);
|
|
242
|
+
} finally {
|
|
243
|
+
fs.closeSync(passagesFd);
|
|
244
|
+
await db.close();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// write the index back (preserve top-level model/dim/metric fields)
|
|
248
|
+
fs.writeFileSync(conf.index, JSON.stringify(idx, null, conf.chunkStyle === 'slash' ? 1 : 0));
|
|
249
|
+
|
|
250
|
+
const afterEntries = Object.keys(idx.entries).length;
|
|
251
|
+
console.log(`[index-primer:${store}] index entries: ${beforeEntries} -> ${afterEntries} `
|
|
252
|
+
+ `(+${afterEntries - beforeEntries}) | passages appended=${appendedPassages} | orientation sections=${sections.length}`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
main().catch((e) => { console.error('ERROR:', e); process.exit(1); });
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// kb-mcp-server.mjs — self-contained MCP stdio server for the Cognitum RVF knowledge bases.
|
|
3
|
+
//
|
|
4
|
+
// Exposes ONE tool:
|
|
5
|
+
// search_kb({ query: string, k?: number = 6, store: "ruvector"|"ruview" })
|
|
6
|
+
// It embeds the query locally (MiniLM), queries the requested .rvf readonly, then loads
|
|
7
|
+
// the FULL passage text for each hit from the matching .passages.jsonl and returns it as
|
|
8
|
+
// readable text (path + title + full passage).
|
|
9
|
+
//
|
|
10
|
+
// Self-contained: needs only @ruvector/rvf (npm-global), @xenova/transformers (AppealArmor
|
|
11
|
+
// build), and the bundled kb/*.rvf + kb/*.passages.jsonl. It implements the MCP JSON-RPC
|
|
12
|
+
// stdio protocol directly (no @modelcontextprotocol/sdk dependency), so it runs anywhere
|
|
13
|
+
// Node 18+ is available.
|
|
14
|
+
//
|
|
15
|
+
// Wire it into .mcp.json (see kb/README) — DO NOT use @ruvector/rvf-mcp-server (a stub).
|
|
16
|
+
//
|
|
17
|
+
// Env overrides: KB_TRANSFORMERS_PATH, KB_MODEL_CACHE (see ask-kb.mjs).
|
|
18
|
+
|
|
19
|
+
import { searchKb, lookupSymbol, getEntrypoints, getDepGraph } from './ask-kb.mjs';
|
|
20
|
+
import { targets, defaultTarget } from './kb.config.mjs';
|
|
21
|
+
|
|
22
|
+
const PROTOCOL_VERSION = '2024-11-05';
|
|
23
|
+
|
|
24
|
+
// Server identity + tool surface are DERIVED from the config registry — NO hard-coded repo names.
|
|
25
|
+
const STORE_SLUGS = Object.keys(targets);
|
|
26
|
+
const KNOWN_STORES = new Set(STORE_SLUGS);
|
|
27
|
+
const DEFAULT_STORE = defaultTarget;
|
|
28
|
+
const META_NAME = (targets[DEFAULT_STORE] && targets[DEFAULT_STORE].metaName) || DEFAULT_STORE;
|
|
29
|
+
|
|
30
|
+
const SERVER_INFO = { name: `${DEFAULT_STORE}-kb`, version: '1.0.0' };
|
|
31
|
+
|
|
32
|
+
// Per-store one-line descriptions (metaName + bundle blurb) so the tool description is specific.
|
|
33
|
+
const STORE_DESCRIPTIONS = STORE_SLUGS.map((slug) => {
|
|
34
|
+
const t = targets[slug];
|
|
35
|
+
const blurb = (t.bundle && t.bundle.blurb) ? ` — ${String(t.bundle.blurb).slice(0, 160)}` : '';
|
|
36
|
+
return `store="${slug}" (${t.metaName || slug})${blurb}`;
|
|
37
|
+
}).join(' · ');
|
|
38
|
+
|
|
39
|
+
const TOOLS = [
|
|
40
|
+
{
|
|
41
|
+
name: 'search_kb',
|
|
42
|
+
description:
|
|
43
|
+
`Semantic search over the ${META_NAME} RVF knowledge base(s). Returns up to 5 whole, `
|
|
44
|
+
+ 'self-contained matched DOCUMENTS (all chunks of each reassembled in order, not fragments) '
|
|
45
|
+
+ '— READMEs, ADRs, package/component docs, source doc-comments, primers — each with its repo '
|
|
46
|
+
+ `path and title. Available stores: ${STORE_DESCRIPTIONS}.`,
|
|
47
|
+
inputSchema: {
|
|
48
|
+
type: 'object',
|
|
49
|
+
properties: {
|
|
50
|
+
query: { type: 'string', description: 'Natural-language question or keywords.' },
|
|
51
|
+
store: { type: 'string', enum: STORE_SLUGS, description: 'Which knowledge base to search.', default: DEFAULT_STORE },
|
|
52
|
+
k: { type: 'integer', description: 'Number of passages to return (default 6).', default: 6 },
|
|
53
|
+
},
|
|
54
|
+
required: ['query'],
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: 'lookup_symbol',
|
|
59
|
+
description:
|
|
60
|
+
`EXACT public-API lookup over the ${META_NAME} symbol index (<store>-symbols.json). Given a `
|
|
61
|
+
+ 'name substring, returns matching functions/structs/enums/traits/modules with their signature, '
|
|
62
|
+
+ 'module path, source file:line, and doc summary. Use this for "what is the signature of X" / '
|
|
63
|
+
+ '"where is X defined" instead of semantic search.',
|
|
64
|
+
inputSchema: {
|
|
65
|
+
type: 'object',
|
|
66
|
+
properties: {
|
|
67
|
+
name: { type: 'string', description: 'Symbol name or module substring (case-insensitive).' },
|
|
68
|
+
store: { type: 'string', enum: STORE_SLUGS, default: DEFAULT_STORE },
|
|
69
|
+
kind: { type: 'string', description: 'Optional kind filter: fn|struct|enum|trait|module|type_alias|const.' },
|
|
70
|
+
limit: { type: 'integer', default: 25 },
|
|
71
|
+
},
|
|
72
|
+
required: ['name'],
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: 'get_entrypoints',
|
|
77
|
+
description:
|
|
78
|
+
`The ${META_NAME} build/test/run/install commands + binaries (<store>-entrypoints.json). Use to `
|
|
79
|
+
+ 'answer "how do I build/test/run/install this" with the EXACT commands.',
|
|
80
|
+
inputSchema: { type: 'object', properties: { store: { type: 'string', enum: STORE_SLUGS, default: DEFAULT_STORE } } },
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: 'get_dep_graph',
|
|
84
|
+
description:
|
|
85
|
+
`The ${META_NAME} component dependency graph (<store>-dep-graph.json): which crates/packages `
|
|
86
|
+
+ 'depend on which, + external deps. Use for architecture / blast-radius reasoning before editing.',
|
|
87
|
+
inputSchema: { type: 'object', properties: { store: { type: 'string', enum: STORE_SLUGS, default: DEFAULT_STORE } } },
|
|
88
|
+
},
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
// ---------- minimal JSON-RPC over stdio (newline-delimited, also tolerates LSP framing) ----------
|
|
92
|
+
function send(obj) {
|
|
93
|
+
process.stdout.write(JSON.stringify(obj) + '\n');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function ok(id, result) { send({ jsonrpc: '2.0', id, result }); }
|
|
97
|
+
function err(id, code, message) { send({ jsonrpc: '2.0', id, error: { code, message } }); }
|
|
98
|
+
|
|
99
|
+
async function handle(msg) {
|
|
100
|
+
const { id, method, params } = msg;
|
|
101
|
+
// notifications (no id) — ack silently
|
|
102
|
+
if (id === undefined || id === null) {
|
|
103
|
+
return; // e.g. notifications/initialized
|
|
104
|
+
}
|
|
105
|
+
switch (method) {
|
|
106
|
+
case 'initialize':
|
|
107
|
+
return ok(id, {
|
|
108
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
109
|
+
capabilities: { tools: {} },
|
|
110
|
+
serverInfo: SERVER_INFO,
|
|
111
|
+
});
|
|
112
|
+
case 'ping':
|
|
113
|
+
return ok(id, {});
|
|
114
|
+
case 'tools/list':
|
|
115
|
+
return ok(id, { tools: TOOLS });
|
|
116
|
+
case 'tools/call': {
|
|
117
|
+
const name = params?.name;
|
|
118
|
+
const args = params?.arguments || {};
|
|
119
|
+
const store = String(args.store || DEFAULT_STORE).trim();
|
|
120
|
+
if (!KNOWN_STORES.has(store)) return err(id, -32602, `store must be one of: ${STORE_SLUGS.join(', ')}`);
|
|
121
|
+
try {
|
|
122
|
+
if (name === 'search_kb') {
|
|
123
|
+
const query = String(args.query || '').trim();
|
|
124
|
+
const k = Math.max(1, parseInt(args.k ?? 6, 10) || 6);
|
|
125
|
+
if (!query) return err(id, -32602, 'query is required');
|
|
126
|
+
const results = await searchKb({ query, k, store });
|
|
127
|
+
const text = results.map((r, i) =>
|
|
128
|
+
`#${i + 1} (distance ${r.bestDistance.toFixed(4)})\n`
|
|
129
|
+
+ `path : ${r.path}\n`
|
|
130
|
+
+ `title: ${r.title}\n`
|
|
131
|
+
+ `----- full document (${r.fullText.length} chars, ${r.chunksJoined} chunk(s)${r.truncated ? ', truncated' : ''}) -----\n`
|
|
132
|
+
+ `${r.fullText}\n`
|
|
133
|
+
).join('\n========================================================\n\n');
|
|
134
|
+
return ok(id, { content: [{ type: 'text', text: text || '(no results)' }], isError: false });
|
|
135
|
+
}
|
|
136
|
+
if (name === 'lookup_symbol') {
|
|
137
|
+
const r = lookupSymbol(store, String(args.name || ''), { kind: args.kind, limit: Math.max(1, parseInt(args.limit ?? 25, 10) || 25) });
|
|
138
|
+
if (!r.available) return ok(id, { content: [{ type: 'text', text: `no ${store}-symbols.json present` }], isError: true });
|
|
139
|
+
const text = `${r.count} symbol(s) matching "${args.name}" (via ${r.method}):\n\n`
|
|
140
|
+
+ r.matches.map((s) => `${s.kind} ${s.signature}\n @ ${s.file}:${s.line}${s.doc ? `\n ${s.doc}` : ''}`).join('\n\n');
|
|
141
|
+
return ok(id, { content: [{ type: 'text', text }], isError: false });
|
|
142
|
+
}
|
|
143
|
+
if (name === 'get_entrypoints') {
|
|
144
|
+
const e = getEntrypoints(store);
|
|
145
|
+
if (!e) return ok(id, { content: [{ type: 'text', text: `no ${store}-entrypoints.json present` }], isError: true });
|
|
146
|
+
return ok(id, { content: [{ type: 'text', text: JSON.stringify({ workspace: e.workspace, install: e.install, quickstart: e.quickstart, binaries: e.binaries, commands: e.commands }, null, 2) }], isError: false });
|
|
147
|
+
}
|
|
148
|
+
if (name === 'get_dep_graph') {
|
|
149
|
+
const g = getDepGraph(store);
|
|
150
|
+
if (!g) return ok(id, { content: [{ type: 'text', text: `no ${store}-dep-graph.json present` }], isError: true });
|
|
151
|
+
return ok(id, { content: [{ type: 'text', text: JSON.stringify({ nodes: g.nodes.map((nn) => ({ name: nn.name, ecosystem: nn.ecosystem, description: nn.description })), internalEdges: g.internalEdges, externalDepNames: g.externalDepNames }, null, 2) }], isError: false });
|
|
152
|
+
}
|
|
153
|
+
return err(id, -32602, `unknown tool: ${name}`);
|
|
154
|
+
} catch (e) {
|
|
155
|
+
return ok(id, { content: [{ type: 'text', text: `${name} error: ${e.message}` }], isError: true });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
default:
|
|
159
|
+
return err(id, -32601, `method not found: ${method}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---------- stdin line reader ----------
|
|
164
|
+
let buf = '';
|
|
165
|
+
let inFlight = 0;
|
|
166
|
+
let ended = false;
|
|
167
|
+
function maybeExit() { if (ended && inFlight === 0) process.exit(0); }
|
|
168
|
+
|
|
169
|
+
process.stdin.setEncoding('utf8');
|
|
170
|
+
process.stdin.on('data', (chunk) => {
|
|
171
|
+
buf += chunk;
|
|
172
|
+
let nl;
|
|
173
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
174
|
+
const line = buf.slice(0, nl).trim();
|
|
175
|
+
buf = buf.slice(nl + 1);
|
|
176
|
+
if (!line) continue;
|
|
177
|
+
let msg;
|
|
178
|
+
try { msg = JSON.parse(line); } catch { continue; }
|
|
179
|
+
inFlight++;
|
|
180
|
+
Promise.resolve(handle(msg))
|
|
181
|
+
.catch((e) => { if (msg && msg.id != null) err(msg.id, -32603, e.message); })
|
|
182
|
+
.finally(() => { inFlight--; maybeExit(); });
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
// Don't exit while requests are still being served (model load + query is async).
|
|
186
|
+
process.stdin.on('end', () => { ended = true; maybeExit(); });
|