@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.
Files changed (40) hide show
  1. package/README.md +16 -7
  2. package/dist/migrations/011.d.ts +3 -0
  3. package/dist/migrations/011.js +9 -0
  4. package/dist/migrations/index.js +2 -0
  5. package/dist/src/annotations/quote.d.ts +1 -0
  6. package/dist/src/annotations/quote.js +11 -1
  7. package/dist/src/cli/commands.js +51 -6
  8. package/dist/src/cli/runtimes.js +6 -2
  9. package/dist/src/cli/worker.js +5 -1
  10. package/dist/src/digest/endpoint.d.ts +3 -2
  11. package/dist/src/digest/endpoint.js +3 -2
  12. package/dist/src/digest/pipeline.js +6 -3
  13. package/dist/src/digest/runner.d.ts +33 -0
  14. package/dist/src/digest/runner.js +82 -0
  15. package/dist/src/digest/schema.js +6 -2
  16. package/dist/src/digest/server.d.ts +40 -0
  17. package/dist/src/digest/server.js +166 -0
  18. package/dist/src/errors.d.ts +1 -0
  19. package/dist/src/errors.js +2 -0
  20. package/dist/src/importers/custody.d.ts +2 -5
  21. package/dist/src/importers/custody.js +3 -51
  22. package/dist/src/models/artifacts.d.ts +5 -0
  23. package/dist/src/models/artifacts.js +92 -17
  24. package/dist/src/models/digest.d.ts +64 -0
  25. package/dist/src/models/digest.js +122 -0
  26. package/dist/src/portability/database.js +2 -0
  27. package/dist/src/search/query.js +7 -54
  28. package/dist/src/store/digest-jobs.d.ts +55 -0
  29. package/dist/src/store/digest-jobs.js +164 -0
  30. package/dist/src/store/library.d.ts +3 -0
  31. package/dist/src/store/library.js +5 -1
  32. package/dist/src/store/queue.d.ts +9 -5
  33. package/dist/src/store/queue.js +14 -9
  34. package/dist/src/store/revisions.js +4 -0
  35. package/dist/src/worker/custody.d.ts +5 -0
  36. package/dist/src/worker/custody.js +7 -4
  37. package/dist/src/worker/transient.d.ts +17 -0
  38. package/dist/src/worker/transient.js +60 -0
  39. package/native/runtime-manifest.json +4 -4
  40. package/package.json +1 -1
@@ -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 { supervisedRunner } from '../worker/supervisor.js';
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 { randomUUID } from 'node:crypto';
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 async function withImportVerification(libraryRoot, verify) {
12
- const custody = await acquireWorkerCustody(libraryRoot), attemptId = randomUUID();
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 maximumBytes = 2 * 1024 ** 3;
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
- if (!/^[a-z0-9][a-z0-9.-]{0,127}$/.test(manifest.id) ||
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 signal = options.signal
481
- ? AbortSignal.any([options.signal, AbortSignal.timeout(600000)])
482
- : AbortSignal.timeout(600000);
483
- const response = await (options.fetch ?? fetch)(artifact.url, { signal });
484
- if (!response.ok || !response.body || (response.url && new URL(response.url).protocol !== 'https:'))
485
- throw new SpongeError('unavailable');
486
- const declared = response.headers.get('content-length');
487
- if (declared !== null && Number(declared) !== artifact.bytes) {
488
- await response.body.cancel();
489
- throw new SpongeError('integrity');
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');
@@ -1,66 +1,19 @@
1
- import { randomUUID } from 'node:crypto';
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, codeOf } from '../errors.js';
3
+ import { SpongeError } from '../errors.js';
10
4
  /** One custody-held helper run. Mutates coordination journals only, never library content or job tables. */
11
- async function custodyEmbed(store, runtime, inputs, signal, options) {
12
- const custody = await acquireWorkerCustody(store.root), attemptId = randomUUID();
13
- let scratchRoot, cleanupUnknown = false;
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
+ }