@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.
Files changed (88) hide show
  1. package/README.md +53 -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/acquisition/lock.d.ts +1 -1
  9. package/dist/src/acquisition/lock.js +1 -1
  10. package/dist/src/acquisition/pins.js +3 -3
  11. package/dist/src/annotations/quote.d.ts +32 -0
  12. package/dist/src/annotations/quote.js +135 -0
  13. package/dist/src/annotations/store.d.ts +50 -0
  14. package/dist/src/annotations/store.js +89 -0
  15. package/dist/src/app/source.d.ts +2 -0
  16. package/dist/src/app/source.js +13 -17
  17. package/dist/src/cli/arguments.js +24 -7
  18. package/dist/src/cli/digest.d.ts +10 -0
  19. package/dist/src/cli/digest.js +63 -0
  20. package/dist/src/cli/import.d.ts +11 -0
  21. package/dist/src/cli/import.js +10 -0
  22. package/dist/src/cli/indexing.d.ts +3 -0
  23. package/dist/src/cli/indexing.js +5 -2
  24. package/dist/src/cli/main.js +94 -8
  25. package/dist/src/cli/runtimes.d.ts +10 -0
  26. package/dist/src/cli/runtimes.js +32 -0
  27. package/dist/src/digest/editorial.d.ts +53 -0
  28. package/dist/src/digest/editorial.js +81 -0
  29. package/dist/src/digest/endpoint.d.ts +20 -0
  30. package/dist/src/digest/endpoint.js +103 -0
  31. package/dist/src/digest/pipeline.d.ts +148 -0
  32. package/dist/src/digest/pipeline.js +410 -0
  33. package/dist/src/digest/schema.d.ts +85 -0
  34. package/dist/src/digest/schema.js +118 -0
  35. package/dist/src/digest/store.d.ts +57 -0
  36. package/dist/src/digest/store.js +77 -0
  37. package/dist/src/identity/query-policy.d.ts +21 -0
  38. package/dist/src/identity/query-policy.js +55 -0
  39. package/dist/src/identity/uri.d.ts +30 -0
  40. package/dist/src/identity/uri.js +94 -0
  41. package/dist/src/identity/visibility.d.ts +24 -0
  42. package/dist/src/identity/visibility.js +75 -0
  43. package/dist/src/importers/apply.d.ts +10 -0
  44. package/dist/src/importers/apply.js +25 -22
  45. package/dist/src/importers/jungle-digest.d.ts +97 -0
  46. package/dist/src/importers/jungle-digest.js +221 -0
  47. package/dist/src/importers/jungle-store.d.ts +45 -0
  48. package/dist/src/importers/jungle-store.js +95 -0
  49. package/dist/src/importers/jungle.d.ts +54 -0
  50. package/dist/src/importers/jungle.js +134 -0
  51. package/dist/src/indexing/index.d.ts +7 -0
  52. package/dist/src/indexing/index.js +31 -8
  53. package/dist/src/indexing/prune-run.d.ts +22 -0
  54. package/dist/src/indexing/prune-run.js +67 -0
  55. package/dist/src/indexing/prune.d.ts +30 -0
  56. package/dist/src/indexing/prune.js +95 -0
  57. package/dist/src/indexing/runner.js +5 -5
  58. package/dist/src/inference/embedding/index.d.ts +25 -0
  59. package/dist/src/inference/embedding/index.js +11 -0
  60. package/dist/src/models/artifacts.js +175 -33
  61. package/dist/src/portability/database.js +3 -0
  62. package/dist/src/publish/component.d.ts +8 -0
  63. package/dist/src/publish/component.js +114 -0
  64. package/dist/src/publish/feed.d.ts +24 -0
  65. package/dist/src/publish/feed.js +77 -0
  66. package/dist/src/publish/identity.d.ts +6 -0
  67. package/dist/src/publish/identity.js +68 -0
  68. package/dist/src/publish/index.d.ts +34 -0
  69. package/dist/src/publish/index.js +137 -0
  70. package/dist/src/publish/projection.d.ts +29 -0
  71. package/dist/src/publish/projection.js +43 -0
  72. package/dist/src/search/hybrid.js +2 -2
  73. package/dist/src/search/lexical.d.ts +11 -0
  74. package/dist/src/search/lexical.js +6 -0
  75. package/dist/src/search/query.d.ts +7 -3
  76. package/dist/src/search/query.js +52 -33
  77. package/dist/src/search/vector.d.ts +1 -1
  78. package/dist/src/search/vector.js +6 -5
  79. package/dist/src/store/changes.d.ts +42 -0
  80. package/dist/src/store/changes.js +178 -0
  81. package/dist/src/store/foundation.d.ts +7 -0
  82. package/dist/src/store/foundation.js +7 -0
  83. package/dist/src/store/index.d.ts +69 -0
  84. package/dist/src/store/index.js +134 -51
  85. package/dist/src/worker/runtime-gc.d.ts +30 -0
  86. package/dist/src/worker/runtime-gc.js +296 -0
  87. package/native/runtime-manifest.json +4 -4
  88. package/package.json +1 -1
@@ -1,9 +1,9 @@
1
1
  import { captureLockfile } from './lock.js';
2
2
  /** Immutable released media/PDF capability pins; installed qualification is recorded separately. */
3
3
  export const defaultCapturePins = {
4
- id: 'capture-ghostget-0.18.101-bun-1.3.14-darwin-arm64',
5
- ghostget: { version: '0.18.101', url: 'https://github.com/hraness/ghostget/releases/download/v0.18.101/hraness-ghostget-0.18.101.tgz', bytes: 12493207, sha256: 'ae62e2792b429b1e9c38292046e4223801ec5a5f7fb36076dfcbd137d4ca4c87' },
4
+ id: 'capture-ghostget-0.18.102-bun-1.3.14-darwin-arm64',
5
+ ghostget: { version: '0.18.102', url: 'https://github.com/hraness/ghostget/releases/download/v0.18.102/hraness-ghostget-0.18.102.tgz', bytes: 12493537, sha256: '68edcad50b72f77b2ed73e9affba3b49e6b683622cdec9e2fabdffa90e3a0bdb' },
6
6
  wordcell: { version: '0.27.0', url: 'https://github.com/hraness/wordcell/releases/download/v0.27.0/hraness-wordcell-0.27.0.tgz', bytes: 1465897, sha256: 'df47868147cda46d741895d3cd1ddcbec14a563b341aa09052545be7696c9262' },
7
7
  bun: { version: '1.3.14', url: 'https://github.com/oven-sh/bun/releases/download/bun-v1.3.14/bun-darwin-aarch64.zip', bytes: 23586433, sha256: 'd8b96221828ad6f97ac7ac0ab7e95872341af763001e8803e8267652c2652620', executableSha256: 'e0c90ec15d33363e6b70713d56bc3b2c7585c17f40a0fe0f8fd9305901d4e233', archiveExecutable: 'bun-darwin-aarch64/bun' },
8
- lockfile: captureLockfile, lockSha256: "afa317a4fdda77ceb8d104bb23bbb2d8d5bef62cda5630e5fcc8d0b299b6c357",
8
+ lockfile: captureLockfile, lockSha256: "456370c26a7286bab70307595745f4be382a684a11223e848dbb0032761d59d0",
9
9
  };
@@ -0,0 +1,32 @@
1
+ /** A located quotation: raw offsets into the revision's Markdown plus context that survives re-capture. */
2
+ export interface TextQuote {
3
+ type: 'text-quote';
4
+ exact: string;
5
+ prefix: string;
6
+ suffix: string;
7
+ start: number;
8
+ end: number;
9
+ }
10
+ export declare function foldText(value: string): string;
11
+ /** Every occurrence of `quote` in `source`, compared after folding. */
12
+ export declare function locateAll(source: string, quote: string): {
13
+ start: number;
14
+ end: number;
15
+ }[];
16
+ export declare function textQuote(source: string, start: number, end: number): TextQuote;
17
+ /** The single match for `quote`, or undefined when absent or ambiguous. */
18
+ export declare function locateQuote(source: string, quote: string): TextQuote | undefined;
19
+ /** A stored selector still names its exact text in `source`. */
20
+ export declare function anchorHolds(source: string, anchor: TextQuote): boolean;
21
+ export declare function words(value: string): number;
22
+ /**
23
+ * Every occurrence of `quote` in `source` ignoring inline Markdown and case, within optional raw bounds.
24
+ * Use only to snap a model's near-verbatim quote to the exact raw span; checking still uses `locateAll`.
25
+ */
26
+ export declare function locateLoose(source: string, quote: string, bounds?: {
27
+ start: number;
28
+ end: number;
29
+ }): {
30
+ start: number;
31
+ end: number;
32
+ }[];
@@ -0,0 +1,135 @@
1
+ const context = 32;
2
+ const graphemes = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
3
+ const escapable = /^[\\`*_{}[\]()#+\-.!|>~<"']$/;
4
+ /**
5
+ * Fold the differences a reader cannot see: Markdown escapes, curly quotes, dashes, runs of
6
+ * whitespace and Unicode normalization. Folding works on grapheme clusters, each NFC-normalized;
7
+ * every folded code unit of a cluster maps to the cluster's raw bounds, so `map[i]` and `ends[i]`
8
+ * bound the raw text of folded unit i and matches map back exactly.
9
+ */
10
+ function fold(raw) {
11
+ let text = '', space = false;
12
+ const map = [], ends = [], clusters = [...graphemes.segment(raw)];
13
+ const emit = (out, start, end) => { text += out; for (let k = 0; k < out.length; k++) {
14
+ map.push(start);
15
+ ends.push(end);
16
+ } };
17
+ for (let c = 0; c < clusters.length; c++) {
18
+ const { segment, index } = clusters[c];
19
+ // An escaped character maps to its backslash, so a match starting there keeps the escape.
20
+ let ch = segment, end = index + segment.length;
21
+ if (segment === '\\' && c + 1 < clusters.length && escapable.test(clusters[c + 1].segment)) {
22
+ ch = clusters[++c].segment;
23
+ end = clusters[c].index + ch.length;
24
+ }
25
+ if (/^\s+$/.test(ch)) {
26
+ if (!space && text.length)
27
+ emit(' ', index, end);
28
+ space = true;
29
+ continue;
30
+ }
31
+ space = false;
32
+ emit(ch.replace(/[‘’‚′]/g, "'").replace(/[“”„″]/g, '"').replace(/[\u2010-\u2015\u2212]/g, '-').normalize('NFC'), index, end);
33
+ }
34
+ if (text.endsWith(' ')) {
35
+ text = text.slice(0, -1);
36
+ map.pop();
37
+ ends.pop();
38
+ }
39
+ return { text, map, ends };
40
+ }
41
+ export function foldText(value) { return fold(value).text; }
42
+ /** Every occurrence of `quote` in `source`, compared after folding. */
43
+ export function locateAll(source, quote) {
44
+ const s = fold(source), q = fold(quote).text, out = [];
45
+ if (!q)
46
+ return out;
47
+ for (let at = s.text.indexOf(q); at >= 0; at = s.text.indexOf(q, at + 1)) {
48
+ // A match must start and end on cluster boundaries; half a cluster has no raw offsets.
49
+ const last = at + q.length - 1;
50
+ if (at > 0 && s.map[at - 1] === s.map[at] || last + 1 < s.text.length && s.map[last + 1] === s.map[last])
51
+ continue;
52
+ out.push({ start: s.map[at], end: s.ends[last] });
53
+ }
54
+ return out;
55
+ }
56
+ export function textQuote(source, start, end) {
57
+ return { type: 'text-quote', exact: source.slice(start, end), prefix: source.slice(Math.max(0, start - context), start), suffix: source.slice(end, end + context), start, end };
58
+ }
59
+ /** The single match for `quote`, or undefined when absent or ambiguous. */
60
+ export function locateQuote(source, quote) {
61
+ const all = locateAll(source, quote);
62
+ return all.length === 1 ? textQuote(source, all[0].start, all[0].end) : undefined;
63
+ }
64
+ /** A stored selector still names its exact text in `source`. */
65
+ export function anchorHolds(source, anchor) {
66
+ return Number.isSafeInteger(anchor.start) && Number.isSafeInteger(anchor.end) && anchor.start >= 0 && anchor.end > anchor.start && source.slice(anchor.start, anchor.end) === anchor.exact;
67
+ }
68
+ export function words(value) { const t = foldText(value); return t ? t.split(' ').length : 0; }
69
+ /**
70
+ * Rendered-text projection for snapping a near-verbatim quote: drops inline Markdown syntax (link targets, brackets,
71
+ * emphasis, code ticks, line-start block markers), folds like `fold`, and lowercases. Offsets map back to raw text.
72
+ */
73
+ function rendered(raw) {
74
+ let text = '', map = [], ends = [], space = false, lineStart = true;
75
+ for (let i = 0; i < raw.length; i++) {
76
+ let ch = raw[i], at = i;
77
+ if (lineStart) {
78
+ const m = /^[ \t]*(?:>[ \t]?|#{1,6}[ \t]+|[-*+][ \t]+|\d{1,3}[.)][ \t]+)*/.exec(raw.slice(i, i + 64));
79
+ if (m[0].length) {
80
+ i += m[0].length - 1;
81
+ lineStart = false;
82
+ continue;
83
+ }
84
+ }
85
+ lineStart = ch === '\n';
86
+ if (ch === ']' && raw[i + 1] === '(') {
87
+ const close = raw.indexOf(')', i + 2);
88
+ if (close > 0 && close - i < 2048 && !raw.slice(i + 2, close).includes('\n')) {
89
+ i = close;
90
+ continue;
91
+ }
92
+ }
93
+ if (ch === '!' && raw[i + 1] === '[' || /[[\]*_`]/.test(ch))
94
+ continue;
95
+ if (ch === '\\' && i + 1 < raw.length && /[\\`*_{}[\]()#+\-.!|>~<"']/.test(raw[i + 1])) {
96
+ ch = raw[++i];
97
+ }
98
+ if (/\s/.test(ch)) {
99
+ if (!space && text.length) {
100
+ text += ' ';
101
+ map.push(i);
102
+ ends.push(i + 1);
103
+ }
104
+ space = true;
105
+ continue;
106
+ }
107
+ space = false;
108
+ ch = ch.replace(/[‘’‚′]/g, "'").replace(/[“”„″]/g, '"').replace(/[‐-―−]/g, '-').normalize('NFC').toLowerCase();
109
+ // One entry per output code unit: normalization and case mapping may change the length.
110
+ text += ch;
111
+ for (let k = 0; k < ch.length; k++) {
112
+ map.push(at);
113
+ ends.push(i + 1);
114
+ }
115
+ }
116
+ if (text.endsWith(' ')) {
117
+ text = text.slice(0, -1);
118
+ map.pop();
119
+ ends.pop();
120
+ }
121
+ return { text, map, ends };
122
+ }
123
+ /**
124
+ * Every occurrence of `quote` in `source` ignoring inline Markdown and case, within optional raw bounds.
125
+ * Use only to snap a model's near-verbatim quote to the exact raw span; checking still uses `locateAll`.
126
+ */
127
+ export function locateLoose(source, quote, bounds = { start: 0, end: source.length }) {
128
+ const q = rendered(quote).text.replace(/^["'\s]+|["'\s]+$/g, ''), out = [];
129
+ if (q.length < 8)
130
+ return out;
131
+ const s = rendered(source.slice(bounds.start, bounds.end));
132
+ for (let at = s.text.indexOf(q); at >= 0; at = s.text.indexOf(q, at + 1))
133
+ out.push({ start: bounds.start + s.map[at], end: bounds.start + s.ends[at + q.length - 1] });
134
+ return out;
135
+ }
@@ -0,0 +1,50 @@
1
+ import type { DatabaseSync } from 'node:sqlite';
2
+ export interface Annotation {
3
+ id: string;
4
+ itemId: string;
5
+ kind: 'note' | 'highlight' | 'comment';
6
+ revisionId: string | null;
7
+ parentId: string | null;
8
+ anchor: unknown;
9
+ body: string;
10
+ createdAt: string;
11
+ updatedAt: string | null;
12
+ }
13
+ export declare function markdownFor(db: DatabaseSync, revisionId: string): string;
14
+ /**
15
+ * Highlight `quote` in the item's current revision. An ambiguous quote needs `occurrence` (0-based),
16
+ * so a highlight never silently lands on the wrong passage.
17
+ */
18
+ export declare function addHighlight(db: DatabaseSync, input: {
19
+ itemId: string;
20
+ quote: string;
21
+ occurrence?: number;
22
+ note?: string;
23
+ }): Annotation;
24
+ export declare function addComment(db: DatabaseSync, input: {
25
+ itemId: string;
26
+ body: string;
27
+ parentId?: string;
28
+ }): Annotation;
29
+ /**
30
+ * An imported note keeps a stable id per source note. Re-importing an unchanged note is a no-op;
31
+ * once the user edits or deletes it, the user's choice wins and the import skips it.
32
+ */
33
+ export declare function importNote(db: DatabaseSync, input: {
34
+ itemId: string;
35
+ revisionId: string;
36
+ namespace: string;
37
+ sourceKey: string;
38
+ noteId: string;
39
+ body: string;
40
+ }): void;
41
+ export declare function updateAnnotation(db: DatabaseSync, id: string, body: string): Annotation;
42
+ /** Tombstone, never a row delete, so another device learns the deletion. */
43
+ export declare function deleteAnnotation(db: DatabaseSync, id: string): void;
44
+ /** Reading state is one field per item; `updatedAt` travels with it. */
45
+ export declare function setReadingState(db: DatabaseSync, itemId: string, state: unknown): void;
46
+ export declare function readAnnotation(db: DatabaseSync, id: string): Annotation | undefined;
47
+ /** The one annotation reader: live annotations in creation order, highlights checked against their revision. */
48
+ export declare function listAnnotations(db: DatabaseSync, itemId: string): (Annotation & {
49
+ anchored?: boolean;
50
+ })[];
@@ -0,0 +1,89 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { anchorHolds, locateAll, textQuote } from './quote.js';
3
+ import { mutate } from '../store/changes.js';
4
+ import { StoreError } from '../store/types.js';
5
+ import { digest, text } from '../store/files.js';
6
+ const now = () => new Date().toISOString();
7
+ const fail = (code = 'invalid-input') => { throw new StoreError(code); };
8
+ export function markdownFor(db, revisionId) {
9
+ const row = db.prepare("SELECT markdown FROM representations WHERE revision_id=? AND kind='markdown'").get(revisionId);
10
+ return typeof row?.markdown === 'string' ? row.markdown : fail('unavailable');
11
+ }
12
+ /** Create through the log with the full row image, so a replay rebuilds the same row. */
13
+ function create(db, id, row) { mutate(db, 'annotation', id, 'created', row); }
14
+ /**
15
+ * Highlight `quote` in the item's current revision. An ambiguous quote needs `occurrence` (0-based),
16
+ * so a highlight never silently lands on the wrong passage.
17
+ */
18
+ export function addHighlight(db, input) {
19
+ text(input.quote, 4000);
20
+ if (input.note !== undefined)
21
+ text(input.note, 16384);
22
+ const item = db.prepare('SELECT current_revision FROM items WHERE id=? AND deleted_at IS NULL').get(input.itemId);
23
+ if (!item?.current_revision)
24
+ fail('unavailable');
25
+ const revisionId = String(item.current_revision), source = markdownFor(db, revisionId), matches = locateAll(source, input.quote);
26
+ const index = input.occurrence ?? (matches.length === 1 ? 0 : -1);
27
+ if (!Number.isSafeInteger(index) || index < 0 || index >= matches.length)
28
+ fail();
29
+ const anchor = textQuote(source, matches[index].start, matches[index].end), id = randomUUID();
30
+ create(db, id, { itemId: input.itemId, kind: 'highlight', revisionId, parentId: null, anchor, body: input.note ?? '', createdAt: now() });
31
+ return readAnnotation(db, id);
32
+ }
33
+ export function addComment(db, input) {
34
+ text(input.body, 16384);
35
+ if (!db.prepare('SELECT 1 FROM items WHERE id=? AND deleted_at IS NULL').get(input.itemId))
36
+ fail('unavailable');
37
+ if (input.parentId !== undefined) {
38
+ const parent = db.prepare('SELECT item_id,deleted_at FROM annotations WHERE id=?').get(input.parentId);
39
+ if (!parent || parent.item_id !== input.itemId || parent.deleted_at)
40
+ fail('unavailable');
41
+ }
42
+ const id = randomUUID();
43
+ create(db, id, { itemId: input.itemId, kind: 'comment', revisionId: null, parentId: input.parentId ?? null, anchor: null, body: input.body, createdAt: now() });
44
+ return readAnnotation(db, id);
45
+ }
46
+ /**
47
+ * An imported note keeps a stable id per source note. Re-importing an unchanged note is a no-op;
48
+ * once the user edits or deletes it, the user's choice wins and the import skips it.
49
+ */
50
+ export function importNote(db, input) {
51
+ const id = digest(JSON.stringify(['import-note-v1', input.namespace, input.sourceKey, input.noteId]));
52
+ const prior = db.prepare('SELECT item_id,body,updated_at,deleted_at FROM annotations WHERE id=?').get(id);
53
+ if (prior) {
54
+ if (prior.item_id !== input.itemId || prior.body !== input.body && prior.updated_at === null && prior.deleted_at === null)
55
+ fail('integrity');
56
+ return;
57
+ }
58
+ create(db, id, { itemId: input.itemId, kind: 'note', revisionId: input.revisionId, parentId: null, anchor: { kind: 'imported-note', sourceId: input.noteId }, body: input.body, createdAt: now() });
59
+ }
60
+ export function updateAnnotation(db, id, body) {
61
+ text(body, 16384);
62
+ const row = db.prepare('SELECT body,deleted_at FROM annotations WHERE id=?').get(id);
63
+ if (!row || row.deleted_at)
64
+ fail('unavailable');
65
+ if (row.body !== body)
66
+ mutate(db, 'annotation', id, 'body', { body, updatedAt: now() });
67
+ return readAnnotation(db, id);
68
+ }
69
+ /** Tombstone, never a row delete, so another device learns the deletion. */
70
+ export function deleteAnnotation(db, id) {
71
+ const row = db.prepare('SELECT deleted_at FROM annotations WHERE id=?').get(id);
72
+ if (!row)
73
+ fail('unavailable');
74
+ if (!row.deleted_at)
75
+ mutate(db, 'annotation', id, 'deleted', now());
76
+ }
77
+ /** Reading state is one field per item; `updatedAt` travels with it. */
78
+ export function setReadingState(db, itemId, state) { mutate(db, 'reading', itemId, 'state', { state, updatedAt: now() }); }
79
+ function annotationRow(r) { return { id: String(r.id), itemId: String(r.item_id), kind: r.kind, revisionId: r.revision_id === null ? null : String(r.revision_id), parentId: r.parent_id === null ? null : String(r.parent_id), anchor: JSON.parse(String(r.anchor_json)), body: String(r.body), createdAt: String(r.created_at), updatedAt: r.updated_at === null ? null : String(r.updated_at) }; }
80
+ export function readAnnotation(db, id) { const r = db.prepare('SELECT * FROM annotations WHERE id=? AND deleted_at IS NULL').get(id); return r ? annotationRow(r) : undefined; }
81
+ /** The one annotation reader: live annotations in creation order, highlights checked against their revision. */
82
+ export function listAnnotations(db, itemId) {
83
+ return db.prepare('SELECT * FROM annotations WHERE item_id=? AND deleted_at IS NULL ORDER BY created_at,rowid').all(itemId).map(r => {
84
+ const a = annotationRow(r);
85
+ if (a.kind !== 'highlight' || !a.revisionId)
86
+ return a;
87
+ return { ...a, anchored: anchorHolds(markdownFor(db, a.revisionId), a.anchor) };
88
+ });
89
+ }
@@ -2,6 +2,8 @@ export declare class InputError extends Error {
2
2
  readonly code = "invalid-input";
3
3
  constructor();
4
4
  }
5
+ /** The identity form of an http(s) locator; source keys and shareable URIs both use it. Its output pins every existing source key. */
6
+ export declare function normalizeWebLocator(locator: string): string;
5
7
  export declare function articleSource(locator: string, realm?: string): {
6
8
  sourceKey: string;
7
9
  locator: string;
@@ -1,4 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { campaignFields, filterQuery } from '../identity/query-policy.js';
2
3
  export class InputError extends Error {
3
4
  code = 'invalid-input';
4
5
  constructor() { super('invalid-input'); }
@@ -7,6 +8,14 @@ export class InputError extends Error {
7
8
  function webSource(locator, realm, kind) {
8
9
  if (Buffer.byteLength(locator) > 8192 || !realm || Buffer.byteLength(realm) > 256 || /[\u0000-\u001f\u007f]/u.test(locator + realm))
9
10
  throw new InputError();
11
+ const normalized = normalizeWebLocator(locator);
12
+ const sourceKey = createHash('sha256').update(JSON.stringify([kind, realm, normalized])).digest('hex');
13
+ return { sourceKey, locator, realm };
14
+ }
15
+ /** The identity form of an http(s) locator; source keys and shareable URIs both use it. Its output pins every existing source key. */
16
+ export function normalizeWebLocator(locator) {
17
+ if (Buffer.byteLength(locator) > 8192 || /[\u0000-\u001f\u007f]/u.test(locator))
18
+ throw new InputError();
10
19
  let url;
11
20
  try {
12
21
  url = new URL(locator);
@@ -16,24 +25,11 @@ function webSource(locator, realm, kind) {
16
25
  }
17
26
  if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || !url.hostname)
18
27
  throw new InputError();
19
- // Filter raw fields: URLSearchParams mutation rewrites unknown signed query bytes.
20
- if (url.search) {
21
- const fields = url.search.slice(1).split('&').filter(field => {
22
- let key;
23
- try {
24
- key = decodeURIComponent(field.split('=', 1)[0].replaceAll('+', ' '));
25
- }
26
- catch {
27
- return true;
28
- }
29
- return !['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'utm_id'].includes(key.toLowerCase());
30
- });
31
- url.search = fields.length ? '?' + fields.join('&') : '';
32
- }
28
+ // Undecodable keys are kept: they cannot be campaign fields.
29
+ if (url.search)
30
+ url.search = filterQuery(url.search, key => campaignFields.has(key));
33
31
  // URL normalizes host casing/default ports. Preserve query order, unknown fields and fragments.
34
- const normalized = url.href;
35
- const sourceKey = createHash('sha256').update(JSON.stringify([kind, realm, normalized])).digest('hex');
36
- return { sourceKey, locator, realm };
32
+ return url.href;
37
33
  }
38
34
  export function articleSource(locator, realm = 'public') { return webSource(locator, realm, 'article'); }
39
35
  /** A page and its media can be saved independently without changing existing article identities. */
@@ -1,5 +1,6 @@
1
1
  import { isAbsolute } from 'node:path';
2
2
  import { InputError } from '../app/source.js';
3
+ import { reviewSpec } from './digest.js';
3
4
  const specifications = {
4
5
  init: { count: 0, flags: {} },
5
6
  add: { count: 1, flags: { connection: 'value', kind: 'value', wait: 'boolean', language: 'value', 'force-local': 'boolean', 'allow-private-network': 'boolean' } },
@@ -16,10 +17,16 @@ const specifications = {
16
17
  export: { count: 1, flags: { output: 'value' } },
17
18
  backup: { count: 0, flags: { output: 'value' } },
18
19
  restore: { count: 1, flags: { into: 'value' } },
19
- gc: { count: 0, flags: { 'dry-run': 'boolean', apply: 'value' } },
20
+ gc: { count: 0, flags: { 'dry-run': 'boolean', apply: 'value', runtimes: 'boolean' } },
20
21
  worker: { count: 1, flags: { language: 'value', 'force-local': 'boolean' } },
21
- index: { count: -1, flags: { profile: 'value' } },
22
- import: { count: 1, flags: { apply: 'boolean', 'dry-run': 'boolean', limit: 'value', offset: 'value' } },
22
+ index: { count: -1, flags: { profile: 'value', keep: 'value', 'dry-run': 'boolean', vacuum: 'boolean' } },
23
+ visibility: { count: 2, flags: {} },
24
+ highlight: { count: 2, flags: { note: 'value', occurrence: 'value' } },
25
+ comment: { count: 2, flags: { on: 'value' } },
26
+ publish: { count: 0, flags: { 'dry-run': 'boolean', output: 'value', 'min-age-days': 'value', title: 'value', 'base-url': 'value' } },
27
+ digest: { count: 1, flags: { endpoint: 'value', model: 'value', 'check-support': 'boolean' } },
28
+ review: { count: 1, flags: { accept: 'value', reject: 'value' } },
29
+ import: { count: -1, flags: { apply: 'boolean', 'dry-run': 'boolean', limit: 'value', offset: 'value' } },
23
30
  };
24
31
  /** No shell grammar, implicit option coercion, duplicate flags, or unbounded argv. */
25
32
  export function parseArguments(argv) {
@@ -63,7 +70,7 @@ export function parseArguments(argv) {
63
70
  positional.push(arg);
64
71
  }
65
72
  const spec = command && specifications[command];
66
- if (!command || !spec || (command === 'queue' ? !(['status', 'recover'].includes(positional[0]) && positional.length === 1 || ['cancel', 'retry'].includes(positional[0]) && positional.length === 2) : command === 'index' ? !(['create', 'run'].includes(positional[0]) && positional.length === 1 || ['status', 'rollback'].includes(positional[0]) && positional.length === 2) : positional.length !== spec.count))
73
+ if (!command || !spec || (command === 'queue' ? !(['status', 'recover'].includes(positional[0]) && positional.length === 1 || ['cancel', 'retry'].includes(positional[0]) && positional.length === 2) : command === 'import' ? !(positional.length === 1 || positional[0] === 'jungle' && positional.length === 2) : command === 'index' ? !(['create', 'run', 'prune'].includes(positional[0]) && positional.length === 1 || ['status', 'rollback'].includes(positional[0]) && positional.length === 2) : positional.length !== spec.count))
67
74
  throw new InputError();
68
75
  if (Object.keys(flags).some(key => !['library', 'json'].includes(key) && !Object.hasOwn(spec.flags, key)))
69
76
  throw new InputError();
@@ -76,7 +83,7 @@ export function parseArguments(argv) {
76
83
  throw new InputError();
77
84
  if (flags.mode && !['lexical', 'hybrid'].includes(String(flags.mode)))
78
85
  throw new InputError();
79
- if (flags.limit && (!/^[1-9]\d{0,2}$/u.test(String(flags.limit)) || Number(flags.limit) > 100))
86
+ if (flags.limit && (!/^[1-9]\d{0,3}$/u.test(String(flags.limit)) || Number(flags.limit) > (command === 'import' && positional[0] === 'jungle' ? 1000 : 100)))
80
87
  throw new InputError();
81
88
  if (command === 'run' && !flags.drain || command === 'setup' && [flags.capture, flags.asr, flags.embedding].filter(Boolean).length !== 1)
82
89
  throw new InputError();
@@ -86,11 +93,19 @@ export function parseArguments(argv) {
86
93
  throw new InputError();
87
94
  if (command === 'restore' && (typeof flags.into !== 'string' || !isAbsolute(flags.into) || flags.library !== undefined || !isAbsolute(positional[0])))
88
95
  throw new InputError();
89
- if (command === 'gc' && (Boolean(flags['dry-run']) === Boolean(flags.apply) || flags.apply !== undefined && !isAbsolute(String(flags.apply))))
96
+ if (command === 'gc' && (flags.runtimes ? flags.apply !== undefined : Boolean(flags['dry-run']) === Boolean(flags.apply) || flags.apply !== undefined && !isAbsolute(String(flags.apply))))
90
97
  throw new InputError();
91
98
  if (command === 'worker' && !['install', 'status', 'uninstall', 'serve'].includes(positional[0]))
92
99
  throw new InputError();
93
- if (command === 'import' && (!isAbsolute(positional[0]) || flags.apply && flags['dry-run'] || flags.offset !== undefined && !/^(0|[1-9][0-9]{0,5})$/.test(String(flags.offset))))
100
+ if (command === 'import' && (!isAbsolute(positional.at(-1)) || flags.apply && flags['dry-run'] || flags.offset !== undefined && !/^(0|[1-9][0-9]{0,5})$/.test(String(flags.offset))))
101
+ throw new InputError();
102
+ if (command === 'visibility' && !['public', 'private', 'default'].includes(positional[1]) || command === 'publish' && (Boolean(flags['dry-run']) === Boolean(flags.output) || flags.output !== undefined && (typeof flags.output !== 'string' || !isAbsolute(flags.output)) || (flags.title !== undefined || flags['base-url'] !== undefined) && !flags.output || flags['min-age-days'] !== undefined && !/^(0|[1-9][0-9]{0,3})$/.test(String(flags['min-age-days'])) || Number(flags['min-age-days'] ?? 0) > 3650 || flags.title !== undefined && String(flags.title).length > 200 || flags['base-url'] !== undefined && !/^https:\/\//.test(String(flags['base-url']))))
103
+ throw new InputError();
104
+ if (command === 'digest' && (Boolean(flags.endpoint) !== Boolean(flags.model) || flags['check-support'] && !flags.endpoint || flags.model !== undefined && !/^[\w.:/@+-]{1,256}$/.test(String(flags.model))))
105
+ throw new InputError();
106
+ if (command === 'review' && (flags.accept === undefined && flags.reject === undefined || [flags.accept, flags.reject].some(v => v !== undefined && !reviewSpec.test(String(v)))))
107
+ throw new InputError();
108
+ if (flags.occurrence !== undefined && !/^(0|[1-9][0-9]{0,4})$/.test(String(flags.occurrence)))
94
109
  throw new InputError();
95
110
  if (flags.language !== undefined && flags.language !== 'en')
96
111
  throw new InputError();
@@ -100,6 +115,8 @@ export function parseArguments(argv) {
100
115
  throw new InputError();
101
116
  if ((flags.language || flags['force-local']) && (['add', 'refresh'].includes(command) && !flags.wait || command === 'worker' && !['serve', 'install'].includes(positional[0])))
102
117
  throw new InputError();
118
+ if (command === 'index' && positional[0] !== 'prune' && (flags.keep !== undefined || flags['dry-run'] || flags.vacuum) || flags.keep !== undefined && !/^(0|[1-9]|1[0-6])$/.test(String(flags.keep)))
119
+ throw new InputError();
103
120
  if (flags.profile && (!(command === 'search' || command === 'index' && positional[0] === 'create') || !/^[a-zA-Z0-9_-]{1,128}$/.test(String(flags.profile))))
104
121
  throw new InputError();
105
122
  return { command, positional, flags, library: library, json: flags.json === true || flags.format === 'json' };
@@ -0,0 +1,10 @@
1
+ import type { Derivation, DigestReview } from '../store/foundation.js';
2
+ /** Review targets: `all`, `gist`, `idea:N` or `quote:N.M` (0-based), comma-separated. */
3
+ export declare const reviewSpec: RegExp;
4
+ /**
5
+ * Merge `--accept` and `--reject` targets into the derivation's existing review. `all` applies first and specific
6
+ * targets override it; a target named under both flags is ambiguous and rejected.
7
+ */
8
+ export declare function reviewFromSpec(d: Derivation, accept?: string, reject?: string): DigestReview;
9
+ /** Plain-text digest with the review targets `sponge review` accepts. */
10
+ export declare function digestText(d: Derivation): string;
@@ -0,0 +1,63 @@
1
+ import { InputError } from '../app/source.js';
2
+ /** Review targets: `all`, `gist`, `idea:N` or `quote:N.M` (0-based), comma-separated. */
3
+ export const reviewSpec = /^(?:all|gist|idea:\d{1,2}|quote:\d{1,2}\.\d)(?:,(?:all|gist|idea:\d{1,2}|quote:\d{1,2}\.\d)){0,63}$/;
4
+ /**
5
+ * Merge `--accept` and `--reject` targets into the derivation's existing review. `all` applies first and specific
6
+ * targets override it; a target named under both flags is ambiguous and rejected.
7
+ */
8
+ export function reviewFromSpec(d, accept, reject) {
9
+ if (accept === undefined && reject === undefined)
10
+ throw new InputError();
11
+ const ideas = d.output.ideas, review = { gist: d.review.gist, ideas: ideas.map((_, i) => d.review.ideas?.[i] ?? null), quotes: ideas.map((idea, i) => idea.quotes.map((_, j) => d.review.quotes?.[i]?.[j] ?? null)) };
12
+ const parse = (spec) => { if (spec === undefined)
13
+ return []; if (!reviewSpec.test(spec))
14
+ throw new InputError(); return [...new Set(spec.split(','))]; };
15
+ const a = parse(accept), r = parse(reject);
16
+ if (a.some(t => r.includes(t)))
17
+ throw new InputError();
18
+ const set = (target, status) => {
19
+ if (target === 'all') {
20
+ review.gist = status;
21
+ review.ideas = review.ideas.map(() => status);
22
+ review.quotes = review.quotes.map(q => q.map(() => status));
23
+ return;
24
+ }
25
+ if (target === 'gist') {
26
+ review.gist = status;
27
+ return;
28
+ }
29
+ const [, kind, i, j] = /^(idea|quote):(\d+)(?:\.(\d+))?$/.exec(target), n = Number(i);
30
+ if (n >= ideas.length)
31
+ throw new InputError();
32
+ if (kind === 'idea') {
33
+ review.ideas[n] = status;
34
+ return;
35
+ }
36
+ if (Number(j) >= ideas[n].quotes.length)
37
+ throw new InputError();
38
+ review.quotes[n][Number(j)] = status;
39
+ };
40
+ for (const [targets, status] of [[a, 'accepted'], [r, 'rejected']])
41
+ for (const t of targets)
42
+ if (t === 'all')
43
+ set(t, status);
44
+ for (const [targets, status] of [[a, 'accepted'], [r, 'rejected']])
45
+ for (const t of targets)
46
+ if (t !== 'all')
47
+ set(t, status);
48
+ return { ...(review.gist ? { gist: review.gist } : {}), ideas: review.ideas, quotes: review.quotes };
49
+ }
50
+ const mark = (s) => s === 'accepted' ? '[accepted]' : s === 'rejected' ? '[rejected]' : '[draft]';
51
+ /** Plain-text digest with the review targets `sponge review` accepts. */
52
+ export function digestText(d) {
53
+ const o = d.output, producer = d.producer;
54
+ const lines = [`Digest ${d.id} (${o.form}${producer?.model?.model ? `, ${producer.model.model}` : ''}${producer?.promptVersion ? `, ${producer.promptVersion}` : ''})`, '', `gist ${mark(d.review.gist)} ${o.gist.lead}`];
55
+ if (o.gist.context)
56
+ lines.push(` ${o.gist.context}`);
57
+ o.ideas.forEach((idea, i) => {
58
+ lines.push('', `idea:${i} ${mark(d.review.ideas?.[i])} ${idea.claim}`, ` ${idea.kind}; ${idea.voice.type === 'author' ? 'author' : `${idea.voice.type}: ${idea.voice.name}`}`, ` ${idea.detail}`);
59
+ idea.quotes.forEach((q, j) => lines.push(` quote:${i}.${j} ${mark(d.review.quotes?.[i]?.[j])} “${q.text.replace(/\s+/g, ' ')}”${q.speaker ? ` (${q.speaker})` : ''}`));
60
+ });
61
+ lines.push('', `Tags: ${o.tags.join(', ')}`);
62
+ return lines.join('\n');
63
+ }
@@ -1,4 +1,15 @@
1
1
  import type { LibraryStore } from '../store/index.js';
2
+ import type { CaptureRuntime } from '../acquisition/index.js';
2
3
  import { type ImportOptions, type ImportReport } from '../importers/apply.js';
4
+ import { type JungleImportReport } from '../importers/jungle.js';
3
5
  /** A source manifest is explicit; default execution produces a private dry-run report. */
4
6
  export declare function importAction(store: LibraryStore, path: string, options: ImportOptions): Promise<ImportReport>;
7
+ /** Jungle is read in place; only an applied import needs the capture runtime, to reverify each bundle. */
8
+ export declare function jungleAction(store: LibraryStore, path: string, options: {
9
+ dryRun: boolean;
10
+ runtime?: CaptureRuntime;
11
+ limit: number;
12
+ offset: number;
13
+ signal?: AbortSignal;
14
+ }): Promise<JungleImportReport>;
15
+ export declare function jungleText(r: JungleImportReport): string;
@@ -1,3 +1,13 @@
1
1
  import { importManifest } from '../importers/apply.js';
2
+ import { releasedBundleVerifier } from '../importers/inspect.js';
3
+ import { importJungle } from '../importers/jungle.js';
2
4
  /** A source manifest is explicit; default execution produces a private dry-run report. */
3
5
  export function importAction(store, path, options) { return importManifest(store, path, options); }
6
+ /** Jungle is read in place; only an applied import needs the capture runtime, to reverify each bundle. */
7
+ export function jungleAction(store, path, options) {
8
+ return importJungle(store, path, { dryRun: options.dryRun, limit: options.limit, offset: options.offset, ...(options.signal ? { signal: options.signal } : {}), ...(options.runtime ? { verify: releasedBundleVerifier(options.runtime, store.root, options.signal) } : {}) });
9
+ }
10
+ const tally = (m) => Object.entries(m).sort().map(([k, n]) => `${k} ${n}`).join(', ') || 'none';
11
+ export function jungleText(r) {
12
+ return [`Jungle ${r.dryRun ? 'dry run (captures verified on --apply)' : 'import'}: ${r.entries.length} of ${r.notes} notes`, `Items: ${r.counts.newItems} new, ${r.counts.existingItems} existing`, `Editorial: ${tally(r.counts.editorial)}`, `Digest v1: ${tally(r.counts.v1)}`, `Visibility: ${tally(r.counts.visibility)}`, `Skipped: ${tally(r.counts.skipped)}`, ...(r.nextOffset !== null ? [`Next offset: ${r.nextOffset}`] : [])].join('\n');
13
+ }
@@ -4,4 +4,7 @@ export declare function indexAction(store: LibraryStore, action: string, id: str
4
4
  runtime?: EmbeddingRuntime;
5
5
  profile?: string;
6
6
  signal?: AbortSignal;
7
+ keep?: number;
8
+ dryRun?: boolean;
9
+ vacuum?: boolean;
7
10
  }): Promise<Record<string, unknown>>;
@@ -1,7 +1,8 @@
1
- import { embeddingIdentity, embeddingFingerprint } from '../inference/embedding/index.js';
1
+ import { indexSpace } from '../inference/embedding/index.js';
2
2
  import { drainIndexQueue } from '../indexing/runner.js';
3
3
  import { StoreError } from '../store/index.js';
4
4
  import { NativeCatalogError } from '../models/catalog.js';
5
+ import { pruneIndex } from '../indexing/prune-run.js';
5
6
  export async function indexAction(store, action, id, options = {}) {
6
7
  if (action === 'status')
7
8
  return { generation: store.indexStatus(id) };
@@ -11,10 +12,12 @@ export async function indexAction(store, action, id, options = {}) {
11
12
  throw new StoreError('invalid-input');
12
13
  return { generation: store.activateIndex(id) };
13
14
  }
15
+ if (action === 'prune')
16
+ return { prune: await pruneIndex(store, { keep: options.keep, dryRun: options.dryRun, vacuum: options.vacuum }) };
14
17
  if (!options.runtime)
15
18
  throw new NativeCatalogError('runtime-missing');
16
19
  if (action === 'create') {
17
- const runtime = options.runtime, generation = store.createIndexGeneration({ profileId: options.profile ?? 'default', fingerprint: embeddingFingerprint(runtime.helperSha256), identity: { ...embeddingIdentity, helperSha256: runtime.helperSha256 }, dimensions: embeddingIdentity.dimensions });
20
+ const space = indexSpace(options.runtime.helperSha256), generation = store.createIndexGeneration({ profileId: options.profile ?? 'default', fingerprint: space.fingerprint, identity: space.identity, dimensions: space.dimensions });
18
21
  return { generation, job: store.submitIndexJob(generation.id) };
19
22
  }
20
23
  return { results: await drainIndexQueue(store, { runtime: options.runtime, signal: options.signal }) };