@hraness/sponge 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +54 -0
  2. package/dist/migrations/005.d.ts +3 -0
  3. package/dist/migrations/005.js +19 -0
  4. package/dist/migrations/006.d.ts +3 -0
  5. package/dist/migrations/006.js +9 -0
  6. package/dist/migrations/007.d.ts +3 -0
  7. package/dist/migrations/007.js +10 -0
  8. package/dist/src/annotations/quote.d.ts +32 -0
  9. package/dist/src/annotations/quote.js +135 -0
  10. package/dist/src/annotations/store.d.ts +50 -0
  11. package/dist/src/annotations/store.js +89 -0
  12. package/dist/src/app/source.d.ts +2 -0
  13. package/dist/src/app/source.js +13 -17
  14. package/dist/src/cli/arguments.js +24 -7
  15. package/dist/src/cli/digest.d.ts +10 -0
  16. package/dist/src/cli/digest.js +63 -0
  17. package/dist/src/cli/import.d.ts +11 -0
  18. package/dist/src/cli/import.js +10 -0
  19. package/dist/src/cli/indexing.d.ts +3 -0
  20. package/dist/src/cli/indexing.js +5 -2
  21. package/dist/src/cli/main.js +94 -8
  22. package/dist/src/cli/runtimes.d.ts +10 -0
  23. package/dist/src/cli/runtimes.js +32 -0
  24. package/dist/src/digest/editorial.d.ts +54 -0
  25. package/dist/src/digest/editorial.js +87 -0
  26. package/dist/src/digest/endpoint.d.ts +20 -0
  27. package/dist/src/digest/endpoint.js +103 -0
  28. package/dist/src/digest/pipeline.d.ts +148 -0
  29. package/dist/src/digest/pipeline.js +399 -0
  30. package/dist/src/digest/schema.d.ts +126 -0
  31. package/dist/src/digest/schema.js +138 -0
  32. package/dist/src/digest/store.d.ts +57 -0
  33. package/dist/src/digest/store.js +79 -0
  34. package/dist/src/identity/query-policy.d.ts +21 -0
  35. package/dist/src/identity/query-policy.js +55 -0
  36. package/dist/src/identity/uri.d.ts +30 -0
  37. package/dist/src/identity/uri.js +94 -0
  38. package/dist/src/identity/visibility.d.ts +24 -0
  39. package/dist/src/identity/visibility.js +75 -0
  40. package/dist/src/importers/apply.d.ts +10 -0
  41. package/dist/src/importers/apply.js +25 -22
  42. package/dist/src/importers/jungle-digest.d.ts +107 -0
  43. package/dist/src/importers/jungle-digest.js +196 -0
  44. package/dist/src/importers/jungle-store.d.ts +45 -0
  45. package/dist/src/importers/jungle-store.js +96 -0
  46. package/dist/src/importers/jungle.d.ts +70 -0
  47. package/dist/src/importers/jungle.js +142 -0
  48. package/dist/src/indexing/index.d.ts +7 -0
  49. package/dist/src/indexing/index.js +31 -8
  50. package/dist/src/indexing/prune-run.d.ts +22 -0
  51. package/dist/src/indexing/prune-run.js +67 -0
  52. package/dist/src/indexing/prune.d.ts +30 -0
  53. package/dist/src/indexing/prune.js +95 -0
  54. package/dist/src/indexing/runner.js +5 -5
  55. package/dist/src/inference/embedding/index.d.ts +25 -0
  56. package/dist/src/inference/embedding/index.js +11 -0
  57. package/dist/src/models/artifacts.js +175 -33
  58. package/dist/src/portability/backup.js +3 -1
  59. package/dist/src/portability/database.js +3 -0
  60. package/dist/src/publish/component.d.ts +8 -0
  61. package/dist/src/publish/component.js +114 -0
  62. package/dist/src/publish/feed.d.ts +24 -0
  63. package/dist/src/publish/feed.js +77 -0
  64. package/dist/src/publish/identity.d.ts +6 -0
  65. package/dist/src/publish/identity.js +68 -0
  66. package/dist/src/publish/index.d.ts +34 -0
  67. package/dist/src/publish/index.js +137 -0
  68. package/dist/src/publish/projection.d.ts +29 -0
  69. package/dist/src/publish/projection.js +43 -0
  70. package/dist/src/search/hybrid.js +2 -2
  71. package/dist/src/search/lexical.d.ts +11 -0
  72. package/dist/src/search/lexical.js +6 -0
  73. package/dist/src/search/query.d.ts +7 -3
  74. package/dist/src/search/query.js +52 -33
  75. package/dist/src/search/vector.d.ts +1 -1
  76. package/dist/src/search/vector.js +6 -5
  77. package/dist/src/store/changes.d.ts +42 -0
  78. package/dist/src/store/changes.js +178 -0
  79. package/dist/src/store/foundation.d.ts +7 -0
  80. package/dist/src/store/foundation.js +7 -0
  81. package/dist/src/store/index.d.ts +69 -0
  82. package/dist/src/store/index.js +134 -51
  83. package/dist/src/worker/runtime-gc.d.ts +30 -0
  84. package/dist/src/worker/runtime-gc.js +296 -0
  85. package/native/runtime-manifest.json +4 -4
  86. package/package.json +1 -1
@@ -0,0 +1,75 @@
1
+ import { accessClass, defaultVisibility, deriveUris } from './uri.js';
2
+ import { mutate } from '../store/changes.js';
3
+ import { StoreError } from '../store/types.js';
4
+ import { text } from '../store/files.js';
5
+ const fail = (code = 'invalid-input') => { throw new StoreError(code); };
6
+ function firstSource(db, itemId) { return db.prepare('SELECT realm_key,locator FROM source_refs WHERE item_id=? ORDER BY rowid LIMIT 1').get(itemId); }
7
+ /** Recompute derived URIs; metadata and user URIs are left alone. Idempotent. */
8
+ export function syncUris(db, itemId) {
9
+ const source = firstSource(db, itemId);
10
+ if (!source)
11
+ return;
12
+ const pdf = db.prepare("SELECT ra.sha256 FROM items i JOIN revision_assets ra ON ra.revision_id=i.current_revision WHERE i.id=? AND ra.relationship='source-pdf' ORDER BY ra.ordinal LIMIT 1").get(itemId);
13
+ const uris = deriveUris({ itemId, locator: String(source.locator), ...(pdf ? { sourcePdfSha256: String(pdf.sha256) } : {}) });
14
+ const current = db.prepare("SELECT uri,role,resolvable,origin FROM item_uris WHERE item_id=? AND origin IN ('locator','revision') ORDER BY uri").all(itemId).map(r => `${r.uri}|${r.role}|${r.resolvable}|${r.origin}`).join('\n');
15
+ const next = uris.map(u => `${u.uri}|${u.role}|${Number(u.resolvable)}|${u.origin}`).sort().join('\n');
16
+ if (current === next)
17
+ return;
18
+ db.prepare("DELETE FROM item_uris WHERE item_id=? AND origin IN ('locator','revision')").run(itemId);
19
+ // A derived primary replaces any other primary; keep later metadata/user URIs as aliases.
20
+ db.prepare("UPDATE item_uris SET role='alias' WHERE item_id=?").run(itemId);
21
+ for (const u of uris)
22
+ db.prepare('INSERT INTO item_uris VALUES(?,?,?,?,?) ON CONFLICT(item_id,uri) DO UPDATE SET role=excluded.role,resolvable=excluded.resolvable,origin=excluded.origin').run(itemId, u.uri, u.role, Number(u.resolvable), u.origin);
23
+ }
24
+ /**
25
+ * Fill URIs for items created before schema 5, inside migration 5. Each item is its own savepoint:
26
+ * one hostile locator leaves that item without URIs instead of making the library unopenable.
27
+ */
28
+ export function backfillUris(db) {
29
+ const missing = db.prepare("SELECT i.id FROM items i WHERE NOT EXISTS (SELECT 1 FROM item_uris u WHERE u.item_id=i.id AND u.role='primary') AND EXISTS (SELECT 1 FROM source_refs r WHERE r.item_id=i.id)").all().map(r => String(r.id));
30
+ let skipped = 0;
31
+ for (const id of missing) {
32
+ db.exec('SAVEPOINT backfill_uri');
33
+ try {
34
+ syncUris(db, id);
35
+ db.exec('RELEASE backfill_uri');
36
+ }
37
+ catch {
38
+ db.exec('ROLLBACK TO backfill_uri; RELEASE backfill_uri');
39
+ skipped++;
40
+ }
41
+ }
42
+ return { filled: missing.length - skipped, skipped };
43
+ }
44
+ export function itemUris(db, itemId) {
45
+ return db.prepare("SELECT uri,role,resolvable,origin FROM item_uris WHERE item_id=? ORDER BY role='alias',uri").all(itemId).map(r => ({ uri: String(r.uri), role: r.role, resolvable: !!r.resolvable, origin: r.origin }));
46
+ }
47
+ /** Items sharing any URI with `uri`: the local half of "who else is reading this". */
48
+ export function itemsForUri(db, uri) {
49
+ text(uri, 8192);
50
+ return db.prepare('SELECT DISTINCT u.item_id FROM item_uris u JOIN items i ON i.id=u.item_id WHERE u.uri=? AND i.deleted_at IS NULL ORDER BY u.item_id').all(uri).map(r => String(r.item_id));
51
+ }
52
+ export function visibility(db, itemId) {
53
+ const item = db.prepare('SELECT visibility,deleted_at FROM items WHERE id=?').get(itemId) ?? fail('unavailable'), source = firstSource(db, itemId);
54
+ const access = source ? accessClass(String(source.realm_key), String(source.locator)) : 'local';
55
+ const reading = db.prepare('SELECT state_json FROM reading_state WHERE item_id=?').get(itemId);
56
+ let importedPrivacy;
57
+ try {
58
+ importedPrivacy = reading ? JSON.parse(String(reading.state_json))?.sourcePrivacy : undefined;
59
+ }
60
+ catch {
61
+ importedPrivacy = undefined;
62
+ }
63
+ const base = defaultVisibility(access, importedPrivacy), override = (item.visibility ?? null);
64
+ return { access, default: base, override, effective: item.deleted_at ? 'private' : override ?? base };
65
+ }
66
+ export function setVisibility(db, itemId, value) {
67
+ if (value !== null && value !== 'public' && value !== 'private')
68
+ fail();
69
+ const item = db.prepare('SELECT visibility,deleted_at FROM items WHERE id=?').get(itemId);
70
+ if (!item || item.deleted_at)
71
+ fail('unavailable');
72
+ if ((item.visibility ?? null) !== value)
73
+ mutate(db, 'item', itemId, 'visibility', value);
74
+ return visibility(db, itemId);
75
+ }
@@ -1,4 +1,5 @@
1
1
  import type { LibraryStore } from '../store/index.js';
2
+ import { type ImportEntry } from './manifest.js';
2
3
  import { type BundleVerifier } from './inspect.js';
3
4
  import type { CaptureRuntime } from '../acquisition/runtime.js';
4
5
  export interface ImportOptions {
@@ -25,5 +26,14 @@ export interface ImportReport {
25
26
  }[];
26
27
  nextOffset: number | null;
27
28
  }
29
+ /** One verified bundle: a receipt short-circuits, a dry run stops after verification, otherwise the capture is promoted through the queue. */
30
+ export declare function importBundleEntry(store: LibraryStore, root: string, namespace: string, entry: ImportEntry, verify: BundleVerifier, dryRun: boolean, signal?: AbortSignal): Promise<{
31
+ status: 'ready' | 'imported' | 'duplicate';
32
+ sourceHash: string;
33
+ historicalIntegrity: 'verified' | 'unavailable';
34
+ itemId?: string;
35
+ }>;
36
+ /** The revision a bundle import committed: content-addressed by its source hash. */
37
+ export declare const importBundleHash: (sourceHash: string) => string;
28
38
  /** Additive bounded batches. Every applied entry is reverified; source bundles are read-only. */
29
39
  export declare function importManifest(store: LibraryStore, path: string, options: ImportOptions): Promise<ImportReport>;
@@ -27,7 +27,7 @@ async function promote(context, bundle, namespace, entry, sourceHash) {
27
27
  throw Error('cancelled');
28
28
  await checkpoint('promoting');
29
29
  const title = selectedMetadata(bundle.metadata, 'title')?.value;
30
- store.commitRevision({ ...attempt, itemId: source.itemId, bundleHash: hash(JSON.stringify(['wordcell-import-v1', sourceHash])), markdown: localized.markdown, title: typeof title === 'string' ? title : undefined, extractor: { name: 'sponge-wordcell-import', version: '1' }, status: bundle.status === 'partial' || localized.missingImages ? 'partial' : 'complete', metadata: [...bundle.metadata, ...Object.entries({ importNamespace: namespace, externalSourceId: entry.id, sourcePrivacy: entry.privacy }).map(([field, value]) => ({ field, value, source: bundle.kind === 'pdf' ? 'pdf' : 'html', observedAt: new Date().toISOString(), rule: 'sponge-import-v1' }))], assets, warnings: bundle.status === 'partial' || localized.missingImages ? ['partial'] : [], importedState: { namespace, sourceKey: entry.id, sourceHash, importerVersion: '1', notes: entry.notes, readingState: entry.readingState, privacy: entry.privacy } });
30
+ store.commitRevision({ ...attempt, itemId: source.itemId, bundleHash: importBundleHash(sourceHash), markdown: localized.markdown, title: typeof title === 'string' ? title : undefined, extractor: { name: 'sponge-wordcell-import', version: '1' }, status: bundle.status === 'partial' || localized.missingImages ? 'partial' : 'complete', metadata: [...bundle.metadata, ...Object.entries({ importNamespace: namespace, externalSourceId: entry.id, sourcePrivacy: entry.privacy }).map(([field, value]) => ({ field, value, source: bundle.kind === 'pdf' ? 'pdf' : 'html', observedAt: new Date().toISOString(), rule: 'sponge-import-v1' }))], assets, warnings: bundle.status === 'partial' || localized.missingImages ? ['partial'] : [], importedState: { namespace, sourceKey: entry.id, sourceHash, importerVersion: '1', notes: entry.notes, readingState: entry.readingState, privacy: entry.privacy } });
31
31
  committed = true;
32
32
  result = { ok: true, itemId: source.itemId };
33
33
  }
@@ -39,6 +39,28 @@ async function promote(context, bundle, namespace, entry, sourceHash) {
39
39
  store.settleAttempt(attempt);
40
40
  return { ...result, cleanup: 'settled' };
41
41
  }
42
+ /** One verified bundle: a receipt short-circuits, a dry run stops after verification, otherwise the capture is promoted through the queue. */
43
+ export async function importBundleEntry(store, root, namespace, entry, verify, dryRun, signal) {
44
+ const bundle = await inspectImportBundle(await importBundleRoot(root, entry), verify), sourceHash = hash(JSON.stringify([bundle.importHash, entry])), receipt = store.lookupImportReceipt(namespace, entry.id, sourceHash);
45
+ if (signal?.aborted)
46
+ throw Object.assign(Error('cancelled'), { code: 'cancelled' });
47
+ const base = { sourceHash, historicalIntegrity: bundle.historicalIntegrity };
48
+ if (receipt)
49
+ return { ...base, status: 'duplicate', itemId: receipt.itemId };
50
+ if (dryRun)
51
+ return { ...base, status: 'ready' };
52
+ const submission = store.submitSource({ kind: bundle.kind, sourceKey: hash(JSON.stringify([namespace, entry.id])), locator: bundle.sourceUrl, realm: 'import:' + namespace, idempotencyKey: 'import:' + hash(JSON.stringify([namespace, entry.id, sourceHash])), intent: 'refresh' });
53
+ const job = store.listJobs().find(row => row.id === submission.jobId);
54
+ if (job && ['failed', 'needs-auth', 'needs-permission', 'unsupported'].includes(String(job.state)))
55
+ store.retryJob(submission.jobId, { automatic: false });
56
+ const results = await drainQueue(store, { jobId: submission.jobId, signal, handlers: { [bundle.kind]: context => promote(context, bundle, namespace, entry, sourceHash) } });
57
+ const result = results[0];
58
+ if (!result || !result.ok)
59
+ throw Object.assign(Error(result && !result.ok ? result.code : 'integrity'), { code: result && !result.ok ? result.code : 'integrity' });
60
+ return { ...base, status: 'imported', itemId: submission.itemId };
61
+ }
62
+ /** The revision a bundle import committed: content-addressed by its source hash. */
63
+ export const importBundleHash = (sourceHash) => hash(JSON.stringify(['wordcell-import-v1', sourceHash]));
42
64
  /** Additive bounded batches. Every applied entry is reverified; source bundles are read-only. */
43
65
  export async function importManifest(store, path, options) {
44
66
  const { root, manifest, sha256 } = await readImportManifest(path), limit = options.limit ?? 20, offset = options.offset ?? 0;
@@ -50,27 +72,8 @@ export async function importManifest(store, path, options) {
50
72
  if (options.signal?.aborted)
51
73
  throw Object.assign(Error('cancelled'), { code: 'cancelled' });
52
74
  try {
53
- const bundle = await inspectImportBundle(await importBundleRoot(root, entry), verify), sourceHash = hash(JSON.stringify([bundle.importHash, entry])), receipt = store.lookupImportReceipt(manifest.namespace, entry.id, sourceHash);
54
- if (options.signal?.aborted)
55
- throw Object.assign(Error('cancelled'), { code: 'cancelled' });
56
- const base = { id: entry.id, sourceHash, historicalIntegrity: bundle.historicalIntegrity, privacy: entry.privacy };
57
- if (receipt) {
58
- report.entries.push({ ...base, status: 'duplicate', itemId: receipt.itemId });
59
- continue;
60
- }
61
- if (dryRun) {
62
- report.entries.push({ ...base, status: 'ready' });
63
- continue;
64
- }
65
- const submission = store.submitSource({ kind: bundle.kind, sourceKey: hash(JSON.stringify([manifest.namespace, entry.id])), locator: bundle.sourceUrl, realm: 'import:' + manifest.namespace, idempotencyKey: 'import:' + hash(JSON.stringify([manifest.namespace, entry.id, sourceHash])), intent: 'refresh' });
66
- const job = store.listJobs().find(row => row.id === submission.jobId);
67
- if (job && ['failed', 'needs-auth', 'needs-permission', 'unsupported'].includes(String(job.state)))
68
- store.retryJob(submission.jobId, { automatic: false });
69
- const results = await drainQueue(store, { jobId: submission.jobId, signal: options.signal, handlers: { [bundle.kind]: context => promote(context, bundle, manifest.namespace, entry, sourceHash) } });
70
- const result = results[0];
71
- if (!result || !result.ok)
72
- throw Object.assign(Error(result && !result.ok ? result.code : 'integrity'), { code: result && !result.ok ? result.code : 'integrity' });
73
- report.entries.push({ ...base, status: 'imported', itemId: submission.itemId });
75
+ const result = await importBundleEntry(store, root, manifest.namespace, entry, verify, dryRun, options.signal), base = { id: entry.id, sourceHash: result.sourceHash, historicalIntegrity: result.historicalIntegrity, privacy: entry.privacy };
76
+ report.entries.push({ ...base, status: result.status, ...(result.itemId ? { itemId: result.itemId } : {}) });
74
77
  }
75
78
  catch (error) {
76
79
  const code = errorCode(error);
@@ -0,0 +1,107 @@
1
+ import { type DigestIssue, type DigestV2 } from '../digest/schema.js';
2
+ import { type Form, type QuoteMatch } from '../digest/pipeline.js';
3
+ /**
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.
7
+ * Nothing here reads files; callers pass the note and, when available, its capture Markdown.
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"];
10
+ /** Tags that name how a source is consumed; a digest carries that as `form`. */
11
+ export declare const jungleFormatTags: readonly ["article", "interactive", "x", "video"];
12
+ export declare class JungleDigestError extends Error {
13
+ readonly code: 'not-digest' | 'malformed' | 'no-capture';
14
+ constructor(code: 'not-digest' | 'malformed' | 'no-capture');
15
+ }
16
+ export interface JungleQuote {
17
+ text: string;
18
+ attribution: string;
19
+ speaker: string;
20
+ }
21
+ export interface JungleNote {
22
+ slug: string;
23
+ title: string;
24
+ status: string;
25
+ sourceUrl: string;
26
+ authors: string[];
27
+ tags: string[];
28
+ capture?: string;
29
+ gist: string;
30
+ ideas: {
31
+ claim: string;
32
+ detail: string;
33
+ }[];
34
+ quotes: JungleQuote[];
35
+ }
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';
41
+ export interface JungleConversion {
42
+ note: JungleNote;
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. */
46
+ quotes: {
47
+ idea: number | null;
48
+ match?: QuoteMatch;
49
+ dropped?: QuoteDrop;
50
+ }[];
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. */
52
+ issues: DigestIssue[];
53
+ /** The gold, when a capture was given and the gold passes every v2 rule against it. */
54
+ digest?: DigestV2;
55
+ }
56
+ export type Yaml = string | boolean | Yaml[] | {
57
+ [k: string]: Yaml;
58
+ };
59
+ /** Capture Markdown without its YAML frontmatter: what a Sponge revision of the same page would hold. */
60
+ export declare function captureBody(capture: string): string;
61
+ export declare function parseJungleNote(markdown: string): JungleNote;
62
+ /**
63
+ * One Jungle Reading note of any form (digest, social, note, conversation), kept whole: the exact Markdown and its hash,
64
+ * the parsed frontmatter, and the gist, ideas and quotes where the form has them. Nothing is trimmed to digest limits.
65
+ * `capture` is the evidence the note is about: the first `synthesizes` relation, else the first `evidenced-by`.
66
+ */
67
+ export interface JungleEditorial {
68
+ schemaVersion: 1;
69
+ source: 'jungle-reading';
70
+ form: string;
71
+ slug: string;
72
+ status: string;
73
+ title: string;
74
+ sourceUrl: string;
75
+ capture: string;
76
+ frontmatter: Yaml;
77
+ gist?: string;
78
+ ideas?: {
79
+ claim: string;
80
+ detail: string;
81
+ }[];
82
+ quotes?: JungleQuote[];
83
+ markdownSha256: string;
84
+ markdown: string;
85
+ }
86
+ export declare function parseJungleEditorial(markdown: string): JungleEditorial;
87
+ /** Content-word stems: lowercase, stopwords out, a crude plural/tense strip. Shared by idea assignment and lexical coverage. */
88
+ export declare function contentTerms(text: string): Set<string>;
89
+ export declare function termSimilarity(a: Set<string>, b: Set<string>): number;
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
+ */
94
+ export declare function assignQuotes(ideas: {
95
+ claim: string;
96
+ detail: string;
97
+ }[], quotes: {
98
+ text: string;
99
+ }[], cap?: number): (number | null)[];
100
+ export declare const firstSentence: (text: string) => readonly [string, string];
101
+ export declare function formOf(note: JungleNote): Form;
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
+ */
107
+ export declare function convertJungleDigest(noteMarkdown: string, capture?: string): JungleConversion;
@@ -0,0 +1,196 @@
1
+ import { createHash } from 'node:crypto';
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
+ /**
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.
9
+ * Nothing here reads files; callers pass the note and, when available, its capture Markdown.
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'];
12
+ /** Tags that name how a source is consumed; a digest carries that as `form`. */
13
+ export const jungleFormatTags = ['article', 'interactive', 'x', 'video'];
14
+ export class JungleDigestError extends Error {
15
+ code;
16
+ constructor(code) {
17
+ super(code);
18
+ this.code = code;
19
+ this.name = 'JungleDigestError';
20
+ }
21
+ }
22
+ const fail = (code = 'malformed') => { throw new JungleDigestError(code); };
23
+ /** The frontmatter subset Jungle writes: block maps and lists of scalars, quoted or plain. */
24
+ function parseYaml(lines) {
25
+ let i = 0;
26
+ const indent = (l) => l.length - l.trimStart().length;
27
+ const scalar = (v) => { v = v.trim(); if (v.startsWith('"')) {
28
+ try {
29
+ return JSON.parse(v);
30
+ }
31
+ catch {
32
+ return fail();
33
+ }
34
+ } if (v.startsWith("'"))
35
+ return v.endsWith("'") && v.length > 1 ? v.slice(1, -1).replace(/''/g, "'") : fail(); return v === 'true' ? true : v === 'false' ? false : v; };
36
+ const block = (at) => {
37
+ if (lines[i].trimStart().startsWith('- ')) {
38
+ const list = [];
39
+ while (i < lines.length && indent(lines[i]) === at && lines[i].trimStart().startsWith('- ')) {
40
+ const rest = lines[i].trimStart().slice(2);
41
+ if (/^[\w-]+:(\s|$)/.test(rest)) {
42
+ lines[i] = ' '.repeat(at + 2) + rest;
43
+ list.push(map(at + 2));
44
+ }
45
+ else {
46
+ list.push(scalar(rest));
47
+ i++;
48
+ }
49
+ }
50
+ return list;
51
+ }
52
+ return map(at);
53
+ };
54
+ const map = (at) => {
55
+ const out = {};
56
+ while (i < lines.length && indent(lines[i]) === at && !lines[i].trimStart().startsWith('- ')) {
57
+ const m = /^([\w-]+):(?:\s+(.*))?$/.exec(lines[i].trim()) ?? fail();
58
+ i++;
59
+ if (m[2] !== undefined && m[2] !== '')
60
+ out[m[1]] = scalar(m[2]);
61
+ else if (i < lines.length && (indent(lines[i]) > at || indent(lines[i]) === at && lines[i].trimStart().startsWith('- ')))
62
+ out[m[1]] = block(indent(lines[i]));
63
+ else
64
+ out[m[1]] = '';
65
+ }
66
+ return out;
67
+ };
68
+ const value = lines.length ? map(0) : {};
69
+ return i === lines.length ? value : fail();
70
+ }
71
+ const object = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
72
+ const strings = (v) => Array.isArray(v) && v.every(x => typeof x === 'string') ? v : fail();
73
+ /** Split a Markdown file into its frontmatter lines and body. */
74
+ function frontmatter(markdown) {
75
+ const m = /^---\n([^]*?)\n---(?:\n|$)/.exec(markdown.replace(/\r\n/g, '\n'));
76
+ return m ? { head: m[1].split('\n').filter(l => l.trim() && !l.trimStart().startsWith('#')), body: markdown.replace(/\r\n/g, '\n').slice(m[0].length) } : { head: [], body: markdown };
77
+ }
78
+ /** Capture Markdown without its YAML frontmatter: what a Sponge revision of the same page would hold. */
79
+ export function captureBody(capture) { return frontmatter(capture).body; }
80
+ /** `## Heading` sections of a note body, trimmed. */
81
+ function sectionsOf(body) {
82
+ const sections = new Map();
83
+ for (const part of body.split(/^## /m).slice(1)) {
84
+ const nl = part.indexOf('\n');
85
+ sections.set(part.slice(0, nl < 0 ? undefined : nl).trim(), nl < 0 ? '' : part.slice(nl + 1).trim());
86
+ }
87
+ return sections;
88
+ }
89
+ /** `- **Claim.** detail` bullets: Jungle's Ideas, and a conversation's Debate. */
90
+ const ideaList = (text) => text.split('\n').filter(l => l.startsWith('- ')).map(l => { const m = /^- \*\*(.+?)\*\*\s*(.*)$/.exec(l.trim()) ?? fail(); return { claim: m[1].trim(), detail: m[2].trim() }; });
91
+ /** Blockquotes ending `> Attribution: Speaker, context`. */
92
+ const quoteList = (text) => text.split(/\n\s*\n/).filter(b => b.trim()).map(b => {
93
+ const lines = b.split('\n').map(l => /^>\s?(.*)$/.exec(l)?.[1] ?? fail()), at = lines.findIndex(l => l.startsWith('Attribution:'));
94
+ if (at < 1)
95
+ fail();
96
+ const text = lines.slice(0, at).join(' ').replace(/\s+/g, ' ').trim(), attribution = lines[at].slice('Attribution:'.length).trim();
97
+ return { text, attribution, speaker: attribution.split(/,|;|\s\(|\s—\s|\s-\s/)[0].trim() };
98
+ });
99
+ export function parseJungleNote(markdown) {
100
+ if (typeof markdown !== 'string' || markdown.length > 4 * 1024 * 1024)
101
+ fail();
102
+ const { head, body } = frontmatter(markdown), meta = parseYaml(head);
103
+ if (!object(meta) || !object(meta.reading))
104
+ throw new JungleDigestError('not-digest');
105
+ const reading = meta.reading;
106
+ if (reading.form !== 'digest')
107
+ fail('not-digest');
108
+ const sections = sectionsOf(body);
109
+ const gist = (sections.get('Gist') ?? fail()).replace(/\s+/g, ' ').trim();
110
+ const ideas = ideaList(sections.get('Ideas') ?? fail());
111
+ const quotes = quoteList(sections.get('Quotes') ?? '');
112
+ if (!gist || !ideas.length || quotes.some(q => !q.text || !q.speaker))
113
+ fail();
114
+ const authors = Array.isArray(reading.authors) ? reading.authors.map(a => object(a) && typeof a.name === 'string' ? a.name : fail()) : [];
115
+ const relations = object(meta.relations) ? meta.relations : {}, synth = Array.isArray(relations.synthesizes) ? strings(relations.synthesizes) : [];
116
+ return { slug: typeof reading.slug === 'string' ? reading.slug : fail(), title: typeof meta.title === 'string' ? meta.title : '', status: typeof reading.status === 'string' ? reading.status : '', sourceUrl: typeof reading.source_url === 'string' ? reading.source_url : '', authors, tags: meta.tags === undefined ? [] : strings(meta.tags), ...(synth[0] ? { capture: synth[0] } : {}), gist, ideas, quotes };
117
+ }
118
+ export function parseJungleEditorial(markdown) {
119
+ if (typeof markdown !== 'string' || markdown.length > 4 * 1024 * 1024)
120
+ fail();
121
+ const { head, body } = frontmatter(markdown), meta = parseYaml(head);
122
+ if (!object(meta) || !object(meta.reading))
123
+ throw new JungleDigestError('not-digest');
124
+ const reading = meta.reading, relations = object(meta.relations) ? meta.relations : {};
125
+ const form = reading.form, slug = reading.slug;
126
+ if (typeof form !== 'string' || !form || typeof slug !== 'string' || !/^[a-z0-9][a-z0-9-]{0,199}$/.test(slug))
127
+ return fail();
128
+ const capture = [relations.synthesizes, relations['evidenced-by']].flatMap(v => Array.isArray(v) ? strings(v) : [])[0] ?? fail('no-capture');
129
+ // A digest must parse strictly; the other forms keep whatever structure they have, and the Markdown keeps the rest.
130
+ const sections = sectionsOf(body), loose = (f) => { if (form === 'digest')
131
+ return f(); try {
132
+ return f();
133
+ }
134
+ catch {
135
+ return undefined;
136
+ } };
137
+ const gistText = sections.get('Gist') ?? sections.get('Summary') ?? (typeof reading.summary === 'string' ? reading.summary : undefined), ideaText = sections.get('Ideas') ?? sections.get('Debate'), quoteText = sections.get('Quotes');
138
+ if (form === 'digest')
139
+ parseJungleNote(markdown);
140
+ const gist = gistText?.replace(/\s+/g, ' ').trim(), ideas = ideaText === undefined ? undefined : loose(() => ideaList(ideaText)), quotes = quoteText === undefined ? undefined : loose(() => quoteList(quoteText));
141
+ return { schemaVersion: 1, source: 'jungle-reading', form, slug, status: typeof reading.status === 'string' ? reading.status : '', title: typeof meta.title === 'string' ? meta.title : '', sourceUrl: typeof reading.source_url === 'string' ? reading.source_url : '', capture, frontmatter: meta, ...(gist ? { gist } : {}), ...(ideas?.length ? { ideas } : {}), ...(quotes?.length && quotes.every(q => q.text && q.speaker) ? { quotes } : {}), markdownSha256: createHash('sha256').update(markdown).digest('hex'), markdown };
142
+ }
143
+ const stop = new Set('the a an and or but of to in on for with by from at as is are was were be been it its this that these those not no than then so such into about over under more most less can could would should will just also only very their there they them we our you your he she his her i my me what which who how when where why do does did has have had'.split(' '));
144
+ /** Content-word stems: lowercase, stopwords out, a crude plural/tense strip. Shared by idea assignment and lexical coverage. */
145
+ export function contentTerms(text) {
146
+ return new Set(foldText(text).toLowerCase().split(/[^a-z0-9]+/).filter(w => w.length > 2 && !stop.has(w)).map(w => w.replace(/(?:ing|ed|es|s)$/, '')).filter(w => w.length > 2));
147
+ }
148
+ export function termSimilarity(a, b) { if (!a.size || !b.size)
149
+ return 0; let n = 0; for (const t of a)
150
+ if (b.has(t))
151
+ n++; return n / Math.sqrt(a.size * b.size); }
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; });
160
+ }
161
+ export const firstSentence = (text) => { const m = /^(.+?[.!?]["”’)]*)\s+(?=["“(]?[A-Z0-9])/.exec(text); return m ? [m[1], text.slice(m[0].length)] : [text, '']; };
162
+ export function formOf(note) {
163
+ if (note.tags.includes('video'))
164
+ return 'video';
165
+ if (note.tags.includes('x'))
166
+ return 'thread';
167
+ return /\.pdf(?:$|[?#])|arxiv\.org\/pdf\//i.test(note.sourceUrl) ? 'pdf' : 'article';
168
+ }
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
+ */
174
+ export function convertJungleDigest(noteMarkdown, capture) {
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);
185
+ }
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 } : {}) };
196
+ }
@@ -0,0 +1,45 @@
1
+ import type { DatabaseSync } from 'node:sqlite';
2
+ import { type DigestV2 } from '../digest/schema.js';
3
+ import { type VisibilityState } from '../identity/visibility.js';
4
+ import { type JungleEditorial } from './jungle-digest.js';
5
+ /**
6
+ * The library side of `sponge import jungle`: one Jungle Reading note onto the item holding its capture.
7
+ * `planJungleNote` only reads; `applyJungleNote` runs the same plan inside the caller's transaction, through the logged paths.
8
+ */
9
+ export declare const jungleNamespace = "jungle", jungleRealm = "import:jungle", jungleProducer: {
10
+ readonly importer: "sponge-jungle-reading";
11
+ readonly version: "2";
12
+ };
13
+ export type EditorialAction = 'new' | 'unchanged' | 'user-review-wins';
14
+ export type DigestAction = 'new' | 'unchanged' | 'not-convertible' | 'not-digest' | 'user-review-wins' | 'user-digest-wins';
15
+ export type VisibilityAction = 'set-public' | 'set-private' | 'restore-default' | 'unchanged' | 'kept-user-edit' | 'guarded' | 'default-policy';
16
+ export interface JungleNoteInput {
17
+ itemId: string;
18
+ revisionId: string;
19
+ editorial: JungleEditorial;
20
+ digest?: DigestV2;
21
+ }
22
+ export interface JunglePlan {
23
+ editorialId: string;
24
+ editorial: EditorialAction;
25
+ digestId?: string;
26
+ digest: DigestAction;
27
+ visibility: VisibilityAction;
28
+ }
29
+ /** What Jungle's `reading.status` asks for: `published` is Jungle's explicit public approval, `draft` stays private. */
30
+ export declare function desiredVisibility(status: string): 'public' | 'private' | undefined;
31
+ /**
32
+ * Jungle's decision becomes an explicit override only while nobody else has set one: once the latest override was not
33
+ * written by this importer, the user's choice wins. A public decision never lifts the capability-URL or local-file guards.
34
+ */
35
+ export declare function visibilityAction(state: Pick<VisibilityState, 'access' | 'default' | 'override'>, status: string, userOverride: boolean): {
36
+ action: VisibilityAction;
37
+ target?: 'public' | 'private' | null;
38
+ };
39
+ export declare function planJungleNote(db: DatabaseSync, input: JungleNoteInput): JunglePlan;
40
+ /**
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 converted Digest v2 is stored and accepted per field, linked to the
43
+ * editorial object by its input hash, unless the user owns a digest of this item or changed an imported digest's review.
44
+ */
45
+ export declare function applyJungleNote(db: DatabaseSync, input: JungleNoteInput): JunglePlan;
@@ -0,0 +1,96 @@
1
+ import { canonicalDigest, derivationId, putDigest, reviewDigest } from '../digest/store.js';
2
+ import { canonicalEditorial, putEditorial, reviewEditorial } from '../digest/editorial.js';
3
+ import { parseDigest } from '../digest/schema.js';
4
+ import { visibility as visibilityState, setVisibility } from '../identity/visibility.js';
5
+ import { digest } from '../store/files.js';
6
+ import { jungleTags } from './jungle-digest.js';
7
+ /**
8
+ * The library side of `sponge import jungle`: one Jungle Reading note onto the item holding its capture.
9
+ * `planJungleNote` only reads; `applyJungleNote` runs the same plan inside the caller's transaction, through the logged paths.
10
+ */
11
+ export const jungleNamespace = 'jungle', jungleRealm = 'import:jungle', jungleProducer = { importer: 'sponge-jungle-reading', version: '2' };
12
+ /** Receipts of the importer's own visibility writes (local, like every import receipt), so a later user override is recognisable. */
13
+ const visibilityReceipts = 'jungle-visibility';
14
+ /** What Jungle's `reading.status` asks for: `published` is Jungle's explicit public approval, `draft` stays private. */
15
+ export function desiredVisibility(status) { return status === 'published' ? 'public' : status === 'draft' ? 'private' : undefined; }
16
+ /**
17
+ * Jungle's decision becomes an explicit override only while nobody else has set one: once the latest override was not
18
+ * written by this importer, the user's choice wins. A public decision never lifts the capability-URL or local-file guards.
19
+ */
20
+ export function visibilityAction(state, status, userOverride) {
21
+ const desired = desiredVisibility(status);
22
+ if (!desired)
23
+ return { action: 'default-policy' };
24
+ if (userOverride)
25
+ return { action: 'kept-user-edit' };
26
+ if (desired === 'public' && state.access !== 'imported' && state.access !== 'public')
27
+ return { action: 'guarded' };
28
+ const target = desired === state.default ? null : desired;
29
+ if ((state.override ?? null) === target)
30
+ return { action: 'unchanged' };
31
+ return { action: target === null ? 'restore-default' : target === 'public' ? 'set-public' : 'set-private', target };
32
+ }
33
+ const allAccepted = (o) => ({ gist: 'accepted', ideas: o.ideas.map(() => 'accepted'), quotes: o.ideas.map(i => i.quotes.map(() => 'accepted')) });
34
+ const imported = (r) => { try {
35
+ return JSON.parse(String(r.producer_json))?.importer === jungleProducer.importer;
36
+ }
37
+ catch {
38
+ return false;
39
+ } };
40
+ const latestVisibility = (db, itemId) => db.prepare("SELECT id FROM changes WHERE entity='item' AND entity_id=? AND field='visibility' ORDER BY seq DESC LIMIT 1").get(itemId);
41
+ /** The latest logged visibility write was not this importer's: the user chose, even when the choice was `default`. */
42
+ function userOverride(db, itemId) {
43
+ const latest = latestVisibility(db, itemId);
44
+ if (!latest)
45
+ return (db.prepare('SELECT visibility FROM items WHERE id=?').get(itemId)?.visibility ?? null) !== null;
46
+ return !db.prepare('SELECT 1 FROM import_receipts WHERE namespace=? AND source_key=? AND source_hash=?').get(visibilityReceipts, itemId, digest(String(latest.id)));
47
+ }
48
+ export function planJungleNote(db, input) {
49
+ const { itemId, revisionId, editorial } = input, editorialId = derivationId(revisionId, 'editorial', editorial.markdownSha256, canonicalEditorial(editorial));
50
+ const editorials = db.prepare("SELECT id,review_json FROM derivations WHERE item_id=? AND kind='editorial'").all(itemId);
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
+ // 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';
63
+ else if (digests.some(r => !imported(r)))
64
+ action = 'user-digest-wins';
65
+ else if (digests.some(r => JSON.stringify(JSON.parse(String(r.review_json))) !== JSON.stringify(allAccepted(JSON.parse(String(r.output_json))))))
66
+ action = 'user-review-wins';
67
+ else
68
+ action = editorialAction === 'user-review-wins' ? 'user-review-wins' : 'new';
69
+ }
70
+ const visibility = visibilityAction(visibilityState(db, itemId), editorial.status, userOverride(db, itemId)).action;
71
+ return { editorialId, editorial: editorialAction, ...(digestId ? { digestId } : {}), digest: action, visibility };
72
+ }
73
+ /**
74
+ * Apply one note; the caller holds the write transaction. A new editorial object is accepted through `review.note` unless
75
+ * the user already rejected or cleared an earlier one; a converted Digest v2 is stored and accepted per field, linked to the
76
+ * editorial object by its input hash, unless the user owns a digest of this item or changed an imported digest's review.
77
+ */
78
+ export function applyJungleNote(db, input) {
79
+ const plan = planJungleNote(db, input), producer = { ...jungleProducer, note: input.editorial.slug };
80
+ if (plan.editorial !== 'unchanged') {
81
+ putEditorial(db, { itemId: input.itemId, revisionId: input.revisionId, producer, output: input.editorial });
82
+ if (plan.editorial === 'new')
83
+ reviewEditorial(db, plan.editorialId, 'accepted');
84
+ }
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));
88
+ }
89
+ const action = visibilityAction(visibilityState(db, input.itemId), input.editorial.status, userOverride(db, input.itemId));
90
+ if (action.target !== undefined) {
91
+ setVisibility(db, input.itemId, action.target);
92
+ const latest = latestVisibility(db, input.itemId);
93
+ db.prepare('INSERT OR IGNORE INTO import_receipts VALUES(?,?,?,?,?,?)').run(visibilityReceipts, input.itemId, digest(String(latest.id)), jungleProducer.version, input.itemId, 'visibility');
94
+ }
95
+ return plan;
96
+ }