@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.
@@ -0,0 +1,122 @@
1
+ // IPC locale autenticato. Nessun provider o modello viene importato qui.
2
+ import fs from 'node:fs/promises';
3
+ import net from 'node:net';
4
+ import path from 'node:path';
5
+ import { createHash, randomUUID } from 'node:crypto';
6
+
7
+ export const PROTOCOL = 1;
8
+ export const HOST = '127.0.0.1';
9
+ export const MAX_FRAME = 8 * 1024 * 1024;
10
+ export const MAX_TEXT_BYTES = 2 * 1024 * 1024;
11
+ export const MAX_TEXTS = 128;
12
+
13
+ export function serviceError(code, message) {
14
+ return Object.assign(new Error(message), { code });
15
+ }
16
+
17
+ export function serviceAddress(config) {
18
+ const directory = path.resolve(config.serviceDir);
19
+ const identity = process.platform === 'win32' ? directory.toLowerCase() : directory;
20
+ const port = config.servicePort || 49152 + createHash('sha256').update(identity).digest().readUInt16BE(0) % 16384;
21
+ return { directory, host: HOST, port, file: path.join(directory, 'service.json') };
22
+ }
23
+
24
+ function checkPrivate(stat, target, directory = false) {
25
+ if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile())) {
26
+ throw serviceError('UNSAFE_SERVICE_PATH', `percorso del servizio non sicuro: ${target}`);
27
+ }
28
+ if (process.platform !== 'win32' && (stat.uid !== process.getuid() || (stat.mode & 0o077))) {
29
+ throw serviceError('UNSAFE_SERVICE_PATH', `il servizio richiede proprietà utente e permessi privati (0700 directory, 0600 file): ${target}`);
30
+ }
31
+ }
32
+
33
+ export async function prepareServiceDirectory(address) {
34
+ await fs.mkdir(address.directory, { recursive: true, mode: 0o700 });
35
+ checkPrivate(await fs.lstat(address.directory), address.directory, true);
36
+ }
37
+
38
+ // Status è read-only: non crea directory/file e non avvia il servizio.
39
+ export async function readServiceRecord(address) {
40
+ try {
41
+ checkPrivate(await fs.lstat(address.directory), address.directory, true);
42
+ const stat = await fs.lstat(address.file);
43
+ checkPrivate(stat, address.file);
44
+ if (stat.size > 4096) throw serviceError('BAD_SERVICE_RECORD', 'record del servizio troppo grande');
45
+ const record = JSON.parse(await fs.readFile(address.file, 'utf8'));
46
+ if (record.port !== address.port || !Number.isInteger(record.pid) || record.pid < 1 ||
47
+ typeof record.token !== 'string' || !/^[a-f0-9]{64}$/.test(record.token)) {
48
+ throw serviceError('BAD_SERVICE_RECORD', 'record del servizio locale non valido');
49
+ }
50
+ if (record.protocol !== PROTOCOL) throw serviceError('PROTOCOL_MISMATCH', 'servizio locale di versione incompatibile: arrestalo prima di riavviare pi');
51
+ return record;
52
+ } catch (e) {
53
+ if (e.code === 'ENOENT') return null;
54
+ throw e;
55
+ }
56
+ }
57
+
58
+ export async function writeServiceRecord(address, record) {
59
+ const tmp = `${address.file}.${randomUUID()}.tmp`;
60
+ try {
61
+ await fs.writeFile(tmp, JSON.stringify(record) + '\n', { flag: 'wx', mode: 0o600 });
62
+ await fs.rename(tmp, address.file);
63
+ } finally { await fs.rm(tmp, { force: true }); }
64
+ }
65
+
66
+ export function readFrame(socket, { timeoutMs = 30_000 } = {}) {
67
+ return new Promise((resolve, reject) => {
68
+ let size = 0;
69
+ const chunks = [];
70
+ const finish = (error, value) => {
71
+ clearTimeout(timer);
72
+ socket.off('data', data); socket.off('end', end); socket.off('close', end); socket.off('error', fail);
73
+ if (error) reject(error); else resolve(value);
74
+ };
75
+ const fail = (e) => finish(e);
76
+ const end = () => fail(serviceError('SERVICE_DISCONNECTED', 'servizio locale disconnesso; nessun fallback in-process'));
77
+ const data = (chunk) => {
78
+ size += chunk.length;
79
+ if (size > MAX_FRAME) return fail(serviceError('FRAME_TOO_LARGE', 'messaggio del servizio troppo grande'));
80
+ chunks.push(chunk);
81
+ const newline = chunk.indexOf(10);
82
+ if (newline < 0) return;
83
+ try {
84
+ const buffer = Buffer.concat(chunks);
85
+ const at = buffer.indexOf(10);
86
+ if (buffer.subarray(at + 1).toString().trim()) throw new Error('attesa una sola richiesta per connessione');
87
+ finish(null, JSON.parse(buffer.subarray(0, at).toString('utf8')));
88
+ } catch (e) { fail(serviceError('BAD_FRAME', `messaggio del servizio non valido: ${e.message}`)); }
89
+ };
90
+ const timer = setTimeout(() => fail(serviceError('SERVICE_TIMEOUT', 'timeout del servizio locale')), timeoutMs);
91
+ socket.on('data', data); socket.once('end', end); socket.once('close', end); socket.once('error', fail);
92
+ });
93
+ }
94
+
95
+ export function writeFrame(socket, value) {
96
+ const frame = JSON.stringify(value) + '\n';
97
+ if (Buffer.byteLength(frame) > MAX_FRAME) throw serviceError('FRAME_TOO_LARGE', 'messaggio del servizio troppo grande');
98
+ socket.write(frame);
99
+ }
100
+
101
+ export async function rpc(address, record, request, { timeoutMs = 15 * 60_000, connectTimeoutMs = 1000 } = {}) {
102
+ const socket = net.createConnection({ host: HOST, port: address.port });
103
+ socket.on('error', () => {});
104
+ try {
105
+ await new Promise((resolve, reject) => {
106
+ const timer = setTimeout(() => { socket.destroy(); reject(serviceError('CONNECT_TIMEOUT', 'timeout connessione al servizio locale')); }, connectTimeoutMs);
107
+ socket.once('connect', () => { clearTimeout(timer); resolve(); });
108
+ socket.once('error', (e) => { clearTimeout(timer); reject(e); });
109
+ });
110
+ const response = readFrame(socket, { timeoutMs });
111
+ try { writeFrame(socket, { protocol: PROTOCOL, token: record.token, ...request }); }
112
+ catch (e) { socket.destroy(); await response.catch(() => {}); throw e; }
113
+ const message = await response;
114
+ if (!message || message.protocol !== PROTOCOL || typeof message.ok !== 'boolean') throw serviceError('BAD_FRAME', 'risposta del servizio non valida');
115
+ if (!message.ok) throw Object.assign(serviceError(message.code ?? 'SERVICE_ERROR', message.error ?? 'errore del servizio locale'), { info: message.info });
116
+ return message;
117
+ } finally { socket.destroy(); }
118
+ }
119
+
120
+ export function disconnected(error) {
121
+ return ['ECONNREFUSED', 'ECONNRESET', 'EPIPE', 'CONNECT_TIMEOUT', 'SERVICE_DISCONNECTED'].includes(error.code);
122
+ }
@@ -0,0 +1,3 @@
1
+ // Worker autonomo: non deve mai importare il client embed.js.
2
+ import { runServiceWorker } from './service.js';
3
+ await runServiceWorker();
package/src/service.js ADDED
@@ -0,0 +1,172 @@
1
+ // Un listener esclusivo per directory/utente, un modello, una coda seriale.
2
+ import net from 'node:net';
3
+ import fs from 'node:fs/promises';
4
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
5
+ import { PROTOCOL, HOST, prepareServiceDirectory, readServiceRecord, writeServiceRecord, readFrame, writeFrame, serviceAddress, serviceError } from './service-protocol.js';
6
+ import { validateServiceConfig, assertServiceCompatible, validateTexts } from './service-policy.js';
7
+
8
+ export async function startEmbeddingService({ directory, port, maxQueue = 16, createRuntime = async (config) => {
9
+ const { createLocalEmbedder } = await import('./local-embed.js');
10
+ return createLocalEmbedder(config);
11
+ } }) {
12
+ const address = serviceAddress({ serviceDir: directory, servicePort: port });
13
+ await prepareServiceDirectory(address);
14
+ const token = randomBytes(32).toString('hex');
15
+ const record = { protocol: PROTOCOL, pid: process.pid, port: address.port, token };
16
+ const sockets = new Set();
17
+ const startedAt = Date.now();
18
+ let lastUsedAt = startedAt;
19
+ let idleMs = 300_000;
20
+ let timer, runtime = null, activeConfig = null;
21
+ let pending = 0, initializing = false, stopping = false, shutdownStarted = false, tail = Promise.resolve();
22
+ let resolveStopped;
23
+ const stopped = new Promise((resolve) => { resolveStopped = resolve; });
24
+
25
+ function info() {
26
+ return { running: !stopping, stopping, shared: true, pid: process.pid, protocol: PROTOCOL, port: address.port,
27
+ pending, initializing, startedAt, lastUsedAt, idleMs, rssBytes: process.memoryUsage().rss,
28
+ ...(runtime?.info() ?? { profile: null, attempts: [], error: null }) };
29
+ }
30
+ function reply(socket, value) {
31
+ if (socket.destroyed) return;
32
+ try { writeFrame(socket, { protocol: PROTOCOL, ...value }); socket.end(); }
33
+ catch { socket.destroy(); }
34
+ }
35
+ function armIdle() {
36
+ clearTimeout(timer);
37
+ if (!stopping && pending === 0) timer = setTimeout(() => { void stop(); }, Math.max(1, idleMs - (Date.now() - lastUsedAt)));
38
+ }
39
+ async function stop() {
40
+ if (shutdownStarted) return stopped;
41
+ shutdownStarted = true;
42
+ stopping = true;
43
+ clearTimeout(timer);
44
+ // L'inferenza attiva termina; quelle in coda ricevono un errore esplicito.
45
+ await tail;
46
+ for (const socket of sockets) socket.destroy();
47
+ if (runtime) {
48
+ let timeout;
49
+ try { await Promise.race([runtime.dispose(), new Promise((resolve) => { timeout = setTimeout(resolve, 3000); })]); }
50
+ catch { /* l'uscita del processo libera comunque le risorse native */ }
51
+ finally { clearTimeout(timeout); }
52
+ }
53
+ try {
54
+ const current = await readServiceRecord(address);
55
+ if (current?.token === token) await fs.rm(address.file, { force: true });
56
+ } catch { /* non cancellare record sostituiti/non sicuri */ }
57
+ // Chiudi anche connessioni arrivate durante dispose, prima di rilasciare
58
+ // la porta: nessun client incompleto deve trattenere il worker nativo.
59
+ for (const socket of sockets) socket.destroy();
60
+ await new Promise((resolve) => server.close(resolve));
61
+ resolveStopped();
62
+ }
63
+ async function modelFor(config, expectedProfile) {
64
+ if (!runtime) {
65
+ activeConfig = config;
66
+ idleMs = config.serviceIdleMs;
67
+ initializing = true;
68
+ try {
69
+ runtime = await createRuntime(config);
70
+ await runtime.getProfile();
71
+ } catch (e) {
72
+ // Dopo un'inizializzazione fallita si esce: nessuna risorsa nativa
73
+ // residua viene riusata per avviare un altro profilo.
74
+ e.initializationFailed = true;
75
+ throw e;
76
+ } finally { initializing = false; }
77
+ }
78
+ const profile = await runtime.getProfile();
79
+ assertServiceCompatible(config, activeConfig, profile, expectedProfile);
80
+ return { runtime, profile };
81
+ }
82
+
83
+ const server = net.createServer((socket) => {
84
+ socket.on('error', () => {});
85
+ sockets.add(socket);
86
+ socket.once('close', () => sockets.delete(socket));
87
+ if (sockets.size > maxQueue + 16) { socket.destroy(); return; }
88
+ void (async () => {
89
+ let authenticated = false;
90
+ try {
91
+ const request = await readFrame(socket, { timeoutMs: 3000 });
92
+ if (typeof request?.token !== 'string' || !/^[a-f0-9]{64}$/.test(request.token) ||
93
+ !timingSafeEqual(Buffer.from(request.token), Buffer.from(token))) throw serviceError('UNAUTHORIZED', 'autenticazione del servizio locale fallita');
94
+ authenticated = true;
95
+ if (request.protocol !== PROTOCOL) throw serviceError('PROTOCOL_MISMATCH', 'protocollo del servizio incompatibile; arresta il servizio e riavvia pi');
96
+ if (request.op === 'status') { reply(socket, { ok: true, result: info() }); return; }
97
+ if (request.op === 'stop') {
98
+ if (stopping) { reply(socket, { ok: true, result: { stopping: true, pid: process.pid } }); return; }
99
+ if (pending) throw serviceError('SERVICE_BUSY', `servizio occupato (${pending} richieste): stop rifiutato per non interrompere altre sessioni`);
100
+ reply(socket, { ok: true, result: { stopping: true, pid: process.pid } });
101
+ // Blocca subito nuove operazioni, poi lascia uscire la risposta.
102
+ stopping = true;
103
+ setImmediate(() => { void stop(); });
104
+ return;
105
+ }
106
+ if (stopping) throw serviceError('SERVICE_STOPPING', 'servizio locale in arresto; riprova tra poco');
107
+ if (!['profile', 'embed'].includes(request.op)) throw serviceError('BAD_OPERATION', 'operazione del servizio sconosciuta');
108
+ const config = validateServiceConfig(request.config);
109
+ const requestedAddress = serviceAddress(config);
110
+ if (requestedAddress.directory !== address.directory || requestedAddress.port !== address.port) throw serviceError('BAD_CONFIG', 'endpoint client diverso da quello del servizio');
111
+ if (request.op === 'embed') validateTexts(request.texts, request.normalize);
112
+ if (pending >= maxQueue) throw serviceError('QUEUE_FULL', `coda del servizio piena (massimo ${maxQueue}); riprova senza avviare altri modelli`);
113
+ pending++;
114
+ clearTimeout(timer);
115
+ const task = tail.then(async () => {
116
+ try {
117
+ if (socket.destroyed) return;
118
+ if (stopping) throw serviceError('SERVICE_STOPPING', 'servizio locale in arresto');
119
+ const { runtime: model, profile } = await modelFor(config, request.expectedProfile);
120
+ const result = request.op === 'embed' ? await model.embed(request.texts, { normalize: request.normalize }) : profile;
121
+ reply(socket, { ok: true, result, info: info() });
122
+ } catch (e) {
123
+ reply(socket, { ok: false, code: e.code ?? 'INFERENCE_FAILED', error: String(e.message ?? e).slice(0, 4000), info: info() });
124
+ if (e.initializationFailed) {
125
+ stopping = true;
126
+ setImmediate(() => { void stop(); });
127
+ }
128
+ } finally {
129
+ pending--;
130
+ lastUsedAt = Date.now();
131
+ armIdle();
132
+ }
133
+ });
134
+ // La coda non viene avvelenata da richieste fallite/disconnesse.
135
+ tail = task.catch(() => {});
136
+ } catch (e) {
137
+ reply(socket, { ok: false, code: e.code ?? 'BAD_REQUEST', error: String(e.message ?? e).slice(0, 4000), ...(authenticated ? { info: info() } : {}) });
138
+ }
139
+ })();
140
+ });
141
+ server.on('error', () => {});
142
+ await new Promise((resolve, reject) => {
143
+ server.once('error', reject);
144
+ server.listen({ host: HOST, port: address.port, exclusive: true }, () => { server.off('error', reject); resolve(); });
145
+ });
146
+ try { await writeServiceRecord(address, record); }
147
+ catch (e) { server.close(); throw e; }
148
+ armIdle();
149
+ return { info, stop, stopped };
150
+ }
151
+
152
+ // Entry point condiviso dal worker reale e da fixture senza modello.
153
+ export async function runServiceWorker(options = {}) {
154
+ let service;
155
+ try {
156
+ const directory = process.argv[2];
157
+ const port = Number(process.argv[3]);
158
+ if (!directory || !Number.isInteger(port) || port < 1024 || port > 65535) throw new Error('argomenti interni del servizio non validi');
159
+ service = await startEmbeddingService({ ...options, directory, port });
160
+ process.once('SIGTERM', () => { void service.stop(); });
161
+ process.once('SIGINT', () => { void service.stop(); });
162
+ if (process.connected) process.send({ ready: true, pid: process.pid }, () => { if (process.connected) process.disconnect(); });
163
+ await service.stopped;
164
+ // Necessario anche se ORT/Dawn trattiene handle dopo dispose: l'OS libera
165
+ // l'intero processo. Non si scarica/ricarica un modello nello stesso worker.
166
+ process.exit(0);
167
+ } catch (e) {
168
+ const exit = () => process.exit(e.code === 'EADDRINUSE' ? 0 : 1);
169
+ if (process.connected) process.send({ ready: false, code: e.code, error: e.message }, exit);
170
+ else exit();
171
+ }
172
+ }
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,110 @@
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
+ /**
23
+ * Path relativo -> normalizzato con '/', o null se fuori scope.
24
+ * Su rename, includeDirectories accetta anche directory/nomi senza estensione
25
+ * supportata: un evento sulla directory può rappresentare molti file rimossi.
26
+ */
27
+ export function watchablePath(rel, root, { includeDirectories = false } = {}) {
28
+ if (!rel) return null;
29
+ let p = String(rel).replace(/\\/g, '/');
30
+ if (path.isAbsolute(p)) {
31
+ if (!root) return null;
32
+ const r = path.relative(root, p).replace(/\\/g, '/');
33
+ if (!r || r.startsWith('..') || path.isAbsolute(r)) return null;
34
+ p = r;
35
+ }
36
+ const segments = path.posix.normalize(p).split('/').filter(Boolean);
37
+ if (segments.length === 0 || segments[0] === '..') return null;
38
+ p = segments.join('/');
39
+ if (segments.some((s) => IGNORED_DIRS.has(s))) return null;
40
+ if (!includeDirectories && !(extOf(p) in LANG_BY_EXT)) return null;
41
+ return segments.join('/');
42
+ }
43
+
44
+ /** Filtra e deduplica una lista di path (watcher, hook dei tool, git status). */
45
+ export function filterDirty(paths, root, options) {
46
+ const out = new Set();
47
+ for (const p of paths ?? []) {
48
+ const w = watchablePath(p, root, options);
49
+ if (w) out.add(w);
50
+ }
51
+ return out;
52
+ }
53
+
54
+ /**
55
+ * `git status --porcelain -z`: `XY path\0`, con `R`/`C` seguiti dal path
56
+ * originale come record successivo. Restituisce entrambi i lati del rename:
57
+ * un file rinominato va re-embedato (il path fa parte dell'identità).
58
+ */
59
+ export function parsePorcelainStatus(z) {
60
+ const parts = String(z ?? '').split('\0');
61
+ const out = [];
62
+ for (let i = 0; i < parts.length; i++) {
63
+ const rec = parts[i];
64
+ if (!rec || rec.length < 4) continue;
65
+ const xy = rec.slice(0, 2);
66
+ out.push(rec.slice(3));
67
+ if (xy[0] === 'R' || xy[0] === 'C') {
68
+ const from = parts[++i];
69
+ if (from) out.push(from);
70
+ }
71
+ }
72
+ return out;
73
+ }
74
+
75
+ /**
76
+ * Coda con debounce: gli eventi ravvicinati diventano una sola riconciliazione.
77
+ * `onFlush` riceve il Set dei path accumulati. Restituisce {add, cancel, pending}.
78
+ */
79
+ export function createDebouncedQueue(onFlush, {
80
+ delayMs = 800,
81
+ setTimeout: setTimer = setTimeout,
82
+ clearTimeout: clearTimer = clearTimeout,
83
+ } = {}) {
84
+ let timer = null;
85
+ let dirty = new Set();
86
+
87
+ const flush = () => {
88
+ timer = null;
89
+ const batch = dirty;
90
+ dirty = new Set();
91
+ if (batch.size > 0) onFlush(batch);
92
+ };
93
+
94
+ return {
95
+ add(paths) {
96
+ for (const p of paths ?? []) dirty.add(p);
97
+ if (dirty.size === 0) return;
98
+ if (timer) clearTimer(timer);
99
+ timer = setTimer(flush, delayMs);
100
+ },
101
+ cancel() {
102
+ if (timer) clearTimer(timer);
103
+ timer = null;
104
+ dirty = new Set();
105
+ },
106
+ get pending() {
107
+ return dirty.size;
108
+ },
109
+ };
110
+ }
package/src/util.js ADDED
@@ -0,0 +1,108 @@
1
+ // Utility pure: hash, git, enumerazione file, euristiche su testo/binari.
2
+
3
+ import { createHash } from 'node:crypto';
4
+ import { execFileSync } from 'node:child_process';
5
+ import fs from 'node:fs/promises';
6
+ import path from 'node:path';
7
+
8
+ export function sha1(input) {
9
+ return createHash('sha1').update(input).digest('hex');
10
+ }
11
+
12
+ export function short(s, n = 60) {
13
+ const one = String(s).replace(/\s+/g, ' ').trim();
14
+ return one.length > n ? `${one.slice(0, n - 1)}…` : one;
15
+ }
16
+
17
+ export function git(cwd, args) {
18
+ try {
19
+ return execFileSync('git', args, {
20
+ cwd,
21
+ encoding: 'utf8',
22
+ maxBuffer: 64 * 1024 * 1024,
23
+ stdio: ['ignore', 'pipe', 'ignore'],
24
+ });
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ export function repoRoot(cwd) {
31
+ const out = git(cwd, ['rev-parse', '--show-toplevel']);
32
+ return out ? out.trim() : null;
33
+ }
34
+
35
+ export function headCommit(cwd) {
36
+ const out = git(cwd, ['rev-parse', 'HEAD']);
37
+ return out ? out.trim() : null;
38
+ }
39
+
40
+ /** Elenca i file tracciati + untracked non ignorati. Null se non è un repo git. */
41
+ export function lsFiles(root) {
42
+ const out = git(root, ['ls-files', '-z', '--cached', '--others', '--exclude-standard']);
43
+ if (out === null) return null;
44
+ return out.split('\0').filter(Boolean);
45
+ }
46
+
47
+ const IGNORED_DIRS = new Set([
48
+ '.git', 'node_modules', 'dist', 'build', 'out', '__pycache__', '.venv', 'venv',
49
+ 'target', 'vendor', '.next', '.nuxt', '.openlore', '.pi', 'coverage', '.cache',
50
+ ]);
51
+
52
+ /** Fallback quando non è un repo git: walk con esclusioni minime. */
53
+ export async function walkFiles(root, { maxDepth = 32 } = {}) {
54
+ const acc = [];
55
+ async function rec(dir, depth) {
56
+ if (depth > maxDepth) return;
57
+ let entries;
58
+ try {
59
+ entries = await fs.readdir(dir, { withFileTypes: true });
60
+ } catch {
61
+ return;
62
+ }
63
+ for (const e of entries) {
64
+ if (e.name.startsWith('.') && e.isDirectory()) continue;
65
+ const p = path.join(dir, e.name);
66
+ if (e.isDirectory()) {
67
+ if (IGNORED_DIRS.has(e.name)) continue;
68
+ await rec(p, depth + 1);
69
+ } else if (e.isFile()) {
70
+ acc.push(path.relative(root, p));
71
+ }
72
+ }
73
+ }
74
+ await rec(root, 0);
75
+ return acc;
76
+ }
77
+
78
+ /** Byte NUL nei primi 8 KB: binario. */
79
+ export function isBinary(buf) {
80
+ const n = Math.min(buf.length, 8192);
81
+ for (let i = 0; i < n; i++) if (buf[i] === 0) return true;
82
+ return false;
83
+ }
84
+
85
+ /** Riga più lunga > 2000 char: probabile bundle/minified. */
86
+ export function isMinified(text) {
87
+ if (text.length < 4096) return false;
88
+ let run = 0;
89
+ for (let i = 0; i < text.length; i++) {
90
+ if (text.charCodeAt(i) === 10) run = 0;
91
+ else if (++run > 2000) return true;
92
+ }
93
+ return false;
94
+ }
95
+
96
+ export function fmtDuration(ms) {
97
+ if (ms < 1000) return `${Math.round(ms)} ms`;
98
+ const s = ms / 1000;
99
+ if (s < 60) return `${s.toFixed(1)} s`;
100
+ return `${Math.floor(s / 60)}m${String(Math.round(s % 60)).padStart(2, '0')}s`;
101
+ }
102
+
103
+ export function fmtBytes(n) {
104
+ if (n < 1024) return `${n} B`;
105
+ if (n < 1024 ** 2) return `${(n / 1024).toFixed(0)} KB`;
106
+ if (n < 1024 ** 3) return `${(n / 1024 ** 2).toFixed(1)} MB`;
107
+ return `${(n / 1024 ** 3).toFixed(2)} GB`;
108
+ }