@alucard_24/pi-codeindex 1.0.0 → 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/extension.js CHANGED
@@ -10,7 +10,7 @@ import { Type } from '@earendil-works/pi-ai';
10
10
  import fs from 'node:fs';
11
11
  import path from 'node:path';
12
12
  import { indexRepo } from './indexer.js';
13
- import { embeddingStatus } from './embed.js';
13
+ import { embeddingStatus, serviceCommand, getServiceStatus, formatServiceStatus } from './embed.js';
14
14
  import { formatProfile } from './backend.js';
15
15
  import { configCommand } from './config.js';
16
16
  import { configure } from './config-ui.js';
@@ -19,7 +19,8 @@ import { loadIndex } from './store.js';
19
19
  import { invalidateSearcher, loadSearcher, search } from './search.js';
20
20
  import { buildBundle, buildGraph } from './graph.js';
21
21
  import { createDebouncedQueue, filterDirty, parsePorcelainStatus } from './sync.js';
22
- import { fmtBytes, git, headCommit, repoRoot } from './util.js';
22
+ import { fmtBytes, git, headCommit } from './util.js';
23
+ import { resolveWorkspace } from './workspace.js';
23
24
  import { formatBundle, formatHits, outlineText, promptLine, shouldAnnotateRead, statusLine, toRepoRel } from './present.js';
24
25
 
25
26
  const STATUS_KEY = 'codeindex';
@@ -30,7 +31,7 @@ const CONFIG_ENTRY = 'codeindex-config';
30
31
  const DEBOUNCE_MS = 800;
31
32
  const MAX_SWEEP_PATHS = 200;
32
33
 
33
- let state = null; // { repo, enabled, indexing, progress, index, error, abort, queue, watcher, rerun }
34
+ let state = null; // { repo, mode, enabled, indexing, progress, index, error, abort, queue, watcher, rerun }
34
35
  const running = new Map(); // repo -> promise (single-flight per repo)
35
36
 
36
37
  function readEnabled(ctx) {
@@ -65,11 +66,13 @@ function showStatus(ctx) {
65
66
 
66
67
  /** Legge solo i metadati (per l'outline e la riga di stato): niente vettori. */
67
68
  async function refreshMeta() {
69
+ const st = state;
70
+ if (!st) return;
68
71
  try {
69
- const { index } = await loadIndex(state.repo);
70
- state.index = index;
72
+ const { index } = await loadIndex(st.repo);
73
+ if (state === st) st.index = index;
71
74
  } catch {
72
- state.index = null;
75
+ if (state === st) st.index = null;
73
76
  }
74
77
  }
75
78
 
@@ -111,9 +114,10 @@ function runIndex(ctx, { force = false } = {}) {
111
114
  });
112
115
  st.index = r.index;
113
116
  const runtime = embeddingStatus();
114
- if (state === st && st.enabled && !st.backendWarning && runtime.attempts.some((a) => !a.ok)) {
117
+ const warnings = [...runtime.warnings, ...runtime.attempts.filter((a) => !a.ok).map((a) => `${a.device}: ${a.error}`)];
118
+ if (state === st && st.enabled && !st.backendWarning && warnings.length) {
115
119
  st.backendWarning = true;
116
- ctx.ui.notify(`codeindex: selezionato ${formatProfile(runtime.profile)}; tentativi falliti: ${runtime.attempts.filter((a) => !a.ok).map((a) => `${a.device}: ${a.error}`).join('; ')}`, 'warning');
120
+ ctx.ui.notify(`codeindex: selezionato ${formatProfile(runtime.profile)}; ${warnings.join('; ')}`, 'warning');
117
121
  }
118
122
  st.graph = null; // il grafo è derivato dall'indice: va ricostruito
119
123
  invalidateSearcher(repo);
@@ -167,16 +171,22 @@ function stopBackground() {
167
171
  }
168
172
 
169
173
  /** Path toccati da watcher/hook: passa dal debounce e poi riconcilia. */
170
- function markDirty(paths) {
174
+ function markDirty(paths, options) {
171
175
  if (!state?.queue) return;
172
- const rel = filterDirty(paths, state.repo);
176
+ const rel = filterDirty(paths, state.repo, options);
173
177
  if (rel.size === 0) return;
174
178
  state.queue.add(rel);
175
179
  }
176
180
 
177
- /** `git status --porcelain -z` dopo un bash: cattura le modifiche indirette. */
178
- function sweepGitStatus() {
181
+ /** Dopo bash: status Git, oppure riconciliazione incrementale senza Git. */
182
+ function sweepChanges() {
179
183
  if (!state?.enabled || !state.repo) return;
184
+ if (state.mode === 'folder') {
185
+ // Non possiamo dedurre i path dal comando. La coda coalesca le richieste
186
+ // e indexRepo riusa i vettori invariati, anche senza watcher disponibile.
187
+ state.queue?.add(['.']);
188
+ return;
189
+ }
180
190
  // Costa ~50–100 ms su un repo grande: il watcher è il segnale primario, questo
181
191
  // è la rete di sicurezza. Un giro ogni 1,5 s basta.
182
192
  const now = Date.now();
@@ -190,9 +200,20 @@ function sweepGitStatus() {
190
200
 
191
201
  /** Watcher ricorsivo; se il sistema non lo consente si degrada senza watcher. */
192
202
  function startWatcher(ctx) {
203
+ const st = state;
204
+ if (!st?.enabled) return;
193
205
  try {
194
- const watcher = fs.watch(state.repo, { recursive: true }, (_event, filename) => {
195
- if (filename) markDirty([String(filename)]);
206
+ const watcher = fs.watch(st.repo, { recursive: true }, (event, filename) => {
207
+ if (state !== st || !st.enabled) return;
208
+ if (!filename) {
209
+ if (st.mode === 'folder') st.queue?.add(['.']);
210
+ return;
211
+ }
212
+ // Un rename può essere una directory intera: senza Git non esiste uno
213
+ // status successivo che recuperi i file aggiunti, spostati o cancellati.
214
+ markDirty([String(filename)], {
215
+ includeDirectories: st.mode === 'folder' && event === 'rename',
216
+ });
196
217
  });
197
218
  watcher.on('error', () => {
198
219
  try {
@@ -200,12 +221,12 @@ function startWatcher(ctx) {
200
221
  } catch {
201
222
  /* già chiuso */
202
223
  }
203
- if (state) state.watcher = null;
224
+ if (st.watcher === watcher) st.watcher = null;
204
225
  });
205
- state.watcher = watcher;
226
+ st.watcher = watcher;
206
227
  } catch (e) {
207
- state.watcher = null;
208
- ctx.ui.notify(`codeindex: watcher non disponibile (${e.message})`, 'warning');
228
+ st.watcher = null;
229
+ if (state === st) ctx.ui.notify(`codeindex: watcher non disponibile (${e.message})`, 'warning');
209
230
  }
210
231
  }
211
232
 
@@ -248,7 +269,7 @@ const searchCode = defineTool({
248
269
  })),
249
270
  }),
250
271
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
251
- if (!state?.repo) return unavailable('la directory corrente non è un repository git');
272
+ if (!state?.repo) return unavailable('nessuna cartella di progetto attiva');
252
273
  if (!state.enabled) return unavailable('indice disattivato (/codeindex enable per riattivarlo)');
253
274
 
254
275
  const k = Math.min(MAX_K, Math.max(1, Math.round(params.k ?? DEFAULT_K)));
@@ -312,23 +333,24 @@ async function annotateRead(event, ctx) {
312
333
  }
313
334
 
314
335
  function statusReport() {
315
- if (!state) return 'codeindex: nessun repository git nella directory corrente';
336
+ if (!state) return 'codeindex: nessuna cartella di progetto attiva';
316
337
  const i = state.index;
317
338
  const lines = [
318
339
  `repo : ${state.repo}`,
340
+ `modalità : ${state.mode === 'git' ? 'repository Git' : 'cartella (senza Git)'}`,
319
341
  `stato : ${state.enabled ? 'attivo' : 'disattivato'}${state.indexing ? ' · indicizzazione in corso' : ''}`,
320
342
  ];
321
343
  if (state.progress) lines.push(`progresso : ${state.progress.done}/${state.progress.total} chunk`);
322
344
  if (i) {
323
345
  lines.push(`file : ${i.stats?.files ?? '?'} (${fmtBytes(i.stats?.bytes ?? 0)})`);
324
346
  lines.push(`chunk : ${i.chunks.length} · ${i.dims}d · ${i.model} (${i.dtype})`);
325
- lines.push(`head : ${i.head ?? '?'}`);
347
+ if (state.mode === 'git') lines.push(`head : ${i.head ?? '?'}`);
326
348
  lines.push(`aggiornato : ${i.updatedAt}`);
327
349
  } else {
328
350
  lines.push('indice : assente (usa /codeindex reindex)');
329
351
  }
330
352
  const runtime = embeddingStatus();
331
- lines.push(`runtime : ${runtime.profile ? formatProfile(runtime.profile) : 'non caricato'}`);
353
+ lines.push(`profilo client: ${runtime.profile ? formatProfile(runtime.profile) : 'non ancora associato al servizio'}`);
332
354
  if (i?.device) lines.push(`profilo indice: ${formatProfile(i)}`);
333
355
  if (state.error) lines.push(`ultimo errore: ${state.error}`);
334
356
  return lines.join('\n');
@@ -338,7 +360,7 @@ export default function codeindexExtension(pi) {
338
360
  pi.registerTool(searchCode);
339
361
 
340
362
  pi.registerCommand('codeindex', {
341
- description: 'Indice e embedding locali (status|reindex|enable|disable|config|configure|doctor)',
363
+ description: 'Indice e embedding locali (status|reindex|enable|disable|config|configure|doctor|service)',
342
364
  handler: async (args, ctx) => {
343
365
  const [sub = 'status', ...rest] = (args ?? '').trim().split(/\s+/).filter(Boolean);
344
366
  // Configurazione/diagnostica funzionano anche fuori da git o con indice spento.
@@ -352,9 +374,13 @@ export default function codeindexExtension(pi) {
352
374
  if (result) ctx.ui.notify(result, 'info');
353
375
  return;
354
376
  }
377
+ if (sub === 'service') {
378
+ ctx.ui.notify(await serviceCommand(rest), 'info');
379
+ return;
380
+ }
355
381
  if (sub === 'doctor') {
356
382
  const options = parseDoctorArgs(rest);
357
- if (options.probe) ctx.ui.notify('codeindex: prova isolata del modello (può scaricare i pesi, timeout 120 s per backend)…', 'info');
383
+ if (options.probe) ctx.ui.notify('codeindex: prova isolata del modello (può caricare una copia aggiuntiva, scaricare pesi; timeout 120 s per backend)…', 'info');
358
384
  const report = await doctor(options);
359
385
  ctx.ui.notify(options.json ? JSON.stringify(report) : formatDoctor(report), report.ok ? 'info' : 'warning');
360
386
  return;
@@ -364,7 +390,7 @@ export default function codeindexExtension(pi) {
364
390
  return;
365
391
  }
366
392
  if (!state) {
367
- ctx.ui.notify('codeindex: nessun repository git nella directory corrente', 'warning');
393
+ ctx.ui.notify('codeindex: nessuna cartella di progetto attiva', 'warning');
368
394
  return;
369
395
  }
370
396
  if (sub === 'status') {
@@ -372,7 +398,10 @@ export default function codeindexExtension(pi) {
372
398
  // abbia finito: meglio aspettare che mentire ("indice assente").
373
399
  if (!state.index) await refreshMeta();
374
400
  showStatus(ctx);
375
- ctx.ui.notify(statusReport(), 'info');
401
+ let service;
402
+ try { service = formatServiceStatus(await getServiceStatus()); }
403
+ catch (e) { service = `servizio condiviso: ${e.message}`; }
404
+ ctx.ui.notify(`${statusReport()}\n${service}`, 'info');
376
405
  return;
377
406
  }
378
407
  if (sub === 'reindex') {
@@ -405,21 +434,19 @@ export default function codeindexExtension(pi) {
405
434
  ctx.ui.notify(`codeindex: ${state.enabled ? 'attivo' : 'disattivato'}`, 'info');
406
435
  return;
407
436
  }
408
- ctx.ui.notify('uso: /codeindex status|reindex [--force]|enable|disable|config [show|set CHIAVE VALORE|unset CHIAVE]|configure|doctor [--probe] [--all] [--device DEVICE] [--webgpu-backend auto|vulkan]', 'warning');
437
+ ctx.ui.notify('uso: /codeindex status|reindex [--force]|enable|disable|config [show|set CHIAVE VALORE|unset CHIAVE]|configure|service [status [--json]|stop]|doctor [--probe] [--all] [--device DEVICE] [--webgpu-backend auto|vulkan]', 'warning');
409
438
  },
410
439
  });
411
440
 
412
441
  // Risorse di sessione: watcher e timer non vanno creati nel factory.
413
442
  pi.on('session_start', async (_event, ctx) => {
414
- const repo = repoRoot(ctx.cwd);
415
- if (!repo) {
416
- state = null;
417
- ctx.ui.setStatus(STATUS_KEY, undefined);
418
- return;
419
- }
443
+ const workspace = resolveWorkspace(ctx.cwd);
420
444
  const st = {
421
- repo,
422
- enabled: readEnabled(ctx),
445
+ repo: workspace.root,
446
+ mode: workspace.mode,
447
+ // Le cartelle protette richiedono consenso a ogni nuova sessione,
448
+ // anche se un enable era stato salvato nel branch in un altro progetto.
449
+ enabled: workspace.autoIndex && readEnabled(ctx),
423
450
  indexing: false,
424
451
  progress: null,
425
452
  index: null,
@@ -435,6 +462,9 @@ export default function codeindexExtension(pi) {
435
462
  }, { delayMs: DEBOUNCE_MS });
436
463
  state = st;
437
464
  showStatus(ctx);
465
+ if (!workspace.autoIndex && !st.enabled) {
466
+ ctx.ui.notify('codeindex: indicizzazione automatica disattivata nella home o nella radice del disco; usa una cartella di progetto o /codeindex enable', 'info');
467
+ }
438
468
  // Non bloccare l'avvio: metadati, watcher e indice corrono in background.
439
469
  void (async () => {
440
470
  await refreshMeta();
@@ -458,9 +488,12 @@ export default function codeindexExtension(pi) {
458
488
  showStatus(ctx);
459
489
  if (!state?.enabled) return undefined; // spento: nessuna riga nel prompt
460
490
  // Un checkout può cambiare HEAD senza eventi utili del watcher.
461
- if (!state.indexing && state.index?.head && headCommit(state.repo) !== state.index.head) {
491
+ if (state.mode === 'git' && !state.indexing && state.index?.head && headCommit(state.repo) !== state.index.head) {
462
492
  void runIndex(ctx);
463
493
  }
494
+ // Senza watcher e senza Git, un nuovo prompt recupera anche le modifiche
495
+ // esterne a pi. Nessun polling o processo aggiuntivo.
496
+ if (state.mode === 'folder' && !state.watcher) state.queue?.add(['.']);
464
497
  const line = promptLine(state?.index?.chunks?.length ?? 0);
465
498
  if (!line) return undefined;
466
499
  return { systemPrompt: `${event.systemPrompt}\n\n${line}` };
@@ -468,15 +501,19 @@ export default function codeindexExtension(pi) {
468
501
 
469
502
  // Invalidazione diretta: `write`/`edit` sanno esattamente che path hanno toccato.
470
503
  pi.on('tool_result', async (event, ctx) => {
471
- if (event.isError || !state?.enabled) return undefined;
504
+ if (!state?.enabled) return undefined;
472
505
  try {
506
+ // Anche un comando terminato con errore può aver modificato dei file.
507
+ if (event.toolName === 'bash') {
508
+ sweepChanges();
509
+ return undefined;
510
+ }
511
+ if (event.isError) return undefined;
473
512
  if (event.toolName === 'read') return await annotateRead(event, ctx);
474
513
  if (event.toolName === 'write' || event.toolName === 'edit') {
475
514
  markDirty([String(event.input?.path ?? '')]);
476
515
  return undefined;
477
516
  }
478
- // Dopo un bash il filesystem può essere cambiato in modo invisibile agli hook.
479
- if (event.toolName === 'bash') sweepGitStatus();
480
517
  } catch {
481
518
  return undefined; // un hook non deve mai rompere un tool
482
519
  }
@@ -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
+ }
package/src/probe.js CHANGED
@@ -1,20 +1,23 @@
1
1
  // Entry point interno, eseguito solo da doctor --probe in un processo isolato.
2
- import { embed, cosine, getEmbeddingProfile, embeddingStatus, getEmbedder } from './embed.js';
2
+ import { cosine } from './embed.js';
3
+ import { loadConfig } from './config.js';
4
+ import { createLocalEmbedder } from './local-embed.js';
3
5
 
4
- let result;
6
+ let result, runtime;
5
7
  try {
6
8
  const t0 = performance.now();
9
+ runtime = createLocalEmbedder((await loadConfig()).config);
7
10
  const same = 'title: args.ts | text: export function parseArgs(argv) { return argv.slice(2); }';
8
11
  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]);
12
+ const [a, b, c] = await runtime.embed([same, same, other]);
10
13
  const cosSame = cosine(a, b);
11
14
  const cosOther = cosine(a, c);
12
15
  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
+ result = { ok: true, profile: await runtime.getProfile(), elapsedMs: Math.round(performance.now() - t0),
17
+ cosSame, cosOther, attempts: runtime.info().attempts };
18
+ await runtime.dispose();
16
19
  } catch (e) {
17
- result = { ok: false, error: String(e.message ?? e), attempts: embeddingStatus().attempts };
20
+ result = { ok: false, error: String(e.message ?? e), attempts: runtime?.info().attempts ?? [] };
18
21
  }
19
22
  if (process.send) process.send(result, () => process.exit(result.ok ? 0 : 1));
20
23
  else { console.log(JSON.stringify(result)); process.exitCode = result.ok ? 0 : 1; }
@@ -0,0 +1,126 @@
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') launchError = serviceError(message.code ?? 'START_FAILED', message.error);
47
+ });
48
+ child.unref(); child.channel?.unref();
49
+ };
50
+ const deadline = Date.now() + startupTimeoutMs;
51
+ while (Date.now() < deadline) {
52
+ if (launchError) throw launchError;
53
+ const { record, status } = await probe(address);
54
+ if (record && status.running) return record;
55
+ // Il vecchio processo tiene la porta durante dispose. Non tentare
56
+ // un secondo modello mentre quel processo sta ancora liberando VRAM.
57
+ if (!record && !childRunning && launches < 3 && Date.now() - lastLaunchAt >= 100) launch();
58
+ await delay(40);
59
+ }
60
+ 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.`);
61
+ })();
62
+ try { return await starting; } finally { starting = null; }
63
+ }
64
+ function remember(info) { if (info) remote = { ...info, lastSeenAt: Date.now() }; }
65
+ async function call(op, payload = {}) {
66
+ const active = await getConfig();
67
+ const address = serviceAddress(active);
68
+ pending++;
69
+ error = null;
70
+ try {
71
+ let record = await readServiceRecord(address);
72
+ if (!record) record = await start(address);
73
+ let response;
74
+ const request = { op, config: active, expectedProfile: pinned, ...payload };
75
+ try { response = await rpc(address, record, request, { timeoutMs: requestTimeoutMs }); }
76
+ catch (e) {
77
+ // Questi errori provano che l'inferenza non è iniziata. Un crash
78
+ // dopo l'invio non viene ritentato automaticamente.
79
+ if (!['ECONNREFUSED', 'SERVICE_STOPPING'].includes(e.code)) throw e;
80
+ record = await start(address);
81
+ response = await rpc(address, record, request, { timeoutMs: requestTimeoutMs });
82
+ }
83
+ remember(response.info);
84
+ const profile = response.info?.profile;
85
+ if (!profile) throw serviceError('BAD_FRAME', 'il servizio non ha restituito il profilo del modello');
86
+ if (pinned) {
87
+ try { assertCompatibleIndex(pinned, profile); }
88
+ catch { throw serviceError('PROFILE_CHANGED', 'profilo del servizio cambiato: riavvia pi e verifica/reindicizza; nessun vettore accettato'); }
89
+ }
90
+ pinned ??= Object.freeze({ ...profile });
91
+ return response.result;
92
+ } catch (e) {
93
+ remember(e.info);
94
+ error = e.message;
95
+ throw e;
96
+ } finally { pending--; }
97
+ }
98
+ return {
99
+ getProfile: async () => { await call('profile'); return pinned; },
100
+ embed: async (texts, { normalize = true } = {}) => {
101
+ if (Array.isArray(texts) && !texts.length) return [];
102
+ validateTexts(texts, normalize);
103
+ const rows = await call('embed', { texts, normalize });
104
+ return validateEmbeddings(rows, texts.length).map((row) => Float32Array.from(row));
105
+ },
106
+ info: () => ({ profile: pinned, config, loading: pending > 0, attempts: (remote?.attempts ?? []).map((a) => ({ ...a })),
107
+ error, shared: true,
108
+ warnings: pinned?.device === 'cpu' && config && !['auto', 'cpu'].includes(config.device)
109
+ ? [`fallback CPU esplicitamente consentito: richiesto ${config.device}, riutilizzato il servizio ${pinned.device}/${pinned.dtype}`] : [],
110
+ service: remote ? { pid: remote.pid, lastSeenAt: remote.lastSeenAt } : null }),
111
+ status: async () => {
112
+ const address = serviceAddress(await getConfig());
113
+ const record = await readServiceRecord(address);
114
+ if (!record) return { running: false, shared: true, port: address.port };
115
+ try { return (await rpc(address, record, { op: 'status' }, { timeoutMs: 2000 })).result; }
116
+ catch (e) { if (disconnected(e)) return { running: false, shared: true, port: address.port }; throw e; }
117
+ },
118
+ stop: async () => {
119
+ const address = serviceAddress(await getConfig());
120
+ const record = await readServiceRecord(address);
121
+ if (!record) return { stopping: false };
122
+ try { return (await rpc(address, record, { op: 'stop' }, { timeoutMs: 5000 })).result; }
123
+ catch (e) { if (disconnected(e)) return { stopping: false }; throw e; }
124
+ },
125
+ };
126
+ }
@@ -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();