@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
package/README.md CHANGED
@@ -101,6 +101,36 @@ full-text search. Use `index create --profile NAME` and `search QUERY --profile
101
101
  to keep separate search profiles. Model changes create separate index generations; a new one
102
102
  becomes active only after it covers the current library. `index rollback
103
103
  GENERATION` requires a complete generation compatible with the current revisions.
104
+ `index prune --dry-run` lists what pruning would delete; `index prune` deletes the
105
+ vectors of cancelled, failed and retired generations, keeping the active and any
106
+ building generation plus the most recent rollback-ready one per profile (`--keep N`,
107
+ 0-16). It refuses while index work, cleanup or a backup is running. Freed pages are
108
+ reused; `--vacuum` also shrinks the file after checking free space. Rollback to a
109
+ pruned generation needs a rebuild.
110
+
111
+ ### Digests
112
+
113
+ A digest (Digest v2) is a short gist led by one sentence and 3–5 ideas, each a
114
+ claim with detail and 0–2 verbatim quotes, with 1–6 quotes in all.
115
+ A local model proposes it; Sponge checks every rule against the captured text
116
+ and stores only a passing draft. Drafting is opt-in and needs a model server you
117
+ start yourself, such as llama.cpp `llama-server`, LM Studio or Ollama:
118
+
119
+ ```sh
120
+ llama-server -m gemma-4-12b-it-Q4_K_M.gguf --port 8080
121
+ sponge digest ITEM --endpoint http://127.0.0.1:8080 --model NAME
122
+ sponge digest ITEM
123
+ sponge review DIGEST --accept all --reject quote:1.0
124
+ ```
125
+
126
+ `--model` must match an id the server lists at `/v1/models`. Endpoints must be
127
+ literal loopback HTTP (`127.0.0.1` or `[::1]`); Sponge refuses anything else so
128
+ captured text never leaves the machine, and never starts or downloads a model.
129
+ Suggested models are Gemma 4 12B on 16 GB Macs and Gemma 4 E4B on 8 GB, with
130
+ Qwen3.5 9B or 4B as alternatives. Rerunning with the same inputs returns the stored
131
+ draft. Review targets are `all`, `gist`, `idea:N` and `quote:N.M` (0-based); only
132
+ accepted fields can be published. `--check-support` also requires each quote to
133
+ support its claim by local embedding similarity. See [digest drafting](src/digest/README.md).
104
134
 
105
135
  ### Export, recovery and background work
106
136
 
@@ -110,6 +140,7 @@ sponge backup --output /absolute/new/library.sponge
110
140
  sponge restore /absolute/library.sponge --into /absolute/new/library
111
141
  sponge gc --dry-run
112
142
  sponge gc --apply /absolute/path/to/report.json
143
+ sponge gc --runtimes --dry-run
113
144
  sponge worker install --language en
114
145
  sponge worker status
115
146
  sponge worker uninstall
@@ -119,6 +150,10 @@ Exports contain Markdown, images and saved notes. Backups preserve revisions,
119
150
  assets, reading state and notes. Restore requires a new directory, verifies every
120
151
  file and leaves connections unresolved; runtimes and credentials are excluded.
121
152
  Garbage collection removes only unreferenced blobs after rechecking the report.
153
+ `gc --runtimes` removes capture runtimes and model packages this Sponge no longer
154
+ pins, plus settled worker journals; `--dry-run` only lists them. It holds worker
155
+ custody, keeps models an active or building index generation needs, and refuses
156
+ symlinks and anything in use. Successful `setup` runs the same collection.
122
157
 
123
158
  The background worker is opt-in and processes work sequentially. Its LaunchAgent
124
159
  pins the installed command; after an upgrade, reinstall the worker to update that
@@ -126,6 +161,25 @@ binding. Uninstall stops the worker and preserves the library. In-session startu
126
161
  and cleanup are checked; fresh-login startup remains unqualified. See
127
162
  [importing existing captures](docs/importing.md) for a preview-first import flow.
128
163
 
164
+ ### Public reading list
165
+
166
+ ```sh
167
+ sponge publish --dry-run
168
+ sponge publish --output /absolute/site --title 'My reads' --base-url https://reads.example.com/ --min-age-days 7
169
+ ```
170
+
171
+ Items captured from public sources are public by default; `visibility ITEM private`
172
+ hides one. `publish --output` writes a static site: a JSON Feed (`feed.json`,
173
+ newest 100), monthly shards under `items/`, an Atom `feed.xml`, and an
174
+ `index.html` that uses the bundled `<sponge-list>` element. An Ed25519 key created
175
+ in the library on first publish signs a manifest of every file's hash; the
176
+ private key stays in the library. The same library state gives byte-identical
177
+ files. `--min-age-days N` holds back items listed in the last N days. Nothing is
178
+ uploaded. Embed the list elsewhere with
179
+ `<sponge-list src="https://reads.example.com/feed.json" verify></sponge-list>` and
180
+ `sponge-list.js`; the host must allow cross-origin reads, and `verify` needs
181
+ HTTPS. See [the decision record](docs/decisions/publish-feed.md).
182
+
129
183
  ### Selected authenticated connections
130
184
 
131
185
  The initial development interface accepts private `connections.json` in the
@@ -0,0 +1,3 @@
1
+ /** Additive sync and publishing foundation: shareable URIs, visibility, change log, annotations, content-addressed derivations. */
2
+ export declare const version = 5;
3
+ export declare const sql = "\nCREATE TABLE item_uris(item_id TEXT NOT NULL REFERENCES items(id),uri TEXT NOT NULL CHECK(length(uri) BETWEEN 3 AND 8192),role TEXT NOT NULL CHECK(role IN ('primary','alias')),resolvable INTEGER NOT NULL CHECK(resolvable IN (0,1)),origin TEXT NOT NULL CHECK(origin IN ('locator','revision','metadata','user')),PRIMARY KEY(item_id,uri)) STRICT;\nCREATE UNIQUE INDEX item_uris_primary ON item_uris(item_id) WHERE role='primary';\nCREATE INDEX item_uris_uri ON item_uris(uri);\nALTER TABLE items ADD COLUMN visibility TEXT CHECK(visibility IN ('public','private'));\nALTER TABLE annotations ADD COLUMN kind TEXT NOT NULL DEFAULT 'note' CHECK(kind IN ('note','highlight','comment'));\nALTER TABLE annotations ADD COLUMN parent_id TEXT REFERENCES annotations(id);\nALTER TABLE annotations ADD COLUMN updated_at TEXT;\nALTER TABLE annotations ADD COLUMN deleted_at TEXT;\nCREATE TABLE device(id INTEGER PRIMARY KEY CHECK(id=1),device_id TEXT NOT NULL CHECK(length(device_id)=32),clock_ms INTEGER NOT NULL DEFAULT 0 CHECK(clock_ms>=0),clock_counter INTEGER NOT NULL DEFAULT 0 CHECK(clock_counter>=0)) STRICT;\nINSERT INTO device(id,device_id) VALUES(1,lower(hex(randomblob(16))));\nCREATE TABLE changes(seq INTEGER PRIMARY KEY AUTOINCREMENT,id TEXT NOT NULL UNIQUE,device_id TEXT NOT NULL,hlc TEXT NOT NULL,entity TEXT NOT NULL CHECK(entity IN ('item','annotation','reading','derivation')),entity_id TEXT NOT NULL,field TEXT NOT NULL,value_json TEXT NOT NULL,created_at TEXT NOT NULL) STRICT;\nCREATE INDEX changes_entity ON changes(entity,entity_id,field,hlc);\nCREATE TABLE derivations(id TEXT PRIMARY KEY,item_id TEXT NOT NULL REFERENCES items(id),revision_id TEXT NOT NULL REFERENCES revisions(id),kind TEXT NOT NULL CHECK(kind IN ('digest')),schema_version INTEGER NOT NULL CHECK(schema_version>0),producer_json TEXT NOT NULL,input_hash TEXT NOT NULL CHECK(length(input_hash)=64),output_json TEXT NOT NULL,review_json TEXT NOT NULL DEFAULT '{}',created_at TEXT NOT NULL) STRICT;\nCREATE INDEX derivations_revision ON derivations(revision_id,kind,input_hash);\nCREATE INDEX derivations_item ON derivations(item_id,kind,created_at);\n";
@@ -0,0 +1,19 @@
1
+ /** Additive sync and publishing foundation: shareable URIs, visibility, change log, annotations, content-addressed derivations. */
2
+ export const version = 5;
3
+ export const sql = `
4
+ CREATE TABLE item_uris(item_id TEXT NOT NULL REFERENCES items(id),uri TEXT NOT NULL CHECK(length(uri) BETWEEN 3 AND 8192),role TEXT NOT NULL CHECK(role IN ('primary','alias')),resolvable INTEGER NOT NULL CHECK(resolvable IN (0,1)),origin TEXT NOT NULL CHECK(origin IN ('locator','revision','metadata','user')),PRIMARY KEY(item_id,uri)) STRICT;
5
+ CREATE UNIQUE INDEX item_uris_primary ON item_uris(item_id) WHERE role='primary';
6
+ CREATE INDEX item_uris_uri ON item_uris(uri);
7
+ ALTER TABLE items ADD COLUMN visibility TEXT CHECK(visibility IN ('public','private'));
8
+ ALTER TABLE annotations ADD COLUMN kind TEXT NOT NULL DEFAULT 'note' CHECK(kind IN ('note','highlight','comment'));
9
+ ALTER TABLE annotations ADD COLUMN parent_id TEXT REFERENCES annotations(id);
10
+ ALTER TABLE annotations ADD COLUMN updated_at TEXT;
11
+ ALTER TABLE annotations ADD COLUMN deleted_at TEXT;
12
+ CREATE TABLE device(id INTEGER PRIMARY KEY CHECK(id=1),device_id TEXT NOT NULL CHECK(length(device_id)=32),clock_ms INTEGER NOT NULL DEFAULT 0 CHECK(clock_ms>=0),clock_counter INTEGER NOT NULL DEFAULT 0 CHECK(clock_counter>=0)) STRICT;
13
+ INSERT INTO device(id,device_id) VALUES(1,lower(hex(randomblob(16))));
14
+ CREATE TABLE changes(seq INTEGER PRIMARY KEY AUTOINCREMENT,id TEXT NOT NULL UNIQUE,device_id TEXT NOT NULL,hlc TEXT NOT NULL,entity TEXT NOT NULL CHECK(entity IN ('item','annotation','reading','derivation')),entity_id TEXT NOT NULL,field TEXT NOT NULL,value_json TEXT NOT NULL,created_at TEXT NOT NULL) STRICT;
15
+ CREATE INDEX changes_entity ON changes(entity,entity_id,field,hlc);
16
+ CREATE TABLE derivations(id TEXT PRIMARY KEY,item_id TEXT NOT NULL REFERENCES items(id),revision_id TEXT NOT NULL REFERENCES revisions(id),kind TEXT NOT NULL CHECK(kind IN ('digest')),schema_version INTEGER NOT NULL CHECK(schema_version>0),producer_json TEXT NOT NULL,input_hash TEXT NOT NULL CHECK(length(input_hash)=64),output_json TEXT NOT NULL,review_json TEXT NOT NULL DEFAULT '{}',created_at TEXT NOT NULL) STRICT;
17
+ CREATE INDEX derivations_revision ON derivations(revision_id,kind,input_hash);
18
+ CREATE INDEX derivations_item ON derivations(item_id,kind,created_at);
19
+ `;
@@ -0,0 +1,3 @@
1
+ /** Storage diet: readable FTS keeps no text copy; representations.markdown and items.title stay authoritative. */
2
+ export declare const version = 6;
3
+ export declare const sql = "\nCREATE TABLE readable_fts_rows(id INTEGER PRIMARY KEY,item_id TEXT NOT NULL UNIQUE REFERENCES items(id),revision_id TEXT NOT NULL REFERENCES revisions(id)) STRICT;\nDROP TABLE readable_fts;\nCREATE VIRTUAL TABLE readable_fts USING fts5(title,markdown,content='',contentless_delete=1,tokenize='unicode61');\nINSERT INTO readable_fts_rows(item_id,revision_id) SELECT i.id,i.current_revision FROM items i WHERE i.deleted_at IS NULL AND i.current_revision IS NOT NULL AND EXISTS(SELECT 1 FROM representations p WHERE p.revision_id=i.current_revision AND p.kind='markdown' AND p.markdown IS NOT NULL) ORDER BY i.created_at,i.id;\nINSERT INTO readable_fts(rowid,title,markdown) SELECT m.id,i.title,(SELECT p.markdown FROM representations p WHERE p.revision_id=m.revision_id AND p.kind='markdown' AND p.markdown IS NOT NULL ORDER BY p.rowid LIMIT 1) FROM readable_fts_rows m JOIN items i ON i.id=m.item_id ORDER BY m.id;\n";
@@ -0,0 +1,9 @@
1
+ /** Storage diet: readable FTS keeps no text copy; representations.markdown and items.title stay authoritative. */
2
+ export const version = 6;
3
+ export const sql = `
4
+ CREATE TABLE readable_fts_rows(id INTEGER PRIMARY KEY,item_id TEXT NOT NULL UNIQUE REFERENCES items(id),revision_id TEXT NOT NULL REFERENCES revisions(id)) STRICT;
5
+ DROP TABLE readable_fts;
6
+ CREATE VIRTUAL TABLE readable_fts USING fts5(title,markdown,content='',contentless_delete=1,tokenize='unicode61');
7
+ INSERT INTO readable_fts_rows(item_id,revision_id) SELECT i.id,i.current_revision FROM items i WHERE i.deleted_at IS NULL AND i.current_revision IS NOT NULL AND EXISTS(SELECT 1 FROM representations p WHERE p.revision_id=i.current_revision AND p.kind='markdown' AND p.markdown IS NOT NULL) ORDER BY i.created_at,i.id;
8
+ INSERT INTO readable_fts(rowid,title,markdown) SELECT m.id,i.title,(SELECT p.markdown FROM representations p WHERE p.revision_id=m.revision_id AND p.kind='markdown' AND p.markdown IS NOT NULL ORDER BY p.rowid LIMIT 1) FROM readable_fts_rows m JOIN items i ON i.id=m.item_id ORDER BY m.id;
9
+ `;
@@ -0,0 +1,3 @@
1
+ /** Editorial derivations: a reviewed source note (Jungle Reading) kept losslessly beside Digest v1. SQLite cannot widen a CHECK in place, so the table is rebuilt; rows and ids are unchanged. */
2
+ export declare const version = 7;
3
+ export declare const sql = "\nCREATE TABLE derivations_v7(id TEXT PRIMARY KEY,item_id TEXT NOT NULL REFERENCES items(id),revision_id TEXT NOT NULL REFERENCES revisions(id),kind TEXT NOT NULL CHECK(kind IN ('digest','editorial')),schema_version INTEGER NOT NULL CHECK(schema_version>0),producer_json TEXT NOT NULL,input_hash TEXT NOT NULL CHECK(length(input_hash)=64),output_json TEXT NOT NULL,review_json TEXT NOT NULL DEFAULT '{}',created_at TEXT NOT NULL) STRICT;\nINSERT INTO derivations_v7(rowid,id,item_id,revision_id,kind,schema_version,producer_json,input_hash,output_json,review_json,created_at) SELECT rowid,id,item_id,revision_id,kind,schema_version,producer_json,input_hash,output_json,review_json,created_at FROM derivations ORDER BY rowid;\nDROP TABLE derivations;\nALTER TABLE derivations_v7 RENAME TO derivations;\nCREATE INDEX derivations_revision ON derivations(revision_id,kind,input_hash);\nCREATE INDEX derivations_item ON derivations(item_id,kind,created_at);\n";
@@ -0,0 +1,10 @@
1
+ /** Editorial derivations: a reviewed source note (Jungle Reading) kept losslessly beside Digest v1. SQLite cannot widen a CHECK in place, so the table is rebuilt; rows and ids are unchanged. */
2
+ export const version = 7;
3
+ export const sql = `
4
+ CREATE TABLE derivations_v7(id TEXT PRIMARY KEY,item_id TEXT NOT NULL REFERENCES items(id),revision_id TEXT NOT NULL REFERENCES revisions(id),kind TEXT NOT NULL CHECK(kind IN ('digest','editorial')),schema_version INTEGER NOT NULL CHECK(schema_version>0),producer_json TEXT NOT NULL,input_hash TEXT NOT NULL CHECK(length(input_hash)=64),output_json TEXT NOT NULL,review_json TEXT NOT NULL DEFAULT '{}',created_at TEXT NOT NULL) STRICT;
5
+ INSERT INTO derivations_v7(rowid,id,item_id,revision_id,kind,schema_version,producer_json,input_hash,output_json,review_json,created_at) SELECT rowid,id,item_id,revision_id,kind,schema_version,producer_json,input_hash,output_json,review_json,created_at FROM derivations ORDER BY rowid;
6
+ DROP TABLE derivations;
7
+ ALTER TABLE derivations_v7 RENAME TO derivations;
8
+ CREATE INDEX derivations_revision ON derivations(revision_id,kind,input_hash);
9
+ CREATE INDEX derivations_item ON derivations(item_id,kind,created_at);
10
+ `;
@@ -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;