@hraness/sponge 0.2.4 → 0.2.6

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 (86) hide show
  1. package/README.md +54 -0
  2. package/dist/migrations/005.d.ts +3 -0
  3. package/dist/migrations/005.js +19 -0
  4. package/dist/migrations/006.d.ts +3 -0
  5. package/dist/migrations/006.js +9 -0
  6. package/dist/migrations/007.d.ts +3 -0
  7. package/dist/migrations/007.js +10 -0
  8. package/dist/src/annotations/quote.d.ts +32 -0
  9. package/dist/src/annotations/quote.js +135 -0
  10. package/dist/src/annotations/store.d.ts +50 -0
  11. package/dist/src/annotations/store.js +89 -0
  12. package/dist/src/app/source.d.ts +2 -0
  13. package/dist/src/app/source.js +13 -17
  14. package/dist/src/cli/arguments.js +24 -7
  15. package/dist/src/cli/digest.d.ts +10 -0
  16. package/dist/src/cli/digest.js +63 -0
  17. package/dist/src/cli/import.d.ts +11 -0
  18. package/dist/src/cli/import.js +10 -0
  19. package/dist/src/cli/indexing.d.ts +3 -0
  20. package/dist/src/cli/indexing.js +5 -2
  21. package/dist/src/cli/main.js +94 -8
  22. package/dist/src/cli/runtimes.d.ts +10 -0
  23. package/dist/src/cli/runtimes.js +32 -0
  24. package/dist/src/digest/editorial.d.ts +54 -0
  25. package/dist/src/digest/editorial.js +87 -0
  26. package/dist/src/digest/endpoint.d.ts +20 -0
  27. package/dist/src/digest/endpoint.js +103 -0
  28. package/dist/src/digest/pipeline.d.ts +148 -0
  29. package/dist/src/digest/pipeline.js +399 -0
  30. package/dist/src/digest/schema.d.ts +126 -0
  31. package/dist/src/digest/schema.js +138 -0
  32. package/dist/src/digest/store.d.ts +57 -0
  33. package/dist/src/digest/store.js +79 -0
  34. package/dist/src/identity/query-policy.d.ts +21 -0
  35. package/dist/src/identity/query-policy.js +55 -0
  36. package/dist/src/identity/uri.d.ts +30 -0
  37. package/dist/src/identity/uri.js +94 -0
  38. package/dist/src/identity/visibility.d.ts +24 -0
  39. package/dist/src/identity/visibility.js +75 -0
  40. package/dist/src/importers/apply.d.ts +10 -0
  41. package/dist/src/importers/apply.js +25 -22
  42. package/dist/src/importers/jungle-digest.d.ts +107 -0
  43. package/dist/src/importers/jungle-digest.js +196 -0
  44. package/dist/src/importers/jungle-store.d.ts +45 -0
  45. package/dist/src/importers/jungle-store.js +96 -0
  46. package/dist/src/importers/jungle.d.ts +70 -0
  47. package/dist/src/importers/jungle.js +142 -0
  48. package/dist/src/indexing/index.d.ts +7 -0
  49. package/dist/src/indexing/index.js +31 -8
  50. package/dist/src/indexing/prune-run.d.ts +22 -0
  51. package/dist/src/indexing/prune-run.js +67 -0
  52. package/dist/src/indexing/prune.d.ts +30 -0
  53. package/dist/src/indexing/prune.js +95 -0
  54. package/dist/src/indexing/runner.js +5 -5
  55. package/dist/src/inference/embedding/index.d.ts +25 -0
  56. package/dist/src/inference/embedding/index.js +11 -0
  57. package/dist/src/models/artifacts.js +175 -33
  58. package/dist/src/portability/backup.js +3 -1
  59. package/dist/src/portability/database.js +3 -0
  60. package/dist/src/publish/component.d.ts +8 -0
  61. package/dist/src/publish/component.js +114 -0
  62. package/dist/src/publish/feed.d.ts +24 -0
  63. package/dist/src/publish/feed.js +77 -0
  64. package/dist/src/publish/identity.d.ts +6 -0
  65. package/dist/src/publish/identity.js +68 -0
  66. package/dist/src/publish/index.d.ts +34 -0
  67. package/dist/src/publish/index.js +137 -0
  68. package/dist/src/publish/projection.d.ts +29 -0
  69. package/dist/src/publish/projection.js +43 -0
  70. package/dist/src/search/hybrid.js +2 -2
  71. package/dist/src/search/lexical.d.ts +11 -0
  72. package/dist/src/search/lexical.js +6 -0
  73. package/dist/src/search/query.d.ts +7 -3
  74. package/dist/src/search/query.js +52 -33
  75. package/dist/src/search/vector.d.ts +1 -1
  76. package/dist/src/search/vector.js +6 -5
  77. package/dist/src/store/changes.d.ts +42 -0
  78. package/dist/src/store/changes.js +178 -0
  79. package/dist/src/store/foundation.d.ts +7 -0
  80. package/dist/src/store/foundation.js +7 -0
  81. package/dist/src/store/index.d.ts +69 -0
  82. package/dist/src/store/index.js +134 -51
  83. package/dist/src/worker/runtime-gc.d.ts +30 -0
  84. package/dist/src/worker/runtime-gc.js +296 -0
  85. package/native/runtime-manifest.json +4 -4
  86. package/package.json +1 -1
@@ -0,0 +1,148 @@
1
+ import { forms, type DigestIssue, type DigestOptions, type DigestV2 } from './schema.js';
2
+ import type { LibraryStore } from '../store/index.js';
3
+ import type { Derivation } from '../store/foundation.js';
4
+ /**
5
+ * Model-agnostic Digest v2 drafting. The model sees numbered passages and cites a passage id plus a short exact
6
+ * span; code snaps, prunes and checks. Nothing leaves this module except through the injected model.
7
+ */
8
+ export declare const PROMPT_VERSION = "sponge-digest-prompt-v2";
9
+ export type ChatMessage = {
10
+ role: 'system' | 'user' | 'assistant';
11
+ content: string;
12
+ };
13
+ export type JsonSchema = Record<string, unknown>;
14
+ /** Stable identity recorded in the producer and the input hash. Unknown parts are the literal 'unknown'. */
15
+ export interface ModelIdentity {
16
+ model: string;
17
+ quantization: string;
18
+ runtime: string;
19
+ }
20
+ export interface GenerateOptions {
21
+ signal: AbortSignal;
22
+ maxOutputBytes: number;
23
+ maxTokens: number;
24
+ temperature: number;
25
+ seed: number;
26
+ }
27
+ export interface DigestModel {
28
+ identity: ModelIdentity;
29
+ generate(messages: ChatMessage[], schema: JsonSchema, options: GenerateOptions): Promise<string>;
30
+ }
31
+ /** Output-shaping parameters are hashed; time and byte bounds are not, because they only decide whether a run finishes. */
32
+ export interface DigestParams {
33
+ temperature: number;
34
+ seed: number;
35
+ maxTokens: number;
36
+ contextChars: number;
37
+ maxSections: number;
38
+ timeoutMs: number;
39
+ deadlineMs: number;
40
+ maxOutputBytes: number;
41
+ }
42
+ /** 24k characters is roughly 6k tokens of passages, leaving room for instructions and output in an 8k context on an 8 GB Mac. */
43
+ export declare const defaultDigestParams: DigestParams;
44
+ export type DigestErrorCode = 'invalid-input' | 'unavailable' | 'cancelled' | 'limit-exceeded';
45
+ export declare class DigestError extends Error {
46
+ readonly code: DigestErrorCode;
47
+ readonly issues?: DigestIssue[] | undefined;
48
+ constructor(code: DigestErrorCode, issues?: DigestIssue[] | undefined);
49
+ }
50
+ export type Form = typeof forms[number];
51
+ /** Quotes under this many words carry no evidence; the checker is gaining the same floor. */
52
+ export declare const minQuoteWords = 4;
53
+ export interface Passage {
54
+ id: number;
55
+ start: number;
56
+ end: number;
57
+ heading: string;
58
+ text: string;
59
+ }
60
+ export interface Highlight {
61
+ text: string;
62
+ start?: number;
63
+ }
64
+ /** Sorted-key JSON; the only serialization that enters input hashes. */
65
+ export declare function canonicalJson(value: unknown): string;
66
+ /** Paragraph-level passages with raw offsets. Headings label passages; code fences and image-only blocks are not quotable. */
67
+ export declare function passages(markdown: string, maxChars?: number): Passage[];
68
+ /** Pack passages into sections of at most `budget` display characters, preferring heading boundaries past half a budget. */
69
+ export declare function sectionsOf(list: Passage[], budget: number): Passage[][];
70
+ export declare const quoteBudget: (markdown: string) => number;
71
+ /** JSON Schema for the model's draft. Tags become an enum when a vocabulary is given. */
72
+ export declare function draftSchema(passageCount: number, vocabulary?: readonly string[]): JsonSchema;
73
+ export declare function notesSchema(first: number, last: number): JsonSchema;
74
+ interface Draft {
75
+ digest: DigestV2;
76
+ cites: (number | undefined)[][];
77
+ }
78
+ export interface QuoteStats {
79
+ exact: number;
80
+ folded: number;
81
+ snapped: number;
82
+ missing: number;
83
+ short: number;
84
+ }
85
+ export type QuoteMatch = 'exact' | 'folded' | 'snapped' | 'missing';
86
+ /**
87
+ * Make a quote exactly verbatim when that is unambiguous: a unique folded or Markdown-insensitive match, inside
88
+ * `within` first, becomes the raw source span. Ambiguous folded matches keep their text for the checker; absent ones are 'missing'.
89
+ */
90
+ export declare function snapQuote(source: string, text: string, within?: {
91
+ start: number;
92
+ end: number;
93
+ }): {
94
+ text: string;
95
+ match: QuoteMatch;
96
+ };
97
+ /** Snap every quote in place and drop quotes under `minQuoteWords`. */
98
+ export declare function snapQuotes(draft: Draft, source: string, list: Passage[]): QuoteStats;
99
+ export interface DraftInput {
100
+ revisionId: string;
101
+ markdown: string;
102
+ title?: string;
103
+ form: Form;
104
+ model: DigestModel;
105
+ highlights?: readonly Highlight[];
106
+ vocabulary?: readonly string[];
107
+ params?: Partial<DigestParams>;
108
+ signal?: AbortSignal;
109
+ /** Unit-normalized embeddings for short texts; when given, quotes must support their claims by cosine. */
110
+ embed?: (texts: string[], signal: AbortSignal) => Promise<number[][]>;
111
+ supportThreshold?: number;
112
+ }
113
+ export interface DraftTrace {
114
+ strategy: 'single' | 'sections';
115
+ sections: number;
116
+ calls: number;
117
+ repaired: boolean;
118
+ attempts: {
119
+ issues: DigestIssue[];
120
+ quotes?: QuoteStats;
121
+ }[];
122
+ dropped: {
123
+ quotes: number;
124
+ ideas: number;
125
+ tags: number;
126
+ };
127
+ }
128
+ export interface DraftResult {
129
+ output: DigestV2;
130
+ inputHash: string;
131
+ producer: Record<string, unknown>;
132
+ options: DigestOptions;
133
+ trace: DraftTrace;
134
+ }
135
+ /** sha256 over canonical JSON of the revision, its Markdown, the prompt version, the model identity and every output-shaping input. */
136
+ export declare function digestInputHash(input: Pick<DraftInput, 'revisionId' | 'markdown' | 'form' | 'highlights' | 'vocabulary' | 'params' | 'supportThreshold' | 'embed'> & {
137
+ model: ModelIdentity;
138
+ }): string;
139
+ /** Unqualified on real claims: a deliberately low bar that only catches quotes unrelated to their claim. */
140
+ export declare const defaultSupportThreshold = 0.35;
141
+ export declare function draftDigest(input: DraftInput): Promise<DraftResult>;
142
+ /** Draft and store a digest for the item's current revision. A matching input hash returns the stored draft without calling the model. */
143
+ export declare function createDigest(store: LibraryStore, itemId: string, model: DigestModel, options?: Omit<DraftInput, 'revisionId' | 'markdown' | 'title' | 'form' | 'model' | 'highlights'>): Promise<{
144
+ derivation: Derivation;
145
+ duplicate: boolean;
146
+ trace?: DraftTrace;
147
+ }>;
148
+ export {};
@@ -0,0 +1,399 @@
1
+ import { createHash } from 'node:crypto';
2
+ 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';
5
+ /**
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
+ */
9
+ export const PROMPT_VERSION = 'sponge-digest-prompt-v2';
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
+ }
22
+ /** 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');
25
+ const collapse = (v) => v.replace(/\s+/g, ' ').trim();
26
+ const object = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
27
+ /** Sorted-key JSON; the only serialization that enters input hashes. */
28
+ export function canonicalJson(value) {
29
+ if (Array.isArray(value))
30
+ return `[${value.map(canonicalJson).join(',')}]`;
31
+ if (object(value))
32
+ return `{${Object.keys(value).filter(k => value[k] !== undefined).sort().map(k => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(',')}}`;
33
+ if (typeof value === 'number' && !Number.isFinite(value))
34
+ throw new DigestError('invalid-input');
35
+ return JSON.stringify(value ?? null);
36
+ }
37
+ /** Paragraph-level passages with raw offsets. Headings label passages; code fences and image-only blocks are not quotable. */
38
+ export function passages(markdown, maxChars = 1200) {
39
+ const out = [], blocks = [];
40
+ let at = 0, start = -1, fence = false, fenced = false;
41
+ for (const line of markdown.split('\n')) {
42
+ const end = at + line.length, blank = !line.trim(), marker = /^\s{0,3}(```|~~~)/.test(line);
43
+ if (marker)
44
+ fence = !fence;
45
+ if (blank && !fence && !marker) {
46
+ if (start >= 0)
47
+ blocks.push({ start, end: at - 1, fence: fenced });
48
+ start = -1;
49
+ fenced = false;
50
+ }
51
+ else {
52
+ if (start < 0)
53
+ start = at;
54
+ if (marker || fence)
55
+ fenced = true;
56
+ }
57
+ at = end + 1;
58
+ }
59
+ if (start >= 0)
60
+ blocks.push({ start, end: markdown.length, fence: fenced });
61
+ let heading = '';
62
+ for (const b of blocks) {
63
+ const raw = markdown.slice(b.start, b.end).trimEnd(), end = b.start + raw.length;
64
+ if (b.fence || !raw.trim())
65
+ continue;
66
+ const h = /^\s{0,3}#{1,6}\s+(.+)$/.exec(raw);
67
+ if (h && !raw.includes('\n')) {
68
+ heading = collapse(h[1]).slice(0, 200);
69
+ continue;
70
+ }
71
+ if (/^(\s*!\[[^\]]*\]\([^)]*\)\s*)+$/.test(raw))
72
+ continue;
73
+ // Long blocks split at sentence ends, then at whitespace, so every passage fits a prompt line.
74
+ let from = b.start;
75
+ while (end - from > maxChars) {
76
+ const window = markdown.slice(from, from + maxChars), sentence = lastSentenceEnd(window), space = window.lastIndexOf(' ');
77
+ const stop = from + (sentence > maxChars / 4 ? sentence : space > maxChars / 4 ? space : maxChars);
78
+ out.push({ id: out.length + 1, start: from, end: stop, heading, text: markdown.slice(from, stop) });
79
+ from = stop;
80
+ while (from < end && /\s/.test(markdown[from]))
81
+ from++;
82
+ }
83
+ if (end > from)
84
+ out.push({ id: out.length + 1, start: from, end, heading, text: markdown.slice(from, end) });
85
+ }
86
+ return out;
87
+ }
88
+ function lastSentenceEnd(window) { let best = -1; for (const m of window.matchAll(/[.!?]["')\]]*(?=\s)/g))
89
+ best = m.index + m[0].length; return best; }
90
+ const display = (p) => `[${p.id}] ${collapse(p.text)}`;
91
+ /** Pack passages into sections of at most `budget` display characters, preferring heading boundaries past half a budget. */
92
+ export function sectionsOf(list, budget) {
93
+ const out = [];
94
+ let current = [], size = 0;
95
+ for (const p of list) {
96
+ const n = display(p).length + 1;
97
+ if (current.length && (size + n > budget || p.heading !== current[current.length - 1].heading && size > budget / 2)) {
98
+ out.push(current);
99
+ current = [];
100
+ size = 0;
101
+ }
102
+ current.push(p);
103
+ size += n;
104
+ }
105
+ if (current.length)
106
+ out.push(current);
107
+ return out;
108
+ }
109
+ const sourceWords = (markdown) => words(markdown);
110
+ export const quoteBudget = (markdown) => Math.min(L.totalQuoteWords, Math.max(L.quoteWords, Math.floor(sourceWords(markdown) * L.sourceQuoteShare)));
111
+ const str = (maxLength) => ({ type: 'string', minLength: 1, maxLength });
112
+ /** JSON Schema for the model's draft. Tags become an enum when a vocabulary is given. */
113
+ 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 } } };
118
+ }
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) } } } } };
121
+ }
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.`;
132
+ function sourceBlock(title, form, list, highlights) {
133
+ let heading;
134
+ const lines = [];
135
+ for (const p of list) {
136
+ if (p.heading !== heading) {
137
+ heading = p.heading;
138
+ if (heading)
139
+ lines.push(`## ${heading}`);
140
+ }
141
+ lines.push(display(p));
142
+ }
143
+ const marks = highlights.map((h, i) => `[h${i + 1}${h.passage ? `, passage ${h.passage}` : ''}] ${collapse(h.text).slice(0, 400)}`);
144
+ 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
+ }
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` };
147
+ 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.`;
150
+ }
151
+ /** Map the model's draft shape into Digest v2; parseDigest then decides whether the shape is valid. Ideas keep the model's order. */
152
+ function fromModel(text, form) {
153
+ let v;
154
+ try {
155
+ v = JSON.parse(text);
156
+ }
157
+ catch {
158
+ return { issues: [{ path: '', code: 'json' }] };
159
+ }
160
+ if (!object(v) || !Array.isArray(v.ideas) || !Array.isArray(v.tags))
161
+ return { issues: [{ path: '', code: 'shape' }] };
162
+ const trim = (s) => typeof s === 'string' ? s.trim() : s, cites = [];
163
+ const ideas = v.ideas.map((idea) => {
164
+ if (!object(idea)) {
165
+ cites.push([]);
166
+ return idea;
167
+ }
168
+ 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 } : {}) }; }) };
171
+ });
172
+ 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) });
174
+ return parsed.digest?.schemaVersion === 2 ? { digest: parsed.digest, cites } : { issues: parsed.issues };
175
+ }
176
+ /**
177
+ * Make a quote exactly verbatim when that is unambiguous: a unique folded or Markdown-insensitive match, inside
178
+ * `within` first, becomes the raw source span. Ambiguous folded matches keep their text for the checker; absent ones are 'missing'.
179
+ */
180
+ export function snapQuote(source, text, within) {
181
+ if (source.includes(text))
182
+ return { text, match: 'exact' };
183
+ 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; };
185
+ const folded = locateAll(source, text);
186
+ if (folded.length) {
187
+ const m = pick(folded);
188
+ return { text: m ? source.slice(m.start, m.end) : text, match: 'folded' };
189
+ }
190
+ const loose = (within && pick(locateLoose(source, text, within))) || pick(locateLoose(source, text));
191
+ if (!loose)
192
+ return { text, match: 'missing' };
193
+ // Keep emphasis and code markers balanced: a span that starts or ends inside `**bold**` takes the adjacent markers.
194
+ let { start, end } = loose;
195
+ while (start > 0 && /[*_`]/.test(source[start - 1]))
196
+ start--;
197
+ while (end < source.length && /[*_`]/.test(source[end]))
198
+ end++;
199
+ return { text: source.slice(start, end), match: 'snapped' };
200
+ }
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]));
204
+ 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;
208
+ idea.quotes = idea.quotes.filter((_, j) => keep[j]);
209
+ draft.cites[i] = (draft.cites[i] ?? []).filter((_, j) => keep[j]);
210
+ });
211
+ return stats;
212
+ }
213
+ const pruneable = new Set(['not-verbatim', 'overlap', 'unsupported', 'too-long', 'speaker-required']);
214
+ /**
215
+ * Deterministic last resort: drop failing quotes, then duplicate ideas and invalid tags, then quotes over the count or word
216
+ * budget (second quotes first, longest first), never the last quote. Quoteless ideas stay: v2 allows them.
217
+ */
218
+ 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]); });
221
+ 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;
224
+ d.ideas = d.ideas.filter((_, i) => keepIdea[i]);
225
+ draft.cites = draft.cites.filter((_, i) => keepIdea[i]);
226
+ if (d.ideas.length > L.maxIdeas) {
227
+ dropped.ideas += d.ideas.length - L.maxIdeas;
228
+ d.ideas = d.ideas.slice(0, L.maxIdeas);
229
+ draft.cites = draft.cites.slice(0, L.maxIdeas);
230
+ }
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);
232
+ dropped.tags = d.tags.length - tags.length;
233
+ d.tags = tags;
234
+ const budget = quoteBudget(source), all = () => d.ideas.flatMap((idea, i) => idea.quotes.map((q, j) => ({ i, j, n: words(q.text) })));
235
+ for (let q = all(); q.length > 1 && (q.length > L.maxQuotes || q.reduce((n, x) => n + x.n, 0) > budget); q = all()) {
236
+ const x = q.sort((a, b) => b.j - a.j || b.n - a.n)[0];
237
+ d.ideas[x.i].quotes.splice(x.j, 1);
238
+ draft.cites[x.i]?.splice(x.j, 1);
239
+ dropped.quotes++;
240
+ }
241
+ return dropped;
242
+ }
243
+ const hashedParams = (p) => ({ temperature: p.temperature, seed: p.seed, maxTokens: p.maxTokens, contextChars: p.contextChars, maxSections: p.maxSections });
244
+ function resolveParams(params = {}) {
245
+ 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');
248
+ return p;
249
+ }
250
+ function validIdentity(id) {
251
+ const ok = (v) => typeof v === 'string' && v.length > 0 && v.length <= 256 && !/[\u0000-\u001f\u007f]/.test(v);
252
+ if (!object(id) || !ok(id.model) || !ok(id.quantization) || !ok(id.runtime))
253
+ throw new DigestError('invalid-input');
254
+ return { model: id.model, quantization: id.quantization, runtime: id.runtime };
255
+ }
256
+ /** sha256 over canonical JSON of the revision, its Markdown, the prompt version, the model identity and every output-shaping input. */
257
+ 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 } }));
259
+ }
260
+ /** Unqualified on real claims: a deliberately low bar that only catches quotes unrelated to their claim. */
261
+ export const defaultSupportThreshold = 0.35;
262
+ export async function draftDigest(input) {
263
+ const p = resolveParams(input.params), identity = validIdentity(input.model.identity);
264
+ 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');
268
+ const inputHash = digestInputHash({ ...input, model: identity }), source = input.markdown, list = passages(source);
269
+ if (list.length === 0)
270
+ throw new DigestError('invalid-input');
271
+ 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 } };
273
+ const call = async (messages, schema) => {
274
+ if (userSignal.aborted)
275
+ throw new DigestError('cancelled');
276
+ if (overall.aborted)
277
+ throw new DigestError('limit-exceeded');
278
+ const signal = AbortSignal.any([overall, AbortSignal.timeout(p.timeoutMs)]);
279
+ trace.calls++;
280
+ let text;
281
+ try {
282
+ text = await input.model.generate(messages, schema, { signal, maxOutputBytes: p.maxOutputBytes, maxTokens: p.maxTokens, temperature: p.temperature, seed: p.seed });
283
+ }
284
+ catch (error) {
285
+ if (userSignal.aborted)
286
+ throw new DigestError('cancelled');
287
+ if (signal.aborted)
288
+ throw new DigestError('limit-exceeded');
289
+ if (error instanceof DigestError)
290
+ throw error;
291
+ throw new DigestError('unavailable');
292
+ }
293
+ if (userSignal.aborted)
294
+ throw new DigestError('cancelled');
295
+ if (typeof text !== 'string' || Buffer.byteLength(text) > p.maxOutputBytes)
296
+ throw new DigestError('limit-exceeded');
297
+ return text;
298
+ };
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 }; });
300
+ let shown = list, notesText;
301
+ const total = list.reduce((n, x) => n + display(x).length + 1, 0);
302
+ if (total > p.contextChars) {
303
+ // Long source: notes per section, then one combined pass over the cited and highlighted passages only.
304
+ const sections = sectionsOf(list, p.contextChars);
305
+ if (sections.length > p.maxSections)
306
+ throw new DigestError('limit-exceeded');
307
+ trace.strategy = 'sections';
308
+ trace.sections = sections.length;
309
+ const notes = [];
310
+ for (const [k, section] of sections.entries()) {
311
+ 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));
313
+ let v;
314
+ try {
315
+ v = JSON.parse(text);
316
+ }
317
+ catch {
318
+ continue;
319
+ }
320
+ if (object(v) && Array.isArray(v.notes))
321
+ 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) });
324
+ }
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');
327
+ shown = [];
328
+ let size = notesText.length;
329
+ for (const x of list)
330
+ if (wanted.has(x.id) && size + display(x).length + 1 <= p.contextChars) {
331
+ shown.push(x);
332
+ size += display(x).length + 1;
333
+ }
334
+ if (!shown.length)
335
+ throw new DigestError('unavailable', [{ path: '', code: 'no-notes' }]);
336
+ }
337
+ 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) }];
339
+ const cache = new Map(), options = { ...(input.vocabulary ? { tags: input.vocabulary } : {}) };
340
+ const embedKey = (t) => Buffer.from(foldText(t)).subarray(0, 900).toString();
341
+ 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; } });
343
+ const check = async (draft) => {
344
+ 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));
346
+ if (missing.length) {
347
+ const vectors = await input.embed(missing, overall);
348
+ if (!Array.isArray(vectors) || vectors.length !== missing.length)
349
+ throw new DigestError('unavailable');
350
+ missing.forEach((t, i) => cache.set(t, vectors[i]));
351
+ }
352
+ }
353
+ return checkDigest(draft.digest, source, options).issues;
354
+ };
355
+ const attempt = async (text) => {
356
+ const draft = fromModel(text, input.form);
357
+ if ('issues' in draft) {
358
+ trace.attempts.push({ issues: draft.issues });
359
+ return { issues: draft.issues };
360
+ }
361
+ const quotes = snapQuotes(draft, source, list);
362
+ const issues = await check(draft);
363
+ trace.attempts.push({ issues, quotes });
364
+ return { draft, issues };
365
+ };
366
+ const first = await call(messages, schema);
367
+ let result = await attempt(first);
368
+ if (result.issues.length) {
369
+ trace.repaired = true;
370
+ result = await attempt(await call([...messages, { role: 'assistant', content: first }, { role: 'user', content: repairPrompt(result.issues) }], schema));
371
+ }
372
+ if (!result.draft)
373
+ throw new DigestError('unavailable', result.issues);
374
+ let issues = result.issues;
375
+ if (issues.length) {
376
+ trace.dropped = prune(result.draft, issues, source, options);
377
+ issues = await check(result.draft);
378
+ if (result.draft.digest.ideas.length < L.minIdeas)
379
+ issues = [...issues.filter(i => !(i.path === 'ideas' && i.code === 'count')), { path: 'ideas', code: 'count' }];
380
+ if (issues.length)
381
+ throw new DigestError('unavailable', issues);
382
+ }
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 } : {}) };
384
+ return { output: result.draft.digest, inputHash, producer, options, trace };
385
+ }
386
+ const formFor = (kind) => kind === 'pdf' ? 'pdf' : kind === 'media' ? 'video' : 'article';
387
+ /** Draft and store a digest for the item's current revision. A matching input hash returns the stored draft without calling the model. */
388
+ export async function createDigest(store, itemId, model, options = {}) {
389
+ const item = store.read(itemId);
390
+ 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 }));
395
+ if (prior)
396
+ 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 };
399
+ }