@hraness/sponge 0.3.4 → 0.4.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/README.md +16 -7
- package/dist/migrations/011.d.ts +3 -0
- package/dist/migrations/011.js +9 -0
- package/dist/migrations/index.js +2 -0
- package/dist/src/annotations/quote.d.ts +1 -0
- package/dist/src/annotations/quote.js +11 -1
- package/dist/src/cli/commands.js +51 -6
- package/dist/src/cli/runtimes.js +6 -2
- package/dist/src/cli/worker.js +5 -1
- package/dist/src/digest/endpoint.d.ts +3 -2
- package/dist/src/digest/endpoint.js +3 -2
- package/dist/src/digest/pipeline.js +6 -3
- package/dist/src/digest/runner.d.ts +33 -0
- package/dist/src/digest/runner.js +82 -0
- package/dist/src/digest/schema.js +6 -2
- package/dist/src/digest/server.d.ts +40 -0
- package/dist/src/digest/server.js +166 -0
- package/dist/src/errors.d.ts +1 -0
- package/dist/src/errors.js +2 -0
- package/dist/src/importers/custody.d.ts +2 -5
- package/dist/src/importers/custody.js +3 -51
- package/dist/src/models/artifacts.d.ts +5 -0
- package/dist/src/models/artifacts.js +92 -17
- package/dist/src/models/digest.d.ts +64 -0
- package/dist/src/models/digest.js +122 -0
- package/dist/src/portability/database.js +2 -0
- package/dist/src/search/query.js +7 -54
- package/dist/src/store/digest-jobs.d.ts +55 -0
- package/dist/src/store/digest-jobs.js +164 -0
- package/dist/src/store/library.d.ts +3 -0
- package/dist/src/store/library.js +5 -1
- package/dist/src/store/queue.d.ts +9 -5
- package/dist/src/store/queue.js +14 -9
- package/dist/src/store/revisions.js +4 -0
- package/dist/src/worker/custody.d.ts +5 -0
- package/dist/src/worker/custody.js +7 -4
- package/dist/src/worker/transient.d.ts +17 -0
- package/dist/src/worker/transient.js +60 -0
- package/native/runtime-manifest.json +4 -4
- package/package.json +1 -1
package/dist/src/errors.js
CHANGED
|
@@ -32,6 +32,7 @@ const parents = {
|
|
|
32
32
|
'no-capture': 'invalid-input',
|
|
33
33
|
'draft-rejected': 'unavailable',
|
|
34
34
|
'model-unavailable': 'unavailable',
|
|
35
|
+
'model-busy': 'unavailable',
|
|
35
36
|
'parent-missing': 'invalid-input',
|
|
36
37
|
'target-exists': 'invalid-input',
|
|
37
38
|
};
|
|
@@ -83,6 +84,7 @@ export const hints = {
|
|
|
83
84
|
export const detailHints = {
|
|
84
85
|
'draft-rejected': 'The model draft failed the digest checks listed above, even after one repair. Drafting is deterministic, so the same capture and model fail again; refresh the item or use another model.',
|
|
85
86
|
'model-unavailable': 'The model server did not answer or returned an error. Check that it is still running and has free GPU memory (another loaded model can exhaust it), then retry.',
|
|
87
|
+
'model-busy': 'Another llama-server is running, and two loaded models can exhaust GPU memory. Wait for it to exit, then retry.',
|
|
86
88
|
};
|
|
87
89
|
/** The one error type. `message` is the code, so bare `String(error)` never carries detail, paths or causes. */
|
|
88
90
|
export class SpongeError extends Error {
|
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { type TransientAttempt } from '../worker/transient.js';
|
|
2
2
|
/** Read-only verification still owns subprocesses, including during an import preview. */
|
|
3
|
-
export declare function withImportVerification<T>(libraryRoot: string, verify: (context:
|
|
4
|
-
scratchRoot: string;
|
|
5
|
-
runProcess: ReturnType<typeof supervisedRunner>;
|
|
6
|
-
}) => Promise<T>): Promise<T>;
|
|
3
|
+
export declare function withImportVerification<T>(libraryRoot: string, verify: (context: Pick<TransientAttempt, 'scratchRoot' | 'runProcess'>) => Promise<T>): Promise<T>;
|
|
@@ -1,53 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import { rm } from 'node:fs/promises';
|
|
4
|
-
import { acquireWorkerCustody, proveAttemptCleanup } from '../worker/custody.js';
|
|
5
|
-
import { directory, writeJournal, readJournal } from '../worker/journal.js';
|
|
6
|
-
import { syncDirectoryAsync } from '../util/fsync.js';
|
|
7
|
-
import { supervisedRunner } from '../worker/supervisor.js';
|
|
8
|
-
import { SpongeError, codeOf } from '../errors.js';
|
|
9
|
-
const pending = () => new SpongeError('cleanup-required');
|
|
1
|
+
import { withTransientAttempt } from '../worker/transient.js';
|
|
10
2
|
/** Read-only verification still owns subprocesses, including during an import preview. */
|
|
11
|
-
export
|
|
12
|
-
|
|
13
|
-
let scratchRoot, unknown = false;
|
|
14
|
-
try {
|
|
15
|
-
const attempt = await directory(join(custody.directory, 'attempts', attemptId));
|
|
16
|
-
await writeJournal(join(attempt, 'import-verification.json'), {
|
|
17
|
-
schemaVersion: 1,
|
|
18
|
-
kind: 'import-verification',
|
|
19
|
-
attemptId,
|
|
20
|
-
ownerIncarnation: custody.incarnation,
|
|
21
|
-
});
|
|
22
|
-
scratchRoot = await directory(join(libraryRoot, 'staging', 'import-verification-' + attemptId));
|
|
23
|
-
await writeJournal(join(scratchRoot, 'ownership.json'), {
|
|
24
|
-
schemaVersion: 1,
|
|
25
|
-
kind: 'import-verification',
|
|
26
|
-
requestId: attemptId,
|
|
27
|
-
ownerIncarnation: custody.incarnation,
|
|
28
|
-
});
|
|
29
|
-
return await verify({
|
|
30
|
-
scratchRoot,
|
|
31
|
-
runProcess: supervisedRunner(custody.directory, custody.incarnation, attemptId),
|
|
32
|
-
});
|
|
33
|
-
}
|
|
34
|
-
catch (error) {
|
|
35
|
-
unknown = codeOf(error) === 'cleanup-required';
|
|
36
|
-
throw error;
|
|
37
|
-
}
|
|
38
|
-
finally {
|
|
39
|
-
if (unknown || !(await proveAttemptCleanup(custody.directory, custody.incarnation, attemptId)))
|
|
40
|
-
throw pending();
|
|
41
|
-
if (scratchRoot) {
|
|
42
|
-
const marker = await readJournal(join(scratchRoot, 'ownership.json'));
|
|
43
|
-
if (marker.schemaVersion !== 1 ||
|
|
44
|
-
marker.kind !== 'import-verification' ||
|
|
45
|
-
marker.requestId !== attemptId ||
|
|
46
|
-
marker.ownerIncarnation !== custody.incarnation)
|
|
47
|
-
throw pending();
|
|
48
|
-
await rm(scratchRoot, { recursive: true });
|
|
49
|
-
await syncDirectoryAsync(join(libraryRoot, 'staging'));
|
|
50
|
-
}
|
|
51
|
-
await custody.release();
|
|
52
|
-
}
|
|
3
|
+
export function withImportVerification(libraryRoot, verify) {
|
|
4
|
+
return withTransientAttempt(libraryRoot, 'import-verification', ({ scratchRoot, runProcess }) => verify({ scratchRoot, runProcess }));
|
|
53
5
|
}
|
|
@@ -9,6 +9,8 @@ export interface ModelPackage {
|
|
|
9
9
|
id: string;
|
|
10
10
|
license: string;
|
|
11
11
|
artifacts: readonly ModelArtifact[];
|
|
12
|
+
/** Per-artifact and per-package byte limit; 2 GiB unless a package that needs more (a local LLM) says so. */
|
|
13
|
+
maximumBytes?: number;
|
|
12
14
|
}
|
|
13
15
|
export declare function modelSetupPlan(manifest: ModelPackage): {
|
|
14
16
|
id: string;
|
|
@@ -28,6 +30,9 @@ export declare function provisionModelPackage(cacheRoot: string, manifest: Model
|
|
|
28
30
|
signal?: AbortSignal;
|
|
29
31
|
fetch?: typeof fetch;
|
|
30
32
|
reuseOnly?: boolean;
|
|
33
|
+
/** Local files to adopt by artifact name instead of downloading; cloned or copied, verified, never modified. */
|
|
34
|
+
adopt?: Readonly<Record<string, string>>;
|
|
35
|
+
idleMs?: number;
|
|
31
36
|
}): Promise<Record<string, string>>;
|
|
32
37
|
/** The installed package for this manifest, by content, or null when none is. A package installed under an earlier
|
|
33
38
|
* URL-bound fingerprint is adopted once: its verified artifacts are hard-linked into the content-named directory under
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { constants } from 'node:fs';
|
|
2
|
-
import { chmod, lstat, mkdir, open, rename, rm, statfs, link, unlink, readdir, realpath } from 'node:fs/promises';
|
|
2
|
+
import { chmod, copyFile, lstat, mkdir, open, rename, rm, statfs, link, unlink, readdir, realpath, } from 'node:fs/promises';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
import { createHash, randomUUID } from 'node:crypto';
|
|
5
5
|
import { processIdentity, identityAlive } from '../worker/identity.js';
|
|
@@ -8,9 +8,15 @@ import { acquireIdentityLock } from '../worker/lock.js';
|
|
|
8
8
|
import { syncDirectoryAsync } from '../util/fsync.js';
|
|
9
9
|
import { privateRoot } from '../acquisition/files.js';
|
|
10
10
|
import { SpongeError, codeOf } from '../errors.js';
|
|
11
|
-
const
|
|
11
|
+
const defaultMaximumBytes = 2 * 1024 ** 3, packageCeiling = 64 * 1024 ** 3;
|
|
12
|
+
/** A download fails after this long without a byte, and after its size at 1 MB/s (at least 10 minutes) in all. */
|
|
13
|
+
const idleMs = 120000, downloadDeadline = (bytes) => Math.max(600000, bytes / 1000);
|
|
12
14
|
function validate(manifest) {
|
|
13
|
-
|
|
15
|
+
const maximumBytes = manifest.maximumBytes ?? defaultMaximumBytes;
|
|
16
|
+
if (!Number.isSafeInteger(maximumBytes) ||
|
|
17
|
+
maximumBytes < 1 ||
|
|
18
|
+
maximumBytes > packageCeiling ||
|
|
19
|
+
!/^[a-z0-9][a-z0-9.-]{0,127}$/.test(manifest.id) ||
|
|
14
20
|
!manifest.license ||
|
|
15
21
|
manifest.license.length > 256 ||
|
|
16
22
|
!manifest.artifacts.length ||
|
|
@@ -125,10 +131,14 @@ async function linksAccounted(root, stat) {
|
|
|
125
131
|
}
|
|
126
132
|
return count === stat.nlink;
|
|
127
133
|
}
|
|
134
|
+
/** Files this process already hashed, by inode and change time (which user space cannot set back): a worker
|
|
135
|
+
* re-verifies a 17 GB model by stat after the first time. Any change to the file, its links or its mode misses. */
|
|
136
|
+
const verified = new Map();
|
|
137
|
+
const statKey = (s) => [s.dev, s.ino, s.size, s.mtimeNs, s.ctimeNs, s.nlink, s.mode].join(':');
|
|
128
138
|
async function verifyArtifact(path, artifact, root) {
|
|
129
139
|
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
130
140
|
try {
|
|
131
|
-
const before = await file.stat();
|
|
141
|
+
const before = await file.stat(), key = statKey(await file.stat({ bigint: true }));
|
|
132
142
|
if (!before.isFile() ||
|
|
133
143
|
before.nlink < 1 ||
|
|
134
144
|
(before.nlink !== 1 && !(root && (await linksAccounted(root, before)))) ||
|
|
@@ -136,6 +146,9 @@ async function verifyArtifact(path, artifact, root) {
|
|
|
136
146
|
(before.mode & 0o077) !== 0 ||
|
|
137
147
|
(artifact.executable && (before.mode & 0o100) === 0))
|
|
138
148
|
throw new SpongeError('integrity');
|
|
149
|
+
if (verified.get(path) === `${key}:${artifact.sha256}`)
|
|
150
|
+
return;
|
|
151
|
+
verified.delete(path);
|
|
139
152
|
const hash = createHash('sha256'), buffer = Buffer.alloc(65536);
|
|
140
153
|
let bytes = 0;
|
|
141
154
|
for (;;) {
|
|
@@ -153,13 +166,14 @@ async function verifyArtifact(path, artifact, root) {
|
|
|
153
166
|
before.mtimeMs !== after.mtimeMs ||
|
|
154
167
|
before.nlink !== after.nlink)
|
|
155
168
|
throw new SpongeError('integrity');
|
|
169
|
+
verified.set(path, `${key}:${artifact.sha256}`);
|
|
156
170
|
}
|
|
157
171
|
finally {
|
|
158
172
|
await file.close();
|
|
159
173
|
}
|
|
160
174
|
}
|
|
161
175
|
/** Write one stream into a fresh staged file while hashing; the caller verifies against the manifest. */
|
|
162
|
-
async function writeStaged(path, artifact, chunks, signal) {
|
|
176
|
+
async function writeStaged(path, artifact, chunks, signal, onChunk) {
|
|
163
177
|
const file = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
164
178
|
const hash = createHash('sha256');
|
|
165
179
|
let bytes = 0;
|
|
@@ -167,6 +181,7 @@ async function writeStaged(path, artifact, chunks, signal) {
|
|
|
167
181
|
for await (const chunk of chunks) {
|
|
168
182
|
if (signal?.aborted)
|
|
169
183
|
throw new SpongeError('cancelled');
|
|
184
|
+
onChunk?.();
|
|
170
185
|
bytes += chunk.length;
|
|
171
186
|
if (bytes > artifact.bytes)
|
|
172
187
|
throw new SpongeError('integrity');
|
|
@@ -410,6 +425,44 @@ export async function recoverModelPackage(cacheRoot, manifest) {
|
|
|
410
425
|
}
|
|
411
426
|
}
|
|
412
427
|
}
|
|
428
|
+
/** Adopt a local file the user named (a model they already downloaded): an APFS clone when the volume allows, else a
|
|
429
|
+
* copy, then verified against the manifest. The source is only read; a mismatch fails without downloading. */
|
|
430
|
+
async function adoptArtifact(root, stage, artifact, source, signal) {
|
|
431
|
+
if (signal?.aborted)
|
|
432
|
+
throw new SpongeError('cancelled');
|
|
433
|
+
const target = join(stage, artifact.name);
|
|
434
|
+
let real;
|
|
435
|
+
try {
|
|
436
|
+
real = await realpath(source);
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
throw new SpongeError('invalid-input');
|
|
440
|
+
}
|
|
441
|
+
const s = await lstat(real);
|
|
442
|
+
if (!s.isFile() || real.startsWith(root + '/'))
|
|
443
|
+
throw new SpongeError('invalid-input');
|
|
444
|
+
if (s.size !== artifact.bytes)
|
|
445
|
+
throw new SpongeError('integrity');
|
|
446
|
+
try {
|
|
447
|
+
try {
|
|
448
|
+
await copyFile(real, target, constants.COPYFILE_EXCL | constants.COPYFILE_FICLONE_FORCE);
|
|
449
|
+
}
|
|
450
|
+
catch (error) {
|
|
451
|
+
if (!['ENOTSUP', 'EXDEV', 'ENOSYS', 'EINVAL'].includes(error.code ?? ''))
|
|
452
|
+
throw error;
|
|
453
|
+
const disk = await statfs(root);
|
|
454
|
+
if (disk.bavail * disk.bsize < artifact.bytes + 512 * 1024 ** 2)
|
|
455
|
+
throw new SpongeError('limit-exceeded');
|
|
456
|
+
await copyFile(real, target, constants.COPYFILE_EXCL);
|
|
457
|
+
}
|
|
458
|
+
await chmod(target, artifact.executable ? 0o700 : 0o600);
|
|
459
|
+
await verifyArtifact(target, artifact, root);
|
|
460
|
+
}
|
|
461
|
+
catch (error) {
|
|
462
|
+
await rm(target, { force: true });
|
|
463
|
+
throw error;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
413
466
|
/** Explicit setup only. Never called by install hooks or by inference fallback, except `reuseOnly`: that adopts an
|
|
414
467
|
* installed package from verified local artifacts under the same setup lock and never reaches the network. */
|
|
415
468
|
export async function provisionModelPackage(cacheRoot, manifest, options = {}) {
|
|
@@ -461,7 +514,7 @@ export async function provisionModelPackage(cacheRoot, manifest, options = {}) {
|
|
|
461
514
|
// Reusable artifacts need no download space; a copy fallback rechecks space for itself.
|
|
462
515
|
let needed = 512 * 1024 ** 2;
|
|
463
516
|
for (const artifact of manifest.artifacts)
|
|
464
|
-
if (!(await reuseCandidates(root, artifact, plan.fingerprint)).length)
|
|
517
|
+
if (!options.adopt?.[artifact.name] && !(await reuseCandidates(root, artifact, plan.fingerprint)).length)
|
|
465
518
|
needed += artifact.bytes;
|
|
466
519
|
const disk = await statfs(root);
|
|
467
520
|
if (disk.bavail * disk.bsize < needed)
|
|
@@ -475,20 +528,42 @@ export async function provisionModelPackage(cacheRoot, manifest, options = {}) {
|
|
|
475
528
|
for (const artifact of manifest.artifacts) {
|
|
476
529
|
if (await reuseArtifact(root, stage, artifact, plan.fingerprint, options.signal))
|
|
477
530
|
continue;
|
|
531
|
+
const local = options.adopt?.[artifact.name];
|
|
532
|
+
if (local) {
|
|
533
|
+
await adoptArtifact(root, stage, artifact, local, options.signal);
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
478
536
|
if (options.reuseOnly)
|
|
479
537
|
throw new SpongeError('unavailable');
|
|
480
|
-
const
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
await
|
|
489
|
-
|
|
538
|
+
const idle = new AbortController(), wait = options.idleMs ?? idleMs;
|
|
539
|
+
let timer = setTimeout(() => idle.abort(), wait);
|
|
540
|
+
const signal = AbortSignal.any([
|
|
541
|
+
idle.signal,
|
|
542
|
+
AbortSignal.timeout(downloadDeadline(artifact.bytes)),
|
|
543
|
+
...(options.signal ? [options.signal] : []),
|
|
544
|
+
]);
|
|
545
|
+
try {
|
|
546
|
+
const response = await (options.fetch ?? fetch)(artifact.url, { signal });
|
|
547
|
+
if (!response.ok || !response.body || (response.url && new URL(response.url).protocol !== 'https:'))
|
|
548
|
+
throw new SpongeError('unavailable');
|
|
549
|
+
const declared = response.headers.get('content-length');
|
|
550
|
+
if (declared !== null && Number(declared) !== artifact.bytes) {
|
|
551
|
+
await response.body.cancel();
|
|
552
|
+
throw new SpongeError('integrity');
|
|
553
|
+
}
|
|
554
|
+
await writeStaged(join(stage, artifact.name), artifact, response.body, signal, () => {
|
|
555
|
+
clearTimeout(timer);
|
|
556
|
+
timer = setTimeout(() => idle.abort(), wait);
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
catch (error) {
|
|
560
|
+
if (idle.signal.aborted && !options.signal?.aborted)
|
|
561
|
+
throw new SpongeError('limit-exceeded', { cause: error });
|
|
562
|
+
throw error;
|
|
563
|
+
}
|
|
564
|
+
finally {
|
|
565
|
+
clearTimeout(timer);
|
|
490
566
|
}
|
|
491
|
-
await writeStaged(join(stage, artifact.name), artifact, response.body, signal);
|
|
492
567
|
}
|
|
493
568
|
if (options.signal?.aborted)
|
|
494
569
|
throw new SpongeError('cancelled');
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { type ModelPackage } from './artifacts.js';
|
|
2
|
+
/**
|
|
3
|
+
* The local digest runtime: upstream llama.cpp's macOS arm64 release build and one pinned GGUF, installed together as
|
|
4
|
+
* one model package (one fingerprint per model, so the shared archive hard-links across them). Pins live in source,
|
|
5
|
+
* not the per-release native catalog: they are third-party bytes that do not change with Sponge releases.
|
|
6
|
+
*/
|
|
7
|
+
export interface DigestModelPin {
|
|
8
|
+
readonly alias: string;
|
|
9
|
+
readonly file: string;
|
|
10
|
+
readonly bytes: number;
|
|
11
|
+
readonly sha256: string;
|
|
12
|
+
readonly repo: string;
|
|
13
|
+
readonly revision: string;
|
|
14
|
+
readonly base: string;
|
|
15
|
+
/** The smallest physical memory this model is chosen for by default. */
|
|
16
|
+
readonly minimumMemoryBytes: number;
|
|
17
|
+
}
|
|
18
|
+
/** llama.cpp b11669 (0.7.0, commit 620d2225c): self-contained, `@loader_path` dylibs, ad hoc signed. */
|
|
19
|
+
export declare const llamaRuntimePin: {
|
|
20
|
+
readonly name: "llama-b11669-bin-macos-arm64.tar.gz";
|
|
21
|
+
readonly url: "https://github.com/ggml-org/llama.cpp/releases/download/b11669/llama-b11669-bin-macos-arm64.tar.gz";
|
|
22
|
+
readonly bytes: 12191582;
|
|
23
|
+
readonly sha256: "dbed55493d72bc1ba2babc8770d669c2289b5bc4ee9fb2a4dfc158d45b7d13fd";
|
|
24
|
+
readonly directory: "llama-b11669";
|
|
25
|
+
readonly build: 11669;
|
|
26
|
+
readonly commit: "620d2225c";
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Gemma 4 26B-A4B (Apache 2.0) at Unsloth's dynamic Q4_K_M is the default: on the 40-item Jungle eval it matched the
|
|
30
|
+
* 9B's idea coverage with no failures, a tenth of its copied details and about 60% of its time per item (see
|
|
31
|
+
* src/digest/README.md). Qwen3.5-9B is the pin for machines with 16-32 GiB.
|
|
32
|
+
*/
|
|
33
|
+
export declare const digestModels: readonly DigestModelPin[];
|
|
34
|
+
export declare const modelUrl: (m: Pick<DigestModelPin, "repo" | "revision" | "file">) => string;
|
|
35
|
+
/** An explicit alias, or the largest pin this machine's memory allows; `unsupported` below every pin. */
|
|
36
|
+
export declare function digestModel(alias?: string, memory?: number): DigestModelPin;
|
|
37
|
+
export declare function digestModelPackage(pin: DigestModelPin): ModelPackage;
|
|
38
|
+
/** Every pin's fingerprint, so runtime GC keeps any installed digest package. */
|
|
39
|
+
export declare const digestFingerprints: () => string[];
|
|
40
|
+
export interface DigestRuntime {
|
|
41
|
+
/** The verified llama.cpp release archive; extracted per run into owned scratch space. */
|
|
42
|
+
archive: string;
|
|
43
|
+
model: string;
|
|
44
|
+
pin: DigestModelPin;
|
|
45
|
+
}
|
|
46
|
+
export declare function digestSetupPlan(pin: DigestModelPin): {
|
|
47
|
+
id: string;
|
|
48
|
+
license: string;
|
|
49
|
+
fingerprint: string;
|
|
50
|
+
downloadBytes: number;
|
|
51
|
+
requiredFreeBytes: number;
|
|
52
|
+
artifacts: readonly import("./artifacts.js").ModelArtifact[];
|
|
53
|
+
model: string;
|
|
54
|
+
memoryBytes: number;
|
|
55
|
+
supported: boolean;
|
|
56
|
+
};
|
|
57
|
+
/** Explicit setup: download (or adopt `modelFile`, a copy of the pinned GGUF the user already has) and verify. */
|
|
58
|
+
export declare function provisionDigest(cacheRoot: string, pin: DigestModelPin, options?: {
|
|
59
|
+
signal?: AbortSignal;
|
|
60
|
+
modelFile?: string;
|
|
61
|
+
fetch?: typeof fetch;
|
|
62
|
+
}): Promise<DigestRuntime>;
|
|
63
|
+
/** The installed runtime for `alias`, or for this machine's default pin, else the first installed pin. */
|
|
64
|
+
export declare function inspectDigest(cacheRoot: string, alias?: string): Promise<DigestRuntime>;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { totalmem } from 'node:os';
|
|
2
|
+
import { mkdir } from 'node:fs/promises';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import { privateRoot } from '../acquisition/files.js';
|
|
5
|
+
import { inspectInstalledModelPackage, modelSetupPlan, provisionModelPackage, recoverModelPackage, } from './artifacts.js';
|
|
6
|
+
import { nativePlatformSupported } from './catalog.js';
|
|
7
|
+
import { SpongeError } from '../errors.js';
|
|
8
|
+
const GiB = 1024 ** 3;
|
|
9
|
+
/** llama.cpp b11669 (0.7.0, commit 620d2225c): self-contained, `@loader_path` dylibs, ad hoc signed. */
|
|
10
|
+
export const llamaRuntimePin = {
|
|
11
|
+
name: 'llama-b11669-bin-macos-arm64.tar.gz',
|
|
12
|
+
url: 'https://github.com/ggml-org/llama.cpp/releases/download/b11669/llama-b11669-bin-macos-arm64.tar.gz',
|
|
13
|
+
bytes: 12_191_582,
|
|
14
|
+
sha256: 'dbed55493d72bc1ba2babc8770d669c2289b5bc4ee9fb2a4dfc158d45b7d13fd',
|
|
15
|
+
directory: 'llama-b11669',
|
|
16
|
+
build: 11669,
|
|
17
|
+
commit: '620d2225c',
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Gemma 4 26B-A4B (Apache 2.0) at Unsloth's dynamic Q4_K_M is the default: on the 40-item Jungle eval it matched the
|
|
21
|
+
* 9B's idea coverage with no failures, a tenth of its copied details and about 60% of its time per item (see
|
|
22
|
+
* src/digest/README.md). Qwen3.5-9B is the pin for machines with 16-32 GiB.
|
|
23
|
+
*/
|
|
24
|
+
export const digestModels = [
|
|
25
|
+
{
|
|
26
|
+
alias: 'gemma-4-26b-a4b',
|
|
27
|
+
file: 'gemma-4-26B-A4B-it-UD-Q4_K_M.gguf',
|
|
28
|
+
bytes: 16_947_541_728,
|
|
29
|
+
sha256: 'f2c28b3dc4776931ac6f879e11f203dec637ea0f14267a86ec8f6165f63f293f',
|
|
30
|
+
repo: 'unsloth/gemma-4-26B-A4B-it-GGUF',
|
|
31
|
+
revision: 'c099eb48e663fd284577b04978a94ffccb261841',
|
|
32
|
+
base: 'google/gemma-4-26B-A4B-it',
|
|
33
|
+
minimumMemoryBytes: 32 * GiB,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
alias: 'qwen3.5-9b',
|
|
37
|
+
file: 'Qwen3.5-9B-Q4_K_M.gguf',
|
|
38
|
+
bytes: 5_627_044_256,
|
|
39
|
+
sha256: 'cd76ec205963b3b33350093e6904d9de16c4e666fd104e1f632d25c7f15f2a13',
|
|
40
|
+
repo: 'lmstudio-community/Qwen3.5-9B-GGUF',
|
|
41
|
+
revision: '1379f25c6b505a3fc737bd7818cb09389cf807c1',
|
|
42
|
+
base: 'Qwen/Qwen3.5-9B',
|
|
43
|
+
minimumMemoryBytes: 16 * GiB,
|
|
44
|
+
},
|
|
45
|
+
];
|
|
46
|
+
export const modelUrl = (m) => `https://huggingface.co/${m.repo}/resolve/${m.revision}/${m.file}`;
|
|
47
|
+
/** An explicit alias, or the largest pin this machine's memory allows; `unsupported` below every pin. */
|
|
48
|
+
export function digestModel(alias, memory = totalmem()) {
|
|
49
|
+
if (alias !== undefined) {
|
|
50
|
+
const pin = digestModels.find((m) => m.alias === alias);
|
|
51
|
+
if (!pin)
|
|
52
|
+
throw new SpongeError('invalid-input');
|
|
53
|
+
return pin;
|
|
54
|
+
}
|
|
55
|
+
const pin = digestModels.find((m) => memory >= m.minimumMemoryBytes);
|
|
56
|
+
if (!pin)
|
|
57
|
+
throw new SpongeError('unsupported');
|
|
58
|
+
return pin;
|
|
59
|
+
}
|
|
60
|
+
export function digestModelPackage(pin) {
|
|
61
|
+
return {
|
|
62
|
+
id: `sponge-digest-b${llamaRuntimePin.build}-${pin.alias}`,
|
|
63
|
+
license: 'llama.cpp: MIT; model: see its repository license (Gemma 4 and Qwen3.5 are Apache-2.0)',
|
|
64
|
+
maximumBytes: 20 * GiB,
|
|
65
|
+
artifacts: [
|
|
66
|
+
{
|
|
67
|
+
name: llamaRuntimePin.name,
|
|
68
|
+
url: llamaRuntimePin.url,
|
|
69
|
+
sha256: llamaRuntimePin.sha256,
|
|
70
|
+
bytes: llamaRuntimePin.bytes,
|
|
71
|
+
},
|
|
72
|
+
{ name: pin.file, url: modelUrl(pin), sha256: pin.sha256, bytes: pin.bytes },
|
|
73
|
+
],
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/** Every pin's fingerprint, so runtime GC keeps any installed digest package. */
|
|
77
|
+
export const digestFingerprints = () => digestModels.map((pin) => modelSetupPlan(digestModelPackage(pin)).fingerprint);
|
|
78
|
+
export function digestSetupPlan(pin) {
|
|
79
|
+
return {
|
|
80
|
+
model: pin.alias,
|
|
81
|
+
memoryBytes: totalmem(),
|
|
82
|
+
supported: nativePlatformSupported(),
|
|
83
|
+
...modelSetupPlan(digestModelPackage(pin)),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/** Explicit setup: download (or adopt `modelFile`, a copy of the pinned GGUF the user already has) and verify. */
|
|
87
|
+
export async function provisionDigest(cacheRoot, pin, options = {}) {
|
|
88
|
+
if (!nativePlatformSupported())
|
|
89
|
+
throw new SpongeError('unsupported');
|
|
90
|
+
const manifest = digestModelPackage(pin);
|
|
91
|
+
await privateRoot(dirname(cacheRoot));
|
|
92
|
+
await mkdir(cacheRoot, { recursive: true, mode: 0o700 });
|
|
93
|
+
await privateRoot(cacheRoot);
|
|
94
|
+
await recoverModelPackage(cacheRoot, manifest);
|
|
95
|
+
const paths = await provisionModelPackage(cacheRoot, manifest, {
|
|
96
|
+
signal: options.signal,
|
|
97
|
+
fetch: options.fetch,
|
|
98
|
+
...(options.modelFile ? { adopt: { [pin.file]: options.modelFile } } : {}),
|
|
99
|
+
});
|
|
100
|
+
return { archive: paths[llamaRuntimePin.name], model: paths[pin.file], pin };
|
|
101
|
+
}
|
|
102
|
+
/** The installed runtime for `alias`, or for this machine's default pin, else the first installed pin. */
|
|
103
|
+
export async function inspectDigest(cacheRoot, alias) {
|
|
104
|
+
if (!nativePlatformSupported())
|
|
105
|
+
throw new SpongeError('unsupported');
|
|
106
|
+
let preferred;
|
|
107
|
+
try {
|
|
108
|
+
preferred = digestModel(alias);
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
if (alias !== undefined)
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
for (const pin of preferred
|
|
115
|
+
? [preferred, ...(alias ? [] : digestModels.filter((m) => m !== preferred))]
|
|
116
|
+
: digestModels) {
|
|
117
|
+
const paths = await inspectInstalledModelPackage(cacheRoot, digestModelPackage(pin));
|
|
118
|
+
if (paths)
|
|
119
|
+
return { archive: paths[llamaRuntimePin.name], model: paths[pin.file], pin };
|
|
120
|
+
}
|
|
121
|
+
throw new SpongeError('runtime-missing');
|
|
122
|
+
}
|
|
@@ -96,6 +96,8 @@ export function neutralizeRestoredDatabase(path) {
|
|
|
96
96
|
db.exec("UPDATE index_jobs SET state='failed',reason='restore-reconciliation-required' WHERE state NOT IN ('succeeded','failed','cancelled')");
|
|
97
97
|
db.prepare("UPDATE index_attempts SET stage='restored-unexecuted',cleanup='settled',finished_at=COALESCE(finished_at,?) WHERE cleanup='pending' OR finished_at IS NULL").run(new Date().toISOString());
|
|
98
98
|
}
|
|
99
|
+
if (inspected.schemaVersion >= 11)
|
|
100
|
+
db.exec("UPDATE digest_jobs SET state='queued',owner_incarnation=NULL,fence=NULL WHERE state='running'; UPDATE digest_jobs SET state='cancelled' WHERE state='queued' AND cancel_requested=1");
|
|
99
101
|
// A restored copy is a new device of the same library: a fresh device id keeps its HLCs distinct; clock_ms only moves forward.
|
|
100
102
|
if (inspected.schemaVersion >= 5)
|
|
101
103
|
db.exec('UPDATE device SET device_id=lower(hex(randomblob(16))) WHERE id=1');
|
package/dist/src/search/query.js
CHANGED
|
@@ -1,66 +1,19 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import { rm } from 'node:fs/promises';
|
|
4
|
-
import { acquireWorkerCustody, proveAttemptCleanup } from '../worker/custody.js';
|
|
5
|
-
import { directory, writeJournal, readJournal } from '../worker/journal.js';
|
|
6
|
-
import { syncDirectoryAsync } from '../util/fsync.js';
|
|
7
|
-
import { supervisedRunner } from '../worker/supervisor.js';
|
|
1
|
+
import { withTransientAttempt } from '../worker/transient.js';
|
|
8
2
|
import { embedBatch, embeddingFingerprint, embeddingTextBytes, resolveIndexSpace, projectVector, encodeEmbeddingRequest, } from '../inference/embedding/index.js';
|
|
9
|
-
import { SpongeError
|
|
3
|
+
import { SpongeError } from '../errors.js';
|
|
10
4
|
/** One custody-held helper run. Mutates coordination journals only, never library content or job tables. */
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
try {
|
|
15
|
-
const attempts = await directory(join(custody.directory, 'attempts', attemptId));
|
|
16
|
-
await writeJournal(join(attempts, 'query.json'), {
|
|
17
|
-
schemaVersion: 1,
|
|
18
|
-
kind: 'query',
|
|
19
|
-
attemptId,
|
|
20
|
-
ownerIncarnation: custody.incarnation,
|
|
21
|
-
});
|
|
22
|
-
scratchRoot = await directory(join(store.root, 'staging', 'query-' + attemptId));
|
|
23
|
-
await writeJournal(join(scratchRoot, 'ownership.json'), {
|
|
24
|
-
schemaVersion: 1,
|
|
25
|
-
kind: 'query',
|
|
26
|
-
requestId: attemptId,
|
|
27
|
-
ownerIncarnation: custody.incarnation,
|
|
28
|
-
});
|
|
29
|
-
await options.onReady?.({ attemptId, ownerDirectory: custody.directory, scratchRoot });
|
|
5
|
+
function custodyEmbed(store, runtime, inputs, signal, options) {
|
|
6
|
+
return withTransientAttempt(store.root, 'query', async ({ attemptId, ownerDirectory, scratchRoot, runProcess }) => {
|
|
7
|
+
await options.onReady?.({ attemptId, ownerDirectory, scratchRoot });
|
|
30
8
|
const expected = encodeEmbeddingRequest(inputs).inputHashes;
|
|
31
|
-
const result = await (options.embed ?? embedBatch)(inputs, {
|
|
32
|
-
runtime,
|
|
33
|
-
scratchRoot,
|
|
34
|
-
signal,
|
|
35
|
-
runProcess: supervisedRunner(custody.directory, custody.incarnation, attemptId),
|
|
36
|
-
});
|
|
9
|
+
const result = await (options.embed ?? embedBatch)(inputs, { runtime, scratchRoot, signal, runProcess });
|
|
37
10
|
if (result.fingerprint !== embeddingFingerprint(runtime.helperSha256) ||
|
|
38
11
|
result.inputHashes.length !== expected.length ||
|
|
39
12
|
result.inputHashes.some((h, i) => h !== expected[i]) ||
|
|
40
13
|
result.vectors.length !== expected.length)
|
|
41
14
|
throw new SpongeError('integrity');
|
|
42
15
|
return result.vectors;
|
|
43
|
-
}
|
|
44
|
-
catch (error) {
|
|
45
|
-
cleanupUnknown = codeOf(error) === 'cleanup-required';
|
|
46
|
-
throw error;
|
|
47
|
-
}
|
|
48
|
-
finally {
|
|
49
|
-
const proved = !cleanupUnknown && (await proveAttemptCleanup(custody.directory, custody.incarnation, attemptId));
|
|
50
|
-
if (!proved)
|
|
51
|
-
throw new SpongeError('cleanup-required');
|
|
52
|
-
if (scratchRoot) {
|
|
53
|
-
const marker = await readJournal(join(scratchRoot, 'ownership.json'));
|
|
54
|
-
if (marker.schemaVersion !== 1 ||
|
|
55
|
-
marker.kind !== 'query' ||
|
|
56
|
-
marker.requestId !== attemptId ||
|
|
57
|
-
marker.ownerIncarnation !== custody.incarnation)
|
|
58
|
-
throw new SpongeError('cleanup-required');
|
|
59
|
-
await rm(scratchRoot, { recursive: true });
|
|
60
|
-
await syncDirectoryAsync(join(store.root, 'staging'));
|
|
61
|
-
}
|
|
62
|
-
await custody.release();
|
|
63
|
-
}
|
|
16
|
+
});
|
|
64
17
|
}
|
|
65
18
|
/** Queries mutate coordination journals only, never library content or job tables. */
|
|
66
19
|
export function createQueryEmbedder(store, runtime, options = {}) {
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { type Context } from './context.js';
|
|
3
|
+
import type { JobKind } from './queue.js';
|
|
4
|
+
import type { OwnerToken } from './types.js';
|
|
5
|
+
export interface DigestSettings {
|
|
6
|
+
auto: boolean;
|
|
7
|
+
/** The model alias `sponge setup --digest` installed, or null for the memory-based default. */
|
|
8
|
+
model: string | null;
|
|
9
|
+
}
|
|
10
|
+
export interface DigestClaim extends OwnerToken {
|
|
11
|
+
jobId: string;
|
|
12
|
+
itemId: string;
|
|
13
|
+
revisionId: string;
|
|
14
|
+
}
|
|
15
|
+
export type DigestOutcome = {
|
|
16
|
+
derivationId: string;
|
|
17
|
+
} | {
|
|
18
|
+
reason: string;
|
|
19
|
+
retryable?: boolean;
|
|
20
|
+
notBefore?: string;
|
|
21
|
+
countAttempt?: boolean;
|
|
22
|
+
};
|
|
23
|
+
/** Queue a digest of a newly captured revision when auto-digest is on. Runs inside the capture commit's transaction. */
|
|
24
|
+
export declare function enqueueDigest(db: DatabaseSync, itemId: string, revisionId: string): void;
|
|
25
|
+
/**
|
|
26
|
+
* Digest jobs under the shared worker lease. A job names one revision; when its item moves on or is deleted before the
|
|
27
|
+
* worker reaches it, the job is cancelled as `superseded`, since the newer revision has a job of its own. The drafting
|
|
28
|
+
* server's process custody is the worker's filesystem journal (`withDigestServer`), so the database holds no attempt
|
|
29
|
+
* rows or staging, and owner recovery returns a crashed owner's running job to the queue.
|
|
30
|
+
*/
|
|
31
|
+
export declare class DigestJobs {
|
|
32
|
+
private readonly c;
|
|
33
|
+
readonly jobs: JobKind;
|
|
34
|
+
constructor(c: Context);
|
|
35
|
+
settings(): DigestSettings;
|
|
36
|
+
/** Turn auto-digest on or off. Turning it on queues nothing already captured; turning it off leaves queued jobs. */
|
|
37
|
+
configure(settings: Partial<DigestSettings>): DigestSettings;
|
|
38
|
+
/** Whether a job is ready for `claim` now. */
|
|
39
|
+
due(): boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Delay every ready job to `notBefore` with `reason`, without counting an attempt: the drafting runtime is missing or
|
|
42
|
+
* could not start, so no job reached the model. Running jobs are untouched. Returns how many were delayed.
|
|
43
|
+
*/
|
|
44
|
+
postpone(reason: string, notBefore: string): number;
|
|
45
|
+
/** The oldest ready job whose revision is still its item's current one; jobs passed over on the way are superseded. */
|
|
46
|
+
claim(owner: OwnerToken): DigestClaim | null;
|
|
47
|
+
/**
|
|
48
|
+
* Settle a claimed job. A retryable failure under the attempt limit is delayed until `notBefore` (default: a backoff
|
|
49
|
+
* from the attempt count); `countAttempt: false` gives the attempt back, for a refusal that never reached the model.
|
|
50
|
+
*/
|
|
51
|
+
finish(claim: DigestClaim, outcome: DigestOutcome): void;
|
|
52
|
+
/** Whether the user asked to cancel the claimed job while it ran. */
|
|
53
|
+
cancelRequested(claim: DigestClaim): boolean;
|
|
54
|
+
private row;
|
|
55
|
+
}
|