@hraness/sponge 0.2.6 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. package/README.md +21 -9
  2. package/bin/sponge.js +8 -7
  3. package/dist/contracts/index.js +24 -6
  4. package/dist/contracts/validate.js +106 -29
  5. package/dist/migrations/008.d.ts +3 -0
  6. package/dist/migrations/008.js +5 -0
  7. package/dist/migrations/009.d.ts +3 -0
  8. package/dist/migrations/009.js +8 -0
  9. package/dist/migrations/010.d.ts +3 -0
  10. package/dist/migrations/010.js +11 -0
  11. package/dist/migrations/index.d.ts +15 -0
  12. package/dist/migrations/index.js +39 -0
  13. package/dist/src/acquisition/capture.js +87 -28
  14. package/dist/src/acquisition/cleanup.d.ts +0 -4
  15. package/dist/src/acquisition/cleanup.js +3 -5
  16. package/dist/src/acquisition/errors.js +17 -2
  17. package/dist/src/acquisition/files.d.ts +0 -1
  18. package/dist/src/acquisition/files.js +14 -11
  19. package/dist/src/acquisition/index.d.ts +2 -3
  20. package/dist/src/acquisition/index.js +1 -2
  21. package/dist/src/acquisition/lock.js +1 -1
  22. package/dist/src/acquisition/normalize.js +57 -18
  23. package/dist/src/acquisition/pins.js +22 -4
  24. package/dist/src/acquisition/process.js +67 -29
  25. package/dist/src/acquisition/runtime.d.ts +5 -7
  26. package/dist/src/acquisition/runtime.js +129 -68
  27. package/dist/src/acquisition/verify-helper.js +10 -3
  28. package/dist/src/annotations/quote.js +40 -12
  29. package/dist/src/annotations/store.d.ts +0 -1
  30. package/dist/src/annotations/store.js +66 -14
  31. package/dist/src/app/article.d.ts +2 -1
  32. package/dist/src/app/article.js +66 -70
  33. package/dist/src/app/bundle-files.d.ts +0 -4
  34. package/dist/src/app/bundle-files.js +11 -14
  35. package/dist/src/app/markdown.js +18 -6
  36. package/dist/src/app/source.d.ts +0 -4
  37. package/dist/src/app/source.js +18 -14
  38. package/dist/src/cli/arguments.d.ts +2 -8
  39. package/dist/src/cli/arguments.js +67 -80
  40. package/dist/src/cli/commands.d.ts +42 -0
  41. package/dist/src/cli/commands.js +795 -0
  42. package/dist/src/cli/config.js +12 -9
  43. package/dist/src/cli/digest.d.ts +1 -1
  44. package/dist/src/cli/digest.js +38 -16
  45. package/dist/src/cli/import.js +25 -4
  46. package/dist/src/cli/indexing.d.ts +2 -0
  47. package/dist/src/cli/indexing.js +26 -9
  48. package/dist/src/cli/main.js +47 -386
  49. package/dist/src/cli/portability.d.ts +0 -2
  50. package/dist/src/cli/portability.js +13 -8
  51. package/dist/src/cli/queue.js +12 -8
  52. package/dist/src/cli/runtimes.d.ts +1 -3
  53. package/dist/src/cli/runtimes.js +13 -6
  54. package/dist/src/cli/terminal.js +1 -1
  55. package/dist/src/cli/worker.d.ts +7 -0
  56. package/dist/src/cli/worker.js +54 -15
  57. package/dist/src/derivations/kinds.d.ts +34 -0
  58. package/dist/src/derivations/kinds.js +14 -0
  59. package/dist/src/derivations/types.d.ts +48 -0
  60. package/dist/src/derivations/types.js +1 -0
  61. package/dist/src/digest/derivation.d.ts +25 -0
  62. package/dist/src/digest/derivation.js +124 -0
  63. package/dist/src/digest/editorial.d.ts +9 -46
  64. package/dist/src/digest/editorial.js +57 -49
  65. package/dist/src/digest/endpoint.js +63 -27
  66. package/dist/src/digest/pipeline.d.ts +49 -28
  67. package/dist/src/digest/pipeline.js +343 -100
  68. package/dist/src/digest/schema.d.ts +11 -25
  69. package/dist/src/digest/schema.js +86 -18
  70. package/dist/src/errors.d.ts +46 -0
  71. package/dist/src/errors.js +117 -0
  72. package/dist/src/identity/query-policy.d.ts +0 -5
  73. package/dist/src/identity/query-policy.js +61 -9
  74. package/dist/src/identity/uri.d.ts +6 -5
  75. package/dist/src/identity/uri.js +37 -18
  76. package/dist/src/identity/visibility.d.ts +11 -1
  77. package/dist/src/identity/visibility.js +81 -17
  78. package/dist/src/importers/apply.js +104 -36
  79. package/dist/src/importers/custody.js +27 -9
  80. package/dist/src/importers/inspect.js +125 -38
  81. package/dist/src/importers/jungle-digest.d.ts +3 -6
  82. package/dist/src/importers/jungle-digest.js +196 -69
  83. package/dist/src/importers/jungle-store.d.ts +15 -6
  84. package/dist/src/importers/jungle-store.js +122 -28
  85. package/dist/src/importers/jungle.d.ts +14 -14
  86. package/dist/src/importers/jungle.js +136 -31
  87. package/dist/src/importers/manifest.js +57 -21
  88. package/dist/src/importers/verify-helper.js +18 -4
  89. package/dist/src/indexing/image.js +29 -10
  90. package/dist/src/indexing/index.d.ts +37 -6
  91. package/dist/src/indexing/index.js +360 -124
  92. package/dist/src/indexing/jobs.js +156 -47
  93. package/dist/src/indexing/prune-run.d.ts +2 -0
  94. package/dist/src/indexing/prune-run.js +69 -26
  95. package/dist/src/indexing/prune.d.ts +12 -3
  96. package/dist/src/indexing/prune.js +85 -35
  97. package/dist/src/indexing/runner.d.ts +2 -1
  98. package/dist/src/indexing/runner.js +83 -40
  99. package/dist/src/indexing/upgrade.d.ts +9 -0
  100. package/dist/src/indexing/upgrade.js +79 -0
  101. package/dist/src/inference/asr/bridge.js +6 -2
  102. package/dist/src/inference/asr/protocol.js +109 -19
  103. package/dist/src/inference/asr/runtime.js +58 -21
  104. package/dist/src/inference/asr/windows.js +9 -5
  105. package/dist/src/inference/embedding/index.d.ts +2 -6
  106. package/dist/src/inference/embedding/index.js +89 -29
  107. package/dist/src/ingest/attempt.d.ts +29 -0
  108. package/dist/src/ingest/attempt.js +55 -0
  109. package/dist/src/media/capture-helper.js +7 -2
  110. package/dist/src/media/capture.d.ts +2 -1
  111. package/dist/src/media/capture.js +124 -43
  112. package/dist/src/media/handler.d.ts +4 -2
  113. package/dist/src/media/handler.js +88 -70
  114. package/dist/src/media/transcribe.js +63 -21
  115. package/dist/src/metadata/article.js +49 -17
  116. package/dist/src/models/artifacts.d.ts +10 -8
  117. package/dist/src/models/artifacts.js +237 -106
  118. package/dist/src/models/catalog.d.ts +2 -5
  119. package/dist/src/models/catalog.js +94 -25
  120. package/dist/src/models/download.js +55 -15
  121. package/dist/src/models/runtime.d.ts +0 -2
  122. package/dist/src/models/runtime.js +69 -33
  123. package/dist/src/pdf/capture-helper.js +3 -1
  124. package/dist/src/pdf/capture.d.ts +2 -1
  125. package/dist/src/pdf/capture.js +95 -27
  126. package/dist/src/pdf/errors.js +12 -1
  127. package/dist/src/pdf/handler.d.ts +2 -1
  128. package/dist/src/pdf/handler.js +70 -71
  129. package/dist/src/pdf/index.d.ts +1 -3
  130. package/dist/src/pdf/index.js +0 -2
  131. package/dist/src/pdf/input.js +12 -7
  132. package/dist/src/pdf/normalize.js +80 -21
  133. package/dist/src/pdf/source-identity.js +3 -2
  134. package/dist/src/pdf/source.js +12 -8
  135. package/dist/src/pdf/verify-helper.js +15 -3
  136. package/dist/src/portability/backup.d.ts +2 -9
  137. package/dist/src/portability/backup.js +106 -22
  138. package/dist/src/portability/database.js +51 -17
  139. package/dist/src/portability/export.js +35 -9
  140. package/dist/src/portability/files.d.ts +1 -4
  141. package/dist/src/portability/files.js +127 -67
  142. package/dist/src/portability/gc.js +65 -22
  143. package/dist/src/publish/component.js +1 -1
  144. package/dist/src/publish/feed.d.ts +2 -2
  145. package/dist/src/publish/feed.js +75 -29
  146. package/dist/src/publish/identity.d.ts +5 -1
  147. package/dist/src/publish/identity.js +31 -11
  148. package/dist/src/publish/index.d.ts +2 -3
  149. package/dist/src/publish/index.js +34 -21
  150. package/dist/src/publish/projection.d.ts +2 -9
  151. package/dist/src/publish/projection.js +21 -14
  152. package/dist/src/search/extension.js +3 -1
  153. package/dist/src/search/hybrid.d.ts +3 -8
  154. package/dist/src/search/hybrid.js +45 -11
  155. package/dist/src/search/lexical.js +18 -4
  156. package/dist/src/search/query.js +56 -25
  157. package/dist/src/search/vector.js +37 -10
  158. package/dist/src/service/launch-agent.d.ts +5 -3
  159. package/dist/src/service/launch-agent.js +272 -83
  160. package/dist/src/service/lifetime.js +47 -14
  161. package/dist/src/service/loop.d.ts +3 -3
  162. package/dist/src/service/loop.js +47 -19
  163. package/dist/src/store/annotations.d.ts +32 -0
  164. package/dist/src/store/annotations.js +44 -0
  165. package/dist/src/store/backups.d.ts +18 -0
  166. package/dist/src/store/backups.js +110 -0
  167. package/dist/src/store/blobs.d.ts +24 -0
  168. package/dist/src/store/blobs.js +139 -0
  169. package/dist/src/store/changes.d.ts +5 -12
  170. package/dist/src/store/changes.js +108 -56
  171. package/dist/src/store/context.d.ts +15 -0
  172. package/dist/src/store/context.js +19 -0
  173. package/dist/src/store/derivations.d.ts +42 -0
  174. package/dist/src/store/derivations.js +104 -0
  175. package/dist/src/store/files.d.ts +0 -2
  176. package/dist/src/store/files.js +19 -24
  177. package/dist/src/store/ids.d.ts +30 -0
  178. package/dist/src/store/ids.js +34 -0
  179. package/dist/src/store/imports.d.ts +26 -0
  180. package/dist/src/store/imports.js +57 -0
  181. package/dist/src/store/index.d.ts +3 -186
  182. package/dist/src/store/index.js +2 -809
  183. package/dist/src/store/lease.d.ts +20 -0
  184. package/dist/src/store/lease.js +62 -0
  185. package/dist/src/store/library.d.ts +75 -0
  186. package/dist/src/store/library.js +174 -0
  187. package/dist/src/store/open.d.ts +7 -0
  188. package/dist/src/store/open.js +183 -0
  189. package/dist/src/store/queue.d.ts +65 -0
  190. package/dist/src/store/queue.js +327 -0
  191. package/dist/src/store/revisions.d.ts +12 -0
  192. package/dist/src/store/revisions.js +189 -0
  193. package/dist/src/store/search-index.d.ts +63 -0
  194. package/dist/src/store/search-index.js +190 -0
  195. package/dist/src/store/sync-ready.d.ts +6 -0
  196. package/dist/src/store/sync-ready.js +121 -0
  197. package/dist/src/store/types.d.ts +1 -5
  198. package/dist/src/store/types.js +1 -8
  199. package/dist/src/util/canonical.d.ts +2 -0
  200. package/dist/src/util/canonical.js +17 -0
  201. package/dist/src/util/exact.d.ts +2 -0
  202. package/dist/src/util/exact.js +9 -0
  203. package/dist/src/util/fsync.d.ts +4 -0
  204. package/dist/src/util/fsync.js +22 -0
  205. package/dist/src/util/hash.d.ts +4 -0
  206. package/dist/src/util/hash.js +8 -0
  207. package/dist/src/worker/basic.d.ts +0 -1
  208. package/dist/src/worker/basic.js +23 -7
  209. package/dist/src/worker/custody.d.ts +4 -1
  210. package/dist/src/worker/custody.js +111 -38
  211. package/dist/src/worker/engine.d.ts +2 -1
  212. package/dist/src/worker/engine.js +42 -20
  213. package/dist/src/worker/identity.js +13 -5
  214. package/dist/src/worker/journal.d.ts +1 -1
  215. package/dist/src/worker/journal.js +54 -24
  216. package/dist/src/worker/lock.d.ts +18 -0
  217. package/dist/src/worker/lock.js +196 -0
  218. package/dist/src/worker/runtime-gc.d.ts +4 -3
  219. package/dist/src/worker/runtime-gc.js +68 -41
  220. package/dist/src/worker/supervisor-child.js +19 -6
  221. package/dist/src/worker/supervisor.js +41 -8
  222. package/native/runtime-manifest.json +4 -4
  223. package/package.json +10 -2
  224. package/dist/src/digest/store.d.ts +0 -57
  225. package/dist/src/digest/store.js +0 -79
  226. package/dist/src/store/foundation.d.ts +0 -7
  227. package/dist/src/store/foundation.js +0 -7
@@ -1,154 +1,390 @@
1
- import { createHash, randomUUID } from 'node:crypto';
2
- import { spaceFingerprint, encodeEmbeddingRequest, embeddingTextBytes } from '../inference/embedding/index.js';
3
- export class IndexError extends Error {
4
- code;
5
- constructor(code) {
6
- super(code);
7
- this.code = code;
1
+ import { randomUUID } from 'node:crypto';
2
+ import { sha256 as hash } from '../util/hash.js';
3
+ import { spaceFingerprint, embeddingTextBytes } from '../inference/embedding/index.js';
4
+ import { SpongeError } from '../errors.js';
5
+ const fail = (code = 'invalid-index') => {
6
+ throw new SpongeError(code);
7
+ };
8
+ function atomic(db, fn) {
9
+ if (db.isTransaction)
10
+ return fn();
11
+ db.exec('BEGIN IMMEDIATE');
12
+ try {
13
+ const value = fn();
14
+ db.exec('COMMIT');
15
+ return value;
8
16
  }
17
+ catch (e) {
18
+ db.exec('ROLLBACK');
19
+ throw e;
20
+ }
21
+ }
22
+ function epoch(db) {
23
+ return Number(db.prepare('SELECT revision_epoch FROM library_info WHERE id=1').get()?.revision_epoch);
9
24
  }
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(); }
25
+ /** Schema 10 keeps coverage in `index_items` and `index_inputs`; a generation whose JSON coverage could not be converted keeps it and never reads. */
26
+ export const coverageMarker = '{"version":2}';
24
27
  /** Storage format follows the space identity: no `quantization` (legacy) or `f32` stores float32 LE; `int8-symmetric-maxabs-v1` stores one int8 code per dimension. */
25
- export function spaceEncoding(identity) { const q = identity.quantization; if (q === undefined || q === 'f32')
26
- return 'f32'; if (q === 'int8-symmetric-maxabs-v1')
27
- return 'int8'; return fail(); }
28
+ export function spaceEncoding(identity) {
29
+ const q = identity.quantization;
30
+ if (q === undefined || q === 'f32')
31
+ return 'f32';
32
+ if (q === 'int8-symmetric-maxabs-v1')
33
+ return 'int8';
34
+ return fail();
35
+ }
28
36
  /** int8 codes are round(x*127/max|x|) of a unit vector. The per-vector scale is not stored: cosine is scale-invariant, so codes rank directly and dequantize by renormalizing. */
29
- export function encodeVector(vector, encoding) { if (!vector.length || vector.some(n => !Number.isFinite(n)))
30
- fail(); if (encoding === 'f32') {
31
- const bytes = Buffer.alloc(vector.length * 4);
32
- vector.forEach((n, i) => bytes.writeFloatLE(n, i * 4));
33
- return bytes;
34
- } const max = Math.max(...vector.map(Math.abs)); if (!(max > 0))
35
- fail(); return Buffer.from(Int8Array.from(vector, n => Math.round(n * 127 / max)).buffer); }
37
+ export function encodeVector(vector, encoding) {
38
+ if (!vector.length || vector.some((n) => !Number.isFinite(n)))
39
+ fail();
40
+ if (encoding === 'f32') {
41
+ const bytes = Buffer.alloc(vector.length * 4);
42
+ vector.forEach((n, i) => bytes.writeFloatLE(n, i * 4));
43
+ return bytes;
44
+ }
45
+ const max = Math.max(...vector.map(Math.abs));
46
+ if (!(max > 0))
47
+ fail();
48
+ return Buffer.from(Int8Array.from(vector, (n) => Math.round((n * 127) / max)).buffer);
49
+ }
36
50
  /** Exact byte length, finite floats, int8 codes within [-127,127] and nonzero; int8 returns the unit dequantized vector. */
37
- export function decodeVector(bytes, dimensions, encoding) { const b = Buffer.from(bytes); if (encoding === 'f32') {
38
- if (b.length !== dimensions * 4)
51
+ export function decodeVector(bytes, dimensions, encoding) {
52
+ const b = Buffer.from(bytes);
53
+ if (encoding === 'f32') {
54
+ if (b.length !== dimensions * 4)
55
+ fail();
56
+ const vector = Array.from({ length: dimensions }, (_, i) => b.readFloatLE(i * 4));
57
+ if (vector.some((n) => !Number.isFinite(n)))
58
+ fail();
59
+ return vector;
60
+ }
61
+ if (b.length !== dimensions)
62
+ fail();
63
+ const codes = Array.from({ length: dimensions }, (_, i) => b.readInt8(i));
64
+ if (codes.some((n) => n < -127))
39
65
  fail();
40
- const vector = Array.from({ length: dimensions }, (_, i) => b.readFloatLE(i * 4));
41
- if (vector.some(n => !Number.isFinite(n)))
66
+ const norm = Math.hypot(...codes);
67
+ if (!(norm > 0))
42
68
  fail();
43
- return vector;
44
- } if (b.length !== dimensions)
45
- fail(); const codes = Array.from({ length: dimensions }, (_, i) => b.readInt8(i)); if (codes.some(n => n < -127))
46
- fail(); const norm = Math.hypot(...codes); if (!(norm > 0))
47
- fail(); return codes.map(n => n / norm); }
69
+ return codes.map((n) => n / norm);
70
+ }
71
+ /** What one document chunk's vector was derived from before schema 10: its representation, position and text. Migrations 9 and 10 still read it. */
72
+ export const chunkHash = (representationId, ordinal, text) => hash(JSON.stringify(['sponge-chunk-v2', representationId, ordinal, text]));
48
73
  const chunkBytes = embeddingTextBytes - Buffer.byteLength('title: none | text: ');
49
74
  /** Unicode-safe chunks fit the fixed document prompt within the embedding text budget, preferring whitespace breaks. */
50
- export function chunkDocument(text) { const chunks = []; let part = '', size = 0, cut = -1, cutSize = 0; for (const char of text) {
51
- const bytes = Buffer.byteLength(char);
52
- if (size + bytes > chunkBytes) {
53
- const at = cut > 0 && cutSize * 2 >= chunkBytes ? cut : part.length;
54
- const head = part.slice(0, at);
55
- if (head.trim())
56
- chunks.push(head);
57
- part = part.slice(at);
58
- size = Buffer.byteLength(part);
59
- cut = -1;
75
+ export function chunkDocument(text) {
76
+ const chunks = [];
77
+ let part = '', size = 0, cut = -1, cutSize = 0;
78
+ for (const char of text) {
79
+ const bytes = Buffer.byteLength(char);
80
+ if (size + bytes > chunkBytes) {
81
+ const at = cut > 0 && cutSize * 2 >= chunkBytes ? cut : part.length;
82
+ const head = part.slice(0, at);
83
+ if (head.trim())
84
+ chunks.push(head);
85
+ part = part.slice(at);
86
+ size = Buffer.byteLength(part);
87
+ cut = -1;
88
+ }
89
+ part += char;
90
+ size += bytes;
91
+ if (/\s/u.test(char)) {
92
+ cut = part.length;
93
+ cutSize = size;
94
+ }
95
+ }
96
+ if (part.trim())
97
+ chunks.push(part);
98
+ return chunks;
99
+ }
100
+ /** The helper's input hash of a document chunk: `encodeEmbeddingRequest([{kind:'document',text}]).inputHashes[0]` without its validation, so a commit never fails on text the embedder refuses. */
101
+ export function documentHash(text) {
102
+ const payload = Buffer.from('title: none | text: ' + text), header = Buffer.alloc(5);
103
+ header[0] = 1;
104
+ header.writeUInt32BE(payload.length, 1);
105
+ return hash(Buffer.concat([header, payload]));
106
+ }
107
+ export const imageHash = (assetSha256) => hash(JSON.stringify(['sponge-image-source-v1', assetSha256]));
108
+ /** A source id is `[revisionId,'document',representationId,ordinal]` or `[revisionId,'image',assetSha256]`. */
109
+ export function parseSource(sourceId, revisionId) {
110
+ let d;
111
+ try {
112
+ d = JSON.parse(sourceId);
60
113
  }
61
- part += char;
62
- size += bytes;
63
- if (/\s/u.test(char)) {
64
- cut = part.length;
65
- cutSize = size;
114
+ catch {
115
+ return fail();
66
116
  }
67
- } if (part.trim())
68
- chunks.push(part); return chunks; }
69
- function snapshot(db) {
70
- const current = revisions(db), inputs = [];
71
- for (const r of current) {
72
- const docs = db.prepare('SELECT id,markdown FROM representations WHERE revision_id=? AND markdown IS NOT NULL ORDER BY id').all(r.revisionId);
73
- for (const doc of docs) {
74
- const chunks = chunkDocument(String(doc.markdown));
75
- for (let ordinal = 0; ordinal < chunks.length; ordinal++) {
76
- const text = chunks[ordinal], derivationHash = hash(JSON.stringify(['sponge-chunk-v2', String(doc.id), ordinal, text]));
77
- inputs.push({ ...r, sourceId: JSON.stringify([r.revisionId, 'document', String(doc.id), ordinal]), kind: 'document', representationId: String(doc.id), ordinal, derivationHash });
117
+ if (!Array.isArray(d) || d[0] !== revisionId)
118
+ return fail();
119
+ if (d[1] === 'document' && d.length === 4 && typeof d[2] === 'string' && Number.isSafeInteger(d[3]) && d[3] >= 0)
120
+ return { kind: 'document', representationId: d[2], ordinal: d[3] };
121
+ if (d[1] === 'image' && d.length === 3 && typeof d[2] === 'string' && /^[a-f0-9]{64}$/.test(d[2]))
122
+ return { kind: 'image', assetSha256: d[2] };
123
+ return fail();
124
+ }
125
+ export const sourceInput = (row) => ({
126
+ sourceId: String(row.source_id),
127
+ revisionId: String(row.revision_id),
128
+ itemId: String(row.item_id),
129
+ derivationHash: String(row.content_hash),
130
+ ...parseSource(String(row.source_id), String(row.revision_id)),
131
+ });
132
+ /** Chunks and images of one revision. A chunk the helper refuses (a NUL byte) is left out rather than failing every later delta. */
133
+ function revisionInputs(db, revisionId) {
134
+ const inputs = [];
135
+ for (const doc of db
136
+ .prepare('SELECT id,markdown FROM representations WHERE revision_id=? AND markdown IS NOT NULL ORDER BY id')
137
+ .all(revisionId))
138
+ chunkDocument(String(doc.markdown)).forEach((text, ordinal) => {
139
+ if (!text.includes('\0'))
140
+ inputs.push({
141
+ sourceId: JSON.stringify([revisionId, 'document', String(doc.id), ordinal]),
142
+ contentHash: documentHash(text),
143
+ });
144
+ });
145
+ for (const image of db
146
+ .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")
147
+ .all(revisionId))
148
+ inputs.push({
149
+ sourceId: JSON.stringify([revisionId, 'image', String(image.sha256)]),
150
+ contentHash: imageHash(String(image.sha256)),
151
+ });
152
+ return inputs;
153
+ }
154
+ function generation(db, id) {
155
+ const row = db
156
+ .prepare('SELECT g.*,s.dimensions,s.identity_json FROM index_generations g JOIN embedding_spaces s ON s.fingerprint=g.fingerprint WHERE g.id=?')
157
+ .get(id);
158
+ if (!row)
159
+ return fail();
160
+ if (row.coverage_json !== coverageMarker)
161
+ fail();
162
+ const profile = JSON.parse(String(row.profile_json));
163
+ if (typeof profile.profileId !== 'string')
164
+ fail();
165
+ return { row, profile, encoding: spaceEncoding(JSON.parse(String(row.identity_json))) };
166
+ }
167
+ const missing = 'NOT EXISTS(SELECT 1 FROM embeddings e WHERE e.fingerprint=? AND e.content_hash=c.content_hash)';
168
+ /** Inputs whose content has no embedding in the space yet. */
169
+ const pendingCount = (db, id, fingerprint) => Number(db.prepare(`SELECT count(*) n FROM index_inputs c WHERE c.generation_id=? AND ${missing}`).get(id, fingerprint)?.n);
170
+ /** Items whose current, non-deleted revision differs from the one a generation covers. */
171
+ const drift = `FROM items i LEFT JOIN index_items x ON x.generation_id=? AND x.item_id=i.id WHERE (CASE WHEN i.deleted_at IS NULL THEN i.current_revision END) IS NOT x.revision_id`;
172
+ /** Brings a building or active generation's coverage to the current revisions: inputs of refreshed or deleted items go, the
173
+ * new revision's come. Embeddings stay; an input is pending while its space has none for its content. Returns items changed. */
174
+ export function syncGeneration(db, id) {
175
+ return atomic(db, () => {
176
+ const g = db.prepare('SELECT state,coverage_json FROM index_generations WHERE id=?').get(id);
177
+ if (!g || g.coverage_json !== coverageMarker || !['building', 'active'].includes(String(g.state)))
178
+ return 0;
179
+ const changed = db
180
+ .prepare(`SELECT i.id,CASE WHEN i.deleted_at IS NULL THEN i.current_revision END AS current,x.revision_id AS covered ${drift}`)
181
+ .all(id), insert = db.prepare('INSERT OR IGNORE INTO index_inputs VALUES(?,?,?,?)');
182
+ for (const item of changed) {
183
+ if (item.covered !== null) {
184
+ db.prepare('DELETE FROM index_inputs WHERE generation_id=? AND revision_id=?').run(id, item.covered);
185
+ db.prepare('DELETE FROM index_items WHERE generation_id=? AND item_id=?').run(id, item.id);
186
+ }
187
+ if (item.current !== null) {
188
+ const revisionId = String(item.current);
189
+ db.prepare('INSERT INTO index_items VALUES(?,?,?)').run(id, item.id, revisionId);
190
+ for (const input of revisionInputs(db, revisionId))
191
+ insert.run(id, input.sourceId, revisionId, input.contentHash);
78
192
  }
79
193
  }
80
- 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);
81
- for (const image of images) {
82
- const assetSha256 = String(image.sha256);
83
- inputs.push({ ...r, sourceId: JSON.stringify([r.revisionId, 'image', assetSha256]), kind: 'image', assetSha256, derivationHash: hash(JSON.stringify(['sponge-image-source-v1', assetSha256])) });
84
- }
194
+ db.prepare('UPDATE index_generations SET revision_epoch=? WHERE id=?').run(epoch(db), id);
195
+ return changed.length;
196
+ });
197
+ }
198
+ /** Queues a generation's index job again (or its first) unless it is queued, running or awaiting cleanup. */
199
+ export function armJob(db, id) {
200
+ const job = db.prepare('SELECT id,state FROM index_jobs WHERE generation_id=?').get(id);
201
+ if (!job) {
202
+ db.prepare("INSERT INTO index_jobs(id,generation_id,state,created_at) VALUES(?,?,'queued',?)").run(randomUUID(), id, new Date().toISOString());
203
+ return;
85
204
  }
86
- return { version: 1, revisions: current, inputs };
205
+ if (['succeeded', 'failed', 'cancelled'].includes(String(job.state)) &&
206
+ !db.prepare("SELECT 1 FROM index_attempts WHERE job_id=? AND cleanup='pending'").get(job.id))
207
+ db.prepare("UPDATE index_jobs SET state='queued',cancel_requested=0,reason=NULL WHERE id=?").run(job.id);
208
+ }
209
+ /** Inside the transaction that changed a current revision or deleted an item: every active generation, and every building one
210
+ * whose job is live, follows the library. An active generation with content to embed gets its job queued: only the delta runs. */
211
+ export function trackLibrary(db) {
212
+ atomic(db, () => {
213
+ for (const g of db
214
+ .prepare("SELECT id,fingerprint,state FROM index_generations g WHERE coverage_json=? AND (id IN (SELECT generation_id FROM active_indexes) OR state='building' AND EXISTS(SELECT 1 FROM index_jobs j WHERE j.generation_id=g.id AND j.state IN ('queued','running','cleanup-pending'))) ORDER BY rowid")
215
+ .all(coverageMarker)) {
216
+ const id = String(g.id);
217
+ syncGeneration(db, id);
218
+ if (g.state === 'active' && pendingCount(db, id, String(g.fingerprint)))
219
+ armJob(db, id);
220
+ }
221
+ });
87
222
  }
88
- function generation(db, id) { const row = db.prepare('SELECT g.*,s.dimensions,s.identity_json FROM index_generations g JOIN embedding_spaces s ON s.fingerprint=g.fingerprint WHERE g.id=?').get(id); if (!row)
89
- 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')
90
- fail(); return { row, coverage, profile, encoding: spaceEncoding(JSON.parse(String(row.identity_json))) }; }
91
- /** Identity must already be the verified embedder fingerprint. Existing identities are immutable. */
223
+ /** Identity must already be the verified embedder fingerprint. Existing identities are immutable. Every input whose content the
224
+ * space already embedded (an earlier generation, another item) is complete at once. */
92
225
  export function createGeneration(db, options) {
93
- if (!options.profileId || options.profileId.length > 128 || !/^[a-f0-9]{64}$/.test(options.fingerprint) || !Number.isSafeInteger(options.dimensions) || options.dimensions < 1 || options.dimensions > 4096)
226
+ if (!options.profileId ||
227
+ options.profileId.length > 128 ||
228
+ !/^[a-f0-9]{64}$/.test(options.fingerprint) ||
229
+ !Number.isSafeInteger(options.dimensions) ||
230
+ options.dimensions < 1 ||
231
+ options.dimensions > 4096)
94
232
  fail();
95
233
  if (spaceFingerprint(options.identity) !== options.fingerprint || options.identity.dimensions !== options.dimensions)
96
234
  fail();
97
235
  spaceEncoding(options.identity);
98
- 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)
99
- 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'))
100
- fail(); if (!existing)
101
- 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); });
102
- }
103
- 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 }; }
104
- export function pendingInputs(db, id, limit = 16) { if (!Number.isSafeInteger(limit) || limit < 1 || limit > 256)
105
- fail(); const { row, coverage } = generation(db, id); if (row.state === 'cancelled')
106
- fail('cancelled-index'); if (row.state !== 'building')
107
- 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')
108
- 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')
109
- return fail(); const text = chunkDocument(doc.markdown)[input.ordinal]; if (text === undefined || hash(JSON.stringify(['sponge-chunk-v2', input.representationId, input.ordinal, text])) !== input.derivationHash)
110
- return fail(); return { ...input, text }; }); }
236
+ return atomic(db, () => {
237
+ const identity = JSON.stringify(Object.fromEntries(Object.keys(options.identity)
238
+ .sort()
239
+ .map((k) => [k, options.identity[k]])));
240
+ if (!identity || Buffer.byteLength(identity) > 16384)
241
+ fail();
242
+ const existing = db.prepare('SELECT * FROM embedding_spaces WHERE fingerprint=?').get(options.fingerprint);
243
+ if (existing &&
244
+ (existing.identity_json !== identity ||
245
+ Number(existing.dimensions) !== options.dimensions ||
246
+ existing.normalization !== 'unit-l2' ||
247
+ existing.metric !== 'cosine'))
248
+ fail();
249
+ if (!existing)
250
+ db.prepare('INSERT INTO embedding_spaces VALUES(?,?,?,?,?)').run(options.fingerprint, identity, options.dimensions, 'unit-l2', 'cosine');
251
+ const id = randomUUID();
252
+ db.prepare('INSERT INTO index_generations VALUES(?,?,?,?,?,?)').run(id, options.fingerprint, JSON.stringify({ profileId: options.profileId }), 'building', epoch(db), coverageMarker);
253
+ syncGeneration(db, id);
254
+ return generationStatus(db, id);
255
+ });
256
+ }
257
+ export function generationStatus(db, id) {
258
+ const { row, profile } = generation(db, id), fingerprint = String(row.fingerprint), expected = Number(db.prepare('SELECT count(*) n FROM index_inputs WHERE generation_id=?').get(id)?.n), pending = pendingCount(db, id, fingerprint);
259
+ return {
260
+ id,
261
+ fingerprint,
262
+ state: String(row.state),
263
+ revisionEpoch: Number(row.revision_epoch),
264
+ items: Number(db.prepare('SELECT count(*) n FROM index_items WHERE generation_id=?').get(id)?.n),
265
+ expected,
266
+ completed: expected - pending,
267
+ pending,
268
+ profileId: profile.profileId,
269
+ };
270
+ }
271
+ /** One input per content hash still to embed, oldest coverage first: identical chunks embed once. */
272
+ export function pendingInputs(db, id, limit = 16) {
273
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 256)
274
+ fail();
275
+ const { row } = generation(db, id);
276
+ if (row.state === 'cancelled')
277
+ fail('cancelled-index');
278
+ if (row.state !== 'building' && row.state !== 'active')
279
+ return [];
280
+ const chunks = new Map();
281
+ return db
282
+ .prepare(`SELECT c.source_id,c.revision_id,c.content_hash,r.item_id,min(c.rowid) AS first FROM index_inputs c JOIN revisions r ON r.id=c.revision_id WHERE c.generation_id=? AND ${missing} GROUP BY c.content_hash ORDER BY first LIMIT ?`)
283
+ .all(id, row.fingerprint, limit)
284
+ .map((r) => {
285
+ const input = sourceInput(r);
286
+ if (input.kind === 'image')
287
+ return input;
288
+ let parts = chunks.get(input.representationId);
289
+ if (!parts) {
290
+ const doc = db
291
+ .prepare('SELECT markdown FROM representations WHERE id=? AND revision_id=?')
292
+ .get(input.representationId, input.revisionId);
293
+ if (!doc || typeof doc.markdown !== 'string')
294
+ return fail();
295
+ parts = chunkDocument(doc.markdown);
296
+ chunks.set(input.representationId, parts);
297
+ }
298
+ const text = parts[input.ordinal];
299
+ if (text === undefined || documentHash(text) !== input.derivationHash)
300
+ return fail();
301
+ return { ...input, text };
302
+ });
303
+ }
304
+ /** Stores each result's vector under its content hash in the space. A source dropped by a refresh or delete since it was read is skipped. */
111
305
  export function writeBatch(db, id, fingerprint, batch) {
112
306
  if (!batch.length || batch.length > 256)
113
307
  fail();
114
- atomic(db, () => { const { row, coverage, encoding } = generation(db, id); if (row.state === 'cancelled')
115
- fail('cancelled-index'); if (row.state !== 'building' || row.fingerprint !== fingerprint)
116
- fail(); const expected = new Map(coverage.inputs.map(i => [i.sourceId, i])); const seen = new Set(); for (const result of batch) {
117
- const input = expected.get(result.sourceId);
118
- if (!input)
119
- return fail();
120
- 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)
308
+ atomic(db, () => {
309
+ const { row, encoding } = generation(db, id);
310
+ if (row.state === 'cancelled')
311
+ fail('cancelled-index');
312
+ if (!['building', 'active'].includes(String(row.state)) || row.fingerprint !== fingerprint)
121
313
  fail();
122
- if (input.kind === 'document') {
123
- const doc = db.prepare('SELECT markdown FROM representations WHERE id=? AND revision_id=?').get(input.representationId, input.revisionId);
124
- if (!doc || typeof doc.markdown !== 'string')
125
- return fail();
126
- const text = chunkDocument(String(doc.markdown))[input.ordinal];
127
- if (text === undefined || encodeEmbeddingRequest([{ kind: 'document', text }]).inputHashes[0] !== result.inputHash)
314
+ const seen = new Set();
315
+ for (const result of batch) {
316
+ if (seen.has(result.sourceId) ||
317
+ !/^[a-f0-9]{64}$/.test(result.inputHash) ||
318
+ result.vector.length !== Number(row.dimensions) ||
319
+ result.vector.some((n) => !Number.isFinite(n)) ||
320
+ Math.abs(Math.hypot(...result.vector) - 1) > 0.001)
128
321
  fail();
129
- }
130
- seen.add(result.sourceId);
131
- const bytes = encodeVector(result.vector, encoding);
132
- const prior = db.prepare('SELECT vector,input_hash FROM vectors WHERE generation_id=? AND source_id=?').get(id, input.sourceId);
133
- if (prior && prior.input_hash !== null) {
134
- if (prior.input_hash !== result.inputHash || !bytes.equals(Buffer.from(prior.vector)))
322
+ seen.add(result.sourceId);
323
+ const input = db
324
+ .prepare('SELECT revision_id,content_hash FROM index_inputs WHERE generation_id=? AND source_id=?')
325
+ .get(id, result.sourceId);
326
+ if (!input)
327
+ continue;
328
+ if (input.revision_id !== result.revisionId ||
329
+ input.content_hash !== result.derivationHash ||
330
+ (parseSource(result.sourceId, result.revisionId).kind === 'document' && result.inputHash !== input.content_hash))
135
331
  fail();
136
- continue;
332
+ const bytes = encodeVector(result.vector, encoding), prior = db
333
+ .prepare('SELECT input_hash,vector FROM embeddings WHERE fingerprint=? AND content_hash=?')
334
+ .get(fingerprint, input.content_hash);
335
+ if (prior) {
336
+ if (prior.input_hash !== result.inputHash || !bytes.equals(Buffer.from(prior.vector)))
337
+ fail();
338
+ continue;
339
+ }
340
+ db.prepare('INSERT INTO embeddings VALUES(?,?,?,?)').run(fingerprint, input.content_hash, result.inputHash, bytes);
137
341
  }
138
- 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);
139
- } });
342
+ });
140
343
  }
344
+ /** Complete and current: no pending input, and coverage names exactly the current revision of every live item. */
141
345
  export function activateGeneration(db, id) {
142
- return atomic(db, () => { const { row, coverage, profile } = generation(db, id); if (!['building', 'active', 'retired'].includes(String(row.state)))
143
- fail('cancelled-index'); if (Number(row.revision_epoch) !== epoch(db) || JSON.stringify(coverage.revisions) !== JSON.stringify(revisions(db)))
144
- 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); }))
145
- 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)
146
- 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); });
147
- }
148
- export function cancelGeneration(db, id) { atomic(db, () => { const { row } = generation(db, id); if (row.state === 'active' || row.state === 'retired')
149
- fail(); db.prepare("UPDATE index_generations SET state='cancelled' WHERE id=?").run(id); }); }
346
+ return atomic(db, () => {
347
+ const { row, profile } = generation(db, id);
348
+ if (!['building', 'active', 'retired'].includes(String(row.state)))
349
+ fail('cancelled-index');
350
+ if (Number(row.revision_epoch) !== epoch(db) || db.prepare(`SELECT 1 ${drift} LIMIT 1`).get(id))
351
+ fail('stale-index');
352
+ if (pendingCount(db, id, String(row.fingerprint)))
353
+ fail('incomplete-index');
354
+ const prior = db.prepare('SELECT generation_id FROM active_indexes WHERE profile_id=?').get(profile.profileId);
355
+ if (prior && prior.generation_id !== id)
356
+ db.prepare("UPDATE index_generations SET state='retired' WHERE id=?").run(prior.generation_id);
357
+ db.prepare('INSERT INTO active_indexes VALUES(?,?) ON CONFLICT(profile_id) DO UPDATE SET generation_id=excluded.generation_id').run(profile.profileId, id);
358
+ db.prepare("UPDATE index_generations SET state='active' WHERE id=?").run(id);
359
+ return generationStatus(db, id);
360
+ });
361
+ }
362
+ export function cancelGeneration(db, id) {
363
+ atomic(db, () => {
364
+ const { row } = generation(db, id);
365
+ if (row.state === 'active' || row.state === 'retired')
366
+ fail();
367
+ db.prepare("UPDATE index_generations SET state='cancelled' WHERE id=?").run(id);
368
+ });
369
+ }
370
+ /** The active generation of a profile, if any. */
371
+ export function activeGeneration(db, profileId) {
372
+ const row = db.prepare('SELECT generation_id FROM active_indexes WHERE profile_id=?').get(profileId);
373
+ return row ? String(row.generation_id) : undefined;
374
+ }
150
375
  /** Read-side join removes deleted/refreshed revisions even when an old generation stays active. */
151
- export function activeVectors(db, profileId) { const active = db.prepare('SELECT generation_id FROM active_indexes WHERE profile_id=?').get(profileId); if (!active)
152
- return []; const id = String(active.generation_id), { row, coverage, encoding } = 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 source = inputs.get(String(v.source_id)); if (!source)
153
- fail(); const vector = decodeVector(v.vector, Number(row.dimensions), encoding); return { fingerprint: String(row.fingerprint), generationId: id, source: source, vector }; }); }
154
- export const rollbackGeneration = activateGeneration;
376
+ export function activeVectors(db, profileId) {
377
+ const id = activeGeneration(db, profileId);
378
+ if (!id)
379
+ return [];
380
+ const { row, encoding } = generation(db, id), fingerprint = String(row.fingerprint);
381
+ return db
382
+ .prepare('SELECT c.source_id,c.revision_id,c.content_hash,r.item_id,e.vector FROM index_inputs c JOIN embeddings e ON e.fingerprint=? AND e.content_hash=c.content_hash JOIN revisions r ON r.id=c.revision_id JOIN items i ON i.id=r.item_id WHERE c.generation_id=? AND i.deleted_at IS NULL AND i.current_revision=c.revision_id ORDER BY c.source_id')
383
+ .all(fingerprint, id)
384
+ .map((v) => ({
385
+ fingerprint,
386
+ generationId: id,
387
+ source: sourceInput(v),
388
+ vector: decodeVector(v.vector, Number(row.dimensions), encoding),
389
+ }));
390
+ }