@alucard_24/pi-codeindex 0.0.0-stage → 1.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/src/graph.js ADDED
@@ -0,0 +1,306 @@
1
+ // L2 — grafo sintattico verificabile.
2
+ //
3
+ // Regola di precisione: un arco esiste solo se la specifica dell'import risolve
4
+ // in **esattamente un** file indicizzato. Se il nome è ambiguo o il target non è
5
+ // nell'indice, l'arco non viene emesso (meglio un fatto assente che uno sbagliato).
6
+ // Niente risoluzione di package esterni, alias di build, o path di include passati
7
+ // da flag di compilazione: sono indovinelli, non fatti.
8
+
9
+ import fs from 'node:fs/promises';
10
+ import path from 'node:path';
11
+ import { langOf } from './chunker.js';
12
+ import { lsFiles } from './util.js';
13
+
14
+ export const GRAPH_VERSION = 1;
15
+
16
+ const JS_EXT = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts'];
17
+ const JS_INDEX = JS_EXT.map((e) => `/index${e}`);
18
+ const C_EXT = ['.h', '.hpp', '.hh', '.hxx', '.c', '.cc', '.cpp', '.cxx'];
19
+
20
+ const dirOf = (p) => {
21
+ const i = p.lastIndexOf('/');
22
+ return i === -1 ? '' : p.slice(0, i);
23
+ };
24
+ const join = (a, b) => (a ? `${a}/${b}` : b);
25
+ const normalize = (p) => path.posix.normalize(p);
26
+
27
+ /**
28
+ * Indice dei suffissi per gli include C/C++: senza i flag di compilazione non
29
+ * conosciamo le include-dir, quindi un arco è verificato solo se la specifica
30
+ * corrisponde a **un solo** file dell'intero repo. È la regola che tiene la
31
+ * precisione sopra il gate invece di indovinare la radice giusta.
32
+ */
33
+ export function makeSuffixLookup(files) {
34
+ const byBase = new Map();
35
+ for (const f of files) {
36
+ const b = path.posix.basename(f);
37
+ if (!byBase.has(b)) byBase.set(b, []);
38
+ byBase.get(b).push(f);
39
+ }
40
+ return (spec) => {
41
+ const clean = normalize(spec).replace(/^\.\//, '');
42
+ const list = byBase.get(path.posix.basename(clean)) ?? [];
43
+ return list.filter((f) => f === clean || f.endsWith(`/${clean}`));
44
+ };
45
+ }
46
+
47
+ /** Righe di import di un file, per famiglia. Nessun parser: solo forma sintattica. */
48
+ export function extractImports(text, lang) {
49
+ const out = [];
50
+ const lines = String(text ?? '').split('\n');
51
+ for (const raw of lines) {
52
+ const line = raw.trim();
53
+ if (!line) continue;
54
+
55
+ if (lang === 'js' || lang === 'ts') {
56
+ // from '...' (import/export), import('...'), require('...')
57
+ const m = /\b(?:from|import|require)\s*\(?\s*['"]([^'"]+)['"]/.exec(line);
58
+ if (m) out.push({ spec: m[1], style: 'js' });
59
+ continue;
60
+ }
61
+
62
+ if (lang === 'py') {
63
+ let m = /^from\s+(\.*)([A-Za-z0-9_.]*)\s+import\b/.exec(line);
64
+ if (m) {
65
+ out.push({ spec: `${m[1]}${m[2]}`, style: 'py-from' });
66
+ continue;
67
+ }
68
+ m = /^import\s+([A-Za-z0-9_.]+(?:\s*,\s*[A-Za-z0-9_.]+)*)/.exec(line);
69
+ if (m) for (const s of m[1].split(',')) out.push({ spec: s.trim(), style: 'py-import' });
70
+ continue;
71
+ }
72
+
73
+ if (lang === 'c' || lang === 'cpp' || lang === 'cs') {
74
+ const m = /^#\s*include\s*([<"])([^>"]+)[>"]/.exec(line);
75
+ if (m) out.push({ spec: m[2], style: m[1] === '"' ? 'c-quoted' : 'c-angle' });
76
+ continue;
77
+ }
78
+
79
+ if (lang === 'go') {
80
+ const m = /^(?:import\s+)?["']([^"']+)["']\s*$/.exec(line);
81
+ if (m) out.push({ spec: m[1], style: 'go' });
82
+ }
83
+ }
84
+ return out;
85
+ }
86
+
87
+ /**
88
+ * Specifica -> file target, solo se unico nell'indice.
89
+ * @param {string} from path relativo del file che importa
90
+ * @param {{spec: string, style: string}} imp
91
+ * @param {Set<string>} universe path indicizzati
92
+ */
93
+ export function resolveImport(from, imp, universe, suffixOf = null) {
94
+ const fromDir = dirOf(from);
95
+
96
+ if (imp.style === 'js') {
97
+ const spec = imp.spec;
98
+ if (!spec.startsWith('./') && !spec.startsWith('../')) return null; // package esterno
99
+ const base = normalize(join(fromDir, spec));
100
+ const candidates = [base, ...JS_EXT.map((e) => base + e), ...JS_INDEX.map((e) => base + e)];
101
+ const hits = candidates.filter((c) => universe.has(c));
102
+ return hits.length === 1 ? hits[0] : null;
103
+ }
104
+
105
+ if (imp.style === 'py-from') {
106
+ const spec = imp.spec;
107
+ const dots = /^\.*/.exec(spec)[0].length;
108
+ const rest = spec.slice(dots);
109
+ let baseDir = fromDir;
110
+ for (let i = 1; i < dots; i++) baseDir = dirOf(baseDir);
111
+ const base = rest ? normalize(join(baseDir, rest.replace(/\./g, '/'))) : baseDir;
112
+ const candidates = new Set([`${base}.py`, `${base}/__init__.py`]);
113
+ if (!rest) candidates.add(`${fromDir}/__init__.py`);
114
+ const hits = [...candidates].filter((c) => universe.has(c));
115
+ return hits.length === 1 ? hits[0] : null;
116
+ }
117
+
118
+ if (imp.style === 'py-import') {
119
+ const spec = imp.spec;
120
+ if (!spec || spec.startsWith('.')) return null;
121
+ const base = spec.replace(/\./g, '/');
122
+ const candidates = [`${base}.py`, `${base}/__init__.py`];
123
+ const hits = candidates.filter((c) => universe.has(c));
124
+ return hits.length === 1 ? hits[0] : null;
125
+ }
126
+
127
+ if (imp.style === 'c-quoted' || imp.style === 'c-angle') {
128
+ const spec = imp.spec.replace(/\\/g, '/');
129
+ // Per `"..."` la semantica C cerca prima nella directory dell'includer:
130
+ // se lì c'è esattamente un file, l'arco è verificato senza indovinare.
131
+ if (imp.style === 'c-quoted') {
132
+ const local = normalize(join(fromDir, spec));
133
+ if (universe.has(local)) return local;
134
+ if (!spec.startsWith('../') && universe.has(normalize(spec))) return normalize(spec);
135
+ }
136
+ // Altrimenti: solo suffisso unico nell'intero repo.
137
+ if (!suffixOf) return null;
138
+ const hits = suffixOf(spec);
139
+ return hits.length === 1 ? hits[0] : null;
140
+ }
141
+
142
+ if (imp.style === 'go') {
143
+ // Senza il module path del go.mod una specifica Go non è verificabile.
144
+ return null;
145
+ }
146
+
147
+ return null;
148
+ }
149
+
150
+ /** Aggiunge un arco, scartando self-loop e target fuori dall'indice. */
151
+ function addEdge(map, from, to) {
152
+ if (!to || to === from) return false;
153
+ if (!map.has(from)) map.set(from, new Set());
154
+ map.get(from).add(to);
155
+ return true;
156
+ }
157
+
158
+ /**
159
+ * Costruisce i due versi del grafo leggendo i file indicizzati dal disco.
160
+ * @returns {Promise<{edges: Map<string, Set<string>>, reverse: Map<string, Set<string>>, stats: object}>}
161
+ */
162
+ export async function buildGraph(root, index, { signal = null, allFiles = null } = {}) {
163
+ // Universo = file tracciati dal repo, non solo quelli indicizzati: un header
164
+ // fuori dal cap è comunque un target legittimo di un import.
165
+ const tracked = allFiles ?? lsFiles(root) ?? index.chunks.map((c) => c.path);
166
+ const universe = new Set(tracked);
167
+ const suffixOf = makeSuffixLookup(tracked);
168
+ const edges = new Map();
169
+ const reverse = new Map();
170
+ const stats = { files: 0, filesRead: 0, imports: 0, resolved: 0, ambiguous: 0, external: 0 };
171
+
172
+ for (const rel of universe) {
173
+ signal?.throwIfAborted();
174
+ stats.files++;
175
+ const lang = langOf(rel);
176
+ if (!['js', 'ts', 'py', 'c', 'cpp', 'cs', 'go'].includes(lang)) continue;
177
+ let text;
178
+ try {
179
+ text = await fs.readFile(path.join(root, rel), 'utf8');
180
+ } catch {
181
+ continue;
182
+ }
183
+ stats.filesRead++;
184
+ for (const imp of extractImports(text, lang)) {
185
+ stats.imports++;
186
+ const target = resolveImport(rel, imp, universe, suffixOf);
187
+ if (!target) {
188
+ if (imp.style === 'js' && !/^\.\.?\//.test(imp.spec)) stats.external++;
189
+ else stats.ambiguous++;
190
+ continue;
191
+ }
192
+ if (addEdge(edges, rel, target)) {
193
+ addEdge(reverse, target, rel);
194
+ stats.resolved++;
195
+ }
196
+ }
197
+ }
198
+
199
+ return { edges, reverse, stats, version: GRAPH_VERSION };
200
+ }
201
+
202
+ /** Import diretti di un file (ordinati, stabili). */
203
+ const DEF_KINDS = new Set(['function', 'method', 'class', 'type', 'struct', 'interface', 'enum', 'impl', 'event']);
204
+
205
+ /**
206
+ * Bundle di contesto (Fase 4.5): ai primi `top` risultati aggiunge import,
207
+ * importatori e chiamanti euristici del simbolo. Un round-trip invece di una
208
+ * catena grep+read. I chiamanti restano etichettati come euristici.
209
+ */
210
+ export async function buildBundle(root, index, results, graph, {
211
+ top = 3,
212
+ maxImports = 6,
213
+ maxImporters = 4,
214
+ maxCallers = 5,
215
+ signal = null,
216
+ } = {}) {
217
+ for (const r of results.slice(0, top)) {
218
+ const ctx = {
219
+ imports: importsOf(graph, r.path).slice(0, maxImports),
220
+ importers: importersOf(graph, r.path).slice(0, maxImporters),
221
+ callers: [],
222
+ callersTruncated: false,
223
+ };
224
+ if (r.symbol && DEF_KINDS.has(r.kind)) {
225
+ const found = await findCallers(root, index, r.symbol, { limit: maxCallers + 1, signal });
226
+ // La definizione stessa non è un chiamante.
227
+ ctx.callers = found.callers
228
+ .filter((c) => !(c.path === r.path && c.container === r.symbol))
229
+ .slice(0, maxCallers);
230
+ ctx.callersTruncated = found.truncated;
231
+ }
232
+ r.context = ctx;
233
+ }
234
+ return results;
235
+ }
236
+
237
+ export function importsOf(graph, rel) {
238
+ return [...(graph.edges.get(rel) ?? [])].sort();
239
+ }
240
+
241
+ /** Chi importa questo file. */
242
+ export function importersOf(graph, rel) {
243
+ return [...(graph.reverse.get(rel) ?? [])].sort();
244
+ }
245
+
246
+ /**
247
+ * L3 — chiamanti euristici: occorrenze dell'identificatore nei file indicizzati,
248
+ * raggruppate per simbolo contenitore. Etichettate `heuristic` perché è grep
249
+ * con il raggruppamento, non un call graph: commenti e stringhe non sono distinti.
250
+ */
251
+ const COMMENT_LINE = /^\s*(?:\/\/|#(?!include)|\*|\/\*|--|;)/;
252
+ // Un import non è un chiamante: gli archi import sono già il livello 2.
253
+ const IMPORT_LINE = /^\s*(?:import\b|from\b|export\b.*\bfrom\b|const\b.*\brequire\()/;
254
+ // Nemmeno una lista di re-export (`AREAS, TITLE_RE, parseSections, …`) è un chiamante.
255
+ const BARE_LIST_LINE = /^[\w$]+(?:\s*,\s*[\w$]+)*,?$/;
256
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
257
+
258
+ export async function findCallers(root, index, symbol, { limit = 20, signal = null } = {}) {
259
+ if (!symbol || !/^[A-Za-z_$][\w$]*$/.test(symbol)) return { callers: [], scanned: 0 };
260
+ const re = new RegExp(`(?<![\\w$])${escapeRe(symbol)}(?![\\w$])`);
261
+ // Indice per path: range dei chunk, così una riga si mappa al simbolo contenitore.
262
+ const byPath = new Map();
263
+ for (const c of index.chunks) {
264
+ if (!byPath.has(c.path)) byPath.set(c.path, []);
265
+ byPath.get(c.path).push(c);
266
+ }
267
+ const callers = [];
268
+ let scanned = 0;
269
+ let files = 0;
270
+
271
+ for (const [rel, chunks] of byPath) {
272
+ if (chunks[0].lang === 'md') continue;
273
+ signal?.throwIfAborted();
274
+ let text;
275
+ try {
276
+ text = await fs.readFile(path.join(root, rel), 'utf8');
277
+ } catch {
278
+ continue;
279
+ }
280
+ files++;
281
+ const lines = text.split('\n');
282
+ scanned += lines.length;
283
+ let lastKey = null;
284
+ for (let i = 0; i < lines.length; i++) {
285
+ const line = lines[i];
286
+ if (!re.test(line) || COMMENT_LINE.test(line) || IMPORT_LINE.test(line)) continue;
287
+ if (BARE_LIST_LINE.test(line.trim())) continue;
288
+ const lineNo = i + 1;
289
+ const holder = chunks.find((c) => lineNo >= c.start && lineNo <= c.end);
290
+ // La definizione stessa non è un chiamante.
291
+ if (holder && holder.symbol === symbol && lineNo === holder.start) continue;
292
+ const key = `${rel}\0${holder?.symbol ?? ''}`;
293
+ if (key === lastKey) continue;
294
+ lastKey = key;
295
+ callers.push({
296
+ path: rel,
297
+ line: lineNo,
298
+ container: holder?.symbol ?? null,
299
+ containerKind: holder?.kind ?? null,
300
+ text: line.trim().slice(0, 120),
301
+ });
302
+ if (callers.length >= limit) return { callers, scanned, files, truncated: true };
303
+ }
304
+ }
305
+ return { callers, scanned, files, truncated: false };
306
+ }
package/src/indexer.js ADDED
@@ -0,0 +1,302 @@
1
+ // Indicizzatore incrementale: scan -> chunk -> embed solo del delta -> save atomico.
2
+
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import {
6
+ CHUNKER_VERSION, LANG_BY_EXT, chunkCode, chunkLines, chunkPayload, langOf,
7
+ } from './chunker.js';
8
+ import { embed, docPrefix, DIMS, MODEL_ID, getEmbeddingProfile } from './embed.js';
9
+ import {
10
+ clearEmbedCache, loadEmbedCache, loadIndex, saveEmbedCache, saveIndex, STORE_VERSION,
11
+ } from './store.js';
12
+ import {
13
+ fmtBytes, fmtDuration, headCommit, isBinary, isMinified, lsFiles, repoRoot, sha1, short, walkFiles,
14
+ } from './util.js';
15
+
16
+ export const PROVIDER = 'transformers.js';
17
+ const MAX_FILE_BYTES = 1024 * 1024;
18
+ const LOCKFILES = new Set([
19
+ 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'Cargo.lock', 'poetry.lock',
20
+ 'go.sum', 'composer.lock', 'Gemfile.lock', 'bun.lockb',
21
+ ]);
22
+
23
+ export function chunkIdentity(filePath, fileHash, model = MODEL_ID, dtype = 'q8', device = 'cpu', webgpuBackend = null) {
24
+ return sha1([filePath, fileHash, CHUNKER_VERSION, model, dtype, DIMS, device,
25
+ ...(device === 'webgpu' ? [webgpuBackend ?? 'auto'] : [])].join('\0'));
26
+ }
27
+
28
+ function extOf(p) {
29
+ const m = /\.[^./]+$/.exec(p);
30
+ return m ? m[0].toLowerCase() : '';
31
+ }
32
+
33
+ /** Filtro di enumerazione (§3.2): solo linguaggi supportati, niente binari/lock/minified. */
34
+ export async function collectFiles(root, { maxFiles, maxBytes }) {
35
+ const listed = lsFiles(root) ?? (await walkFiles(root));
36
+ const sorted = listed.sort();
37
+ const picked = [];
38
+ let bytes = 0;
39
+ let skipped = { ext: 0, big: 0, cap: 0 };
40
+
41
+ for (const rel of sorted) {
42
+ if (picked.length >= maxFiles) {
43
+ skipped.cap = sorted.length - picked.length;
44
+ break;
45
+ }
46
+ const base = path.basename(rel);
47
+ if (LOCKFILES.has(base)) {
48
+ skipped.ext++;
49
+ continue;
50
+ }
51
+ if (!(extOf(rel) in LANG_BY_EXT)) {
52
+ skipped.ext++;
53
+ continue;
54
+ }
55
+ let st;
56
+ try {
57
+ st = await fs.stat(path.join(root, rel));
58
+ } catch {
59
+ continue;
60
+ }
61
+ if (!st.isFile() || st.size === 0 || st.size > MAX_FILE_BYTES) {
62
+ skipped.big++;
63
+ continue;
64
+ }
65
+ if (bytes + st.size > maxBytes) {
66
+ skipped.cap = sorted.length - picked.length;
67
+ break;
68
+ }
69
+ bytes += st.size;
70
+ picked.push({ path: rel, size: st.size });
71
+ }
72
+ return { files: picked, bytes, skipped };
73
+ }
74
+
75
+ // Batch per budget di token (stima ~3,5 char/token): l'attenzione è quadratica
76
+ // nella lunghezza, un batch di 16 documenti da 4000 char può saturare la VRAM.
77
+ const MAX_BATCH_TOKENS = 6000;
78
+ const estTokens = (doc) => Math.ceil(doc.length / 3.5);
79
+
80
+ /** Batch greedy: lunghezze già ordinate, si chiude quando il budget token è pieno. */
81
+ export function makeBatches(pending, batchSize, maxTokens = MAX_BATCH_TOKENS) {
82
+ const out = [];
83
+ let cur = [];
84
+ let tokens = 0;
85
+ for (const p of pending) {
86
+ const t = estTokens(p.doc);
87
+ if (cur.length >= batchSize || (cur.length > 0 && tokens + t > maxTokens)) {
88
+ out.push(cur);
89
+ cur = [];
90
+ tokens = 0;
91
+ }
92
+ cur.push(p);
93
+ tokens += t;
94
+ }
95
+ if (cur.length) out.push(cur);
96
+ return out;
97
+ }
98
+
99
+ const isOom = (e) => /allocate memory|out of memory|outofmemory|cuda_error_out_of_memory|failed to allocate/i.test(e.message ?? '');
100
+
101
+ /** Embed con split-retry: se un batch esaurisce la memoria, si dimezza. */
102
+ async function embedSafe(docs, log, embedFn) {
103
+ try {
104
+ return await embedFn(docs);
105
+ } catch (e) {
106
+ if (!isOom(e)) throw e;
107
+ if (docs.length > 1) {
108
+ const mid = Math.floor(docs.length / 2);
109
+ log(`[codeindex] batch di ${docs.length} troppo grande (${e.message.slice(0, 60)}…): lo divido`);
110
+ return [...(await embedSafe(docs.slice(0, mid), log, embedFn)), ...(await embedSafe(docs.slice(mid), log, embedFn))];
111
+ }
112
+ const shorter = docs[0].slice(0, Math.max(600, Math.floor(docs[0].length / 2)));
113
+ if (shorter.length < docs[0].length) {
114
+ log(`[codeindex] documento troppo lungo anche da solo (${docs[0].length} char): tronco a ${shorter.length}`);
115
+ return embedSafe([shorter], log, embedFn);
116
+ }
117
+ throw e;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Indicizza il repo. Riusa i vettori dei chunk la cui identità (path+contenuto+
123
+ * versione chunker+modello+dtype+provider+backend WebGPU) non è cambiata.
124
+ */
125
+ export async function indexRepo({
126
+ root,
127
+ maxFiles = 5000,
128
+ maxBytes = 200 * 1024 * 1024,
129
+ batchSize = 16,
130
+ force = false,
131
+ signal = null,
132
+ onProgress = null,
133
+ embedding = { embed, getProfile: getEmbeddingProfile },
134
+ log = (msg) => process.stderr.write(`${msg}\n`),
135
+ } = {}) {
136
+ const t0 = performance.now();
137
+ const repo = path.resolve(root ?? repoRoot(process.cwd()) ?? process.cwd());
138
+ signal?.throwIfAborted();
139
+ const { files, bytes, skipped } = await collectFiles(repo, { maxFiles, maxBytes });
140
+ // Fissa il profilo DOPO eventuali fallback e PRIMA di calcolare identità/cache.
141
+ const profile = await embedding.getProfile();
142
+ signal?.throwIfAborted();
143
+
144
+ const { index: oldIndex, vectors: oldVectors } = force
145
+ ? { index: null, vectors: null }
146
+ : await loadIndex(repo);
147
+
148
+ const reusable = new Map(); // identity -> row index nel vecchio vettore
149
+ if (oldIndex?.chunks && oldVectors) {
150
+ oldIndex.chunks.forEach((c, i) => reusable.set(c.hash, i));
151
+ if (oldIndex.chunkerVersion !== CHUNKER_VERSION || oldIndex.dims !== DIMS) reusable.clear();
152
+ }
153
+ // Ripresa: i vettori già calcolati in un run interrotto non si ricalcolano.
154
+ const cached = force ? new Map() : await loadEmbedCache(repo);
155
+ const cacheHit = cached.size > 0;
156
+
157
+ log(`[codeindex] ${files.length} file (${fmtBytes(bytes)}), ${skipped.ext} esclusi per estensione, ${skipped.big} per dimensione`);
158
+
159
+ const chunks = [];
160
+ const pending = []; // { row, doc }
161
+ let skippedBinary = 0;
162
+ let skippedMinified = 0;
163
+ let unchanged = 0;
164
+ const oldByPath = new Map((oldIndex?.chunks ?? []).map((c) => [c.path, c.fileHash]));
165
+
166
+ for (const f of files) {
167
+ signal?.throwIfAborted();
168
+ let buf;
169
+ try {
170
+ buf = await fs.readFile(path.join(repo, f.path));
171
+ } catch {
172
+ continue;
173
+ }
174
+ if (isBinary(buf)) {
175
+ skippedBinary++;
176
+ continue;
177
+ }
178
+ const text = buf.toString('utf8');
179
+ const lang = langOf(f.path);
180
+ // I Markdown hanno righe lunghe legittime (tabelle, link): non sono bundle.
181
+ if (lang !== 'md' && isMinified(text)) {
182
+ skippedMinified++;
183
+ continue;
184
+ }
185
+ const fileHash = sha1(buf);
186
+ if (oldByPath.get(f.path) === fileHash) unchanged++;
187
+
188
+ const lines = text.split('\n');
189
+ if (lines.length && lines[lines.length - 1] === '') lines.pop();
190
+ for (const c of chunkLines(lines, lang)) {
191
+ const code = chunkCode(lines, c);
192
+ const hash = chunkIdentity(f.path, `${fileHash}:${c.start}-${c.end}`, profile.model, profile.dtype, profile.device, profile.webgpuBackend);
193
+ const row = chunks.length;
194
+ chunks.push({
195
+ path: f.path,
196
+ start: c.start,
197
+ end: c.end,
198
+ symbol: c.symbol ?? null,
199
+ kind: c.kind,
200
+ lang,
201
+ fileHash,
202
+ hash,
203
+ preview: short(code, 200),
204
+ });
205
+ if (reusable.has(hash) || cached.has(hash)) continue;
206
+ pending.push({ row, hash, doc: docPrefix(f.path) + chunkPayload(c, code) });
207
+ }
208
+ }
209
+
210
+ const total = chunks.length;
211
+ onProgress?.({ phase: 'scan', done: 0, total });
212
+ const rows = new Float32Array(total * DIMS);
213
+ let reused = 0;
214
+ for (let i = 0; i < total; i++) {
215
+ const idx = reusable.get(chunks[i].hash);
216
+ if (idx !== undefined && oldVectors) {
217
+ rows.set(oldVectors.subarray(idx * DIMS, idx * DIMS + DIMS), i * DIMS);
218
+ reused++;
219
+ } else {
220
+ const v = cached.get(chunks[i].hash);
221
+ if (v) {
222
+ rows.set(v, i * DIMS);
223
+ reused++;
224
+ }
225
+ }
226
+ }
227
+
228
+ log(`[codeindex] ${total} chunk, ${reused} riusati${cacheHit ? ' (di cui da cache di ripresa)' : ''}, ${pending.length} da embeddare`);
229
+ // Batch omogenei per lunghezza: meno padding, ~2x di throughput misurato.
230
+ pending.sort((a, b) => a.doc.length - b.doc.length);
231
+ const batches = makeBatches(pending, batchSize);
232
+ let embedded = 0;
233
+ let sinceFlush = 0;
234
+ const tEmbed = performance.now();
235
+ for (const slice of batches) {
236
+ signal?.throwIfAborted();
237
+ let vecs;
238
+ let lastErr;
239
+ for (let attempt = 1; attempt <= 3; attempt++) {
240
+ try {
241
+ vecs = await embedSafe(slice.map((p) => p.doc), log, embedding.embed);
242
+ slice.forEach((p, k) => {
243
+ rows.set(vecs[k], p.row * DIMS);
244
+ cached.set(p.hash, vecs[k]);
245
+ });
246
+ lastErr = null;
247
+ break;
248
+ } catch (e) {
249
+ lastErr = e;
250
+ await new Promise((r) => setTimeout(r, 250 * attempt));
251
+ }
252
+ }
253
+ if (lastErr) throw new Error(`embedding fallito su ${slice[0].doc.slice(0, 60)}: ${lastErr.message}`);
254
+ embedded += slice.length;
255
+ sinceFlush += slice.length;
256
+ onProgress?.({ phase: 'embed', done: embedded, total: pending.length });
257
+ if (sinceFlush >= 2000) {
258
+ await saveEmbedCache(repo, cached);
259
+ sinceFlush = 0;
260
+ }
261
+ if (pending.length > batchSize) {
262
+ const elapsed = performance.now() - tEmbed;
263
+ const rate = embedded / (elapsed / 1000);
264
+ const eta = (pending.length - embedded) / rate;
265
+ if (embedded % (batchSize * 8) === 0 || embedded === pending.length) {
266
+ log(`[codeindex] embed ${embedded}/${pending.length} · ${rate.toFixed(1)} chunk/s · ETA ${fmtDuration(eta * 1000)}`);
267
+ }
268
+ }
269
+ }
270
+
271
+ const now = new Date().toISOString();
272
+ const index = {
273
+ version: STORE_VERSION,
274
+ repoRoot: repo,
275
+ provider: PROVIDER,
276
+ model: profile.model,
277
+ dtype: profile.dtype,
278
+ device: profile.device,
279
+ webgpuBackend: profile.webgpuBackend ?? (profile.device === 'webgpu' ? 'auto' : null),
280
+ dims: DIMS,
281
+ chunkerVersion: CHUNKER_VERSION,
282
+ head: headCommit(repo),
283
+ createdAt: oldIndex?.createdAt ?? now,
284
+ updatedAt: now,
285
+ caps: { maxFiles, maxBytes },
286
+ stats: { files: files.length, bytes, chunks: total, reused, embedded, unchanged },
287
+ skipped: { ...skipped, binary: skippedBinary, minified: skippedMinified },
288
+ chunks,
289
+ };
290
+ await saveIndex(repo, index, rows);
291
+ await clearEmbedCache(repo);
292
+ onProgress?.({ phase: 'done', done: total, total });
293
+
294
+ const duration = performance.now() - t0;
295
+ return { index, repo, duration, embedded, reused, total, unchanged };
296
+ }
297
+
298
+ export async function statusOf(root) {
299
+ const repo = path.resolve(root ?? repoRoot(process.cwd()) ?? process.cwd());
300
+ const { index, vectors } = await loadIndex(repo);
301
+ return { repo, index, hasVectors: Boolean(vectors && vectors.length) };
302
+ }
@@ -0,0 +1,51 @@
1
+ // Solo nel servizio e nei probe esplicitamente isolati. I client pi non
2
+ // importano Transformers/ORT e non possiedono una pipeline nativa.
3
+ import { createEmbeddingRuntime, nativeBackends, validateEmbeddings, QUERY_PREFIX } from './backend.js';
4
+ import { ensureTextModel } from './model.js';
5
+
6
+ export function createLocalEmbedder(config) {
7
+ const runtime = createEmbeddingRuntime({
8
+ settings: () => ({ config }),
9
+ inventory: async () => nativeBackends().backends,
10
+ log: (s) => process.stderr.write(`${s}\n`),
11
+ initialize: async (profile, settings) => {
12
+ const sessionOptions = {};
13
+ if (profile.webgpuBackend === 'vulkan') {
14
+ const { registerVulkanBackend } = await import('./vulkan.js');
15
+ sessionOptions.executionProviders = [await registerVulkanBackend()];
16
+ }
17
+ const dir = await ensureTextModel(profile, settings);
18
+ const { pipeline } = await import('@huggingface/transformers');
19
+ if (settings.cpuThreads) sessionOptions.intraOpNumThreads = settings.cpuThreads;
20
+ if (profile.device === 'dml') {
21
+ sessionOptions.executionMode = 'sequential';
22
+ sessionOptions.enableMemPattern = false;
23
+ }
24
+ let pipe;
25
+ try {
26
+ pipe = await pipeline('feature-extraction', dir, {
27
+ dtype: profile.dtype, device: profile.device, local_files_only: true, session_options: sessionOptions,
28
+ });
29
+ const out = await pipe([QUERY_PREFIX + 'code retrieval smoke test'], { pooling: 'mean', normalize: true });
30
+ validateEmbeddings(out.tolist(), 1);
31
+ return pipe;
32
+ } catch (e) {
33
+ try { await pipe?.dispose(); } catch { /* conserva l'errore originale */ }
34
+ throw e;
35
+ }
36
+ },
37
+ });
38
+ return {
39
+ getProfile: async () => (await runtime.get()).profile,
40
+ info: () => runtime.info(),
41
+ embed: async (texts, { normalize = true } = {}) => {
42
+ const { pipe } = await runtime.get();
43
+ // Non cambiare provider dopo la prima inferenza riuscita.
44
+ const out = await pipe(texts, { pooling: 'mean', normalize });
45
+ return validateEmbeddings(out.tolist(), texts.length);
46
+ },
47
+ dispose: async () => {
48
+ if (runtime.info().profile) await (await runtime.get()).pipe.dispose();
49
+ },
50
+ };
51
+ }