@hraness/sponge 0.2.5 → 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 +23 -10
  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 +39 -17
  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 +10 -46
  64. package/dist/src/digest/editorial.js +73 -59
  65. package/dist/src/digest/endpoint.js +63 -27
  66. package/dist/src/digest/pipeline.d.ts +53 -32
  67. package/dist/src/digest/pipeline.js +357 -125
  68. package/dist/src/digest/schema.d.ts +44 -17
  69. package/dist/src/digest/schema.js +132 -44
  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 +31 -24
  82. package/dist/src/importers/jungle-digest.js +217 -115
  83. package/dist/src/importers/jungle-store.d.ts +20 -11
  84. package/dist/src/importers/jungle-store.js +136 -41
  85. package/dist/src/importers/jungle.d.ts +32 -16
  86. package/dist/src/importers/jungle.js +145 -32
  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 +108 -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 -77
  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, ideaKinds, 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
- * Model-agnostic Digest v1 drafting. The model sees numbered passages and cites a passage id plus a short exact
7
- * span; code snaps, orders, prunes and checks. Nothing leaves this module except through the injected model.
6
+ * Model-agnostic Digest v2 drafting. The model sees numbered passages and cites a passage id plus a short exact
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-v1';
9
+ export const PROMPT_VERSION = 'sponge-digest-prompt-v4';
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,29 +118,74 @@ 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), speaker: str(120) } };
115
- const idea = { type: 'object', additionalProperties: false, required: ['claim', 'detail', 'kind', 'voice', 'quotes'], properties: { claim: str(200), detail: str(600), kind: { type: 'string', enum: [...ideaKinds] }, voice: { type: 'object', additionalProperties: false, required: ['type'], properties: { type: { type: 'string', enum: ['author', 'speaker', 'reported'] }, name: str(120) } }, quotes: { type: 'array', minItems: 1, 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(400), ideas: { type: 'array', minItems: L.minIdeas, maxItems: L.maxIdeas, items: idea }, tags: { type: 'array', minItems: 1, 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', '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 v1 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.
125
- - ideas: ${L.minIdeas} to ${L.maxIdeas}, in the order they appear in the source. Each idea has:
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
- - kind: ${ideaKinds.join(', ')}.
129
- - voice: "author" when the author makes the claim; "speaker" with name for a speaker in a talk or interview; "reported" with name when the author reports someone else's claim.
130
- - quotes: 1 or ${L.maxQuotesPerIdea}, 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 speaker when the voice is not the author. Prefer reader highlights.
131
- - All quotes together stay within ${budget} words and never overlap.
132
- - tags: 1 to ${L.maxTags} ${vocabulary?.length ? `from: ${vocabulary.join(', ')}` : 'lowercase kebab-case topics'}.
180
+ const system = (budget, vocabulary) => `You write a Digest v2 of one source for a private reading library. Reply with JSON only, matching the schema.
181
+ - lead: one dense sentence of at most ${L.leadChars} characters: who (author, organization or product by name) did or argues what, with the concrete specifics that make it this source (names, numbers, mechanism). Not a teaser.
182
+ - context: optional, at most ${L.contextWords} words: the key evidence, numbers or limits the lead could not hold. Lead and context together stay within ${L.gistWords} words and ${L.gistSentences} sentences.
183
+ - 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:
184
+ - claim: one short sentence of at most ${L.claimWords} words (often 4 to 10) that asserts something; never a topic label.
185
+ - detail: at most ${L.detailWords} words in at most ${L.detailSentences} sentences: the specific evidence, mechanism, numbers or limits from the source.
186
+ - 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.
187
+ - 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.
188
+ - tags: 0 to ${L.maxTags} ${vocabulary?.length ? `from: ${vocabulary.join(', ')}` : 'lowercase kebab-case topics'}; usually one subject tag.
133
189
  Use only what the source says.`;
134
190
  function sourceBlock(title, form, list, highlights) {
135
191
  let heading;
@@ -145,12 +201,28 @@ function sourceBlock(title, form, list, highlights) {
145
201
  const marks = highlights.map((h, i) => `[h${i + 1}${h.passage ? `, passage ${h.passage}` : ''}] ${collapse(h.text).slice(0, 400)}`);
146
202
  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')}`;
147
203
  }
148
- const rule = { 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: `1-${L.maxQuotesPerIdea} quotes, each copied exactly from its passage, ${minQuoteWords} to ${L.quoteWords} words, not overlapping another quote`, ideas: `${L.minIdeas}-${L.maxIdeas} ideas; quotes within the total budget`, tags: `1-${L.maxTags} distinct allowed tags` };
204
+ const rule = {
205
+ gist: `lead and context within ${L.gistWords} words and ${L.gistSentences} sentences`,
206
+ lead: `one sentence, at most ${L.leadChars} characters`,
207
+ context: `at most ${L.contextWords} words`,
208
+ claim: `one distinct sentence, at most ${L.claimWords} words`,
209
+ detail: `at most ${L.detailWords} words and ${L.detailSentences} sentences`,
210
+ quotes: `0-${L.maxQuotesPerIdea} quotes per idea, each copied exactly from its passage, ${minQuoteWords} to ${L.quoteWords} words, not overlapping another quote`,
211
+ attribution: `at most ${L.attributionWords} words`,
212
+ ideas: `${L.minIdeas}-${L.maxIdeas} ideas; ${L.minQuotes}-${L.maxQuotes} quotes in all, within the total budget`,
213
+ tags: `0-${L.maxTags} distinct allowed tags`,
214
+ };
149
215
  function repairPrompt(issues) {
150
- 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]})` : ''}`; });
151
- return `That draft broke these rules:\n${lines.join('\n')}\nReturn the complete corrected JSON. Copy quotes exactly from the numbered passages.`;
216
+ const lines = issues.slice(0, 40).map((i) => {
217
+ const last = i.path
218
+ .split('.')
219
+ .filter((s) => !/^\d+$/.test(s))
220
+ .pop() ?? '';
221
+ return `- ${i.path || 'output'}: ${i.code}${rule[last] ? ` (rule: ${rule[last]})` : ''}`;
222
+ });
223
+ 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.`;
152
224
  }
153
- /** Map the model's draft shape into DigestV1; parseDigest then decides whether the shape is valid. */
225
+ /** Map the model's draft shape into Digest v2; parseDigest then decides whether the shape is valid. Ideas keep the model's order. */
154
226
  function fromModel(text, form) {
155
227
  let v;
156
228
  try {
@@ -161,20 +233,32 @@ function fromModel(text, form) {
161
233
  }
162
234
  if (!object(v) || !Array.isArray(v.ideas) || !Array.isArray(v.tags))
163
235
  return { issues: [{ path: '', code: 'shape' }] };
164
- const trim = (s) => typeof s === 'string' ? s.trim() : s, cites = [];
236
+ const trim = (s) => (typeof s === 'string' ? s.trim() : s), cites = [];
165
237
  const ideas = v.ideas.map((idea) => {
166
238
  if (!object(idea)) {
167
239
  cites.push([]);
168
240
  return idea;
169
241
  }
170
- const voice = object(idea.voice) && idea.voice.type !== 'author' ? { type: idea.voice.type, name: trim(idea.voice.name) } : { type: 'author' };
171
242
  const quotes = Array.isArray(idea.quotes) ? idea.quotes : [];
172
- cites.push(quotes.map((q) => Number.isSafeInteger(q?.passage) ? q.passage : undefined));
173
- return { claim: trim(idea.claim), detail: trim(idea.detail), kind: idea.kind, voice, quotes: quotes.map((q) => { const speaker = trim(q?.speaker) || (voice.type !== 'author' ? voice.name : undefined); return { text: trim(q?.text), ...(speaker ? { speaker } : {}) }; }) };
243
+ cites.push(quotes.map((q) => (Number.isSafeInteger(q?.passage) ? q.passage : undefined)));
244
+ return {
245
+ claim: trim(idea.claim),
246
+ detail: trim(idea.detail),
247
+ quotes: quotes.map((q) => {
248
+ const attribution = trim(q?.attribution);
249
+ return { text: trim(q?.text), ...(attribution ? { attribution } : {}) };
250
+ }),
251
+ };
174
252
  });
175
253
  const context = trim(v.context);
176
- const parsed = parseDigest({ schemaVersion: 1, form, gist: { lead: trim(v.lead), ...(context ? { context } : {}) }, ideas, tags: v.tags.map(trim) });
177
- return parsed.digest ? { digest: parsed.digest, cites } : { issues: parsed.issues };
254
+ const parsed = parseDigest({
255
+ schemaVersion: 2,
256
+ form,
257
+ gist: { lead: trim(v.lead), ...(context ? { context } : {}) },
258
+ ideas,
259
+ tags: v.tags.map(trim),
260
+ });
261
+ return parsed.digest?.schemaVersion === 2 ? { digest: parsed.digest, cites } : { issues: parsed.issues };
178
262
  }
179
263
  /**
180
264
  * Make a quote exactly verbatim when that is unambiguous: a unique folded or Markdown-insensitive match, inside
@@ -184,7 +268,10 @@ export function snapQuote(source, text, within) {
184
268
  if (source.includes(text))
185
269
  return { text, match: 'exact' };
186
270
  const inside = (m) => !!within && m.start >= within.start && m.end <= within.end;
187
- const pick = (all) => { const local = all.filter(inside); return local.length === 1 ? local[0] : all.length === 1 ? all[0] : undefined; };
271
+ const pick = (all) => {
272
+ const local = all.filter(inside);
273
+ return local.length === 1 ? local[0] : all.length === 1 ? all[0] : undefined;
274
+ };
188
275
  const folded = locateAll(source, text);
189
276
  if (folded.length) {
190
277
  const m = pick(folded);
@@ -201,34 +288,68 @@ export function snapQuote(source, text, within) {
201
288
  end++;
202
289
  return { text: source.slice(start, end), match: 'snapped' };
203
290
  }
204
- /** Snap every quote in place and drop quotes under `minQuoteWords`. */
205
- export function snapQuotes(draft, source, list) {
206
- const stats = { exact: 0, folded: 0, snapped: 0, missing: 0, short: 0 }, byId = new Map(list.map(p => [p.id, p]));
291
+ /**
292
+ * A located quote over the word limit keeps its leading words: a prefix of a source span is still verbatim. Trailing
293
+ * clause punctuation goes; unlocated quotes are left for the checker.
294
+ */
295
+ export function trimQuote(text, max = L.quoteWords) {
296
+ if (words(text) <= max)
297
+ return text;
298
+ const ends = [...text.matchAll(/\S+/g)].map((m) => m.index + m[0].length);
299
+ let n = Math.min(max, ends.length), out = text;
300
+ do {
301
+ out = text.slice(0, ends[n - 1]).replace(/[\s,;:(\[\u2014\u2013-]+$/, '');
302
+ n--;
303
+ } while (n > 0 && words(out) > max);
304
+ return out;
305
+ }
306
+ /** Snap every quote in place, trim located quotes over the word limit, and drop quotes under `minQuoteWords`. */
307
+ function snapQuotes(draft, source, list) {
308
+ const stats = { exact: 0, folded: 0, snapped: 0, missing: 0, short: 0, trimmed: 0 }, byId = new Map(list.map((p) => [p.id, p]));
207
309
  draft.digest.ideas.forEach((idea, i) => {
208
- 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]++; });
209
- const keep = idea.quotes.map(q => words(q.text) >= minQuoteWords);
210
- stats.short += keep.filter(k => !k).length;
310
+ idea.quotes.forEach((q, j) => {
311
+ const r = snapQuote(source, q.text, byId.get(draft.cites[i]?.[j] ?? 0));
312
+ q.text = r.text;
313
+ stats[r.match]++;
314
+ if (r.match !== 'missing') {
315
+ const t = trimQuote(q.text);
316
+ if (t !== q.text) {
317
+ q.text = t;
318
+ stats.trimmed++;
319
+ }
320
+ }
321
+ });
322
+ const keep = idea.quotes.map((q) => words(q.text) >= minQuoteWords);
323
+ stats.short += keep.filter((k) => !k).length;
211
324
  idea.quotes = idea.quotes.filter((_, j) => keep[j]);
212
325
  draft.cites[i] = (draft.cites[i] ?? []).filter((_, j) => keep[j]);
213
326
  });
214
327
  return stats;
215
328
  }
216
- /** Ideas and their quotes in source order, by first located occurrence: the checker requires non-decreasing idea starts. */
217
- function order(draft, source) {
218
- const at = (text) => locateAll(source, text)[0]?.start ?? Number.MAX_SAFE_INTEGER;
219
- const rows = draft.digest.ideas.map((idea, i) => { const qs = idea.quotes.map((q, j) => ({ q, cite: draft.cites[i]?.[j], at: at(q.text) })).sort((a, b) => a.at - b.at); return { idea: { ...idea, quotes: qs.map(x => x.q) }, cites: qs.map(x => x.cite), at: qs[0]?.at ?? Number.MAX_SAFE_INTEGER }; });
220
- rows.sort((a, b) => a.at - b.at);
221
- draft.digest.ideas = rows.map(r => r.idea);
222
- draft.cites = rows.map(r => r.cites);
223
- }
224
329
  const pruneable = new Set(['not-verbatim', 'overlap', 'unsupported', 'too-long', 'speaker-required']);
225
- /** Deterministic last resort: drop failing quotes, then quoteless or duplicate ideas, invalid tags, and second quotes over budget. */
330
+ /**
331
+ * Deterministic last resort: drop failing quotes, then duplicate ideas and invalid tags, then quotes over the count or word
332
+ * budget (second quotes first, longest first), never the last quote. Quoteless ideas stay: v2 allows them.
333
+ */
226
334
  function prune(draft, issues, source, options) {
227
- const dropped = { quotes: 0, ideas: 0, tags: 0 }, d = draft.digest, bad = new Set(issues.filter(i => /^ideas\.\d+\.quotes\.\d+$/.test(i.path) && pruneable.has(i.code)).map(i => i.path));
228
- 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]); });
335
+ const dropped = { quotes: 0, ideas: 0, tags: 0 }, d = draft.digest, bad = new Set(issues
336
+ .filter((i) => /^ideas\.\d+\.quotes\.\d+(?:\.attribution)?$/.test(i.path) && pruneable.has(i.code))
337
+ .map((i) => i.path.replace(/\.attribution$/, '')));
338
+ d.ideas.forEach((idea, i) => {
339
+ const keep = idea.quotes
340
+ .map((_, j) => !bad.has(`ideas.${i}.quotes.${j}`))
341
+ .map((k, j) => k && j < L.maxQuotesPerIdea);
342
+ dropped.quotes += keep.filter((k) => !k).length;
343
+ idea.quotes = idea.quotes.filter((_, j) => keep[j]);
344
+ draft.cites[i] = (draft.cites[i] ?? []).filter((_, j) => keep[j]);
345
+ });
229
346
  const seen = new Set();
230
- const keepIdea = d.ideas.map(idea => { const key = foldText(idea.claim).toLowerCase(), ok = idea.quotes.length > 0 && !seen.has(key); seen.add(key); return ok; });
231
- dropped.ideas = keepIdea.filter(k => !k).length;
347
+ const keepIdea = d.ideas.map((idea) => {
348
+ const key = foldText(idea.claim).toLowerCase(), ok = !seen.has(key);
349
+ seen.add(key);
350
+ return ok;
351
+ });
352
+ dropped.ideas = keepIdea.filter((k) => !k).length;
232
353
  d.ideas = d.ideas.filter((_, i) => keepIdea[i]);
233
354
  draft.cites = draft.cites.filter((_, i) => keepIdea[i]);
234
355
  if (d.ideas.length > L.maxIdeas) {
@@ -236,90 +357,146 @@ function prune(draft, issues, source, options) {
236
357
  d.ideas = d.ideas.slice(0, L.maxIdeas);
237
358
  draft.cites = draft.cites.slice(0, L.maxIdeas);
238
359
  }
239
- 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);
360
+ const tags = [...new Set(d.tags)]
361
+ .filter((t) => (options.tags ? options.tags.includes(t) : /^[a-z0-9][a-z0-9-]{0,63}$/.test(t)))
362
+ .slice(0, L.maxTags);
240
363
  dropped.tags = d.tags.length - tags.length;
241
364
  d.tags = tags;
242
- const budget = quoteBudget(source), total = () => d.ideas.reduce((n, idea) => n + idea.quotes.reduce((m, q) => m + words(q.text), 0), 0);
243
- while (total() > budget) {
244
- const i = d.ideas.map((idea, k) => ({ k, n: idea.quotes.length > 1 ? words(idea.quotes[1].text) : -1 })).sort((a, b) => b.n - a.n)[0];
245
- if (!i || i.n < 0)
246
- break;
247
- d.ideas[i.k].quotes.pop();
248
- draft.cites[i.k]?.pop();
365
+ const budget = quoteBudget(source), all = () => d.ideas.flatMap((idea, i) => idea.quotes.map((q, j) => ({ i, j, n: words(q.text) })));
366
+ for (let q = all(); q.length > 1 && (q.length > L.maxQuotes || q.reduce((n, x) => n + x.n, 0) > budget); q = all()) {
367
+ const x = q.sort((a, b) => b.j - a.j || b.n - a.n)[0];
368
+ d.ideas[x.i].quotes.splice(x.j, 1);
369
+ draft.cites[x.i]?.splice(x.j, 1);
249
370
  dropped.quotes++;
250
371
  }
251
372
  return dropped;
252
373
  }
253
- const hashedParams = (p) => ({ temperature: p.temperature, seed: p.seed, maxTokens: p.maxTokens, contextChars: p.contextChars, maxSections: p.maxSections });
374
+ const hashedParams = (p) => ({
375
+ temperature: p.temperature,
376
+ seed: p.seed,
377
+ maxTokens: p.maxTokens,
378
+ contextChars: p.contextChars,
379
+ maxSections: p.maxSections,
380
+ });
254
381
  function resolveParams(params = {}) {
255
382
  const p = { ...defaultDigestParams, ...params }, int = (v, min, max) => Number.isSafeInteger(v) && v >= min && v <= max;
256
- 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))
257
- throw new DigestError('invalid-input');
383
+ if (!(p.temperature >= 0 && p.temperature <= 2) ||
384
+ !int(p.seed, 0, 2 ** 31) ||
385
+ !int(p.maxTokens, 256, 32768) ||
386
+ !int(p.contextChars, 2000, 1_000_000) ||
387
+ !int(p.maxSections, 1, 256) ||
388
+ !int(p.timeoutMs, 1, 3_600_000) ||
389
+ !int(p.deadlineMs, 1, 14_400_000) ||
390
+ !int(p.maxOutputBytes, 1024, 4 * 1024 * 1024))
391
+ throw new SpongeError('invalid-input');
258
392
  return p;
259
393
  }
260
394
  function validIdentity(id) {
261
395
  const ok = (v) => typeof v === 'string' && v.length > 0 && v.length <= 256 && !/[\u0000-\u001f\u007f]/.test(v);
262
396
  if (!object(id) || !ok(id.model) || !ok(id.quantization) || !ok(id.runtime))
263
- throw new DigestError('invalid-input');
397
+ throw new SpongeError('invalid-input');
264
398
  return { model: id.model, quantization: id.quantization, runtime: id.runtime };
265
399
  }
266
400
  /** sha256 over canonical JSON of the revision, its Markdown, the prompt version, the model identity and every output-shaping input. */
267
401
  export function digestInputHash(input) {
268
- 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 } }));
402
+ return sha256(canonicalJson({
403
+ revisionId: input.revisionId,
404
+ markdownSha256: sha256(input.markdown),
405
+ promptVersion: PROMPT_VERSION,
406
+ model: validIdentity(input.model),
407
+ params: {
408
+ ...hashedParams(resolveParams(input.params)),
409
+ form: input.form,
410
+ vocabulary: input.vocabulary ? [...input.vocabulary] : null,
411
+ highlights: (input.highlights ?? []).map((h) => h.text),
412
+ support: input.embed ? (input.supportThreshold ?? defaultSupportThreshold) : null,
413
+ },
414
+ }));
269
415
  }
270
416
  /** Unqualified on real claims: a deliberately low bar that only catches quotes unrelated to their claim. */
271
- export const defaultSupportThreshold = 0.35;
417
+ const defaultSupportThreshold = 0.35;
272
418
  export async function draftDigest(input) {
273
419
  const p = resolveParams(input.params), identity = validIdentity(input.model.identity);
274
420
  if (typeof input.markdown !== 'string' || !input.markdown.trim() || !forms.includes(input.form))
275
- throw new DigestError('invalid-input');
276
- if (input.vocabulary && (!input.vocabulary.length || input.vocabulary.length > 256 || input.vocabulary.some(t => !/^[a-z0-9][a-z0-9-]{0,63}$/.test(t))))
277
- throw new DigestError('invalid-input');
421
+ throw new SpongeError('invalid-input');
422
+ if (input.vocabulary &&
423
+ (!input.vocabulary.length ||
424
+ input.vocabulary.length > 256 ||
425
+ input.vocabulary.some((t) => !/^[a-z0-9][a-z0-9-]{0,63}$/.test(t))))
426
+ throw new SpongeError('invalid-input');
278
427
  const inputHash = digestInputHash({ ...input, model: identity }), source = input.markdown, list = passages(source);
279
428
  if (list.length === 0)
280
- throw new DigestError('invalid-input');
429
+ throw new SpongeError('invalid-input');
281
430
  const userSignal = input.signal ?? new AbortController().signal, overall = AbortSignal.any([userSignal, AbortSignal.timeout(p.deadlineMs)]);
282
- const trace = { strategy: 'single', sections: 1, calls: 0, repaired: false, attempts: [], dropped: { quotes: 0, ideas: 0, tags: 0 } };
431
+ const trace = {
432
+ strategy: 'single',
433
+ sections: 1,
434
+ calls: 0,
435
+ repaired: false,
436
+ attempts: [],
437
+ dropped: { quotes: 0, ideas: 0, tags: 0 },
438
+ };
283
439
  const call = async (messages, schema) => {
284
440
  if (userSignal.aborted)
285
- throw new DigestError('cancelled');
441
+ throw new SpongeError('cancelled');
286
442
  if (overall.aborted)
287
- throw new DigestError('limit-exceeded');
443
+ throw new SpongeError('limit-exceeded');
288
444
  const signal = AbortSignal.any([overall, AbortSignal.timeout(p.timeoutMs)]);
289
445
  trace.calls++;
290
446
  let text;
291
447
  try {
292
- text = await input.model.generate(messages, schema, { signal, maxOutputBytes: p.maxOutputBytes, maxTokens: p.maxTokens, temperature: p.temperature, seed: p.seed });
448
+ text = await input.model.generate(messages, schema, {
449
+ signal,
450
+ maxOutputBytes: p.maxOutputBytes,
451
+ maxTokens: p.maxTokens,
452
+ temperature: p.temperature,
453
+ seed: p.seed,
454
+ });
293
455
  }
294
456
  catch (error) {
295
457
  if (userSignal.aborted)
296
- throw new DigestError('cancelled');
458
+ throw new SpongeError('cancelled');
297
459
  if (signal.aborted)
298
- throw new DigestError('limit-exceeded');
299
- if (error instanceof DigestError)
460
+ throw new SpongeError('limit-exceeded');
461
+ if (error instanceof SpongeError)
300
462
  throw error;
301
- throw new DigestError('unavailable');
463
+ throw new SpongeError('unavailable');
302
464
  }
303
465
  if (userSignal.aborted)
304
- throw new DigestError('cancelled');
466
+ throw new SpongeError('cancelled');
305
467
  if (typeof text !== 'string' || Buffer.byteLength(text) > p.maxOutputBytes)
306
- throw new DigestError('limit-exceeded');
468
+ throw new SpongeError('limit-exceeded');
307
469
  return text;
308
470
  };
309
- 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 }; });
471
+ const highlights = (input.highlights ?? [])
472
+ .filter((h) => typeof h?.text === 'string' && h.text.trim())
473
+ .slice(0, 32)
474
+ .map((h) => {
475
+ const at = h.start ?? locateAll(source, h.text)[0]?.start;
476
+ return {
477
+ text: h.text,
478
+ passage: at === undefined ? undefined : list.find((x) => at >= x.start && at < x.end)?.id,
479
+ };
480
+ });
310
481
  let shown = list, notesText;
311
482
  const total = list.reduce((n, x) => n + display(x).length + 1, 0);
312
483
  if (total > p.contextChars) {
313
484
  // Long source: notes per section, then one combined pass over the cited and highlighted passages only.
314
485
  const sections = sectionsOf(list, p.contextChars);
315
486
  if (sections.length > p.maxSections)
316
- throw new DigestError('limit-exceeded');
487
+ throw new SpongeError('limit-exceeded');
317
488
  trace.strategy = 'sections';
318
489
  trace.sections = sections.length;
319
490
  const notes = [];
320
491
  for (const [k, section] of sections.entries()) {
321
492
  const first = section[0].id, last = section[section.length - 1].id;
322
- 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));
493
+ const text = await call([
494
+ {
495
+ role: 'system',
496
+ 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.`,
497
+ },
498
+ { role: 'user', content: sourceBlock(input.title, input.form, section, []) },
499
+ ], notesSchema(first, last));
323
500
  let v;
324
501
  try {
325
502
  v = JSON.parse(text);
@@ -329,11 +506,23 @@ export async function draftDigest(input) {
329
506
  }
330
507
  if (object(v) && Array.isArray(v.notes))
331
508
  for (const n of v.notes.slice(0, 4))
332
- if (object(n) && typeof n.point === 'string' && Number.isSafeInteger(n.passage) && n.passage >= first && n.passage <= last && typeof n.quote === 'string')
333
- notes.push({ point: collapse(n.point).slice(0, 300), passage: n.passage, quote: collapse(n.quote).slice(0, 300) });
509
+ if (object(n) &&
510
+ typeof n.point === 'string' &&
511
+ Number.isSafeInteger(n.passage) &&
512
+ n.passage >= first &&
513
+ n.passage <= last &&
514
+ typeof n.quote === 'string')
515
+ notes.push({
516
+ point: collapse(n.point).slice(0, 300),
517
+ passage: n.passage,
518
+ quote: collapse(n.quote).slice(0, 300),
519
+ });
334
520
  }
335
- const wanted = new Set([...notes.map(n => n.passage), ...highlights.flatMap(h => h.passage ? [h.passage] : [])]);
336
- notesText = notes.map(n => `- [${n.passage}] ${n.point}`).join('\n');
521
+ const wanted = new Set([
522
+ ...notes.map((n) => n.passage),
523
+ ...highlights.flatMap((h) => (h.passage ? [h.passage] : [])),
524
+ ]);
525
+ notesText = notes.map((n) => `- [${n.passage}] ${n.point}`).join('\n');
337
526
  shown = [];
338
527
  let size = notesText.length;
339
528
  for (const x of list)
@@ -342,21 +531,36 @@ export async function draftDigest(input) {
342
531
  size += display(x).length + 1;
343
532
  }
344
533
  if (!shown.length)
345
- throw new DigestError('unavailable', [{ path: '', code: 'no-notes' }]);
534
+ throw new SpongeError('unavailable', { issues: [{ path: '', code: 'no-notes' }] });
346
535
  }
347
536
  const budget = quoteBudget(source), schema = draftSchema(list.length, input.vocabulary);
348
- 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) }];
537
+ const messages = [
538
+ { role: 'system', content: system(budget, input.vocabulary) },
539
+ {
540
+ role: 'user',
541
+ content: (notesText ? `Notes from each part of the source, in order:\n${notesText}\n\n` : '') +
542
+ sourceBlock(input.title, input.form, shown, highlights),
543
+ },
544
+ ];
349
545
  const cache = new Map(), options = { ...(input.vocabulary ? { tags: input.vocabulary } : {}) };
350
546
  const embedKey = (t) => Buffer.from(foldText(t)).subarray(0, 900).toString();
351
547
  if (input.embed)
352
- 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; } });
548
+ Object.assign(options, {
549
+ supportThreshold: input.supportThreshold ?? defaultSupportThreshold,
550
+ supports: (claim, quote) => {
551
+ const a = cache.get(embedKey(claim)), b = cache.get(embedKey(quote));
552
+ return a && b ? a.reduce((n, x, i) => n + x * b[i], 0) : 0;
553
+ },
554
+ });
353
555
  const check = async (draft) => {
354
556
  if (input.embed) {
355
- const missing = [...new Set(draft.digest.ideas.flatMap(i => [i.claim, ...i.quotes.map(q => q.text)]).map(embedKey))].filter(t => !cache.has(t));
557
+ const missing = [
558
+ ...new Set(draft.digest.ideas.flatMap((i) => [i.claim, ...i.quotes.map((q) => q.text)]).map(embedKey)),
559
+ ].filter((t) => !cache.has(t));
356
560
  if (missing.length) {
357
561
  const vectors = await input.embed(missing, overall);
358
562
  if (!Array.isArray(vectors) || vectors.length !== missing.length)
359
- throw new DigestError('unavailable');
563
+ throw new SpongeError('unavailable');
360
564
  missing.forEach((t, i) => cache.set(t, vectors[i]));
361
565
  }
362
566
  }
@@ -369,7 +573,6 @@ export async function draftDigest(input) {
369
573
  return { issues: draft.issues };
370
574
  }
371
575
  const quotes = snapQuotes(draft, source, list);
372
- order(draft, source);
373
576
  const issues = await check(draft);
374
577
  trace.attempts.push({ issues, quotes });
375
578
  return { draft, issues };
@@ -381,30 +584,59 @@ export async function draftDigest(input) {
381
584
  result = await attempt(await call([...messages, { role: 'assistant', content: first }, { role: 'user', content: repairPrompt(result.issues) }], schema));
382
585
  }
383
586
  if (!result.draft)
384
- throw new DigestError('unavailable', result.issues);
587
+ throw new SpongeError('unavailable', { issues: result.issues });
385
588
  let issues = result.issues;
386
589
  if (issues.length) {
387
590
  trace.dropped = prune(result.draft, issues, source, options);
388
591
  issues = await check(result.draft);
389
592
  if (result.draft.digest.ideas.length < L.minIdeas)
390
- issues = [...issues.filter(i => !(i.path === 'ideas' && i.code === 'count')), { path: 'ideas', code: 'count' }];
593
+ issues = [...issues.filter((i) => !(i.path === 'ideas' && i.code === 'count')), { path: 'ideas', code: 'count' }];
391
594
  if (issues.length)
392
- throw new DigestError('unavailable', issues);
595
+ throw new SpongeError('unavailable', { issues });
393
596
  }
394
- 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 } : {}) };
597
+ const producer = {
598
+ pipeline: 'sponge-digest',
599
+ promptVersion: PROMPT_VERSION,
600
+ model: identity,
601
+ params: hashedParams(p),
602
+ strategy: trace.strategy,
603
+ sections: trace.sections,
604
+ calls: trace.calls,
605
+ repaired: trace.repaired,
606
+ dropped: trace.dropped,
607
+ ...(input.embed ? { supportThreshold: options.supportThreshold } : {}),
608
+ };
395
609
  return { output: result.draft.digest, inputHash, producer, options, trace };
396
610
  }
397
- const formFor = (kind) => kind === 'pdf' ? 'pdf' : kind === 'media' ? 'video' : 'article';
611
+ const formFor = (kind) => (kind === 'pdf' ? 'pdf' : kind === 'media' ? 'video' : 'article');
398
612
  /** Draft and store a digest for the item's current revision. A matching input hash returns the stored draft without calling the model. */
399
613
  export async function createDigest(store, itemId, model, options = {}) {
400
614
  const item = store.read(itemId);
401
615
  if (!item)
402
- throw new StoreError('unavailable');
403
- 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 }));
404
- const input = { ...options, revisionId: item.revisionId, markdown: item.markdown, title: item.title, form: formFor(item.kind), model, highlights };
405
- const prior = store.findDigest(item.revisionId, digestInputHash({ ...input, model: model.identity }));
616
+ throw new SpongeError('unavailable');
617
+ const highlights = store.annotations
618
+ .list(itemId)
619
+ .filter((a) => a.kind === 'highlight' && a.revisionId === item.revisionId && a.anchored !== false)
620
+ .map((a) => ({ text: a.anchor.exact, start: a.anchor.start }));
621
+ const input = {
622
+ ...options,
623
+ revisionId: item.revisionId,
624
+ markdown: item.markdown,
625
+ title: item.title,
626
+ form: formFor(item.kind),
627
+ model,
628
+ highlights,
629
+ };
630
+ const prior = store.derivations.find('digest', item.revisionId, digestInputHash({ ...input, model: model.identity }));
406
631
  if (prior)
407
632
  return { derivation: prior, duplicate: true };
408
- const draft = await draftDigest(input), stored = store.putDigest({ itemId, revisionId: item.revisionId, producer: draft.producer, inputHash: draft.inputHash, output: draft.output, options: draft.options });
409
- return { derivation: store.readDigest(stored.id), duplicate: stored.duplicate, trace: draft.trace };
633
+ const draft = await draftDigest(input), stored = store.derivations.put('digest', {
634
+ itemId,
635
+ revisionId: item.revisionId,
636
+ producer: draft.producer,
637
+ inputHash: draft.inputHash,
638
+ output: draft.output,
639
+ options: draft.options,
640
+ });
641
+ return { derivation: store.derivations.read('digest', stored.id), duplicate: stored.duplicate, trace: draft.trace };
410
642
  }