@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/CHANGELOG.md +32 -0
- package/README.md +117 -20
- package/config.schema.json +4 -1
- package/package.json +2 -2
- package/src/cli.js +12 -4
- package/src/config-ui.js +6 -4
- package/src/config.js +10 -5
- package/src/doctor.js +7 -2
- package/src/embed.js +39 -70
- package/src/extension.js +76 -39
- package/src/local-embed.js +51 -0
- package/src/probe.js +10 -7
- package/src/service-client.js +126 -0
- package/src/service-policy.js +33 -0
- package/src/service-protocol.js +122 -0
- package/src/service-worker.js +3 -0
- package/src/service.js +172 -0
- package/src/sync.js +9 -5
- package/src/workspace.js +18 -0
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
|
|
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(
|
|
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
|
-
|
|
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)};
|
|
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
|
-
/**
|
|
178
|
-
function
|
|
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(
|
|
195
|
-
if (
|
|
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 (
|
|
224
|
+
if (st.watcher === watcher) st.watcher = null;
|
|
204
225
|
});
|
|
205
|
-
|
|
226
|
+
st.watcher = watcher;
|
|
206
227
|
} catch (e) {
|
|
207
|
-
|
|
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('
|
|
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:
|
|
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(`
|
|
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ò
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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 {
|
|
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
|
|
14
|
-
cosSame, cosOther, attempts:
|
|
15
|
-
await
|
|
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:
|
|
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
|
+
}
|