@hraness/sponge 0.2.3 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -0
- package/dist/migrations/005.d.ts +3 -0
- package/dist/migrations/005.js +19 -0
- package/dist/migrations/006.d.ts +3 -0
- package/dist/migrations/006.js +9 -0
- package/dist/migrations/007.d.ts +3 -0
- package/dist/migrations/007.js +10 -0
- package/dist/src/acquisition/lock.d.ts +1 -1
- package/dist/src/acquisition/lock.js +1 -1
- package/dist/src/acquisition/pins.js +3 -3
- package/dist/src/annotations/quote.d.ts +32 -0
- package/dist/src/annotations/quote.js +135 -0
- package/dist/src/annotations/store.d.ts +50 -0
- package/dist/src/annotations/store.js +89 -0
- package/dist/src/app/source.d.ts +2 -0
- package/dist/src/app/source.js +13 -17
- package/dist/src/cli/arguments.js +24 -7
- package/dist/src/cli/digest.d.ts +10 -0
- package/dist/src/cli/digest.js +63 -0
- package/dist/src/cli/import.d.ts +11 -0
- package/dist/src/cli/import.js +10 -0
- package/dist/src/cli/indexing.d.ts +3 -0
- package/dist/src/cli/indexing.js +5 -2
- package/dist/src/cli/main.js +94 -8
- package/dist/src/cli/runtimes.d.ts +10 -0
- package/dist/src/cli/runtimes.js +32 -0
- package/dist/src/digest/editorial.d.ts +53 -0
- package/dist/src/digest/editorial.js +81 -0
- package/dist/src/digest/endpoint.d.ts +20 -0
- package/dist/src/digest/endpoint.js +103 -0
- package/dist/src/digest/pipeline.d.ts +148 -0
- package/dist/src/digest/pipeline.js +410 -0
- package/dist/src/digest/schema.d.ts +85 -0
- package/dist/src/digest/schema.js +118 -0
- package/dist/src/digest/store.d.ts +57 -0
- package/dist/src/digest/store.js +77 -0
- package/dist/src/identity/query-policy.d.ts +21 -0
- package/dist/src/identity/query-policy.js +55 -0
- package/dist/src/identity/uri.d.ts +30 -0
- package/dist/src/identity/uri.js +94 -0
- package/dist/src/identity/visibility.d.ts +24 -0
- package/dist/src/identity/visibility.js +75 -0
- package/dist/src/importers/apply.d.ts +10 -0
- package/dist/src/importers/apply.js +25 -22
- package/dist/src/importers/jungle-digest.d.ts +97 -0
- package/dist/src/importers/jungle-digest.js +221 -0
- package/dist/src/importers/jungle-store.d.ts +45 -0
- package/dist/src/importers/jungle-store.js +95 -0
- package/dist/src/importers/jungle.d.ts +54 -0
- package/dist/src/importers/jungle.js +134 -0
- package/dist/src/indexing/index.d.ts +7 -0
- package/dist/src/indexing/index.js +31 -8
- package/dist/src/indexing/prune-run.d.ts +22 -0
- package/dist/src/indexing/prune-run.js +67 -0
- package/dist/src/indexing/prune.d.ts +30 -0
- package/dist/src/indexing/prune.js +95 -0
- package/dist/src/indexing/runner.js +5 -5
- package/dist/src/inference/embedding/index.d.ts +25 -0
- package/dist/src/inference/embedding/index.js +11 -0
- package/dist/src/models/artifacts.js +175 -33
- package/dist/src/portability/database.js +3 -0
- package/dist/src/publish/component.d.ts +8 -0
- package/dist/src/publish/component.js +114 -0
- package/dist/src/publish/feed.d.ts +24 -0
- package/dist/src/publish/feed.js +77 -0
- package/dist/src/publish/identity.d.ts +6 -0
- package/dist/src/publish/identity.js +68 -0
- package/dist/src/publish/index.d.ts +34 -0
- package/dist/src/publish/index.js +137 -0
- package/dist/src/publish/projection.d.ts +29 -0
- package/dist/src/publish/projection.js +43 -0
- package/dist/src/search/hybrid.js +2 -2
- package/dist/src/search/lexical.d.ts +11 -0
- package/dist/src/search/lexical.js +6 -0
- package/dist/src/search/query.d.ts +7 -3
- package/dist/src/search/query.js +52 -33
- package/dist/src/search/vector.d.ts +1 -1
- package/dist/src/search/vector.js +6 -5
- package/dist/src/store/changes.d.ts +42 -0
- package/dist/src/store/changes.js +178 -0
- package/dist/src/store/foundation.d.ts +7 -0
- package/dist/src/store/foundation.js +7 -0
- package/dist/src/store/index.d.ts +69 -0
- package/dist/src/store/index.js +134 -51
- package/dist/src/worker/runtime-gc.d.ts +30 -0
- package/dist/src/worker/runtime-gc.js +296 -0
- package/native/runtime-manifest.json +4 -4
- package/package.json +1 -1
|
@@ -0,0 +1,221 @@
|
|
|
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';
|
|
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.
|
|
8
|
+
* Nothing here reads files; callers pass the note and, when available, its capture Markdown.
|
|
9
|
+
*/
|
|
10
|
+
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
|
+
export const jungleFormatTags = ['article', 'interactive', 'x', 'video'];
|
|
13
|
+
export class JungleDigestError extends Error {
|
|
14
|
+
code;
|
|
15
|
+
constructor(code) {
|
|
16
|
+
super(code);
|
|
17
|
+
this.code = code;
|
|
18
|
+
this.name = 'JungleDigestError';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const fail = (code = 'malformed') => { throw new JungleDigestError(code); };
|
|
22
|
+
/** The frontmatter subset Jungle writes: block maps and lists of scalars, quoted or plain. */
|
|
23
|
+
function parseYaml(lines) {
|
|
24
|
+
let i = 0;
|
|
25
|
+
const indent = (l) => l.length - l.trimStart().length;
|
|
26
|
+
const scalar = (v) => { v = v.trim(); if (v.startsWith('"')) {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(v);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return fail();
|
|
32
|
+
}
|
|
33
|
+
} if (v.startsWith("'"))
|
|
34
|
+
return v.endsWith("'") && v.length > 1 ? v.slice(1, -1).replace(/''/g, "'") : fail(); return v === 'true' ? true : v === 'false' ? false : v; };
|
|
35
|
+
const block = (at) => {
|
|
36
|
+
if (lines[i].trimStart().startsWith('- ')) {
|
|
37
|
+
const list = [];
|
|
38
|
+
while (i < lines.length && indent(lines[i]) === at && lines[i].trimStart().startsWith('- ')) {
|
|
39
|
+
const rest = lines[i].trimStart().slice(2);
|
|
40
|
+
if (/^[\w-]+:(\s|$)/.test(rest)) {
|
|
41
|
+
lines[i] = ' '.repeat(at + 2) + rest;
|
|
42
|
+
list.push(map(at + 2));
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
list.push(scalar(rest));
|
|
46
|
+
i++;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return list;
|
|
50
|
+
}
|
|
51
|
+
return map(at);
|
|
52
|
+
};
|
|
53
|
+
const map = (at) => {
|
|
54
|
+
const out = {};
|
|
55
|
+
while (i < lines.length && indent(lines[i]) === at && !lines[i].trimStart().startsWith('- ')) {
|
|
56
|
+
const m = /^([\w-]+):(?:\s+(.*))?$/.exec(lines[i].trim()) ?? fail();
|
|
57
|
+
i++;
|
|
58
|
+
if (m[2] !== undefined && m[2] !== '')
|
|
59
|
+
out[m[1]] = scalar(m[2]);
|
|
60
|
+
else if (i < lines.length && (indent(lines[i]) > at || indent(lines[i]) === at && lines[i].trimStart().startsWith('- ')))
|
|
61
|
+
out[m[1]] = block(indent(lines[i]));
|
|
62
|
+
else
|
|
63
|
+
out[m[1]] = '';
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
};
|
|
67
|
+
const value = lines.length ? map(0) : {};
|
|
68
|
+
return i === lines.length ? value : fail();
|
|
69
|
+
}
|
|
70
|
+
const object = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
71
|
+
const strings = (v) => Array.isArray(v) && v.every(x => typeof x === 'string') ? v : fail();
|
|
72
|
+
/** Split a Markdown file into its frontmatter lines and body. */
|
|
73
|
+
function frontmatter(markdown) {
|
|
74
|
+
const m = /^---\n([^]*?)\n---(?:\n|$)/.exec(markdown.replace(/\r\n/g, '\n'));
|
|
75
|
+
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 };
|
|
76
|
+
}
|
|
77
|
+
/** Capture Markdown without its YAML frontmatter: what a Sponge revision of the same page would hold. */
|
|
78
|
+
export function captureBody(capture) { return frontmatter(capture).body; }
|
|
79
|
+
/** `## Heading` sections of a note body, trimmed. */
|
|
80
|
+
function sectionsOf(body) {
|
|
81
|
+
const sections = new Map();
|
|
82
|
+
for (const part of body.split(/^## /m).slice(1)) {
|
|
83
|
+
const nl = part.indexOf('\n');
|
|
84
|
+
sections.set(part.slice(0, nl < 0 ? undefined : nl).trim(), nl < 0 ? '' : part.slice(nl + 1).trim());
|
|
85
|
+
}
|
|
86
|
+
return sections;
|
|
87
|
+
}
|
|
88
|
+
/** `- **Claim.** detail` bullets: Jungle's Ideas, and a conversation's Debate. */
|
|
89
|
+
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() }; });
|
|
90
|
+
/** Blockquotes ending `> Attribution: Speaker, context`. */
|
|
91
|
+
const quoteList = (text) => text.split(/\n\s*\n/).filter(b => b.trim()).map(b => {
|
|
92
|
+
const lines = b.split('\n').map(l => /^>\s?(.*)$/.exec(l)?.[1] ?? fail()), at = lines.findIndex(l => l.startsWith('Attribution:'));
|
|
93
|
+
if (at < 1)
|
|
94
|
+
fail();
|
|
95
|
+
const text = lines.slice(0, at).join(' ').replace(/\s+/g, ' ').trim(), attribution = lines[at].slice('Attribution:'.length).trim();
|
|
96
|
+
return { text, attribution, speaker: attribution.split(/,|;|\s\(|\s—\s|\s-\s/)[0].trim() };
|
|
97
|
+
});
|
|
98
|
+
export function parseJungleNote(markdown) {
|
|
99
|
+
if (typeof markdown !== 'string' || markdown.length > 4 * 1024 * 1024)
|
|
100
|
+
fail();
|
|
101
|
+
const { head, body } = frontmatter(markdown), meta = parseYaml(head);
|
|
102
|
+
if (!object(meta) || !object(meta.reading))
|
|
103
|
+
throw new JungleDigestError('not-digest');
|
|
104
|
+
const reading = meta.reading;
|
|
105
|
+
if (reading.form !== 'digest')
|
|
106
|
+
fail('not-digest');
|
|
107
|
+
const sections = sectionsOf(body);
|
|
108
|
+
const gist = (sections.get('Gist') ?? fail()).replace(/\s+/g, ' ').trim();
|
|
109
|
+
const ideas = ideaList(sections.get('Ideas') ?? fail());
|
|
110
|
+
const quotes = quoteList(sections.get('Quotes') ?? '');
|
|
111
|
+
if (!gist || !ideas.length || quotes.some(q => !q.text || !q.speaker))
|
|
112
|
+
fail();
|
|
113
|
+
const authors = Array.isArray(reading.authors) ? reading.authors.map(a => object(a) && typeof a.name === 'string' ? a.name : fail()) : [];
|
|
114
|
+
const relations = object(meta.relations) ? meta.relations : {}, synth = Array.isArray(relations.synthesizes) ? strings(relations.synthesizes) : [];
|
|
115
|
+
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 };
|
|
116
|
+
}
|
|
117
|
+
export function parseJungleEditorial(markdown) {
|
|
118
|
+
if (typeof markdown !== 'string' || markdown.length > 4 * 1024 * 1024)
|
|
119
|
+
fail();
|
|
120
|
+
const { head, body } = frontmatter(markdown), meta = parseYaml(head);
|
|
121
|
+
if (!object(meta) || !object(meta.reading))
|
|
122
|
+
throw new JungleDigestError('not-digest');
|
|
123
|
+
const reading = meta.reading, relations = object(meta.relations) ? meta.relations : {};
|
|
124
|
+
const form = reading.form, slug = reading.slug;
|
|
125
|
+
if (typeof form !== 'string' || !form || typeof slug !== 'string' || !/^[a-z0-9][a-z0-9-]{0,199}$/.test(slug))
|
|
126
|
+
return fail();
|
|
127
|
+
const capture = [relations.synthesizes, relations['evidenced-by']].flatMap(v => Array.isArray(v) ? strings(v) : [])[0] ?? fail('no-capture');
|
|
128
|
+
// A digest must parse strictly; the other forms keep whatever structure they have, and the Markdown keeps the rest.
|
|
129
|
+
const sections = sectionsOf(body), loose = (f) => { if (form === 'digest')
|
|
130
|
+
return f(); try {
|
|
131
|
+
return f();
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return undefined;
|
|
135
|
+
} };
|
|
136
|
+
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');
|
|
137
|
+
if (form === 'digest')
|
|
138
|
+
parseJungleNote(markdown);
|
|
139
|
+
const gist = gistText?.replace(/\s+/g, ' ').trim(), ideas = ideaText === undefined ? undefined : loose(() => ideaList(ideaText)), quotes = quoteText === undefined ? undefined : loose(() => quoteList(quoteText));
|
|
140
|
+
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 };
|
|
141
|
+
}
|
|
142
|
+
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(' '));
|
|
143
|
+
/** Content-word stems: lowercase, stopwords out, a crude plural/tense strip. Shared by idea assignment and lexical coverage. */
|
|
144
|
+
export function contentTerms(text) {
|
|
145
|
+
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));
|
|
146
|
+
}
|
|
147
|
+
export function termSimilarity(a, b) { if (!a.size || !b.size)
|
|
148
|
+
return 0; let n = 0; for (const t of a)
|
|
149
|
+
if (b.has(t))
|
|
150
|
+
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;
|
|
166
|
+
}
|
|
167
|
+
export const firstSentence = (text) => { const m = /^(.+?[.!?]["”’)]*)\s+(?=["“(]?[A-Z0-9])/.exec(text); return m ? [m[1], text.slice(m[0].length)] : [text, '']; };
|
|
168
|
+
export function formOf(note) {
|
|
169
|
+
if (note.tags.includes('video'))
|
|
170
|
+
return 'video';
|
|
171
|
+
if (note.tags.includes('x'))
|
|
172
|
+
return 'thread';
|
|
173
|
+
return /\.pdf(?:$|[?#])|arxiv\.org\/pdf\//i.test(note.sourceUrl) ? 'pdf' : 'article';
|
|
174
|
+
}
|
|
175
|
+
/** Convert one Jungle digest note. With `capture` (the capture Markdown, frontmatter allowed), quotes are located and the gold is checked. */
|
|
176
|
+
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;
|
|
219
|
+
}
|
|
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 };
|
|
221
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { type DigestV1 } 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: "1";
|
|
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
|
+
v1?: DigestV1;
|
|
21
|
+
}
|
|
22
|
+
export interface JunglePlan {
|
|
23
|
+
editorialId: string;
|
|
24
|
+
editorial: EditorialAction;
|
|
25
|
+
v1Id?: string;
|
|
26
|
+
v1: 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 clean Digest v1 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,95 @@
|
|
|
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: '1' };
|
|
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
|
+
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';
|
|
62
|
+
else if (digests.some(r => !imported(r)))
|
|
63
|
+
v1 = 'user-digest-wins';
|
|
64
|
+
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
|
+
else
|
|
67
|
+
v1 = editorialAction === 'user-review-wins' ? 'user-review-wins' : 'new';
|
|
68
|
+
}
|
|
69
|
+
const visibility = visibilityAction(visibilityState(db, itemId), editorial.status, userOverride(db, itemId)).action;
|
|
70
|
+
return { editorialId, editorial: editorialAction, ...(v1Id ? { v1Id } : {}), v1, visibility };
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* 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
|
+
* editorial object by its input hash, unless the user owns a digest of this item or changed an imported digest's review.
|
|
76
|
+
*/
|
|
77
|
+
export function applyJungleNote(db, input) {
|
|
78
|
+
const plan = planJungleNote(db, input), producer = { ...jungleProducer, note: input.editorial.slug };
|
|
79
|
+
if (plan.editorial !== 'unchanged') {
|
|
80
|
+
putEditorial(db, { itemId: input.itemId, revisionId: input.revisionId, producer, output: input.editorial });
|
|
81
|
+
if (plan.editorial === 'new')
|
|
82
|
+
reviewEditorial(db, plan.editorialId, 'accepted');
|
|
83
|
+
}
|
|
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));
|
|
87
|
+
}
|
|
88
|
+
const action = visibilityAction(visibilityState(db, input.itemId), input.editorial.status, userOverride(db, input.itemId));
|
|
89
|
+
if (action.target !== undefined) {
|
|
90
|
+
setVisibility(db, input.itemId, action.target);
|
|
91
|
+
const latest = latestVisibility(db, input.itemId);
|
|
92
|
+
db.prepare('INSERT OR IGNORE INTO import_receipts VALUES(?,?,?,?,?,?)').run(visibilityReceipts, input.itemId, digest(String(latest.id)), jungleProducer.version, input.itemId, 'visibility');
|
|
93
|
+
}
|
|
94
|
+
return plan;
|
|
95
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { LibraryStore } from '../store/index.js';
|
|
2
|
+
import { type DigestV1 } from '../digest/schema.js';
|
|
3
|
+
import { type JungleEditorial } from './jungle-digest.js';
|
|
4
|
+
import { type DigestAction, type EditorialAction, type VisibilityAction } from './jungle-store.js';
|
|
5
|
+
import type { BundleVerifier } from './inspect.js';
|
|
6
|
+
/**
|
|
7
|
+
* `sponge import jungle DIR`: every Jungle Reading note (`kb/notes/reading/<slug>.md`) onto the item holding its capture.
|
|
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.
|
|
11
|
+
*/
|
|
12
|
+
export interface JungleImportOptions {
|
|
13
|
+
verify?: BundleVerifier;
|
|
14
|
+
dryRun?: boolean;
|
|
15
|
+
limit?: number;
|
|
16
|
+
offset?: number;
|
|
17
|
+
signal?: AbortSignal;
|
|
18
|
+
}
|
|
19
|
+
export type JungleSkip = 'not-reading-note' | 'malformed-note' | 'no-capture' | 'capture-missing' | 'item-deleted' | 'capture-unsupported' | 'capture-failed';
|
|
20
|
+
export interface JungleEntry {
|
|
21
|
+
slug: string;
|
|
22
|
+
form?: string;
|
|
23
|
+
status?: string;
|
|
24
|
+
item?: 'new' | 'existing';
|
|
25
|
+
itemId?: string;
|
|
26
|
+
editorial?: EditorialAction;
|
|
27
|
+
v1?: DigestAction;
|
|
28
|
+
v1Issues?: string[];
|
|
29
|
+
visibility?: VisibilityAction;
|
|
30
|
+
skipped?: JungleSkip;
|
|
31
|
+
code?: string;
|
|
32
|
+
}
|
|
33
|
+
export interface JungleImportReport {
|
|
34
|
+
schemaVersion: 1;
|
|
35
|
+
dryRun: boolean;
|
|
36
|
+
captureVerification: 'deferred' | 'verified';
|
|
37
|
+
notes: number;
|
|
38
|
+
counts: {
|
|
39
|
+
newItems: number;
|
|
40
|
+
existingItems: number;
|
|
41
|
+
editorial: Record<string, number>;
|
|
42
|
+
v1: Record<string, number>;
|
|
43
|
+
visibility: Record<string, number>;
|
|
44
|
+
skipped: Record<string, number>;
|
|
45
|
+
};
|
|
46
|
+
entries: JungleEntry[];
|
|
47
|
+
nextOffset: number | null;
|
|
48
|
+
}
|
|
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;
|
|
52
|
+
issues: string[];
|
|
53
|
+
};
|
|
54
|
+
export declare function importJungle(store: LibraryStore, jungle: string, options?: JungleImportOptions): Promise<JungleImportReport>;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { readdir, realpath } from 'node:fs/promises';
|
|
2
|
+
import { dirname, isAbsolute, join } from 'node:path';
|
|
3
|
+
import { readConfined, hash } from '../acquisition/files.js';
|
|
4
|
+
import { object } from '../acquisition/normalize.js';
|
|
5
|
+
import { localizeMarkdown } from '../app/markdown.js';
|
|
6
|
+
import { checkDigest } from '../digest/schema.js';
|
|
7
|
+
import { accessClass, defaultVisibility } from '../identity/uri.js';
|
|
8
|
+
import { convertJungleDigest, parseJungleEditorial, jungleTags, JungleDigestError } from './jungle-digest.js';
|
|
9
|
+
import { jungleNamespace, jungleRealm, visibilityAction } from './jungle-store.js';
|
|
10
|
+
import { importBundleEntry, importBundleHash } from './apply.js';
|
|
11
|
+
const noteBytes = 4 * 1024 ** 2, captureBytes = 16 * 1024 ** 2;
|
|
12
|
+
const errorCode = (error) => String(error?.code ?? (error instanceof Error ? error.message : 'unclassified'));
|
|
13
|
+
/** A capture link is `<dir>/<name>` under `kb/`, never escaping it; the bundle is its directory. */
|
|
14
|
+
function captureLink(link) { if (isAbsolute(link) || link.includes('\\') || link.split('/').length < 2 || link.split('/').some(p => !p || p === '.' || p === '..'))
|
|
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) {
|
|
18
|
+
if (editorial.form !== 'digest')
|
|
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)] };
|
|
22
|
+
}
|
|
23
|
+
/** What a verified import would store, read from the bundle without the verifier: the same localization, from the manifest's asset list. */
|
|
24
|
+
async function previewCapture(kb, bundle, link) {
|
|
25
|
+
const manifest = object(JSON.parse((await readConfined(kb, join(bundle, 'capture.json'), captureBytes)).toString('utf8'))), pdf = manifest.kind === 'pdf';
|
|
26
|
+
const listed = (pdf ? (Array.isArray(manifest.images) ? manifest.images.map(i => object(i).asset) : []) : Array.isArray(manifest.assets) ? manifest.assets : []).flatMap(raw => { const a = raw && typeof raw === 'object' ? raw : {}; return typeof a.path === 'string' && typeof a.sha256 === 'string' ? [{ relativePath: a.path, sha256: a.sha256, bytes: Number(a.bytes), mediaType: String(a.mimeType) }] : []; });
|
|
27
|
+
const text = (await readConfined(kb, link + '.md', captureBytes)).toString('utf8'), markdown = localizeMarkdown(pdf ? text.replace(/^<!-- pdf-page: ([1-9][0-9]*) -->$/gm, '## Page $1') : text, listed).markdown;
|
|
28
|
+
const locator = pdf ? String(object(manifest.source ?? {}).requestedUrl ?? '') : String(manifest.sourceUrl ?? '');
|
|
29
|
+
return { markdown, locator };
|
|
30
|
+
}
|
|
31
|
+
export async function importJungle(store, jungle, options = {}) {
|
|
32
|
+
if (!isAbsolute(jungle))
|
|
33
|
+
throw Object.assign(Error('invalid-input'), { code: 'invalid-input' });
|
|
34
|
+
const kb = await realpath(join(jungle, 'kb')), notesDir = join(kb, 'notes', 'reading'), files = (await readdir(notesDir)).filter(f => /^[a-z0-9][a-z0-9-]*\.md$/.test(f)).sort();
|
|
35
|
+
const limit = options.limit ?? 1000, offset = options.offset ?? 0, dryRun = options.dryRun !== false;
|
|
36
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000 || !Number.isSafeInteger(offset) || offset < 0 || offset > files.length)
|
|
37
|
+
throw Object.assign(Error('invalid-input'), { code: 'invalid-input' });
|
|
38
|
+
if (!dryRun && !options.verify)
|
|
39
|
+
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 bump = (m, k) => { m[k] = (m[k] ?? 0) + 1; }, seen = new Set();
|
|
42
|
+
for (const file of files.slice(offset, offset + limit)) {
|
|
43
|
+
if (options.signal?.aborted)
|
|
44
|
+
throw Object.assign(Error('cancelled'), { code: 'cancelled' });
|
|
45
|
+
const slug = file.slice(0, -3);
|
|
46
|
+
let entry = { slug };
|
|
47
|
+
try {
|
|
48
|
+
let editorial;
|
|
49
|
+
try {
|
|
50
|
+
editorial = parseJungleEditorial((await readConfined(notesDir, file, noteBytes)).toString('utf8'));
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
const code = error instanceof JungleDigestError ? error.code : 'malformed';
|
|
54
|
+
entry.skipped = code === 'not-digest' ? 'not-reading-note' : code === 'no-capture' ? 'no-capture' : 'malformed-note';
|
|
55
|
+
throw entry;
|
|
56
|
+
}
|
|
57
|
+
entry = { slug, form: editorial.form, status: editorial.status };
|
|
58
|
+
let link;
|
|
59
|
+
try {
|
|
60
|
+
link = captureLink(editorial.capture);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
entry.skipped = 'no-capture';
|
|
64
|
+
throw entry;
|
|
65
|
+
}
|
|
66
|
+
const bundle = dirname(link);
|
|
67
|
+
try {
|
|
68
|
+
await readConfined(kb, join(bundle, 'capture.json'), captureBytes);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
entry.skipped = 'capture-missing';
|
|
72
|
+
throw entry;
|
|
73
|
+
}
|
|
74
|
+
const prior = store.importedItem(jungleRealm, hash(JSON.stringify([jungleNamespace, link])));
|
|
75
|
+
if (prior?.deleted) {
|
|
76
|
+
entry.skipped = 'item-deleted';
|
|
77
|
+
entry.itemId = prior.itemId;
|
|
78
|
+
throw entry;
|
|
79
|
+
}
|
|
80
|
+
// Two notes on one capture share its item, in a dry run too.
|
|
81
|
+
entry.item = prior || seen.has(link) ? 'existing' : 'new';
|
|
82
|
+
seen.add(link);
|
|
83
|
+
if (dryRun) {
|
|
84
|
+
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 });
|
|
87
|
+
}
|
|
88
|
+
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 });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
let imported;
|
|
95
|
+
try {
|
|
96
|
+
imported = await importBundleEntry(store, kb, jungleNamespace, { id: link, bundle, privacy: 'private', notes: [] }, options.verify, false, options.signal);
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
const code = errorCode(error);
|
|
100
|
+
if (['cleanup-required', 'owner-busy', 'cancelled'].includes(code))
|
|
101
|
+
throw error;
|
|
102
|
+
entry.skipped = code === 'unsupported' ? 'capture-unsupported' : 'capture-failed';
|
|
103
|
+
entry.code = code;
|
|
104
|
+
throw entry;
|
|
105
|
+
}
|
|
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 });
|
|
109
|
+
}
|
|
110
|
+
bump(report.counts.editorial, entry.editorial);
|
|
111
|
+
bump(report.counts.v1, entry.v1);
|
|
112
|
+
bump(report.counts.visibility, entry.visibility);
|
|
113
|
+
if (entry.item === 'new')
|
|
114
|
+
report.counts.newItems++;
|
|
115
|
+
else
|
|
116
|
+
report.counts.existingItems++;
|
|
117
|
+
report.entries.push(entry);
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
if (error !== entry) {
|
|
121
|
+
const code = errorCode(error);
|
|
122
|
+
if (['cleanup-required', 'owner-busy', 'cancelled'].includes(code)) {
|
|
123
|
+
report.nextOffset = offset + report.entries.length;
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
entry.skipped = 'capture-failed';
|
|
127
|
+
entry.code = code;
|
|
128
|
+
}
|
|
129
|
+
bump(report.counts.skipped, entry.skipped);
|
|
130
|
+
report.entries.push(entry);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return report;
|
|
134
|
+
}
|
|
@@ -23,6 +23,13 @@ export declare class IndexError extends Error {
|
|
|
23
23
|
code: 'invalid-index' | 'stale-index' | 'incomplete-index' | 'cancelled-index';
|
|
24
24
|
constructor(code: 'invalid-index' | 'stale-index' | 'incomplete-index' | 'cancelled-index');
|
|
25
25
|
}
|
|
26
|
+
export type VectorEncoding = 'f32' | 'int8';
|
|
27
|
+
/** Storage format follows the space identity: no `quantization` (legacy) or `f32` stores float32 LE; `int8-symmetric-maxabs-v1` stores one int8 code per dimension. */
|
|
28
|
+
export declare function spaceEncoding(identity: Record<string, unknown>): VectorEncoding;
|
|
29
|
+
/** int8 codes are round(x*127/max|x|) of a unit vector. The per-vector scale is not stored: cosine is scale-invariant, so codes rank directly and dequantize by renormalizing. */
|
|
30
|
+
export declare function encodeVector(vector: readonly number[], encoding: VectorEncoding): Buffer;
|
|
31
|
+
/** Exact byte length, finite floats, int8 codes within [-127,127] and nonzero; int8 returns the unit dequantized vector. */
|
|
32
|
+
export declare function decodeVector(bytes: Uint8Array, dimensions: number, encoding: VectorEncoding): number[];
|
|
26
33
|
/** Unicode-safe chunks fit the fixed document prompt within the embedding text budget, preferring whitespace breaks. */
|
|
27
34
|
export declare function chunkDocument(text: string): string[];
|
|
28
35
|
/** Identity must already be the verified embedder fingerprint. Existing identities are immutable. */
|