@hraness/sponge 0.2.5 → 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.
package/README.md CHANGED
@@ -110,7 +110,8 @@ pruned generation needs a rebuild.
110
110
 
111
111
  ### Digests
112
112
 
113
- A digest is a one-sentence gist and 3–5 ideas, each with 1–2 verbatim quotes.
113
+ A digest (Digest v2) is a short gist led by one sentence and 3–5 ideas, each a
114
+ claim with detail and 0–2 verbatim quotes, with 1–6 quotes in all.
114
115
  A local model proposes it; Sponge checks every rule against the captured text
115
116
  and stores only a passing draft. Drafting is opt-in and needs a model server you
116
117
  start yourself, such as llama.cpp `llama-server`, LM Studio or Ollama:
@@ -55,9 +55,9 @@ export function digestText(d) {
55
55
  if (o.gist.context)
56
56
  lines.push(` ${o.gist.context}`);
57
57
  o.ideas.forEach((idea, i) => {
58
- lines.push('', `idea:${i} ${mark(d.review.ideas?.[i])} ${idea.claim}`, ` ${idea.kind}; ${idea.voice.type === 'author' ? 'author' : `${idea.voice.type}: ${idea.voice.name}`}`, ` ${idea.detail}`);
59
- idea.quotes.forEach((q, j) => lines.push(` quote:${i}.${j} ${mark(d.review.quotes?.[i]?.[j])} “${q.text.replace(/\s+/g, ' ')}”${q.speaker ? ` (${q.speaker})` : ''}`));
58
+ lines.push('', `idea:${i} ${mark(d.review.ideas?.[i])} ${idea.claim}`, ...(idea.kind || idea.voice ? [` ${[idea.kind, idea.voice && (idea.voice.type === 'author' ? 'author' : `${idea.voice.type}: ${idea.voice.name}`)].filter(Boolean).join('; ')}`] : []), ` ${idea.detail}`);
59
+ idea.quotes.forEach((q, j) => lines.push(` quote:${i}.${j} ${mark(d.review.quotes?.[i]?.[j])} “${q.text.replace(/\s+/g, ' ')}”${q.attribution ?? q.speaker ? ` (${q.attribution ?? q.speaker})` : ''}`));
60
60
  });
61
- lines.push('', `Tags: ${o.tags.join(', ')}`);
61
+ lines.push('', `Tags: ${o.tags.join(', ') || 'none'}`);
62
62
  return lines.join('\n');
63
63
  }
@@ -9,5 +9,5 @@ export function jungleAction(store, path, options) {
9
9
  }
10
10
  const tally = (m) => Object.entries(m).sort().map(([k, n]) => `${k} ${n}`).join(', ') || 'none';
11
11
  export function jungleText(r) {
12
- return [`Jungle ${r.dryRun ? 'dry run (captures verified on --apply)' : 'import'}: ${r.entries.length} of ${r.notes} notes`, `Items: ${r.counts.newItems} new, ${r.counts.existingItems} existing`, `Editorial: ${tally(r.counts.editorial)}`, `Digest v1: ${tally(r.counts.v1)}`, `Visibility: ${tally(r.counts.visibility)}`, `Skipped: ${tally(r.counts.skipped)}`, ...(r.nextOffset !== null ? [`Next offset: ${r.nextOffset}`] : [])].join('\n');
12
+ return [`Jungle ${r.dryRun ? 'dry run (captures verified on --apply)' : 'import'}: ${r.entries.length} of ${r.notes} notes`, `Items: ${r.counts.newItems} new, ${r.counts.existingItems} existing`, `Editorial: ${tally(r.counts.editorial)}`, `Digest v2: ${tally(r.counts.digest)}`, ...(Object.keys(r.counts.digestIssues).length ? [`Not convertible because: ${tally(r.counts.digestIssues)}`] : []), `Quotes: ${r.counts.quotes.assigned} assigned, dropped ${tally(r.counts.quotes.dropped)}`, `Visibility: ${tally(r.counts.visibility)}`, `Skipped: ${tally(r.counts.skipped)}`, ...(r.nextOffset !== null ? [`Next offset: ${r.nextOffset}`] : [])].join('\n');
13
13
  }
@@ -3,7 +3,7 @@ import { type JungleEditorial } from '../importers/jungle-digest.js';
3
3
  import { type ReviewStatus } from '../store/changes.js';
4
4
  /**
5
5
  * An editorial derivation keeps a reviewed source note whole (today: one Jungle Reading note of any form), without
6
- * Digest v1's limits. It is immutable data addressed like a digest; its review is one logged path, `review.note`.
6
+ * Digest's limits. It is immutable data addressed like a digest; its review is one logged path, `review.note`.
7
7
  */
8
8
  export interface Editorial {
9
9
  id: string;
@@ -39,9 +39,10 @@ export declare function listEditorials(db: DatabaseSync, itemId: string): Editor
39
39
  /** One logged change, and only when the status differs. */
40
40
  export declare function reviewEditorial(db: DatabaseSync, id: string, status: ReviewStatus | null): Editorial;
41
41
  /**
42
- * The public subset of an accepted editorial note, in the public digest shape: a lead only when the note's first
43
- * sentence fits the v1 lead limit (never cut), claims with detail, and quotes that occur verbatim in the capture,
44
- * each at most 25 words, within the v1 per-entry budget (150 words, and the 10% source share with its 25-word floor).
42
+ * The public subset of an accepted editorial note, in the public digest shape and within Digest v2's limits: a lead only
43
+ * when the note's first sentence fits the lead limit (never cut), claims with detail, and quotes that occur verbatim in the
44
+ * capture with 4-25 words, assigned to ideas as the importer assigns them (at most 2 per idea and 6 in all), within the
45
+ * per-entry budget (150 words, and the 10% source share with its 25-word floor).
45
46
  */
46
47
  export declare function publicEditorial(db: DatabaseSync, e: Editorial): {
47
48
  lead?: string;
@@ -51,9 +51,10 @@ export function reviewEditorial(db, id, status) {
51
51
  return readEditorial(db, id);
52
52
  }
53
53
  /**
54
- * The public subset of an accepted editorial note, in the public digest shape: a lead only when the note's first
55
- * sentence fits the v1 lead limit (never cut), claims with detail, and quotes that occur verbatim in the capture,
56
- * each at most 25 words, within the v1 per-entry budget (150 words, and the 10% source share with its 25-word floor).
54
+ * The public subset of an accepted editorial note, in the public digest shape and within Digest v2's limits: a lead only
55
+ * when the note's first sentence fits the lead limit (never cut), claims with detail, and quotes that occur verbatim in the
56
+ * capture with 4-25 words, assigned to ideas as the importer assigns them (at most 2 per idea and 6 in all), within the
57
+ * per-entry budget (150 words, and the 10% source share with its 25-word floor).
57
58
  */
58
59
  export function publicEditorial(db, e) {
59
60
  if (e.review.note !== 'accepted')
@@ -61,20 +62,25 @@ export function publicEditorial(db, e) {
61
62
  const o = e.output, [first] = firstSentence(o.gist ?? ''), lead = first && first.length <= L.leadChars ? first : undefined;
62
63
  const ideas = (o.ideas ?? []).map(i => ({ claim: i.claim, detail: i.detail, quotes: [] }));
63
64
  if (ideas.length && o.quotes?.length) {
64
- const source = markdownFor(db, e.revisionId), budget = Math.min(L.totalQuoteWords, Math.max(L.quoteWords, Math.floor(words(source) * L.sourceQuoteShare))), taken = [];
65
- let used = 0;
66
- assignQuotes(ideas, o.quotes).forEach((idea, k) => {
67
- if (idea === null || ideas[idea].quotes.length >= L.maxQuotesPerIdea)
68
- return;
69
- const match = locateAll(source, o.quotes[k].text).find(m => !taken.some(t => m.start < t.end && t.start < m.end));
65
+ const source = markdownFor(db, e.revisionId), budget = Math.min(L.totalQuoteWords, Math.max(L.quoteWords, Math.floor(words(source) * L.sourceQuoteShare))), taken = [], texts = [];
66
+ for (const q of o.quotes) {
67
+ const match = locateAll(source, q.text).find(m => !taken.some(t => m.start < t.end && t.start < m.end));
70
68
  if (!match)
71
- return;
69
+ continue;
72
70
  const text = source.slice(match.start, match.end), n = words(text);
73
- if (n < L.minQuoteWords || n > L.quoteWords || used + n > budget)
71
+ if (n >= L.minQuoteWords && n <= L.quoteWords) {
72
+ taken.push(match);
73
+ texts.push(text);
74
+ }
75
+ }
76
+ let used = 0, count = 0;
77
+ assignQuotes(ideas, texts.map(text => ({ text }))).forEach((idea, k) => {
78
+ const n = words(texts[k]);
79
+ if (idea === null || count >= L.maxQuotes || used + n > budget)
74
80
  return;
75
81
  used += n;
76
- taken.push(match);
77
- ideas[idea].quotes.push(text);
82
+ count++;
83
+ ideas[idea].quotes.push(texts[k]);
78
84
  });
79
85
  }
80
86
  return lead || ideas.length ? { ...(lead ? { lead } : {}), ideas } : undefined;
@@ -1,11 +1,11 @@
1
- import { forms, type DigestIssue, type DigestOptions, type DigestV1 } from './schema.js';
1
+ import { forms, type DigestIssue, type DigestOptions, type DigestV2 } from './schema.js';
2
2
  import type { LibraryStore } from '../store/index.js';
3
3
  import type { Derivation } from '../store/foundation.js';
4
4
  /**
5
- * Model-agnostic Digest v1 drafting. The model sees numbered passages and cites a passage id plus a short exact
6
- * span; code snaps, orders, prunes and checks. Nothing leaves this module except through the injected model.
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
7
  */
8
- export declare const PROMPT_VERSION = "sponge-digest-prompt-v1";
8
+ export declare const PROMPT_VERSION = "sponge-digest-prompt-v2";
9
9
  export type ChatMessage = {
10
10
  role: 'system' | 'user' | 'assistant';
11
11
  content: string;
@@ -72,7 +72,7 @@ export declare const quoteBudget: (markdown: string) => number;
72
72
  export declare function draftSchema(passageCount: number, vocabulary?: readonly string[]): JsonSchema;
73
73
  export declare function notesSchema(first: number, last: number): JsonSchema;
74
74
  interface Draft {
75
- digest: DigestV1;
75
+ digest: DigestV2;
76
76
  cites: (number | undefined)[][];
77
77
  }
78
78
  export interface QuoteStats {
@@ -126,7 +126,7 @@ export interface DraftTrace {
126
126
  };
127
127
  }
128
128
  export interface DraftResult {
129
- output: DigestV1;
129
+ output: DigestV2;
130
130
  inputHash: string;
131
131
  producer: Record<string, unknown>;
132
132
  options: DigestOptions;
@@ -1,12 +1,12 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { foldText, locateAll, locateLoose, words } from '../annotations/quote.js';
3
- import { checkDigest, digestLimits as L, forms, ideaKinds, parseDigest } from './schema.js';
3
+ import { checkDigest, digestLimits as L, forms, parseDigest } from './schema.js';
4
4
  import { StoreError } from '../store/types.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-v2';
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
11
  export const defaultDigestParams = { temperature: 0, seed: 1, maxTokens: 2048, contextChars: 24000, maxSections: 24, timeoutMs: 300000, deadlineMs: 1800000, maxOutputBytes: 65536 };
12
12
  export class DigestError extends Error {
@@ -111,25 +111,23 @@ export const quoteBudget = (markdown) => Math.min(L.totalQuoteWords, Math.max(L.
111
111
  const str = (maxLength) => ({ type: 'string', minLength: 1, maxLength });
112
112
  /** JSON Schema for the model's draft. Tags become an enum when a vocabulary is given. */
113
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), 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 } } };
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
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 } } };
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
118
  }
119
119
  export function notesSchema(first, last) {
120
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
121
  }
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.
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
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:
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
126
  - claim: one sentence of at most ${L.claimWords} words that asserts something; never a topic label.
127
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'}.
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'}.
133
131
  Use only what the source says.`;
134
132
  function sourceBlock(title, form, list, highlights) {
135
133
  let heading;
@@ -145,12 +143,12 @@ function sourceBlock(title, form, list, highlights) {
145
143
  const marks = highlights.map((h, i) => `[h${i + 1}${h.passage ? `, passage ${h.passage}` : ''}] ${collapse(h.text).slice(0, 400)}`);
146
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')}`;
147
145
  }
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` };
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` };
149
147
  function repairPrompt(issues) {
150
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]})` : ''}`; });
151
149
  return `That draft broke these rules:\n${lines.join('\n')}\nReturn the complete corrected JSON. Copy quotes exactly from the numbered passages.`;
152
150
  }
153
- /** Map the model's draft shape into DigestV1; parseDigest then decides whether the shape is valid. */
151
+ /** Map the model's draft shape into Digest v2; parseDigest then decides whether the shape is valid. Ideas keep the model's order. */
154
152
  function fromModel(text, form) {
155
153
  let v;
156
154
  try {
@@ -167,14 +165,13 @@ function fromModel(text, form) {
167
165
  cites.push([]);
168
166
  return idea;
169
167
  }
170
- const voice = object(idea.voice) && idea.voice.type !== 'author' ? { type: idea.voice.type, name: trim(idea.voice.name) } : { type: 'author' };
171
168
  const quotes = Array.isArray(idea.quotes) ? idea.quotes : [];
172
169
  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 } : {}) }; }) };
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 } : {}) }; }) };
174
171
  });
175
172
  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 };
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 };
178
175
  }
179
176
  /**
180
177
  * Make a quote exactly verbatim when that is unambiguous: a unique folded or Markdown-insensitive match, inside
@@ -213,21 +210,16 @@ export function snapQuotes(draft, source, list) {
213
210
  });
214
211
  return stats;
215
212
  }
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
213
  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. */
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
+ */
226
218
  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));
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$/, '')));
228
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]); });
229
221
  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; });
222
+ const keepIdea = d.ideas.map(idea => { const key = foldText(idea.claim).toLowerCase(), ok = !seen.has(key); seen.add(key); return ok; });
231
223
  dropped.ideas = keepIdea.filter(k => !k).length;
232
224
  d.ideas = d.ideas.filter((_, i) => keepIdea[i]);
233
225
  draft.cites = draft.cites.filter((_, i) => keepIdea[i]);
@@ -239,13 +231,11 @@ function prune(draft, issues, source, options) {
239
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);
240
232
  dropped.tags = d.tags.length - tags.length;
241
233
  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();
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);
249
239
  dropped.quotes++;
250
240
  }
251
241
  return dropped;
@@ -369,7 +359,6 @@ export async function draftDigest(input) {
369
359
  return { issues: draft.issues };
370
360
  }
371
361
  const quotes = snapQuotes(draft, source, list);
372
- order(draft, source);
373
362
  const issues = await check(draft);
374
363
  trace.attempts.push({ issues, quotes });
375
364
  return { draft, issues };
@@ -1,7 +1,9 @@
1
1
  import { type TextQuote } from '../annotations/quote.js';
2
2
  /**
3
- * Digest v1: a one-sentence gist and 3-5 ideas, each a claim with detail and 1-2 verbatim quotes.
4
- * The model proposes; this validator decides. Nothing here calls a model.
3
+ * Digest v2: a gist led by one sentence, and 3-5 ideas in the author's order, each a claim with detail and 0-2 verbatim
4
+ * quotes; the digest carries 1-6 quotes in all. Digest v1 (one short lead, 1-2 quotes on every idea, ideas in source
5
+ * order, kind and voice required) stays readable and checkable; new digests are v2. The model proposes; this validator
6
+ * decides. Nothing here calls a model.
5
7
  */
6
8
  export declare const ideaKinds: readonly ["finding", "argument", "method", "evidence", "recommendation", "counterpoint"];
7
9
  export declare const forms: readonly ["article", "video", "paper", "thread", "pdf"];
@@ -18,17 +20,23 @@ export type QuoteLocator = {
18
20
  type: 'page';
19
21
  page: number;
20
22
  };
23
+ /** `attribution` is v2 only: who said it and where, as the reviewer wrote it. */
21
24
  export interface DigestQuote {
22
25
  text: string;
23
26
  speaker?: string;
27
+ attribution?: string;
24
28
  locator?: QuoteLocator;
25
29
  }
26
30
  export interface DigestIdea {
27
31
  claim: string;
28
32
  detail: string;
33
+ kind?: typeof ideaKinds[number];
34
+ voice?: Voice;
35
+ quotes: DigestQuote[];
36
+ }
37
+ export interface DigestIdeaV1 extends DigestIdea {
29
38
  kind: typeof ideaKinds[number];
30
39
  voice: Voice;
31
- quotes: DigestQuote[];
32
40
  }
33
41
  export interface DigestV1 {
34
42
  schemaVersion: 1;
@@ -37,10 +45,22 @@ export interface DigestV1 {
37
45
  lead: string;
38
46
  context?: string;
39
47
  };
48
+ ideas: DigestIdeaV1[];
49
+ tags: string[];
50
+ }
51
+ export interface DigestV2 {
52
+ schemaVersion: 2;
53
+ form: typeof forms[number];
54
+ gist: {
55
+ lead: string;
56
+ context?: string;
57
+ };
40
58
  ideas: DigestIdea[];
41
59
  tags: string[];
42
60
  }
43
- export declare const digestLimits: {
61
+ export type Digest = DigestV1 | DigestV2;
62
+ export declare const digestSchemaVersion = 2;
63
+ export declare const digestLimitsV1: {
44
64
  readonly leadChars: 155;
45
65
  readonly contextWords: 50;
46
66
  readonly minIdeas: 3;
@@ -55,6 +75,27 @@ export declare const digestLimits: {
55
75
  readonly sourceQuoteShare: 0.1;
56
76
  readonly maxTags: 3;
57
77
  };
78
+ /** The current (v2) limits. A gist is its lead plus context; quotes are counted per idea and per digest. */
79
+ export declare const digestLimits: {
80
+ readonly leadChars: 240;
81
+ readonly contextWords: 80;
82
+ readonly gistWords: 100;
83
+ readonly gistSentences: 5;
84
+ readonly minIdeas: 3;
85
+ readonly maxIdeas: 5;
86
+ readonly claimWords: 20;
87
+ readonly detailWords: 60;
88
+ readonly detailSentences: 3;
89
+ readonly maxQuotesPerIdea: 2;
90
+ readonly minQuotes: 1;
91
+ readonly maxQuotes: 6;
92
+ readonly minQuoteWords: 4;
93
+ readonly quoteWords: 25;
94
+ readonly attributionWords: 25;
95
+ readonly totalQuoteWords: 150;
96
+ readonly sourceQuoteShare: 0.1;
97
+ readonly maxTags: 3;
98
+ };
58
99
  export interface DigestIssue {
59
100
  path: string;
60
101
  code: string;
@@ -70,16 +111,16 @@ export interface DigestCheck {
70
111
  quotes: LocatedQuote[];
71
112
  }
72
113
  export interface DigestOptions {
73
- /** Controlled tag vocabulary; omitted means any 1-3 lowercase tags. */
114
+ /** Controlled tag vocabulary; omitted means any lowercase kebab-case tags (1-3 in v1, 0-3 in v2). */
74
115
  tags?: readonly string[];
75
116
  /** Optional semantic check that a quote supports its claim, e.g. embedding cosine. */
76
117
  supports?: (claim: string, quote: string) => number;
77
118
  supportThreshold?: number;
78
119
  }
79
- /** Parse an untrusted model output into a DigestV1 shape, or report why not. */
120
+ /** Parse an untrusted digest (v1 or v2) into its shape, or report why not. */
80
121
  export declare function parseDigest(value: unknown): {
81
- digest?: DigestV1;
122
+ digest?: Digest;
82
123
  issues: DigestIssue[];
83
124
  };
84
- /** Check every v1 rule that code can decide against the captured Markdown. */
85
- export declare function checkDigest(digest: DigestV1, source: string, options?: DigestOptions): DigestCheck;
125
+ /** Check every rule of the digest's own version that code can decide against the captured Markdown. */
126
+ export declare function checkDigest(digest: Digest, source: string, options?: DigestOptions): DigestCheck;
@@ -1,21 +1,29 @@
1
1
  import { foldText, locateAll, textQuote, words } from '../annotations/quote.js';
2
2
  /**
3
- * Digest v1: a one-sentence gist and 3-5 ideas, each a claim with detail and 1-2 verbatim quotes.
4
- * The model proposes; this validator decides. Nothing here calls a model.
3
+ * Digest v2: a gist led by one sentence, and 3-5 ideas in the author's order, each a claim with detail and 0-2 verbatim
4
+ * quotes; the digest carries 1-6 quotes in all. Digest v1 (one short lead, 1-2 quotes on every idea, ideas in source
5
+ * order, kind and voice required) stays readable and checkable; new digests are v2. The model proposes; this validator
6
+ * decides. Nothing here calls a model.
5
7
  */
6
8
  export const ideaKinds = ['finding', 'argument', 'method', 'evidence', 'recommendation', 'counterpoint'];
7
9
  export const forms = ['article', 'video', 'paper', 'thread', 'pdf'];
8
- export const digestLimits = { leadChars: 155, contextWords: 50, minIdeas: 3, maxIdeas: 5, claimWords: 20, detailWords: 60, detailSentences: 3, maxQuotesPerIdea: 2, minQuoteWords: 4, quoteWords: 25, totalQuoteWords: 150, sourceQuoteShare: 0.1, maxTags: 3 };
10
+ export const digestSchemaVersion = 2;
11
+ export const digestLimitsV1 = { leadChars: 155, contextWords: 50, minIdeas: 3, maxIdeas: 5, claimWords: 20, detailWords: 60, detailSentences: 3, maxQuotesPerIdea: 2, minQuoteWords: 4, quoteWords: 25, totalQuoteWords: 150, sourceQuoteShare: 0.1, maxTags: 3 };
12
+ /** The current (v2) limits. A gist is its lead plus context; quotes are counted per idea and per digest. */
13
+ export const digestLimits = { leadChars: 240, contextWords: 80, gistWords: 100, gistSentences: 5, minIdeas: 3, maxIdeas: 5, claimWords: 20, detailWords: 60, detailSentences: 3, maxQuotesPerIdea: 2, minQuotes: 1, maxQuotes: 6, minQuoteWords: 4, quoteWords: 25, attributionWords: 25, totalQuoteWords: 150, sourceQuoteShare: 0.1, maxTags: 3 };
9
14
  const object = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
10
15
  const keys = (v, allowed) => Object.keys(v).every(k => allowed.includes(k));
11
16
  const str = (v, max = 2000) => typeof v === 'string' && v.trim().length > 0 && v.length <= max && !/[\u0000-\u0008\u000b-\u001f\u007f]/.test(v);
12
- /** Sentence terminators followed by more text; abbreviations are rare enough in a 155-character lead to reject. */
13
- const sentences = (v) => foldText(v).split(/(?<=[.!?])\s+(?=\S)/).length;
14
- /** Parse an untrusted model output into a DigestV1 shape, or report why not. */
17
+ /** v1: sentence terminators followed by more text; abbreviations are rare enough in a 155-character lead to reject. */
18
+ const sentencesV1 = (v) => foldText(v).split(/(?<=[.!?])\s+(?=\S)/).length;
19
+ /** v2: a terminator (and closing quotes) followed by a capital or digit, the same split Jungle's first-sentence lead uses, so `e.g. this` stays one sentence. */
20
+ const sentences = (v) => foldText(v).split(/(?<=[.!?]["')]*)\s+(?=["(]?[A-Z0-9])/).length;
21
+ /** Parse an untrusted digest (v1 or v2) into its shape, or report why not. */
15
22
  export function parseDigest(value) {
16
23
  const issues = [], bad = (path, code = 'shape') => { issues.push({ path, code }); };
17
- if (!object(value) || !keys(value, ['schemaVersion', 'form', 'gist', 'ideas', 'tags']) || value.schemaVersion !== 1)
24
+ if (!object(value) || !keys(value, ['schemaVersion', 'form', 'gist', 'ideas', 'tags']) || value.schemaVersion !== 1 && value.schemaVersion !== 2)
18
25
  return { issues: [{ path: '', code: 'shape' }] };
26
+ const v2 = value.schemaVersion === 2;
19
27
  if (!forms.includes(value.form))
20
28
  bad('form');
21
29
  const gist = value.gist;
@@ -26,15 +34,15 @@ export function parseDigest(value) {
26
34
  else
27
35
  value.ideas.forEach((idea, i) => {
28
36
  const at = `ideas.${i}`;
29
- if (!object(idea) || !keys(idea, ['claim', 'detail', 'kind', 'voice', 'quotes']) || !str(idea.claim) || !str(idea.detail) || !ideaKinds.includes(idea.kind))
37
+ if (!object(idea) || !keys(idea, ['claim', 'detail', 'kind', 'voice', 'quotes']) || !str(idea.claim) || !str(idea.detail) || !(v2 && idea.kind === undefined || ideaKinds.includes(idea.kind)))
30
38
  return bad(at);
31
39
  const voice = idea.voice;
32
- if (!object(voice) || !(voice.type === 'author' && keys(voice, ['type']) || (voice.type === 'speaker' || voice.type === 'reported') && keys(voice, ['type', 'name']) && str(voice.name, 200)))
40
+ if (!(v2 && voice === undefined) && !(object(voice) && (voice.type === 'author' && keys(voice, ['type']) || (voice.type === 'speaker' || voice.type === 'reported') && keys(voice, ['type', 'name']) && str(voice.name, 200))))
33
41
  bad(at + '.voice');
34
42
  if (!Array.isArray(idea.quotes) || idea.quotes.length > 4)
35
43
  return bad(at + '.quotes');
36
44
  idea.quotes.forEach((quote, j) => {
37
- if (!object(quote) || !keys(quote, ['text', 'speaker', 'locator']) || !str(quote.text, 600) || quote.speaker !== undefined && !str(quote.speaker, 200))
45
+ if (!object(quote) || !keys(quote, v2 ? ['text', 'speaker', 'attribution', 'locator'] : ['text', 'speaker', 'locator']) || !str(quote.text, 600) || quote.speaker !== undefined && !str(quote.speaker, 200) || quote.attribution !== undefined && !str(quote.attribution, 600))
38
46
  return bad(`${at}.quotes.${j}`);
39
47
  const loc = quote.locator;
40
48
  if (loc !== undefined && !(object(loc) && (loc.type === 'timestamp' && keys(loc, ['type', 'ms']) && Number.isSafeInteger(loc.ms) && loc.ms >= 0 || loc.type === 'page' && keys(loc, ['type', 'page']) && Number.isSafeInteger(loc.page) && loc.page >= 1)))
@@ -45,42 +53,51 @@ export function parseDigest(value) {
45
53
  bad('tags');
46
54
  return issues.length ? { issues } : { digest: value, issues };
47
55
  }
48
- /** Check every v1 rule that code can decide against the captured Markdown. */
56
+ /** Check every rule of the digest's own version that code can decide against the captured Markdown. */
49
57
  export function checkDigest(digest, source, options = {}) {
50
- const issues = [], located = [], issue = (path, code) => { issues.push({ path, code }); }, L = digestLimits;
51
- if (digest.gist.lead.length > L.leadChars)
58
+ const issues = [], located = [], issue = (path, code) => { issues.push({ path, code }); }, v2 = digest.schemaVersion === 2, L = v2 ? digestLimits : digestLimitsV1, count = v2 ? sentences : sentencesV1;
59
+ const { lead, context } = digest.gist;
60
+ if (lead.length > L.leadChars)
52
61
  issue('gist.lead', 'too-long');
53
- if (sentences(digest.gist.lead) !== 1)
62
+ if (count(lead) !== 1)
54
63
  issue('gist.lead', 'one-sentence');
55
- if (digest.gist.context !== undefined && words(digest.gist.context) > L.contextWords)
64
+ if (context !== undefined && words(context) > L.contextWords)
56
65
  issue('gist.context', 'too-long');
66
+ if (v2 && words(lead) + (context === undefined ? 0 : words(context)) > digestLimits.gistWords)
67
+ issue('gist', 'too-long');
68
+ if (v2 && count(lead) + (context === undefined ? 0 : count(context)) > digestLimits.gistSentences)
69
+ issue('gist', 'too-many-sentences');
57
70
  if (digest.ideas.length < L.minIdeas || digest.ideas.length > L.maxIdeas)
58
71
  issue('ideas', 'count');
59
72
  const claims = new Set();
60
- let quoteWords = 0;
73
+ let quoteWords = 0, quoteCount = 0;
61
74
  digest.ideas.forEach((idea, i) => {
62
75
  const at = `ideas.${i}`;
63
76
  if (words(idea.claim) > L.claimWords)
64
77
  issue(at + '.claim', 'too-long');
65
- if (sentences(idea.claim) !== 1)
78
+ if (count(idea.claim) !== 1)
66
79
  issue(at + '.claim', 'one-sentence');
67
80
  if (claims.has(foldText(idea.claim).toLowerCase()))
68
81
  issue(at + '.claim', 'duplicate');
69
82
  claims.add(foldText(idea.claim).toLowerCase());
70
83
  if (words(idea.detail) > L.detailWords)
71
84
  issue(at + '.detail', 'too-long');
72
- if (sentences(idea.detail) > L.detailSentences)
85
+ if (count(idea.detail) > L.detailSentences)
73
86
  issue(at + '.detail', 'too-many-sentences');
74
- if (idea.quotes.length < 1 || idea.quotes.length > L.maxQuotesPerIdea)
87
+ // v1 needs a quote on every idea; v2 allows quoteless ideas and counts quotes per digest below.
88
+ if (idea.quotes.length < (v2 ? 0 : 1) || idea.quotes.length > L.maxQuotesPerIdea)
75
89
  issue(at + '.quotes', 'count');
76
90
  idea.quotes.forEach((quote, j) => {
77
- const path = `${at}.quotes.${j}`, count = words(quote.text);
78
- quoteWords += count;
79
- if (count > L.quoteWords)
91
+ const path = `${at}.quotes.${j}`, n = words(quote.text);
92
+ quoteWords += n;
93
+ quoteCount++;
94
+ if (n > L.quoteWords)
80
95
  issue(path, 'too-long');
81
- if (count < L.minQuoteWords)
96
+ if (n < L.minQuoteWords)
82
97
  issue(path, 'too-short');
83
- if (idea.voice.type !== 'author' && !quote.speaker)
98
+ if (quote.attribution !== undefined && words(quote.attribution) > digestLimits.attributionWords)
99
+ issue(path + '.attribution', 'too-long');
100
+ if (idea.voice && idea.voice.type !== 'author' && !quote.speaker)
84
101
  issue(path, 'speaker-required');
85
102
  const matches = locateAll(source, quote.text);
86
103
  if (!matches.length)
@@ -94,23 +111,26 @@ export function checkDigest(digest, source, options = {}) {
94
111
  issue(path, 'unsupported');
95
112
  });
96
113
  });
97
- // Ideas follow the source: each idea's earliest located quote starts no earlier than the previous idea's.
114
+ if (v2 && (quoteCount < digestLimits.minQuotes || quoteCount > digestLimits.maxQuotes))
115
+ issue('ideas', 'quote-count');
116
+ // v1 only: ideas follow the source, each idea's earliest located quote starting no earlier than the previous idea's.
98
117
  let previous = -1;
99
- digest.ideas.forEach((_, i) => {
100
- const starts = located.filter(q => q.idea === i).map(q => q.anchor.start);
101
- if (!starts.length)
102
- return;
103
- const first = Math.min(...starts);
104
- if (first < previous)
105
- issue(`ideas.${i}`, 'out-of-order');
106
- previous = first;
107
- });
118
+ if (!v2)
119
+ digest.ideas.forEach((_, i) => {
120
+ const starts = located.filter(q => q.idea === i).map(q => q.anchor.start);
121
+ if (!starts.length)
122
+ return;
123
+ const first = Math.min(...starts);
124
+ if (first < previous)
125
+ issue(`ideas.${i}`, 'out-of-order');
126
+ previous = first;
127
+ });
108
128
  if (quoteWords > L.totalQuoteWords)
109
129
  issue('ideas', 'quote-budget');
110
130
  // The 10% share has a 25-word floor: an exemption so a short source can still carry one full quote.
111
131
  if (quoteWords > Math.max(L.quoteWords, Math.floor(words(source) * L.sourceQuoteShare)))
112
132
  issue('ideas', 'quote-share');
113
- if (digest.tags.length < 1 || digest.tags.length > L.maxTags || new Set(digest.tags).size !== digest.tags.length)
133
+ if (digest.tags.length < (v2 ? 0 : 1) || digest.tags.length > L.maxTags || new Set(digest.tags).size !== digest.tags.length)
114
134
  issue('tags', 'count');
115
135
  digest.tags.forEach((tag, i) => { if (options.tags ? !options.tags.includes(tag) : !/^[a-z0-9][a-z0-9-]{0,63}$/.test(tag))
116
136
  issue(`tags.${i}`, 'vocabulary'); });
@@ -1,5 +1,5 @@
1
1
  import type { DatabaseSync } from 'node:sqlite';
2
- import { type DigestOptions, type DigestV1 } from './schema.js';
2
+ import { type Digest, type DigestOptions } from './schema.js';
3
3
  import { type ReviewStatus } from '../store/changes.js';
4
4
  export type { ReviewStatus };
5
5
  /** Per-field review of a digest. Only accepted fields can ever be published. */
@@ -15,17 +15,17 @@ export interface Derivation {
15
15
  kind: 'digest';
16
16
  producer: unknown;
17
17
  inputHash: string;
18
- output: DigestV1;
18
+ output: Digest;
19
19
  review: DigestReview;
20
20
  createdAt: string;
21
21
  }
22
- /** A parsed digest serialized in fixed key order, so key order never makes a different object. */
23
- export declare function canonicalDigest(d: DigestV1): string;
22
+ /** A parsed digest serialized in fixed key order, so key order never makes a different object. Optional fields appear only when present. */
23
+ export declare function canonicalDigest(d: Digest): string;
24
24
  /** Content address of a derivation: the same output from the same input is the same immutable object on every device. */
25
25
  export declare const derivationId: (revisionId: string, kind: string, inputHash: string, output: string) => string;
26
26
  /**
27
- * Store a draft digest only when every v1 rule passes against the revision it was made from.
28
- * Drafts are immutable data, not change-log entries; a different output is a new object.
27
+ * Store a draft digest only when it is Digest v2 and every v2 rule passes against the revision it was made from.
28
+ * Stored v1 digests stay readable; new ones are v2. Drafts are immutable data, not change-log entries; a different output is a new object.
29
29
  */
30
30
  export declare function putDigest(db: DatabaseSync, input: {
31
31
  itemId: string;
@@ -45,13 +45,13 @@ export declare function readDigest(db: DatabaseSync, id: string): Derivation | u
45
45
  export declare function findDigest(db: DatabaseSync, revisionId: string, inputHash: string): Derivation | undefined;
46
46
  /** Review fields of a digest. Each changed path is its own change: `review.gist`, `review.ideas.<i>`, `review.quotes.<i>.<j>`. */
47
47
  export declare function reviewDigest(db: DatabaseSync, id: string, review: DigestReview): Derivation;
48
- /** The accepted subset of a digest. Rejected or unreviewed fields never leave the device. */
48
+ /** The accepted subset of a digest (v1 or v2). Rejected or unreviewed fields never leave the device; v1 ideas keep their kind. */
49
49
  export declare function acceptedDigest(d: Derivation): {
50
50
  lead?: string;
51
51
  ideas: {
52
52
  claim: string;
53
53
  detail: string;
54
- kind: string;
54
+ kind?: string;
55
55
  quotes: string[];
56
56
  }[];
57
57
  } | undefined;