@alucard_24/pi-codeindex 1.0.0 → 1.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.
@@ -0,0 +1,53 @@
1
+ // Consenso persistente per progetto, separato dal profilo embedding globale.
2
+ // Nessun file viene letto o scritto nel repository del progetto.
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { createHash, randomUUID } from 'node:crypto';
6
+ import { configPath } from './config.js';
7
+
8
+ export function projectIdentity(root, platform = process.platform) {
9
+ const resolved = (platform === 'win32' ? path.win32 : path).resolve(root);
10
+ return platform === 'win32' ? resolved.toLowerCase() : resolved;
11
+ }
12
+
13
+ async function location(root, options = {}) {
14
+ const project = await fs.realpath(path.resolve(root));
15
+ const identity = projectIdentity(project);
16
+ const hash = createHash('sha256').update(identity).digest('hex');
17
+ const file = path.join(path.dirname(configPath(options.env, options.home)), 'projects', `${hash}.json`);
18
+ return { project, identity, file };
19
+ }
20
+
21
+ export async function readProjectState(root, options = {}) {
22
+ const target = await location(root, options);
23
+ let text;
24
+ try { text = await fs.readFile(target.file, 'utf8'); }
25
+ catch (e) {
26
+ if (e.code === 'ENOENT') return { ...target, enabled: false };
27
+ throw e;
28
+ }
29
+ let value;
30
+ try { value = JSON.parse(text); }
31
+ catch { throw new Error(`attivazione progetto non valida: ${target.file}`); }
32
+ if (!value || value.version !== 1 || typeof value.enabled !== 'boolean'
33
+ || typeof value.project !== 'string' || !path.isAbsolute(value.project)
34
+ || projectIdentity(value.project) !== target.identity) {
35
+ throw new Error(`attivazione progetto non valida: ${target.file}`);
36
+ }
37
+ return { ...target, enabled: value.enabled };
38
+ }
39
+
40
+ export async function writeProjectState(root, enabled, options = {}) {
41
+ if (typeof enabled !== 'boolean') throw new Error('attivazione progetto: enabled deve essere booleano');
42
+ const target = await location(root, options);
43
+ await fs.mkdir(path.dirname(target.file), { recursive: true, mode: 0o700 });
44
+ const tmp = `${target.file}.${randomUUID()}.tmp`;
45
+ try {
46
+ await fs.writeFile(tmp, JSON.stringify({ version: 1, project: target.project, enabled }, null, 2) + '\n',
47
+ { flag: 'wx', mode: 0o600 });
48
+ await fs.rename(tmp, target.file);
49
+ } finally {
50
+ await fs.rm(tmp, { force: true });
51
+ }
52
+ return { ...target, enabled };
53
+ }
@@ -0,0 +1,130 @@
1
+ // Client leggero: solo JSON e socket loopback. Nessuna pipeline locale.
2
+ import { fork } from 'node:child_process';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { setTimeout as delay } from 'node:timers/promises';
5
+ import { loadConfig } from './config.js';
6
+ import { assertCompatibleIndex, validateEmbeddings } from './backend.js';
7
+ import { serviceAddress, readServiceRecord, rpc, disconnected, serviceError } from './service-protocol.js';
8
+ import { validateServiceConfig, validateTexts } from './service-policy.js';
9
+
10
+ export function createEmbeddingClient({ env = process.env, settings = () => loadConfig({ env }),
11
+ workerFile = new URL('./service-worker.js', import.meta.url), startupTimeoutMs = 10_000, requestTimeoutMs = 15 * 60_000 } = {}) {
12
+ let config = null, configLoading = null, starting = null, pinned = null;
13
+ let pending = 0, error = null, remote = null;
14
+ async function getConfig() {
15
+ if (config) return config;
16
+ if (!configLoading) configLoading = Promise.resolve().then(settings).then((loaded) => {
17
+ config = validateServiceConfig(loaded.config);
18
+ return config;
19
+ });
20
+ try { return await configLoading; } finally { configLoading = null; }
21
+ }
22
+ async function probe(address) {
23
+ const record = await readServiceRecord(address);
24
+ if (!record) return { record: null };
25
+ try {
26
+ const response = await rpc(address, record, { op: 'status' }, { timeoutMs: 2000 });
27
+ return { record, status: response.result };
28
+ } catch (e) {
29
+ if (disconnected(e)) return { record: null };
30
+ if (e.code === 'SERVICE_STOPPING') return { record, status: { running: false } };
31
+ throw e;
32
+ }
33
+ }
34
+ async function start(address) {
35
+ if (starting) return starting;
36
+ starting = (async () => {
37
+ let launchError, childRunning = false, launches = 0, lastLaunchAt = 0;
38
+ const launch = () => {
39
+ launches++; childRunning = true; lastLaunchAt = Date.now();
40
+ const child = fork(workerFile instanceof URL ? fileURLToPath(workerFile) : workerFile,
41
+ [address.directory, String(address.port)], { detached: true, execArgv: [], env,
42
+ stdio: ['ignore', 'ignore', 'ignore', 'ipc'] });
43
+ child.on('error', (e) => { launchError = e; childRunning = false; });
44
+ child.on('exit', () => { childRunning = false; });
45
+ child.on('message', (message) => {
46
+ if (message?.ready === false && message.code !== 'EADDRINUSE') {
47
+ const hint = message.code === 'EACCES' && /^listen\b/.test(message.error ?? '')
48
+ ? ' Porta locale non utilizzabile: potrebbe essere esclusa dal sistema. Scegli una porta libera con /codeindex config set servicePort NUMERO e riavvia pi. Nessun fallback in-process.' : '';
49
+ launchError = serviceError(message.code ?? 'START_FAILED', `${message.error}${hint}`);
50
+ }
51
+ });
52
+ child.unref(); child.channel?.unref();
53
+ };
54
+ const deadline = Date.now() + startupTimeoutMs;
55
+ while (Date.now() < deadline) {
56
+ if (launchError) throw launchError;
57
+ const { record, status } = await probe(address);
58
+ if (record && status.running) return record;
59
+ // Il vecchio processo tiene la porta durante dispose. Non tentare
60
+ // un secondo modello mentre quel processo sta ancora liberando VRAM.
61
+ if (!record && !childRunning && launches < 3 && Date.now() - lastLaunchAt >= 100) launch();
62
+ await delay(40);
63
+ }
64
+ throw serviceError('START_FAILED', `servizio locale non avviabile su 127.0.0.1:${address.port}: porta occupata o avvio fallito. Controlla service status e servicePort. Nessun fallback in-process.`);
65
+ })();
66
+ try { return await starting; } finally { starting = null; }
67
+ }
68
+ function remember(info) { if (info) remote = { ...info, lastSeenAt: Date.now() }; }
69
+ async function call(op, payload = {}) {
70
+ const active = await getConfig();
71
+ const address = serviceAddress(active);
72
+ pending++;
73
+ error = null;
74
+ try {
75
+ let record = await readServiceRecord(address);
76
+ if (!record) record = await start(address);
77
+ let response;
78
+ const request = { op, config: active, expectedProfile: pinned, ...payload };
79
+ try { response = await rpc(address, record, request, { timeoutMs: requestTimeoutMs }); }
80
+ catch (e) {
81
+ // Questi errori provano che l'inferenza non è iniziata. Un crash
82
+ // dopo l'invio non viene ritentato automaticamente.
83
+ if (!['ECONNREFUSED', 'SERVICE_STOPPING'].includes(e.code)) throw e;
84
+ record = await start(address);
85
+ response = await rpc(address, record, request, { timeoutMs: requestTimeoutMs });
86
+ }
87
+ remember(response.info);
88
+ const profile = response.info?.profile;
89
+ if (!profile) throw serviceError('BAD_FRAME', 'il servizio non ha restituito il profilo del modello');
90
+ if (pinned) {
91
+ try { assertCompatibleIndex(pinned, profile); }
92
+ catch { throw serviceError('PROFILE_CHANGED', 'profilo del servizio cambiato: riavvia pi e verifica/reindicizza; nessun vettore accettato'); }
93
+ }
94
+ pinned ??= Object.freeze({ ...profile });
95
+ return response.result;
96
+ } catch (e) {
97
+ remember(e.info);
98
+ error = e.message;
99
+ throw e;
100
+ } finally { pending--; }
101
+ }
102
+ return {
103
+ getProfile: async () => { await call('profile'); return pinned; },
104
+ embed: async (texts, { normalize = true } = {}) => {
105
+ if (Array.isArray(texts) && !texts.length) return [];
106
+ validateTexts(texts, normalize);
107
+ const rows = await call('embed', { texts, normalize });
108
+ return validateEmbeddings(rows, texts.length).map((row) => Float32Array.from(row));
109
+ },
110
+ info: () => ({ profile: pinned, config, loading: pending > 0, attempts: (remote?.attempts ?? []).map((a) => ({ ...a })),
111
+ error, shared: true,
112
+ warnings: pinned?.device === 'cpu' && config && !['auto', 'cpu'].includes(config.device)
113
+ ? [`fallback CPU esplicitamente consentito: richiesto ${config.device}, riutilizzato il servizio ${pinned.device}/${pinned.dtype}`] : [],
114
+ service: remote ? { pid: remote.pid, lastSeenAt: remote.lastSeenAt } : null }),
115
+ status: async () => {
116
+ const address = serviceAddress(await getConfig());
117
+ const record = await readServiceRecord(address);
118
+ if (!record) return { running: false, shared: true, port: address.port };
119
+ try { return (await rpc(address, record, { op: 'status' }, { timeoutMs: 2000 })).result; }
120
+ catch (e) { if (disconnected(e)) return { running: false, shared: true, port: address.port }; throw e; }
121
+ },
122
+ stop: async () => {
123
+ const address = serviceAddress(await getConfig());
124
+ const record = await readServiceRecord(address);
125
+ if (!record) return { stopping: false };
126
+ try { return (await rpc(address, record, { op: 'stop' }, { timeoutMs: 5000 })).result; }
127
+ catch (e) { if (disconnected(e)) return { stopping: false }; throw e; }
128
+ },
129
+ };
130
+ }
@@ -0,0 +1,33 @@
1
+ import path from 'node:path';
2
+ import { ENV_KEYS, validateConfig } from './config.js';
3
+ import { assertCompatibleIndex, backendPlan, formatProfile, webgpuBackendOf } from './backend.js';
4
+ import { MAX_TEXTS, MAX_TEXT_BYTES, serviceError } from './service-protocol.js';
5
+
6
+ export function validateServiceConfig(config) {
7
+ validateConfig(config, 'client del servizio');
8
+ if (Object.keys(ENV_KEYS).some((key) => !Object.hasOwn(config, key))) throw serviceError('BAD_CONFIG', 'configurazione client incompleta');
9
+ if (!path.isAbsolute(config.cacheDir) || !path.isAbsolute(config.serviceDir)) throw serviceError('BAD_CONFIG', 'cacheDir e serviceDir devono essere assoluti');
10
+ return Object.freeze({ ...config });
11
+ }
12
+
13
+ export function assertServiceCompatible(config, activeConfig, profile, expectedProfile) {
14
+ const permitted = backendPlan(config, [{ device: profile.device, available: true }]).some((p) =>
15
+ p.device === profile.device && p.dtype === profile.dtype &&
16
+ (p.device !== 'webgpu' || config.webgpuBackend === webgpuBackendOf(profile)));
17
+ const differs = ['cacheDir', 'cpuThreads', 'serviceIdleMs'].filter((key) => config[key] !== activeConfig[key]);
18
+ if (!permitted || differs.length) {
19
+ throw serviceError('PROFILE_CONFLICT', `servizio condiviso già configurato per ${formatProfile(profile)}${differs.length ? `; impostazioni diverse: ${differs.join(', ')}` : ''}. Nessun secondo modello avviato. Usa /codeindex service status; per cambiare profilo, service stop quando inattivo e riavvia pi.`);
20
+ }
21
+ if (expectedProfile) {
22
+ try { assertCompatibleIndex(expectedProfile, profile); }
23
+ catch { throw serviceError('PROFILE_CHANGED', `profilo del servizio cambiato dopo una riconnessione: ${formatProfile(profile)}. Riavvia pi e verifica/reindicizza; nessun vettore è stato accettato.`); }
24
+ }
25
+ }
26
+
27
+ export function validateTexts(texts, normalize) {
28
+ if (!Array.isArray(texts) || !texts.length || texts.length > MAX_TEXTS || texts.some((t) => typeof t !== 'string')) {
29
+ throw serviceError('BAD_TEXTS', `embedding: attesi da 1 a ${MAX_TEXTS} testi`);
30
+ }
31
+ if (typeof normalize !== 'boolean') throw serviceError('BAD_TEXTS', 'normalize deve essere booleano');
32
+ if (texts.reduce((n, t) => n + Buffer.byteLength(t), 0) > MAX_TEXT_BYTES) throw serviceError('BAD_TEXTS', `batch di embedding troppo grande (massimo ${MAX_TEXT_BYTES} byte)`);
33
+ }
@@ -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/sync.js CHANGED
@@ -19,8 +19,12 @@ const extOf = (p) => {
19
19
  return m ? m[0].toLowerCase() : '';
20
20
  };
21
21
 
22
- /** Path relativo al repo -> normalizzato con '/', o null se non è indicizzabile. */
23
- export function watchablePath(rel, root) {
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 } = {}) {
24
28
  if (!rel) return null;
25
29
  let p = String(rel).replace(/\\/g, '/');
26
30
  if (path.isAbsolute(p)) {
@@ -33,15 +37,15 @@ export function watchablePath(rel, root) {
33
37
  if (segments.length === 0 || segments[0] === '..') return null;
34
38
  p = segments.join('/');
35
39
  if (segments.some((s) => IGNORED_DIRS.has(s))) return null;
36
- if (!(extOf(p) in LANG_BY_EXT)) return null;
40
+ if (!includeDirectories && !(extOf(p) in LANG_BY_EXT)) return null;
37
41
  return segments.join('/');
38
42
  }
39
43
 
40
44
  /** Filtra e deduplica una lista di path (watcher, hook dei tool, git status). */
41
- export function filterDirty(paths, root) {
45
+ export function filterDirty(paths, root, options) {
42
46
  const out = new Set();
43
47
  for (const p of paths ?? []) {
44
- const w = watchablePath(p, root);
48
+ const w = watchablePath(p, root, options);
45
49
  if (w) out.add(w);
46
50
  }
47
51
  return out;
@@ -0,0 +1,26 @@
1
+ // Radice del progetto: Git quando disponibile, altrimenti la cartella corrente.
2
+ // Nessuna configurazione di progetto viene letta e nessun repository viene creato.
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { repoRoot } from './util.js';
7
+
8
+ function canonicalPath(value) {
9
+ let result = path.resolve(value);
10
+ try { result = fs.realpathSync.native(result); } catch { /* directory non accessibile */ }
11
+ return process.platform === 'win32' ? result.toLowerCase() : result;
12
+ }
13
+
14
+ export function resolveWorkspace(cwd = process.cwd(), {
15
+ findRepoRoot = repoRoot,
16
+ home = os.homedir(),
17
+ } = {}) {
18
+ const gitRoot = findRepoRoot(cwd);
19
+ const root = path.resolve(gitRoot ?? cwd);
20
+ const mode = gitRoot ? 'git' : 'folder';
21
+ // Un avvio dalla home o dalla radice del disco non deve scandire l'intera
22
+ // macchina per errore. /codeindex enable resta un consenso esplicito.
23
+ const canonicalRoot = canonicalPath(root);
24
+ const broadFolder = canonicalRoot === path.parse(canonicalRoot).root || canonicalRoot === canonicalPath(home);
25
+ return { root, mode, autoIndex: mode === 'git' || !broadFolder };
26
+ }