@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,126 @@
1
+ import { type TextQuote } from '../annotations/quote.js';
2
+ /**
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.
7
+ */
8
+ export declare const ideaKinds: readonly ["finding", "argument", "method", "evidence", "recommendation", "counterpoint"];
9
+ export declare const forms: readonly ["article", "video", "paper", "thread", "pdf"];
10
+ export type Voice = {
11
+ type: 'author';
12
+ } | {
13
+ type: 'speaker' | 'reported';
14
+ name: string;
15
+ };
16
+ export type QuoteLocator = {
17
+ type: 'timestamp';
18
+ ms: number;
19
+ } | {
20
+ type: 'page';
21
+ page: number;
22
+ };
23
+ /** `attribution` is v2 only: who said it and where, as the reviewer wrote it. */
24
+ export interface DigestQuote {
25
+ text: string;
26
+ speaker?: string;
27
+ attribution?: string;
28
+ locator?: QuoteLocator;
29
+ }
30
+ export interface DigestIdea {
31
+ claim: string;
32
+ detail: string;
33
+ kind?: typeof ideaKinds[number];
34
+ voice?: Voice;
35
+ quotes: DigestQuote[];
36
+ }
37
+ export interface DigestIdeaV1 extends DigestIdea {
38
+ kind: typeof ideaKinds[number];
39
+ voice: Voice;
40
+ }
41
+ export interface DigestV1 {
42
+ schemaVersion: 1;
43
+ form: typeof forms[number];
44
+ gist: {
45
+ lead: string;
46
+ context?: string;
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
+ };
58
+ ideas: DigestIdea[];
59
+ tags: string[];
60
+ }
61
+ export type Digest = DigestV1 | DigestV2;
62
+ export declare const digestSchemaVersion = 2;
63
+ export declare const digestLimitsV1: {
64
+ readonly leadChars: 155;
65
+ readonly contextWords: 50;
66
+ readonly minIdeas: 3;
67
+ readonly maxIdeas: 5;
68
+ readonly claimWords: 20;
69
+ readonly detailWords: 60;
70
+ readonly detailSentences: 3;
71
+ readonly maxQuotesPerIdea: 2;
72
+ readonly minQuoteWords: 4;
73
+ readonly quoteWords: 25;
74
+ readonly totalQuoteWords: 150;
75
+ readonly sourceQuoteShare: 0.1;
76
+ readonly maxTags: 3;
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
+ };
99
+ export interface DigestIssue {
100
+ path: string;
101
+ code: string;
102
+ }
103
+ export interface LocatedQuote {
104
+ idea: number;
105
+ quote: number;
106
+ anchor: TextQuote;
107
+ }
108
+ export interface DigestCheck {
109
+ ok: boolean;
110
+ issues: DigestIssue[];
111
+ quotes: LocatedQuote[];
112
+ }
113
+ export interface DigestOptions {
114
+ /** Controlled tag vocabulary; omitted means any lowercase kebab-case tags (1-3 in v1, 0-3 in v2). */
115
+ tags?: readonly string[];
116
+ /** Optional semantic check that a quote supports its claim, e.g. embedding cosine. */
117
+ supports?: (claim: string, quote: string) => number;
118
+ supportThreshold?: number;
119
+ }
120
+ /** Parse an untrusted digest (v1 or v2) into its shape, or report why not. */
121
+ export declare function parseDigest(value: unknown): {
122
+ digest?: Digest;
123
+ issues: DigestIssue[];
124
+ };
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;
@@ -0,0 +1,138 @@
1
+ import { foldText, locateAll, textQuote, words } from '../annotations/quote.js';
2
+ /**
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.
7
+ */
8
+ export const ideaKinds = ['finding', 'argument', 'method', 'evidence', 'recommendation', 'counterpoint'];
9
+ export const forms = ['article', 'video', 'paper', 'thread', 'pdf'];
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 };
14
+ const object = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
15
+ const keys = (v, allowed) => Object.keys(v).every(k => allowed.includes(k));
16
+ const str = (v, max = 2000) => typeof v === 'string' && v.trim().length > 0 && v.length <= max && !/[\u0000-\u0008\u000b-\u001f\u007f]/.test(v);
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. */
22
+ export function parseDigest(value) {
23
+ const issues = [], bad = (path, code = 'shape') => { issues.push({ path, code }); };
24
+ if (!object(value) || !keys(value, ['schemaVersion', 'form', 'gist', 'ideas', 'tags']) || value.schemaVersion !== 1 && value.schemaVersion !== 2)
25
+ return { issues: [{ path: '', code: 'shape' }] };
26
+ const v2 = value.schemaVersion === 2;
27
+ if (!forms.includes(value.form))
28
+ bad('form');
29
+ const gist = value.gist;
30
+ if (!object(gist) || !keys(gist, ['lead', 'context']) || !str(gist.lead) || gist.context !== undefined && !str(gist.context))
31
+ bad('gist');
32
+ if (!Array.isArray(value.ideas) || value.ideas.length > 8)
33
+ bad('ideas');
34
+ else
35
+ value.ideas.forEach((idea, i) => {
36
+ const at = `ideas.${i}`;
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)))
38
+ return bad(at);
39
+ const voice = idea.voice;
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))))
41
+ bad(at + '.voice');
42
+ if (!Array.isArray(idea.quotes) || idea.quotes.length > 4)
43
+ return bad(at + '.quotes');
44
+ idea.quotes.forEach((quote, j) => {
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))
46
+ return bad(`${at}.quotes.${j}`);
47
+ const loc = quote.locator;
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)))
49
+ bad(`${at}.quotes.${j}.locator`);
50
+ });
51
+ });
52
+ if (!Array.isArray(value.tags) || value.tags.some(t => !str(t, 64)))
53
+ bad('tags');
54
+ return issues.length ? { issues } : { digest: value, issues };
55
+ }
56
+ /** Check every rule of the digest's own version that code can decide against the captured Markdown. */
57
+ export function checkDigest(digest, source, options = {}) {
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)
61
+ issue('gist.lead', 'too-long');
62
+ if (count(lead) !== 1)
63
+ issue('gist.lead', 'one-sentence');
64
+ if (context !== undefined && words(context) > L.contextWords)
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');
70
+ if (digest.ideas.length < L.minIdeas || digest.ideas.length > L.maxIdeas)
71
+ issue('ideas', 'count');
72
+ const claims = new Set();
73
+ let quoteWords = 0, quoteCount = 0;
74
+ digest.ideas.forEach((idea, i) => {
75
+ const at = `ideas.${i}`;
76
+ if (words(idea.claim) > L.claimWords)
77
+ issue(at + '.claim', 'too-long');
78
+ if (count(idea.claim) !== 1)
79
+ issue(at + '.claim', 'one-sentence');
80
+ if (claims.has(foldText(idea.claim).toLowerCase()))
81
+ issue(at + '.claim', 'duplicate');
82
+ claims.add(foldText(idea.claim).toLowerCase());
83
+ if (words(idea.detail) > L.detailWords)
84
+ issue(at + '.detail', 'too-long');
85
+ if (count(idea.detail) > L.detailSentences)
86
+ issue(at + '.detail', 'too-many-sentences');
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)
89
+ issue(at + '.quotes', 'count');
90
+ idea.quotes.forEach((quote, j) => {
91
+ const path = `${at}.quotes.${j}`, n = words(quote.text);
92
+ quoteWords += n;
93
+ quoteCount++;
94
+ if (n > L.quoteWords)
95
+ issue(path, 'too-long');
96
+ if (n < L.minQuoteWords)
97
+ issue(path, 'too-short');
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)
101
+ issue(path, 'speaker-required');
102
+ const matches = locateAll(source, quote.text);
103
+ if (!matches.length)
104
+ return issue(path, 'not-verbatim');
105
+ // Prefer the first occurrence not already claimed by an earlier quote.
106
+ const match = matches.find(m => !located.some(q => m.start < q.anchor.end && q.anchor.start < m.end));
107
+ if (!match)
108
+ return issue(path, 'overlap');
109
+ located.push({ idea: i, quote: j, anchor: textQuote(source, match.start, match.end) });
110
+ if (options.supports && options.supports(idea.claim, quote.text) < (options.supportThreshold ?? 0.5))
111
+ issue(path, 'unsupported');
112
+ });
113
+ });
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.
117
+ let previous = -1;
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
+ });
128
+ if (quoteWords > L.totalQuoteWords)
129
+ issue('ideas', 'quote-budget');
130
+ // The 10% share has a 25-word floor: an exemption so a short source can still carry one full quote.
131
+ if (quoteWords > Math.max(L.quoteWords, Math.floor(words(source) * L.sourceQuoteShare)))
132
+ issue('ideas', 'quote-share');
133
+ if (digest.tags.length < (v2 ? 0 : 1) || digest.tags.length > L.maxTags || new Set(digest.tags).size !== digest.tags.length)
134
+ issue('tags', 'count');
135
+ digest.tags.forEach((tag, i) => { if (options.tags ? !options.tags.includes(tag) : !/^[a-z0-9][a-z0-9-]{0,63}$/.test(tag))
136
+ issue(`tags.${i}`, 'vocabulary'); });
137
+ return { ok: issues.length === 0, issues, quotes: located };
138
+ }
@@ -0,0 +1,57 @@
1
+ import type { DatabaseSync } from 'node:sqlite';
2
+ import { type Digest, type DigestOptions } from './schema.js';
3
+ import { type ReviewStatus } from '../store/changes.js';
4
+ export type { ReviewStatus };
5
+ /** Per-field review of a digest. Only accepted fields can ever be published. */
6
+ export interface DigestReview {
7
+ gist?: ReviewStatus;
8
+ ideas?: (ReviewStatus | null)[];
9
+ quotes?: (ReviewStatus | null)[][];
10
+ }
11
+ export interface Derivation {
12
+ id: string;
13
+ itemId: string;
14
+ revisionId: string;
15
+ kind: 'digest';
16
+ producer: unknown;
17
+ inputHash: string;
18
+ output: Digest;
19
+ review: DigestReview;
20
+ createdAt: string;
21
+ }
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
+ /** Content address of a derivation: the same output from the same input is the same immutable object on every device. */
25
+ export declare const derivationId: (revisionId: string, kind: string, inputHash: string, output: string) => string;
26
+ /**
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
+ */
30
+ export declare function putDigest(db: DatabaseSync, input: {
31
+ itemId: string;
32
+ revisionId: string;
33
+ producer: Record<string, unknown>;
34
+ inputHash: string;
35
+ output: unknown;
36
+ options?: DigestOptions;
37
+ }): {
38
+ id: string;
39
+ duplicate: boolean;
40
+ };
41
+ /** The newest digest made from the item's current revision. */
42
+ export declare function currentDigest(db: DatabaseSync, itemId: string): Derivation | undefined;
43
+ export declare function readDigest(db: DatabaseSync, id: string): Derivation | undefined;
44
+ /** The newest stored digest for exactly these inputs, so a rerun reuses it instead of asking the model again. */
45
+ export declare function findDigest(db: DatabaseSync, revisionId: string, inputHash: string): Derivation | undefined;
46
+ /** Review fields of a digest. Each changed path is its own change: `review.gist`, `review.ideas.<i>`, `review.quotes.<i>.<j>`. */
47
+ export declare function reviewDigest(db: DatabaseSync, id: string, review: DigestReview): Derivation;
48
+ /** The accepted subset of a digest (v1 or v2). Rejected or unreviewed fields never leave the device; v1 ideas keep their kind. */
49
+ export declare function acceptedDigest(d: Derivation): {
50
+ lead?: string;
51
+ ideas: {
52
+ claim: string;
53
+ detail: string;
54
+ kind?: string;
55
+ quotes: string[];
56
+ }[];
57
+ } | undefined;
@@ -0,0 +1,79 @@
1
+ import { checkDigest, digestSchemaVersion, parseDigest } from './schema.js';
2
+ import { markdownFor } from '../annotations/store.js';
3
+ import { mutate } from '../store/changes.js';
4
+ import { StoreError } from '../store/types.js';
5
+ import { digest, hash } from '../store/files.js';
6
+ const now = () => new Date().toISOString();
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. Optional fields appear only when present. */
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, ...(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
+ }
12
+ /** Content address of a derivation: the same output from the same input is the same immutable object on every device. */
13
+ export const derivationId = (revisionId, kind, inputHash, output) => digest(JSON.stringify(['derivation-v1', revisionId, kind, inputHash, output]));
14
+ /**
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
+ */
18
+ export function putDigest(db, input) {
19
+ hash(input.inputHash);
20
+ const producer = JSON.stringify(input.producer);
21
+ if (!producer || Buffer.byteLength(producer) > 4096)
22
+ fail();
23
+ const revision = db.prepare('SELECT item_id FROM revisions WHERE id=?').get(input.revisionId);
24
+ if (revision?.item_id !== input.itemId)
25
+ fail('unavailable');
26
+ const parsed = parseDigest(input.output);
27
+ if (!parsed.digest)
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' }] });
31
+ const check = checkDigest(parsed.digest, markdownFor(db, input.revisionId), input.options);
32
+ if (!check.ok)
33
+ throw Object.assign(new StoreError('invalid-input'), { issues: check.issues });
34
+ const output = canonicalDigest(parsed.digest);
35
+ if (Buffer.byteLength(output) > 65536)
36
+ fail();
37
+ const id = derivationId(input.revisionId, 'digest', input.inputHash, output);
38
+ if (db.prepare('SELECT 1 FROM derivations WHERE id=?').get(id))
39
+ return { id, duplicate: true };
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());
41
+ return { id, duplicate: false };
42
+ }
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) }; }
44
+ /** The newest digest made from the item's current revision. */
45
+ export function currentDigest(db, itemId) {
46
+ const r = db.prepare("SELECT d.* FROM derivations d JOIN items i ON i.id=d.item_id AND i.current_revision=d.revision_id WHERE d.item_id=? AND d.kind='digest' ORDER BY d.created_at DESC,d.rowid DESC LIMIT 1").get(itemId);
47
+ return r ? derivationRow(r) : undefined;
48
+ }
49
+ export function readDigest(db, id) { const r = db.prepare("SELECT * FROM derivations WHERE id=? AND kind='digest'").get(id); return r ? derivationRow(r) : undefined; }
50
+ /** The newest stored digest for exactly these inputs, so a rerun reuses it instead of asking the model again. */
51
+ export function findDigest(db, revisionId, inputHash) { hash(inputHash); const r = db.prepare("SELECT * FROM derivations WHERE revision_id=? AND kind='digest' AND input_hash=? ORDER BY created_at DESC,rowid DESC LIMIT 1").get(revisionId, inputHash); return r ? derivationRow(r) : undefined; }
52
+ /** Review fields of a digest. Each changed path is its own change: `review.gist`, `review.ideas.<i>`, `review.quotes.<i>.<j>`. */
53
+ export function reviewDigest(db, id, review) {
54
+ const row = db.prepare("SELECT * FROM derivations WHERE id=? AND kind='digest'").get(id) ?? fail('unavailable'), d = derivationRow(row);
55
+ const status = (v) => v === null || v === 'accepted' || v === 'rejected';
56
+ if (typeof review !== 'object' || review === null || Object.keys(review).some(k => !['gist', 'ideas', 'quotes'].includes(k)))
57
+ fail();
58
+ if (review.gist !== undefined && review.gist !== 'accepted' && review.gist !== 'rejected')
59
+ fail();
60
+ if (review.ideas !== undefined && (!Array.isArray(review.ideas) || review.ideas.length !== d.output.ideas.length || !review.ideas.every(status)))
61
+ fail();
62
+ if (review.quotes !== undefined && (!Array.isArray(review.quotes) || review.quotes.length !== d.output.ideas.length || review.quotes.some((q, i) => !Array.isArray(q) || q.length !== d.output.ideas[i].quotes.length || !q.every(status))))
63
+ fail();
64
+ const paths = [];
65
+ if (review.gist !== undefined)
66
+ paths.push(['review.gist', review.gist, d.review.gist ?? null]);
67
+ review.ideas?.forEach((v, i) => paths.push([`review.ideas.${i}`, v, d.review.ideas?.[i] ?? null]));
68
+ review.quotes?.forEach((q, i) => q.forEach((v, j) => paths.push([`review.quotes.${i}.${j}`, v, d.review.quotes?.[i]?.[j] ?? null])));
69
+ for (const [path, next, prior] of paths)
70
+ if (next !== prior)
71
+ mutate(db, 'derivation', id, path, next);
72
+ return derivationRow(db.prepare('SELECT * FROM derivations WHERE id=?').get(id));
73
+ }
74
+ /** The accepted subset of a digest (v1 or v2). Rejected or unreviewed fields never leave the device; v1 ideas keep their kind. */
75
+ export function acceptedDigest(d) {
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) }] : []);
77
+ const lead = d.review.gist === 'accepted' ? d.output.gist.lead : undefined;
78
+ return lead || ideas.length ? { ...(lead ? { lead } : {}), ideas } : undefined;
79
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The one query-field policy shared by source keys (src/app/source.ts) and shareable URIs (uri.ts).
3
+ * Source keys drop only `campaignFields`; changing that list changes every existing source key.
4
+ * Shareable URIs also drop `clickTrackers` and every credential-like field, so a published URI
5
+ * never carries a capability. See docs/decisions/sync-and-publishing.md.
6
+ */
7
+ export declare const campaignFields: ReadonlySet<string>;
8
+ export declare const clickTrackers: ReadonlySet<string>;
9
+ /** Credential-like query keys, compared case-insensitively. Presence makes a capture private by default. */
10
+ export declare const secretFields: ReadonlySet<string>;
11
+ export declare const secretPrefixes: readonly string[];
12
+ /** The decoded, lowercased key of one raw `a=b` field, or undefined when it does not decode. */
13
+ export declare function fieldKey(field: string): string | undefined;
14
+ export declare function isSecretField(key: string): boolean;
15
+ /** Filter raw fields: URLSearchParams mutation rewrites unknown signed query bytes. */
16
+ export declare function filterQuery(search: string, drop: (key: string) => boolean): string;
17
+ /**
18
+ * A link that grants access by itself: userinfo, a credential-like query key, or a known
19
+ * anyone-with-the-link path. Deliberately short; documented in the decision record.
20
+ */
21
+ export declare function isCapabilityUrl(locator: string): boolean;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The one query-field policy shared by source keys (src/app/source.ts) and shareable URIs (uri.ts).
3
+ * Source keys drop only `campaignFields`; changing that list changes every existing source key.
4
+ * Shareable URIs also drop `clickTrackers` and every credential-like field, so a published URI
5
+ * never carries a capability. See docs/decisions/sync-and-publishing.md.
6
+ */
7
+ export const campaignFields = new Set(['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'utm_id']);
8
+ export const clickTrackers = new Set(['fbclid', 'gclid', 'dclid', 'msclkid', 'mc_cid', 'mc_eid', 'igshid']);
9
+ /** Credential-like query keys, compared case-insensitively. Presence makes a capture private by default. */
10
+ export const secretFields = new Set(['token', 'key', 'sig', 'signature', 'auth', 'access_token', 'code', 'rlkey', 'session', 'password', 'secret', 'api_key', 'apikey', 'auth_token', 'id_token', 'refresh_token', 'client_secret']);
11
+ export const secretPrefixes = ['x-amz-', 'x-goog-'];
12
+ /** The decoded, lowercased key of one raw `a=b` field, or undefined when it does not decode. */
13
+ export function fieldKey(field) {
14
+ try {
15
+ return decodeURIComponent(field.split('=', 1)[0].replaceAll('+', ' ')).toLowerCase();
16
+ }
17
+ catch {
18
+ return undefined;
19
+ }
20
+ }
21
+ export function isSecretField(key) { return secretFields.has(key) || secretPrefixes.some(p => key.startsWith(p)); }
22
+ /** Filter raw fields: URLSearchParams mutation rewrites unknown signed query bytes. */
23
+ export function filterQuery(search, drop) {
24
+ if (!search)
25
+ return '';
26
+ const fields = search.slice(1).split('&').filter(field => { const key = fieldKey(field); return key === undefined || !drop(key); });
27
+ return fields.length ? '?' + fields.join('&') : '';
28
+ }
29
+ /**
30
+ * A link that grants access by itself: userinfo, a credential-like query key, or a known
31
+ * anyone-with-the-link path. Deliberately short; documented in the decision record.
32
+ */
33
+ export function isCapabilityUrl(locator) {
34
+ let url;
35
+ try {
36
+ url = new URL(locator);
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ if (!['http:', 'https:'].includes(url.protocol))
42
+ return false;
43
+ if (url.username || url.password)
44
+ return true;
45
+ if (url.search && url.search.slice(1).split('&').some(field => { const key = fieldKey(field); return key !== undefined && isSecretField(key); }))
46
+ return true;
47
+ const host = url.hostname.replace(/^www\./, ''), path = url.pathname;
48
+ // Google Docs, Sheets, Slides, Forms, Drawings and Drive files shared "anyone with the link".
49
+ if ((host === 'docs.google.com' && /^\/(document|spreadsheets|presentation|forms|drawings)\/d\/[^/]+/.test(path) && !/^\/[^/]+\/d\/e\//.test(path) || host === 'drive.google.com' && /^\/(file\/d|drive\/folders)\/[^/]+/.test(path)) && url.searchParams.get('usp') === 'sharing')
50
+ return true;
51
+ // Dropbox shared links are capabilities with or without rlkey.
52
+ if ((host === 'dropbox.com' || host === 'dl.dropboxusercontent.com') && /^\/(s|sh|scl)\//.test(path))
53
+ return true;
54
+ return false;
55
+ }
@@ -0,0 +1,30 @@
1
+ /** One shareable identifier. Resolvable URIs may be published; fingerprints and local ids never are by default. */
2
+ export interface ItemUri {
3
+ uri: string;
4
+ role: 'primary' | 'alias';
5
+ resolvable: boolean;
6
+ origin: 'locator' | 'revision';
7
+ }
8
+ /** `capability`: a public-realm or imported link that grants access by itself (see query-policy.ts). */
9
+ export type AccessClass = 'public' | 'account' | 'local' | 'imported' | 'capability';
10
+ export declare const maxUriBytes = 8192;
11
+ /** Same-work identity drops fragments, click trackers and credential-like fields that the source key keeps for capture fidelity. */
12
+ export declare function shareableWebUri(locator: string): string;
13
+ /** Provider identifiers name the same work across URL spellings, hosts and mirrors. */
14
+ export declare function providerUris(href: string): string[];
15
+ export declare function isResolvable(uri: string): boolean;
16
+ /**
17
+ * Derive an item's URIs from its first source and, once captured, its source PDF. Total: a
18
+ * locator that cannot be parsed, carries userinfo or is too long yields only `sponge:item/<id>`.
19
+ * A local file never exposes its path: before capture it is `sponge:item/<id>`, afterwards
20
+ * `urn:sha256:<file hash>`, which matches the same file anywhere but is not resolvable.
21
+ */
22
+ export declare function deriveUris(input: {
23
+ itemId: string;
24
+ locator: string;
25
+ sourcePdfSha256?: string;
26
+ }): ItemUri[];
27
+ /** How the first source was reached decides the default visibility; see docs/decisions/sync-and-publishing.md. */
28
+ export declare function accessClass(realm: string, locator: string): AccessClass;
29
+ /** Public only when the source was public. Imports keep their source's publication decision. */
30
+ export declare function defaultVisibility(access: AccessClass, importedPrivacy?: string): 'public' | 'private';
@@ -0,0 +1,94 @@
1
+ import { normalizeWebLocator } from '../app/source.js';
2
+ import { clickTrackers, filterQuery, isCapabilityUrl, isSecretField } from './query-policy.js';
3
+ export const maxUriBytes = 8192;
4
+ /** Same-work identity drops fragments, click trackers and credential-like fields that the source key keeps for capture fidelity. */
5
+ export function shareableWebUri(locator) {
6
+ const url = new URL(normalizeWebLocator(locator));
7
+ url.hash = '';
8
+ if (url.search)
9
+ url.search = filterQuery(url.search, key => clickTrackers.has(key) || isSecretField(key));
10
+ return url.href;
11
+ }
12
+ const youtubeId = /^[A-Za-z0-9_-]{11}$/;
13
+ const decoded = (value) => { try {
14
+ return decodeURIComponent(value);
15
+ }
16
+ catch {
17
+ return undefined;
18
+ } };
19
+ /** Provider identifiers name the same work across URL spellings, hosts and mirrors. */
20
+ export function providerUris(href) {
21
+ const url = new URL(href), host = url.hostname.replace(/^(www|m|mobile)\./, ''), parts = url.pathname.split('/').filter(Boolean), out = [];
22
+ if (host === 'youtube.com' || host === 'music.youtube.com') {
23
+ const id = url.pathname === '/watch' ? url.searchParams.get('v') : ['shorts', 'live', 'embed'].includes(parts[0]) ? parts[1] : undefined;
24
+ if (id && youtubeId.test(id))
25
+ out.push('youtube:' + id);
26
+ }
27
+ if (host === 'youtu.be' && youtubeId.test(parts[0] ?? ''))
28
+ out.push('youtube:' + parts[0]);
29
+ if ((host === 'x.com' || host === 'twitter.com') && parts[1] === 'status' && /^\d{1,25}$/.test(parts[2] ?? ''))
30
+ out.push('x:status/' + parts[2]);
31
+ if (host === 'arxiv.org' && ['abs', 'pdf'].includes(parts[0]) && parts[1]) {
32
+ const id = parts.slice(1).join('/').replace(/\.pdf$/, '').replace(/v\d+$/, '');
33
+ if (/^(\d{4}\.\d{4,5}|[a-z-]+(\.[A-Z]{2})?\/\d{7})$/.test(id))
34
+ out.push('arxiv:' + id);
35
+ }
36
+ if ((host === 'doi.org' || host === 'dx.doi.org') && /^10\.\d{4,9}$/.test(parts[0] ?? '') && parts.length > 1) {
37
+ const doi = decoded(parts.join('/'));
38
+ if (doi && !/[\u0000-\u001f\u007f]/.test(doi))
39
+ out.push('doi:' + doi.toLowerCase());
40
+ }
41
+ return out;
42
+ }
43
+ export function isResolvable(uri) { return /^(https?:|doi:|arxiv:|isbn:|youtube:|x:)/.test(uri); }
44
+ /**
45
+ * Derive an item's URIs from its first source and, once captured, its source PDF. Total: a
46
+ * locator that cannot be parsed, carries userinfo or is too long yields only `sponge:item/<id>`.
47
+ * A local file never exposes its path: before capture it is `sponge:item/<id>`, afterwards
48
+ * `urn:sha256:<file hash>`, which matches the same file anywhere but is not resolvable.
49
+ */
50
+ export function deriveUris(input) {
51
+ const out = [], add = (uri, origin) => { if (uri.length >= 3 && Buffer.byteLength(uri) <= maxUriBytes && !out.some(u => u.uri === uri))
52
+ out.push({ uri, role: out.length ? 'alias' : 'primary', resolvable: isResolvable(uri), origin }); };
53
+ if (/^https?:/i.test(input.locator)) {
54
+ let web;
55
+ try {
56
+ web = shareableWebUri(input.locator);
57
+ }
58
+ catch {
59
+ web = undefined;
60
+ }
61
+ if (web && Buffer.byteLength(web) <= maxUriBytes) {
62
+ add(web, 'locator');
63
+ try {
64
+ for (const uri of providerUris(web))
65
+ add(uri, 'locator');
66
+ }
67
+ catch { /* a provider alias is optional */ }
68
+ }
69
+ }
70
+ if (input.sourcePdfSha256)
71
+ add('urn:sha256:' + input.sourcePdfSha256, 'revision');
72
+ if (!out.length)
73
+ add('sponge:item/' + input.itemId, 'locator');
74
+ return out;
75
+ }
76
+ /** How the first source was reached decides the default visibility; see docs/decisions/sync-and-publishing.md. */
77
+ export function accessClass(realm, locator) {
78
+ if (locator.startsWith('file:'))
79
+ return 'local';
80
+ if (realm.startsWith('connection:'))
81
+ return 'account';
82
+ // A guard on top of the source rule: a link that is itself a credential is never public by default.
83
+ if (isCapabilityUrl(locator))
84
+ return 'capability';
85
+ if (realm.startsWith('import:'))
86
+ return 'imported';
87
+ return realm === '' || realm === 'public' ? 'public' : 'account';
88
+ }
89
+ /** Public only when the source was public. Imports keep their source's publication decision. */
90
+ export function defaultVisibility(access, importedPrivacy) {
91
+ if (access === 'public')
92
+ return 'public';
93
+ return access === 'imported' && importedPrivacy === 'published' ? 'public' : 'private';
94
+ }
@@ -0,0 +1,24 @@
1
+ import type { DatabaseSync } from 'node:sqlite';
2
+ import { type AccessClass, type ItemUri } from './uri.js';
3
+ export type Visibility = 'public' | 'private';
4
+ /** Recompute derived URIs; metadata and user URIs are left alone. Idempotent. */
5
+ export declare function syncUris(db: DatabaseSync, itemId: string): void;
6
+ /**
7
+ * Fill URIs for items created before schema 5, inside migration 5. Each item is its own savepoint:
8
+ * one hostile locator leaves that item without URIs instead of making the library unopenable.
9
+ */
10
+ export declare function backfillUris(db: DatabaseSync): {
11
+ filled: number;
12
+ skipped: number;
13
+ };
14
+ export declare function itemUris(db: DatabaseSync, itemId: string): ItemUri[];
15
+ /** Items sharing any URI with `uri`: the local half of "who else is reading this". */
16
+ export declare function itemsForUri(db: DatabaseSync, uri: string): string[];
17
+ export interface VisibilityState {
18
+ access: AccessClass;
19
+ default: Visibility;
20
+ override: Visibility | null;
21
+ effective: Visibility;
22
+ }
23
+ export declare function visibility(db: DatabaseSync, itemId: string): VisibilityState;
24
+ export declare function setVisibility(db: DatabaseSync, itemId: string, value: Visibility | null): VisibilityState;