@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 +2 -1
- package/dist/src/cli/digest.js +3 -3
- package/dist/src/cli/import.js +1 -1
- package/dist/src/digest/editorial.d.ts +5 -4
- package/dist/src/digest/editorial.js +19 -13
- package/dist/src/digest/pipeline.d.ts +6 -6
- package/dist/src/digest/pipeline.js +29 -40
- package/dist/src/digest/schema.d.ts +50 -9
- package/dist/src/digest/schema.js +55 -35
- package/dist/src/digest/store.d.ts +8 -8
- package/dist/src/digest/store.js +10 -8
- package/dist/src/importers/jungle-digest.d.ts +28 -18
- package/dist/src/importers/jungle-digest.js +40 -65
- package/dist/src/importers/jungle-store.d.ts +6 -6
- package/dist/src/importers/jungle-store.js +20 -19
- package/dist/src/importers/jungle.d.ts +27 -11
- package/dist/src/importers/jungle.js +21 -13
- package/dist/src/portability/backup.js +3 -1
- package/dist/src/publish/projection.js +2 -2
- package/native/runtime-manifest.json +4 -4
- package/package.json +1 -1
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
|
|
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:
|
package/dist/src/cli/digest.js
CHANGED
|
@@ -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
|
|
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
|
}
|
package/dist/src/cli/import.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
43
|
-
* sentence fits the
|
|
44
|
-
*
|
|
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
|
|
55
|
-
* sentence fits the
|
|
56
|
-
*
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
69
|
+
continue;
|
|
72
70
|
const text = source.slice(match.start, match.end), n = words(text);
|
|
73
|
-
if (n
|
|
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
|
-
|
|
77
|
-
ideas[idea].quotes.push(
|
|
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
|
|
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
|
|
6
|
-
* span; code snaps,
|
|
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-
|
|
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:
|
|
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:
|
|
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,
|
|
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
|
|
7
|
-
* span; code snaps,
|
|
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-
|
|
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),
|
|
115
|
-
const idea = { type: 'object', additionalProperties: false, required: ['claim', 'detail', '
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
-
|
|
129
|
-
|
|
130
|
-
|
|
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: `
|
|
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
|
|
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),
|
|
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:
|
|
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
|
-
/**
|
|
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
|
|
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 =
|
|
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),
|
|
243
|
-
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
4
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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?:
|
|
122
|
+
digest?: Digest;
|
|
82
123
|
issues: DigestIssue[];
|
|
83
124
|
};
|
|
84
|
-
/** Check every
|
|
85
|
-
export declare function checkDigest(digest:
|
|
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
|
|
4
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
13
|
-
const
|
|
14
|
-
/**
|
|
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 (!
|
|
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
|
|
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
|
-
|
|
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 (
|
|
62
|
+
if (count(lead) !== 1)
|
|
54
63
|
issue('gist.lead', 'one-sentence');
|
|
55
|
-
if (
|
|
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 (
|
|
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 (
|
|
85
|
+
if (count(idea.detail) > L.detailSentences)
|
|
73
86
|
issue(at + '.detail', 'too-many-sentences');
|
|
74
|
-
|
|
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}`,
|
|
78
|
-
quoteWords +=
|
|
79
|
-
|
|
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 (
|
|
96
|
+
if (n < L.minQuoteWords)
|
|
82
97
|
issue(path, 'too-short');
|
|
83
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
54
|
+
kind?: string;
|
|
55
55
|
quotes: string[];
|
|
56
56
|
}[];
|
|
57
57
|
} | undefined;
|