@hraness/sponge 0.2.6 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +369 -102
  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 +19 -6
  84. package/dist/src/importers/jungle-store.js +124 -28
  85. package/dist/src/importers/jungle.d.ts +14 -14
  86. package/dist/src/importers/jungle.js +142 -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,37 +1,43 @@
1
- import { createHash } from 'node:crypto';
2
1
  import { foldText, locateAll, locateLoose, words } from '../annotations/quote.js';
3
- import { checkDigest, digestLimits as L, forms, parseDigest } from './schema.js';
4
- import { StoreError } from '../store/types.js';
2
+ import { checkDigest, digestLimits as L, forms, parseDigest, } from './schema.js';
3
+ import { SpongeError } from '../errors.js';
4
+ import { sha256 } from '../util/hash.js';
5
5
  /**
6
6
  * Model-agnostic Digest v2 drafting. The model sees numbered passages and cites a passage id plus a short exact
7
7
  * span; code snaps, prunes and checks. Nothing leaves this module except through the injected model.
8
8
  */
9
- export const PROMPT_VERSION = 'sponge-digest-prompt-v2';
9
+ export const PROMPT_VERSION = 'sponge-digest-prompt-v5';
10
10
  /** 24k characters is roughly 6k tokens of passages, leaving room for instructions and output in an 8k context on an 8 GB Mac. */
11
- export const defaultDigestParams = { temperature: 0, seed: 1, maxTokens: 2048, contextChars: 24000, maxSections: 24, timeoutMs: 300000, deadlineMs: 1800000, maxOutputBytes: 65536 };
12
- export class DigestError extends Error {
13
- code;
14
- issues;
15
- constructor(code, issues) {
16
- super(code);
17
- this.code = code;
18
- this.issues = issues;
19
- this.name = 'DigestError';
20
- }
21
- }
11
+ const defaultDigestParams = {
12
+ temperature: 0,
13
+ seed: 1,
14
+ maxTokens: 2048,
15
+ contextChars: 24000,
16
+ maxSections: 24,
17
+ timeoutMs: 300000,
18
+ deadlineMs: 1800000,
19
+ maxOutputBytes: 65536,
20
+ };
22
21
  /** Quotes under this many words carry no evidence; the checker is gaining the same floor. */
23
- export const minQuoteWords = 4;
24
- const sha256 = (v) => createHash('sha256').update(v).digest('hex');
22
+ const minQuoteWords = 4;
25
23
  const collapse = (v) => v.replace(/\s+/g, ' ').trim();
26
24
  const object = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
27
- /** Sorted-key JSON; the only serialization that enters input hashes. */
25
+ /**
26
+ * Sorted-key JSON; the only serialization that enters input hashes. Deliberately not `util/canonical.ts`: this one
27
+ * accepts finite non-integer numbers (temperature) and writes `undefined` array entries as null, so merging would
28
+ * change digest input hashes.
29
+ */
28
30
  export function canonicalJson(value) {
29
31
  if (Array.isArray(value))
30
32
  return `[${value.map(canonicalJson).join(',')}]`;
31
33
  if (object(value))
32
- return `{${Object.keys(value).filter(k => value[k] !== undefined).sort().map(k => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(',')}}`;
34
+ return `{${Object.keys(value)
35
+ .filter((k) => value[k] !== undefined)
36
+ .sort()
37
+ .map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`)
38
+ .join(',')}}`;
33
39
  if (typeof value === 'number' && !Number.isFinite(value))
34
- throw new DigestError('invalid-input');
40
+ throw new SpongeError('invalid-input');
35
41
  return JSON.stringify(value ?? null);
36
42
  }
37
43
  /** Paragraph-level passages with raw offsets. Headings label passages; code fences and image-only blocks are not quotable. */
@@ -85,8 +91,12 @@ export function passages(markdown, maxChars = 1200) {
85
91
  }
86
92
  return out;
87
93
  }
88
- function lastSentenceEnd(window) { let best = -1; for (const m of window.matchAll(/[.!?]["')\]]*(?=\s)/g))
89
- best = m.index + m[0].length; return best; }
94
+ function lastSentenceEnd(window) {
95
+ let best = -1;
96
+ for (const m of window.matchAll(/[.!?]["')\]]*(?=\s)/g))
97
+ best = m.index + m[0].length;
98
+ return best;
99
+ }
90
100
  const display = (p) => `[${p.id}] ${collapse(p.text)}`;
91
101
  /** Pack passages into sections of at most `budget` display characters, preferring heading boundaries past half a budget. */
92
102
  export function sectionsOf(list, budget) {
@@ -94,7 +104,8 @@ export function sectionsOf(list, budget) {
94
104
  let current = [], size = 0;
95
105
  for (const p of list) {
96
106
  const n = display(p).length + 1;
97
- if (current.length && (size + n > budget || p.heading !== current[current.length - 1].heading && size > budget / 2)) {
107
+ if (current.length &&
108
+ (size + n > budget || (p.heading !== current[current.length - 1].heading && size > budget / 2))) {
98
109
  out.push(current);
99
110
  current = [];
100
111
  size = 0;
@@ -107,28 +118,95 @@ export function sectionsOf(list, budget) {
107
118
  return out;
108
119
  }
109
120
  const sourceWords = (markdown) => words(markdown);
110
- export const quoteBudget = (markdown) => Math.min(L.totalQuoteWords, Math.max(L.quoteWords, Math.floor(sourceWords(markdown) * L.sourceQuoteShare)));
121
+ const quoteBudget = (markdown) => Math.min(L.totalQuoteWords, Math.max(L.quoteWords, Math.floor(sourceWords(markdown) * L.sourceQuoteShare)));
111
122
  const str = (maxLength) => ({ type: 'string', minLength: 1, maxLength });
112
123
  /** JSON Schema for the model's draft. Tags become an enum when a vocabulary is given. */
113
124
  export function draftSchema(passageCount, vocabulary) {
114
- const quote = { type: 'object', additionalProperties: false, required: ['passage', 'text'], properties: { passage: { type: 'integer', minimum: 1, maximum: Math.max(1, passageCount) }, text: str(300), attribution: str(200) } };
115
- const idea = { type: 'object', additionalProperties: false, required: ['claim', 'detail', 'quotes'], properties: { claim: str(200), detail: str(600), quotes: { type: 'array', minItems: 0, maxItems: L.maxQuotesPerIdea, items: quote } } };
116
- const tag = vocabulary?.length ? { type: 'string', enum: [...vocabulary] } : { type: 'string', pattern: '^[a-z0-9][a-z0-9-]{0,63}$' };
117
- return { type: 'object', additionalProperties: false, required: ['lead', 'ideas', 'tags'], properties: { lead: str(L.leadChars), context: str(600), ideas: { type: 'array', minItems: L.minIdeas, maxItems: L.maxIdeas, items: idea }, tags: { type: 'array', minItems: 0, maxItems: L.maxTags, items: tag } } };
125
+ const quote = {
126
+ type: 'object',
127
+ additionalProperties: false,
128
+ required: ['passage', 'text'],
129
+ properties: {
130
+ passage: { type: 'integer', minimum: 1, maximum: Math.max(1, passageCount) },
131
+ text: str(300),
132
+ attribution: str(200),
133
+ },
134
+ };
135
+ const idea = {
136
+ type: 'object',
137
+ additionalProperties: false,
138
+ required: ['claim', 'detail', 'quotes'],
139
+ properties: {
140
+ claim: str(200),
141
+ detail: str(600),
142
+ quotes: { type: 'array', minItems: 0, maxItems: L.maxQuotesPerIdea, items: quote },
143
+ },
144
+ };
145
+ const tag = vocabulary?.length
146
+ ? { type: 'string', enum: [...vocabulary] }
147
+ : { type: 'string', pattern: '^[a-z0-9][a-z0-9-]{0,63}$' };
148
+ return {
149
+ type: 'object',
150
+ additionalProperties: false,
151
+ required: ['lead', 'context', 'ideas', 'tags'],
152
+ properties: {
153
+ lead: str(L.leadChars),
154
+ context: str(600),
155
+ ideas: { type: 'array', minItems: L.minIdeas, maxItems: L.maxIdeas, items: idea },
156
+ tags: { type: 'array', minItems: 0, maxItems: L.maxTags, items: tag },
157
+ },
158
+ };
118
159
  }
119
- export function notesSchema(first, last) {
120
- return { type: 'object', additionalProperties: false, required: ['notes'], properties: { notes: { type: 'array', minItems: 1, maxItems: 4, items: { type: 'object', additionalProperties: false, required: ['point', 'passage', 'quote'], properties: { point: str(300), passage: { type: 'integer', minimum: first, maximum: last }, quote: str(300) } } } } };
160
+ function notesSchema(first, last) {
161
+ return {
162
+ type: 'object',
163
+ additionalProperties: false,
164
+ required: ['notes'],
165
+ properties: {
166
+ notes: {
167
+ type: 'array',
168
+ minItems: 1,
169
+ maxItems: 4,
170
+ items: {
171
+ type: 'object',
172
+ additionalProperties: false,
173
+ required: ['point', 'passage', 'quote'],
174
+ properties: { point: str(300), passage: { type: 'integer', minimum: first, maximum: last }, quote: str(300) },
175
+ },
176
+ },
177
+ },
178
+ };
121
179
  }
122
- const system = (budget, vocabulary) => `You write a Digest v2 of one source for a private reading library. Reply with JSON only, matching the schema.
123
- - lead: one sentence of at most ${L.leadChars} characters that names the author or organization when known and states the main finding.
124
- - context: optional, at most ${L.contextWords} words of essential background. Lead and context together stay within ${L.gistWords} words and ${L.gistSentences} sentences.
125
- - ideas: ${L.minIdeas} to ${L.maxIdeas}, in the order the author makes them. Each idea is a bold one-sentence claim with detail:
126
- - claim: one sentence of at most ${L.claimWords} words that asserts something; never a topic label.
127
- - detail: at most ${L.detailWords} words in at most ${L.detailSentences} sentences: evidence, mechanism or limits.
128
- - quotes: 0 to ${L.maxQuotesPerIdea} that support this idea, each copied character for character from one numbered passage, ${minQuoteWords} to ${L.quoteWords} words, with that passage number. No added quotation marks or ellipses. Give attribution (who said it) when it is not the author. Prefer reader highlights.
129
- - Attach at least ${L.minQuotes} and at most ${L.maxQuotes} quotes in all. All quotes together stay within ${budget} words and never overlap.
130
- - tags: 0 to ${L.maxTags} ${vocabulary?.length ? `from: ${vocabulary.join(', ')}` : 'lowercase kebab-case topics'}.
131
- Use only what the source says.`;
180
+ /**
181
+ * Style examples are invented sources shaped like reviewed digests (short verdict claims, dense details); no library
182
+ * text. They show voice only: the model must still draw every claim and number from the source it is given.
183
+ */
184
+ const styleExamples = `Style examples (invented sources; copy the voice, never the content):
185
+ Source: a database company's post on moving its write path to object storage.
186
+ lead: Acme moves Ledger's write path onto an append-only log in S3 and cuts p99 commit latency from 40 ms to 9 ms.
187
+ context: Postgres becomes a read replica rebuilt from the log. Batching makes object storage affordable; single-row workloads still get slower.
188
+ claim: The log is the database. | detail: Every write appends one immutable segment; Postgres is rebuilt from the log and serves reads only.
189
+ claim: Batching pays for the bucket. | detail: Commits wait up to 5 ms, so one PUT carries about 300 writes and request cost falls 40-fold.
190
+ claim: Small writers lose. | detail: Under ten writes per second, latency is higher than before the move.
191
+ Source: an essay arguing that taste is a skill.
192
+ lead: Mara Lind argues taste is trained by making and comparing many small things, not inherited, and that teams can schedule it.
193
+ claim: Taste is trained. | detail: Lind's students improved most after drawing fifty quick versions, not after studying masters.
194
+ claim: Volume beats polish early. | detail: Side-by-side comparison of many rough drafts sharpens judgment faster than finishing one.
195
+ Bad claim: The essay explores how practice over time shapes a person's sense of taste.
196
+ Good claim: Taste is trained.`;
197
+ const system = (budget, vocabulary) => `You write a Digest v2 of one source for a private reading library, in the owner's voice: terse, declarative, specific. Reply with JSON only, matching the schema.
198
+ - lead: one sentence of at most ${L.leadChars} characters stating this source's specific thesis or result: who (author, organization or product by name) shows or argues what, with the number, name or mechanism that makes it this source. Do not restate the title, describe the source ("This article discusses"), or tease.
199
+ - context: 1 or 2 sentences, at most 45 words: the evidence, method, numbers or limits the lead could not hold. Lead and context together stay within ${L.gistWords} words and ${L.gistSentences} sentences.
200
+ - ideas: ${L.minIdeas} to ${L.maxIdeas} (prefer ${L.maxIdeas} when the source has them), in the order the author makes them, covering the whole source including its caveats and limits. Each idea is a claim with detail:
201
+ - claim: a verdict of 4 to 8 words (never more than 12): subject, verb, point, naming this source's own subject ("Batching pays for the bucket", not "Costs drop"). Something a reader could disagree with; never a topic label or a description of the source.
202
+ - detail: one or two sentences, 15 to 30 words, dense with the source's own names, numbers, mechanism or limits behind the claim. Do not repeat the claim.
203
+ - quotes: 0 to ${L.maxQuotesPerIdea} that support this idea, each a contiguous span copied character for character from one numbered passage, ${minQuoteWords + 2} to 20 words, with that passage number. A quote is a fragment, not a whole long sentence: when a sentence runs past 20 words, copy only its strongest part. No added quotation marks or ellipses. Give attribution (who said it) when it is not the author. Prefer reader highlights.
204
+ - Attach at least ${L.minQuotes} and at most ${L.maxQuotes} quotes in all; ${L.minQuotes + 1} to 3 is typical. All quotes together stay within ${budget} words and never overlap.
205
+ - tags: 0 to ${L.maxTags} ${vocabulary?.length ? `from: ${vocabulary.join(', ')}` : 'lowercase kebab-case topics'}; usually one subject tag.
206
+ Plain words. Name things and use numbers. No hedges or filler: may, might, potentially, various, significantly, key, crucial, notably, highlights, explores, discusses, "the author argues", "the article".
207
+ Use only what the source says.
208
+
209
+ ${styleExamples}`;
132
210
  function sourceBlock(title, form, list, highlights) {
133
211
  let heading;
134
212
  const lines = [];
@@ -143,10 +221,26 @@ function sourceBlock(title, form, list, highlights) {
143
221
  const marks = highlights.map((h, i) => `[h${i + 1}${h.passage ? `, passage ${h.passage}` : ''}] ${collapse(h.text).slice(0, 400)}`);
144
222
  return `Form: ${form}${title ? `\nTitle: ${collapse(title).slice(0, 300)}` : ''}${marks.length ? `\n\nReader highlights (preferred quotes):\n${marks.join('\n')}` : ''}\n\nPassages:\n${lines.join('\n')}`;
145
223
  }
146
- const rule = { gist: `lead and context within ${L.gistWords} words and ${L.gistSentences} sentences`, lead: `one sentence, at most ${L.leadChars} characters`, context: `at most ${L.contextWords} words`, claim: `one distinct sentence, at most ${L.claimWords} words`, detail: `at most ${L.detailWords} words and ${L.detailSentences} sentences`, quotes: `0-${L.maxQuotesPerIdea} quotes per idea, each copied exactly from its passage, ${minQuoteWords} to ${L.quoteWords} words, not overlapping another quote`, attribution: `at most ${L.attributionWords} words`, ideas: `${L.minIdeas}-${L.maxIdeas} ideas; ${L.minQuotes}-${L.maxQuotes} quotes in all, within the total budget`, tags: `0-${L.maxTags} distinct allowed tags` };
224
+ const rule = {
225
+ gist: `lead and context within ${L.gistWords} words and ${L.gistSentences} sentences`,
226
+ lead: `one sentence, at most ${L.leadChars} characters`,
227
+ context: `at most ${L.contextWords} words`,
228
+ claim: `one distinct sentence, at most ${L.claimWords} words`,
229
+ detail: `at most ${L.detailWords} words and ${L.detailSentences} sentences`,
230
+ quotes: `0-${L.maxQuotesPerIdea} quotes per idea, each copied exactly from its passage, ${minQuoteWords} to ${L.quoteWords} words, not overlapping another quote`,
231
+ attribution: `at most ${L.attributionWords} words`,
232
+ ideas: `${L.minIdeas}-${L.maxIdeas} ideas; ${L.minQuotes}-${L.maxQuotes} quotes in all, within the total budget`,
233
+ tags: `0-${L.maxTags} distinct allowed tags`,
234
+ };
147
235
  function repairPrompt(issues) {
148
- const lines = issues.slice(0, 40).map(i => { const last = i.path.split('.').filter(s => !/^\d+$/.test(s)).pop() ?? ''; return `- ${i.path || 'output'}: ${i.code}${rule[last] ? ` (rule: ${rule[last]})` : ''}`; });
149
- return `That draft broke these rules:\n${lines.join('\n')}\nReturn the complete corrected JSON. Copy quotes exactly from the numbered passages.`;
236
+ const lines = issues.slice(0, 40).map((i) => {
237
+ const last = i.path
238
+ .split('.')
239
+ .filter((s) => !/^\d+$/.test(s))
240
+ .pop() ?? '';
241
+ return `- ${i.path || 'output'}: ${i.code}${rule[last] ? ` (rule: ${rule[last]})` : ''}`;
242
+ });
243
+ return `That draft broke these rules:\n${lines.join('\n')}\nReturn the complete corrected JSON. Copy quotes exactly from the numbered passages; shorten a too-long quote to its strongest contiguous 6 to 20 words.`;
150
244
  }
151
245
  /** Map the model's draft shape into Digest v2; parseDigest then decides whether the shape is valid. Ideas keep the model's order. */
152
246
  function fromModel(text, form) {
@@ -159,18 +253,31 @@ function fromModel(text, form) {
159
253
  }
160
254
  if (!object(v) || !Array.isArray(v.ideas) || !Array.isArray(v.tags))
161
255
  return { issues: [{ path: '', code: 'shape' }] };
162
- const trim = (s) => typeof s === 'string' ? s.trim() : s, cites = [];
256
+ const trim = (s) => (typeof s === 'string' ? s.trim() : s), cites = [];
163
257
  const ideas = v.ideas.map((idea) => {
164
258
  if (!object(idea)) {
165
259
  cites.push([]);
166
260
  return idea;
167
261
  }
168
262
  const quotes = Array.isArray(idea.quotes) ? idea.quotes : [];
169
- cites.push(quotes.map((q) => Number.isSafeInteger(q?.passage) ? q.passage : undefined));
170
- return { claim: trim(idea.claim), detail: trim(idea.detail), quotes: quotes.map((q) => { const attribution = trim(q?.attribution); return { text: trim(q?.text), ...(attribution ? { attribution } : {}) }; }) };
263
+ cites.push(quotes.map((q) => (Number.isSafeInteger(q?.passage) ? q.passage : undefined)));
264
+ return {
265
+ claim: trim(idea.claim),
266
+ detail: trim(idea.detail),
267
+ quotes: quotes.map((q) => {
268
+ const attribution = trim(q?.attribution);
269
+ return { text: trim(q?.text), ...(attribution ? { attribution } : {}) };
270
+ }),
271
+ };
171
272
  });
172
273
  const context = trim(v.context);
173
- const parsed = parseDigest({ schemaVersion: 2, form, gist: { lead: trim(v.lead), ...(context ? { context } : {}) }, ideas, tags: v.tags.map(trim) });
274
+ const parsed = parseDigest({
275
+ schemaVersion: 2,
276
+ form,
277
+ gist: { lead: trim(v.lead), ...(context ? { context } : {}) },
278
+ ideas,
279
+ tags: v.tags.map(trim),
280
+ });
174
281
  return parsed.digest?.schemaVersion === 2 ? { digest: parsed.digest, cites } : { issues: parsed.issues };
175
282
  }
176
283
  /**
@@ -181,7 +288,10 @@ export function snapQuote(source, text, within) {
181
288
  if (source.includes(text))
182
289
  return { text, match: 'exact' };
183
290
  const inside = (m) => !!within && m.start >= within.start && m.end <= within.end;
184
- const pick = (all) => { const local = all.filter(inside); return local.length === 1 ? local[0] : all.length === 1 ? all[0] : undefined; };
291
+ const pick = (all) => {
292
+ const local = all.filter(inside);
293
+ return local.length === 1 ? local[0] : all.length === 1 ? all[0] : undefined;
294
+ };
185
295
  const folded = locateAll(source, text);
186
296
  if (folded.length) {
187
297
  const m = pick(folded);
@@ -198,13 +308,39 @@ export function snapQuote(source, text, within) {
198
308
  end++;
199
309
  return { text: source.slice(start, end), match: 'snapped' };
200
310
  }
201
- /** Snap every quote in place and drop quotes under `minQuoteWords`. */
202
- export function snapQuotes(draft, source, list) {
203
- const stats = { exact: 0, folded: 0, snapped: 0, missing: 0, short: 0 }, byId = new Map(list.map(p => [p.id, p]));
311
+ /**
312
+ * A located quote over the word limit keeps its leading words: a prefix of a source span is still verbatim. Trailing
313
+ * clause punctuation goes; unlocated quotes are left for the checker.
314
+ */
315
+ export function trimQuote(text, max = L.quoteWords) {
316
+ if (words(text) <= max)
317
+ return text;
318
+ const ends = [...text.matchAll(/\S+/g)].map((m) => m.index + m[0].length);
319
+ let n = Math.min(max, ends.length), out = text;
320
+ do {
321
+ out = text.slice(0, ends[n - 1]).replace(/[\s,;:(\[\u2014\u2013-]+$/, '');
322
+ n--;
323
+ } while (n > 0 && words(out) > max);
324
+ return out;
325
+ }
326
+ /** Snap every quote in place, trim located quotes over the word limit, and drop quotes under `minQuoteWords`. */
327
+ function snapQuotes(draft, source, list) {
328
+ const stats = { exact: 0, folded: 0, snapped: 0, missing: 0, short: 0, trimmed: 0 }, byId = new Map(list.map((p) => [p.id, p]));
204
329
  draft.digest.ideas.forEach((idea, i) => {
205
- idea.quotes.forEach((q, j) => { const r = snapQuote(source, q.text, byId.get(draft.cites[i]?.[j] ?? 0)); q.text = r.text; stats[r.match]++; });
206
- const keep = idea.quotes.map(q => words(q.text) >= minQuoteWords);
207
- stats.short += keep.filter(k => !k).length;
330
+ idea.quotes.forEach((q, j) => {
331
+ const r = snapQuote(source, q.text, byId.get(draft.cites[i]?.[j] ?? 0));
332
+ q.text = r.text;
333
+ stats[r.match]++;
334
+ if (r.match !== 'missing') {
335
+ const t = trimQuote(q.text);
336
+ if (t !== q.text) {
337
+ q.text = t;
338
+ stats.trimmed++;
339
+ }
340
+ }
341
+ });
342
+ const keep = idea.quotes.map((q) => words(q.text) >= minQuoteWords);
343
+ stats.short += keep.filter((k) => !k).length;
208
344
  idea.quotes = idea.quotes.filter((_, j) => keep[j]);
209
345
  draft.cites[i] = (draft.cites[i] ?? []).filter((_, j) => keep[j]);
210
346
  });
@@ -216,11 +352,28 @@ const pruneable = new Set(['not-verbatim', 'overlap', 'unsupported', 'too-long',
216
352
  * budget (second quotes first, longest first), never the last quote. Quoteless ideas stay: v2 allows them.
217
353
  */
218
354
  function prune(draft, issues, source, options) {
219
- const dropped = { quotes: 0, ideas: 0, tags: 0 }, d = draft.digest, bad = new Set(issues.filter(i => /^ideas\.\d+\.quotes\.\d+(?:\.attribution)?$/.test(i.path) && pruneable.has(i.code)).map(i => i.path.replace(/\.attribution$/, '')));
220
- d.ideas.forEach((idea, i) => { const keep = idea.quotes.map((_, j) => !bad.has(`ideas.${i}.quotes.${j}`)).map((k, j) => k && j < L.maxQuotesPerIdea); dropped.quotes += keep.filter(k => !k).length; idea.quotes = idea.quotes.filter((_, j) => keep[j]); draft.cites[i] = (draft.cites[i] ?? []).filter((_, j) => keep[j]); });
355
+ const dropped = { quotes: 0, ideas: 0, tags: 0 }, d = draft.digest;
356
+ // Context is optional in Digest v2: an over-long gist loses its context rather than the draft.
357
+ if (d.gist.context !== undefined && issues.some((i) => i.path === 'gist' || i.path === 'gist.context'))
358
+ delete d.gist.context;
359
+ const bad = new Set(issues
360
+ .filter((i) => /^ideas\.\d+\.quotes\.\d+(?:\.attribution)?$/.test(i.path) && pruneable.has(i.code))
361
+ .map((i) => i.path.replace(/\.attribution$/, '')));
362
+ d.ideas.forEach((idea, i) => {
363
+ const keep = idea.quotes
364
+ .map((_, j) => !bad.has(`ideas.${i}.quotes.${j}`))
365
+ .map((k, j) => k && j < L.maxQuotesPerIdea);
366
+ dropped.quotes += keep.filter((k) => !k).length;
367
+ idea.quotes = idea.quotes.filter((_, j) => keep[j]);
368
+ draft.cites[i] = (draft.cites[i] ?? []).filter((_, j) => keep[j]);
369
+ });
221
370
  const seen = new Set();
222
- const keepIdea = d.ideas.map(idea => { const key = foldText(idea.claim).toLowerCase(), ok = !seen.has(key); seen.add(key); return ok; });
223
- dropped.ideas = keepIdea.filter(k => !k).length;
371
+ const keepIdea = d.ideas.map((idea) => {
372
+ const key = foldText(idea.claim).toLowerCase(), ok = !seen.has(key);
373
+ seen.add(key);
374
+ return ok;
375
+ });
376
+ dropped.ideas = keepIdea.filter((k) => !k).length;
224
377
  d.ideas = d.ideas.filter((_, i) => keepIdea[i]);
225
378
  draft.cites = draft.cites.filter((_, i) => keepIdea[i]);
226
379
  if (d.ideas.length > L.maxIdeas) {
@@ -228,7 +381,9 @@ function prune(draft, issues, source, options) {
228
381
  d.ideas = d.ideas.slice(0, L.maxIdeas);
229
382
  draft.cites = draft.cites.slice(0, L.maxIdeas);
230
383
  }
231
- const tags = [...new Set(d.tags)].filter(t => options.tags ? options.tags.includes(t) : /^[a-z0-9][a-z0-9-]{0,63}$/.test(t)).slice(0, L.maxTags);
384
+ const tags = [...new Set(d.tags)]
385
+ .filter((t) => (options.tags ? options.tags.includes(t) : /^[a-z0-9][a-z0-9-]{0,63}$/.test(t)))
386
+ .slice(0, L.maxTags);
232
387
  dropped.tags = d.tags.length - tags.length;
233
388
  d.tags = tags;
234
389
  const budget = quoteBudget(source), all = () => d.ideas.flatMap((idea, i) => idea.quotes.map((q, j) => ({ i, j, n: words(q.text) })));
@@ -240,76 +395,132 @@ function prune(draft, issues, source, options) {
240
395
  }
241
396
  return dropped;
242
397
  }
243
- const hashedParams = (p) => ({ temperature: p.temperature, seed: p.seed, maxTokens: p.maxTokens, contextChars: p.contextChars, maxSections: p.maxSections });
398
+ const hashedParams = (p) => ({
399
+ temperature: p.temperature,
400
+ seed: p.seed,
401
+ maxTokens: p.maxTokens,
402
+ contextChars: p.contextChars,
403
+ maxSections: p.maxSections,
404
+ });
244
405
  function resolveParams(params = {}) {
245
406
  const p = { ...defaultDigestParams, ...params }, int = (v, min, max) => Number.isSafeInteger(v) && v >= min && v <= max;
246
- if (!(p.temperature >= 0 && p.temperature <= 2) || !int(p.seed, 0, 2 ** 31) || !int(p.maxTokens, 256, 32768) || !int(p.contextChars, 2000, 1_000_000) || !int(p.maxSections, 1, 256) || !int(p.timeoutMs, 1, 3_600_000) || !int(p.deadlineMs, 1, 14_400_000) || !int(p.maxOutputBytes, 1024, 4 * 1024 * 1024))
247
- throw new DigestError('invalid-input');
407
+ if (!(p.temperature >= 0 && p.temperature <= 2) ||
408
+ !int(p.seed, 0, 2 ** 31) ||
409
+ !int(p.maxTokens, 256, 32768) ||
410
+ !int(p.contextChars, 2000, 1_000_000) ||
411
+ !int(p.maxSections, 1, 256) ||
412
+ !int(p.timeoutMs, 1, 3_600_000) ||
413
+ !int(p.deadlineMs, 1, 14_400_000) ||
414
+ !int(p.maxOutputBytes, 1024, 4 * 1024 * 1024))
415
+ throw new SpongeError('invalid-input');
248
416
  return p;
249
417
  }
250
418
  function validIdentity(id) {
251
419
  const ok = (v) => typeof v === 'string' && v.length > 0 && v.length <= 256 && !/[\u0000-\u001f\u007f]/.test(v);
252
420
  if (!object(id) || !ok(id.model) || !ok(id.quantization) || !ok(id.runtime))
253
- throw new DigestError('invalid-input');
421
+ throw new SpongeError('invalid-input');
254
422
  return { model: id.model, quantization: id.quantization, runtime: id.runtime };
255
423
  }
256
424
  /** sha256 over canonical JSON of the revision, its Markdown, the prompt version, the model identity and every output-shaping input. */
257
425
  export function digestInputHash(input) {
258
- return sha256(canonicalJson({ revisionId: input.revisionId, markdownSha256: sha256(input.markdown), promptVersion: PROMPT_VERSION, model: validIdentity(input.model), params: { ...hashedParams(resolveParams(input.params)), form: input.form, vocabulary: input.vocabulary ? [...input.vocabulary] : null, highlights: (input.highlights ?? []).map(h => h.text), support: input.embed ? input.supportThreshold ?? defaultSupportThreshold : null } }));
426
+ return sha256(canonicalJson({
427
+ revisionId: input.revisionId,
428
+ markdownSha256: sha256(input.markdown),
429
+ promptVersion: PROMPT_VERSION,
430
+ model: validIdentity(input.model),
431
+ params: {
432
+ ...hashedParams(resolveParams(input.params)),
433
+ form: input.form,
434
+ vocabulary: input.vocabulary ? [...input.vocabulary] : null,
435
+ highlights: (input.highlights ?? []).map((h) => h.text),
436
+ support: input.embed ? (input.supportThreshold ?? defaultSupportThreshold) : null,
437
+ },
438
+ }));
259
439
  }
260
440
  /** Unqualified on real claims: a deliberately low bar that only catches quotes unrelated to their claim. */
261
- export const defaultSupportThreshold = 0.35;
441
+ const defaultSupportThreshold = 0.35;
262
442
  export async function draftDigest(input) {
263
443
  const p = resolveParams(input.params), identity = validIdentity(input.model.identity);
264
444
  if (typeof input.markdown !== 'string' || !input.markdown.trim() || !forms.includes(input.form))
265
- throw new DigestError('invalid-input');
266
- if (input.vocabulary && (!input.vocabulary.length || input.vocabulary.length > 256 || input.vocabulary.some(t => !/^[a-z0-9][a-z0-9-]{0,63}$/.test(t))))
267
- throw new DigestError('invalid-input');
445
+ throw new SpongeError('invalid-input');
446
+ if (input.vocabulary &&
447
+ (!input.vocabulary.length ||
448
+ input.vocabulary.length > 256 ||
449
+ input.vocabulary.some((t) => !/^[a-z0-9][a-z0-9-]{0,63}$/.test(t))))
450
+ throw new SpongeError('invalid-input');
268
451
  const inputHash = digestInputHash({ ...input, model: identity }), source = input.markdown, list = passages(source);
269
452
  if (list.length === 0)
270
- throw new DigestError('invalid-input');
453
+ throw new SpongeError('invalid-input');
271
454
  const userSignal = input.signal ?? new AbortController().signal, overall = AbortSignal.any([userSignal, AbortSignal.timeout(p.deadlineMs)]);
272
- const trace = { strategy: 'single', sections: 1, calls: 0, repaired: false, attempts: [], dropped: { quotes: 0, ideas: 0, tags: 0 } };
455
+ const trace = {
456
+ strategy: 'single',
457
+ sections: 1,
458
+ calls: 0,
459
+ repaired: false,
460
+ attempts: [],
461
+ dropped: { quotes: 0, ideas: 0, tags: 0 },
462
+ };
273
463
  const call = async (messages, schema) => {
274
464
  if (userSignal.aborted)
275
- throw new DigestError('cancelled');
465
+ throw new SpongeError('cancelled');
276
466
  if (overall.aborted)
277
- throw new DigestError('limit-exceeded');
467
+ throw new SpongeError('limit-exceeded');
278
468
  const signal = AbortSignal.any([overall, AbortSignal.timeout(p.timeoutMs)]);
279
469
  trace.calls++;
280
470
  let text;
281
471
  try {
282
- text = await input.model.generate(messages, schema, { signal, maxOutputBytes: p.maxOutputBytes, maxTokens: p.maxTokens, temperature: p.temperature, seed: p.seed });
472
+ text = await input.model.generate(messages, schema, {
473
+ signal,
474
+ maxOutputBytes: p.maxOutputBytes,
475
+ maxTokens: p.maxTokens,
476
+ temperature: p.temperature,
477
+ seed: p.seed,
478
+ });
283
479
  }
284
480
  catch (error) {
285
481
  if (userSignal.aborted)
286
- throw new DigestError('cancelled');
482
+ throw new SpongeError('cancelled');
287
483
  if (signal.aborted)
288
- throw new DigestError('limit-exceeded');
289
- if (error instanceof DigestError)
484
+ throw new SpongeError('limit-exceeded');
485
+ if (error instanceof SpongeError)
290
486
  throw error;
291
- throw new DigestError('unavailable');
487
+ throw new SpongeError('unavailable');
292
488
  }
293
489
  if (userSignal.aborted)
294
- throw new DigestError('cancelled');
490
+ throw new SpongeError('cancelled');
295
491
  if (typeof text !== 'string' || Buffer.byteLength(text) > p.maxOutputBytes)
296
- throw new DigestError('limit-exceeded');
492
+ throw new SpongeError('limit-exceeded');
297
493
  return text;
298
494
  };
299
- const highlights = (input.highlights ?? []).filter(h => typeof h?.text === 'string' && h.text.trim()).slice(0, 32).map(h => { const at = h.start ?? locateAll(source, h.text)[0]?.start; return { text: h.text, passage: at === undefined ? undefined : list.find(x => at >= x.start && at < x.end)?.id }; });
495
+ const highlights = (input.highlights ?? [])
496
+ .filter((h) => typeof h?.text === 'string' && h.text.trim())
497
+ .slice(0, 32)
498
+ .map((h) => {
499
+ const at = h.start ?? locateAll(source, h.text)[0]?.start;
500
+ return {
501
+ text: h.text,
502
+ passage: at === undefined ? undefined : list.find((x) => at >= x.start && at < x.end)?.id,
503
+ };
504
+ });
300
505
  let shown = list, notesText;
301
506
  const total = list.reduce((n, x) => n + display(x).length + 1, 0);
302
507
  if (total > p.contextChars) {
303
508
  // Long source: notes per section, then one combined pass over the cited and highlighted passages only.
304
509
  const sections = sectionsOf(list, p.contextChars);
305
510
  if (sections.length > p.maxSections)
306
- throw new DigestError('limit-exceeded');
511
+ throw new SpongeError('limit-exceeded');
307
512
  trace.strategy = 'sections';
308
513
  trace.sections = sections.length;
309
514
  const notes = [];
310
515
  for (const [k, section] of sections.entries()) {
311
516
  const first = section[0].id, last = section[section.length - 1].id;
312
- const text = await call([{ role: 'system', content: `You take notes on part ${k + 1} of ${sections.length} of a longer source. Reply with JSON only. Give 1 to 4 notes, each a point of at most 25 words that the section asserts, with one passage number from this part and a short quote copied exactly from that passage.` }, { role: 'user', content: sourceBlock(input.title, input.form, section, []) }], notesSchema(first, last));
517
+ const text = await call([
518
+ {
519
+ role: 'system',
520
+ content: `You take notes on part ${k + 1} of ${sections.length} of a longer source. Reply with JSON only. Give 1 to 4 notes, each a point of at most 25 words that the section asserts, with one passage number from this part and a short quote copied exactly from that passage.`,
521
+ },
522
+ { role: 'user', content: sourceBlock(input.title, input.form, section, []) },
523
+ ], notesSchema(first, last));
313
524
  let v;
314
525
  try {
315
526
  v = JSON.parse(text);
@@ -319,11 +530,23 @@ export async function draftDigest(input) {
319
530
  }
320
531
  if (object(v) && Array.isArray(v.notes))
321
532
  for (const n of v.notes.slice(0, 4))
322
- if (object(n) && typeof n.point === 'string' && Number.isSafeInteger(n.passage) && n.passage >= first && n.passage <= last && typeof n.quote === 'string')
323
- notes.push({ point: collapse(n.point).slice(0, 300), passage: n.passage, quote: collapse(n.quote).slice(0, 300) });
533
+ if (object(n) &&
534
+ typeof n.point === 'string' &&
535
+ Number.isSafeInteger(n.passage) &&
536
+ n.passage >= first &&
537
+ n.passage <= last &&
538
+ typeof n.quote === 'string')
539
+ notes.push({
540
+ point: collapse(n.point).slice(0, 300),
541
+ passage: n.passage,
542
+ quote: collapse(n.quote).slice(0, 300),
543
+ });
324
544
  }
325
- const wanted = new Set([...notes.map(n => n.passage), ...highlights.flatMap(h => h.passage ? [h.passage] : [])]);
326
- notesText = notes.map(n => `- [${n.passage}] ${n.point}`).join('\n');
545
+ const wanted = new Set([
546
+ ...notes.map((n) => n.passage),
547
+ ...highlights.flatMap((h) => (h.passage ? [h.passage] : [])),
548
+ ]);
549
+ notesText = notes.map((n) => `- [${n.passage}] ${n.point}`).join('\n');
327
550
  shown = [];
328
551
  let size = notesText.length;
329
552
  for (const x of list)
@@ -332,21 +555,36 @@ export async function draftDigest(input) {
332
555
  size += display(x).length + 1;
333
556
  }
334
557
  if (!shown.length)
335
- throw new DigestError('unavailable', [{ path: '', code: 'no-notes' }]);
558
+ throw new SpongeError('unavailable', { issues: [{ path: '', code: 'no-notes' }] });
336
559
  }
337
560
  const budget = quoteBudget(source), schema = draftSchema(list.length, input.vocabulary);
338
- const messages = [{ role: 'system', content: system(budget, input.vocabulary) }, { role: 'user', content: (notesText ? `Notes from each part of the source, in order:\n${notesText}\n\n` : '') + sourceBlock(input.title, input.form, shown, highlights) }];
561
+ const messages = [
562
+ { role: 'system', content: system(budget, input.vocabulary) },
563
+ {
564
+ role: 'user',
565
+ content: (notesText ? `Notes from each part of the source, in order:\n${notesText}\n\n` : '') +
566
+ sourceBlock(input.title, input.form, shown, highlights),
567
+ },
568
+ ];
339
569
  const cache = new Map(), options = { ...(input.vocabulary ? { tags: input.vocabulary } : {}) };
340
570
  const embedKey = (t) => Buffer.from(foldText(t)).subarray(0, 900).toString();
341
571
  if (input.embed)
342
- Object.assign(options, { supportThreshold: input.supportThreshold ?? defaultSupportThreshold, supports: (claim, quote) => { const a = cache.get(embedKey(claim)), b = cache.get(embedKey(quote)); return a && b ? a.reduce((n, x, i) => n + x * b[i], 0) : 0; } });
572
+ Object.assign(options, {
573
+ supportThreshold: input.supportThreshold ?? defaultSupportThreshold,
574
+ supports: (claim, quote) => {
575
+ const a = cache.get(embedKey(claim)), b = cache.get(embedKey(quote));
576
+ return a && b ? a.reduce((n, x, i) => n + x * b[i], 0) : 0;
577
+ },
578
+ });
343
579
  const check = async (draft) => {
344
580
  if (input.embed) {
345
- const missing = [...new Set(draft.digest.ideas.flatMap(i => [i.claim, ...i.quotes.map(q => q.text)]).map(embedKey))].filter(t => !cache.has(t));
581
+ const missing = [
582
+ ...new Set(draft.digest.ideas.flatMap((i) => [i.claim, ...i.quotes.map((q) => q.text)]).map(embedKey)),
583
+ ].filter((t) => !cache.has(t));
346
584
  if (missing.length) {
347
585
  const vectors = await input.embed(missing, overall);
348
586
  if (!Array.isArray(vectors) || vectors.length !== missing.length)
349
- throw new DigestError('unavailable');
587
+ throw new SpongeError('unavailable');
350
588
  missing.forEach((t, i) => cache.set(t, vectors[i]));
351
589
  }
352
590
  }
@@ -370,30 +608,59 @@ export async function draftDigest(input) {
370
608
  result = await attempt(await call([...messages, { role: 'assistant', content: first }, { role: 'user', content: repairPrompt(result.issues) }], schema));
371
609
  }
372
610
  if (!result.draft)
373
- throw new DigestError('unavailable', result.issues);
611
+ throw new SpongeError('unavailable', { issues: result.issues });
374
612
  let issues = result.issues;
375
613
  if (issues.length) {
376
614
  trace.dropped = prune(result.draft, issues, source, options);
377
615
  issues = await check(result.draft);
378
616
  if (result.draft.digest.ideas.length < L.minIdeas)
379
- issues = [...issues.filter(i => !(i.path === 'ideas' && i.code === 'count')), { path: 'ideas', code: 'count' }];
617
+ issues = [...issues.filter((i) => !(i.path === 'ideas' && i.code === 'count')), { path: 'ideas', code: 'count' }];
380
618
  if (issues.length)
381
- throw new DigestError('unavailable', issues);
619
+ throw new SpongeError('unavailable', { issues });
382
620
  }
383
- const producer = { pipeline: 'sponge-digest', promptVersion: PROMPT_VERSION, model: identity, params: hashedParams(p), strategy: trace.strategy, sections: trace.sections, calls: trace.calls, repaired: trace.repaired, dropped: trace.dropped, ...(input.embed ? { supportThreshold: options.supportThreshold } : {}) };
621
+ const producer = {
622
+ pipeline: 'sponge-digest',
623
+ promptVersion: PROMPT_VERSION,
624
+ model: identity,
625
+ params: hashedParams(p),
626
+ strategy: trace.strategy,
627
+ sections: trace.sections,
628
+ calls: trace.calls,
629
+ repaired: trace.repaired,
630
+ dropped: trace.dropped,
631
+ ...(input.embed ? { supportThreshold: options.supportThreshold } : {}),
632
+ };
384
633
  return { output: result.draft.digest, inputHash, producer, options, trace };
385
634
  }
386
- const formFor = (kind) => kind === 'pdf' ? 'pdf' : kind === 'media' ? 'video' : 'article';
635
+ const formFor = (kind) => (kind === 'pdf' ? 'pdf' : kind === 'media' ? 'video' : 'article');
387
636
  /** Draft and store a digest for the item's current revision. A matching input hash returns the stored draft without calling the model. */
388
637
  export async function createDigest(store, itemId, model, options = {}) {
389
638
  const item = store.read(itemId);
390
639
  if (!item)
391
- throw new StoreError('unavailable');
392
- const highlights = store.listAnnotations(itemId).filter(a => a.kind === 'highlight' && a.revisionId === item.revisionId && a.anchored !== false).map(a => ({ text: a.anchor.exact, start: a.anchor.start }));
393
- const input = { ...options, revisionId: item.revisionId, markdown: item.markdown, title: item.title, form: formFor(item.kind), model, highlights };
394
- const prior = store.findDigest(item.revisionId, digestInputHash({ ...input, model: model.identity }));
640
+ throw new SpongeError('unavailable');
641
+ const highlights = store.annotations
642
+ .list(itemId)
643
+ .filter((a) => a.kind === 'highlight' && a.revisionId === item.revisionId && a.anchored !== false)
644
+ .map((a) => ({ text: a.anchor.exact, start: a.anchor.start }));
645
+ const input = {
646
+ ...options,
647
+ revisionId: item.revisionId,
648
+ markdown: item.markdown,
649
+ title: item.title,
650
+ form: formFor(item.kind),
651
+ model,
652
+ highlights,
653
+ };
654
+ const prior = store.derivations.find('digest', item.revisionId, digestInputHash({ ...input, model: model.identity }));
395
655
  if (prior)
396
656
  return { derivation: prior, duplicate: true };
397
- const draft = await draftDigest(input), stored = store.putDigest({ itemId, revisionId: item.revisionId, producer: draft.producer, inputHash: draft.inputHash, output: draft.output, options: draft.options });
398
- return { derivation: store.readDigest(stored.id), duplicate: stored.duplicate, trace: draft.trace };
657
+ const draft = await draftDigest(input), stored = store.derivations.put('digest', {
658
+ itemId,
659
+ revisionId: item.revisionId,
660
+ producer: draft.producer,
661
+ inputHash: draft.inputHash,
662
+ output: draft.output,
663
+ options: draft.options,
664
+ });
665
+ return { derivation: store.derivations.read('digest', stored.id), duplicate: stored.duplicate, trace: draft.trace };
399
666
  }