@alucard_24/pi-codeindex 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/present.js ADDED
@@ -0,0 +1,177 @@
1
+ // Formattazione per l'agente: funzioni pure, nessun accesso a pi o al disco.
2
+ // Stanno qui, e non in extension.js, perché sono l'unica parte dell'integrazione
3
+ // che vale la pena testare con `node --test` (l'adapter resta sottile).
4
+
5
+ import path from 'node:path';
6
+
7
+ export const OUTLINE_MIN_LINES = 300;
8
+ export const OUTLINE_MIN_SYMBOLS = 5;
9
+ export const OUTLINE_MAX_SYMBOLS = 40;
10
+ // Limite di default di `read` (DEFAULT_MAX_LINES): un `limit` a questo valore è
11
+ // una read completa esplicita, non una lettura mirata.
12
+ const FULL_READ_LIMIT = 2000;
13
+ const MAX_PREVIEW_LINES = 2;
14
+ const MAX_PREVIEW_CHARS = 160;
15
+
16
+ /** 1234 -> "1.2k"; 5230 -> "5.2k". Sotto il migliaio resta il numero esatto. */
17
+ export function fmtK(n) {
18
+ if (!Number.isFinite(n) || n < 0) return '?';
19
+ if (n < 1000) return String(Math.round(n));
20
+ const k = n / 1000;
21
+ return `${k < 10 ? k.toFixed(1) : Math.round(k)}k`;
22
+ }
23
+
24
+ /**
25
+ * Riga di stato per il footer. `undefined` = nessuna riga (nessun repo git).
26
+ * @param {{repo?: string|null, enabled?: boolean, indexing?: boolean,
27
+ * progress?: {done: number, total: number}|null, chunks?: number}} s
28
+ */
29
+ export function statusLine(s) {
30
+ if (!s?.repo) return undefined;
31
+ if (s.enabled === false) return 'codeindex: off';
32
+ if (s.indexing) {
33
+ const p = s.progress;
34
+ return p && p.total > 0
35
+ ? `codeindex: ${fmtK(p.done)}/${fmtK(p.total)}`
36
+ : 'codeindex: indicizzazione…';
37
+ }
38
+ if (s.chunks > 0) return `codeindex: ${fmtK(s.chunks)} chunk`;
39
+ return 'codeindex: nessun indice';
40
+ }
41
+
42
+ /**
43
+ * Bundle (Fase 4.5): hit + import + importatori + chiamanti euristici dei primi
44
+ * risultati. Formato compatto: ogni fatto è una riga, il resto è contesto.
45
+ */
46
+ export function formatBundle(query, res, { contexts = 3 } = {}) {
47
+ const lines = [formatHitsHead(res), ''];
48
+ const results = res?.results ?? [];
49
+ results.forEach((r, i) => {
50
+ lines.push(`${(r.score ?? 0).toFixed(3)} ${r.path}:${r.start}-${r.end} [${r.kind}${r.symbols?.length ? ` · ${r.symbols[0]}` : ''}]${r.stale ? ' STALE' : ''}`);
51
+ const p = String(r.preview ?? '').split('\n').slice(0, MAX_PREVIEW_LINES);
52
+ for (const l of p) if (l.trim()) lines.push(` ${oneLine(l)}`);
53
+ if (i >= contexts) return;
54
+ const c = r.context;
55
+ if (c?.imports?.length) lines.push(` imports: ${c.imports.join(', ')}`);
56
+ if (c?.importers?.length) lines.push(` imported by: ${c.importers.join(', ')}`);
57
+ for (const caller of c?.callers ?? []) {
58
+ lines.push(` caller (heuristic): ${caller.path}:${caller.line} in ${caller.container ?? 'top level'}`);
59
+ }
60
+ if (c?.callersTruncated) lines.push(' … more callers not shown');
61
+ });
62
+ if (!results.length) {
63
+ lines.push('No semantic match. Try different wording, or grep for an exact identifier.');
64
+ } else {
65
+ if (results.some((r) => r.stale)) {
66
+ lines.push('STALE = file changed after indexing: re-read it, line numbers may have moved.');
67
+ }
68
+ lines.push('Callers are heuristic (identifier scan), not a call graph. Verify before editing.');
69
+ }
70
+ return lines.join('\n');
71
+ }
72
+
73
+ function formatHitsHead(res) {
74
+ const n = res?.results?.length ?? 0;
75
+ return `${n} result${n === 1 ? '' : 's'} in ${Math.round(res?.tookMs ?? 0)} ms` +
76
+ `${res?.chunks ? ` · ${fmtK(res.chunks)} chunks indexed` : ''}`;
77
+ }
78
+
79
+ /**
80
+ * Riga iniettata nel system prompt: rende `search_code` scopribile senza
81
+ * contare le query già presenti nelle dichiarazioni dei tool.
82
+ * Il conteggio è arrotondato perché il testo resti stabile (prompt cache).
83
+ */
84
+ export function promptLine(chunks) {
85
+ if (!chunks || chunks < 100) return null;
86
+ return (
87
+ `Code index: ${fmtK(chunks)} chunks of this repository are embedded locally ` +
88
+ '(search_code). Prefer it to localize code before grep/read; grep stays best for exact strings.'
89
+ );
90
+ }
91
+
92
+ const oneLine = (s, n = MAX_PREVIEW_CHARS) => String(s ?? '').replace(/\s+/g, ' ').trim().slice(0, n);
93
+
94
+ /** Risultati di ricerca in forma compatta: una riga per hit + anteprima breve. */
95
+ export function formatHits(query, res) {
96
+ const n = res?.results?.length ?? 0;
97
+ const lines = [formatHitsHead(res)];
98
+ for (const r of res?.results ?? []) {
99
+ const sym = r.symbols?.length ? ` · ${r.symbols.slice(0, 3).join(', ')}` : '';
100
+ const stale = r.stale ? ' STALE' : '';
101
+ lines.push(
102
+ `${(r.score ?? 0).toFixed(3)} ${r.path}:${r.start}-${r.end} [${r.kind}${sym}]` +
103
+ ` cos ${(r.cos ?? 0).toFixed(3)} lex ${(r.lex ?? 0).toFixed(2)}${stale}`,
104
+ );
105
+ const preview = String(r.preview ?? '').split('\n').slice(0, MAX_PREVIEW_LINES);
106
+ for (const l of preview) if (l.trim()) lines.push(` ${oneLine(l)}`);
107
+ }
108
+ if (n === 0) {
109
+ lines.push(
110
+ 'No semantic match. Try a different wording, or grep for an exact identifier — ' +
111
+ 'the index finds concepts, not literal strings.',
112
+ );
113
+ } else {
114
+ if (res?.results?.some((r) => r.stale)) {
115
+ lines.push('STALE = file changed after indexing: re-read it, line numbers may have moved.');
116
+ }
117
+ lines.push('Read the hits you need (read with offset/limit for large files); grep for exact strings.');
118
+ }
119
+ return lines.join('\n');
120
+ }
121
+
122
+ /**
123
+ * Outline di un file dall'indice: solo simboli, nessun contenuto.
124
+ * @returns {string|null} null se il file non ha abbastanza simboli indicizzati.
125
+ */
126
+ export function outlineText(relPath, lineCount, chunks, {
127
+ minLines = OUTLINE_MIN_LINES,
128
+ minSymbols = OUTLINE_MIN_SYMBOLS,
129
+ maxSymbols = OUTLINE_MAX_SYMBOLS,
130
+ } = {}) {
131
+ if (!(lineCount > minLines)) return null;
132
+ const mine = (chunks ?? [])
133
+ .filter((c) => c.path === relPath && c.symbol && c.kind !== 'preamble')
134
+ .sort((a, b) => a.start - b.start);
135
+ const seen = new Set();
136
+ const picked = [];
137
+ for (const c of mine) {
138
+ if (seen.has(c.symbol)) continue;
139
+ seen.add(c.symbol);
140
+ picked.push(c);
141
+ }
142
+ if (picked.length < minSymbols) return null;
143
+ const shown = picked.slice(0, maxSymbols);
144
+ const lines = [
145
+ `[outline from code index · ${relPath} · ${lineCount} lines · ${picked.length} symbols]`,
146
+ ];
147
+ for (const c of shown) {
148
+ const range = `${c.start}-${c.end}`.padEnd(12);
149
+ lines.push(`${range}${c.kind.padEnd(10)}${c.symbol}`);
150
+ }
151
+ if (picked.length > shown.length) lines.push(`… ${picked.length - shown.length} more (search_code for details)`);
152
+ lines.push('[full file content follows]');
153
+ return lines.join('\n');
154
+ }
155
+
156
+ /**
157
+ * Decide se annotare una `read`. L'outline serve a chi legge un file intero:
158
+ * una read mirata (offset/limit espliciti) non lo riceve.
159
+ * `offset: 1`/`limit: 2000` sono la read completa scritta in modo esplicito.
160
+ */
161
+ export function shouldAnnotateRead(input, lineCount, { minLines = OUTLINE_MIN_LINES } = {}) {
162
+ if (!(lineCount > minLines)) return false;
163
+ const offset = input?.offset;
164
+ if (offset !== undefined && offset !== null && offset > 1) return false;
165
+ const limit = input?.limit;
166
+ if (limit !== undefined && limit !== null && limit < FULL_READ_LIMIT) return false;
167
+ return true;
168
+ }
169
+
170
+ /** Path assoluto -> relativo al repo, o null se esce dal repo. */
171
+ export function toRepoRel(repo, absPath) {
172
+ if (!repo || !absPath) return null;
173
+ const abs = path.resolve(absPath);
174
+ const rel = path.relative(repo, abs);
175
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
176
+ return rel.split(path.sep).join('/');
177
+ }
package/src/probe.js ADDED
@@ -0,0 +1,20 @@
1
+ // Entry point interno, eseguito solo da doctor --probe in un processo isolato.
2
+ import { embed, cosine, getEmbeddingProfile, embeddingStatus, getEmbedder } from './embed.js';
3
+
4
+ let result;
5
+ try {
6
+ const t0 = performance.now();
7
+ const same = 'title: args.ts | text: export function parseArgs(argv) { return argv.slice(2); }';
8
+ const other = 'title: math.py | text: def factorial(n): return 1 if n < 2 else n * factorial(n - 1)';
9
+ const [a, b, c] = await embed([same, same, other]);
10
+ const cosSame = cosine(a, b);
11
+ const cosOther = cosine(a, c);
12
+ if (cosSame < 0.999 || cosOther > 0.9999) throw new Error(`smoke test discriminazione fallito: stesso=${cosSame}, diverso=${cosOther}`);
13
+ result = { ok: true, profile: await getEmbeddingProfile(), elapsedMs: Math.round(performance.now() - t0),
14
+ cosSame, cosOther, attempts: embeddingStatus().attempts };
15
+ await (await getEmbedder()).dispose();
16
+ } catch (e) {
17
+ result = { ok: false, error: String(e.message ?? e), attempts: embeddingStatus().attempts };
18
+ }
19
+ if (process.send) process.send(result, () => process.exit(result.ok ? 0 : 1));
20
+ else { console.log(JSON.stringify(result)); process.exitCode = result.ok ? 0 : 1; }
package/src/search.js ADDED
@@ -0,0 +1,189 @@
1
+ // Ricerca ibrida: coseno sui vettori + overlap lessicale su path/simboli/preview.
2
+ // Due stadi: il lessico viene valutato solo sui migliori candidati del coseno
3
+ // (200 su 30k), così una query resta < 300 ms senza un indice invertito.
4
+
5
+ import fs from 'node:fs/promises';
6
+ import path from 'node:path';
7
+ import { embedQuery, getEmbeddingProfile } from './embed.js';
8
+ import { assertCompatibleIndex } from './backend.js';
9
+ import { indexDir, indexPaths, loadIndex } from './store.js';
10
+ import { repoRoot, sha1 } from './util.js';
11
+
12
+ export const WEIGHT_COS = 0.75;
13
+ export const WEIGHT_LEX = 0.25;
14
+ const CANDIDATES = 200;
15
+ const PREVIEW_LINES = 3;
16
+
17
+ const STOPWORDS = new Set([
18
+ 'the', 'and', 'for', 'with', 'from', 'into', 'this', 'that', 'are', 'was',
19
+ 'how', 'what', 'where', 'when', 'which', 'does', 'used', 'use', 'using',
20
+ 'di', 'del', 'della', 'dei', 'delle', 'che', 'chi', 'cui', 'per', 'con',
21
+ 'una', 'uno', 'gli', 'nel', 'nella', 'nei', 'come', 'dove', 'quando',
22
+ 'viene', 'vengono', 'sono', 'essere', 'alla', 'allo', 'agli', 'sul', 'sui',
23
+ 'la', 'le', 'lo', 'il', 'un', 'uno', 'si', 'non', 'più', 'dei', 'delle',
24
+ ]);
25
+
26
+ /** camelCase/snake_case/kebab -> token minuscoli, stopword rimosse. */
27
+ export function tokenize(text) {
28
+ return String(text ?? '')
29
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
30
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
31
+ .toLowerCase()
32
+ .split(/[^a-z0-9]+/)
33
+ .filter((t) => t.length >= 2 && !STOPWORDS.has(t));
34
+ }
35
+
36
+ export function lexicalOverlap(queryTokens, chunk) {
37
+ if (queryTokens.size === 0) return 0;
38
+ // Path e simboli sono segnali forti; la preview (200 char) è un segnale debole:
39
+ // un documento lungo che parla dell'argomento non deve battere il file che lo implementa.
40
+ const strong = new Set(tokenize([chunk.path, chunk.symbol ?? '', ...(chunk.symbols ?? [])].join(' ')));
41
+ const weak = new Set(tokenize(chunk.preview ?? ''));
42
+ let score = 0;
43
+ for (const t of queryTokens) score += strong.has(t) ? 1 : weak.has(t) ? 0.5 : 0;
44
+ return score / queryTokens.size;
45
+ }
46
+
47
+ function dot(a, aOff, b) {
48
+ let s = 0;
49
+ for (let i = 0; i < b.length; i++) s += a[aOff + i] * b[i];
50
+ return s;
51
+ }
52
+
53
+ // Cache in-process: un indice da 30k chunk pesa ~100 MB di JSON + 92 MB di vettori.
54
+ // Rileggerlo a ogni query costerebbe centinaia di ms; lo stat di due file costa ~0,1 ms.
55
+ // La chiave d'invalidazione è la coppia (size, mtime) dei due file dell'indice, quindi
56
+ // anche un reindex fatto da un altro processo viene visto.
57
+ const _cache = new Map();
58
+ const MAX_CACHED = 2;
59
+
60
+ async function stampOf(repo) {
61
+ const { json, bin } = indexPaths(indexDir(repo));
62
+ try {
63
+ const [a, b] = await Promise.all([fs.stat(json), fs.stat(bin)]);
64
+ return `${a.size}:${a.mtimeMs}:${b.size}:${b.mtimeMs}`;
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ /** Butta via l'indice in memoria (dopo un reindex, o alla chiusura della sessione). */
71
+ export function invalidateSearcher(root) {
72
+ if (root) _cache.delete(path.resolve(root));
73
+ else _cache.clear();
74
+ }
75
+
76
+ export async function loadSearcher(root, { refresh = false } = {}) {
77
+ const repo = path.resolve(root ?? repoRoot(process.cwd()) ?? process.cwd());
78
+ const stamp = await stampOf(repo);
79
+ const hit = _cache.get(repo);
80
+ if (!refresh && hit && stamp && hit.stamp === stamp) return hit.value;
81
+
82
+ const { index, vectors } = await loadIndex(repo);
83
+ if (!index || !vectors || vectors.length === 0) {
84
+ throw new Error(`nessun indice per ${repo}: esegui "index" prima`);
85
+ }
86
+ const value = { repo, index, vectors };
87
+ if (stamp) {
88
+ _cache.delete(repo);
89
+ _cache.set(repo, { stamp, value });
90
+ while (_cache.size > MAX_CACHED) _cache.delete(_cache.keys().next().value);
91
+ }
92
+ return value;
93
+ }
94
+
95
+ /**
96
+ * L1 — provenienza: confronta l'hash su disco con quello indicizzato.
97
+ * Costa una lettura per file distinto (≤ k file, cap 1 MB): non dipende dal
98
+ * watcher, quindi è vero anche se la sessione non ha visto la modifica.
99
+ */
100
+ async function markStale(repo, results) {
101
+ const seen = new Map();
102
+ for (const r of results) {
103
+ if (seen.has(r.path)) {
104
+ r.stale = seen.get(r.path);
105
+ continue;
106
+ }
107
+ let stale = true;
108
+ try {
109
+ const buf = await fs.readFile(path.resolve(repo, r.path));
110
+ stale = sha1(buf) !== r.fileHash;
111
+ } catch {
112
+ stale = true; // file sparito: non fidarsi del risultato
113
+ }
114
+ seen.set(r.path, stale);
115
+ r.stale = stale;
116
+ }
117
+ }
118
+
119
+ export async function previewOf(repo, chunk) {
120
+ try {
121
+ const abs = path.resolve(repo, chunk.path);
122
+ if (!abs.startsWith(repo + path.sep)) return null; // niente path fuori dal repo
123
+ const text = await fs.readFile(abs, 'utf8');
124
+ const lines = text.split('\n').slice(chunk.start - 1, Math.min(chunk.end, chunk.start - 1 + PREVIEW_LINES));
125
+ return lines.map((l) => l.trimEnd()).filter((l, i, arr) => l !== '' || i < arr.length - 1).join('\n');
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
130
+
131
+ /**
132
+ * @returns {Promise<{results:Array, tookMs:number, candidates:number}>}
133
+ */
134
+ export async function search(query, { root, k = 8, pathPrefix = null, maxPerFile = 2, checkStale = false } = {}) {
135
+ const t0 = performance.now();
136
+ const { repo, index, vectors } = await loadSearcher(root);
137
+ assertCompatibleIndex(index, await getEmbeddingProfile());
138
+ const qVec = await embedQuery(query);
139
+ const qTokens = new Set(tokenize(query));
140
+ const dims = index.dims;
141
+
142
+ const scored = [];
143
+ for (let i = 0; i < index.chunks.length; i++) {
144
+ const c = index.chunks[i];
145
+ if (pathPrefix && !c.path.startsWith(pathPrefix)) continue;
146
+ scored.push({ i, cos: dot(vectors, i * dims, qVec[0]) });
147
+ }
148
+ scored.sort((a, b) => b.cos - a.cos);
149
+ const top = scored.slice(0, Math.min(CANDIDATES, scored.length));
150
+
151
+ const results = top.map(({ i, cos }) => {
152
+ const c = index.chunks[i];
153
+ const lex = lexicalOverlap(qTokens, c);
154
+ return {
155
+ path: c.path,
156
+ start: c.start,
157
+ end: c.end,
158
+ symbol: c.symbol,
159
+ symbols: c.symbols ?? (c.symbol ? [c.symbol] : []),
160
+ kind: c.kind,
161
+ fileHash: c.fileHash,
162
+ score: WEIGHT_COS * cos + WEIGHT_LEX * lex,
163
+ cos,
164
+ lex,
165
+ };
166
+ });
167
+ results.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path) || a.start - b.start);
168
+ // Diversità: senza cap, un piano di 300 KB occupa tutti gli slot e nasconde il codice.
169
+ const perFile = new Map();
170
+ const final = [];
171
+ for (const r of results) {
172
+ const n = perFile.get(r.path) ?? 0;
173
+ if (n >= maxPerFile) continue;
174
+ perFile.set(r.path, n + 1);
175
+ final.push(r);
176
+ if (final.length >= k) break;
177
+ }
178
+ for (const r of final) r.preview = await previewOf(repo, r);
179
+ if (checkStale && final.length) await markStale(repo, final);
180
+
181
+ return {
182
+ repo,
183
+ results: final,
184
+ tookMs: Math.round((performance.now() - t0) * 10) / 10,
185
+ candidates: top.length,
186
+ chunks: index.chunks.length,
187
+ stale: final.some((r) => r.stale),
188
+ };
189
+ }
package/src/store.js ADDED
@@ -0,0 +1,142 @@
1
+ // Storage a due file: index.json (metadati + chunk senza testo) e vectors.bin.
2
+ // Niente database, niente dipendenze. Salvataggio atomico (tmp + rename).
3
+
4
+ import fs from 'node:fs/promises';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { createHash, randomUUID } from 'node:crypto';
8
+ import { sha1 } from './util.js';
9
+
10
+ export const STORE_VERSION = 2;
11
+
12
+ export function cacheRoot() {
13
+ return path.join(os.homedir(), '.cache', 'pi-codeindex');
14
+ }
15
+
16
+ export function indexDir(repoRoot) {
17
+ return path.join(cacheRoot(), sha1(path.resolve(repoRoot)));
18
+ }
19
+
20
+ export function indexPaths(dir) {
21
+ return {
22
+ dir,
23
+ json: path.join(dir, 'index.json'),
24
+ bin: path.join(dir, 'vectors.bin'),
25
+ cache: path.join(dir, 'embed-cache.json'), // ripresa dopo interruzione
26
+ };
27
+ }
28
+
29
+ export async function loadIndex(repoRoot) {
30
+ const { json, bin } = indexPaths(indexDir(repoRoot));
31
+ try {
32
+ const raw = await fs.readFile(json, 'utf8');
33
+ const index = JSON.parse(raw);
34
+ let vectors = null;
35
+ try {
36
+ const buf = await fs.readFile(bin);
37
+ const expected = index.chunks.length * index.dims;
38
+ vectors = unpackVectors(buf, index.dims, index.chunks.length);
39
+ // Due rename non sono una transazione: il digest lega il manifest ai
40
+ // vettori anche durante scritture concorrenti con profili differenti.
41
+ if (vectors.length !== expected || !index.vectorsHash ||
42
+ createHash('sha256').update(buf).digest('hex') !== index.vectorsHash) vectors = null;
43
+ } catch {
44
+ vectors = null; // indice senza vettori: si ricostruisce
45
+ }
46
+ return { index, vectors };
47
+ } catch {
48
+ return { index: null, vectors: null };
49
+ }
50
+ }
51
+
52
+ export async function saveIndex(repoRoot, index, vectors) {
53
+ const paths = indexPaths(indexDir(repoRoot));
54
+ await fs.mkdir(paths.dir, { recursive: true });
55
+ const token = randomUUID();
56
+ const binTmp = `${paths.bin}.${token}.tmp`;
57
+ const jsonTmp = `${paths.json}.${token}.tmp`;
58
+ const packed = packVectors(vectors, index.dims);
59
+ const vectorsHash = createHash('sha256').update(packed).digest('hex');
60
+ try {
61
+ await fs.writeFile(binTmp, packed);
62
+ await fs.writeFile(jsonTmp, JSON.stringify({ ...index, vectorsHash }));
63
+ await fs.rename(binTmp, paths.bin);
64
+ await fs.rename(jsonTmp, paths.json);
65
+ } finally {
66
+ await Promise.all([fs.rm(binTmp, { force: true }), fs.rm(jsonTmp, { force: true })]);
67
+ }
68
+ return paths;
69
+ }
70
+
71
+ /** righe (Float32Array[] | Float32Array) -> Buffer contiguo little-endian. */
72
+ export function packVectors(vectors, dims) {
73
+ const rows = vectors instanceof Float32Array ? vectors.length / dims : vectors.length;
74
+ const out = new Float32Array(rows * dims);
75
+ if (vectors instanceof Float32Array) {
76
+ out.set(vectors);
77
+ } else {
78
+ for (let i = 0; i < rows; i++) out.set(vectors[i], i * dims);
79
+ }
80
+ return Buffer.from(out.buffer, out.byteOffset, out.byteLength);
81
+ }
82
+
83
+ export function unpackVectors(buf, dims, expectedRows) {
84
+ if (!buf || buf.length === 0) return new Float32Array(0);
85
+ const bytes = expectedRows ? Math.min(buf.length, expectedRows * dims * 4) : buf.length;
86
+ if (buf.byteOffset % 4 === 0) {
87
+ return new Float32Array(buf.buffer, buf.byteOffset, bytes / 4);
88
+ }
89
+ const copy = new Uint8Array(bytes);
90
+ copy.set(buf.subarray(0, bytes));
91
+ return new Float32Array(copy.buffer, 0, bytes / 4);
92
+ }
93
+
94
+ /** Cache di ripresa: identità chunk -> vettore. Sopravvive a un run interrotto. */
95
+ export async function loadEmbedCache(repoRoot) {
96
+ const { cache } = indexPaths(indexDir(repoRoot));
97
+ try {
98
+ const obj = JSON.parse(await fs.readFile(cache, 'utf8'));
99
+ const out = new Map();
100
+ for (const [hash, b64] of Object.entries(obj)) {
101
+ const buf = Buffer.from(b64, 'base64');
102
+ if (buf.length === 768 * 4) out.set(hash, new Float32Array(buf.buffer, buf.byteOffset, 768));
103
+ }
104
+ return out;
105
+ } catch {
106
+ return new Map();
107
+ }
108
+ }
109
+
110
+ export async function saveEmbedCache(repoRoot, map) {
111
+ if (map.size === 0) return;
112
+ const { dir, cache } = indexPaths(indexDir(repoRoot));
113
+ await fs.mkdir(dir, { recursive: true });
114
+ const obj = {};
115
+ for (const [hash, vec] of map) obj[hash] = Buffer.from(vec.buffer, vec.byteOffset, vec.byteLength).toString('base64');
116
+ const tmp = `${cache}.tmp`;
117
+ await fs.writeFile(tmp, JSON.stringify(obj));
118
+ await fs.rename(tmp, cache);
119
+ }
120
+
121
+ export async function clearEmbedCache(repoRoot) {
122
+ const { cache } = indexPaths(indexDir(repoRoot));
123
+ await fs.rm(cache, { force: true });
124
+ }
125
+
126
+ export async function removeIndex(repoRoot) {
127
+ const { dir } = indexPaths(indexDir(repoRoot));
128
+ await fs.rm(dir, { recursive: true, force: true });
129
+ }
130
+
131
+ export async function indexSize(repoRoot) {
132
+ const paths = indexPaths(indexDir(repoRoot));
133
+ let total = 0;
134
+ for (const p of [paths.json, paths.bin]) {
135
+ try {
136
+ total += (await fs.stat(p)).size;
137
+ } catch {
138
+ /* file assente */
139
+ }
140
+ }
141
+ return total;
142
+ }
package/src/sync.js ADDED
@@ -0,0 +1,106 @@
1
+ // Sincronizzazione: quali file meritano una riconciliazione.
2
+ //
3
+ // Non facciamo re-index per singolo path: `indexRepo` è già incrementale
4
+ // (hash per file → riuso dei vettori) e su un repo da 2.872 file costa ~0,7 s.
5
+ // Quindi la politica è: raccogli i path sporchi, attendi un debounce, riconcilia
6
+ // tutto una volta. Meno codice, nessun indice parziale da mantenere coerente.
7
+
8
+ import path from 'node:path';
9
+ import { LANG_BY_EXT } from './chunker.js';
10
+
11
+ /** Directory che il watcher non deve nemmeno guardare (oltre a quelle di git). */
12
+ export const IGNORED_DIRS = new Set([
13
+ '.git', 'node_modules', '.venv', 'venv', '__pycache__', 'dist', 'build', 'out',
14
+ 'target', 'vendor', '.next', '.nuxt', '.cache', '.openlore', '.pi', 'coverage',
15
+ ]);
16
+
17
+ const extOf = (p) => {
18
+ const m = /\.[^./]+$/.exec(p);
19
+ return m ? m[0].toLowerCase() : '';
20
+ };
21
+
22
+ /** Path relativo al repo -> normalizzato con '/', o null se non è indicizzabile. */
23
+ export function watchablePath(rel, root) {
24
+ if (!rel) return null;
25
+ let p = String(rel).replace(/\\/g, '/');
26
+ if (path.isAbsolute(p)) {
27
+ if (!root) return null;
28
+ const r = path.relative(root, p).replace(/\\/g, '/');
29
+ if (!r || r.startsWith('..') || path.isAbsolute(r)) return null;
30
+ p = r;
31
+ }
32
+ const segments = path.posix.normalize(p).split('/').filter(Boolean);
33
+ if (segments.length === 0 || segments[0] === '..') return null;
34
+ p = segments.join('/');
35
+ if (segments.some((s) => IGNORED_DIRS.has(s))) return null;
36
+ if (!(extOf(p) in LANG_BY_EXT)) return null;
37
+ return segments.join('/');
38
+ }
39
+
40
+ /** Filtra e deduplica una lista di path (watcher, hook dei tool, git status). */
41
+ export function filterDirty(paths, root) {
42
+ const out = new Set();
43
+ for (const p of paths ?? []) {
44
+ const w = watchablePath(p, root);
45
+ if (w) out.add(w);
46
+ }
47
+ return out;
48
+ }
49
+
50
+ /**
51
+ * `git status --porcelain -z`: `XY path\0`, con `R`/`C` seguiti dal path
52
+ * originale come record successivo. Restituisce entrambi i lati del rename:
53
+ * un file rinominato va re-embedato (il path fa parte dell'identità).
54
+ */
55
+ export function parsePorcelainStatus(z) {
56
+ const parts = String(z ?? '').split('\0');
57
+ const out = [];
58
+ for (let i = 0; i < parts.length; i++) {
59
+ const rec = parts[i];
60
+ if (!rec || rec.length < 4) continue;
61
+ const xy = rec.slice(0, 2);
62
+ out.push(rec.slice(3));
63
+ if (xy[0] === 'R' || xy[0] === 'C') {
64
+ const from = parts[++i];
65
+ if (from) out.push(from);
66
+ }
67
+ }
68
+ return out;
69
+ }
70
+
71
+ /**
72
+ * Coda con debounce: gli eventi ravvicinati diventano una sola riconciliazione.
73
+ * `onFlush` riceve il Set dei path accumulati. Restituisce {add, cancel, pending}.
74
+ */
75
+ export function createDebouncedQueue(onFlush, {
76
+ delayMs = 800,
77
+ setTimeout: setTimer = setTimeout,
78
+ clearTimeout: clearTimer = clearTimeout,
79
+ } = {}) {
80
+ let timer = null;
81
+ let dirty = new Set();
82
+
83
+ const flush = () => {
84
+ timer = null;
85
+ const batch = dirty;
86
+ dirty = new Set();
87
+ if (batch.size > 0) onFlush(batch);
88
+ };
89
+
90
+ return {
91
+ add(paths) {
92
+ for (const p of paths ?? []) dirty.add(p);
93
+ if (dirty.size === 0) return;
94
+ if (timer) clearTimer(timer);
95
+ timer = setTimer(flush, delayMs);
96
+ },
97
+ cancel() {
98
+ if (timer) clearTimer(timer);
99
+ timer = null;
100
+ dirty = new Set();
101
+ },
102
+ get pending() {
103
+ return dirty.size;
104
+ },
105
+ };
106
+ }