@hraness/sponge 0.1.2 → 0.2.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 (174) hide show
  1. package/README.md +163 -17
  2. package/bin/sponge.js +9 -2
  3. package/dist/contracts/index.d.ts +164 -0
  4. package/dist/contracts/index.js +6 -0
  5. package/dist/contracts/validate.d.ts +14 -0
  6. package/dist/contracts/validate.js +187 -0
  7. package/dist/migrations/001.d.ts +3 -0
  8. package/dist/migrations/001.js +33 -0
  9. package/dist/migrations/002.d.ts +3 -0
  10. package/dist/migrations/002.js +6 -0
  11. package/dist/migrations/003.d.ts +3 -0
  12. package/dist/migrations/003.js +10 -0
  13. package/dist/migrations/004.d.ts +3 -0
  14. package/dist/migrations/004.js +9 -0
  15. package/dist/src/acquisition/capture.d.ts +29 -0
  16. package/dist/src/acquisition/capture.js +160 -0
  17. package/dist/src/acquisition/cleanup.d.ts +6 -0
  18. package/dist/src/acquisition/cleanup.js +20 -0
  19. package/dist/src/acquisition/errors.d.ts +3 -0
  20. package/dist/src/acquisition/errors.js +20 -0
  21. package/dist/src/acquisition/files.d.ts +4 -0
  22. package/dist/src/acquisition/files.js +49 -0
  23. package/dist/src/acquisition/index.d.ts +6 -0
  24. package/dist/src/acquisition/index.js +4 -0
  25. package/dist/src/acquisition/lock.d.ts +1 -0
  26. package/dist/src/acquisition/lock.js +2 -0
  27. package/dist/src/acquisition/normalize.d.ts +11 -0
  28. package/dist/src/acquisition/normalize.js +62 -0
  29. package/dist/src/acquisition/pins.d.ts +3 -0
  30. package/dist/src/acquisition/pins.js +9 -0
  31. package/dist/src/acquisition/process.d.ts +16 -0
  32. package/dist/src/acquisition/process.js +63 -0
  33. package/dist/src/acquisition/runtime.d.ts +53 -0
  34. package/dist/src/acquisition/runtime.js +216 -0
  35. package/dist/src/acquisition/verify-helper.d.ts +1 -0
  36. package/dist/src/acquisition/verify-helper.js +19 -0
  37. package/dist/src/app/article.d.ts +23 -0
  38. package/dist/src/app/article.js +82 -0
  39. package/dist/src/app/bundle-files.d.ts +8 -0
  40. package/dist/src/app/bundle-files.js +66 -0
  41. package/dist/src/app/markdown.d.ts +6 -0
  42. package/dist/src/app/markdown.js +90 -0
  43. package/dist/src/app/source.d.ts +15 -0
  44. package/dist/src/app/source.js +40 -0
  45. package/dist/src/cli/arguments.d.ts +9 -0
  46. package/dist/src/cli/arguments.js +106 -0
  47. package/dist/src/cli/config.d.ts +8 -0
  48. package/dist/src/cli/config.js +45 -0
  49. package/dist/src/cli/import.d.ts +4 -0
  50. package/dist/src/cli/import.js +3 -0
  51. package/dist/src/cli/indexing.d.ts +7 -0
  52. package/dist/src/cli/indexing.js +21 -0
  53. package/dist/src/cli/main.d.ts +1 -0
  54. package/dist/src/cli/main.js +333 -0
  55. package/dist/src/cli/portability.d.ts +9 -0
  56. package/dist/src/cli/portability.js +39 -0
  57. package/dist/src/cli/queue.d.ts +2 -0
  58. package/dist/src/cli/queue.js +23 -0
  59. package/dist/src/cli/terminal.d.ts +2 -0
  60. package/dist/src/cli/terminal.js +4 -0
  61. package/dist/src/cli/worker.d.ts +16 -0
  62. package/dist/src/cli/worker.js +62 -0
  63. package/dist/src/importers/apply.d.ts +29 -0
  64. package/dist/src/importers/apply.js +85 -0
  65. package/dist/src/importers/custody.d.ts +6 -0
  66. package/dist/src/importers/custody.js +35 -0
  67. package/dist/src/importers/inspect.d.ts +21 -0
  68. package/dist/src/importers/inspect.js +97 -0
  69. package/dist/src/importers/manifest.d.ts +22 -0
  70. package/dist/src/importers/manifest.js +43 -0
  71. package/dist/src/importers/verify-helper.d.ts +1 -0
  72. package/dist/src/importers/verify-helper.js +27 -0
  73. package/dist/src/indexing/image.d.ts +10 -0
  74. package/dist/src/indexing/image.js +34 -0
  75. package/dist/src/indexing/index.d.ts +53 -0
  76. package/dist/src/indexing/index.js +123 -0
  77. package/dist/src/indexing/jobs.d.ts +14 -0
  78. package/dist/src/indexing/jobs.js +49 -0
  79. package/dist/src/indexing/runner.d.ts +23 -0
  80. package/dist/src/indexing/runner.js +114 -0
  81. package/dist/src/inference/asr/bridge.d.ts +1 -0
  82. package/dist/src/inference/asr/bridge.js +16 -0
  83. package/dist/src/inference/asr/protocol.d.ts +23 -0
  84. package/dist/src/inference/asr/protocol.js +60 -0
  85. package/dist/src/inference/asr/runtime.d.ts +29 -0
  86. package/dist/src/inference/asr/runtime.js +103 -0
  87. package/dist/src/inference/asr/windows.d.ts +9 -0
  88. package/dist/src/inference/asr/windows.js +41 -0
  89. package/dist/src/inference/embedding/index.d.ts +47 -0
  90. package/dist/src/inference/embedding/index.js +122 -0
  91. package/dist/src/media/capture-helper.d.ts +1 -0
  92. package/dist/src/media/capture-helper.js +16 -0
  93. package/dist/src/media/capture.d.ts +32 -0
  94. package/dist/src/media/capture.js +193 -0
  95. package/dist/src/media/handler.d.ts +22 -0
  96. package/dist/src/media/handler.js +82 -0
  97. package/dist/src/media/transcribe.d.ts +29 -0
  98. package/dist/src/media/transcribe.js +93 -0
  99. package/dist/src/metadata/article.d.ts +5 -0
  100. package/dist/src/metadata/article.js +51 -0
  101. package/dist/src/models/artifacts.d.ts +36 -0
  102. package/dist/src/models/artifacts.js +274 -0
  103. package/dist/src/models/catalog.d.ts +35 -0
  104. package/dist/src/models/catalog.js +71 -0
  105. package/dist/src/models/download.d.ts +9 -0
  106. package/dist/src/models/download.js +147 -0
  107. package/dist/src/models/runtime.d.ts +24 -0
  108. package/dist/src/models/runtime.js +37 -0
  109. package/dist/src/pdf/capture-helper.d.ts +1 -0
  110. package/dist/src/pdf/capture-helper.js +12 -0
  111. package/dist/src/pdf/capture.d.ts +29 -0
  112. package/dist/src/pdf/capture.js +156 -0
  113. package/dist/src/pdf/errors.d.ts +2 -0
  114. package/dist/src/pdf/errors.js +13 -0
  115. package/dist/src/pdf/handler.d.ts +24 -0
  116. package/dist/src/pdf/handler.js +84 -0
  117. package/dist/src/pdf/index.d.ts +4 -0
  118. package/dist/src/pdf/index.js +4 -0
  119. package/dist/src/pdf/input.d.ts +2 -0
  120. package/dist/src/pdf/input.js +46 -0
  121. package/dist/src/pdf/normalize.d.ts +10 -0
  122. package/dist/src/pdf/normalize.js +64 -0
  123. package/dist/src/pdf/source-identity.d.ts +4 -0
  124. package/dist/src/pdf/source-identity.js +12 -0
  125. package/dist/src/pdf/source.d.ts +6 -0
  126. package/dist/src/pdf/source.js +22 -0
  127. package/dist/src/pdf/verify-helper.d.ts +1 -0
  128. package/dist/src/pdf/verify-helper.js +17 -0
  129. package/dist/src/portability/backup.d.ts +23 -0
  130. package/dist/src/portability/backup.js +138 -0
  131. package/dist/src/portability/database.d.ts +14 -0
  132. package/dist/src/portability/database.js +78 -0
  133. package/dist/src/portability/export.d.ts +8 -0
  134. package/dist/src/portability/export.js +36 -0
  135. package/dist/src/portability/files.d.ts +33 -0
  136. package/dist/src/portability/files.js +78 -0
  137. package/dist/src/portability/gc.d.ts +21 -0
  138. package/dist/src/portability/gc.js +66 -0
  139. package/dist/src/search/extension.d.ts +3 -0
  140. package/dist/src/search/extension.js +17 -0
  141. package/dist/src/search/hybrid.d.ts +39 -0
  142. package/dist/src/search/hybrid.js +118 -0
  143. package/dist/src/search/query.d.ts +12 -0
  144. package/dist/src/search/query.js +47 -0
  145. package/dist/src/search/vector.d.ts +21 -0
  146. package/dist/src/search/vector.js +21 -0
  147. package/dist/src/service/launch-agent.d.ts +47 -0
  148. package/dist/src/service/launch-agent.js +179 -0
  149. package/dist/src/service/lifetime.d.ts +7 -0
  150. package/dist/src/service/lifetime.js +73 -0
  151. package/dist/src/service/loop.d.ts +21 -0
  152. package/dist/src/service/loop.js +94 -0
  153. package/dist/src/store/files.d.ts +12 -0
  154. package/dist/src/store/files.js +101 -0
  155. package/dist/src/store/index.d.ts +118 -0
  156. package/dist/src/store/index.js +727 -0
  157. package/dist/src/store/types.d.ts +90 -0
  158. package/dist/src/store/types.js +8 -0
  159. package/dist/src/worker/basic.d.ts +10 -0
  160. package/dist/src/worker/basic.js +27 -0
  161. package/dist/src/worker/custody.d.ts +13 -0
  162. package/dist/src/worker/custody.js +185 -0
  163. package/dist/src/worker/engine.d.ts +28 -0
  164. package/dist/src/worker/engine.js +78 -0
  165. package/dist/src/worker/identity.d.ts +7 -0
  166. package/dist/src/worker/identity.js +27 -0
  167. package/dist/src/worker/journal.d.ts +5 -0
  168. package/dist/src/worker/journal.js +30 -0
  169. package/dist/src/worker/supervisor-child.d.ts +1 -0
  170. package/dist/src/worker/supervisor-child.js +33 -0
  171. package/dist/src/worker/supervisor.d.ts +3 -0
  172. package/dist/src/worker/supervisor.js +47 -0
  173. package/native/runtime-manifest.json +40 -0
  174. package/package.json +31 -5
@@ -0,0 +1,123 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { spaceFingerprint, encodeEmbeddingRequest } from '../inference/embedding/index.js';
3
+ export class IndexError extends Error {
4
+ code;
5
+ constructor(code) {
6
+ super(code);
7
+ this.code = code;
8
+ }
9
+ }
10
+ const fail = (code = 'invalid-index') => { throw new IndexError(code); };
11
+ const hash = (value) => createHash('sha256').update(value).digest('hex');
12
+ function atomic(db, fn) { if (db.isTransaction)
13
+ return fn(); db.exec('BEGIN IMMEDIATE'); try {
14
+ const value = fn();
15
+ db.exec('COMMIT');
16
+ return value;
17
+ }
18
+ catch (e) {
19
+ db.exec('ROLLBACK');
20
+ throw e;
21
+ } }
22
+ function epoch(db) { return Number(db.prepare('SELECT revision_epoch FROM library_info WHERE id=1').get()?.revision_epoch); }
23
+ function revisions(db) { return db.prepare('SELECT id AS itemId,current_revision AS revisionId FROM items WHERE deleted_at IS NULL AND current_revision IS NOT NULL ORDER BY id').all(); }
24
+ /** Unicode-safe chunks leave room for the fixed document prompt within 8192 UTF-8 bytes. */
25
+ export function chunkDocument(text) { const chunks = []; let part = '', size = 0; for (const char of text) {
26
+ const bytes = Buffer.byteLength(char);
27
+ if (size + bytes > 7600) {
28
+ if (part.trim())
29
+ chunks.push(part);
30
+ part = '';
31
+ size = 0;
32
+ }
33
+ part += char;
34
+ size += bytes;
35
+ } if (part.trim())
36
+ chunks.push(part); return chunks; }
37
+ function snapshot(db) {
38
+ const current = revisions(db), inputs = [];
39
+ for (const r of current) {
40
+ const docs = db.prepare('SELECT id,markdown FROM representations WHERE revision_id=? AND markdown IS NOT NULL ORDER BY id').all(r.revisionId);
41
+ for (const doc of docs) {
42
+ const chunks = chunkDocument(String(doc.markdown));
43
+ for (let ordinal = 0; ordinal < chunks.length; ordinal++) {
44
+ const text = chunks[ordinal], derivationHash = hash(JSON.stringify(['sponge-chunk-v1', String(doc.id), ordinal, text]));
45
+ inputs.push({ ...r, sourceId: JSON.stringify([r.revisionId, 'document', String(doc.id), ordinal]), kind: 'document', representationId: String(doc.id), ordinal, derivationHash });
46
+ }
47
+ }
48
+ const images = db.prepare("SELECT DISTINCT a.sha256 FROM revision_assets ra JOIN assets a ON a.sha256=ra.sha256 WHERE ra.revision_id=? AND a.media_type LIKE 'image/%' ORDER BY a.sha256").all(r.revisionId);
49
+ for (const image of images) {
50
+ const assetSha256 = String(image.sha256);
51
+ inputs.push({ ...r, sourceId: JSON.stringify([r.revisionId, 'image', assetSha256]), kind: 'image', assetSha256, derivationHash: hash(JSON.stringify(['sponge-image-source-v1', assetSha256])) });
52
+ }
53
+ }
54
+ return { version: 1, revisions: current, inputs };
55
+ }
56
+ function generation(db, id) { const row = db.prepare('SELECT g.*,s.dimensions FROM index_generations g JOIN embedding_spaces s ON s.fingerprint=g.fingerprint WHERE g.id=?').get(id); if (!row)
57
+ return fail(); const coverage = JSON.parse(String(row.coverage_json)); const profile = JSON.parse(String(row.profile_json)); if (coverage.version !== 1 || !Array.isArray(coverage.inputs) || !Array.isArray(coverage.revisions) || typeof profile.profileId !== 'string')
58
+ fail(); return { row, coverage, profile }; }
59
+ /** Identity must already be the verified embedder fingerprint. Existing identities are immutable. */
60
+ export function createGeneration(db, options) {
61
+ if (!options.profileId || options.profileId.length > 128 || !/^[a-f0-9]{64}$/.test(options.fingerprint) || !Number.isSafeInteger(options.dimensions) || options.dimensions < 1 || options.dimensions > 4096)
62
+ fail();
63
+ if (spaceFingerprint(options.identity) !== options.fingerprint || options.identity.dimensions !== options.dimensions)
64
+ fail();
65
+ return atomic(db, () => { const identity = JSON.stringify(Object.fromEntries(Object.keys(options.identity).sort().map(k => [k, options.identity[k]]))); if (!identity || Buffer.byteLength(identity) > 16384)
66
+ fail(); const existing = db.prepare('SELECT * FROM embedding_spaces WHERE fingerprint=?').get(options.fingerprint); if (existing && (existing.identity_json !== identity || Number(existing.dimensions) !== options.dimensions || existing.normalization !== 'unit-l2' || existing.metric !== 'cosine'))
67
+ fail(); if (!existing)
68
+ db.prepare('INSERT INTO embedding_spaces VALUES(?,?,?,?,?)').run(options.fingerprint, identity, options.dimensions, 'unit-l2', 'cosine'); const id = randomUUID(); db.prepare('INSERT INTO index_generations VALUES(?,?,?,?,?,?)').run(id, options.fingerprint, JSON.stringify({ profileId: options.profileId }), 'building', epoch(db), JSON.stringify(snapshot(db))); return generationStatus(db, id); });
69
+ }
70
+ export function generationStatus(db, id) { const { row, coverage, profile } = generation(db, id); return { id, fingerprint: String(row.fingerprint), state: String(row.state), revisionEpoch: Number(row.revision_epoch), expected: coverage.inputs.length, completed: Number(db.prepare('SELECT count(*) AS n FROM vectors WHERE generation_id=?').get(id)?.n), profileId: profile.profileId }; }
71
+ export function pendingInputs(db, id, limit = 16) { if (!Number.isSafeInteger(limit) || limit < 1 || limit > 256)
72
+ fail(); const { row, coverage } = generation(db, id); if (row.state === 'cancelled')
73
+ fail('cancelled-index'); if (row.state !== 'building')
74
+ return []; const done = new Set(db.prepare('SELECT source_id FROM vectors WHERE generation_id=? AND input_hash IS NOT NULL').all(id).map(r => String(r.source_id))); return coverage.inputs.filter(i => !done.has(i.sourceId)).slice(0, limit).map(input => { if (input.kind === 'image')
75
+ return input; const doc = db.prepare('SELECT markdown FROM representations WHERE id=? AND revision_id=?').get(input.representationId, input.revisionId); if (!doc || typeof doc.markdown !== 'string')
76
+ return fail(); const text = chunkDocument(doc.markdown)[input.ordinal]; if (text === undefined || hash(JSON.stringify(['sponge-chunk-v1', input.representationId, input.ordinal, text])) !== input.derivationHash)
77
+ return fail(); return { ...input, text }; }); }
78
+ export function writeBatch(db, id, fingerprint, batch) {
79
+ if (!batch.length || batch.length > 256)
80
+ fail();
81
+ atomic(db, () => { const { row, coverage } = generation(db, id); if (row.state === 'cancelled')
82
+ fail('cancelled-index'); if (row.state !== 'building' || row.fingerprint !== fingerprint)
83
+ fail(); const expected = new Map(coverage.inputs.map(i => [i.sourceId, i])); const seen = new Set(); for (const result of batch) {
84
+ const input = expected.get(result.sourceId);
85
+ if (!input)
86
+ return fail();
87
+ if (seen.has(result.sourceId) || input.revisionId !== result.revisionId || input.derivationHash !== result.derivationHash || !/^[a-f0-9]{64}$/.test(result.inputHash) || result.vector.length !== Number(row.dimensions) || result.vector.some(n => !Number.isFinite(n)) || Math.abs(Math.hypot(...result.vector) - 1) > .001)
88
+ fail();
89
+ if (input.kind === 'document') {
90
+ const doc = db.prepare('SELECT markdown FROM representations WHERE id=? AND revision_id=?').get(input.representationId, input.revisionId);
91
+ if (!doc || typeof doc.markdown !== 'string')
92
+ return fail();
93
+ const text = chunkDocument(String(doc.markdown))[input.ordinal];
94
+ if (text === undefined || encodeEmbeddingRequest([{ kind: 'document', text }]).inputHashes[0] !== result.inputHash)
95
+ fail();
96
+ }
97
+ seen.add(result.sourceId);
98
+ const bytes = Buffer.alloc(result.vector.length * 4);
99
+ result.vector.forEach((n, i) => bytes.writeFloatLE(n, i * 4));
100
+ const prior = db.prepare('SELECT vector,input_hash FROM vectors WHERE generation_id=? AND source_id=?').get(id, input.sourceId);
101
+ if (prior && prior.input_hash !== null) {
102
+ if (prior.input_hash !== result.inputHash || !bytes.equals(Buffer.from(prior.vector)))
103
+ fail();
104
+ continue;
105
+ }
106
+ db.prepare('INSERT INTO vectors(generation_id,source_id,revision_id,asset_sha256,derivation_hash,vector,input_hash) VALUES(?,?,?,?,?,?,?) ON CONFLICT(generation_id,source_id) DO UPDATE SET vector=excluded.vector,input_hash=excluded.input_hash').run(id, input.sourceId, input.revisionId, input.assetSha256 ?? null, input.derivationHash, bytes, result.inputHash);
107
+ } });
108
+ }
109
+ export function activateGeneration(db, id) {
110
+ return atomic(db, () => { const { row, coverage, profile } = generation(db, id); if (!['building', 'active', 'retired'].includes(String(row.state)))
111
+ fail('cancelled-index'); if (Number(row.revision_epoch) !== epoch(db) || JSON.stringify(coverage.revisions) !== JSON.stringify(revisions(db)))
112
+ fail('stale-index'); const actual = db.prepare('SELECT source_id,revision_id,derivation_hash,input_hash FROM vectors WHERE generation_id=?').all(id); const expected = new Map(coverage.inputs.map(i => [i.sourceId, i])); if (actual.length !== expected.size || actual.some(v => { const input = expected.get(String(v.source_id)); return !input || input.revisionId !== v.revision_id || input.derivationHash !== v.derivation_hash || typeof v.input_hash !== 'string' || !/^[a-f0-9]{64}$/.test(v.input_hash); }))
113
+ fail('incomplete-index'); const prior = db.prepare('SELECT generation_id FROM active_indexes WHERE profile_id=?').get(profile.profileId); if (prior && prior.generation_id !== id)
114
+ db.prepare("UPDATE index_generations SET state='retired' WHERE id=?").run(prior.generation_id); db.prepare('INSERT INTO active_indexes VALUES(?,?) ON CONFLICT(profile_id) DO UPDATE SET generation_id=excluded.generation_id').run(profile.profileId, id); db.prepare("UPDATE index_generations SET state='active' WHERE id=?").run(id); return generationStatus(db, id); });
115
+ }
116
+ export function cancelGeneration(db, id) { atomic(db, () => { const { row } = generation(db, id); if (row.state === 'active' || row.state === 'retired')
117
+ fail(); db.prepare("UPDATE index_generations SET state='cancelled' WHERE id=?").run(id); }); }
118
+ /** Read-side join removes deleted/refreshed revisions even when an old generation stays active. */
119
+ export function activeVectors(db, profileId) { const active = db.prepare('SELECT generation_id FROM active_indexes WHERE profile_id=?').get(profileId); if (!active)
120
+ return []; const id = String(active.generation_id), { row, coverage } = generation(db, id), inputs = new Map(coverage.inputs.map(i => [i.sourceId, i])); return db.prepare('SELECT v.* FROM vectors v JOIN revisions r ON r.id=v.revision_id JOIN items i ON i.id=r.item_id WHERE v.generation_id=? AND i.deleted_at IS NULL AND i.current_revision=v.revision_id ORDER BY v.source_id').all(id).map(v => { const bytes = Buffer.from(v.vector), source = inputs.get(String(v.source_id)); if (!source || bytes.length !== Number(row.dimensions) * 4)
121
+ fail(); const vector = Array.from({ length: Number(row.dimensions) }, (_, i) => bytes.readFloatLE(i * 4)); if (vector.some(n => !Number.isFinite(n)))
122
+ fail(); return { fingerprint: String(row.fingerprint), generationId: id, source: source, vector }; }); }
123
+ export const rollbackGeneration = activateGeneration;
@@ -0,0 +1,14 @@
1
+ import type { DatabaseSync } from 'node:sqlite';
2
+ import { type OwnerToken, type AttemptToken, type OwnedAttempt } from '../store/types.js';
3
+ export declare function submitIndexJob(db: DatabaseSync, generationId: string): string;
4
+ export declare function listIndexJobs(db: DatabaseSync): Record<string, import("node:sqlite").SQLOutputValue>[];
5
+ export declare function indexJob(db: DatabaseSync, id: string): Record<string, import("node:sqlite").SQLOutputValue> | undefined;
6
+ export declare function claimIndexJob(db: DatabaseSync, token: OwnerToken, id?: string): AttemptToken | null;
7
+ export declare function withIndexAttempt<T>(db: DatabaseSync, token: AttemptToken, fn: (generationId: string) => T): T;
8
+ export declare function checkpointIndexAttempt(db: DatabaseSync, token: AttemptToken, stage: string): void;
9
+ export declare function finishIndexAttempt(db: DatabaseSync, token: AttemptToken, reason?: string): void;
10
+ export declare function settleIndexAttempt(db: DatabaseSync, token: AttemptToken): void;
11
+ export declare function cancelIndexJob(db: DatabaseSync, id: string): void;
12
+ export declare function retryIndexJob(db: DatabaseSync, id: string): void;
13
+ export declare function indexOwnerAttempts(db: DatabaseSync, token: OwnerToken): OwnedAttempt[];
14
+ export declare function recoverIndexAttempt(db: DatabaseSync, token: AttemptToken): void;
@@ -0,0 +1,49 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { StoreError } from '../store/types.js';
3
+ const now = () => new Date().toISOString();
4
+ function atomic(db, fn) { if (db.isTransaction)
5
+ return fn(); db.exec('BEGIN IMMEDIATE'); try {
6
+ const result = fn();
7
+ db.exec('COMMIT');
8
+ return result;
9
+ }
10
+ catch (error) {
11
+ db.exec('ROLLBACK');
12
+ throw error;
13
+ } }
14
+ function owner(db, token) { const row = db.prepare('SELECT * FROM worker_owner WHERE id=1').get(); if (row?.incarnation !== token.ownerIncarnation || row.fence !== token.fence)
15
+ throw new StoreError('owner-busy'); }
16
+ function attempt(db, token, active = true) { owner(db, token); const row = db.prepare('SELECT a.*,j.state,j.cancel_requested,j.generation_id FROM index_attempts a JOIN index_jobs j ON j.id=a.job_id WHERE a.id=? AND a.job_id=?').get(token.attemptId, token.jobId); if (!row || row.owner_incarnation !== token.ownerIncarnation || row.fence !== token.fence || (active && (row.finished_at !== null || row.state !== 'running')))
17
+ throw new StoreError('owner-busy'); if (active && row.cancel_requested === 1)
18
+ throw new StoreError('cancelled'); return row; }
19
+ export function submitIndexJob(db, generationId) { return atomic(db, () => { const generation = db.prepare('SELECT state FROM index_generations WHERE id=?').get(generationId); if (!generation || generation.state !== 'building')
20
+ throw new StoreError('unavailable'); const prior = db.prepare('SELECT id FROM index_jobs WHERE generation_id=?').get(generationId); if (prior)
21
+ return String(prior.id); const id = randomUUID(); db.prepare("INSERT INTO index_jobs(id,generation_id,state,created_at) VALUES(?,?,'queued',?)").run(id, generationId, now()); return id; }); }
22
+ export function listIndexJobs(db) { return db.prepare("SELECT *, 'index' AS operation, NULL AS item_id FROM index_jobs ORDER BY created_at,id").all(); }
23
+ export function indexJob(db, id) { return db.prepare('SELECT * FROM index_jobs WHERE id=?').get(id); }
24
+ export function claimIndexJob(db, token, id) { return atomic(db, () => { owner(db, token); const job = id ? indexJob(db, id) : db.prepare("SELECT * FROM index_jobs WHERE state='queued' AND cancel_requested=0 ORDER BY created_at,id LIMIT 1").get(); if (!job) {
25
+ if (id)
26
+ throw new StoreError('unavailable');
27
+ return null;
28
+ } if (job.state !== 'queued' || job.cancel_requested)
29
+ throw new StoreError('unavailable'); if (db.prepare("SELECT (SELECT count(*) FROM attempts WHERE cleanup='pending')+(SELECT count(*) FROM index_attempts WHERE cleanup='pending') AS n").get()?.n)
30
+ throw new StoreError('owner-busy'); const attemptId = randomUUID(); db.prepare("UPDATE index_jobs SET state='running',attempt_count=attempt_count+1 WHERE id=?").run(job.id); db.prepare("INSERT INTO index_attempts VALUES(?,?,?,?,'acquiring','pending',?,NULL)").run(attemptId, job.id, token.ownerIncarnation, token.fence, now()); return { ...token, jobId: String(job.id), attemptId }; }); }
31
+ export function withIndexAttempt(db, token, fn) { return atomic(db, () => { const row = attempt(db, token); return fn(String(row.generation_id)); }); }
32
+ export function checkpointIndexAttempt(db, token, stage) { const stages = ['acquiring', 'extracting', 'indexing', 'promoting', 'cleanup']; atomic(db, () => { const row = attempt(db, token); if (!stages.includes(stage) || stages.indexOf(stage) < stages.indexOf(String(row.stage)))
33
+ throw new StoreError('invalid-input'); db.prepare('UPDATE index_attempts SET stage=? WHERE id=?').run(stage, token.attemptId); }); }
34
+ export function finishIndexAttempt(db, token, reason) { atomic(db, () => { const row = attempt(db, token, false); if (row.finished_at !== null || row.state !== 'running')
35
+ throw new StoreError('owner-busy'); if (reason !== undefined && !/^[a-z][a-z-]{0,63}$/.test(reason))
36
+ throw new StoreError('invalid-input'); if (!reason && row.cancel_requested)
37
+ throw new StoreError('cancelled'); db.prepare('UPDATE index_jobs SET state=?,reason=? WHERE id=?').run(row.cancel_requested ? 'cancelled' : reason ? 'failed' : 'succeeded', reason ?? null, token.jobId); db.prepare("UPDATE index_attempts SET stage='cleanup',finished_at=? WHERE id=?").run(now(), token.attemptId); }); }
38
+ export function settleIndexAttempt(db, token) { atomic(db, () => { const row = attempt(db, token, false); if (row.finished_at === null)
39
+ throw new StoreError('owner-busy'); db.prepare("UPDATE index_attempts SET cleanup='settled',stage='settled' WHERE id=?").run(token.attemptId); }); }
40
+ export function cancelIndexJob(db, id) { atomic(db, () => { const row = indexJob(db, id); if (!row)
41
+ throw new StoreError('unavailable'); if (row.state === 'succeeded')
42
+ return; db.prepare("UPDATE index_jobs SET cancel_requested=1,state=CASE WHEN state IN ('queued','failed','cancelled') THEN 'cancelled' ELSE state END WHERE id=?").run(id); }); }
43
+ export function retryIndexJob(db, id) { atomic(db, () => { const row = indexJob(db, id); if (!row || !['failed', 'cancelled'].includes(String(row.state)))
44
+ throw new StoreError('unavailable'); if (db.prepare("SELECT count(*) AS n FROM index_attempts WHERE job_id=? AND cleanup='pending'").get(id)?.n)
45
+ throw new StoreError('owner-busy'); const generation = db.prepare('SELECT state,revision_epoch FROM index_generations WHERE id=?').get(row.generation_id); if (!generation || !['building', 'active'].includes(String(generation.state)) || generation.revision_epoch !== db.prepare('SELECT revision_epoch FROM library_info WHERE id=1').get()?.revision_epoch)
46
+ throw new StoreError('unavailable'); db.prepare("UPDATE index_jobs SET state='queued',cancel_requested=0,reason=NULL WHERE id=?").run(id); }); }
47
+ export function indexOwnerAttempts(db, token) { owner(db, token); return db.prepare('SELECT a.*,j.state,j.cancel_requested FROM index_attempts a JOIN index_jobs j ON j.id=a.job_id WHERE a.owner_incarnation=? AND a.fence=? ORDER BY a.created_at,a.id').all(token.ownerIncarnation, token.fence).map(a => ({ ...token, kind: 'index', attemptId: String(a.id), jobId: String(a.job_id), stage: String(a.stage), cleanup: a.cleanup, finished: a.finished_at !== null, jobState: String(a.state), cancelRequested: a.cancel_requested === 1 })); }
48
+ export function recoverIndexAttempt(db, token) { const row = attempt(db, token, false); db.prepare("UPDATE index_attempts SET cleanup='settled',stage='settled',finished_at=COALESCE(finished_at,?) WHERE id=?").run(now(), token.attemptId); if (row.finished_at === null)
49
+ db.prepare("UPDATE index_jobs SET state=CASE WHEN cancel_requested=1 THEN 'cancelled' ELSE 'failed' END,reason='unavailable' WHERE id=?").run(token.jobId); }
@@ -0,0 +1,23 @@
1
+ import type { LibraryStore, AttemptToken } from '../store/index.js';
2
+ import { embedBatch, type EmbeddingRuntime } from '../inference/embedding/index.js';
3
+ import { deriveEmbeddingImage } from './image.js';
4
+ import type { ErrorEnvelope } from '../../contracts/index.js';
5
+ export type IndexRunResult = ({
6
+ ok: true;
7
+ jobId: string;
8
+ generationId: string;
9
+ } | ErrorEnvelope) & {
10
+ cleanup: 'settled' | 'pending';
11
+ };
12
+ export interface IndexRunOptions {
13
+ runtime: EmbeddingRuntime;
14
+ jobId?: string;
15
+ signal?: AbortSignal;
16
+ embed?: typeof embedBatch;
17
+ deriveImage?: typeof deriveEmbeddingImage;
18
+ onResult?: (result: IndexRunResult) => void;
19
+ onCheckpoint?: (attempt: AttemptToken, stage: string) => Promise<void>;
20
+ onBatch?: (attempt: AttemptToken, completed: number) => Promise<void>;
21
+ }
22
+ /** Same durable worker lock/owner/supervisor as acquisition, with sequential bounded batches. */
23
+ export declare function drainIndexQueue(store: LibraryStore, options: IndexRunOptions): Promise<IndexRunResult[]>;
@@ -0,0 +1,114 @@
1
+ import { join } from 'node:path';
2
+ import { StoreError } from '../store/index.js';
3
+ import { acquireWorkerCustody, proveAttemptCleanup } from '../worker/custody.js';
4
+ import { supervisedRunner } from '../worker/supervisor.js';
5
+ import { directory, writeJournal } from '../worker/journal.js';
6
+ import { embedBatch, embeddingFingerprint, encodeEmbeddingRequest } from '../inference/embedding/index.js';
7
+ import { deriveEmbeddingImage } from './image.js';
8
+ import { failure } from '../acquisition/errors.js';
9
+ /** Same durable worker lock/owner/supervisor as acquisition, with sequential bounded batches. */
10
+ export async function drainIndexQueue(store, options) {
11
+ const custody = await acquireWorkerCustody(store.root);
12
+ let owner, settled = true;
13
+ const results = [];
14
+ try {
15
+ owner = store.acquireOwner(custody.incarnation);
16
+ while (!options.signal?.aborted) {
17
+ const attempt = store.claimIndexJob(owner, options.jobId);
18
+ if (!attempt)
19
+ break;
20
+ settled = false;
21
+ const attemptDirectory = await directory(join(custody.directory, 'attempts', attempt.attemptId)), scratchRoot = await directory(join(store.root, 'staging', attempt.attemptId));
22
+ await writeJournal(join(scratchRoot, 'ownership.json'), { schemaVersion: 1, requestId: attempt.attemptId, ownerIncarnation: attempt.ownerIncarnation });
23
+ const checkpoint = async (stage) => { store.checkpointIndexAttempt(attempt, stage); await writeJournal(join(attemptDirectory, 'checkpoint.json'), { schemaVersion: 1, ...attempt, stage }); await options.onCheckpoint?.(attempt, stage); };
24
+ const controller = new AbortController(), cancel = () => { store.requestCancellation(attempt.jobId); controller.abort(); };
25
+ options.signal?.addEventListener('abort', cancel, { once: true });
26
+ const poll = setInterval(() => { try {
27
+ if (store.jobCancellationRequested(attempt.jobId))
28
+ controller.abort();
29
+ }
30
+ catch {
31
+ controller.abort();
32
+ } }, 100);
33
+ const runProcess = supervisedRunner(custody.directory, custody.incarnation, attempt.attemptId);
34
+ let result;
35
+ try {
36
+ if (options.signal?.aborted)
37
+ cancel();
38
+ await checkpoint('acquiring');
39
+ const job = store.indexJob(attempt.jobId);
40
+ if (!job)
41
+ throw new StoreError('unavailable');
42
+ const generationId = String(job.generation_id), status = store.indexStatus(generationId);
43
+ if (embeddingFingerprint(options.runtime.helperSha256) !== status.fingerprint)
44
+ throw new StoreError('integrity');
45
+ await checkpoint('indexing');
46
+ for (;;) {
47
+ if (controller.signal.aborted || store.jobCancellationRequested(attempt.jobId))
48
+ throw new StoreError('cancelled');
49
+ if (store.revisionEpoch !== status.revisionEpoch)
50
+ throw Object.assign(Error('stale-index'), { code: 'stale-index' });
51
+ const inputs = store.indexInputs(generationId, 16);
52
+ if (!inputs.length)
53
+ break;
54
+ const batch = [];
55
+ for (const input of inputs) {
56
+ if (input.kind === 'document') {
57
+ batch.push({ kind: 'document', text: input.text });
58
+ continue;
59
+ }
60
+ const item = store.read(input.itemId), asset = item?.assets.find(a => a.sha256 === input.assetSha256);
61
+ if (!item || item.revisionId !== input.revisionId || !asset)
62
+ throw new StoreError('unavailable');
63
+ const derived = await (options.deriveImage ?? deriveEmbeddingImage)(store.readBlob(asset), { scratchRoot, runProcess, signal: controller.signal });
64
+ batch.push({ kind: 'image', png: derived.png });
65
+ }
66
+ const expected = encodeEmbeddingRequest(batch).inputHashes, embedded = await (options.embed ?? embedBatch)(batch, { runtime: options.runtime, scratchRoot, runProcess, signal: controller.signal });
67
+ if (embedded.fingerprint !== status.fingerprint || embedded.inputHashes.length !== expected.length || embedded.inputHashes.some((hash, i) => hash !== expected[i]))
68
+ throw new StoreError('integrity');
69
+ store.writeIndexAttemptBatch(attempt, embedded.fingerprint, inputs.map((input, i) => ({ ...input, inputHash: expected[i], vector: embedded.vectors[i] })));
70
+ await options.onBatch?.(attempt, store.indexStatus(generationId).completed);
71
+ }
72
+ await checkpoint('promoting');
73
+ store.completeIndexAttempt(attempt);
74
+ result = { ok: true, jobId: attempt.jobId, generationId, cleanup: 'settled' };
75
+ }
76
+ catch (error) {
77
+ const raw = error.code, reason = raw && /^[a-z][a-z-]{0,63}$/.test(raw) ? raw : 'unclassified';
78
+ store.failIndexAttempt(attempt, reason);
79
+ const known = ['integrity', 'unavailable', 'unsupported', 'cancelled', 'limit-exceeded', 'cleanup-required', 'runtime-missing', 'owner-busy'];
80
+ result = { ...failure(attempt.attemptId, known.includes(reason) ? reason : 'unavailable', 'inference'), cleanup: 'pending' };
81
+ }
82
+ finally {
83
+ clearInterval(poll);
84
+ options.signal?.removeEventListener('abort', cancel);
85
+ }
86
+ settled = !(result.ok === false && result.code === 'cleanup-required') && await proveAttemptCleanup(custody.directory, custody.incarnation, attempt.attemptId);
87
+ if (settled) {
88
+ store.settleIndexAttempt(attempt);
89
+ result.cleanup = 'settled';
90
+ }
91
+ else
92
+ result.cleanup = 'pending';
93
+ results.push(result);
94
+ options.onResult?.(result);
95
+ if (!settled || options.jobId)
96
+ break;
97
+ }
98
+ if (!settled)
99
+ throw Object.assign(Error('cleanup-required'), { code: 'cleanup-required' });
100
+ return results;
101
+ }
102
+ finally {
103
+ if (owner && settled) {
104
+ try {
105
+ store.releaseOwner(owner);
106
+ }
107
+ catch {
108
+ settled = false;
109
+ }
110
+ }
111
+ if (settled)
112
+ await custody.release();
113
+ }
114
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,16 @@
1
+ /** Private one-request transport. Outer ProcessRunner owns the whole inherited group. */
2
+ import { readFile } from 'node:fs/promises';
3
+ import { spawn } from 'node:child_process';
4
+ try {
5
+ const config = JSON.parse(await readFile(process.argv[2], 'utf8'));
6
+ const child = spawn('/usr/bin/sandbox-exec', ['-p', '(version 1) (allow default) (deny network*)', config.helper, '--model', config.model, '--once'], { stdio: ['pipe', 'pipe', 'pipe'], detached: false });
7
+ child.stdout.pipe(process.stdout);
8
+ child.stderr.pipe(process.stderr);
9
+ child.stdin.on('error', () => { });
10
+ child.stdin.end(JSON.stringify(config.request) + '\n');
11
+ child.once('error', () => { process.exitCode = 1; });
12
+ child.once('close', code => { process.exitCode = code ?? 1; });
13
+ }
14
+ catch {
15
+ process.exitCode = 1;
16
+ }
@@ -0,0 +1,23 @@
1
+ import type { Transcript } from '../../../contracts/index.js';
2
+ export declare const asrIdentity: {
3
+ readonly engine: "Cactus-Compute/needle3@93e88ede867e4311c39151285e74df513e4b835b";
4
+ readonly model: "Cactus-Compute/whistle@ca5287601bef25af26dcf2e1b2bdc0843a7c19e5";
5
+ readonly modelSha256: "b6e02f048568ac5d01a2042556c658061e699acbc0aa2a1439f52f3d461dffeb";
6
+ readonly modelBytes: 16919407;
7
+ };
8
+ export declare const asrLimits: {
9
+ readonly outputBytes: number;
10
+ readonly frameBytes: number;
11
+ readonly words: 30000;
12
+ readonly maximumDurationMs: 3600000;
13
+ readonly windowDurationMs: 60000;
14
+ readonly overlapMs: 4000;
15
+ readonly deadlineMs: 180000;
16
+ };
17
+ export interface SourceTranscript extends Transcript {
18
+ sourceSha256: string;
19
+ timing: 'source-captions' | 'decoded-audio-map';
20
+ durationMs: number;
21
+ }
22
+ /** Decode only correlated native events; diagnostics never become error classifications. */
23
+ export declare function parseAsrOutput(stdout: string, requestId: string, sourceSha256: string, helperSha256: string): SourceTranscript;
@@ -0,0 +1,60 @@
1
+ import { createHash } from 'node:crypto';
2
+ export const asrIdentity = { engine: 'Cactus-Compute/needle3@93e88ede867e4311c39151285e74df513e4b835b', model: 'Cactus-Compute/whistle@ca5287601bef25af26dcf2e1b2bdc0843a7c19e5', modelSha256: 'b6e02f048568ac5d01a2042556c658061e699acbc0aa2a1439f52f3d461dffeb', modelBytes: 16919407 };
3
+ export const asrLimits = { outputBytes: 4 * 1024 ** 2, frameBytes: 4 * 1024 ** 2, words: 30000, maximumDurationMs: 3600000, windowDurationMs: 60000, overlapMs: 4000, deadlineMs: 180000 };
4
+ const fail = () => { throw Error('integrity'); };
5
+ const obj = (v) => v && typeof v === 'object' && !Array.isArray(v) ? v : fail();
6
+ const finite = (v, min = 0, max = Number.MAX_SAFE_INTEGER) => typeof v === 'number' && Number.isFinite(v) && v >= min && v <= max ? v : fail();
7
+ function time(v) { const t = obj(v); if (!Number.isSafeInteger(t.value) || !Number.isSafeInteger(t.timescale) || t.timescale <= 0)
8
+ fail(); return finite(t.value / t.timescale, 0, asrLimits.maximumDurationMs / 1000); }
9
+ /** Decode only correlated native events; diagnostics never become error classifications. */
10
+ export function parseAsrOutput(stdout, requestId, sourceSha256, helperSha256) {
11
+ if (Buffer.byteLength(stdout) > asrLimits.outputBytes || !/^[a-f0-9]{64}$/.test(sourceSha256) || !/^[a-f0-9]{64}$/.test(helperSha256))
12
+ fail();
13
+ let ready = false, result;
14
+ for (const line of stdout.split('\n')) {
15
+ if (!line.trim())
16
+ continue;
17
+ if (Buffer.byteLength(line) > asrLimits.frameBytes)
18
+ fail();
19
+ const event = obj(JSON.parse(line));
20
+ if (event.type === 'ready') {
21
+ if (ready || event.protocolVersion !== 2 || event.engine !== asrIdentity.engine || event.model !== asrIdentity.model)
22
+ fail();
23
+ ready = true;
24
+ }
25
+ else if (event.type === 'progress') {
26
+ if (!ready || result || event.id !== requestId || !['verifying', 'extracting', 'recognizing'].includes(event.stage))
27
+ fail();
28
+ }
29
+ else if (event.type === 'result') {
30
+ if (!ready || result || event.id !== requestId || event.sourceSha256 !== sourceSha256 || event.engine !== asrIdentity.engine || event.model !== asrIdentity.model)
31
+ fail();
32
+ result = event;
33
+ }
34
+ else if (event.type === 'error' || event.type === 'fatal')
35
+ throw Error('unclassified');
36
+ else
37
+ fail();
38
+ }
39
+ if (!ready || !result || result.hasAudio !== true)
40
+ return fail();
41
+ const recognition = obj(result.recognition), map = obj(result.audioMap);
42
+ if (recognition.language !== 'en' || typeof recognition.text !== 'string' || !Array.isArray(recognition.words) || recognition.words.length > asrLimits.words || map.sampleRate !== 16000 || !Number.isSafeInteger(map.sampleCount) || map.sampleCount < 0 || map.sampleCount > asrLimits.windowDurationMs * 16 || !Array.isArray(map.spans) || map.spans.length > 30000)
43
+ fail();
44
+ const durationMs = time(map.sourceDuration) * 1000;
45
+ if (durationMs <= 0)
46
+ fail();
47
+ let sampleEnd = 0, sourceEnd = 0;
48
+ const spans = map.spans.map((raw) => { const s = obj(raw), range = obj(s.source), start = time(range.start), end = time(range.end); if (!Number.isSafeInteger(s.sampleStart) || !Number.isSafeInteger(s.sampleEnd) || s.sampleStart !== sampleEnd || s.sampleEnd <= s.sampleStart || s.sampleEnd > map.sampleCount || start < sourceEnd || end <= start || end * 1000 > durationMs + .1 || !['audio', 'silence'].includes(s.kind))
49
+ fail(); sampleEnd = s.sampleEnd; sourceEnd = end; return { sampleStart: s.sampleStart, sampleEnd: s.sampleEnd, start, end }; });
50
+ if (sampleEnd !== map.sampleCount)
51
+ fail();
52
+ const sourceTime = (seconds, end) => { const sample = seconds * 16000; const span = spans.find(s => sample >= s.sampleStart && (end ? sample <= s.sampleEnd : sample < s.sampleEnd)) ?? (sample === map.sampleCount ? spans.at(-1) : undefined); if (!span)
53
+ return fail(); return (span.start + (sample - span.sampleStart) / (span.sampleEnd - span.sampleStart) * (span.end - span.start)) * 1000; };
54
+ let prior = 0;
55
+ const segments = recognition.words.map((raw) => { const w = obj(raw); if (typeof w.word !== 'string' || !w.word.trim() || Buffer.byteLength(w.word) > 16384)
56
+ fail(); const start = finite(w.start, prior, map.sampleCount / 16000), end = finite(w.end, start, map.sampleCount / 16000); prior = start; return { startMs: sourceTime(start, false), endMs: sourceTime(end, true), text: w.word.trim() }; });
57
+ if (!segments.length && recognition.text.trim())
58
+ fail();
59
+ return { schemaVersion: 1, requestId, source: 'local-asr', language: 'en', modelFingerprint: createHash('sha256').update(JSON.stringify(['sponge-asr-v1', asrIdentity, helperSha256, 'decoded-audio-map-window-v2', { windowDurationMs: asrLimits.windowDurationMs, overlapMs: asrLimits.overlapMs, join: 'source-midpoint-overlap-dedupe-v1' }])).digest('hex'), segments, cleanup: 'settled', sourceSha256, timing: 'decoded-audio-map', durationMs };
60
+ }
@@ -0,0 +1,29 @@
1
+ import type { ErrorEnvelope } from '../../../contracts/index.js';
2
+ import type { ProcessRunner } from '../../worker/supervisor.js';
3
+ import { type SourceTranscript } from './protocol.js';
4
+ export interface AsrRuntime {
5
+ helper: string;
6
+ helperSha256: string;
7
+ model: string;
8
+ }
9
+ export interface AudioInput {
10
+ path: string;
11
+ sha256: string;
12
+ bytes: number;
13
+ durationMs: number;
14
+ }
15
+ export interface AsrOptions {
16
+ scratchRoot: string;
17
+ runtime?: AsrRuntime;
18
+ runProcess: ProcessRunner;
19
+ signal?: AbortSignal;
20
+ }
21
+ export type AsrOutcome = ({
22
+ ok: true;
23
+ transcript: SourceTranscript;
24
+ } | ErrorEnvelope) & {
25
+ cleanup: 'settled' | 'pending';
26
+ };
27
+ /** Portable callers can use captions; this pinned native artifact requires macOS26 arm64. */
28
+ export declare function supportsNativeAsr(platform?: NodeJS.Platform, arch?: NodeJS.Architecture, kernel?: string): boolean;
29
+ export declare function transcribeAudio(requestId: string, audio: AudioInput, language: string | undefined, options: AsrOptions): Promise<AsrOutcome>;
@@ -0,0 +1,103 @@
1
+ import { constants } from 'node:fs';
2
+ import { open, realpath, mkdtemp, writeFile, rm } from 'node:fs/promises';
3
+ import { join, isAbsolute } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { release } from 'node:os';
6
+ import { createHash } from 'node:crypto';
7
+ import { failure } from '../../acquisition/errors.js';
8
+ import { privateRoot } from '../../acquisition/files.js';
9
+ import { asrIdentity, asrLimits, parseAsrOutput } from './protocol.js';
10
+ import { audioWindows, joinWindows } from './windows.js';
11
+ async function verify(path, sha256, maximum, expected) {
12
+ if (!isAbsolute(path) || !/^[a-f0-9]{64}$/.test(sha256) || await realpath(path) !== path)
13
+ throw Error('integrity');
14
+ const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
15
+ try {
16
+ const stat = await file.stat();
17
+ if (!stat.isFile() || stat.size > maximum || expected !== undefined && stat.size !== expected)
18
+ throw Error('integrity');
19
+ const hash = createHash('sha256'), buffer = Buffer.alloc(65536);
20
+ let bytes = 0;
21
+ for (;;) {
22
+ const { bytesRead } = await file.read(buffer, 0, buffer.length, null);
23
+ if (!bytesRead)
24
+ break;
25
+ bytes += bytesRead;
26
+ if (bytes > maximum)
27
+ throw Error('limit-exceeded');
28
+ hash.update(buffer.subarray(0, bytesRead));
29
+ }
30
+ if (bytes !== stat.size || hash.digest('hex') !== sha256)
31
+ throw Error('integrity');
32
+ }
33
+ finally {
34
+ await file.close();
35
+ }
36
+ }
37
+ /** Portable callers can use captions; this pinned native artifact requires macOS26 arm64. */
38
+ export function supportsNativeAsr(platform = process.platform, arch = process.arch, kernel = release()) { return platform === 'darwin' && arch === 'arm64' && /^\d+\./.test(kernel) && Number(kernel.split('.')[0]) >= 25; }
39
+ export async function transcribeAudio(requestId, audio, language, options) {
40
+ const error = (code, cleanup = 'settled') => ({ ...failure(requestId, code, cleanup === 'pending' ? 'cleanup' : 'inference'), cleanup });
41
+ if (options.signal?.aborted)
42
+ return error('cancelled');
43
+ if (!supportsNativeAsr() || language !== 'en')
44
+ return error('unsupported');
45
+ if (!options.runtime)
46
+ return error('runtime-missing');
47
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(requestId) || !Number.isSafeInteger(audio.bytes) || audio.bytes < 1 || audio.bytes > 512 * 1024 ** 2 || !Number.isFinite(audio.durationMs) || audio.durationMs <= 0 || audio.durationMs > asrLimits.maximumDurationMs)
48
+ return error('limit-exceeded');
49
+ let scratch, cleanup = 'settled', outcome;
50
+ try {
51
+ const root = await privateRoot(options.scratchRoot), runtime = options.runtime;
52
+ await verify(audio.path, audio.sha256, 512 * 1024 ** 2, audio.bytes);
53
+ await verify(runtime.helper, runtime.helperSha256, 32 * 1024 ** 2);
54
+ await verify(runtime.model, asrIdentity.modelSha256, asrIdentity.modelBytes, asrIdentity.modelBytes);
55
+ if (options.signal?.aborted)
56
+ return error('cancelled');
57
+ scratch = await mkdtemp(join(root, 'sponge-asr-'));
58
+ const requestFile = join(scratch, 'request.json');
59
+ const source = import.meta.url.endsWith('.ts'), entry = fileURLToPath(new URL(source ? './bridge.ts' : './bridge.js', import.meta.url));
60
+ const windows = audioWindows(audio.durationMs), transcripts = [];
61
+ let totalWords = 0;
62
+ for (const window of windows) {
63
+ await writeFile(requestFile, JSON.stringify({ helper: runtime.helper, model: runtime.model, request: { id: requestId, sourcePath: audio.path, expectedSourceSha256: audio.sha256, windowStartMs: window.startMs, windowEndMs: window.endMs } }), { mode: 0o600, flag: transcripts.length ? 'w' : 'wx' });
64
+ cleanup = 'pending';
65
+ const result = await options.runProcess(process.execPath, source ? ['--import', import.meta.resolve('tsx'), entry, requestFile] : [entry, requestFile], { env: { PATH: '/usr/bin:/bin', HOME: scratch, TMPDIR: scratch, LANG: 'en_US.UTF-8' }, deadlineMs: asrLimits.deadlineMs, maxOutputBytes: asrLimits.outputBytes, signal: options.signal });
66
+ cleanup = result.cleanup;
67
+ if (cleanup === 'pending')
68
+ throw Error('cleanup-required');
69
+ if (options.signal?.aborted || result.stopped === 'cancelled')
70
+ throw Error('cancelled');
71
+ if (result.stopped)
72
+ throw Error(result.stopped);
73
+ if (result.code !== 0)
74
+ throw Error('unclassified');
75
+ const transcript = parseAsrOutput(result.stdout, requestId, audio.sha256, runtime.helperSha256);
76
+ if (Math.abs(transcript.durationMs - audio.durationMs) > 1000)
77
+ throw Error('integrity');
78
+ if (transcript.segments.some(s => s.startMs < window.startMs - .1 || s.endMs > window.endMs + .1))
79
+ throw Error('integrity');
80
+ totalWords += transcript.segments.length;
81
+ if (totalWords > asrLimits.words)
82
+ throw Error('limit-exceeded');
83
+ transcripts.push(transcript);
84
+ }
85
+ await verify(audio.path, audio.sha256, 512 * 1024 ** 2, audio.bytes);
86
+ outcome = { ok: true, transcript: joinWindows(windows, transcripts), cleanup };
87
+ }
88
+ catch (e) {
89
+ const code = e instanceof Error ? e.message : '';
90
+ outcome = error(cleanup === 'pending' ? 'cleanup-required' : code === 'integrity' ? 'integrity' : code === 'limit-exceeded' ? 'limit-exceeded' : code === 'cancelled' ? 'cancelled' : code === 'unavailable' ? 'unavailable' : 'unclassified', cleanup);
91
+ }
92
+ finally {
93
+ if (scratch && cleanup === 'settled') {
94
+ try {
95
+ await rm(scratch, { recursive: true, force: true });
96
+ }
97
+ catch {
98
+ cleanup = 'pending';
99
+ }
100
+ }
101
+ }
102
+ return cleanup === 'pending' ? error('cleanup-required', 'pending') : outcome;
103
+ }
@@ -0,0 +1,9 @@
1
+ import { type SourceTranscript } from './protocol.js';
2
+ export interface AudioWindow {
3
+ startMs: number;
4
+ endMs: number;
5
+ }
6
+ export declare function audioWindows(durationMs: number): AudioWindow[];
7
+ /** Choose one owner on each overlap midpoint using source timestamps. Repeated
8
+ * words are removed only when their time ranges overlap, never globally. */
9
+ export declare function joinWindows(windows: AudioWindow[], transcripts: SourceTranscript[]): SourceTranscript;
@@ -0,0 +1,41 @@
1
+ import { asrLimits } from './protocol.js';
2
+ export function audioWindows(durationMs) {
3
+ if (!Number.isFinite(durationMs) || durationMs <= 0 || durationMs > asrLimits.maximumDurationMs)
4
+ throw Error('limit-exceeded');
5
+ durationMs = Math.ceil(durationMs);
6
+ const result = [];
7
+ for (let startMs = 0; startMs < durationMs; startMs += asrLimits.windowDurationMs - asrLimits.overlapMs) {
8
+ const endMs = Math.min(durationMs, startMs + asrLimits.windowDurationMs);
9
+ result.push({ startMs, endMs });
10
+ if (endMs === durationMs)
11
+ break;
12
+ }
13
+ return result;
14
+ }
15
+ /** Choose one owner on each overlap midpoint using source timestamps. Repeated
16
+ * words are removed only when their time ranges overlap, never globally. */
17
+ export function joinWindows(windows, transcripts) {
18
+ if (!transcripts.length || windows.length !== transcripts.length)
19
+ throw Error('integrity');
20
+ const first = transcripts[0], segments = [];
21
+ for (let i = 0; i < windows.length; i++) {
22
+ const window = windows[i], transcript = transcripts[i];
23
+ if (transcript.sourceSha256 !== first.sourceSha256 || transcript.durationMs !== first.durationMs || transcript.modelFingerprint !== first.modelFingerprint || transcript.requestId !== first.requestId)
24
+ throw Error('integrity');
25
+ const lower = i ? (windows[i - 1].endMs + window.startMs) / 2 : 0, upper = i + 1 < windows.length ? (window.endMs + windows[i + 1].startMs) / 2 : Infinity;
26
+ for (const segment of transcript.segments) {
27
+ const center = (segment.startMs + segment.endMs) / 2;
28
+ if (center < lower || center >= upper)
29
+ continue;
30
+ const prior = segments.at(-1);
31
+ if (prior && segment.startMs < prior.endMs && segment.text.toLowerCase() === prior.text.toLowerCase()) {
32
+ prior.endMs = Math.max(prior.endMs, segment.endMs);
33
+ continue;
34
+ }
35
+ if (prior && segment.startMs < prior.startMs)
36
+ throw Error('integrity');
37
+ segments.push({ ...segment });
38
+ }
39
+ }
40
+ return { ...first, segments };
41
+ }