@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.
@@ -1,19 +1,19 @@
1
- import { checkDigest, parseDigest } from './schema.js';
1
+ import { checkDigest, digestSchemaVersion, parseDigest } from './schema.js';
2
2
  import { markdownFor } from '../annotations/store.js';
3
3
  import { mutate } from '../store/changes.js';
4
4
  import { StoreError } from '../store/types.js';
5
5
  import { digest, hash } from '../store/files.js';
6
6
  const now = () => new Date().toISOString();
7
7
  const fail = (code = 'invalid-input') => { throw new StoreError(code); };
8
- /** A parsed digest serialized in fixed key order, so key order never makes a different object. */
8
+ /** A parsed digest serialized in fixed key order, so key order never makes a different object. Optional fields appear only when present. */
9
9
  export function canonicalDigest(d) {
10
- return JSON.stringify({ schemaVersion: d.schemaVersion, form: d.form, gist: { lead: d.gist.lead, ...(d.gist.context !== undefined ? { context: d.gist.context } : {}) }, ideas: d.ideas.map(i => ({ claim: i.claim, detail: i.detail, kind: i.kind, voice: i.voice.type === 'author' ? { type: 'author' } : { type: i.voice.type, name: i.voice.name }, quotes: i.quotes.map(q => ({ text: q.text, ...(q.speaker !== undefined ? { speaker: q.speaker } : {}), ...(q.locator ? { locator: q.locator.type === 'timestamp' ? { type: 'timestamp', ms: q.locator.ms } : { type: 'page', page: q.locator.page } } : {}) })) })), tags: [...d.tags] });
10
+ return JSON.stringify({ schemaVersion: d.schemaVersion, form: d.form, gist: { lead: d.gist.lead, ...(d.gist.context !== undefined ? { context: d.gist.context } : {}) }, ideas: d.ideas.map(i => ({ claim: i.claim, detail: i.detail, ...(i.kind !== undefined ? { kind: i.kind } : {}), ...(i.voice ? { voice: i.voice.type === 'author' ? { type: 'author' } : { type: i.voice.type, name: i.voice.name } } : {}), quotes: i.quotes.map(q => ({ text: q.text, ...(q.speaker !== undefined ? { speaker: q.speaker } : {}), ...(q.attribution !== undefined ? { attribution: q.attribution } : {}), ...(q.locator ? { locator: q.locator.type === 'timestamp' ? { type: 'timestamp', ms: q.locator.ms } : { type: 'page', page: q.locator.page } } : {}) })) })), tags: [...d.tags] });
11
11
  }
12
12
  /** Content address of a derivation: the same output from the same input is the same immutable object on every device. */
13
13
  export const derivationId = (revisionId, kind, inputHash, output) => digest(JSON.stringify(['derivation-v1', revisionId, kind, inputHash, output]));
14
14
  /**
15
- * Store a draft digest only when every v1 rule passes against the revision it was made from.
16
- * Drafts are immutable data, not change-log entries; a different output is a new object.
15
+ * Store a draft digest only when it is Digest v2 and every v2 rule passes against the revision it was made from.
16
+ * Stored v1 digests stay readable; new ones are v2. Drafts are immutable data, not change-log entries; a different output is a new object.
17
17
  */
18
18
  export function putDigest(db, input) {
19
19
  hash(input.inputHash);
@@ -26,6 +26,8 @@ export function putDigest(db, input) {
26
26
  const parsed = parseDigest(input.output);
27
27
  if (!parsed.digest)
28
28
  throw Object.assign(new StoreError('invalid-input'), { issues: parsed.issues });
29
+ if (parsed.digest.schemaVersion !== digestSchemaVersion)
30
+ throw Object.assign(new StoreError('invalid-input'), { issues: [{ path: 'schemaVersion', code: 'superseded' }] });
29
31
  const check = checkDigest(parsed.digest, markdownFor(db, input.revisionId), input.options);
30
32
  if (!check.ok)
31
33
  throw Object.assign(new StoreError('invalid-input'), { issues: check.issues });
@@ -35,7 +37,7 @@ export function putDigest(db, input) {
35
37
  const id = derivationId(input.revisionId, 'digest', input.inputHash, output);
36
38
  if (db.prepare('SELECT 1 FROM derivations WHERE id=?').get(id))
37
39
  return { id, duplicate: true };
38
- db.prepare("INSERT INTO derivations(id,item_id,revision_id,kind,schema_version,producer_json,input_hash,output_json,created_at) VALUES(?,?,?,'digest',1,?,?,?,?)").run(id, input.itemId, input.revisionId, producer, input.inputHash, output, now());
40
+ db.prepare("INSERT INTO derivations(id,item_id,revision_id,kind,schema_version,producer_json,input_hash,output_json,created_at) VALUES(?,?,?,'digest',?,?,?,?,?)").run(id, input.itemId, input.revisionId, parsed.digest.schemaVersion, producer, input.inputHash, output, now());
39
41
  return { id, duplicate: false };
40
42
  }
41
43
  function derivationRow(r) { return { id: String(r.id), itemId: String(r.item_id), revisionId: String(r.revision_id), kind: 'digest', producer: JSON.parse(String(r.producer_json)), inputHash: String(r.input_hash), output: JSON.parse(String(r.output_json)), review: JSON.parse(String(r.review_json)), createdAt: String(r.created_at) }; }
@@ -69,9 +71,9 @@ export function reviewDigest(db, id, review) {
69
71
  mutate(db, 'derivation', id, path, next);
70
72
  return derivationRow(db.prepare('SELECT * FROM derivations WHERE id=?').get(id));
71
73
  }
72
- /** The accepted subset of a digest. Rejected or unreviewed fields never leave the device. */
74
+ /** The accepted subset of a digest (v1 or v2). Rejected or unreviewed fields never leave the device; v1 ideas keep their kind. */
73
75
  export function acceptedDigest(d) {
74
- const ideas = d.output.ideas.flatMap((idea, i) => d.review.ideas?.[i] === 'accepted' ? [{ claim: idea.claim, detail: idea.detail, kind: idea.kind, quotes: idea.quotes.filter((_, j) => d.review.quotes?.[i]?.[j] === 'accepted').map(q => q.text) }] : []);
76
+ const ideas = d.output.ideas.flatMap((idea, i) => d.review.ideas?.[i] === 'accepted' ? [{ claim: idea.claim, detail: idea.detail, ...(idea.kind !== undefined ? { kind: idea.kind } : {}), quotes: idea.quotes.filter((_, j) => d.review.quotes?.[i]?.[j] === 'accepted').map(q => q.text) }] : []);
75
77
  const lead = d.review.gist === 'accepted' ? d.output.gist.lead : undefined;
76
78
  return lead || ideas.length ? { ...(lead ? { lead } : {}), ideas } : undefined;
77
79
  }
@@ -1,12 +1,13 @@
1
- import { type DigestIssue, type DigestV1 } from '../digest/schema.js';
1
+ import { type DigestIssue, type DigestV2 } from '../digest/schema.js';
2
2
  import { type Form, type QuoteMatch } from '../digest/pipeline.js';
3
3
  /**
4
- * Pure conversion of one reviewed Jungle Reading digest note (`kb/notes/reading/<slug>.md`) into Digest v1. Jungle keeps
5
- * quotes in their own section, so each quote is assigned to the idea it shares the most content words with.
4
+ * Pure conversion of one reviewed Jungle Reading digest note (`kb/notes/reading/<slug>.md`) into Digest v2. Jungle keeps
5
+ * quotes in their own section, so each verbatim quote is assigned to the idea it shares the most content words with.
6
+ * Owner text is never cut or rewritten: a note that does not fit v2 as written is reported, not converted.
6
7
  * Nothing here reads files; callers pass the note and, when available, its capture Markdown.
7
8
  */
8
9
  export declare const jungleTags: readonly ["agent-eng", "ai", "work", "business", "creative-coding", "finance", "music", "internet", "security", "history", "art", "hardware", "climate", "article", "interactive", "code", "x", "video"];
9
- /** Tags that name how a source is consumed; Digest v1 carries that as `form`. */
10
+ /** Tags that name how a source is consumed; a digest carries that as `form`. */
10
11
  export declare const jungleFormatTags: readonly ["article", "interactive", "x", "video"];
11
12
  export declare class JungleDigestError extends Error {
12
13
  readonly code: 'not-digest' | 'malformed' | 'no-capture';
@@ -32,23 +33,25 @@ export interface JungleNote {
32
33
  }[];
33
34
  quotes: JungleQuote[];
34
35
  }
35
- export type Lossy = 'context-trimmed' | 'quote-unassigned' | 'quote-missing' | 'idea-unquoted' | 'no-content-tag';
36
+ /**
37
+ * Why a Jungle quote is left out of the digest: not found as written in the capture (`snapped` matches differ in markup),
38
+ * under four words, over the quote budget (count, words or source share), or every idea already holds two.
39
+ */
40
+ export type QuoteDrop = 'not-verbatim' | 'too-short' | 'budget' | 'overflow';
36
41
  export interface JungleConversion {
37
42
  note: JungleNote;
38
- /** Every idea, including unquoted ones: the evaluation gold. */
39
- gold: DigestV1;
40
- /** Per Jungle quote: the gold idea it was assigned to, and how it located in the capture. */
43
+ /** The note as Digest v2: the Gist's first sentence as lead and the rest as context, every idea, and the quotes that survive. */
44
+ gold: DigestV2;
45
+ /** Per Jungle quote, in note order: the idea it went to (null when dropped), how it located in the capture, and why it was dropped. */
41
46
  quotes: {
42
47
  idea: number | null;
43
48
  match?: QuoteMatch;
49
+ dropped?: QuoteDrop;
44
50
  }[];
45
- lossy: Lossy[];
46
- /** checkDigest issues of the gold against the capture; empty without a capture. */
51
+ /** Why the gold is not a valid Digest v2: shape issues (an empty detail), then checkDigest issues against the capture when one is given. */
47
52
  issues: DigestIssue[];
48
- /** The gold with unlocated quotes and then unquoted ideas dropped, when that still passes every v1 rule. */
49
- importable?: DigestV1;
50
- /** Gold passes every v1 rule against the capture with nothing dropped or trimmed. `kind` is never gold: Jungle has no kinds. */
51
- clean: boolean;
53
+ /** The gold, when a capture was given and the gold passes every v2 rule against it. */
54
+ digest?: DigestV2;
52
55
  }
53
56
  export type Yaml = string | boolean | Yaml[] | {
54
57
  [k: string]: Yaml;
@@ -58,7 +61,7 @@ export declare function captureBody(capture: string): string;
58
61
  export declare function parseJungleNote(markdown: string): JungleNote;
59
62
  /**
60
63
  * One Jungle Reading note of any form (digest, social, note, conversation), kept whole: the exact Markdown and its hash,
61
- * the parsed frontmatter, and the gist, ideas and quotes where the form has them. Nothing is trimmed to Digest v1 limits.
64
+ * the parsed frontmatter, and the gist, ideas and quotes where the form has them. Nothing is trimmed to digest limits.
62
65
  * `capture` is the evidence the note is about: the first `synthesizes` relation, else the first `evidenced-by`.
63
66
  */
64
67
  export interface JungleEditorial {
@@ -84,14 +87,21 @@ export declare function parseJungleEditorial(markdown: string): JungleEditorial;
84
87
  /** Content-word stems: lowercase, stopwords out, a crude plural/tense strip. Shared by idea assignment and lexical coverage. */
85
88
  export declare function contentTerms(text: string): Set<string>;
86
89
  export declare function termSimilarity(a: Set<string>, b: Set<string>): number;
87
- /** Best-first assignment: every idea gets at most one quote before any gets a second; zero-overlap pairs are never made. */
90
+ /**
91
+ * Each quote in order goes to the idea sharing the most content words with its claim and detail (ties: the earliest idea),
92
+ * else to the next best idea with room; null when every idea already holds `cap` quotes.
93
+ */
88
94
  export declare function assignQuotes(ideas: {
89
95
  claim: string;
90
96
  detail: string;
91
97
  }[], quotes: {
92
98
  text: string;
93
- }[]): (number | null)[];
99
+ }[], cap?: number): (number | null)[];
94
100
  export declare const firstSentence: (text: string) => readonly [string, string];
95
101
  export declare function formOf(note: JungleNote): Form;
96
- /** Convert one Jungle digest note. With `capture` (the capture Markdown, frontmatter allowed), quotes are located and the gold is checked. */
102
+ /**
103
+ * Convert one Jungle digest note. With `capture` (the capture Markdown, frontmatter allowed), each quote is kept only when it
104
+ * occurs as written (folded typography only) and has at least four words; whole quotes then leave, longest and then latest in
105
+ * the note first, until the rest fit the quote budget (never the last one); survivors are assigned to ideas, and the gold is checked.
106
+ */
97
107
  export declare function convertJungleDigest(noteMarkdown: string, capture?: string): JungleConversion;
@@ -1,14 +1,15 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { foldText, locateAll, words } from '../annotations/quote.js';
3
- import { checkDigest, digestLimits as L } from '../digest/schema.js';
4
- import { minQuoteWords, snapQuote } from '../digest/pipeline.js';
2
+ import { foldText, words } from '../annotations/quote.js';
3
+ import { checkDigest, digestLimits as L, parseDigest } from '../digest/schema.js';
4
+ import { snapQuote } from '../digest/pipeline.js';
5
5
  /**
6
- * Pure conversion of one reviewed Jungle Reading digest note (`kb/notes/reading/<slug>.md`) into Digest v1. Jungle keeps
7
- * quotes in their own section, so each quote is assigned to the idea it shares the most content words with.
6
+ * Pure conversion of one reviewed Jungle Reading digest note (`kb/notes/reading/<slug>.md`) into Digest v2. Jungle keeps
7
+ * quotes in their own section, so each verbatim quote is assigned to the idea it shares the most content words with.
8
+ * Owner text is never cut or rewritten: a note that does not fit v2 as written is reported, not converted.
8
9
  * Nothing here reads files; callers pass the note and, when available, its capture Markdown.
9
10
  */
10
11
  export const jungleTags = ['agent-eng', 'ai', 'work', 'business', 'creative-coding', 'finance', 'music', 'internet', 'security', 'history', 'art', 'hardware', 'climate', 'article', 'interactive', 'code', 'x', 'video'];
11
- /** Tags that name how a source is consumed; Digest v1 carries that as `form`. */
12
+ /** Tags that name how a source is consumed; a digest carries that as `form`. */
12
13
  export const jungleFormatTags = ['article', 'interactive', 'x', 'video'];
13
14
  export class JungleDigestError extends Error {
14
15
  code;
@@ -148,21 +149,14 @@ export function termSimilarity(a, b) { if (!a.size || !b.size)
148
149
  return 0; let n = 0; for (const t of a)
149
150
  if (b.has(t))
150
151
  n++; return n / Math.sqrt(a.size * b.size); }
151
- /** Best-first assignment: every idea gets at most one quote before any gets a second; zero-overlap pairs are never made. */
152
- export function assignQuotes(ideas, quotes) { return assign(ideas, quotes); }
153
- function assign(ideas, quotes) {
154
- const ideaTerms = ideas.map(i => contentTerms(`${i.claim} ${i.detail}`)), pairs = [];
155
- quotes.forEach((q, qi) => { const t = contentTerms(q.text); ideaTerms.forEach((it, ii) => { const s = termSimilarity(t, it); if (s > 0)
156
- pairs.push({ q: qi, i: ii, s }); }); });
157
- pairs.sort((a, b) => b.s - a.s || Math.abs(a.q - a.i) - Math.abs(b.q - b.i) || a.q - b.q);
158
- const out = quotes.map(() => null), load = ideas.map(() => 0);
159
- for (const cap of [1, L.maxQuotesPerIdea])
160
- for (const p of pairs)
161
- if (out[p.q] === null && load[p.i] < cap) {
162
- out[p.q] = p.i;
163
- load[p.i]++;
164
- }
165
- return out;
152
+ /**
153
+ * Each quote in order goes to the idea sharing the most content words with its claim and detail (ties: the earliest idea),
154
+ * else to the next best idea with room; null when every idea already holds `cap` quotes.
155
+ */
156
+ export function assignQuotes(ideas, quotes, cap = L.maxQuotesPerIdea) {
157
+ const terms = ideas.map(i => contentTerms(`${i.claim} ${i.detail}`)), load = ideas.map(() => 0);
158
+ return quotes.map(q => { const t = contentTerms(q.text), pick = terms.map((it, i) => ({ i, s: termSimilarity(t, it) })).sort((a, b) => b.s - a.s || a.i - b.i).find(r => load[r.i] < cap); if (!pick)
159
+ return null; load[pick.i]++; return pick.i; });
166
160
  }
167
161
  export const firstSentence = (text) => { const m = /^(.+?[.!?]["”’)]*)\s+(?=["“(]?[A-Z0-9])/.exec(text); return m ? [m[1], text.slice(m[0].length)] : [text, '']; };
168
162
  export function formOf(note) {
@@ -172,50 +166,31 @@ export function formOf(note) {
172
166
  return 'thread';
173
167
  return /\.pdf(?:$|[?#])|arxiv\.org\/pdf\//i.test(note.sourceUrl) ? 'pdf' : 'article';
174
168
  }
175
- /** Convert one Jungle digest note. With `capture` (the capture Markdown, frontmatter allowed), quotes are located and the gold is checked. */
169
+ /**
170
+ * Convert one Jungle digest note. With `capture` (the capture Markdown, frontmatter allowed), each quote is kept only when it
171
+ * occurs as written (folded typography only) and has at least four words; whole quotes then leave, longest and then latest in
172
+ * the note first, until the rest fit the quote budget (never the last one); survivors are assigned to ideas, and the gold is checked.
173
+ */
176
174
  export function convertJungleDigest(noteMarkdown, capture) {
177
- const note = parseJungleNote(noteMarkdown), lossy = new Set(), source = capture === undefined ? undefined : captureBody(capture);
178
- const [lead, rest] = firstSentence(note.gist);
179
- let context = '';
180
- for (let r = rest; r;) {
181
- const [s, next] = firstSentence(r);
182
- if (words(context ? `${context} ${s}` : s) > L.contextWords) {
183
- lossy.add('context-trimmed');
184
- break;
185
- }
186
- context = context ? `${context} ${s}` : s;
187
- r = next;
188
- }
189
- const assigned = assign(note.ideas, note.quotes), authors = new Set(note.authors.map(a => foldText(a).toLowerCase())), form = formOf(note);
190
- const located = note.quotes.map(q => source === undefined ? undefined : snapQuote(source, q.text));
191
- const ideas = note.ideas.map((idea, i) => {
192
- const mine = note.quotes.map((q, k) => ({ q, k })).filter(x => assigned[x.k] === i);
193
- const other = mine.find(x => !authors.has(foldText(x.q.speaker).toLowerCase()));
194
- // A non-author in a talk is a speaker; in writing, the author reports them.
195
- const voice = other ? { type: form === 'video' ? 'speaker' : 'reported', name: other.q.speaker } : { type: 'author' };
196
- const quotes = mine.map(x => ({ text: located[x.k]?.text ?? x.q.text, speaker: x.q.speaker }));
197
- return { claim: idea.claim, detail: idea.detail, kind: 'argument', voice, quotes };
198
- });
199
- const content = note.tags.filter(t => t !== 'reading' && !jungleFormatTags.includes(t)), tags = (content.length ? content : note.tags.filter(t => t !== 'reading')).slice(0, L.maxTags);
200
- if (!content.length)
201
- lossy.add('no-content-tag');
202
- if (assigned.some(a => a === null))
203
- lossy.add('quote-unassigned');
204
- if (located.some(l => l?.match === 'missing'))
205
- lossy.add('quote-missing');
206
- if (ideas.some(i => !i.quotes.length))
207
- lossy.add('idea-unquoted');
208
- const gold = { schemaVersion: 1, form, gist: { lead, ...(context ? { context } : {}) }, ideas, tags };
209
- const options = { tags: jungleTags };
210
- const issues = source === undefined ? [] : checkDigest(gold, source, options).issues;
211
- let importable;
212
- if (source !== undefined) {
213
- const kept = note.quotes.map((_, k) => assigned[k] !== null && located[k]?.match !== 'missing');
214
- // Import keeps Sponge's ordering rule: ideas by their earliest quote in the capture, quotes of at least minQuoteWords.
215
- const at = (t) => locateAll(source, t)[0]?.start ?? Number.MAX_SAFE_INTEGER;
216
- const trimmed = { ...gold, ideas: note.ideas.map((_, i) => ({ ...ideas[i], quotes: note.quotes.flatMap((__, k) => assigned[k] === i && kept[k] && words(located[k].text) >= minQuoteWords ? [{ text: located[k].text, speaker: note.quotes[k].speaker }] : []).sort((a, b) => at(a.text) - at(b.text)) })).filter(i => i.quotes.length).sort((a, b) => at(a.quotes[0].text) - at(b.quotes[0].text)) };
217
- if (checkDigest(trimmed, source, options).ok)
218
- importable = trimmed;
175
+ const note = parseJungleNote(noteMarkdown), source = capture === undefined ? undefined : captureBody(capture), [lead, context] = firstSentence(note.gist);
176
+ const located = note.quotes.map(q => source === undefined ? undefined : snapQuote(source, q.text).match);
177
+ const dropped = note.quotes.map((q, k) => located[k] === 'missing' || located[k] === 'snapped' ? 'not-verbatim' : words(q.text) < L.minQuoteWords ? 'too-short' : undefined);
178
+ let kept = note.quotes.flatMap((q, k) => dropped[k] ? [] : [{ q, k, n: words(q.text) }]);
179
+ // The pipeline's budget rule, applied to owner quotes: drop whole quotes, never edit one. A single quote over budget still fails the check.
180
+ const budget = source === undefined ? Infinity : Math.min(L.totalQuoteWords, Math.max(L.quoteWords, Math.floor(words(source) * L.sourceQuoteShare)));
181
+ while (kept.length > 1 && (kept.length > L.maxQuotes || kept.reduce((n, x) => n + x.n, 0) > budget)) {
182
+ const x = [...kept].sort((a, b) => b.n - a.n || b.k - a.k)[0];
183
+ dropped[x.k] = 'budget';
184
+ kept = kept.filter(y => y !== x);
219
185
  }
220
- return { note, gold, quotes: note.quotes.map((_, k) => ({ idea: assigned[k] ?? null, ...(located[k] ? { match: located[k].match } : {}) })), lossy: [...lossy], issues, ...(importable ? { importable } : {}), clean: source !== undefined && issues.length === 0 && lossy.size === 0 };
186
+ const assigned = note.quotes.map(() => null);
187
+ assignQuotes(note.ideas, kept.map(x => x.q)).forEach((idea, n) => { if (idea === null)
188
+ dropped[kept[n].k] = 'overflow';
189
+ else
190
+ assigned[kept[n].k] = idea; });
191
+ const ideas = note.ideas.map((idea, i) => ({ claim: idea.claim, detail: idea.detail, quotes: note.quotes.flatMap((q, k) => assigned[k] === i ? [{ text: q.text, ...(q.attribution ? { attribution: q.attribution } : {}) }] : []) }));
192
+ const tags = note.tags.filter(t => t !== 'reading' && !jungleFormatTags.includes(t));
193
+ const gold = { schemaVersion: 2, form: formOf(note), gist: { lead, ...(context ? { context } : {}) }, ideas, tags };
194
+ const issues = [...parseDigest(gold).issues, ...source === undefined ? [] : checkDigest(gold, source, { tags: jungleTags }).issues];
195
+ return { note, gold, quotes: note.quotes.map((_, k) => ({ idea: assigned[k] ?? null, ...(located[k] ? { match: located[k] } : {}), ...(dropped[k] ? { dropped: dropped[k] } : {}) })), issues, ...(source !== undefined && !issues.length ? { digest: gold } : {}) };
221
196
  }
@@ -1,5 +1,5 @@
1
1
  import type { DatabaseSync } from 'node:sqlite';
2
- import { type DigestV1 } from '../digest/schema.js';
2
+ import { type DigestV2 } from '../digest/schema.js';
3
3
  import { type VisibilityState } from '../identity/visibility.js';
4
4
  import { type JungleEditorial } from './jungle-digest.js';
5
5
  /**
@@ -8,7 +8,7 @@ import { type JungleEditorial } from './jungle-digest.js';
8
8
  */
9
9
  export declare const jungleNamespace = "jungle", jungleRealm = "import:jungle", jungleProducer: {
10
10
  readonly importer: "sponge-jungle-reading";
11
- readonly version: "1";
11
+ readonly version: "2";
12
12
  };
13
13
  export type EditorialAction = 'new' | 'unchanged' | 'user-review-wins';
14
14
  export type DigestAction = 'new' | 'unchanged' | 'not-convertible' | 'not-digest' | 'user-review-wins' | 'user-digest-wins';
@@ -17,13 +17,13 @@ export interface JungleNoteInput {
17
17
  itemId: string;
18
18
  revisionId: string;
19
19
  editorial: JungleEditorial;
20
- v1?: DigestV1;
20
+ digest?: DigestV2;
21
21
  }
22
22
  export interface JunglePlan {
23
23
  editorialId: string;
24
24
  editorial: EditorialAction;
25
- v1Id?: string;
26
- v1: DigestAction;
25
+ digestId?: string;
26
+ digest: DigestAction;
27
27
  visibility: VisibilityAction;
28
28
  }
29
29
  /** What Jungle's `reading.status` asks for: `published` is Jungle's explicit public approval, `draft` stays private. */
@@ -39,7 +39,7 @@ export declare function visibilityAction(state: Pick<VisibilityState, 'access' |
39
39
  export declare function planJungleNote(db: DatabaseSync, input: JungleNoteInput): JunglePlan;
40
40
  /**
41
41
  * Apply one note; the caller holds the write transaction. A new editorial object is accepted through `review.note` unless
42
- * the user already rejected or cleared an earlier one; a clean Digest v1 is stored and accepted per field, linked to the
42
+ * the user already rejected or cleared an earlier one; a converted Digest v2 is stored and accepted per field, linked to the
43
43
  * editorial object by its input hash, unless the user owns a digest of this item or changed an imported digest's review.
44
44
  */
45
45
  export declare function applyJungleNote(db: DatabaseSync, input: JungleNoteInput): JunglePlan;
@@ -8,7 +8,7 @@ import { jungleTags } from './jungle-digest.js';
8
8
  * The library side of `sponge import jungle`: one Jungle Reading note onto the item holding its capture.
9
9
  * `planJungleNote` only reads; `applyJungleNote` runs the same plan inside the caller's transaction, through the logged paths.
10
10
  */
11
- export const jungleNamespace = 'jungle', jungleRealm = 'import:jungle', jungleProducer = { importer: 'sponge-jungle-reading', version: '1' };
11
+ export const jungleNamespace = 'jungle', jungleRealm = 'import:jungle', jungleProducer = { importer: 'sponge-jungle-reading', version: '2' };
12
12
  /** Receipts of the importer's own visibility writes (local, like every import receipt), so a later user override is recognisable. */
13
13
  const visibilityReceipts = 'jungle-visibility';
14
14
  /** What Jungle's `reading.status` asks for: `published` is Jungle's explicit public approval, `draft` stays private. */
@@ -49,29 +49,30 @@ export function planJungleNote(db, input) {
49
49
  const { itemId, revisionId, editorial } = input, editorialId = derivationId(revisionId, 'editorial', editorial.markdownSha256, canonicalEditorial(editorial));
50
50
  const editorials = db.prepare("SELECT id,review_json FROM derivations WHERE item_id=? AND kind='editorial'").all(itemId);
51
51
  const editorialAction = editorials.some(r => r.id === editorialId) ? 'unchanged' : editorials.some(r => JSON.parse(String(r.review_json)).note !== 'accepted') ? 'user-review-wins' : 'new';
52
- let v1 = 'not-digest', v1Id;
53
- if (editorial.form === 'digest' && !input.v1)
54
- v1 = 'not-convertible';
55
- else if (input.v1) {
56
- const digests = db.prepare("SELECT id,producer_json,output_json,review_json FROM derivations WHERE item_id=? AND kind='digest'").all(itemId), parsed = parseDigest(input.v1).digest;
57
- v1Id = parsed && derivationId(revisionId, 'digest', editorialId, canonicalDigest(parsed));
58
- if (!parsed)
59
- v1 = 'not-convertible';
60
- else if (digests.some(r => r.id === v1Id))
61
- v1 = 'unchanged';
52
+ // Any earlier digest of the item, v1 included, takes part: an unchanged imported one is superseded by the v2 import, a user's is never.
53
+ let action = 'not-digest', digestId;
54
+ if (editorial.form === 'digest' && !input.digest)
55
+ action = 'not-convertible';
56
+ else if (input.digest) {
57
+ const digests = db.prepare("SELECT id,producer_json,output_json,review_json FROM derivations WHERE item_id=? AND kind='digest'").all(itemId), parsed = parseDigest(input.digest).digest;
58
+ digestId = parsed && derivationId(revisionId, 'digest', editorialId, canonicalDigest(parsed));
59
+ if (!parsed || parsed.schemaVersion !== 2)
60
+ action = 'not-convertible';
61
+ else if (digests.some(r => r.id === digestId))
62
+ action = 'unchanged';
62
63
  else if (digests.some(r => !imported(r)))
63
- v1 = 'user-digest-wins';
64
+ action = 'user-digest-wins';
64
65
  else if (digests.some(r => JSON.stringify(JSON.parse(String(r.review_json))) !== JSON.stringify(allAccepted(JSON.parse(String(r.output_json))))))
65
- v1 = 'user-review-wins';
66
+ action = 'user-review-wins';
66
67
  else
67
- v1 = editorialAction === 'user-review-wins' ? 'user-review-wins' : 'new';
68
+ action = editorialAction === 'user-review-wins' ? 'user-review-wins' : 'new';
68
69
  }
69
70
  const visibility = visibilityAction(visibilityState(db, itemId), editorial.status, userOverride(db, itemId)).action;
70
- return { editorialId, editorial: editorialAction, ...(v1Id ? { v1Id } : {}), v1, visibility };
71
+ return { editorialId, editorial: editorialAction, ...(digestId ? { digestId } : {}), digest: action, visibility };
71
72
  }
72
73
  /**
73
74
  * Apply one note; the caller holds the write transaction. A new editorial object is accepted through `review.note` unless
74
- * the user already rejected or cleared an earlier one; a clean Digest v1 is stored and accepted per field, linked to the
75
+ * the user already rejected or cleared an earlier one; a converted Digest v2 is stored and accepted per field, linked to the
75
76
  * editorial object by its input hash, unless the user owns a digest of this item or changed an imported digest's review.
76
77
  */
77
78
  export function applyJungleNote(db, input) {
@@ -81,9 +82,9 @@ export function applyJungleNote(db, input) {
81
82
  if (plan.editorial === 'new')
82
83
  reviewEditorial(db, plan.editorialId, 'accepted');
83
84
  }
84
- if (plan.v1 === 'new') {
85
- const stored = putDigest(db, { itemId: input.itemId, revisionId: input.revisionId, producer: { ...producer, editorial: plan.editorialId }, inputHash: plan.editorialId, output: input.v1, options: { tags: jungleTags } });
86
- reviewDigest(db, stored.id, allAccepted(input.v1));
85
+ if (plan.digest === 'new') {
86
+ const stored = putDigest(db, { itemId: input.itemId, revisionId: input.revisionId, producer: { ...producer, editorial: plan.editorialId }, inputHash: plan.editorialId, output: input.digest, options: { tags: jungleTags } });
87
+ reviewDigest(db, stored.id, allAccepted(input.digest));
87
88
  }
88
89
  const action = visibilityAction(visibilityState(db, input.itemId), input.editorial.status, userOverride(db, input.itemId));
89
90
  if (action.target !== undefined) {
@@ -1,13 +1,13 @@
1
1
  import type { LibraryStore } from '../store/index.js';
2
- import { type DigestV1 } from '../digest/schema.js';
3
- import { type JungleEditorial } from './jungle-digest.js';
2
+ import { type DigestV2 } from '../digest/schema.js';
3
+ import { type JungleEditorial, type QuoteDrop } from './jungle-digest.js';
4
4
  import { type DigestAction, type EditorialAction, type VisibilityAction } from './jungle-store.js';
5
5
  import type { BundleVerifier } from './inspect.js';
6
6
  /**
7
7
  * `sponge import jungle DIR`: every Jungle Reading note (`kb/notes/reading/<slug>.md`) onto the item holding its capture.
8
8
  * The capture goes through the verified bundle import (always as a private association); the note becomes an accepted
9
- * editorial derivation, plus an accepted Digest v1 when it converts with nothing dropped; `reading.status` becomes the
10
- * item's visibility. Jungle is only read. A dry run reads the notes, captures and library and writes nothing.
9
+ * editorial derivation, plus an accepted Digest v2 when it converts without altering owner text (non-verbatim, short and
10
+ * overflowing quotes are dropped and reported); `reading.status` becomes the item's visibility. Jungle is only read. A dry run reads the notes, captures and library and writes nothing.
11
11
  */
12
12
  export interface JungleImportOptions {
13
13
  verify?: BundleVerifier;
@@ -17,6 +17,14 @@ export interface JungleImportOptions {
17
17
  signal?: AbortSignal;
18
18
  }
19
19
  export type JungleSkip = 'not-reading-note' | 'malformed-note' | 'no-capture' | 'capture-missing' | 'item-deleted' | 'capture-unsupported' | 'capture-failed';
20
+ /** Per note: quotes assigned to ideas, and each dropped quote by its 0-based position in the note's Quotes section. */
21
+ export interface JungleQuoteReport {
22
+ assigned: number;
23
+ dropped: {
24
+ quote: number;
25
+ reason: QuoteDrop;
26
+ }[];
27
+ }
20
28
  export interface JungleEntry {
21
29
  slug: string;
22
30
  form?: string;
@@ -24,14 +32,16 @@ export interface JungleEntry {
24
32
  item?: 'new' | 'existing';
25
33
  itemId?: string;
26
34
  editorial?: EditorialAction;
27
- v1?: DigestAction;
28
- v1Issues?: string[];
35
+ digest?: DigestAction;
36
+ digestIssues?: string[];
37
+ quotes?: JungleQuoteReport;
29
38
  visibility?: VisibilityAction;
30
39
  skipped?: JungleSkip;
31
40
  code?: string;
32
41
  }
42
+ /** Schema 2: digest actions are Digest v2 (`digest`, `digestIssues`; counts add notes per reason and quotes assigned or dropped); schema 1 reported Digest v1 as `v1`. */
33
43
  export interface JungleImportReport {
34
- schemaVersion: 1;
44
+ schemaVersion: 2;
35
45
  dryRun: boolean;
36
46
  captureVerification: 'deferred' | 'verified';
37
47
  notes: number;
@@ -39,16 +49,22 @@ export interface JungleImportReport {
39
49
  newItems: number;
40
50
  existingItems: number;
41
51
  editorial: Record<string, number>;
42
- v1: Record<string, number>;
52
+ digest: Record<string, number>;
53
+ digestIssues: Record<string, number>;
54
+ quotes: {
55
+ assigned: number;
56
+ dropped: Record<string, number>;
57
+ };
43
58
  visibility: Record<string, number>;
44
59
  skipped: Record<string, number>;
45
60
  };
46
61
  entries: JungleEntry[];
47
62
  nextOffset: number | null;
48
63
  }
49
- /** Digest v1 only when the note converts with nothing dropped or trimmed and passes every rule against this revision's Markdown. */
50
- export declare function jungleV1(editorial: JungleEditorial, markdown: string): {
51
- v1?: DigestV1;
64
+ /** Digest v2 when the note converts and passes every rule against this revision's Markdown; otherwise the reasons. */
65
+ export declare function jungleDigest(editorial: JungleEditorial, markdown: string): {
66
+ digest?: DigestV2;
52
67
  issues: string[];
68
+ quotes?: JungleQuoteReport;
53
69
  };
54
70
  export declare function importJungle(store: LibraryStore, jungle: string, options?: JungleImportOptions): Promise<JungleImportReport>;
@@ -13,12 +13,13 @@ const errorCode = (error) => String(error?.code ?? (error instanceof Error ? err
13
13
  /** A capture link is `<dir>/<name>` under `kb/`, never escaping it; the bundle is its directory. */
14
14
  function captureLink(link) { if (isAbsolute(link) || link.includes('\\') || link.split('/').length < 2 || link.split('/').some(p => !p || p === '.' || p === '..'))
15
15
  throw new JungleDigestError('no-capture'); return link; }
16
- /** Digest v1 only when the note converts with nothing dropped or trimmed and passes every rule against this revision's Markdown. */
17
- export function jungleV1(editorial, markdown) {
16
+ /** Digest v2 when the note converts and passes every rule against this revision's Markdown; otherwise the reasons. */
17
+ export function jungleDigest(editorial, markdown) {
18
18
  if (editorial.form !== 'digest')
19
19
  return { issues: [] };
20
- const c = convertJungleDigest(editorial.markdown, markdown), check = checkDigest(c.gold, markdown, { tags: jungleTags }), issues = [...c.lossy, ...c.issues.map(i => `${i.path}:${i.code}`), ...check.issues.map(i => `${i.path}:${i.code}`)];
21
- return c.clean && check.ok ? { v1: c.gold, issues: [] } : { issues: [...new Set(issues)] };
20
+ const c = convertJungleDigest(editorial.markdown, markdown), check = checkDigest(c.gold, markdown, { tags: jungleTags }), issues = [...new Set([...c.issues, ...check.issues].map(i => `${i.path}:${i.code}`))];
21
+ const quotes = { assigned: c.quotes.filter(q => q.idea !== null).length, dropped: c.quotes.flatMap((q, k) => q.dropped ? [{ quote: k, reason: q.dropped }] : []) };
22
+ return c.digest && check.ok ? { digest: c.digest, issues: [], quotes } : { issues, quotes };
22
23
  }
23
24
  /** What a verified import would store, read from the bundle without the verifier: the same localization, from the manifest's asset list. */
24
25
  async function previewCapture(kb, bundle, link) {
@@ -37,7 +38,7 @@ export async function importJungle(store, jungle, options = {}) {
37
38
  throw Object.assign(Error('invalid-input'), { code: 'invalid-input' });
38
39
  if (!dryRun && !options.verify)
39
40
  throw Object.assign(Error('runtime-missing'), { code: 'runtime-missing' });
40
- const report = { schemaVersion: 1, dryRun, captureVerification: dryRun ? 'deferred' : 'verified', notes: files.length, counts: { newItems: 0, existingItems: 0, editorial: {}, v1: {}, visibility: {}, skipped: {} }, entries: [], nextOffset: offset + limit < files.length ? offset + limit : null };
41
+ const report = { schemaVersion: 2, dryRun, captureVerification: dryRun ? 'deferred' : 'verified', notes: files.length, counts: { newItems: 0, existingItems: 0, editorial: {}, digest: {}, digestIssues: {}, quotes: { assigned: 0, dropped: {} }, visibility: {}, skipped: {} }, entries: [], nextOffset: offset + limit < files.length ? offset + limit : null };
41
42
  const bump = (m, k) => { m[k] = (m[k] ?? 0) + 1; }, seen = new Set();
42
43
  for (const file of files.slice(offset, offset + limit)) {
43
44
  if (options.signal?.aborted)
@@ -82,12 +83,12 @@ export async function importJungle(store, jungle, options = {}) {
82
83
  seen.add(link);
83
84
  if (dryRun) {
84
85
  if (prior?.revisionId) {
85
- const markdown = store.revisionMarkdown(prior.revisionId), { v1, issues } = jungleV1(editorial, markdown), plan = store.planJungleNote({ itemId: prior.itemId, revisionId: prior.revisionId, editorial, ...(v1 ? { v1 } : {}) });
86
- Object.assign(entry, { itemId: prior.itemId, editorial: plan.editorial, v1: plan.v1, ...(issues.length ? { v1Issues: issues } : {}), visibility: plan.visibility });
86
+ const markdown = store.revisionMarkdown(prior.revisionId), { digest, issues, quotes } = jungleDigest(editorial, markdown), plan = store.planJungleNote({ itemId: prior.itemId, revisionId: prior.revisionId, editorial, ...(digest ? { digest } : {}) });
87
+ Object.assign(entry, { itemId: prior.itemId, editorial: plan.editorial, digest: plan.digest, ...(issues.length ? { digestIssues: issues } : {}), ...(quotes ? { quotes } : {}), visibility: plan.visibility });
87
88
  }
88
89
  else {
89
- const preview = await previewCapture(kb, bundle, link), { v1, issues } = jungleV1(editorial, preview.markdown), access = accessClass(jungleRealm, preview.locator);
90
- Object.assign(entry, { editorial: 'new', v1: editorial.form !== 'digest' ? 'not-digest' : v1 ? 'new' : 'not-convertible', ...(issues.length ? { v1Issues: issues } : {}), visibility: visibilityAction({ access, default: defaultVisibility(access, 'private'), override: null }, editorial.status, false).action });
90
+ const preview = await previewCapture(kb, bundle, link), { digest, issues, quotes } = jungleDigest(editorial, preview.markdown), access = accessClass(jungleRealm, preview.locator);
91
+ Object.assign(entry, { editorial: 'new', digest: editorial.form !== 'digest' ? 'not-digest' : digest ? 'new' : 'not-convertible', ...(issues.length ? { digestIssues: issues } : {}), ...(quotes ? { quotes } : {}), visibility: visibilityAction({ access, default: defaultVisibility(access, 'private'), override: null }, editorial.status, false).action });
91
92
  }
92
93
  }
93
94
  else {
@@ -103,12 +104,19 @@ export async function importJungle(store, jungle, options = {}) {
103
104
  entry.code = code;
104
105
  throw entry;
105
106
  }
106
- const itemId = imported.itemId, revisionId = store.importedRevision(itemId, importBundleHash(imported.sourceHash)), markdown = store.revisionMarkdown(revisionId), { v1, issues } = jungleV1(editorial, markdown);
107
- const plan = store.applyJungleNote({ itemId, revisionId, editorial, ...(v1 ? { v1 } : {}) });
108
- Object.assign(entry, { itemId, editorial: plan.editorial, v1: plan.v1, ...(issues.length ? { v1Issues: issues } : {}), visibility: plan.visibility });
107
+ const itemId = imported.itemId, revisionId = store.importedRevision(itemId, importBundleHash(imported.sourceHash)), markdown = store.revisionMarkdown(revisionId), { digest, issues, quotes } = jungleDigest(editorial, markdown);
108
+ const plan = store.applyJungleNote({ itemId, revisionId, editorial, ...(digest ? { digest } : {}) });
109
+ Object.assign(entry, { itemId, editorial: plan.editorial, digest: plan.digest, ...(issues.length ? { digestIssues: issues } : {}), ...(quotes ? { quotes } : {}), visibility: plan.visibility });
109
110
  }
110
111
  bump(report.counts.editorial, entry.editorial);
111
- bump(report.counts.v1, entry.v1);
112
+ bump(report.counts.digest, entry.digest);
113
+ for (const i of new Set((entry.digestIssues ?? []).map(i => i.replace(/\.\d+/g, '.N'))))
114
+ bump(report.counts.digestIssues, i);
115
+ if (entry.quotes) {
116
+ report.counts.quotes.assigned += entry.quotes.assigned;
117
+ for (const d of entry.quotes.dropped)
118
+ bump(report.counts.quotes.dropped, d.reason);
119
+ }
112
120
  bump(report.counts.visibility, entry.visibility);
113
121
  if (entry.item === 'new')
114
122
  report.counts.newItems++;
@@ -1,4 +1,4 @@
1
- import { constants, openSync, closeSync, fstatSync, fsyncSync, unlinkSync } from 'node:fs';
1
+ import { constants, openSync, closeSync, fstatSync, fsyncSync, unlinkSync, rmSync } from 'node:fs';
2
2
  import { dirname, join, resolve } from 'node:path';
3
3
  import { openLibrary } from '../store/index.js';
4
4
  import { safeDirectory } from '../store/files.js';
@@ -74,6 +74,8 @@ export async function backupLibrary(store, output) {
74
74
  closeSync(fd);
75
75
  try {
76
76
  unlinkSync(snapshot.path);
77
+ for (const side of ['-wal', '-shm'])
78
+ rmSync(snapshot.path + side, { force: true });
77
79
  }
78
80
  finally {
79
81
  store.finishBackup(snapshot.id);
@@ -12,8 +12,8 @@ function selectedClaim(db, revisionId, fields) {
12
12
  return undefined;
13
13
  }
14
14
  /**
15
- * The public digest: the accepted fields of the current Digest v1, else the public subset of an accepted editorial
16
- * note (lead, claims, verbatim quotes within the v1 budget). Never both, and never the note's other content.
15
+ * The public digest: the accepted fields of the current digest (v2, or a stored v1), else the public subset of an accepted
16
+ * editorial note (lead, claims, verbatim quotes within the v2 limits). Never both, never attributions, and never the note's other content.
17
17
  */
18
18
  function publicDigest(db, id) { const d = currentDigest(db, id); if (d)
19
19
  return acceptedDigest(d); const e = currentEditorial(db, id); return e && publicEditorial(db, e); }
@@ -27,14 +27,14 @@
27
27
  }
28
28
  ],
29
29
  "build": {
30
- "asrSourceSha256": "1cb743b39e76666adc814306e06c02ae1c59ca3b784a6bb8f186270387f59ee9",
30
+ "asrSourceSha256": "e944e4e0b1c2cf571e78a83fcfbf56a91a14fd08dcc701562d32555cf090ec40",
31
31
  "clangVersion": "Apple clang version 21.0.0 (clang-2100.1.1.101)",
32
- "embeddingSourceSha256": "7e499ea896cd9805daca51bb6489c0856acad4edbaf7e76b07ff1e63d5766457",
32
+ "embeddingSourceSha256": "7d58718911725b3c3dce35a09a253704b4e5e732cf1e23c12389485127534b10",
33
33
  "swiftVersion": "Apple Swift version 6.3.3 (swiftlang-6.3.3.1.3 clang-2100.1.1.101)"
34
34
  },
35
35
  "minimumMacOS": "26.0",
36
- "packageVersion": "0.2.5",
36
+ "packageVersion": "0.2.6",
37
37
  "platform": "darwin",
38
38
  "schemaVersion": 1,
39
- "sourceCommit": "e69bea289723e18288322257ffff269b7fe16bac"
39
+ "sourceCommit": "1190f23ad793985b6934583c5d8b0957d79a4ce0"
40
40
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hraness/sponge",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "description": "A local library for captured articles and verified offline assets.",
5
5
  "repository": {
6
6
  "type": "git",