@hraness/sponge 0.3.5 → 0.4.1

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 (38) 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/cli/commands.js +59 -7
  6. package/dist/src/cli/runtimes.js +6 -2
  7. package/dist/src/cli/worker.js +5 -1
  8. package/dist/src/digest/endpoint.d.ts +3 -2
  9. package/dist/src/digest/endpoint.js +3 -2
  10. package/dist/src/digest/pipeline.d.ts +29 -4
  11. package/dist/src/digest/pipeline.js +125 -21
  12. package/dist/src/digest/runner.d.ts +33 -0
  13. package/dist/src/digest/runner.js +82 -0
  14. package/dist/src/digest/server.d.ts +40 -0
  15. package/dist/src/digest/server.js +166 -0
  16. package/dist/src/errors.d.ts +2 -0
  17. package/dist/src/errors.js +4 -0
  18. package/dist/src/importers/custody.d.ts +2 -5
  19. package/dist/src/importers/custody.js +3 -51
  20. package/dist/src/models/artifacts.d.ts +5 -0
  21. package/dist/src/models/artifacts.js +92 -17
  22. package/dist/src/models/digest.d.ts +64 -0
  23. package/dist/src/models/digest.js +122 -0
  24. package/dist/src/portability/database.js +2 -0
  25. package/dist/src/search/query.js +7 -54
  26. package/dist/src/store/digest-jobs.d.ts +58 -0
  27. package/dist/src/store/digest-jobs.js +168 -0
  28. package/dist/src/store/library.d.ts +3 -0
  29. package/dist/src/store/library.js +5 -1
  30. package/dist/src/store/queue.d.ts +9 -5
  31. package/dist/src/store/queue.js +14 -9
  32. package/dist/src/store/revisions.js +4 -0
  33. package/dist/src/worker/custody.d.ts +5 -0
  34. package/dist/src/worker/custody.js +7 -4
  35. package/dist/src/worker/transient.d.ts +17 -0
  36. package/dist/src/worker/transient.js +60 -0
  37. package/native/runtime-manifest.json +4 -4
  38. package/package.json +1 -1
@@ -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,58 @@
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
+ /**
24
+ * Queue a digest of a newly captured revision when auto-digest is on and the capture has enough text to digest. Runs
25
+ * inside the capture commit's transaction.
26
+ */
27
+ export declare function enqueueDigest(db: DatabaseSync, itemId: string, revisionId: string, markdown: string): void;
28
+ /**
29
+ * Digest jobs under the shared worker lease. A job names one revision; when its item moves on or is deleted before the
30
+ * worker reaches it, the job is cancelled as `superseded`, since the newer revision has a job of its own. The drafting
31
+ * server's process custody is the worker's filesystem journal (`withDigestServer`), so the database holds no attempt
32
+ * rows or staging, and owner recovery returns a crashed owner's running job to the queue.
33
+ */
34
+ export declare class DigestJobs {
35
+ private readonly c;
36
+ readonly jobs: JobKind;
37
+ constructor(c: Context);
38
+ settings(): DigestSettings;
39
+ /** Turn auto-digest on or off. Turning it on queues nothing already captured; turning it off leaves queued jobs. */
40
+ configure(settings: Partial<DigestSettings>): DigestSettings;
41
+ /** Whether a job is ready for `claim` now. */
42
+ due(): boolean;
43
+ /**
44
+ * Delay every ready job to `notBefore` with `reason`, without counting an attempt: the drafting runtime is missing or
45
+ * could not start, so no job reached the model. Running jobs are untouched. Returns how many were delayed.
46
+ */
47
+ postpone(reason: string, notBefore: string): number;
48
+ /** The oldest ready job whose revision is still its item's current one; jobs passed over on the way are superseded. */
49
+ claim(owner: OwnerToken): DigestClaim | null;
50
+ /**
51
+ * Settle a claimed job. A retryable failure under the attempt limit is delayed until `notBefore` (default: a backoff
52
+ * from the attempt count); `countAttempt: false` gives the attempt back, for a refusal that never reached the model.
53
+ */
54
+ finish(claim: DigestClaim, outcome: DigestOutcome): void;
55
+ /** Whether the user asked to cancel the claimed job while it ran. */
56
+ cancelRequested(claim: DigestClaim): boolean;
57
+ private row;
58
+ }
@@ -0,0 +1,168 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { text } from './files.js';
3
+ import { checkOwner } from './lease.js';
4
+ import { now, transaction } from './context.js';
5
+ import { SpongeError } from '../errors.js';
6
+ import { undigestible } from '../digest/pipeline.js';
7
+ /** Automatic retries a retryable failure gets before it stays failed. */
8
+ const maxAttempts = 3;
9
+ const alias = /^[\w.:/@+-]{1,256}$/;
10
+ /**
11
+ * Queue a digest of a newly captured revision when auto-digest is on and the capture has enough text to digest. Runs
12
+ * inside the capture commit's transaction.
13
+ */
14
+ export function enqueueDigest(db, itemId, revisionId, markdown) {
15
+ if (db.prepare('SELECT auto FROM digest_settings WHERE id=1').get()?.auto !== 1 || undigestible(markdown))
16
+ return;
17
+ db.prepare("INSERT INTO digest_jobs(id,item_id,revision_id,state,created_at) VALUES(?,?,?,'queued',?) ON CONFLICT(revision_id) DO NOTHING").run(randomUUID(), itemId, revisionId, now());
18
+ }
19
+ /**
20
+ * Digest jobs under the shared worker lease. A job names one revision; when its item moves on or is deleted before the
21
+ * worker reaches it, the job is cancelled as `superseded`, since the newer revision has a job of its own. The drafting
22
+ * server's process custody is the worker's filesystem journal (`withDigestServer`), so the database holds no attempt
23
+ * rows or staging, and owner recovery returns a crashed owner's running job to the queue.
24
+ */
25
+ export class DigestJobs {
26
+ c;
27
+ jobs;
28
+ constructor(c) {
29
+ this.c = c;
30
+ const db = c.db;
31
+ this.jobs = {
32
+ owns: (id) => !!db.prepare('SELECT 1 FROM digest_jobs WHERE id=?').get(id),
33
+ list: () => db
34
+ .prepare("SELECT d.*,'digest' AS operation FROM digest_jobs d JOIN items i ON i.id=d.item_id WHERE i.deleted_at IS NULL ORDER BY d.created_at,d.id")
35
+ .all(),
36
+ attempts: () => [],
37
+ cancel: (id) => transaction(db, () => {
38
+ const job = this.row(id);
39
+ if (['succeeded', 'cancelled'].includes(String(job.state)))
40
+ return;
41
+ db.prepare("UPDATE digest_jobs SET cancel_requested=1,state=CASE WHEN state='running' THEN state ELSE 'cancelled' END,finished_at=CASE WHEN state='running' THEN finished_at ELSE ? END WHERE id=?").run(now(), id);
42
+ }),
43
+ retry: (id, options) => {
44
+ if (options.automatic || options.notBefore !== undefined)
45
+ throw new SpongeError('invalid-input');
46
+ transaction(db, () => {
47
+ const job = this.row(id);
48
+ if (job.state !== 'failed' || job.cancel_requested)
49
+ throw new SpongeError('unavailable');
50
+ db.prepare("UPDATE digest_jobs SET state='queued',reason=NULL,retryable=0,not_before=NULL,attempt_count=0,finished_at=NULL WHERE id=?").run(id);
51
+ });
52
+ },
53
+ cancelRequested: (id) => this.row(id).cancel_requested === 1,
54
+ recover: () => {
55
+ throw new SpongeError('integrity');
56
+ },
57
+ release: (owner) => {
58
+ db.prepare("UPDATE digest_jobs SET state=CASE WHEN cancel_requested=1 THEN 'cancelled' ELSE 'queued' END,owner_incarnation=NULL,fence=NULL WHERE state='running' AND owner_incarnation=? AND fence=?").run(owner.ownerIncarnation, owner.fence);
59
+ },
60
+ };
61
+ }
62
+ settings() {
63
+ const row = this.c.db.prepare('SELECT auto,model FROM digest_settings WHERE id=1').get();
64
+ return { auto: row.auto === 1, model: row.model === null ? null : String(row.model) };
65
+ }
66
+ /** Turn auto-digest on or off. Turning it on queues nothing already captured; turning it off leaves queued jobs. */
67
+ configure(settings) {
68
+ if (settings.auto !== undefined && typeof settings.auto !== 'boolean')
69
+ throw new SpongeError('invalid-input');
70
+ if (settings.model !== undefined && settings.model !== null && !alias.test(String(settings.model)))
71
+ throw new SpongeError('invalid-input');
72
+ transaction(this.c.db, () => {
73
+ if (settings.auto !== undefined)
74
+ this.c.db.prepare('UPDATE digest_settings SET auto=? WHERE id=1').run(Number(settings.auto));
75
+ if (settings.model !== undefined)
76
+ this.c.db.prepare('UPDATE digest_settings SET model=? WHERE id=1').run(settings.model);
77
+ });
78
+ return this.settings();
79
+ }
80
+ /** Whether a job is ready for `claim` now. */
81
+ due() {
82
+ return !!this.c.db
83
+ .prepare("SELECT 1 FROM digest_jobs WHERE state IN ('queued','delayed') AND cancel_requested=0 AND (not_before IS NULL OR not_before<=?) LIMIT 1")
84
+ .get(now());
85
+ }
86
+ /**
87
+ * Delay every ready job to `notBefore` with `reason`, without counting an attempt: the drafting runtime is missing or
88
+ * could not start, so no job reached the model. Running jobs are untouched. Returns how many were delayed.
89
+ */
90
+ postpone(reason, notBefore) {
91
+ if (!/^[a-z][a-z-]{0,63}$/.test(reason) || !Number.isFinite(Date.parse(notBefore)))
92
+ throw new SpongeError('invalid-input');
93
+ return Number(this.c.db
94
+ .prepare("UPDATE digest_jobs SET state='delayed',reason=?,retryable=1,not_before=? WHERE state IN ('queued','delayed') AND cancel_requested=0 AND (not_before IS NULL OR not_before<=?)")
95
+ .run(reason, notBefore, now()).changes);
96
+ }
97
+ /** The oldest ready job whose revision is still its item's current one; jobs passed over on the way are superseded. */
98
+ claim(owner) {
99
+ const db = this.c.db;
100
+ return transaction(db, () => {
101
+ checkOwner(db, owner);
102
+ for (;;) {
103
+ const job = db
104
+ .prepare("SELECT d.id,d.item_id,d.revision_id,i.current_revision,i.deleted_at FROM digest_jobs d JOIN items i ON i.id=d.item_id WHERE d.state IN ('queued','delayed') AND d.cancel_requested=0 AND (d.not_before IS NULL OR d.not_before<=?) ORDER BY d.created_at,d.id LIMIT 1")
105
+ .get(now());
106
+ if (!job)
107
+ return null;
108
+ if (job.deleted_at !== null || job.current_revision !== job.revision_id) {
109
+ db.prepare("UPDATE digest_jobs SET state='cancelled',reason='superseded',finished_at=? WHERE id=?").run(now(), String(job.id));
110
+ continue;
111
+ }
112
+ db.prepare("UPDATE digest_jobs SET state='running',attempt_count=attempt_count+1,owner_incarnation=?,fence=? WHERE id=?").run(owner.ownerIncarnation, owner.fence, String(job.id));
113
+ return {
114
+ ...owner,
115
+ jobId: String(job.id),
116
+ itemId: String(job.item_id),
117
+ revisionId: String(job.revision_id),
118
+ };
119
+ }
120
+ });
121
+ }
122
+ /**
123
+ * Settle a claimed job. A retryable failure under the attempt limit is delayed until `notBefore` (default: a backoff
124
+ * from the attempt count); `countAttempt: false` gives the attempt back, for a refusal that never reached the model.
125
+ */
126
+ finish(claim, outcome) {
127
+ const db = this.c.db;
128
+ if ('reason' in outcome && !/^[a-z][a-z-]{0,63}$/.test(outcome.reason))
129
+ throw new SpongeError('invalid-input');
130
+ if ('reason' in outcome && outcome.notBefore !== undefined && !Number.isFinite(Date.parse(outcome.notBefore)))
131
+ throw new SpongeError('invalid-input');
132
+ transaction(db, () => {
133
+ checkOwner(db, claim);
134
+ const job = this.row(claim.jobId);
135
+ if (job.state !== 'running' || job.owner_incarnation !== claim.ownerIncarnation || job.fence !== claim.fence)
136
+ throw new SpongeError('owner-busy');
137
+ const release = 'UPDATE digest_jobs SET owner_incarnation=NULL,fence=NULL WHERE id=?';
138
+ if (job.cancel_requested) {
139
+ db.prepare("UPDATE digest_jobs SET state='cancelled',derivation_id=?,finished_at=? WHERE id=?").run('derivationId' in outcome ? outcome.derivationId : null, now(), claim.jobId);
140
+ }
141
+ else if ('derivationId' in outcome) {
142
+ text(outcome.derivationId, 256);
143
+ db.prepare("UPDATE digest_jobs SET state='succeeded',reason=NULL,retryable=0,not_before=NULL,derivation_id=?,finished_at=? WHERE id=?").run(outcome.derivationId, now(), claim.jobId);
144
+ }
145
+ else {
146
+ const attempts = Number(job.attempt_count) - (outcome.countAttempt === false ? 1 : 0);
147
+ if (outcome.retryable && (attempts < maxAttempts || outcome.countAttempt === false)) {
148
+ const due = outcome.notBefore ?? new Date(Date.now() + 60000 * 2 ** Math.max(0, attempts - 1)).toISOString();
149
+ db.prepare("UPDATE digest_jobs SET state='delayed',reason=?,retryable=1,not_before=?,attempt_count=? WHERE id=?").run(outcome.reason, due, attempts, claim.jobId);
150
+ }
151
+ else
152
+ db.prepare("UPDATE digest_jobs SET state='failed',reason=?,retryable=?,not_before=NULL,attempt_count=?,finished_at=? WHERE id=?").run(outcome.reason, Number(!!outcome.retryable), attempts, now(), claim.jobId);
153
+ }
154
+ db.prepare(release).run(claim.jobId);
155
+ });
156
+ }
157
+ /** Whether the user asked to cancel the claimed job while it ran. */
158
+ cancelRequested(claim) {
159
+ return this.row(claim.jobId).cancel_requested === 1;
160
+ }
161
+ row(id) {
162
+ text(id, 256);
163
+ const job = this.c.db.prepare('SELECT * FROM digest_jobs WHERE id=?').get(id);
164
+ if (!job)
165
+ throw new SpongeError('unavailable');
166
+ return job;
167
+ }
168
+ }
@@ -6,6 +6,7 @@ import { Revisions } from './revisions.js';
6
6
  import { Derivations } from './derivations.js';
7
7
  import { Annotations } from './annotations.js';
8
8
  import { SearchIndex } from './search-index.js';
9
+ import { DigestJobs } from './digest-jobs.js';
9
10
  import { Backups } from './backups.js';
10
11
  import { Imports } from './imports.js';
11
12
  import type { ReadableItem, StoreOptions } from './types.js';
@@ -32,6 +33,8 @@ export declare class LibraryStore {
32
33
  readonly annotations: Annotations;
33
34
  /** Embedding index generations, index jobs, pruning and hybrid search. */
34
35
  readonly index: SearchIndex;
36
+ /** Digest jobs and the auto-digest settings. */
37
+ readonly digests: DigestJobs;
35
38
  readonly backups: Backups;
36
39
  readonly imports: Imports;
37
40
  constructor(root: string, db: DatabaseSync, options: StoreOptions);
@@ -11,6 +11,7 @@ import { Revisions } from './revisions.js';
11
11
  import { Derivations } from './derivations.js';
12
12
  import { Annotations } from './annotations.js';
13
13
  import { SearchIndex } from './search-index.js';
14
+ import { DigestJobs } from './digest-jobs.js';
14
15
  import { Backups } from './backups.js';
15
16
  import { Imports } from './imports.js';
16
17
  import { validateMetadataWarnings } from '../../contracts/validate.js';
@@ -32,6 +33,8 @@ export class LibraryStore {
32
33
  annotations;
33
34
  /** Embedding index generations, index jobs, pruning and hybrid search. */
34
35
  index;
36
+ /** Digest jobs and the auto-digest settings. */
37
+ digests;
35
38
  backups;
36
39
  imports;
37
40
  constructor(root, db, options) {
@@ -39,7 +42,8 @@ export class LibraryStore {
39
42
  const c = { root, db, options };
40
43
  this.db = db;
41
44
  this.index = new SearchIndex(c);
42
- this.queue = new Queue(c, this.index.jobs);
45
+ this.digests = new DigestJobs(c);
46
+ this.queue = new Queue(c, [this.index.jobs, this.digests.jobs]);
43
47
  this.blobs = new Blobs(c);
44
48
  this.revisions = new Revisions(c);
45
49
  this.derivations = new Derivations(c);
@@ -6,7 +6,8 @@ export interface RetryOptions {
6
6
  automatic?: boolean;
7
7
  }
8
8
  /**
9
- * One kind of job under the shared worker lease. Capture jobs live here; index jobs in `search-index.ts`. The queue dispatches
9
+ * One kind of job under the shared worker lease. Capture jobs live here; index jobs in `search-index.ts`, digest jobs in
10
+ * `digest-jobs.ts`. The queue dispatches
10
11
  * listing, cancellation, retry and owner recovery through this interface, so a new job kind plugs in without new branches.
11
12
  */
12
13
  export interface JobKind {
@@ -19,19 +20,22 @@ export interface JobKind {
19
20
  cancelRequested(jobId: string): boolean;
20
21
  /** Inside `recoverOwner`'s transaction, once custody of the attempt is proved: remove its staging and settle it. */
21
22
  recover(attempt: OwnedAttempt): void;
23
+ /** Inside `recoverOwner`'s transaction: return work the recovered owner held without attempt rows. */
24
+ release?(owner: OwnerToken): void;
22
25
  }
23
26
  /** Capture jobs (acquire, extract, commit one source) and the owner lease shared with every other job kind. */
24
27
  export declare class Queue {
25
28
  private readonly c;
26
- private readonly index;
29
+ private readonly kinds;
27
30
  private readonly capture;
28
- constructor(c: Context, index: JobKind);
29
- /** Index jobs are checked first; any other id is a capture job (which reports `unavailable` when it does not exist). */
31
+ /** `kinds` are every other job kind, in dispatch order. */
32
+ constructor(c: Context, kinds: readonly JobKind[]);
33
+ /** The other kinds are checked first; any other id is a capture job (which reports `unavailable` when it does not exist). */
30
34
  private kindOf;
31
35
  acquireOwner(ownerIncarnation: string): OwnerToken;
32
36
  releaseOwner(token: OwnerToken): void;
33
37
  ownerSnapshot(): OwnerToken | null;
34
- /** Every attempt the owner claimed, capture first, then index. */
38
+ /** Every attempt the owner claimed, capture first, then the other kinds in order. */
35
39
  ownerAttempts(token: OwnerToken): OwnedAttempt[];
36
40
  /** Requires external custody proof for EVERY pending attempt, even after its job committed.
37
41
  * IDs are acknowledgements from the custody owner, never evidence inferred from PID/timeouts here. */
@@ -12,11 +12,12 @@ const activeStates = "('queued','running','delayed','cleanup-pending')";
12
12
  /** Capture jobs (acquire, extract, commit one source) and the owner lease shared with every other job kind. */
13
13
  export class Queue {
14
14
  c;
15
- index;
15
+ kinds;
16
16
  capture;
17
- constructor(c, index) {
17
+ /** `kinds` are every other job kind, in dispatch order. */
18
+ constructor(c, kinds) {
18
19
  this.c = c;
19
- this.index = index;
20
+ this.kinds = kinds;
20
21
  const db = c.db;
21
22
  this.capture = {
22
23
  owns: (id) => !!db.prepare('SELECT 1 FROM jobs WHERE id=?').get(id),
@@ -99,9 +100,9 @@ export class Queue {
99
100
  },
100
101
  };
101
102
  }
102
- /** Index jobs are checked first; any other id is a capture job (which reports `unavailable` when it does not exist). */
103
+ /** The other kinds are checked first; any other id is a capture job (which reports `unavailable` when it does not exist). */
103
104
  kindOf(jobId) {
104
- return this.index.owns(jobId) ? this.index : this.capture;
105
+ return this.kinds.find((kind) => kind.owns(jobId)) ?? this.capture;
105
106
  }
106
107
  acquireOwner(ownerIncarnation) {
107
108
  text(ownerIncarnation, 256);
@@ -127,10 +128,10 @@ export class Queue {
127
128
  ownerSnapshot() {
128
129
  return ownerSnapshot(this.c.db);
129
130
  }
130
- /** Every attempt the owner claimed, capture first, then index. */
131
+ /** Every attempt the owner claimed, capture first, then the other kinds in order. */
131
132
  ownerAttempts(token) {
132
133
  checkOwner(this.c.db, token);
133
- return [...this.capture.attempts(token), ...this.index.attempts(token)];
134
+ return [this.capture, ...this.kinds].flatMap((kind) => kind.attempts(token));
134
135
  }
135
136
  /** Requires external custody proof for EVERY pending attempt, even after its job committed.
136
137
  * IDs are acknowledgements from the custody owner, never evidence inferred from PID/timeouts here. */
@@ -146,7 +147,9 @@ export class Queue {
146
147
  if (proof.size !== pending.length || pending.some((a) => !proof.has(a.attemptId)))
147
148
  throw new SpongeError('owner-busy');
148
149
  for (const attempt of pending)
149
- (attempt.kind === 'index' ? this.index : this.capture).recover(attempt);
150
+ this.kindOf(attempt.jobId).recover(attempt);
151
+ for (const kind of this.kinds)
152
+ kind.release?.(expected);
150
153
  this.c.db.prepare('UPDATE worker_owner SET incarnation=NULL,fence=fence+1 WHERE id=1').run();
151
154
  });
152
155
  }
@@ -217,7 +220,9 @@ export class Queue {
217
220
  }
218
221
  /** Jobs of every kind, oldest first. */
219
222
  list() {
220
- return [...this.capture.list(), ...this.index.list()].sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)) || String(a.id).localeCompare(String(b.id)));
223
+ return [this.capture, ...this.kinds]
224
+ .flatMap((kind) => kind.list())
225
+ .sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)) || String(a.id).localeCompare(String(b.id)));
221
226
  }
222
227
  /** Acquisition data for a capture job. A local file's locator is its path on this device (`local_sources`), never the synced form. */
223
228
  source(jobId) {
@@ -11,6 +11,7 @@ import { indexReadable } from '../search/lexical.js';
11
11
  import { trackLibrary } from '../indexing/index.js';
12
12
  import { checkAttempt } from './lease.js';
13
13
  import { importReceipt } from './imports.js';
14
+ import { enqueueDigest } from './digest-jobs.js';
14
15
  import { bumpEpoch, now, transaction } from './context.js';
15
16
  import { SpongeError } from '../errors.js';
16
17
  /** Captured revisions: content-addressed, immutable once committed, and the item's readable pointer. */
@@ -151,6 +152,9 @@ export class Revisions {
151
152
  bumpEpoch(db);
152
153
  // Active indexes follow the new revision; only its new chunks are queued for embedding.
153
154
  trackLibrary(db);
155
+ // A capture, not an import: imported libraries are digested on purpose, not as a side effect.
156
+ if (!imported)
157
+ enqueueDigest(db, input.itemId, revisionId, String(stored.markdown));
154
158
  }
155
159
  if (imported) {
156
160
  const receipt = importReceipt(db, imported.namespace, imported.sourceKey, imported.sourceHash);
@@ -7,6 +7,10 @@ export interface WorkerCustody {
7
7
  export declare function acquireWorkerCustody(root: string): Promise<WorkerCustody>;
8
8
  /** Receipts are specific to every pre-launch ticket. Missing receipts never mean absent children. */
9
9
  export declare function proveAttemptCleanup(ownerDirectory: string, incarnation: string, attemptId: string): Promise<boolean>;
10
+ /** Attempts outside the job tables: a search query, an import verification, a digest server. Each writes `KIND.json`
11
+ * under its attempt and `staging/KIND-ID/ownership.json`. */
12
+ declare const transientKinds: readonly ["query", "import-verification", "digest-server"];
13
+ export type TransientKind = (typeof transientKinds)[number];
10
14
  export type RecoveryFault = 'guarded' | 'recovered';
11
15
  export declare function recoverWorker(store: LibraryStore, options?: {
12
16
  fault?: (point: RecoveryFault) => void;
@@ -14,3 +18,4 @@ export declare function recoverWorker(store: LibraryStore, options?: {
14
18
  recovered: boolean;
15
19
  attempts: number;
16
20
  }>;
21
+ export {};