sublera-cli 0.0.0-stage → 1.3.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.
Files changed (72) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/LICENSE +21 -0
  3. package/README.md +585 -2
  4. package/SECURITY.md +78 -0
  5. package/THIRD_PARTY_NOTICES.md +37 -0
  6. package/dist/agent.d.ts +270 -0
  7. package/dist/agent.js +1867 -0
  8. package/dist/commands.d.ts +76 -0
  9. package/dist/commands.js +841 -0
  10. package/dist/config.d.ts +27 -0
  11. package/dist/config.js +95 -0
  12. package/dist/engine/binaryManager.d.ts +67 -0
  13. package/dist/engine/binaryManager.js +590 -0
  14. package/dist/engine/gguf.d.ts +88 -0
  15. package/dist/engine/gguf.js +458 -0
  16. package/dist/engine/llamaRunner.d.ts +78 -0
  17. package/dist/engine/llamaRunner.js +282 -0
  18. package/dist/engine/modelManager.d.ts +63 -0
  19. package/dist/engine/modelManager.js +510 -0
  20. package/dist/engine/selfCheck.d.ts +34 -0
  21. package/dist/engine/selfCheck.js +108 -0
  22. package/dist/exitCodes.d.ts +20 -0
  23. package/dist/exitCodes.js +20 -0
  24. package/dist/hardware/gpuMonitor.d.ts +32 -0
  25. package/dist/hardware/gpuMonitor.js +378 -0
  26. package/dist/hardware/sleepGuard.d.ts +42 -0
  27. package/dist/hardware/sleepGuard.js +149 -0
  28. package/dist/hardware/types.d.ts +40 -0
  29. package/dist/hardware/types.js +2 -0
  30. package/dist/health.d.ts +17 -0
  31. package/dist/health.js +38 -0
  32. package/dist/index.d.ts +11 -0
  33. package/dist/index.js +268 -0
  34. package/dist/logger.d.ts +53 -0
  35. package/dist/logger.js +201 -0
  36. package/dist/network/proxy.d.ts +58 -0
  37. package/dist/network/proxy.js +328 -0
  38. package/dist/network/streamProxy.d.ts +51 -0
  39. package/dist/network/streamProxy.js +286 -0
  40. package/dist/network/tunnelClient.d.ts +54 -0
  41. package/dist/network/tunnelClient.js +257 -0
  42. package/dist/settings.d.ts +53 -0
  43. package/dist/settings.js +171 -0
  44. package/dist/state.d.ts +52 -0
  45. package/dist/state.js +71 -0
  46. package/dist/storageCommand.d.ts +22 -0
  47. package/dist/storageCommand.js +81 -0
  48. package/dist/types.d.ts +222 -0
  49. package/dist/types.js +43 -0
  50. package/dist/ui/progress.d.ts +29 -0
  51. package/dist/ui/progress.js +82 -0
  52. package/dist/ui/terminalDashboard.d.ts +88 -0
  53. package/dist/ui/terminalDashboard.js +295 -0
  54. package/dist/util/env.d.ts +3 -0
  55. package/dist/util/env.js +6 -0
  56. package/dist/util/exec.d.ts +23 -0
  57. package/dist/util/exec.js +116 -0
  58. package/dist/util/format.d.ts +47 -0
  59. package/dist/util/format.js +125 -0
  60. package/dist/util/fs.d.ts +18 -0
  61. package/dist/util/fs.js +140 -0
  62. package/dist/util/http.d.ts +28 -0
  63. package/dist/util/http.js +128 -0
  64. package/dist/util/instanceLock.d.ts +3 -0
  65. package/dist/util/instanceLock.js +108 -0
  66. package/dist/util/open.d.ts +16 -0
  67. package/dist/util/open.js +65 -0
  68. package/dist/util/storage.d.ts +10 -0
  69. package/dist/util/storage.js +69 -0
  70. package/dist/version.d.ts +15 -0
  71. package/dist/version.js +29 -0
  72. package/package.json +68 -4
@@ -0,0 +1,510 @@
1
+ import { createReadStream, createWriteStream } from 'node:fs';
2
+ import { createHash } from 'node:crypto';
3
+ import { open, readdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
4
+ import { basename, join } from 'node:path';
5
+ import { ensureDir, freeDiskBytes } from '../util/fs.js';
6
+ import { formatBytes } from '../util/format.js';
7
+ import { ProgressBar } from '../ui/progress.js';
8
+ import { checkModelUrl, estimateVramMb, fetchModelUrl, MAX_MODEL_BYTES, parseGgufHeader, parseHfUrl, readGgufHeader, } from './gguf.js';
9
+ import { modelsDir } from '../config.js';
10
+ import { USER_AGENT } from '../version.js';
11
+ import { directoryBytes, diskWriteAllowance, storageLimitError, storagePolicy } from '../util/storage.js';
12
+ /** Avoid repeated hashes of unchanged files. Stat identity detects ordinary changes, not a malicious local administrator. */
13
+ const hashedThisRun = new Map();
14
+ function fileIdentity(info) {
15
+ // Windows/libuv may return dev=0 for path stat but a volume ID for fstat. Inode and
16
+ // birth/change timestamps remain comparable; this cache is additionally keyed by path.
17
+ const dev = process.platform === 'win32' ? 0n : info.dev;
18
+ return [dev, info.ino, info.size, info.mtimeNs, info.ctimeNs, info.birthtimeNs].join(':');
19
+ }
20
+ async function assertUnchanged(path, before) {
21
+ if (fileIdentity(await stat(path, { bigint: true })) !== fileIdentity(before)) {
22
+ hashedThisRun.delete(path);
23
+ throw new Error(`The model file ${basename(path)} changed during verification; retry when it is no longer being modified`);
24
+ }
25
+ }
26
+ /** Never recursively delete a path supplied by cache state (it may now be a directory). */
27
+ async function removeIfExists(path) {
28
+ try {
29
+ await unlink(path);
30
+ }
31
+ catch (error) {
32
+ if (error.code !== 'ENOENT')
33
+ throw error;
34
+ }
35
+ }
36
+ function expectedHash(value) {
37
+ if (value == null)
38
+ return null;
39
+ if (typeof value !== 'string' || !/^[0-9a-f]{64}$/i.test(value))
40
+ throw new Error('Expected SHA-256 must be exactly 64 hexadecimal characters');
41
+ return value.toLowerCase();
42
+ }
43
+ /** Hash one open descriptor, never a path reopened after recording its identity. */
44
+ async function hashModel(path, onProgress) {
45
+ const handle = await open(path, 'r');
46
+ try {
47
+ const before = await handle.stat({ bigint: true });
48
+ if (!before.isFile())
49
+ throw new Error(`Not a regular model file: ${basename(path)}`);
50
+ await assertUnchanged(path, before);
51
+ const hash = createHash('sha256');
52
+ let received = 0;
53
+ for await (const chunk of handle.createReadStream({ autoClose: false })) {
54
+ hash.update(chunk);
55
+ received += chunk.length;
56
+ onProgress?.(received);
57
+ }
58
+ const after = await handle.stat({ bigint: true });
59
+ if (fileIdentity(before) !== fileIdentity(after) || BigInt(received) !== before.size) {
60
+ throw new Error(`The model file ${basename(path)} changed during verification; retry when it is no longer being modified`);
61
+ }
62
+ await assertUnchanged(path, before);
63
+ return { sha256: hash.digest('hex'), snapshot: before };
64
+ }
65
+ catch (error) {
66
+ hashedThisRun.delete(path);
67
+ throw error;
68
+ }
69
+ finally {
70
+ await handle.close();
71
+ }
72
+ }
73
+ /** Unit tests: behave like a new agent process that has hashed nothing yet. */
74
+ export function forgetHashedModels() {
75
+ hashedThisRun.clear();
76
+ }
77
+ function sanitizeFile(name) {
78
+ const base = basename(name).replace(/[^\w.\-()+ ]+/g, '_').slice(0, 180);
79
+ return base || 'model.gguf';
80
+ }
81
+ export function modelPathFor(url, cacheDir = modelsDir()) {
82
+ let name = 'model.gguf';
83
+ try {
84
+ const u = new URL(url);
85
+ name = decodeURIComponent(u.pathname.split('/').filter(Boolean).pop() || name);
86
+ }
87
+ catch {
88
+ name = basename(url) || name;
89
+ }
90
+ return join(cacheDir, sanitizeFile(name));
91
+ }
92
+ function sidecarPath(path) {
93
+ return `${path}.sublet.json`;
94
+ }
95
+ function partSourcePath(part) {
96
+ return `${part}.json`;
97
+ }
98
+ async function readPartSource(part) {
99
+ try {
100
+ const data = JSON.parse(await readFile(partSourcePath(part), 'utf8'));
101
+ return typeof data?.url === 'string' ? data.url : null;
102
+ }
103
+ catch (error) {
104
+ if (error instanceof SyntaxError || error.code === 'ENOENT')
105
+ return null;
106
+ throw error;
107
+ }
108
+ }
109
+ function emitProgress(options, progress, bar) {
110
+ options.onProgress?.(progress);
111
+ if (bar)
112
+ bar.set(progress.received, progress.total ?? undefined);
113
+ }
114
+ async function readSidecar(path) {
115
+ try {
116
+ const raw = await readFile(sidecarPath(path), 'utf8');
117
+ const data = JSON.parse(raw);
118
+ if (!data || typeof data.sha256 !== 'string' || !/^[0-9a-f]{64}$/i.test(data.sha256)
119
+ || !Number.isSafeInteger(data.size) || data.size <= 0 || !Number.isFinite(data.mtimeMs)
120
+ || typeof data.identity !== 'string')
121
+ return null;
122
+ return data;
123
+ }
124
+ catch {
125
+ return null;
126
+ }
127
+ }
128
+ /** Cache inventory hints, not proof of a hash check. downloadModel revalidates bytes before use. */
129
+ export async function listCachedModels(cacheDir = modelsDir()) {
130
+ let entries;
131
+ try {
132
+ entries = await readdir(cacheDir, { withFileTypes: true });
133
+ }
134
+ catch {
135
+ return [];
136
+ }
137
+ const models = [];
138
+ for (const entry of entries) {
139
+ if (!entry.isFile() || !/\.gguf$/i.test(entry.name))
140
+ continue;
141
+ const path = join(cacheDir, entry.name);
142
+ const [sidecar, info] = await Promise.all([readSidecar(path), stat(path, { bigint: true }).catch(() => null)]);
143
+ if (!sidecar || !info?.isFile() || info.size !== BigInt(sidecar.size) || sidecar.identity !== fileIdentity(info))
144
+ continue;
145
+ models.push({ sha256: sidecar.sha256.toLowerCase(), file: entry.name, sizeBytes: Number(info.size) });
146
+ if (models.length >= 200)
147
+ break;
148
+ }
149
+ return models;
150
+ }
151
+ async function writeSidecar(path, sha256, info, snapshot) {
152
+ await assertUnchanged(path, snapshot);
153
+ const data = {
154
+ sha256,
155
+ size: Number(snapshot.size),
156
+ mtimeMs: Number(snapshot.mtimeNs) / 1e6,
157
+ identity: fileIdentity(snapshot),
158
+ checkedAt: Date.now(),
159
+ info: {
160
+ architecture: info.architecture,
161
+ layers: info.layers,
162
+ paramB: info.paramB,
163
+ quantLabel: info.quantLabel,
164
+ contextTrain: info.contextTrain,
165
+ },
166
+ };
167
+ await writeFile(sidecarPath(path), JSON.stringify(data), 'utf8');
168
+ await assertUnchanged(path, snapshot);
169
+ }
170
+ function loadedFrom(path, url, sha, size, info, verified, context, slots = 1) {
171
+ const paramB = info.paramB;
172
+ const quant = info.quantLabel;
173
+ return {
174
+ path,
175
+ file: basename(path),
176
+ url,
177
+ sha256: sha,
178
+ size,
179
+ info,
180
+ paramB,
181
+ quant,
182
+ layers: info.layers,
183
+ contextTrain: info.contextTrain,
184
+ architecture: info.architecture,
185
+ needsMb: paramB ? estimateVramMb({ paramB, quant: quant || 'Q4_K_M', context, layers: info.layers ?? undefined, slots }) : null,
186
+ verified,
187
+ };
188
+ }
189
+ async function verifyHeader(path, expectedLayers) {
190
+ const info = await readGgufHeader(path);
191
+ if (expectedLayers && info.layers && Math.abs(info.layers - expectedLayers) > 2) {
192
+ throw new Error(`GGUF header has ${info.layers} layers, buyer asked for ${expectedLayers}`);
193
+ }
194
+ return info;
195
+ }
196
+ let pendingDownload = Promise.resolve();
197
+ /** Serialize downloads so concurrent jobs in this process cannot spend the same disk budget. */
198
+ export async function downloadModel(options) {
199
+ const operation = pendingDownload.catch(() => undefined).then(() => downloadModelWithinBudget(options));
200
+ pendingDownload = operation;
201
+ return operation;
202
+ }
203
+ async function downloadModelWithinBudget(options) {
204
+ const expectedSha = expectedHash(options.expectedSha256);
205
+ const url = checkModelUrl(options.url, { allowLocalhost: options.allowLocalhost });
206
+ const cacheDir = options.cacheDir ?? modelsDir();
207
+ await ensureDir(cacheDir);
208
+ const dest = modelPathFor(url, cacheDir);
209
+ const part = `${dest}.part`;
210
+ const context = options.context ?? 8192;
211
+ const slots = options.slots ?? 1;
212
+ const policy = storagePolicy();
213
+ options.signal?.throwIfAborted();
214
+ const fetchModel = (headers) => fetchModelUrl(url, { headers, signal: options.signal }, {
215
+ allowLocalhost: options.allowLocalhost,
216
+ fetchImpl: options.fetchImpl,
217
+ });
218
+ if (await fileExists(dest)) {
219
+ try {
220
+ let snapshot = await stat(dest, { bigint: true });
221
+ const size = Number(snapshot.size);
222
+ const seen = hashedThisRun.get(dest);
223
+ let actual;
224
+ if (seen?.identity === fileIdentity(snapshot)) {
225
+ actual = seen.sha256;
226
+ }
227
+ else {
228
+ const note = `Verifying cached ${basename(dest)}`;
229
+ let lastNote = 0;
230
+ const report = (received) => {
231
+ const now = Date.now();
232
+ if (received < size && now - lastNote < 1_000)
233
+ return;
234
+ lastNote = now;
235
+ emitProgress(options, { phase: 'verify', received, total: size, percent: size ? Math.min(100, (received / size) * 100) : null, speedBps: null, etaSeconds: null, note }, null);
236
+ };
237
+ report(0);
238
+ const hashed = await hashModel(dest, report);
239
+ actual = hashed.sha256;
240
+ snapshot = hashed.snapshot;
241
+ }
242
+ if (expectedSha && actual !== expectedSha) {
243
+ // A measured mismatch justifies fetching a replacement, not deleting the current file
244
+ // before that replacement succeeds. IO, header, callback, and sidecar errors never do.
245
+ hashedThisRun.delete(dest);
246
+ emitProgress(options, { phase: 'cache', received: 0, total: null, percent: null, speedBps: null, etaSeconds: null, note: `The cached ${basename(dest)} failed verification; downloading it again` }, null);
247
+ }
248
+ else {
249
+ const info = await verifyHeader(dest, options.expectedLayers);
250
+ await assertUnchanged(dest, snapshot);
251
+ await writeSidecar(dest, actual, info, snapshot);
252
+ hashedThisRun.set(dest, { identity: fileIdentity(snapshot), sha256: actual });
253
+ return loadedFrom(dest, url, actual, Number(snapshot.size), info, Boolean(expectedSha), context, slots);
254
+ }
255
+ }
256
+ catch (error) {
257
+ hashedThisRun.delete(dest);
258
+ throw error;
259
+ }
260
+ }
261
+ let startAt = (await fileExists(part)) ? (await stat(part)).size : 0;
262
+ if (startAt > 0 && await readPartSource(part) !== url) {
263
+ await removeIfExists(part);
264
+ startAt = 0;
265
+ }
266
+ let total = options.expectedSize ?? null;
267
+ if (total != null && (!Number.isSafeInteger(total) || total <= 0))
268
+ throw new Error('Expected model size must be a positive safe integer');
269
+ let response;
270
+ const headers = { 'user-agent': USER_AGENT, accept: 'application/octet-stream' };
271
+ if (startAt > 0)
272
+ headers.range = `bytes=${startAt}-`;
273
+ try {
274
+ response = await fetchModel(headers);
275
+ }
276
+ catch (error) {
277
+ // A transient network failure says nothing about the bytes already downloaded.
278
+ throw new Error(`Download failed: ${error instanceof Error ? error.message : String(error)}`);
279
+ }
280
+ if (response.status === 416) {
281
+ await removeIfExists(part);
282
+ startAt = 0;
283
+ response = await fetchModel({ 'user-agent': USER_AGENT });
284
+ }
285
+ if (!response.ok || !response.body) {
286
+ throw new Error(`Download failed with HTTP ${response.status}`);
287
+ }
288
+ let append = false;
289
+ if (startAt > 0 && response.status === 206) {
290
+ const contentRange = response.headers.get('content-range') || '';
291
+ const match = contentRange.match(/bytes\s+(\d+)-(\d+)\/(\d+|\*)/i);
292
+ if (!match || Number(match[1]) !== startAt) {
293
+ await response.body.cancel().catch(() => undefined);
294
+ throw new Error(`Server returned an invalid resume range: ${contentRange || 'missing'}`);
295
+ }
296
+ total = match[3] && match[3] !== '*' ? Number(match[3]) : total;
297
+ append = true;
298
+ }
299
+ else if (response.status === 200 && startAt > 0) {
300
+ // Server ignored Range; the body starts at byte zero.
301
+ startAt = 0;
302
+ append = false;
303
+ }
304
+ const contentLength = Number(response.headers.get('content-length') || 0);
305
+ if (!total && contentLength > 0)
306
+ total = startAt + contentLength;
307
+ if (total != null && (!Number.isSafeInteger(total) || total <= 0)) {
308
+ await response.body.cancel().catch(() => undefined);
309
+ throw new Error('Invalid model download size');
310
+ }
311
+ // Once the response establishes a restart, discard the old partial before
312
+ // measuring cache usage and free space. Otherwise it is counted alongside its
313
+ // replacement and can falsely exhaust either budget. Resumes keep their bytes.
314
+ if (!append) {
315
+ try {
316
+ await removeIfExists(part);
317
+ }
318
+ catch (error) {
319
+ await response.body.cancel().catch(() => undefined);
320
+ throw error;
321
+ }
322
+ }
323
+ const limit = Math.min(total ?? MAX_MODEL_BYTES, MAX_MODEL_BYTES);
324
+ const free = await freeDiskBytes(cacheDir);
325
+ const refusal = total && total > MAX_MODEL_BYTES
326
+ ? `The model is ${formatBytes(total)}, above the ${formatBytes(MAX_MODEL_BYTES)} limit for one GGUF file`
327
+ : total && free != null && total - startAt > free
328
+ ? `Not enough disk space: ${formatBytes(total - startAt)} is needed, ${formatBytes(free)} is free in ${cacheDir}`
329
+ : null;
330
+ if (refusal) {
331
+ await response.body.cancel().catch(() => undefined);
332
+ throw new Error(refusal);
333
+ }
334
+ let writeBudget;
335
+ try {
336
+ const used = await directoryBytes(cacheDir);
337
+ // Keep 64 KiB for sidecar metadata; old/replaced models count until replacement succeeds.
338
+ writeBudget = Math.min(policy.modelCacheBytes - used - 64 * 1024, await diskWriteAllowance(cacheDir, policy.reserveBytes));
339
+ if (writeBudget <= 0 || (total != null && total - startAt > writeBudget))
340
+ throw storageLimitError();
341
+ }
342
+ catch (error) {
343
+ await response.body.cancel().catch(() => undefined);
344
+ throw error;
345
+ }
346
+ const hash = createHash('sha256');
347
+ if (append) {
348
+ await new Promise((resolve, reject) => {
349
+ const rs = createReadStream(part);
350
+ rs.on('data', (chunk) => hash.update(chunk));
351
+ rs.on('end', () => resolve());
352
+ rs.on('error', reject);
353
+ });
354
+ }
355
+ const bar = options.showProgress === false ? null : new ProgressBar({
356
+ label: `Downloading ${basename(dest)}`,
357
+ total: total ?? 0,
358
+ enabled: options.showProgress ?? Boolean(options.stream?.isTTY ?? process.stderr.isTTY),
359
+ stream: options.stream ?? process.stderr,
360
+ });
361
+ await writeFile(partSourcePath(part), JSON.stringify({ url }), 'utf8');
362
+ const writer = createWriteStream(part, { flags: append ? 'a' : 'w' });
363
+ let received = startAt;
364
+ let oversized = false;
365
+ let lastDiskCheck = startAt;
366
+ const started = Date.now();
367
+ const reader = response.body.getReader();
368
+ try {
369
+ while (true) {
370
+ const { done, value } = await reader.read();
371
+ if (done)
372
+ break;
373
+ if (options.signal?.aborted)
374
+ throw new Error('Download cancelled');
375
+ if (!value?.length)
376
+ continue;
377
+ if (received - startAt + value.length > writeBudget)
378
+ throw storageLimitError();
379
+ if (received - lastDiskCheck >= 64 * 1024 * 1024) {
380
+ if (await diskWriteAllowance(cacheDir, policy.reserveBytes) < value.length)
381
+ throw storageLimitError();
382
+ lastDiskCheck = received;
383
+ }
384
+ if (received + value.length > limit) {
385
+ oversized = true;
386
+ throw new Error(`The download is larger than the expected ${formatBytes(limit)}; the file was rejected`);
387
+ }
388
+ hash.update(value);
389
+ received += value.length;
390
+ if (!writer.write(Buffer.from(value))) {
391
+ await new Promise((resolve, reject) => {
392
+ const onDrain = () => { cleanup(); resolve(); };
393
+ const onError = (error) => { cleanup(); reject(error); };
394
+ const cleanup = () => {
395
+ writer.off('drain', onDrain);
396
+ writer.off('error', onError);
397
+ };
398
+ writer.once('drain', onDrain);
399
+ writer.once('error', onError);
400
+ });
401
+ }
402
+ const elapsed = Math.max(0.001, (Date.now() - started) / 1000);
403
+ const speed = (received - startAt) / elapsed;
404
+ emitProgress(options, {
405
+ phase: 'download',
406
+ received,
407
+ total,
408
+ percent: total ? Math.min(100, (received / total) * 100) : null,
409
+ speedBps: speed,
410
+ etaSeconds: total && speed > 0 ? Math.max(0, (total - received) / speed) : null,
411
+ note: `Downloading ${basename(dest)}`,
412
+ }, bar);
413
+ }
414
+ await new Promise((resolve, reject) => writer.end((error) => error ? reject(error) : resolve()));
415
+ }
416
+ catch (error) {
417
+ if (!writer.closed)
418
+ await new Promise((resolve) => writer.once('close', () => resolve()).destroy());
419
+ await reader.cancel().catch(() => undefined);
420
+ bar?.fail();
421
+ if (oversized) {
422
+ await removeIfExists(part);
423
+ await removeIfExists(partSourcePath(part));
424
+ }
425
+ throw error;
426
+ }
427
+ bar?.done();
428
+ const actual = hash.digest('hex');
429
+ if (expectedSha && actual !== expectedSha) {
430
+ await removeIfExists(part);
431
+ await removeIfExists(partSourcePath(part));
432
+ throw new Error(`SHA-256 mismatch. Expected ${expectedSha}, got ${actual}. The file was rejected.`);
433
+ }
434
+ if (total != null && received !== total)
435
+ throw new Error(`Incomplete model download: expected ${total} bytes, received ${received}`);
436
+ // Reject bad headers before replacing any existing cache entry.
437
+ await verifyHeader(part, options.expectedLayers);
438
+ await rename(part, dest);
439
+ await removeIfExists(partSourcePath(part));
440
+ emitProgress(options, {
441
+ phase: 'verify',
442
+ received,
443
+ total,
444
+ percent: 100,
445
+ speedBps: null,
446
+ etaSeconds: 0,
447
+ note: 'Verifying GGUF header',
448
+ }, null);
449
+ // A stream hash measures incoming bytes, not necessarily the final file on disk. Reopen and
450
+ // hash one stable descriptor before associating the hash with its on-disk stat identity.
451
+ const hashed = await hashModel(dest);
452
+ if (hashed.sha256 !== actual)
453
+ throw new Error(`The model file ${basename(dest)} changed during download verification`);
454
+ const info = await verifyHeader(dest, options.expectedLayers);
455
+ await assertUnchanged(dest, hashed.snapshot);
456
+ await writeSidecar(dest, hashed.sha256, info, hashed.snapshot);
457
+ const model = loadedFrom(dest, url, hashed.sha256, Number(hashed.snapshot.size), info, Boolean(expectedSha), context, slots);
458
+ emitProgress(options, {
459
+ phase: 'header',
460
+ received,
461
+ total,
462
+ percent: 100,
463
+ speedBps: null,
464
+ etaSeconds: 0,
465
+ note: `Read ${model.info.architecture || 'GGUF'} header, ${model.layers || '?'} layers${expectedSha ? '; expected SHA-256 matched' : '; no expected SHA-256 supplied'}`,
466
+ }, null);
467
+ await assertUnchanged(dest, hashed.snapshot);
468
+ hashedThisRun.set(dest, { identity: fileIdentity(hashed.snapshot), sha256: hashed.sha256 });
469
+ return model;
470
+ }
471
+ export async function verifyModelFile(path, expected) {
472
+ const expectedSha = expectedHash(expected?.sha256);
473
+ const { sha256: hash, snapshot } = await hashModel(path);
474
+ if (expectedSha && hash !== expectedSha) {
475
+ throw new Error(`SHA-256 mismatch for ${basename(path)}`);
476
+ }
477
+ const info = await readGgufHeader(path);
478
+ if (expected?.layers && info.layers && Math.abs(info.layers - expected.layers) > 2) {
479
+ throw new Error(`GGUF has ${info.layers} layers, expected ${expected.layers}`);
480
+ }
481
+ await assertUnchanged(path, snapshot);
482
+ return loadedFrom(path, path, hash, Number(snapshot.size), info, Boolean(expectedSha), expected?.context ?? 8192, expected?.slots ?? 1);
483
+ }
484
+ export function cachedModelName(url) {
485
+ const parsed = parseHfUrl(url);
486
+ return parsed?.file || sanitizeFile(url.split('/').pop() || 'model.gguf');
487
+ }
488
+ async function fileExists(path) {
489
+ try {
490
+ const s = await stat(path);
491
+ return s.isFile();
492
+ }
493
+ catch (error) {
494
+ if (error.code === 'ENOENT')
495
+ return false;
496
+ throw error;
497
+ }
498
+ }
499
+ export async function openModelHeader(path, bytes = 4096) {
500
+ const handle = await open(path, 'r');
501
+ try {
502
+ const buf = Buffer.alloc(bytes);
503
+ const { bytesRead } = await handle.read(buf, 0, buf.length, 0);
504
+ return parseGgufHeader(buf.subarray(0, bytesRead));
505
+ }
506
+ finally {
507
+ await handle.close();
508
+ }
509
+ }
510
+ //# sourceMappingURL=modelManager.js.map
@@ -0,0 +1,34 @@
1
+ /**
2
+ * A local check of the engine's own answers. The server decides whether a host runs the model it
3
+ * claims (it compares token probabilities across hosts); nothing here replaces that, and a host
4
+ * who edits this program can skip it. This is a limited repeatability check, not proof of model
5
+ * identity or GPU health. Even temperature-0 inference can vary with batching, backend kernels,
6
+ * software, and numerical rounding. A measured change alone does not diagnose its cause.
7
+ */
8
+ /** The chosen token and its log probability at each position of one short generation. */
9
+ export type EngineFingerprint = {
10
+ tokens: string[];
11
+ logprobs: number[];
12
+ };
13
+ export type FingerprintDiff = {
14
+ /** true = matching measured evidence; false = measured change; null = inconclusive. */
15
+ same: boolean | null;
16
+ /** Positions that were compared before the answers ended or split at a near-tie. */
17
+ compared: number;
18
+ maxDelta: number;
19
+ reason: string | null;
20
+ };
21
+ /**
22
+ * Heuristic log-probability tolerance, not a calibrated health threshold. This has not been
23
+ * validated across backends or GPUs and can produce both false positives and false negatives.
24
+ */
25
+ export declare const FINGERPRINT_TOLERANCE = 0.5;
26
+ type ProbeOptions = {
27
+ signal?: AbortSignal;
28
+ timeoutMs?: number;
29
+ fetchImpl?: typeof fetch;
30
+ };
31
+ /** Runs the probe once. Returns null when it cannot be measured (request failed, cancelled, or no probabilities); that is "unknown", never a failure. */
32
+ export declare function probeEngine(llamaUrl: string, options?: ProbeOptions): Promise<EngineFingerprint | null>;
33
+ export declare function compareFingerprints(baseline: EngineFingerprint, current: EngineFingerprint, tolerance?: number): FingerprintDiff;
34
+ export {};
@@ -0,0 +1,108 @@
1
+ /**
2
+ * A local check of the engine's own answers. The server decides whether a host runs the model it
3
+ * claims (it compares token probabilities across hosts); nothing here replaces that, and a host
4
+ * who edits this program can skip it. This is a limited repeatability check, not proof of model
5
+ * identity or GPU health. Even temperature-0 inference can vary with batching, backend kernels,
6
+ * software, and numerical rounding. A measured change alone does not diagnose its cause.
7
+ */
8
+ // A short repeatable prompt; models are not guaranteed to answer it with confident tokens.
9
+ const PROBE_MESSAGES = [{ role: 'user', content: 'Count from one to twelve in English words, separated by commas.' }];
10
+ const PROBE_TOKENS = 16;
11
+ /**
12
+ * Heuristic log-probability tolerance, not a calibrated health threshold. This has not been
13
+ * validated across backends or GPUs and can produce both false positives and false negatives.
14
+ */
15
+ export const FINGERPRINT_TOLERANCE = 0.5;
16
+ /** Approximately ln(0.7), used only as a heuristic for distinguishing near-tie divergence. */
17
+ const CONFIDENT_LOGPROB = -0.357;
18
+ /** Runs the probe once. Returns null when it cannot be measured (request failed, cancelled, or no probabilities); that is "unknown", never a failure. */
19
+ export async function probeEngine(llamaUrl, options = {}) {
20
+ const fetchImpl = options.fetchImpl ?? fetch;
21
+ const timeout = AbortSignal.timeout(options.timeoutMs ?? 10_000);
22
+ const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
23
+ try {
24
+ const response = await fetchImpl(`${llamaUrl.replace(/\/+$/, '')}/v1/chat/completions`, {
25
+ method: 'POST',
26
+ headers: { 'content-type': 'application/json' },
27
+ body: JSON.stringify({
28
+ model: 'local',
29
+ messages: PROBE_MESSAGES,
30
+ stream: false,
31
+ temperature: 0,
32
+ seed: 1,
33
+ max_tokens: PROBE_TOKENS,
34
+ logprobs: true,
35
+ top_logprobs: 1,
36
+ // Do not keep this prompt in the slot's cache.
37
+ cache_prompt: false,
38
+ }),
39
+ signal,
40
+ });
41
+ if (!response.ok)
42
+ return null;
43
+ const json = await response.json();
44
+ if (!json || !Array.isArray(json.choices))
45
+ return null;
46
+ const entries = json.choices?.[0]?.logprobs?.content;
47
+ if (!Array.isArray(entries))
48
+ return null;
49
+ const tokens = [];
50
+ const logprobs = [];
51
+ if (entries.length > PROBE_TOKENS)
52
+ return null;
53
+ for (const entry of entries) {
54
+ if (!entry || typeof entry.token !== 'string' || !validLogprob(entry.logprob))
55
+ return null;
56
+ tokens.push(entry.token);
57
+ logprobs.push(entry.logprob);
58
+ }
59
+ return tokens.length ? { tokens, logprobs } : null;
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ }
65
+ function validLogprob(value) {
66
+ // OpenAI-compatible APIs use -9999 as an unavailable-logprob sentinel, not a measurement.
67
+ return typeof value === 'number' && Number.isFinite(value) && value <= 0 && value !== -9999;
68
+ }
69
+ function validFingerprint(value) {
70
+ if (!value || !Array.isArray(value.tokens) || !Array.isArray(value.logprobs)
71
+ || !value.tokens.length || value.tokens.length !== value.logprobs.length)
72
+ return false;
73
+ // Indexed access also rejects sparse arrays (Array.every would skip their holes).
74
+ for (let i = 0; i < value.tokens.length; i++) {
75
+ if (typeof value.tokens[i] !== 'string' || !validLogprob(value.logprobs[i]))
76
+ return false;
77
+ }
78
+ return true;
79
+ }
80
+ export function compareFingerprints(baseline, current, tolerance = FINGERPRINT_TOLERANCE) {
81
+ if (!Number.isFinite(tolerance) || tolerance < 0 || !validFingerprint(baseline) || !validFingerprint(current)) {
82
+ return { same: null, compared: 0, maxDelta: 0, reason: 'Missing or invalid fingerprint evidence or tolerance' };
83
+ }
84
+ const length = Math.min(baseline.tokens.length, current.tokens.length);
85
+ let maxDelta = 0;
86
+ for (let i = 0; i < length; i++) {
87
+ const was = baseline.logprobs[i];
88
+ const now = current.logprobs[i];
89
+ if (baseline.tokens[i] !== current.tokens[i]) {
90
+ if (was > CONFIDENT_LOGPROB || now > CONFIDENT_LOGPROB) {
91
+ return { same: false, compared: i + 1, maxDelta, reason: `token ${i + 1} changed from ${JSON.stringify(baseline.tokens[i])} to ${JSON.stringify(current.tokens[i])}` };
92
+ }
93
+ // A near-tie can fall either way on rounding alone; the texts differ from here, so stop comparing.
94
+ return { same: null, compared: i, maxDelta, reason: `Answers diverged at a low-confidence token ${i + 1}; remaining positions cannot be compared` };
95
+ }
96
+ const delta = Math.abs(was - now);
97
+ if (delta > maxDelta)
98
+ maxDelta = delta;
99
+ if (delta > tolerance) {
100
+ return { same: false, compared: i + 1, maxDelta, reason: `the log probability of token ${i + 1} (${JSON.stringify(baseline.tokens[i])}) moved by ${delta.toFixed(2)}` };
101
+ }
102
+ }
103
+ if (baseline.tokens.length !== current.tokens.length) {
104
+ return { same: null, compared: length, maxDelta, reason: 'Answers have different lengths; only their shared prefix was compared' };
105
+ }
106
+ return { same: true, compared: length, maxDelta, reason: null };
107
+ }
108
+ //# sourceMappingURL=selfCheck.js.map
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Process exit codes. These are part of the public contract: service managers,
3
+ * scripts, and fleet tooling may branch on them, so a code never changes meaning.
4
+ */
5
+ export declare const EXIT: {
6
+ /** Success, or a booking that ended normally. */
7
+ readonly OK: 0;
8
+ /** A runtime failure: server unreachable, host key rejected, a failed check, a crash loop. */
9
+ readonly ERROR: 1;
10
+ /** Bad command line or bad settings (unknown option, invalid agent.json, invalid proxy). */
11
+ readonly USAGE: 2;
12
+ /** Stopped with Ctrl+C (SIGINT). Saved booking state is cleared. */
13
+ readonly INTERRUPTED: 130;
14
+ /** Stopped by the system or a service manager (SIGTERM). Saved booking state is kept for the next start. */
15
+ readonly TERMINATED: 143;
16
+ };
17
+ export type ExitCode = (typeof EXIT)[keyof typeof EXIT];
18
+ /** Configuration failures should not be retried forever by service managers. */
19
+ export declare class UsageError extends Error {
20
+ }