@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
@@ -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,53 @@
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 v1'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: a lead only when the note's first
43
+ * sentence fits the v1 lead limit (never cut), claims with detail, and quotes that occur verbatim in the capture,
44
+ * each at most 25 words, within the v1 per-entry budget (150 words, and the 10% source share with its 25-word floor).
45
+ */
46
+ export declare function publicEditorial(db: DatabaseSync, e: Editorial): {
47
+ lead?: string;
48
+ ideas: {
49
+ claim: string;
50
+ detail: string;
51
+ quotes: string[];
52
+ }[];
53
+ } | undefined;
@@ -0,0 +1,81 @@
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: a lead only when the note's first
55
+ * sentence fits the v1 lead limit (never cut), claims with detail, and quotes that occur verbatim in the capture,
56
+ * each at most 25 words, within the v1 per-entry budget (150 words, and the 10% source share with its 25-word floor).
57
+ */
58
+ export function publicEditorial(db, e) {
59
+ if (e.review.note !== 'accepted')
60
+ return undefined;
61
+ const o = e.output, [first] = firstSentence(o.gist ?? ''), lead = first && first.length <= L.leadChars ? first : undefined;
62
+ const ideas = (o.ideas ?? []).map(i => ({ claim: i.claim, detail: i.detail, quotes: [] }));
63
+ if (ideas.length && o.quotes?.length) {
64
+ const source = markdownFor(db, e.revisionId), budget = Math.min(L.totalQuoteWords, Math.max(L.quoteWords, Math.floor(words(source) * L.sourceQuoteShare))), taken = [];
65
+ let used = 0;
66
+ assignQuotes(ideas, o.quotes).forEach((idea, k) => {
67
+ if (idea === null || ideas[idea].quotes.length >= L.maxQuotesPerIdea)
68
+ return;
69
+ const match = locateAll(source, o.quotes[k].text).find(m => !taken.some(t => m.start < t.end && t.start < m.end));
70
+ if (!match)
71
+ return;
72
+ const text = source.slice(match.start, match.end), n = words(text);
73
+ if (n < L.minQuoteWords || n > L.quoteWords || used + n > budget)
74
+ return;
75
+ used += n;
76
+ taken.push(match);
77
+ ideas[idea].quotes.push(text);
78
+ });
79
+ }
80
+ return lead || ideas.length ? { ...(lead ? { lead } : {}), ideas } : undefined;
81
+ }
@@ -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
+ }
@@ -0,0 +1,148 @@
1
+ import { forms, type DigestIssue, type DigestOptions, type DigestV1 } from './schema.js';
2
+ import type { LibraryStore } from '../store/index.js';
3
+ import type { Derivation } from '../store/foundation.js';
4
+ /**
5
+ * Model-agnostic Digest v1 drafting. The model sees numbered passages and cites a passage id plus a short exact
6
+ * span; code snaps, orders, prunes and checks. Nothing leaves this module except through the injected model.
7
+ */
8
+ export declare const PROMPT_VERSION = "sponge-digest-prompt-v1";
9
+ export type ChatMessage = {
10
+ role: 'system' | 'user' | 'assistant';
11
+ content: string;
12
+ };
13
+ export type JsonSchema = Record<string, unknown>;
14
+ /** Stable identity recorded in the producer and the input hash. Unknown parts are the literal 'unknown'. */
15
+ export interface ModelIdentity {
16
+ model: string;
17
+ quantization: string;
18
+ runtime: string;
19
+ }
20
+ export interface GenerateOptions {
21
+ signal: AbortSignal;
22
+ maxOutputBytes: number;
23
+ maxTokens: number;
24
+ temperature: number;
25
+ seed: number;
26
+ }
27
+ export interface DigestModel {
28
+ identity: ModelIdentity;
29
+ generate(messages: ChatMessage[], schema: JsonSchema, options: GenerateOptions): Promise<string>;
30
+ }
31
+ /** Output-shaping parameters are hashed; time and byte bounds are not, because they only decide whether a run finishes. */
32
+ export interface DigestParams {
33
+ temperature: number;
34
+ seed: number;
35
+ maxTokens: number;
36
+ contextChars: number;
37
+ maxSections: number;
38
+ timeoutMs: number;
39
+ deadlineMs: number;
40
+ maxOutputBytes: number;
41
+ }
42
+ /** 24k characters is roughly 6k tokens of passages, leaving room for instructions and output in an 8k context on an 8 GB Mac. */
43
+ export declare const defaultDigestParams: DigestParams;
44
+ export type DigestErrorCode = 'invalid-input' | 'unavailable' | 'cancelled' | 'limit-exceeded';
45
+ export declare class DigestError extends Error {
46
+ readonly code: DigestErrorCode;
47
+ readonly issues?: DigestIssue[] | undefined;
48
+ constructor(code: DigestErrorCode, issues?: DigestIssue[] | undefined);
49
+ }
50
+ export type Form = typeof forms[number];
51
+ /** Quotes under this many words carry no evidence; the checker is gaining the same floor. */
52
+ export declare const minQuoteWords = 4;
53
+ export interface Passage {
54
+ id: number;
55
+ start: number;
56
+ end: number;
57
+ heading: string;
58
+ text: string;
59
+ }
60
+ export interface Highlight {
61
+ text: string;
62
+ start?: number;
63
+ }
64
+ /** Sorted-key JSON; the only serialization that enters input hashes. */
65
+ export declare function canonicalJson(value: unknown): string;
66
+ /** Paragraph-level passages with raw offsets. Headings label passages; code fences and image-only blocks are not quotable. */
67
+ export declare function passages(markdown: string, maxChars?: number): Passage[];
68
+ /** Pack passages into sections of at most `budget` display characters, preferring heading boundaries past half a budget. */
69
+ export declare function sectionsOf(list: Passage[], budget: number): Passage[][];
70
+ export declare const quoteBudget: (markdown: string) => number;
71
+ /** JSON Schema for the model's draft. Tags become an enum when a vocabulary is given. */
72
+ export declare function draftSchema(passageCount: number, vocabulary?: readonly string[]): JsonSchema;
73
+ export declare function notesSchema(first: number, last: number): JsonSchema;
74
+ interface Draft {
75
+ digest: DigestV1;
76
+ cites: (number | undefined)[][];
77
+ }
78
+ export interface QuoteStats {
79
+ exact: number;
80
+ folded: number;
81
+ snapped: number;
82
+ missing: number;
83
+ short: number;
84
+ }
85
+ export type QuoteMatch = 'exact' | 'folded' | 'snapped' | 'missing';
86
+ /**
87
+ * Make a quote exactly verbatim when that is unambiguous: a unique folded or Markdown-insensitive match, inside
88
+ * `within` first, becomes the raw source span. Ambiguous folded matches keep their text for the checker; absent ones are 'missing'.
89
+ */
90
+ export declare function snapQuote(source: string, text: string, within?: {
91
+ start: number;
92
+ end: number;
93
+ }): {
94
+ text: string;
95
+ match: QuoteMatch;
96
+ };
97
+ /** Snap every quote in place and drop quotes under `minQuoteWords`. */
98
+ export declare function snapQuotes(draft: Draft, source: string, list: Passage[]): QuoteStats;
99
+ export interface DraftInput {
100
+ revisionId: string;
101
+ markdown: string;
102
+ title?: string;
103
+ form: Form;
104
+ model: DigestModel;
105
+ highlights?: readonly Highlight[];
106
+ vocabulary?: readonly string[];
107
+ params?: Partial<DigestParams>;
108
+ signal?: AbortSignal;
109
+ /** Unit-normalized embeddings for short texts; when given, quotes must support their claims by cosine. */
110
+ embed?: (texts: string[], signal: AbortSignal) => Promise<number[][]>;
111
+ supportThreshold?: number;
112
+ }
113
+ export interface DraftTrace {
114
+ strategy: 'single' | 'sections';
115
+ sections: number;
116
+ calls: number;
117
+ repaired: boolean;
118
+ attempts: {
119
+ issues: DigestIssue[];
120
+ quotes?: QuoteStats;
121
+ }[];
122
+ dropped: {
123
+ quotes: number;
124
+ ideas: number;
125
+ tags: number;
126
+ };
127
+ }
128
+ export interface DraftResult {
129
+ output: DigestV1;
130
+ inputHash: string;
131
+ producer: Record<string, unknown>;
132
+ options: DigestOptions;
133
+ trace: DraftTrace;
134
+ }
135
+ /** sha256 over canonical JSON of the revision, its Markdown, the prompt version, the model identity and every output-shaping input. */
136
+ export declare function digestInputHash(input: Pick<DraftInput, 'revisionId' | 'markdown' | 'form' | 'highlights' | 'vocabulary' | 'params' | 'supportThreshold' | 'embed'> & {
137
+ model: ModelIdentity;
138
+ }): string;
139
+ /** Unqualified on real claims: a deliberately low bar that only catches quotes unrelated to their claim. */
140
+ export declare const defaultSupportThreshold = 0.35;
141
+ export declare function draftDigest(input: DraftInput): Promise<DraftResult>;
142
+ /** Draft and store a digest for the item's current revision. A matching input hash returns the stored draft without calling the model. */
143
+ export declare function createDigest(store: LibraryStore, itemId: string, model: DigestModel, options?: Omit<DraftInput, 'revisionId' | 'markdown' | 'title' | 'form' | 'model' | 'highlights'>): Promise<{
144
+ derivation: Derivation;
145
+ duplicate: boolean;
146
+ trace?: DraftTrace;
147
+ }>;
148
+ export {};