@hraness/sponge 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +54 -0
  2. package/dist/migrations/005.d.ts +3 -0
  3. package/dist/migrations/005.js +19 -0
  4. package/dist/migrations/006.d.ts +3 -0
  5. package/dist/migrations/006.js +9 -0
  6. package/dist/migrations/007.d.ts +3 -0
  7. package/dist/migrations/007.js +10 -0
  8. package/dist/src/annotations/quote.d.ts +32 -0
  9. package/dist/src/annotations/quote.js +135 -0
  10. package/dist/src/annotations/store.d.ts +50 -0
  11. package/dist/src/annotations/store.js +89 -0
  12. package/dist/src/app/source.d.ts +2 -0
  13. package/dist/src/app/source.js +13 -17
  14. package/dist/src/cli/arguments.js +24 -7
  15. package/dist/src/cli/digest.d.ts +10 -0
  16. package/dist/src/cli/digest.js +63 -0
  17. package/dist/src/cli/import.d.ts +11 -0
  18. package/dist/src/cli/import.js +10 -0
  19. package/dist/src/cli/indexing.d.ts +3 -0
  20. package/dist/src/cli/indexing.js +5 -2
  21. package/dist/src/cli/main.js +94 -8
  22. package/dist/src/cli/runtimes.d.ts +10 -0
  23. package/dist/src/cli/runtimes.js +32 -0
  24. package/dist/src/digest/editorial.d.ts +54 -0
  25. package/dist/src/digest/editorial.js +87 -0
  26. package/dist/src/digest/endpoint.d.ts +20 -0
  27. package/dist/src/digest/endpoint.js +103 -0
  28. package/dist/src/digest/pipeline.d.ts +148 -0
  29. package/dist/src/digest/pipeline.js +399 -0
  30. package/dist/src/digest/schema.d.ts +126 -0
  31. package/dist/src/digest/schema.js +138 -0
  32. package/dist/src/digest/store.d.ts +57 -0
  33. package/dist/src/digest/store.js +79 -0
  34. package/dist/src/identity/query-policy.d.ts +21 -0
  35. package/dist/src/identity/query-policy.js +55 -0
  36. package/dist/src/identity/uri.d.ts +30 -0
  37. package/dist/src/identity/uri.js +94 -0
  38. package/dist/src/identity/visibility.d.ts +24 -0
  39. package/dist/src/identity/visibility.js +75 -0
  40. package/dist/src/importers/apply.d.ts +10 -0
  41. package/dist/src/importers/apply.js +25 -22
  42. package/dist/src/importers/jungle-digest.d.ts +107 -0
  43. package/dist/src/importers/jungle-digest.js +196 -0
  44. package/dist/src/importers/jungle-store.d.ts +45 -0
  45. package/dist/src/importers/jungle-store.js +96 -0
  46. package/dist/src/importers/jungle.d.ts +70 -0
  47. package/dist/src/importers/jungle.js +142 -0
  48. package/dist/src/indexing/index.d.ts +7 -0
  49. package/dist/src/indexing/index.js +31 -8
  50. package/dist/src/indexing/prune-run.d.ts +22 -0
  51. package/dist/src/indexing/prune-run.js +67 -0
  52. package/dist/src/indexing/prune.d.ts +30 -0
  53. package/dist/src/indexing/prune.js +95 -0
  54. package/dist/src/indexing/runner.js +5 -5
  55. package/dist/src/inference/embedding/index.d.ts +25 -0
  56. package/dist/src/inference/embedding/index.js +11 -0
  57. package/dist/src/models/artifacts.js +175 -33
  58. package/dist/src/portability/backup.js +3 -1
  59. package/dist/src/portability/database.js +3 -0
  60. package/dist/src/publish/component.d.ts +8 -0
  61. package/dist/src/publish/component.js +114 -0
  62. package/dist/src/publish/feed.d.ts +24 -0
  63. package/dist/src/publish/feed.js +77 -0
  64. package/dist/src/publish/identity.d.ts +6 -0
  65. package/dist/src/publish/identity.js +68 -0
  66. package/dist/src/publish/index.d.ts +34 -0
  67. package/dist/src/publish/index.js +137 -0
  68. package/dist/src/publish/projection.d.ts +29 -0
  69. package/dist/src/publish/projection.js +43 -0
  70. package/dist/src/search/hybrid.js +2 -2
  71. package/dist/src/search/lexical.d.ts +11 -0
  72. package/dist/src/search/lexical.js +6 -0
  73. package/dist/src/search/query.d.ts +7 -3
  74. package/dist/src/search/query.js +52 -33
  75. package/dist/src/search/vector.d.ts +1 -1
  76. package/dist/src/search/vector.js +6 -5
  77. package/dist/src/store/changes.d.ts +42 -0
  78. package/dist/src/store/changes.js +178 -0
  79. package/dist/src/store/foundation.d.ts +7 -0
  80. package/dist/src/store/foundation.js +7 -0
  81. package/dist/src/store/index.d.ts +69 -0
  82. package/dist/src/store/index.js +134 -51
  83. package/dist/src/worker/runtime-gc.d.ts +30 -0
  84. package/dist/src/worker/runtime-gc.js +296 -0
  85. package/native/runtime-manifest.json +4 -4
  86. package/package.json +1 -1
@@ -0,0 +1,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 ? [` ${[idea.kind, idea.voice && (idea.voice.type === 'author' ? 'author' : `${idea.voice.type}: ${idea.voice.name}`)].filter(Boolean).join('; ')}`] : []), ` ${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.attribution ?? q.speaker ? ` (${q.attribution ?? q.speaker})` : ''}`));
60
+ });
61
+ lines.push('', `Tags: ${o.tags.join(', ') || 'none'}`);
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 v2: ${tally(r.counts.digest)}`, ...(Object.keys(r.counts.digestIssues).length ? [`Not convertible because: ${tally(r.counts.digestIssues)}`] : []), `Quotes: ${r.counts.quotes.assigned} assigned, dropped ${tally(r.counts.quotes.dropped)}`, `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 }) };
@@ -15,10 +15,16 @@ import { workerAction, serveWorker, hasDueAcquisition } from './worker.js';
15
15
  import { nativeSetupPlan, provisionNative, inspectNative } from '../models/runtime.js';
16
16
  import { nativeDownloadAuthGuidance } from '../models/download.js';
17
17
  import { drainIndexQueue } from '../indexing/runner.js';
18
- import { createQueryEmbedder } from '../search/query.js';
19
18
  import { indexAction } from './indexing.js';
20
- import { importAction } from './import.js';
19
+ import { pruneText } from '../indexing/prune-run.js';
20
+ import { importAction, jungleAction, jungleText } from './import.js';
21
+ import { digestText, reviewFromSpec } from './digest.js';
22
+ import { connectEndpoint } from '../digest/endpoint.js';
23
+ import { createDigest } from '../digest/pipeline.js';
24
+ import { createQueryEmbedder, createTextEmbedder } from '../search/query.js';
25
+ import { runtimeGcAction, runtimeGcText, setupRuntimeGc } from './runtimes.js';
21
26
  import { drainLibrary } from '../worker/basic.js';
27
+ import { minAge, publishSite } from '../publish/index.js';
22
28
  import { failure } from '../acquisition/errors.js';
23
29
  const help = `sponge — a local article library
24
30
 
@@ -40,19 +46,34 @@ const help = `sponge — a local article library
40
46
  index status GENERATION Inspect generation coverage
41
47
  index run Drain queued embedding jobs
42
48
  index rollback GENERATION Reactivate a compatible complete generation
49
+ index prune [--keep 1] [--dry-run] [--vacuum] Delete vectors of retired generations
43
50
  doctor Check library and runtime states
44
51
  export ITEM --output DIR Export Markdown and local assets
45
52
  backup --output FILE Save a verified offline backup
46
53
  restore FILE --into NEW_DIR Restore into a new private library
47
54
  gc --dry-run Write a report of unreferenced blobs
48
55
  gc --apply REPORT Recheck and remove reported blobs
56
+ gc --runtimes [--dry-run] Remove superseded runtimes, models and worker journals
49
57
  worker install [--language en] Enable the optional background worker
50
58
  worker status Inspect the background worker
51
59
  worker uninstall Stop it and preserve the library
52
60
  import MANIFEST [--apply] Preview or import existing Wordcell captures
61
+ import jungle DIR [--apply] Preview or import Jungle Reading notes, their captures and published state
62
+ visibility ITEM public|private|default Override or restore the default
63
+ highlight ITEM TEXT [--note BODY] [--occurrence N] Anchor a verbatim quote
64
+ comment ITEM BODY [--on ANNOTATION] Comment on an item or reply
65
+ publish --dry-run [--min-age-days N] Show the public reading-list entries
66
+ publish --output DIR [--title T] [--base-url https://...] [--min-age-days N]
67
+ Write a signed static feed site with <sponge-list>
68
+ digest ITEM --endpoint http://127.0.0.1:PORT --model NAME [--check-support]
69
+ Draft a digest with a local model server you started
70
+ digest ITEM Show the current digest and its review state
71
+ review DIGEST --accept SPEC --reject SPEC Review fields: all, gist, idea:N, quote:N.M
53
72
 
54
73
  Global flags: --library /absolute/path --json
74
+ Items are public by default only when captured from a public source.
55
75
  Use -- before an option-like search query. Capture setup is explicit.
76
+ Digest endpoints must be literal loopback HTTP (127.0.0.1 or [::1]); Sponge never starts a model server.
56
77
  Media: --language en enables local English ASR; --force-local bypasses captions.
57
78
  These flags apply to --wait, run --drain, or worker serve.
58
79
  worker install --language en saves the English ASR choice for background work.
@@ -102,6 +123,11 @@ export async function main(argv, version) {
102
123
  emit(await workerAction(store, args.positional[0], version, undefined, { language: args.flags.language }));
103
124
  return 0;
104
125
  }
126
+ if (args.command === 'gc' && args.flags.runtimes) {
127
+ const report = await runtimeGcAction(store, version, args.flags['dry-run'] === true);
128
+ emit(report, runtimeGcText(report));
129
+ return 0;
130
+ }
105
131
  if (args.command === 'export' || args.command === 'backup' || args.command === 'gc') {
106
132
  emit(await portabilityAction(store, args.command, { itemId: args.positional[0], output: args.flags.output, dryRun: args.flags['dry-run'] === true, report: args.flags.apply }));
107
133
  return 0;
@@ -119,14 +145,16 @@ export async function main(argv, version) {
119
145
  process.stderr.write(nativeDownloadAuthGuidance + '\n');
120
146
  throw error;
121
147
  }
122
- emit({ [kind]: 'ready' });
148
+ const gc = await setupRuntimeGc(store, version);
149
+ emit({ [kind]: 'ready', gc });
123
150
  return 0;
124
151
  }
125
152
  if (args.command === 'setup') {
126
153
  const plan = captureSetupPlan(defaultCapturePins);
127
154
  process.stderr.write(`Capture setup: ${plan.downloadBytes} pinned artifact bytes, plus frozen dependency downloads. Temporary installation limit: ${plan.stagingBudgetBytes} bytes; retained runtime limit: ${plan.installationBudgetBytes} bytes; dependency installation deadline: ${plan.installationDeadlineMs / 60000} minutes. ${plan.requiredFreeBytes} free bytes required.\n`);
128
155
  await provisionCapture({ runtimeRoot: join(store.root, 'runtime', 'capture'), pins: defaultCapturePins, signal: controller.signal });
129
- emit({ capture: 'ready' }, 'Capture ready');
156
+ const gc = await setupRuntimeGc(store, version);
157
+ emit({ capture: 'ready', gc }, 'skipped' in gc ? `Capture ready\nRuntime GC skipped: ${gc.skipped}` : `Capture ready\n${runtimeGcText(gc)}`);
130
158
  return 0;
131
159
  }
132
160
  if (args.command === 'list') {
@@ -138,7 +166,7 @@ export async function main(argv, version) {
138
166
  const item = store.read(args.positional[0]);
139
167
  if (!item)
140
168
  throw new StoreError('unavailable');
141
- emit({ item }, item.markdown);
169
+ emit({ item, identity: store.identity(item.itemId), annotations: store.listAnnotations(item.itemId), digest: store.currentDigest(item.itemId) ?? null, editorial: store.currentEditorial(item.itemId) ?? null }, item.markdown);
142
170
  return item.status === 'partial' ? 3 : 0;
143
171
  }
144
172
  if (args.command === 'asset') {
@@ -157,8 +185,8 @@ export async function main(argv, version) {
157
185
  }
158
186
  if (args.command === 'index') {
159
187
  const action = args.positional[0], runtime = ['create', 'run'].includes(action) ? await inspectNative('embedding', nativeRoot, version) : undefined;
160
- const result = await indexAction(store, action, args.positional[1], { runtime, profile: args.flags.profile, signal: controller.signal });
161
- emit(result);
188
+ const result = await indexAction(store, action, args.positional[1], { runtime, profile: args.flags.profile, signal: controller.signal, ...(action === 'prune' ? { keep: args.flags.keep === undefined ? undefined : Number(args.flags.keep), dryRun: args.flags['dry-run'] === true, vacuum: args.flags.vacuum === true } : {}) });
189
+ emit(result, result.prune ? pruneText(result.prune) : undefined);
162
190
  const failed = result.results?.find(r => !r.ok);
163
191
  return failed ? exitFor(failed.code) : 0;
164
192
  }
@@ -177,6 +205,55 @@ export async function main(argv, version) {
177
205
  emit({ items: result.hits, warnings: result.warnings, generation: result.generation }, [...result.hits.map(item => `${item.itemId}\t${item.title}`), ...result.warnings.map(warning => `Warning: ${warning}`)].join('\n'));
178
206
  return controller.signal.aborted ? 130 : 0;
179
207
  }
208
+ if (args.command === 'visibility') {
209
+ const value = args.positional[1] === 'default' ? null : args.positional[1];
210
+ const state = store.setVisibility(args.positional[0], value);
211
+ emit({ itemId: args.positional[0], visibility: state }, `${state.effective}${state.override ? ' (set)' : ' (default)'}`);
212
+ return 0;
213
+ }
214
+ if (args.command === 'highlight') {
215
+ const annotation = store.addHighlight({ itemId: args.positional[0], quote: args.positional[1], ...(typeof args.flags.note === 'string' ? { note: args.flags.note } : {}), ...(args.flags.occurrence !== undefined ? { occurrence: Number(args.flags.occurrence) } : {}) });
216
+ emit({ annotation }, annotation.id);
217
+ return 0;
218
+ }
219
+ if (args.command === 'comment') {
220
+ const annotation = store.addComment({ itemId: args.positional[0], body: args.positional[1], ...(typeof args.flags.on === 'string' ? { parentId: args.flags.on } : {}) });
221
+ emit({ annotation }, annotation.id);
222
+ return 0;
223
+ }
224
+ if (args.command === 'publish') {
225
+ const minAgeDays = Number(args.flags['min-age-days'] ?? 0);
226
+ if (typeof args.flags.output === 'string') {
227
+ const result = publishSite(store, { output: args.flags.output, minAgeDays, ...(typeof args.flags.title === 'string' ? { title: args.flags.title } : {}), ...(typeof args.flags['base-url'] === 'string' ? { baseUrl: args.flags['base-url'] } : {}) });
228
+ emit(result, `Published ${result.entries} entr${result.entries === 1 ? 'y' : 'ies'} to ${result.output}`);
229
+ return 0;
230
+ }
231
+ const all = store.publicProjection(), projection = { ...all, entries: minAge(all.entries, minAgeDays) };
232
+ emit({ dryRun: true, ...projection, ...(minAgeDays ? { held: all.entries.length - projection.entries.length } : {}) }, [...projection.entries.map(entry => `${entry.uri}\t${entry.title}`), ...(projection.skipped.length ? [`Skipped ${projection.skipped.length} public item(s) without a public link`] : [])].join('\n') || 'Nothing public');
233
+ return 0;
234
+ }
235
+ if (args.command === 'digest') {
236
+ if (!args.flags.endpoint) {
237
+ const digest = store.currentDigest(args.positional[0]);
238
+ if (!digest)
239
+ throw new StoreError('unavailable');
240
+ emit({ digest }, digestText(digest));
241
+ return 0;
242
+ }
243
+ const model = await connectEndpoint({ endpoint: String(args.flags.endpoint), model: String(args.flags.model), signal: controller.signal });
244
+ const embed = args.flags['check-support'] ? createTextEmbedder(store, await inspectNative('embedding', nativeRoot, version)) : undefined;
245
+ const result = await createDigest(store, args.positional[0], model, { signal: controller.signal, ...(embed ? { embed } : {}) });
246
+ emit({ digest: result.derivation, duplicate: result.duplicate, ...(result.trace ? { trace: result.trace } : {}) }, digestText(result.derivation));
247
+ return 0;
248
+ }
249
+ if (args.command === 'review') {
250
+ const prior = store.readDigest(args.positional[0]);
251
+ if (!prior)
252
+ throw new StoreError('unavailable');
253
+ const digest = store.reviewDigest(prior.id, reviewFromSpec(prior, args.flags.accept, args.flags.reject));
254
+ emit({ digest }, digestText(digest));
255
+ return 0;
256
+ }
180
257
  if (args.command === 'delete') {
181
258
  store.delete(args.positional[0]);
182
259
  emit({ itemId: args.positional[0], deleted: true }, 'Deleted');
@@ -270,6 +347,13 @@ export async function main(argv, version) {
270
347
  throw Object.assign(Error('repair-auth'), { code: 'repair-auth' }); return connection.capture; } }, version));
271
348
  return 0;
272
349
  }
350
+ if (args.command === 'import' && args.positional[0] === 'jungle') {
351
+ // A dry run reads Jungle and the library only; applying reverifies every capture with the released verifier.
352
+ const dryRun = args.flags.apply !== true, runtime = dryRun ? undefined : await inspectCaptureRuntime(runtimeRoot, defaultCapturePins).catch(error => { const code = errorCode(error, 'runtime-missing'); throw Object.assign(Error(code), { code }); });
353
+ const report = await jungleAction(store, args.positional[1], { dryRun, runtime, limit: Number(args.flags.limit ?? 1000), offset: Number(args.flags.offset ?? 0), signal: controller.signal });
354
+ emit({ report }, jungleText(report));
355
+ return controller.signal.aborted ? 130 : report.counts.skipped['capture-failed'] || report.counts.skipped['capture-unsupported'] ? 3 : 0;
356
+ }
273
357
  let runtime;
274
358
  try {
275
359
  runtime = await inspectCaptureRuntime(runtimeRoot, defaultCapturePins);
@@ -321,7 +405,9 @@ export async function main(argv, version) {
321
405
  return results.some(result => result.ok && result.status === 'partial') ? 3 : 0;
322
406
  }
323
407
  catch (error) {
324
- const value = error?.code, code = value && codes.has(value) ? value : 'unclassified';
408
+ const value = error?.code, code = value && codes.has(value) ? value : 'unclassified', issues = error?.issues;
409
+ if (Array.isArray(issues))
410
+ process.stderr.write(JSON.stringify({ issues }) + '\n');
325
411
  process.stderr.write(JSON.stringify(failure(requestId, code)) + '\n');
326
412
  return exitFor(code);
327
413
  }
@@ -0,0 +1,10 @@
1
+ import type { LibraryStore } from '../store/index.js';
2
+ import { type RuntimeGcOptions, type RuntimeGcReport } from '../worker/runtime-gc.js';
3
+ /** References of this installed Sponge: its capture pin and its catalog's model packages. */
4
+ export declare function installedReferences(version: string): Promise<Omit<RuntimeGcOptions, 'dryRun'>>;
5
+ export declare function runtimeGcAction(store: LibraryStore, version: string, dryRun: boolean): Promise<RuntimeGcReport>;
6
+ export declare function runtimeGcText(report: RuntimeGcReport): string;
7
+ /** Setup collection follows a verified install and never fails that install. */
8
+ export declare function setupRuntimeGc(store: LibraryStore, version: string): Promise<RuntimeGcReport | {
9
+ skipped: string;
10
+ }>;
@@ -0,0 +1,32 @@
1
+ import { defaultCapturePins } from '../acquisition/index.js';
2
+ import { loadNativeCatalog, nativeModelPackage } from '../models/catalog.js';
3
+ import { modelSetupPlan } from '../models/artifacts.js';
4
+ import { collectRuntimes } from '../worker/runtime-gc.js';
5
+ /** References of this installed Sponge: its capture pin and its catalog's model packages. */
6
+ export async function installedReferences(version) {
7
+ let models = null;
8
+ try {
9
+ const catalog = await loadNativeCatalog(version);
10
+ models = { fingerprints: ['asr', 'embedding'].map(kind => modelSetupPlan(nativeModelPackage(catalog, kind)).fingerprint) };
11
+ }
12
+ catch { /* Without the catalog no model directory is provably superseded. */ }
13
+ return { captureIds: [defaultCapturePins.id], models };
14
+ }
15
+ export async function runtimeGcAction(store, version, dryRun) { return collectRuntimes(store, { ...await installedReferences(version), dryRun }); }
16
+ export function runtimeGcText(report) {
17
+ const lines = report.removed.map(e => `${report.dryRun ? 'would remove' : 'removed'}\t${e.kind}\t${e.name}\t${e.bytes}`);
18
+ for (const e of report.kept)
19
+ if (e.reason !== 'referenced')
20
+ lines.push(`kept\t${e.kind}\t${e.name}\t${e.reason}`);
21
+ lines.push(`${report.dryRun ? 'Would reclaim' : 'Reclaimed'} ${report.bytes} bytes`);
22
+ return lines.join('\n');
23
+ }
24
+ /** Setup collection follows a verified install and never fails that install. */
25
+ export async function setupRuntimeGc(store, version) {
26
+ try {
27
+ return await runtimeGcAction(store, version, false);
28
+ }
29
+ catch (error) {
30
+ return { skipped: String(error?.code ?? 'unclassified') };
31
+ }
32
+ }
@@ -0,0 +1,54 @@
1
+ import type { DatabaseSync } from 'node:sqlite';
2
+ import { type JungleEditorial } from '../importers/jungle-digest.js';
3
+ import { type ReviewStatus } from '../store/changes.js';
4
+ /**
5
+ * An editorial derivation keeps a reviewed source note whole (today: one Jungle Reading note of any form), without
6
+ * Digest's limits. It is immutable data addressed like a digest; its review is one logged path, `review.note`.
7
+ */
8
+ export interface Editorial {
9
+ id: string;
10
+ itemId: string;
11
+ revisionId: string;
12
+ kind: 'editorial';
13
+ producer: unknown;
14
+ inputHash: string;
15
+ output: JungleEditorial;
16
+ review: {
17
+ note?: ReviewStatus;
18
+ };
19
+ createdAt: string;
20
+ }
21
+ /** Bound on one editorial object: Jungle's largest notes (full transcripts) are a few hundred KiB. */
22
+ export declare const editorialBytes: number;
23
+ /** Fixed key order, so the same note is the same object on every device. */
24
+ export declare function canonicalEditorial(e: JungleEditorial): string;
25
+ /** The input hash is the note's own Markdown hash: an edited note is a new object, an unchanged one a duplicate. */
26
+ export declare function putEditorial(db: DatabaseSync, input: {
27
+ itemId: string;
28
+ revisionId: string;
29
+ producer: Record<string, unknown>;
30
+ output: JungleEditorial;
31
+ }): {
32
+ id: string;
33
+ duplicate: boolean;
34
+ };
35
+ export declare function readEditorial(db: DatabaseSync, id: string): Editorial | undefined;
36
+ /** The newest editorial derivation of the item's current revision. */
37
+ export declare function currentEditorial(db: DatabaseSync, itemId: string): Editorial | undefined;
38
+ export declare function listEditorials(db: DatabaseSync, itemId: string): Editorial[];
39
+ /** One logged change, and only when the status differs. */
40
+ export declare function reviewEditorial(db: DatabaseSync, id: string, status: ReviewStatus | null): Editorial;
41
+ /**
42
+ * The public subset of an accepted editorial note, in the public digest shape and within Digest v2's limits: a lead only
43
+ * when the note's first sentence fits the lead limit (never cut), claims with detail, and quotes that occur verbatim in the
44
+ * capture with 4-25 words, assigned to ideas as the importer assigns them (at most 2 per idea and 6 in all), within the
45
+ * per-entry budget (150 words, and the 10% source share with its 25-word floor).
46
+ */
47
+ export declare function publicEditorial(db: DatabaseSync, e: Editorial): {
48
+ lead?: string;
49
+ ideas: {
50
+ claim: string;
51
+ detail: string;
52
+ quotes: string[];
53
+ }[];
54
+ } | undefined;
@@ -0,0 +1,87 @@
1
+ import { locateAll, words } from '../annotations/quote.js';
2
+ import { markdownFor } from '../annotations/store.js';
3
+ import { assignQuotes, firstSentence } from '../importers/jungle-digest.js';
4
+ import { mutate } from '../store/changes.js';
5
+ import { StoreError } from '../store/types.js';
6
+ import { digest } from '../store/files.js';
7
+ import { derivationId } from './store.js';
8
+ import { digestLimits as L } from './schema.js';
9
+ const now = () => new Date().toISOString();
10
+ const fail = (code = 'invalid-input') => { throw new StoreError(code); };
11
+ /** Bound on one editorial object: Jungle's largest notes (full transcripts) are a few hundred KiB. */
12
+ export const editorialBytes = 1024 ** 2;
13
+ const str = (v, max = 65536) => typeof v === 'string' && Buffer.byteLength(v) <= max;
14
+ /** Fixed key order, so the same note is the same object on every device. */
15
+ export function canonicalEditorial(e) {
16
+ return JSON.stringify({ schemaVersion: e.schemaVersion, source: e.source, form: e.form, slug: e.slug, status: e.status, title: e.title, sourceUrl: e.sourceUrl, capture: e.capture, frontmatter: e.frontmatter, ...(e.gist !== undefined ? { gist: e.gist } : {}), ...(e.ideas ? { ideas: e.ideas.map(i => ({ claim: i.claim, detail: i.detail })) } : {}), ...(e.quotes ? { quotes: e.quotes.map(q => ({ text: q.text, attribution: q.attribution, speaker: q.speaker })) } : {}), markdownSha256: e.markdownSha256, markdown: e.markdown });
17
+ }
18
+ /** The input hash is the note's own Markdown hash: an edited note is a new object, an unchanged one a duplicate. */
19
+ export function putEditorial(db, input) {
20
+ const e = input.output, producer = JSON.stringify(input.producer);
21
+ if (!producer || Buffer.byteLength(producer) > 4096)
22
+ fail();
23
+ if (db.prepare('SELECT item_id FROM revisions WHERE id=?').get(input.revisionId)?.item_id !== input.itemId)
24
+ fail('unavailable');
25
+ if (e?.schemaVersion !== 1 || e.source !== 'jungle-reading' || ![e.form, e.slug, e.status, e.title, e.sourceUrl, e.capture].every(v => str(v, 16384)) || !str(e.markdown, 4 * 1024 ** 2) || digest(e.markdown) !== e.markdownSha256)
26
+ fail();
27
+ const output = canonicalEditorial(e);
28
+ if (Buffer.byteLength(output) > editorialBytes)
29
+ fail();
30
+ const id = derivationId(input.revisionId, 'editorial', e.markdownSha256, output);
31
+ if (db.prepare('SELECT 1 FROM derivations WHERE id=?').get(id))
32
+ return { id, duplicate: true };
33
+ db.prepare("INSERT INTO derivations(id,item_id,revision_id,kind,schema_version,producer_json,input_hash,output_json,created_at) VALUES(?,?,?,'editorial',1,?,?,?,?)").run(id, input.itemId, input.revisionId, producer, e.markdownSha256, output, now());
34
+ return { id, duplicate: false };
35
+ }
36
+ function editorialRow(r) { return { id: String(r.id), itemId: String(r.item_id), revisionId: String(r.revision_id), kind: 'editorial', producer: JSON.parse(String(r.producer_json)), inputHash: String(r.input_hash), output: JSON.parse(String(r.output_json)), review: JSON.parse(String(r.review_json)), createdAt: String(r.created_at) }; }
37
+ export function readEditorial(db, id) { const r = db.prepare("SELECT * FROM derivations WHERE id=? AND kind='editorial'").get(id); return r ? editorialRow(r) : undefined; }
38
+ /** The newest editorial derivation of the item's current revision. */
39
+ export function currentEditorial(db, itemId) {
40
+ const r = db.prepare("SELECT d.* FROM derivations d JOIN items i ON i.id=d.item_id AND i.current_revision=d.revision_id WHERE d.item_id=? AND d.kind='editorial' ORDER BY d.created_at DESC,d.rowid DESC LIMIT 1").get(itemId);
41
+ return r ? editorialRow(r) : undefined;
42
+ }
43
+ export function listEditorials(db, itemId) { return db.prepare("SELECT * FROM derivations WHERE item_id=? AND kind='editorial' ORDER BY created_at,rowid").all(itemId).map(editorialRow); }
44
+ /** One logged change, and only when the status differs. */
45
+ export function reviewEditorial(db, id, status) {
46
+ const d = readEditorial(db, id) ?? fail('unavailable');
47
+ if (status !== null && status !== 'accepted' && status !== 'rejected')
48
+ fail();
49
+ if ((d.review.note ?? null) !== status)
50
+ mutate(db, 'derivation', id, 'review.note', status);
51
+ return readEditorial(db, id);
52
+ }
53
+ /**
54
+ * The public subset of an accepted editorial note, in the public digest shape and within Digest v2's limits: a lead only
55
+ * when the note's first sentence fits the lead limit (never cut), claims with detail, and quotes that occur verbatim in the
56
+ * capture with 4-25 words, assigned to ideas as the importer assigns them (at most 2 per idea and 6 in all), within the
57
+ * per-entry budget (150 words, and the 10% source share with its 25-word floor).
58
+ */
59
+ export function publicEditorial(db, e) {
60
+ if (e.review.note !== 'accepted')
61
+ return undefined;
62
+ const o = e.output, [first] = firstSentence(o.gist ?? ''), lead = first && first.length <= L.leadChars ? first : undefined;
63
+ const ideas = (o.ideas ?? []).map(i => ({ claim: i.claim, detail: i.detail, quotes: [] }));
64
+ if (ideas.length && o.quotes?.length) {
65
+ const source = markdownFor(db, e.revisionId), budget = Math.min(L.totalQuoteWords, Math.max(L.quoteWords, Math.floor(words(source) * L.sourceQuoteShare))), taken = [], texts = [];
66
+ for (const q of o.quotes) {
67
+ const match = locateAll(source, q.text).find(m => !taken.some(t => m.start < t.end && t.start < m.end));
68
+ if (!match)
69
+ continue;
70
+ const text = source.slice(match.start, match.end), n = words(text);
71
+ if (n >= L.minQuoteWords && n <= L.quoteWords) {
72
+ taken.push(match);
73
+ texts.push(text);
74
+ }
75
+ }
76
+ let used = 0, count = 0;
77
+ assignQuotes(ideas, texts.map(text => ({ text }))).forEach((idea, k) => {
78
+ const n = words(texts[k]);
79
+ if (idea === null || count >= L.maxQuotes || used + n > budget)
80
+ return;
81
+ used += n;
82
+ count++;
83
+ ideas[idea].quotes.push(texts[k]);
84
+ });
85
+ }
86
+ return lead || ideas.length ? { ...(lead ? { lead } : {}), ideas } : undefined;
87
+ }
@@ -0,0 +1,20 @@
1
+ import { type DigestModel } from './pipeline.js';
2
+ /**
3
+ * A user-started local OpenAI-compatible server (llama.cpp `llama-server`, LM Studio, Ollama). Captured text must not
4
+ * leave the machine, so only literal loopback HTTP origins are accepted, redirects are refused, and Sponge never starts a server.
5
+ */
6
+ export declare function loopbackEndpoint(value: string): URL;
7
+ /** GGUF quantization from a model filename, e.g. `gemma-4-12b-it-Q4_K_M.gguf` → `Q4_K_M`. */
8
+ export declare function quantizationOf(path: string): string | undefined;
9
+ export interface EndpointOptions {
10
+ endpoint: string;
11
+ model: string;
12
+ signal?: AbortSignal;
13
+ fetch?: typeof fetch;
14
+ probeTimeoutMs?: number;
15
+ }
16
+ /**
17
+ * Confirm the server lists `model`, read what identity it exposes (llama.cpp `/props`, Ollama `/api/version`),
18
+ * and return a DigestModel that requests JSON-schema-constrained chat completions.
19
+ */
20
+ export declare function connectEndpoint(options: EndpointOptions): Promise<DigestModel>;
@@ -0,0 +1,103 @@
1
+ import { DigestError } from './pipeline.js';
2
+ /**
3
+ * A user-started local OpenAI-compatible server (llama.cpp `llama-server`, LM Studio, Ollama). Captured text must not
4
+ * leave the machine, so only literal loopback HTTP origins are accepted, redirects are refused, and Sponge never starts a server.
5
+ */
6
+ export function loopbackEndpoint(value) {
7
+ let url;
8
+ try {
9
+ url = new URL(value);
10
+ }
11
+ catch {
12
+ throw new DigestError('invalid-input');
13
+ }
14
+ // A hostname such as `localhost` resolves through configuration Sponge does not control; only literal addresses pass.
15
+ if (url.protocol !== 'http:' || !['127.0.0.1', '[::1]'].includes(url.hostname) || url.username || url.password || url.search || url.hash || !['', '/', '/v1', '/v1/'].includes(url.pathname))
16
+ throw new DigestError('invalid-input');
17
+ return new URL(url.origin);
18
+ }
19
+ async function readCapped(response, max) {
20
+ const reader = response.body?.getReader(), chunks = [];
21
+ if (!reader)
22
+ throw new DigestError('unavailable');
23
+ let size = 0;
24
+ for (;;) {
25
+ const { done, value } = await reader.read();
26
+ if (done)
27
+ break;
28
+ size += value.byteLength;
29
+ if (size > max) {
30
+ await reader.cancel().catch(() => undefined);
31
+ throw new DigestError('limit-exceeded');
32
+ }
33
+ chunks.push(value);
34
+ }
35
+ return Buffer.concat(chunks).toString('utf8');
36
+ }
37
+ const label = (v, max = 128) => typeof v === 'string' && v.trim() && v.length <= max && !/[\u0000-\u001f\u007f]/.test(v) ? v.trim() : undefined;
38
+ /** GGUF quantization from a model filename, e.g. `gemma-4-12b-it-Q4_K_M.gguf` → `Q4_K_M`. */
39
+ export function quantizationOf(path) {
40
+ return /(?:^|[-_.])((?:IQ|Q)\d(?:_[A-Z0-9]+)*|BF16|F16|F32)(?=\.gguf$|[-_.])/i.exec(path.split('/').pop() ?? '')?.[1]?.toUpperCase();
41
+ }
42
+ /**
43
+ * Confirm the server lists `model`, read what identity it exposes (llama.cpp `/props`, Ollama `/api/version`),
44
+ * and return a DigestModel that requests JSON-schema-constrained chat completions.
45
+ */
46
+ export async function connectEndpoint(options) {
47
+ const origin = loopbackEndpoint(options.endpoint), request = options.fetch ?? fetch, model = label(options.model, 256);
48
+ if (!model)
49
+ throw new DigestError('invalid-input');
50
+ const get = async (path) => {
51
+ const signal = AbortSignal.any([options.signal ?? new AbortController().signal, AbortSignal.timeout(options.probeTimeoutMs ?? 10000)]);
52
+ try {
53
+ const r = await request(new URL(path, origin), { signal, redirect: 'error', headers: { accept: 'application/json' } });
54
+ return r.ok ? JSON.parse(await readCapped(r, 1 << 20)) : undefined;
55
+ }
56
+ catch (error) {
57
+ if (options.signal?.aborted)
58
+ throw new DigestError('cancelled');
59
+ if (error instanceof DigestError)
60
+ throw error;
61
+ return undefined;
62
+ }
63
+ };
64
+ const models = await get('/v1/models');
65
+ if (!Array.isArray(models?.data))
66
+ throw new DigestError('unavailable');
67
+ if (!models.data.some(m => m?.id === model))
68
+ throw new DigestError('invalid-input');
69
+ const props = await get('/props'), build = label(props?.build_info);
70
+ let runtime = build ? `llama.cpp ${build}` : undefined;
71
+ if (!runtime) {
72
+ const ollama = label((await get('/api/version'))?.version);
73
+ runtime = ollama ? `ollama ${ollama}` : 'openai-compatible';
74
+ }
75
+ const identity = { model, quantization: (typeof props?.model_path === 'string' && quantizationOf(props.model_path)) || quantizationOf(model) || 'unknown', runtime };
76
+ return { identity, generate: (messages, schema, generate) => complete(origin, request, model, messages, schema, generate) };
77
+ }
78
+ async function complete(origin, request, model, messages, schema, o) {
79
+ const body = JSON.stringify({ model, messages, stream: false, temperature: o.temperature, seed: o.seed, max_tokens: o.maxTokens, response_format: { type: 'json_schema', json_schema: { name: 'sponge_digest', strict: true, schema } } });
80
+ const response = await request(new URL('/v1/chat/completions', origin), { method: 'POST', signal: o.signal, redirect: 'error', headers: { 'content-type': 'application/json', accept: 'application/json' }, body });
81
+ if (!response.ok) {
82
+ await response.body?.cancel().catch(() => undefined);
83
+ throw new DigestError('unavailable');
84
+ }
85
+ let parsed;
86
+ try {
87
+ parsed = JSON.parse(await readCapped(response, o.maxOutputBytes * 2 + 65536));
88
+ }
89
+ catch (error) {
90
+ if (error instanceof DigestError)
91
+ throw error;
92
+ throw new DigestError('unavailable');
93
+ }
94
+ const choice = parsed?.choices?.[0];
95
+ if (choice?.finish_reason === 'length')
96
+ throw new DigestError('limit-exceeded');
97
+ const content = choice?.message?.content;
98
+ if (typeof content !== 'string' || !content.trim())
99
+ throw new DigestError('unavailable');
100
+ if (Buffer.byteLength(content) > o.maxOutputBytes)
101
+ throw new DigestError('limit-exceeded');
102
+ return content;
103
+ }